Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 82 additions & 10 deletions packages/injected/src/storageScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
* limitations under the License.
*/

import { parseEvaluationResultValue, serializeAsCallArgument } from '@isomorphic/utilityScriptSerializers';
import { parseEvaluationResultValue, serializeAsCallArgument, serializeFile } from '@isomorphic/utilityScriptSerializers';
import type { SerializedValue } from '@isomorphic/utilityScriptSerializers';

type NameValue = { name: string, value: string };

Expand Down Expand Up @@ -42,15 +43,21 @@ type IndexedDBDatabase = {
}[],
};

type OPFSTree = Array<
[name: string, contents: Extract<SerializedValue, {f: any}> | OPFSTree]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use conservative types:

type FSEntry {
  type: 'file' | 'folder';
  name: string;
  lastModified: number;
}

type File = FSEntry & {
  type: 'file';
  base64: string;
}

type Folder = FSEntry & {
  type: 'folder';
  entries: (File | Folder)[];
}

>;

Comment thread
gwennlbh marked this conversation as resolved.
Outdated
type SetOriginStorage = {
origin: string,
localStorage: NameValue[],
indexedDB?: IndexedDBDatabase[],
opfs?: OPFSTree
};

export type SerializedStorage = {
localStorage: NameValue[],
indexedDB?: IndexedDBDatabase[],
opfs?: OPFSTree
};

export class StorageScript {
Expand Down Expand Up @@ -166,17 +173,47 @@ export class StorageScript {
};
}

async collect(recordIndexedDB: boolean): Promise<SerializedStorage> {
private async _collectOPFS(root: FileSystemDirectoryHandle) {
async function walk(base: FileSystemDirectoryHandle){
const tree: OPFSTree = [];

for await (const [name, entry] of base) {
if (entry instanceof FileSystemFileHandle)
tree.push([name, await serializeFile(await entry.getFile())]);
else
tree.push([name, await walk(entry)]);

}

return tree;
}

return walk(root);
}

async collect(record: {indexedDB: boolean, opfs: boolean}): Promise<SerializedStorage> {
const localStorage = Object.keys(this._global.localStorage).map(name => ({ name, value: this._global.localStorage.getItem(name)! }));
if (!recordIndexedDB)
return { localStorage };
try {
const databases = await this._global.indexedDB.databases();
const indexedDB = await Promise.all(databases.map(db => this._collectDB(db)));
return { localStorage, indexedDB };
} catch (e) {
throw new Error('Unable to serialize IndexedDB: ' + e.message);

const collected: SerializedStorage = { localStorage };

if (record.indexedDB) {
try {
const databases = await this._global.indexedDB.databases();
collected.indexedDB = await Promise.all(databases.map(db => this._collectDB(db)));
} catch (e) {
throw new Error('Unable to serialize IndexedDB: ' + e.message);
}
}

if (record.opfs) {
try {
collected.opfs = await this._collectOPFS(await this._global.navigator.storage.getDirectory());
} catch (e) {
throw new Error('Unable to serialize OPFS: ' + e.message);
}
}

return collected;
}

private async _restoreDB(dbInfo: IndexedDBDatabase) {
Expand Down Expand Up @@ -209,6 +246,28 @@ export class StorageScript {
}));
}

private async _restoreOPFS(tree: OPFSTree) {
async function walk(base: FileSystemDirectoryHandle, tree: OPFSTree) {

for (const [name, entry] of tree) {
if (!Array.isArray(entry)) {
const handle = await base.getFileHandle(name, { create: true });
const writable = await handle.createWritable();
Comment thread
gwennlbh marked this conversation as resolved.
const writer = writable.getWriter();
await writer.write(parseEvaluationResultValue(entry));
} else {
const directory = await base.getDirectoryHandle(name, { create: true });
for (const [filename, subentry] of tree)
await walk(directory, [[filename, subentry]]);

}
}
}

const root = await this._global.navigator.storage.getDirectory();
await walk(root, tree);
}

