
Run Playwright's WebKit project on a Figment Mac and confirm Safari Private Browsing drops localStorage, IndexedDB, cookies and sessionStorage when the context closes. A Figment M-series Mac from $95/month is the recommended target because Safari-fidelity codecs and layout match real user devices on Apple silicon; the $29 virtual Intel macOS plan (2 vCPU, 8 GiB RAM, 80 GiB disk) also runs Playwright's WebKit build and is useful for pure storage-logic checks where codec fidelity is not the point. Start a configuration at Figment Mac hosting.
The suite below is small on purpose: it writes known values to each store inside a fresh WebKit BrowserContext, confirms the writes land, closes the context, and then opens a new context to assert nothing leaked across the boundary.
WebKit documents Safari's Private Browsing as a per-tab ephemeral session: "Safari's Private Browsing Mode uses a new ephemeral session for each tab the user opens to isolate tabs from each other" (webkit.org/tracking-prevention). Cookies, localStorage, sessionStorage, IndexedDB and cache for that session go away when the tab closes.
Two consequences matter for tests:
localStorage key is wrong in Private Browsing.| Behaviour | WebKit Private Browsing | Chromium Incognito |
|---|---|---|
| Storage persists after tab/context close | No | No |
| Two private tabs share storage in one session | No | Yes |
localStorage / IndexedDB writes succeed during the session | Yes | Yes |
Playwright models each BrowserContext as an independent incognito-like profile with its own cookies, localStorage, IndexedDB, cache and service workers (playwright.dev BrowserContext), which is the right primitive for simulating a Private Browsing tab.
Pin the test to the webkit project so results reflect the same engine Safari ships. Helpers below write and read both stores from the page runtime.
// storage-isolation.spec.ts
import { test, expect, BrowserContext, Page } from '@playwright/test';
const TEST_ORIGIN = process.env.BASE_URL ?? 'http://localhost:3000';
const LS_KEY = 'privacyTest_ls';
const LS_VALUE = 'isolation-check-value';
const IDB_DB = 'privacyTestDB';
const IDB_STORE = 'entries';
const IDB_KEY = 'privacyTest_idb';
const IDB_VALUE = 'idb-isolation-check';
async function writeStorage(page: Page): Promise<void> {
await page.evaluate(
({ lsKey, lsVal, dbName, storeName, idbKey, idbVal }) => {
localStorage.setItem(lsKey, lsVal);
return new Promise<void>((resolve, reject) => {
const req = indexedDB.open(dbName, 1);
req.onupgradeneeded = () => {
req.result.createObjectStore(storeName);
};
req.onsuccess = () => {
const tx = req.result.transaction(storeName, 'readwrite');
tx.objectStore(storeName).put(idbVal, idbKey);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
};
req.onerror = () => reject(req.error);
});
},
{ lsKey: LS_KEY, lsVal: LS_VALUE,
dbName: IDB_DB, storeName: IDB_STORE,
idbKey: IDB_KEY, idbVal: IDB_VALUE }
);
}
async function readLocalStorage(page: Page, key: string): Promise<string | null> {
return page.evaluate((k) => localStorage.getItem(k), key);
}
async function readIndexedDB(page: Page): Promise<unknown> {
return page.evaluate(
({ dbName, storeName, idbKey }) =>
new Promise((resolve) => {
const req = indexedDB.open(dbName);
req.onsuccess = () => {
const db = req.result;
if (!db.objectStoreNames.contains(storeName)) {
resolve(null);
return;
}
const tx = db.transaction(storeName, 'readonly');
const getReq = tx.objectStore(storeName).get(idbKey);
getReq.onsuccess = () => resolve(getReq.result ?? null);
getReq.onerror = () => resolve(null);
};
req.onerror = () => resolve(null);
}),
{ dbName: IDB_DB, storeName: IDB_STORE, idbKey: IDB_KEY }
);
}
The suite opens a write context, confirms writes land in that same context, closes it, then opens a fresh context for each assertion. A non-null read from the fresh context would indicate a genuine cross-context leak.
test.describe('Safari Private Browsing storage isolation', () => {
let writeContext: BrowserContext;
test.beforeAll(async ({ browser }) => {
writeContext = await browser.newContext();
const page = await writeContext.newPage();
await page.goto(TEST_ORIGIN);
await writeStorage(page);
const lsValue = await readLocalStorage(page, LS_KEY);
const idbValue = await readIndexedDB(page);
expect(lsValue).toBe(LS_VALUE);
expect(idbValue).toBe(IDB_VALUE);
const state = await writeContext.storageState({ indexedDB: true });
await test.info().attach('storage-state-before-close.json', {
body: Buffer.from(JSON.stringify(state, null, 2)),
contentType: 'application/json',
});
await writeContext.close();
});
test('localStorage is empty after context disposal', async ({ browser }) => {
const freshContext = await browser.newContext();
const page = await freshContext.newPage();
await page.goto(TEST_ORIGIN);
expect(await readLocalStorage(page, LS_KEY)).toBeNull();
await freshContext.close();
});
test('IndexedDB has no residual entries after context disposal', async ({ browser }) => {
const freshContext = await browser.newContext();
const page = await freshContext.newPage();
await page.goto(TEST_ORIGIN);
expect(await readIndexedDB(page)).toBeNull();
await freshContext.close();
});
test('sessionStorage is not accessible from a new context', async ({ browser }) => {
const writeCtx = await browser.newContext();
const writePage = await writeCtx.newPage();
await writePage.goto(TEST_ORIGIN);
await writePage.evaluate(() =>
sessionStorage.setItem('privacyTest_ss', 'ss-isolation-value')
);
await writeCtx.close();
const readCtx = await browser.newContext();
const readPage = await readCtx.newPage();
await readPage.goto(TEST_ORIGIN);
const residual = await readPage.evaluate(() =>
sessionStorage.getItem('privacyTest_ss')
);
expect(residual).toBeNull();
await readCtx.close();
});
});
storageState() serializes cookies and localStorage by default; pass { indexedDB: true } to include IndexedDB in the JSON snapshot, per the Playwright API reference linked above. sessionStorage is per-tab and intentionally excluded from storageState(), so the dedicated test above exercises that path directly.
Lock the project list so results reflect the macOS WebKit build, and keep a Chromium project alongside for parity evidence:
// playwright.config.ts (excerpt)
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});
Playwright's own browser documentation notes that media codec availability "varies substantially between Linux, macOS and Windows" and that "for the closest-to-Safari experience you should run WebKit on mac" (playwright.dev/docs/browsers). For storage-only assertions, Linux WebKit is usually adequate; for anything that touches media or layout surfaces that differ by platform, run on macOS.
Request a configuration at get started. Tax and any fees apply as stated in the actual offer you receive.