From 18b71cc4e7d074c4e0a11ad3e62837aa29d8b0f8 Mon Sep 17 00:00:00 2001 From: Gwenn Le Bihan Date: Tue, 23 Jun 2026 10:35:08 +0200 Subject: [PATCH 1/2] feat(storageState): add OPFS support --- packages/injected/src/storageScript.ts | 92 +++++++++++++++++-- .../isomorphic/utilityScriptSerializers.ts | 35 ++++++- .../playwright-core/src/client/channels.d.ts | 5 +- packages/playwright-core/src/client/fetch.ts | 4 +- .../src/server/browserContext.ts | 12 +-- .../playwright-core/src/server/channels.d.ts | 12 ++- .../dispatchers/browserContextDispatcher.ts | 2 +- .../server/dispatchers/networkDispatchers.ts | 2 +- packages/playwright-core/src/server/fetch.ts | 10 +- packages/playwright-core/types/types.d.ts | 6 ++ 10 files changed, 152 insertions(+), 28 deletions(-) diff --git a/packages/injected/src/storageScript.ts b/packages/injected/src/storageScript.ts index 13ec974c011e7..7135ce13964fa 100644 --- a/packages/injected/src/storageScript.ts +++ b/packages/injected/src/storageScript.ts @@ -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 }; @@ -42,15 +43,21 @@ type IndexedDBDatabase = { }[], }; +type OPFSTree = Array< + [name: string, contents: Extract | OPFSTree] +>; + type SetOriginStorage = { origin: string, localStorage: NameValue[], indexedDB?: IndexedDBDatabase[], + opfs?: OPFSTree }; export type SerializedStorage = { localStorage: NameValue[], indexedDB?: IndexedDBDatabase[], + opfs?: OPFSTree }; export class StorageScript { @@ -166,17 +173,47 @@ export class StorageScript { }; } - async collect(recordIndexedDB: boolean): Promise { + 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 { 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 +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(); + 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() : []; @@ -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); + } } } diff --git a/packages/isomorphic/utilityScriptSerializers.ts b/packages/isomorphic/utilityScriptSerializers.ts index 6be0773adb097..98d77e73ea384 100644 --- a/packages/isomorphic/utilityScriptSerializers.ts +++ b/packages/isomorphic/utilityScriptSerializers.ts @@ -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 } }; type HandleOrValue = { h: number } | { fallThrough: any }; @@ -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 = { i8: Int8Array, ui8: Uint8Array, @@ -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) { + return new File( + [base64ToTypedArray(value.f.b, Uint8Array)], + value.f.n, + { + lastModified: value.f.m, + type: value.f.t + } + ); + } } return value; } @@ -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> { + 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 @@ -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) 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..f2073548833be 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?.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. @@ -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..3c1d2bbeeae40 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,23 @@ export type IndexedDBDatabase = { }[], }; +export type OPFSTree = Array< + [name: string, contents: Extract | 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 = { @@ -5565,4 +5576,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..e33992501b6a7 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.indexedDB, params.credentials, params.opfs); } 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..007d8fa724629 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 : [] })), }; } } 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 From 2040049da3686fe1a3fff8b1fb66afb7ff15feee Mon Sep 17 00:00:00 2001 From: Gwenn Le Bihan Date: Thu, 25 Jun 2026 21:26:27 +0200 Subject: [PATCH 2/2] address comments --- packages/injected/src/storageScript.ts | 73 ++++++++++++------- .../isomorphic/utilityScriptSerializers.ts | 49 +++++-------- .../src/server/browserContext.ts | 4 +- .../playwright-core/src/server/channels.d.ts | 23 ++++-- .../dispatchers/browserContextDispatcher.ts | 2 +- packages/playwright-core/src/server/fetch.ts | 4 +- 6 files changed, 90 insertions(+), 65 deletions(-) diff --git a/packages/injected/src/storageScript.ts b/packages/injected/src/storageScript.ts index 7135ce13964fa..8ce04c0cd771e 100644 --- a/packages/injected/src/storageScript.ts +++ b/packages/injected/src/storageScript.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import { parseEvaluationResultValue, serializeAsCallArgument, serializeFile } from '@isomorphic/utilityScriptSerializers'; -import type { SerializedValue } from '@isomorphic/utilityScriptSerializers'; +import { parseEvaluationResultValue, parseSerializedFile, serializeAsCallArgument, serializeFile } from '@isomorphic/utilityScriptSerializers'; type NameValue = { name: string, value: string }; @@ -43,21 +42,34 @@ type IndexedDBDatabase = { }[], }; -type OPFSTree = Array< - [name: string, contents: Extract | OPFSTree] ->; +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?: OPFSTree + opfs?: FSFolder }; export type SerializedStorage = { localStorage: NameValue[], indexedDB?: IndexedDBDatabase[], - opfs?: OPFSTree + opfs?: FSFolder }; export class StorageScript { @@ -173,22 +185,32 @@ export class StorageScript { }; } - private async _collectOPFS(root: FileSystemDirectoryHandle) { - async function walk(base: FileSystemDirectoryHandle){ - const tree: OPFSTree = []; + 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([name, await serializeFile(await entry.getFile())]); - else - tree.push([name, await walk(entry)]); + if (entry instanceof FileSystemFileHandle) { + tree.push( + await serializeFile(await entry.getFile()) + ); + } else { + tree.push({ + type: 'folder', + name, entries: await walk(entry) + }); + } } - return tree; + return walk(base); } - return walk(root); + return { + type: 'folder', + name: '', + entries: await walk(root) + }; } async collect(record: {indexedDB: boolean, opfs: boolean}): Promise { @@ -246,20 +268,19 @@ export class StorageScript { })); } - private async _restoreOPFS(tree: OPFSTree) { - async function walk(base: FileSystemDirectoryHandle, tree: OPFSTree) { + private async _restoreOPFS(tree: FSFolder) { + async function walk(base: FileSystemDirectoryHandle, tree: FSFolder) { - for (const [name, entry] of tree) { - if (!Array.isArray(entry)) { - const handle = await base.getFileHandle(name, { create: true }); + 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(parseEvaluationResultValue(entry)); + await writer.write(parseSerializedFile(entry)); + await writer.close() } else { - const directory = await base.getDirectoryHandle(name, { create: true }); - for (const [filename, subentry] of tree) - await walk(directory, [[filename, subentry]]); - + const directory = await base.getDirectoryHandle(entry.name, { create: true }); + await walk(directory, entry); } } } diff --git a/packages/isomorphic/utilityScriptSerializers.ts b/packages/isomorphic/utilityScriptSerializers.ts index 98d77e73ea384..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 = @@ -29,8 +31,7 @@ export type SerializedValue = { ref: number } | { h: number } | { ta: { b: string, k: TypedArrayKind } } | - { ab: { b: string } } | - { f: { b: string, n: string, t: string, m: number } }; + { ab: { b: string } }; type HandleOrValue = { h: number } | { fallThrough: any }; @@ -88,14 +89,6 @@ 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 = { i8: Int8Array, ui8: Uint8Array, @@ -190,16 +183,6 @@ 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) { - return new File( - [base64ToTypedArray(value.f.b, Uint8Array)], - value.f.n, - { - lastModified: value.f.m, - type: value.f.t - } - ); - } } return value; } @@ -209,17 +192,27 @@ export function serializeAsCallArgument(value: any, handleSerializer: (value: an } // Getting a File object's contents requires async -export async function serializeFile(value: File): Promise> { +export async function serializeFile(value: File): Promise { return { - f: { - b: typedArrayToBase64(await value.bytes()), - n: value.name, - m: value.lastModified, - t: value.type - } + 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 @@ -288,8 +281,6 @@ 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) diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index f2073548833be..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, opfs = false): Promise { + async storageState(progress: Progress, { indexedDB = false, credentials = false, opfs = false } = {}): Promise { const result: channels.BrowserContextStorageStateResult = { cookies: await this.cookies(progress), origins: [] @@ -632,7 +632,7 @@ 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 || storage.opfs?.length) + 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 { diff --git a/packages/playwright-core/src/server/channels.d.ts b/packages/playwright-core/src/server/channels.d.ts index 3c1d2bbeeae40..5d5214bf27822 100644 --- a/packages/playwright-core/src/server/channels.d.ts +++ b/packages/playwright-core/src/server/channels.d.ts @@ -5152,23 +5152,36 @@ export type IndexedDBDatabase = { }[], }; -export type OPFSTree = Array< - [name: string, contents: Extract | OPFSTree] ->; +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?: OPFSTree + opfs?: FSFolder }; export type OriginStorage = { origin: string, localStorage: NameValue[], indexedDB?: IndexedDBDatabase[], - opfs?: OPFSTree + opfs?: FSFolder }; export type RecordHarOptions = { diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index e33992501b6a7..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, params.opfs); + return await this._context.storageState(progress, params); } async setStorageState(params: channels.BrowserContextSetStorageStateParams, progress: Progress): Promise { diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 007d8fa724629..e6b6399cd0790 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -680,7 +680,7 @@ export class BrowserContextAPIRequestContext extends APIRequestContext { } override async storageState(progress: Progress, indexedDB?: boolean, opfs?: boolean): Promise { - return this._context.storageState(progress, indexedDB, opfs); + return this._context.storageState(progress, { indexedDB, opfs }); } } @@ -739,7 +739,7 @@ export class GlobalAPIRequestContext extends APIRequestContext { 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 : [], opfs: opfs ? origin.opfs : [] })), + origins: (this._origins || []).map(origin => ({ ...origin, indexedDB: indexedDB ? origin.indexedDB : [], opfs: opfs ? origin.opfs : undefined })), }; } }