At 2:17 AM, PagerDuty went off again — users reported that after refreshing the page, all their preferences reverted to defaults, and the custom dashboard layout was lost. This was our third midnight incident this month caused by front-end storage issues. Before each release, our "regression testing" consisted of product and QA manually clicking through a dozen scenarios. Everyone agreed it looked fine, but it exploded in production. Staring at the stream of localStorage write errors in monitoring, I suddenly realized: we didn’t need more cautious manual verification — we needed a set of end-to-end storage tests that run automatically on every PR.
What exactly was broken
Our product is a SaaS admin panel heavily reliant on browser storage: custom sidebar widths, table column orders, dark mode toggles, even multi-step form drafts are all persisted on the client side. The tech stack looks simple — localStorage for lightweight key-value pairs, IndexedDB for drafts and large structured data, plus an in-memory Map as a caching layer. What’s not simple is the storage strategy tangled with business logic:
- How to degrade graciously when quota is exceeded (
localStorage quota exceeded) - Which data to clear on logout / login and which to keep across sessions
- Synchronizing writes from multiple tabs via the
storageevent - Compatibility logic for migrating old data during
IndexedDBversion upgrades
The regression difficulty comes from these interlocking rules — change one and you might break another. We tried writing manual regression checklists, but a full pass covered over 30 scenarios, took more than 25 minutes, and relied heavily on the tester’s memory and experience. The slightest oversight, and a time bomb like “draft overwritten after switching tabs” would slip into production. It’s not about effort; human beings simply aren’t built for repetitive precision work.
Why Playwright + GitHub Actions
For automated regression, you basically have two paths: unit tests that mock browser storage APIs, or true browser end-to-end tests. We immediately discarded the first option — edge cases like localStorage quota exceptions and IndexedDB transaction behavior when the browser process exits cannot be mocked faithfully. What can be mocked is too far from reality. We had to use a real browser.
The tooling decision came down to two words: No Cypress. No Selenium.
Cypress’s storage APIs are decent, but multi-tab and multi-window support has always been painful — and we specifically needed to test cross-tab synchronization via the storage event. Selenium is too heavy, script maintenance costs are high, and WebDriver bindings are slow enough to make you want to clock out. Playwright solved almost all our pain points: native multi-browser, multi-tab support, isolated context, built-in page.evaluate() to directly poke localStorage/IndexedDB, and the ability to capture and restore entire storage snapshots via browserContext.storageState(). Paired with GitHub Actions, every push to main triggers a headless Chromium run of the whole storage regression suite — making it impossible to merge without passing every scenario.
Core implementation: three must-automate patterns
Out of dozens of business scenarios, we extracted the three deadliest patterns: cross-tab synchronization, IndexedDB version migration, and storage quota degradation. Below are working Playwright tests — business details stripped away but the full storage interaction skeletons left intact.
Scenario 1: Cross-tab storage event synchronization
This test verifies that after user A changes the theme settings in one tab, tab B instantly receives the update via window.addEventListener('storage', ...) without needing a full refresh.
// storage-sync.spec.ts
import { test, expect } from '@playwright/test';
test('跨标签页同步 localStorage 偏好设置', async ({ browser }) => {
// 创建两个隔离的浏览器上下文,模拟同一用户的两个标签页
const contextA = await browser.newContext();
const contextB = await browser.newContext();
const pageA = await contextA.newPage();
const pageB = await contextB.newPage();
// 让两个页面都监听 storage 事件并写入 window 标记
await pageA.goto('http://localhost:3000');
await pageA.evaluate(() => {
window.addEventListener('storage', (e) => {
if (e.key === 'theme') (window as any).__receivedTheme = e.newValue;
});
});
await pageB.goto('http://localhost:3000');
await pageB.evaluate(() => {
window.addEventListener('storage', (e) => {
if (e.key === 'theme') (window as any).__receivedTheme = e.newValue;
});
});
// 在 A 标签页写入新主题值——注意这里用 evaluate 模拟真实用户操作
await pageA.evaluate(() => localStorage.setItem('theme', 'dark'));
// 给事件传播留一点时间,然后断言 B 标签页收到同步
await pageB.waitForTimeout(300);
const syncedValue = await pageB.evaluate(() => (window as any).__receivedTheme);
expect(syncedValue).toBe('dark');
await contextA.close();
await contextB.close();
});
Scenario 2: Data migration during IndexedDB version upgrades
Our drafts system relies on the idb library. Every time the database version number bumps, the migration logic inside onupgradeneeded tends to get broken. This test opens IndexedDB directly inside the page, creates a store, inserts old-version data, then reopens a database with a higher version — validating that all data survives the migration intact.
typescript
// indexeddb-migration.spec.ts
import { test, expect } from '@playwright/test';
test('IndexedDB 升级后旧数据不丢失'
Top comments (0)