chore: add encoded versions of IndexedDB key/value (#34630)

This commit is contained in:
Simon Knott 2025-02-06 09:48:30 +01:00 committed by GitHub
parent 11e1b8f30a
commit 7aac96d780
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 131 additions and 16 deletions

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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).

View file

@ -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,

View file

@ -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<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 });
}
@ -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);
}

View file

@ -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');
@ -32,6 +33,39 @@ 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 => {
@ -39,10 +73,24 @@ export async function collect(): Promise<Storage> {
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<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);
@ -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),
)
);
}));

View file

@ -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;
}>;
}>;
}>;

View file

@ -281,7 +281,9 @@ export type IndexedDBDatabase = {
keyPathArray?: string[],
records: {
key?: any,
value: any,
keyEncoded?: any,
value?: any,
valueEncoded?: any,
}[],
indexes: {
name: string,

View file

@ -244,7 +244,9 @@ IndexedDBDatabase:
type: object
properties:
key: json?
value: json
keyEncoded: json?
value: json?
valueEncoded: json?
indexes:
type: array
items:

View file

@ -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();
});