From 7aac96d780152e297bd420d3eaf29a9395a37bb6 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 6 Feb 2025 09:48:30 +0100 Subject: [PATCH] chore: add encoded versions of IndexedDB key/value (#34630) --- docs/src/api/class-apirequest.md | 2 + docs/src/api/class-apirequestcontext.md | 2 + docs/src/api/class-browsercontext.md | 2 + docs/src/api/params.md | 2 + .../playwright-core/src/protocol/validator.ts | 4 +- .../src/server/browserContext.ts | 9 ++- .../src/server/storageScript.ts | 64 ++++++++++++++++--- packages/playwright-core/types/types.d.ts | 50 +++++++++++++++ packages/protocol/src/channels.d.ts | 4 +- packages/protocol/src/protocol.yml | 4 +- .../browsercontext-storage-state.spec.ts | 4 +- 11 files changed, 131 insertions(+), 16 deletions(-) diff --git a/docs/src/api/class-apirequest.md b/docs/src/api/class-apirequest.md index 19e884d9f5..feea5b8378 100644 --- a/docs/src/api/class-apirequest.md +++ b/docs/src/api/class-apirequest.md @@ -80,7 +80,9 @@ Methods like [`method: APIRequestContext.get`] take the base URL into considerat - `multiEntry` <[boolean]> - `records` <[Array]<[Object]>> - `key` ?<[Object]> + - `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types. - `value` <[Object]> + - `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types. Populates context with given storage state. This option can be used to initialize context with logged-in information obtained via [`method: BrowserContext.storageState`] or [`method: APIRequestContext.storageState`]. Either a path to the diff --git a/docs/src/api/class-apirequestcontext.md b/docs/src/api/class-apirequestcontext.md index 3cbc9c09cf..b8c179de4f 100644 --- a/docs/src/api/class-apirequestcontext.md +++ b/docs/src/api/class-apirequestcontext.md @@ -896,7 +896,9 @@ context cookies from the response. The method will automatically follow redirect - `multiEntry` <[boolean]> - `records` <[Array]<[Object]>> - `key` ?<[Object]> + - `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types. - `value` <[Object]> + - `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types. Returns storage state for this request context, contains current cookies and local storage snapshot if it was passed to the constructor. diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index cae2c0d139..0da156b664 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -1527,7 +1527,9 @@ Whether to emulate network being offline for the browser context. - `multiEntry` <[boolean]> - `records` <[Array]<[Object]>> - `key` ?<[Object]> + - `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types. - `value` <[Object]> + - `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types. Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB snapshot. diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 9fe3d1b53a..f2d8734336 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -280,7 +280,9 @@ Specify environment variables that will be visible to the browser. Defaults to ` - `multiEntry` <[boolean]> - `records` <[Array]<[Object]>> - `key` ?<[Object]> + - `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types. - `value` <[Object]> + - `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types. Learn more about [storage state and auth](../auth.md). diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 8981df6f4a..35832219ee 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -152,7 +152,9 @@ scheme.IndexedDBDatabase = tObject({ keyPathArray: tOptional(tArray(tString)), records: tArray(tObject({ key: tOptional(tAny), - value: tAny, + keyEncoded: tOptional(tAny), + value: tOptional(tAny), + valueEncoded: tOptional(tAny), })), indexes: tArray(tObject({ name: tString, diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 8de81bb1ee..d75be1c34d 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -44,6 +44,7 @@ import { Clock } from './clock'; import type { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; import { RecorderApp } from './recorder/recorderApp'; import * as storageScript from './storageScript'; +import * as utilityScriptSerializers from './isomorphic/utilityScriptSerializers'; export abstract class BrowserContext extends SdkObject { static Events = { @@ -514,13 +515,15 @@ export abstract class BrowserContext extends SdkObject { }; const originsToSave = new Set(this._origins); + const collectScript = `(${storageScript.collect})((${utilityScriptSerializers.source})(), ${this._browser.options.name === 'firefox'})`; + // First try collecting storage stage from existing pages. for (const page of this.pages()) { const origin = page.mainFrame().origin(); if (!origin || !originsToSave.has(origin)) continue; try { - const storage: storageScript.Storage = await page.mainFrame().nonStallingEvaluateInExistingContext(`(${storageScript.collect})()`, 'utility'); + const storage: storageScript.Storage = await page.mainFrame().nonStallingEvaluateInExistingContext(collectScript, 'utility'); if (storage.localStorage.length || storage.indexedDB.length) result.origins.push({ origin, localStorage: storage.localStorage, indexedDB: storage.indexedDB }); originsToSave.delete(origin); @@ -540,7 +543,7 @@ export abstract class BrowserContext extends SdkObject { for (const origin of originsToSave) { const frame = page.mainFrame(); await frame.goto(internalMetadata, origin); - const storage: Awaited> = await frame.evaluateExpression(`(${storageScript.collect})()`, { world: 'utility' }); + const storage: storageScript.Storage = await frame.evaluateExpression(collectScript, { world: 'utility' }); if (storage.localStorage.length || storage.indexedDB.length) result.origins.push({ origin, localStorage: storage.localStorage, indexedDB: storage.indexedDB }); } @@ -605,7 +608,7 @@ export abstract class BrowserContext extends SdkObject { for (const originState of state.origins) { const frame = page.mainFrame(); await frame.goto(metadata, originState.origin); - await frame.evaluateExpression(storageScript.restore.toString(), { isFunction: true, world: 'utility' }, originState); + await frame.evaluateExpression(`(${storageScript.restore})(${JSON.stringify(originState)}, (${utilityScriptSerializers.source})())`, { world: 'utility' }); } await page.close(internalMetadata); } diff --git a/packages/playwright-core/src/server/storageScript.ts b/packages/playwright-core/src/server/storageScript.ts index acde6b54f7..517a1529d3 100644 --- a/packages/playwright-core/src/server/storageScript.ts +++ b/packages/playwright-core/src/server/storageScript.ts @@ -15,10 +15,11 @@ */ import type * as channels from '@protocol/channels'; +import type { source } from './isomorphic/utilityScriptSerializers'; export type Storage = Omit; -export async function collect(): Promise { +export async function collect(serializers: ReturnType, isFirefox: boolean): Promise { const idbResult = await Promise.all((await indexedDB.databases()).map(async dbInfo => { if (!dbInfo.name) throw new Error('Database name is empty'); @@ -32,6 +33,39 @@ export async function collect(): Promise { }); } + function isPlainObject(v: any) { + const ctor = v?.constructor; + if (isFirefox) { + const constructorImpl = ctor?.toString(); + if (constructorImpl.startsWith('function Object() {') && constructorImpl.includes('[native code]')) + return true; + } + + return ctor === Object; + } + + function trySerialize(value: any): { trivial?: any, encoded?: any } { + let trivial = true; + const encoded = serializers.serializeAsCallArgument(value, v => { + const isTrivial = ( + isPlainObject(v) + || Array.isArray(v) + || typeof v === 'string' + || typeof v === 'number' + || typeof v === 'boolean' + || Object.is(v, null) + ); + + if (!isTrivial) + trivial = false; + + return { fallThrough: v }; + }); + if (trivial) + return { trivial: value }; + return { encoded }; + } + const db = await idbRequestToPromise(indexedDB.open(dbInfo.name)); const transaction = db.transaction(db.objectStoreNames, 'readonly'); const stores = await Promise.all([...db.objectStoreNames].map(async storeName => { @@ -39,10 +73,24 @@ export async function collect(): Promise { const keys = await idbRequestToPromise(objectStore.getAllKeys()); const records = await Promise.all(keys.map(async key => { - return { - key: objectStore.keyPath === null ? key : undefined, - value: await idbRequestToPromise(objectStore.get(key)) - }; + const record: channels.OriginStorage['indexedDB'][0]['stores'][0]['records'][0] = {}; + + if (objectStore.keyPath === null) { + const { encoded, trivial } = trySerialize(key); + if (trivial) + record.key = trivial; + else + record.keyEncoded = encoded; + } + + const value = await idbRequestToPromise(objectStore.get(key)); + const { encoded, trivial } = trySerialize(value); + if (trivial) + record.value = trivial; + else + record.valueEncoded = encoded; + + return record; })); const indexes = [...objectStore.indexNames].map(indexName => { @@ -81,7 +129,7 @@ export async function collect(): Promise { }; } -export async function restore(originState: channels.SetOriginStorage) { +export async function restore(originState: channels.SetOriginStorage, serializers: ReturnType) { for (const { name, value } of (originState.localStorage || [])) localStorage.setItem(name, value); @@ -111,8 +159,8 @@ export async function restore(originState: channels.SetOriginStorage) { await Promise.all(store.records.map(async record => { await idbRequestToPromise( objectStore.add( - record.value, - objectStore.keyPath === null ? record.key : undefined + record.value ?? serializers.parseEvaluationResultValue(record.valueEncoded), + record.key ?? serializers.parseEvaluationResultValue(record.keyEncoded), ) ); })); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 4e86e5b58b..6f7c35e361 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -9338,7 +9338,17 @@ export interface BrowserContext { records: Array<{ key?: Object; + /** + * if `key` is not JSON-serializable, this contains an encoded version that preserves types. + */ + keyEncoded?: Object; + value: Object; + + /** + * if `value` is not JSON-serializable, this contains an encoded version that preserves types. + */ + valueEncoded?: Object; }>; }>; }>; @@ -10147,7 +10157,17 @@ export interface Browser { records: Array<{ key?: Object; + /** + * if `key` is not JSON-serializable, this contains an encoded version that preserves types. + */ + keyEncoded?: Object; + value: Object; + + /** + * if `value` is not JSON-serializable, this contains an encoded version that preserves types. + */ + valueEncoded?: Object; }>; }>; }>; @@ -17725,7 +17745,17 @@ export interface APIRequest { records: Array<{ key?: Object; + /** + * if `key` is not JSON-serializable, this contains an encoded version that preserves types. + */ + keyEncoded?: Object; + value: Object; + + /** + * if `value` is not JSON-serializable, this contains an encoded version that preserves types. + */ + valueEncoded?: Object; }>; }>; }>; @@ -18568,7 +18598,17 @@ export interface APIRequestContext { records: Array<{ key?: Object; + /** + * if `key` is not JSON-serializable, this contains an encoded version that preserves types. + */ + keyEncoded?: Object; + value: Object; + + /** + * if `value` is not JSON-serializable, this contains an encoded version that preserves types. + */ + valueEncoded?: Object; }>; }>; }>; @@ -22454,7 +22494,17 @@ export interface BrowserContextOptions { records: Array<{ key?: Object; + /** + * if `key` is not JSON-serializable, this contains an encoded version that preserves types. + */ + keyEncoded?: Object; + value: Object; + + /** + * if `value` is not JSON-serializable, this contains an encoded version that preserves types. + */ + valueEncoded?: Object; }>; }>; }>; diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index bc8c7c56bb..b0ccdcbc2a 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -281,7 +281,9 @@ export type IndexedDBDatabase = { keyPathArray?: string[], records: { key?: any, - value: any, + keyEncoded?: any, + value?: any, + valueEncoded?: any, }[], indexes: { name: string, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index bdf55069e4..b581eacb4e 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -244,7 +244,9 @@ IndexedDBDatabase: type: object properties: key: json? - value: json + keyEncoded: json? + value: json? + valueEncoded: json? indexes: type: array items: diff --git a/tests/library/browsercontext-storage-state.spec.ts b/tests/library/browsercontext-storage-state.spec.ts index 04a1eee02c..4b003a6c81 100644 --- a/tests/library/browsercontext-storage-state.spec.ts +++ b/tests/library/browsercontext-storage-state.spec.ts @@ -96,7 +96,7 @@ it('should round-trip through the file', async ({ contextFactory }, testInfo) => openRequest.onsuccess = () => { const request = openRequest.result.transaction('store', 'readwrite') .objectStore('store') - .put('foo', 'bar'); + .put({ name: 'foo', date: new Date(0) }, 'bar'); request.addEventListener('success', resolve); request.addEventListener('error', reject); }; @@ -131,7 +131,7 @@ it('should round-trip through the file', async ({ contextFactory }, testInfo) => }); openRequest.addEventListener('error', () => reject(openRequest.error)); })); - expect(idbValue).toEqual('foo'); + expect(idbValue).toEqual({ name: 'foo', date: new Date(0) }); await context2.close(); });