async restore(originState: SetOriginStorage | undefined) {
// Clean Service Workers.
const registrations = this._global.navigator.serviceWorker ? await this._global.navigator.serviceWorker.getRegistrations() : [];
Expand Down Expand Up @@ -239,5 +298,18 @@ export class StorageScript {
this._global.localStorage.clear();
for (const { name, value } of (originState?.localStorage || []))
this._global.localStorage.setItem(name, value);

try {
// Clear everything
const root = await this._global.navigator.storage.getDirectory();
for await (const name of root.keys())
await root.removeEntry(name, { recursive: true });


await this._restoreOPFS(originState?.opfs ?? []);

} catch (e) {
throw new Error('Unable to restore OPFS: ' + e.message);
}
}
}
35 changes: 34 additions & 1 deletion packages/isomorphic/utilityScriptSerializers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ export type SerializedValue =
{ ref: number } |
{ h: number } |
{ ta: { b: string, k: TypedArrayKind } } |
{ ab: { b: string } };
{ ab: { b: string } } |
{ f: { b: string, n: string, t: string, m: number } };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to serialize and restore opfs without this.


type HandleOrValue = { h: number } | { fallThrough: any };

Expand Down Expand Up @@ -87,6 +88,14 @@ function isArrayBuffer(obj: any): obj is ArrayBuffer {
}
}

function isFile(obj: any): obj is File {
try {
return obj instanceof File || Object.prototype.toString.call(obj) === '[object File]';
} catch (error) {
return false;
}
}

