diff --git a/packages/injected/src/storageScript.ts b/packages/injected/src/storageScript.ts index 13ec974c011e7..8ce04c0cd771e 100644 --- a/packages/injected/src/storageScript.ts +++ b/packages/injected/src/storageScript.ts @@ -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 }; @@ -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 { @@ -166,17 +185,57 @@ export class StorageScript { }; } - async collect(recordIndexedDB: boolean): Promise { + private async _collectOPFS(root: FileSystemDirectoryHandle): Promise { + async function walk(base: FileSystemDirectoryHandle) { + const tree: Array = []; + + 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 { 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) { @@ -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(); + 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() : []; @@ -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); + } } } diff --git a/packages/isomorphic/utilityScriptSerializers.ts b/packages/isomorphic/utilityScriptSerializers.ts index 6be0773adb097..c61e45a87be56 100644 --- a/packages/isomorphic/utilityScriptSerializers.ts +++ b/packages/isomorphic/utilityScriptSerializers.ts @@ -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 = @@ -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 { + 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 diff --git a/packages/playwright-core/src/client/channels.d.ts b/packages/playwright-core/src/client/channels.d.ts index 3387f0c5999f7..ce4bf1375c9e3 100644 --- a/packages/playwright-core/src/client/channels.d.ts +++ b/packages/playwright-core/src/client/channels.d.ts @@ -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[], @@ -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[], @@ -5562,4 +5566,3 @@ export interface WorkerEvents { 'console': WorkerConsoleEvent; 'close': WorkerCloseEvent; } - diff --git a/packages/playwright-core/src/client/fetch.ts b/packages/playwright-core/src/client/fetch.ts index 8f94e622e1853..cd649c2ce892d 100644 --- a/packages/playwright-core/src/client/fetch.ts +++ b/packages/playwright-core/src/client/fetch.ts @@ -263,8 +263,8 @@ export class APIRequestContext extends ChannelOwner { - const state = await this._channel.storageState({ indexedDB: options.indexedDB }); + async storageState(options: { path?: string, indexedDB?: boolean, opfs?: boolean } = {}): Promise { + 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'); diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index e0dcbb214a42a..ec4e3760e9dc4 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -609,7 +609,7 @@ export abstract class BrowserContext extends Sdk this._origins.add(origin); } - async storageState(progress: Progress, indexedDB = false, credentials = false): Promise { + async storageState(progress: Progress, { indexedDB = false, credentials = false, opfs = false } = {}): Promise { const result: channels.BrowserContextStorageStateResult = { cookies: await this.cookies(progress), origins: [] @@ -622,7 +622,7 @@ export abstract class BrowserContext 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. @@ -632,8 +632,8 @@ export abstract class BrowserContext 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. @@ -651,8 +651,8 @@ export abstract class BrowserContext 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); diff --git a/packages/playwright-core/src/server/channels.d.ts b/packages/playwright-core/src/server/channels.d.ts index e70d98db233b4..5d5214bf27822 100644 --- a/packages/playwright-core/src/server/channels.d.ts +++ b/packages/playwright-core/src/server/channels.d.ts @@ -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[], @@ -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[], @@ -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 = { @@ -5565,4 +5589,3 @@ export interface WorkerEvents { 'console': WorkerConsoleEvent; 'close': WorkerCloseEvent; } - diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index fb57858e7148d..719dd22bb3702 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -336,7 +336,7 @@ export class BrowserContextDispatcher extends Dispatcher { - 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 { diff --git a/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts b/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts index 8a7cfd3e8412e..2bdd4dc40c50d 100644 --- a/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts +++ b/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts @@ -212,7 +212,7 @@ export class APIRequestContextDispatcher extends Dispatcher { - 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 { diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index f048bd2a3659b..e6b6399cd0790 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -128,7 +128,7 @@ export abstract class APIRequestContext extends SdkObject { APIRequestContext.allInstances.add(this); } - abstract storageState(progress: Progress, indexedDB?: boolean): Promise; + abstract storageState(progress: Progress, indexedDB?: boolean, opfs?: boolean): Promise; fetchResponseBody(progress: Progress, fetchUid: string): Buffer | undefined { return this.fetchResponses.get(fetchUid); @@ -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 { - return this._context.storageState(progress, indexedDB); + override async storageState(progress: Progress, indexedDB?: boolean, opfs?: boolean): Promise { + return this._context.storageState(progress, { indexedDB, opfs }); } } @@ -736,10 +736,10 @@ export class GlobalAPIRequestContext extends APIRequestContext { return this._cookieStore.cookies(url); } - override async storageState(progress: Progress, indexedDB = false): Promise { + override async storageState(progress: Progress, indexedDB = false, opfs = false): Promise { 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 })), }; } } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 4f4e40c7cdb18..e99cf05cb6901 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -9742,6 +9742,12 @@ export interface BrowserContext { */ indexedDB?: boolean; + /** + * Set to `true` to include [Origin private file system](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system) in the + * storage state snapshot. + */ + opfs?: boolean; + /** * The file path to save the storage state to. If * [`path`](https://playwright.dev/docs/api/class-browsercontext#browser-context-storage-state-option-path) is a