Skip to content

Commit

Permalink
chore: add encoded versions of IndexedDB key/value (#34630)
Browse files Browse the repository at this point in the history
  • Loading branch information
Skn0tt authored Feb 6, 2025
1 parent 11e1b8f commit 7aac96d
Show file tree
Hide file tree
Showing 11 changed files with 131 additions and 16 deletions.
2 changes: 2 additions & 0 deletions docs/src/api/class-apirequest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/src/api/class-apirequestcontext.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions docs/src/api/class-browsercontext.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions docs/src/api/params.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
4 changes: 3 additions & 1 deletion packages/playwright-core/src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 6 additions & 3 deletions packages/playwright-core/src/server/browserContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
Expand All @@ -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<ReturnType<typeof storageScript.collect>> = 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 });
}
Expand Down Expand Up @@ -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);
}
Expand Down
64 changes: 56 additions & 8 deletions packages/playwright-core/src/server/storageScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@
*/

import type * as channels from '@protocol/channels';
import type { source } from './isomorphic/utilityScriptSerializers';

export type Storage = Omit<channels.OriginStorage, 'origin'>;

export async function collect(): Promise<Storage> {
export async function collect(serializers: ReturnType<typeof source>, isFirefox: boolean): Promise<Storage> {
const idbResult = await Promise.all((await indexedDB.databases()).map(async dbInfo => {
if (!dbInfo.name)
throw new Error('Database name is empty');
Expand All @@ -32,17 +33,64 @@ export async function collect(): Promise<Storage> {
});
}

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 => {
const objectStore = transaction.objectStore(storeName);

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 => {
Expand Down Expand Up @@ -81,7 +129,7 @@ export async function collect(): Promise<Storage> {
};
}

export async function restore(originState: channels.SetOriginStorage) {
export async function restore(originState: channels.SetOriginStorage, serializers: ReturnType<typeof source>) {
for (const { name, value } of (originState.localStorage || []))
localStorage.setItem(name, value);

Expand Down Expand Up @@ -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),
)
);
}));
Expand Down
50 changes: 50 additions & 0 deletions packages/playwright-core/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}>;
}>;
}>;
Expand Down Expand Up @@ -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;
}>;
}>;
}>;
Expand Down Expand Up @@ -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;
}>;
}>;
}>;
Expand Down Expand Up @@ -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;
}>;
}>;
}>;
Expand Down Expand Up @@ -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;
}>;
}>;
}>;
Expand Down
4 changes: 3 additions & 1 deletion packages/protocol/src/channels.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,9 @@ export type IndexedDBDatabase = {
keyPathArray?: string[],
records: {
key?: any,
value: any,
keyEncoded?: any,
value?: any,
valueEncoded?: any,
}[],
indexes: {
name: string,
Expand Down
4 changes: 3 additions & 1 deletion packages/protocol/src/protocol.yml
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,9 @@ IndexedDBDatabase:
type: object
properties:
key: json?
value: json
keyEncoded: json?
value: json?
valueEncoded: json?
indexes:
type: array
items:
Expand Down
4 changes: 2 additions & 2 deletions tests/library/browsercontext-storage-state.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand Down Expand Up @@ -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();
});

Expand Down

0 comments on commit 7aac96d

Please sign in to comment.