Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
113 changes: 103 additions & 10 deletions packages/injected/src/storageScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

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

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

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

export type FSEntry = {
type: 'file' | 'folder';
name: string;
};

export type FSFile = FSEntry & {
type: 'file';
base64: string;
contentType: string;
lastModified: number;
};

export type FSFolder = FSEntry & {
type: 'folder';
entries: (FSFile | FSFolder)[];
};

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

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

export class StorageScript {
Expand Down Expand Up @@ -166,17 +185,57 @@ export class StorageScript {
};
}

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

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

}

return walk(base);
}

return {
type: 'folder',
name: '',
entries: await 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 +268,27 @@ export class StorageScript {
}));
}

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

for (const entry of tree.entries) {
if (entry.type === 'file') {
const handle = await base.getFileHandle(entry.name, { create: true });
const writable = await handle.createWritable();
Comment thread
gwennlbh marked this conversation as resolved.
const writer = writable.getWriter();
await writer.write(parseSerializedFile(entry));
await writer.close()
} else {
const directory = await base.getDirectoryHandle(entry.name, { create: true });
await walk(directory, entry);
}
}
}

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 +319,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);
}
}
}
24 changes: 24 additions & 0 deletions packages/isomorphic/utilityScriptSerializers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
* limitations under the License.
*/

import { FSFile } from '@injected/storageScript';

type TypedArrayKind = 'i8' | 'ui8' | 'ui8c' | 'i16' | 'ui16' | 'i32' | 'ui32' | 'f32' | 'f64' | 'bi64' | 'bui64';

export type SerializedValue =
Expand Down Expand Up @@ -189,6 +191,28 @@ 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<FSFile> {
return {
name: value.name,
base64: typedArrayToBase64(await value.bytes()),
lastModified: value.lastModified,
contentType: value.type,
type: 'file',
};
}

export function parseSerializedFile(value: FSFile): File {
return new File(
[base64ToTypedArray(value.base64, Uint8Array)],
value.name,
{
type: value.contentType,
lastModified: value.lastModified
}
)
}

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
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)
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
25 changes: 24 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,36 @@ export type IndexedDBDatabase = {
}[],
};

export type FSEntry = {
type: 'file' | 'folder';
name: string;
};

export type FSFile = FSEntry & {
type: 'file';
base64: string;
contentType: string;
lastModified: number;
};

export type FSFolder = FSEntry & {
type: 'folder';
entries: (FSFile | FSFolder)[];
};


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

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

export type RecordHarOptions = {
Expand Down Expand Up @@ -5565,4 +5589,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);
}

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

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 : undefined })),
};
}
}
Expand Down
Loading