const typedArrayConstructors: Record<TypedArrayKind, Function> = {
i8: Int8Array,
ui8: Uint8Array,
Expand Down Expand Up @@ -181,6 +190,16 @@ export function parseEvaluationResultValue(value: SerializedValue, handles: any[
return base64ToTypedArray(value.ta.b, typedArrayConstructors[value.ta.k]);
if ('ab' in value)
return base64ToTypedArray(value.ab.b, Uint8Array).buffer;
if ('f' in value) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did it like this so that we would get File object support in IndexedDB as a side effect of the PR, but yeah it's not strictly necessary

return new File(
[base64ToTypedArray(value.f.b, Uint8Array)],
value.f.n,
{
lastModified: value.f.m,
type: value.f.t
}
);
}
}
return value;
}
Expand All @@ -189,6 +208,18 @@ export function serializeAsCallArgument(value: any, handleSerializer: (value: an
return serialize(value, handleSerializer, { visited: new Map(), lastId: 0 });
}

// Getting a File object's contents requires async
export async function serializeFile(value: File): Promise<Extract<SerializedValue, { f: any; }>> {

This comment was marked as outdated.

return {
f: {
b: typedArrayToBase64(await value.bytes()),
n: value.name,
m: value.lastModified,
t: value.type
}
};
}

function serialize(value: any, handleSerializer: (value: any) => HandleOrValue, visitorInfo: VisitorInfo): SerializedValue {
if (value && typeof value === 'object') {
// eslint-disable-next-line no-restricted-globals
Expand Down Expand Up @@ -257,6 +288,8 @@ function innerSerialize(value: any, handleSerializer: (value: any) => HandleOrVa
}
if (isArrayBuffer(value))
return { ab: { b: typedArrayToBase64(new Uint8Array(value)) } };
if (isFile(value))
throw new Error('File serialization is asynchronous and must be done separately from serializeAsCallArgument');

const id = visitorInfo.visited.get(value);
if (id)
Expand Down
5 changes: 4 additions & 1 deletion packages/playwright-core/src/client/channels.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -702,9 +702,11 @@ export type APIRequestContextFetchLogResult = {
};
export type APIRequestContextStorageStateParams = {
indexedDB?: boolean,
opfs?: boolean
};
export type APIRequestContextStorageStateOptions = {
indexedDB?: boolean,
opfs?: boolean
};
export type APIRequestContextStorageStateResult = {
cookies: NetworkCookie[],
Expand Down Expand Up @@ -1599,10 +1601,12 @@ export type BrowserContextSetOfflineResult = void;
export type BrowserContextStorageStateParams = {
indexedDB?: boolean,
credentials?: boolean,
opfs?: boolean,
};
export type BrowserContextStorageStateOptions = {
indexedDB?: boolean,
credentials?: boolean,
opfs?: boolean,
};
export type BrowserContextStorageStateResult = {
cookies: NetworkCookie[],
Expand Down Expand Up @@ -5562,4 +5566,3 @@ export interface WorkerEvents {
'console': WorkerConsoleEvent;
'close': WorkerCloseEvent;
}

4 changes: 2 additions & 2 deletions packages/playwright-core/src/client/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,8 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
});
}

async storageState(options: { path?: string, indexedDB?: boolean } = {}): Promise<StorageState> {
const state = await this._channel.storageState({ indexedDB: options.indexedDB });
async storageState(options: { path?: string, indexedDB?: boolean, opfs?: boolean } = {}): Promise<StorageState> {
const state = await this._channel.storageState({ indexedDB: options.indexedDB, opfs: options.opfs });
if (options.path) {
await mkdirIfNeeded(this._platform, options.path);
await this._platform.fs().promises.writeFile(options.path, JSON.stringify(state, undefined, 2), 'utf8');
Expand Down
12 changes: 6 additions & 6 deletions packages/playwright-core/src/server/browserContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,7 @@ export abstract class BrowserContext<EM extends EventMap = EventMap> extends Sdk
this._origins.add(origin);
}

async storageState(progress: Progress, indexedDB = false, credentials = false): Promise<channels.BrowserContextStorageStateResult> {
async storageState(progress: Progress, indexedDB = false, credentials = false, opfs = false): Promise<channels.BrowserContextStorageStateResult> {
const result: channels.BrowserContextStorageStateResult = {
cookies: await this.cookies(progress),
origins: []
Expand All @@ -622,7 +622,7 @@ export abstract class BrowserContext<EM extends EventMap = EventMap> extends Sdk
const module = {};
${rawStorageSource.source}
const script = new (module.exports.StorageScript())(${this._browser.options.name === 'firefox'});
return script.collect(${indexedDB});
return script.collect({ indexedDB: ${indexedDB}, opfs: ${opfs} });
})()`;

// First try collecting storage stage from existing pages.
Expand All @@ -632,8 +632,8 @@ export abstract class BrowserContext<EM extends EventMap = EventMap> extends Sdk
continue;
try {
const storage: SerializedStorage = await progress.race(page.mainFrame().nonStallingEvaluateInExistingContext(collectScript, 'utility'));
if (storage.localStorage.length || storage.indexedDB?.length)
result.origins.push({ origin, localStorage: storage.localStorage, indexedDB: storage.indexedDB });
if (storage.localStorage.length || storage.indexedDB?.length || storage.opfs?.length)
result.origins.push({ origin, localStorage: storage.localStorage, indexedDB: storage.indexedDB, opfs: storage.opfs });
originsToSave.delete(origin);
} catch {
// When failed on the live page, we'll retry on the blank page below.
Expand All @@ -651,8 +651,8 @@ export abstract class BrowserContext<EM extends EventMap = EventMap> extends Sdk
const frame = page.mainFrame();
await frame.gotoImpl(progress, origin, {});
const storage: SerializedStorage = await frame.evaluateExpression(progress, collectScript, { world: 'utility' });
if (storage.localStorage.length || storage.indexedDB?.length)
result.origins.push({ origin, localStorage: storage.localStorage, indexedDB: storage.indexedDB });
if (storage.localStorage.length || storage.indexedDB?.length || storage.opfs?.length)
result.origins.push({ origin, localStorage: storage.localStorage, indexedDB: storage.indexedDB, opfs: storage.opfs });
}
} finally {
await page.close(progress);
Expand Down
12 changes: 11 additions & 1 deletion packages/playwright-core/src/server/channels.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -705,9 +705,11 @@ export type APIRequestContextFetchLogResult = {
};
export type APIRequestContextStorageStateParams = {
indexedDB?: boolean,
opfs?: boolean,
};
export type APIRequestContextStorageStateOptions = {
indexedDB?: boolean,
opfs?: boolean,
};
export type APIRequestContextStorageStateResult = {
cookies: NetworkCookie[],
Expand Down Expand Up @@ -1602,10 +1604,12 @@ export type BrowserContextSetOfflineResult = void;
export type BrowserContextStorageStateParams = {
indexedDB?: boolean,
credentials?: boolean,
opfs?: boolean
};
export type BrowserContextStorageStateOptions = {
indexedDB?: boolean,
credentials?: boolean,
opfs?: boolean
};
export type BrowserContextStorageStateResult = {
cookies: NetworkCookie[],
Expand Down Expand Up @@ -5148,16 +5152,23 @@ export type IndexedDBDatabase = {
}[],
};

export type OPFSTree = Array<
[name: string, contents: Extract<SerializedValue, {f: any}> | OPFSTree]
>;


export type SetOriginStorage = {
origin: string,
localStorage: NameValue[],
indexedDB?: IndexedDBDatabase[],
opfs?: OPFSTree
};

export type OriginStorage = {
origin: string,
localStorage: NameValue[],
indexedDB?: IndexedDBDatabase[],
opfs?: OPFSTree
};

export type RecordHarOptions = {
Expand Down Expand Up @@ -5565,4 +5576,3 @@ export interface WorkerEvents {
'console': WorkerConsoleEvent;
'close': WorkerCloseEvent;
}

Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
}

async storageState(params: channels.BrowserContextStorageStateParams, progress: Progress): Promise<channels.BrowserContextStorageStateResult> {
return await this._context.storageState(progress, params.indexedDB, params.credentials);
return await this._context.storageState(progress, params.indexedDB, params.credentials, params.opfs);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pass params as object

}

async setStorageState(params: channels.BrowserContextSetStorageStateParams, progress: Progress): Promise<void> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export class APIRequestContextDispatcher extends Dispatcher<APIRequestContext, c
}

async storageState(params: channels.APIRequestContextStorageStateParams, progress: Progress): Promise<channels.APIRequestContextStorageStateResult> {
return await this._object.storageState(progress, params.indexedDB);
return await this._object.storageState(progress, params.indexedDB, params.opfs);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: params object here

}

async dispose(params: channels.APIRequestContextDisposeParams, progress: Progress): Promise<void> {
Expand Down
10 changes: 5 additions & 5 deletions packages/playwright-core/src/server/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export abstract class APIRequestContext extends SdkObject {
APIRequestContext.allInstances.add(this);
}

abstract storageState(progress: Progress, indexedDB?: boolean): Promise<channels.APIRequestContextStorageStateResult>;
abstract storageState(progress: Progress, indexedDB?: boolean, opfs?: boolean): Promise<channels.APIRequestContextStorageStateResult>;

fetchResponseBody(progress: Progress, fetchUid: string): Buffer | undefined {
return this.fetchResponses.get(fetchUid);
Expand Down Expand Up @@ -679,8 +679,8 @@ export class BrowserContextAPIRequestContext extends APIRequestContext {
return await this._context.cookies(progress, url.toString());
}

override async storageState(progress: Progress, indexedDB?: boolean): Promise<channels.APIRequestContextStorageStateResult> {
return this._context.storageState(progress, indexedDB);
override async storageState(progress: Progress, indexedDB?: boolean, opfs?: boolean): Promise<channels.APIRequestContextStorageStateResult> {
return this._context.storageState(progress, indexedDB, opfs);
}
}

Expand Down Expand Up @@ -736,10 +736,10 @@ export class GlobalAPIRequestContext extends APIRequestContext {
return this._cookieStore.cookies(url);
}

override async storageState(progress: Progress, indexedDB = false): Promise<channels.APIRequestContextStorageStateResult> {
override async storageState(progress: Progress, indexedDB = false, opfs = false): Promise<channels.APIRequestContextStorageStateResult> {
return {
cookies: this._cookieStore.allCookies(),
origins: (this._origins || []).map(origin => ({ ...origin, indexedDB: indexedDB ? origin.indexedDB : [] })),
origins: (this._origins || []).map(origin => ({ ...origin, indexedDB: indexedDB ? origin.indexedDB : [], opfs: opfs ? origin.opfs : [] })),
};
}
}
Expand Down
Loading