From 0d30295b95386431c730bd449a847e9f3bdec706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81?= Date: Sat, 15 Aug 2026 00:38:18 +0200 Subject: [PATCH 1/8] Implemented #5117 --- Browser/keywords/playwright_state.py | 94 ++++++++++-- .../01_Browser_Management/storage_state.robot | 134 +++++++++++++++++- node/playwright-wrapper/grpc-service.ts | 16 ++- node/playwright-wrapper/playwright-state.ts | 39 ++++- package-lock.json | 102 ++++--------- protobuf/playwright.proto | 12 +- 6 files changed, 304 insertions(+), 93 deletions(-) diff --git a/Browser/keywords/playwright_state.py b/Browser/keywords/playwright_state.py index 77758bdb9..db653007e 100755 --- a/Browser/keywords/playwright_state.py +++ b/Browser/keywords/playwright_state.py @@ -610,7 +610,7 @@ def new_context( screen: dict[str, int] | None = None, serviceWorkers: ServiceWorkersPermissions | None = ServiceWorkersPermissions.allow, - storageState: str | None = None, + storageState: Path | None = None, timezoneId: str | None = None, tracing: bool | Path | None = None, userAgent: str | None = None, @@ -886,7 +886,7 @@ def _set_context_options(self, params, httpCredentials, storageState): reduced_motion = str(params.get("reducedMotion")) reduced_motion = reduced_motion.replace("_", "-") params["reducedMotion"] = reduced_motion - if storageState and not Path(storageState).is_file(): + if storageState and not storageState.is_file(): raise ValueError( f"storageState argument value '{storageState}' is not file, but it should be." ) @@ -1677,7 +1677,13 @@ def _get_active_browser_item(browser_catalog): return {} @keyword(tags=("Getter", "BrowserControl")) - def save_storage_state(self) -> str: + def save_storage_state( + self, + path: Path | None = None, + *, + indexedDB: bool = False, + credentials: bool = False, + ) -> str: """Saves the current active context storage state to a file. Web apps use cookie-based or token-based authentication, where @@ -1692,8 +1698,14 @@ def save_storage_state(self) -> str: Please note that the state file may contain secrets and should not be shared with people outside of your organisation. - The file is created in ${OUTPUTDIR}/browser/state folder and file(s) - are automatically deleted when new test execution starts. File path + | =Arguments= | =Description= | + | ``path`` | Where the state file is written. Relative paths are resolved against the current working directory and missing parent directories are created. If the file already exists, it is overwritten. If not given, a file with a generated name is created in ${OUTPUTDIR}/browser/state. | + | ``indexedDB`` | Also save IndexedDB. Needed by applications, like Firebase, which store authentication tokens in IndexedDB. | + | ``credentials`` | Also save the context's virtual WebAuthn credentials, as created by `Create Credential`. This is not related to the ``httpCredentials`` argument of `New Context`, which is about HTTP authentication. | + + Files in ${OUTPUTDIR}/browser/state are automatically deleted when new + test execution starts. To keep a state file over several executions, + save it with ``path`` to a location outside of that folder. File path is returned by the keyword. Example: @@ -1717,17 +1729,77 @@ def save_storage_state(self) -> str: [https://forum.robotframework.org/t//4318|Comment >>] """ - file = str(self.state_file / f"{uuid4()!s}.json") - self.state_file.mkdir(parents=True, exist_ok=True) - log = self._save_storage_state(file) + state_file = path if path is not None else self.state_file / f"{uuid4()!s}.json" + state_file.parent.mkdir(parents=True, exist_ok=True) + log = self._save_storage_state(str(state_file), indexedDB, credentials) logger.info(log) - return file + return str(state_file) - def _save_storage_state(self, path: str) -> str: + def _save_storage_state( + self, path: str, indexedDB: bool = False, credentials: bool = False + ) -> str: with self.playwright.grpc_channel() as stub: - response = stub.SaveStorageState(Request().FilePath(path=path)) + response = stub.SaveStorageState( + Request().StorageState( + path=path, indexedDB=indexedDB, credentials=credentials + ) + ) return response.log + @keyword(tags=("Setter", "BrowserControl")) + def set_storage_state(self, path: Path, timeout: timedelta | None = None) -> None: + """Restores a storage state file into the current active context. + + Clears the cookies, local storage, IndexedDB and virtual WebAuthn + credentials of the currently active context and replaces them with the + ones from the ``path`` file, which must have been created by + `Save Storage State`. Unlike creating a `New Context` with the + ``storageState`` argument, the context and all of its pages stay open. + + Pages that are already open keep the state they have loaded into memory. + Reload them, with the `Reload` keyword, to make them see the restored + state. + + | =Arguments= | =Description= | + | ``path`` | Path to a state file created by `Save Storage State`. Relative paths are resolved against the current working directory. The keyword fails if the file does not exist. | + | ``timeout`` | Time to wait for the state to be restored. If not defined, the library default timeout is used. | + + == Restoring IndexedDB == + + `Save Storage State` with ``indexedDB=True`` leaves an open IndexedDB + connection in the page, which is a + [https://github.com/microsoft/playwright/issues/42258|Playwright bug]. + Restoring a state file that contains IndexedDB into a context whose + pages still hold such a connection never finishes, and this keyword + fails when ``timeout`` expires. Avoid that by either reloading the + pages of the context before calling this keyword, or by restoring the + state into a `New Context`. + + Example: + | Test Case + | `New Context` + | `New Page` https://login.page.html + | # Perform login as first user + | ${user_a} = `Save Storage State` + | # Perform login as second user + | ${user_b} = `Save Storage State` + | # Switch back to the first user without creating a new context + | `Set Storage State` ${user_a} + | `Reload` + | `Get Text` id=current-user == userA + """ + if not path.is_file(): + raise ValueError( + f"path argument value '{path}' is not file, but it should be." + ) + with self.playwright.grpc_channel() as stub: + response = stub.SetStorageState( + Request().SetStorageState( + path=str(path), timeout=int(self.get_timeout(timeout)) + ) + ) + logger.info(response.log) + def set_peer_id(self, new_id) -> str: """Sets the peer_id for the current GRPC connection to browser's backend. diff --git a/atest/test/01_Browser_Management/storage_state.robot b/atest/test/01_Browser_Management/storage_state.robot index 1a7f6d913..127c69be3 100644 --- a/atest/test/01_Browser_Management/storage_state.robot +++ b/atest/test/01_Browser_Management/storage_state.robot @@ -3,6 +3,12 @@ Resource imports.resource Suite Setup New Browser headless=${HEADLESS} +*** Variables *** +${CUSTOM_STATE_DIR} = ${OUTPUT_DIR}/custom_state +${SEED_INDEXED_DB} = async () => { const req = indexedDB.open('rfdb', 1); req.onupgradeneeded = () => req.result.createObjectStore('kv'); const db = await new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); await new Promise((res, rej) => { const tx = db.transaction('kv', 'readwrite'); tx.objectStore('kv').put('token-abc', 'auth'); tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error); }); db.close(); } +${READ_INDEXED_DB} = async () => { const req = indexedDB.open('rfdb', 1); req.onupgradeneeded = () => req.result.createObjectStore('kv'); const db = await new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); const value = await new Promise((res, rej) => { const tx = db.transaction('kv', 'readonly'); const get = tx.objectStore('kv').get('auth'); get.onsuccess = () => res(get.result); get.onerror = () => rej(get.error); }); db.close(); return value === undefined ? '' : value; } +${DELETE_INDEXED_DB} = async () => { await new Promise((res, rej) => { const req = indexedDB.deleteDatabase('rfdb'); req.onsuccess = () => res(); req.onerror = () => rej(req.error); req.onblocked = () => rej(new Error('blocked')); }); } + *** Test Cases *** Save Storage State New Context @@ -10,7 +16,7 @@ Save Storage State Add Cookies For Storage ${STATE_FILE} = Save Storage State VAR ${STATE_FILE} = ${STATE_FILE} scope=SUITE - File Should Not Be Empty ${state_file} + File Should Not Be Empty ${STATE_FILE} Restore Storage State New Context storageState=${STATE_FILE} @@ -30,6 +36,132 @@ Restore Storage State With Invalid File ... SyntaxError*JSON* ... New Context storageState=${OUTPUT_DIR}/invalid_state_file.json +Save Storage State To Given Path + New Context + New Page ${LOGIN_URL} + Add Cookies For Storage + VAR ${target} = ${CUSTOM_STATE_DIR}/auth.json + ${returned} = Save Storage State ${target} + Should Be Equal ${returned} ${target} + File Should Not Be Empty ${target} + +Save Storage State To Given Path Overwrites Existing File + New Context + New Page ${LOGIN_URL} + VAR ${target} = ${CUSTOM_STATE_DIR}/overwritten.json + Create File ${target} not valid json + Save Storage State ${target} + ${content} = Get File ${target} + Should Not Be Equal ${content} not valid json + Should Contain ${content} cookies + +Set Storage State Restores Cookies And Local Storage + New Context + New Page ${LOGIN_URL} + Add Cookies For Storage + ${state} = Save Storage State + Delete All Cookies + Evaluate JavaScript ${None} localStorage.clear(); + ${cookies} = Get Cookies + Should Be Empty ${cookies} + Set Storage State ${state} + Reload + ${cookie} = Get Cookie Foo + Should Be Equal ${cookie.value} Bar + ${color} = Evaluate JavaScript ${None} localStorage.getItem('bgcolor'); + Should Be Equal ${color} red + +Set Storage State Keeps The Context And Its Pages Open + New Context + ${page} = New Page ${LOGIN_URL} + Add Cookies For Storage + ${state} = Save Storage State + Set Storage State ${state} + ${current} = Get Page Ids + Should Contain ${current} ${page}[page_id] + +Set Storage State With Invalid Path + Run Keyword And Expect Error + ... ValueError: path argument value '/not/here' is not file, but it should be. + ... Set Storage State /not/here + +Save Storage State With IndexedDB Restores IndexedDB + New Context + New Page ${LOGIN_URL} + Evaluate JavaScript ${None} ${SEED_INDEXED_DB} + ${state} = Save Storage State indexedDB=True + New Context + New Page ${LOGIN_URL} + ${value} = Evaluate JavaScript ${None} ${READ_INDEXED_DB} + Should Be Equal ${value} ${EMPTY} + Set Storage State ${state} + Reload + ${value} = Evaluate JavaScript ${None} ${READ_INDEXED_DB} + Should Be Equal ${value} token-abc + +Save Storage State Without IndexedDB Omits IndexedDB + New Context + New Page ${LOGIN_URL} + Evaluate JavaScript ${None} ${SEED_INDEXED_DB} + ${state} = Save Storage State + New Context + New Page ${LOGIN_URL} + Set Storage State ${state} + Reload + ${value} = Evaluate JavaScript ${None} ${READ_INDEXED_DB} + Should Be Equal ${value} ${EMPTY} + +Set Storage State Restores IndexedDB Into The Same Context + [Documentation] Disabled until https://github.com/microsoft/playwright/issues/42258 is fixed. + ... Save Storage State with indexedDB=True leaves an open IndexedDB connection in the + ... page, which blocks the restore, so Set Storage State never finishes here. Enable + ... this test, and remove the timeout from Set Storage State Times Out On IndexedDB + ... Held By An Open Page, once Playwright closes that connection. + [Tags] playwright-42258 + Skip Blocked by https://github.com/microsoft/playwright/issues/42258 + New Context + New Page ${LOGIN_URL} + Evaluate JavaScript ${None} ${SEED_INDEXED_DB} + ${state} = Save Storage State indexedDB=True + Evaluate JavaScript ${None} ${DELETE_INDEXED_DB} + Set Storage State ${state} + Reload + ${value} = Evaluate JavaScript ${None} ${READ_INDEXED_DB} + Should Be Equal ${value} token-abc + +Set Storage State Times Out On IndexedDB Held By An Open Page + [Documentation] Guards the timeout that works around + ... https://github.com/microsoft/playwright/issues/42258 + New Context + New Page ${LOGIN_URL} + Evaluate JavaScript ${None} ${SEED_INDEXED_DB} + ${state} = Save Storage State indexedDB=True + Run Keyword And Expect Error + ... *Set Storage State timed out after 3000 ms* + ... Set Storage State ${state} timeout=3s + +Save Storage State With Credentials Restores WebAuthn Credentials + New Context + New Page ${LOGIN_URL} + Create Credential rpId=localhost + ${credential} = Get Credential rpId=localhost + ${state} = Save Storage State credentials=True + Delete Credential ${credential}[id] + Set Storage State ${state} + ${restored} = Get Credential rpId=localhost + Should Be Equal ${restored}[id] ${credential}[id] + +Save Storage State Without Credentials Omits WebAuthn Credentials + New Context + New Page ${LOGIN_URL} + Create Credential rpId=localhost + Get Credential rpId=localhost + ${state} = Save Storage State + Set Storage State ${state} + Run Keyword And Expect Error + ... TypeError: Cannot read properties of undefined (reading 'id') + ... Get Credential rpId=localhost + *** Keywords *** Add Cookies For Storage ${url} = Get Url diff --git a/node/playwright-wrapper/grpc-service.ts b/node/playwright-wrapper/grpc-service.ts index 60c4131c8..16b90fcf7 100644 --- a/node/playwright-wrapper/grpc-service.ts +++ b/node/playwright-wrapper/grpc-service.ts @@ -257,7 +257,7 @@ export class PlaywrightServer { } async saveStorageState( - call: ServerUnaryCall, + call: ServerUnaryCall, callback: sendUnaryData, ): Promise { try { @@ -270,6 +270,20 @@ export class PlaywrightServer { } } + async setStorageState( + call: ServerUnaryCall, + callback: sendUnaryData, + ): Promise { + try { + const request = call.request; + if (request === null) throw Error('No request'); + const response = await playwrightState.setStorageState(request, this.getActiveBrowser(call)); + callback(null, response); + } catch (e) { + callback(errorResponse(e), null); + } + } + switchBrowser = this.wrapping(playwrightState.switchBrowser); newPage = this.wrapping(playwrightState.newPage); newContext = this.wrapping(playwrightState.newContext); diff --git a/node/playwright-wrapper/playwright-state.ts b/node/playwright-wrapper/playwright-state.ts index 4e97f0ff7..fec3e3c41 100644 --- a/node/playwright-wrapper/playwright-state.ts +++ b/node/playwright-wrapper/playwright-state.ts @@ -56,6 +56,8 @@ import { Request_KeywordCall, Request_PersistentContext, Request_RFContext, + Request_SetStorageState, + Request_StorageState, Request_TraceGroup, Request_UrlOptions, Response_Empty, @@ -1123,17 +1125,50 @@ export async function getErrorMessages(request: Request_Bool, openBrowsers: Play } export async function saveStorageState( - request: Request_FilePath, + request: Request_StorageState, browserState?: BrowserState, ): Promise { exists(browserState, "Tried to save storage state but browser wasn't open"); const context = browserState.context; exists(context, 'Tried to save storage state butno context was open'); const stateFile = request.path; - await context.c.storageState({ path: stateFile }); + await context.c.storageState({ + path: stateFile, + indexedDB: request.indexedDB, + credentials: request.credentials, + }); return emptyWithLog('Current context state is saved to: ' + stateFile); } +export async function setStorageState( + request: Request_SetStorageState, + browserState?: BrowserState, +): Promise { + exists(browserState, "Tried to set storage state but browser wasn't open"); + const context = browserState.context; + exists(context, 'Tried to set storage state but no context was open'); + const stateFile = request.path; + const timeout = request.timeout; + // https://github.com/microsoft/playwright/issues/42258: setStorageState never settles + // while a page of the context holds an open IndexedDB connection, and it does not honor + // the context timeout, so it needs a timeout of its own. + let timer: NodeJS.Timeout | undefined; + const timeoutMessage = + `Set Storage State timed out after ${timeout} ms. If the state file contains IndexedDB, ` + + 'reload or close the pages of the context, or set the state into a new context, before calling this keyword.'; + try { + await Promise.race([ + context.c.setStorageState(stateFile), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(timeoutMessage)), timeout); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + return emptyWithLog('Current context state is set from: ' + stateFile); +} + export async function startCoverage(request: Request_CoverageStart, state: PlaywrightState): Promise { const activePage = state.getActivePage(); exists(activePage, 'Could not find active page'); diff --git a/package-lock.json b/package-lock.json index f1865ecf4..1b630e749 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,6 +85,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -610,29 +611,6 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1786,7 +1764,6 @@ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -2205,6 +2182,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~8.3.0" } @@ -2229,6 +2207,7 @@ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2340,6 +2319,7 @@ "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/types": "8.67.0", @@ -2851,7 +2831,6 @@ "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" @@ -2862,24 +2841,21 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", @@ -2887,7 +2863,6 @@ "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", @@ -2899,8 +2874,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", @@ -2908,7 +2882,6 @@ "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -2922,7 +2895,6 @@ "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -2933,7 +2905,6 @@ "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -2943,8 +2914,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", @@ -2952,7 +2922,6 @@ "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -2970,7 +2939,6 @@ "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", @@ -2985,7 +2953,6 @@ "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -2999,7 +2966,6 @@ "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", @@ -3015,7 +2981,6 @@ "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" @@ -3026,16 +2991,14 @@ "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/abbrev": { "version": "3.0.1", @@ -3066,6 +3029,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3140,7 +3104,6 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -3159,7 +3122,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3176,8 +3138,7 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ansi-escapes": { "version": "4.3.2", @@ -3479,6 +3440,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", @@ -3661,7 +3623,6 @@ "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.0" } @@ -4056,7 +4017,6 @@ "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" @@ -4100,8 +4060,7 @@ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/es-object-atoms": { "version": "1.1.2", @@ -4123,6 +4082,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4209,6 +4169,7 @@ "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -4398,7 +4359,6 @@ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.x" } @@ -4535,8 +4495,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/fb-watchman": { "version": "2.0.2", @@ -5228,6 +5187,7 @@ "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -6138,7 +6098,6 @@ "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -6200,7 +6159,6 @@ "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -6216,7 +6174,6 @@ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -7033,6 +6990,7 @@ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7084,6 +7042,7 @@ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7170,7 +7129,6 @@ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7251,7 +7209,6 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -7290,7 +7247,6 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -7303,8 +7259,7 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "7.8.5", @@ -7693,7 +7648,6 @@ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" }, @@ -7735,7 +7689,6 @@ "integrity": "sha512-rGbJiKeQ4WDe3EXlDAIaQcwftVfv2Q8o1awFNfvXolJYKkb1AuZY1RTOmqx4LJXZENbWZA7eIsYGHuEzHsi1nQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -7754,8 +7707,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/terser/node_modules/source-map-support": { "version": "0.5.21", @@ -7763,7 +7715,6 @@ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -7973,6 +7924,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -8130,6 +8082,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8332,7 +8285,6 @@ "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2" }, @@ -8353,7 +8305,6 @@ "integrity": "sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", @@ -8398,7 +8349,6 @@ "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" } @@ -8409,7 +8359,6 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -8424,7 +8373,6 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } diff --git a/protobuf/playwright.proto b/protobuf/playwright.proto index f11abb43b..3af0f540f 100644 --- a/protobuf/playwright.proto +++ b/protobuf/playwright.proto @@ -121,6 +121,15 @@ message Request { message FilePath { string path = 1; } + message StorageState { + string path = 1; + bool indexedDB = 2; + bool credentials = 3; + } + message SetStorageState { + string path = 1; + int32 timeout = 2; + } message FileBySelector { repeated string path = 1; string selector = 2; @@ -641,7 +650,8 @@ service Playwright { rpc GetBrowserCatalog(Request.Bool) returns (Response.Json); rpc GetDownloadState(Request.DownloadID) returns (Response.Json); rpc CancelDownload(Request.DownloadID) returns (Response.Empty); - rpc SaveStorageState(Request.FilePath) returns (Response.Empty); + rpc SaveStorageState(Request.StorageState) returns (Response.Empty); + rpc SetStorageState(Request.SetStorageState) returns (Response.Empty); rpc GrantPermissions(Request.Permissions) returns (Response.Empty); rpc ExecutePlaywright(Request.Json) returns (Response.Empty); From 8442c970cc96bd006040825418bb002f4947049d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81?= Date: Sat, 15 Aug 2026 08:56:45 +0200 Subject: [PATCH 2/8] Resolve storage state paths and fix CI lock file Review fixes for #5117: - Resolve the path of Save Storage State and Set Storage State to an absolute one before sending it over grpc. The node wrapper runs with Browser/wrapper as its working directory, so a relative path was created in one directory and written in another, and the returned path pointed nowhere. Covered by a new acceptance test. - Skip the Set Storage State timeout when it resolves to zero, which conventionally means no timeout. - Do not leave the raced setStorageState promise unhandled, and say in the timeout message that the state can still be applied later, so the context should no longer be used. Restore package-lock.json to the version on main. It had lost its @emnapi entries, which broke npm ci and with it every CI job. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SuiwgM8Rt9kd6K7TBtFMDz --- Browser/keywords/playwright_state.py | 10 +- .../01_Browser_Management/storage_state.robot | 17 +++ node/playwright-wrapper/playwright-state.ts | 19 +++- package-lock.json | 102 +++++++++++++----- 4 files changed, 116 insertions(+), 32 deletions(-) diff --git a/Browser/keywords/playwright_state.py b/Browser/keywords/playwright_state.py index db653007e..6ece87b6a 100755 --- a/Browser/keywords/playwright_state.py +++ b/Browser/keywords/playwright_state.py @@ -1699,7 +1699,7 @@ def save_storage_state( shared with people outside of your organisation. | =Arguments= | =Description= | - | ``path`` | Where the state file is written. Relative paths are resolved against the current working directory and missing parent directories are created. If the file already exists, it is overwritten. If not given, a file with a generated name is created in ${OUTPUTDIR}/browser/state. | + | ``path`` | Where the state file is written. Relative paths are resolved against the current working directory and missing parent directories are created. If the file already exists, it is overwritten. If not given, a file with a generated name is created in ${OUTPUTDIR}/browser/state. The absolute path of the written file is returned. | | ``indexedDB`` | Also save IndexedDB. Needed by applications, like Firebase, which store authentication tokens in IndexedDB. | | ``credentials`` | Also save the context's virtual WebAuthn credentials, as created by `Create Credential`. This is not related to the ``httpCredentials`` argument of `New Context`, which is about HTTP authentication. | @@ -1729,7 +1729,11 @@ def save_storage_state( [https://forum.robotframework.org/t//4318|Comment >>] """ - state_file = path if path is not None else self.state_file / f"{uuid4()!s}.json" + state_file = ( + path.resolve() + if path is not None + else (self.state_file / f"{uuid4()!s}.json").resolve() + ) state_file.parent.mkdir(parents=True, exist_ok=True) log = self._save_storage_state(str(state_file), indexedDB, credentials) logger.info(log) @@ -1795,7 +1799,7 @@ def set_storage_state(self, path: Path, timeout: timedelta | None = None) -> Non with self.playwright.grpc_channel() as stub: response = stub.SetStorageState( Request().SetStorageState( - path=str(path), timeout=int(self.get_timeout(timeout)) + path=str(path.resolve()), timeout=int(self.get_timeout(timeout)) ) ) logger.info(response.log) diff --git a/atest/test/01_Browser_Management/storage_state.robot b/atest/test/01_Browser_Management/storage_state.robot index 127c69be3..de033aedc 100644 --- a/atest/test/01_Browser_Management/storage_state.robot +++ b/atest/test/01_Browser_Management/storage_state.robot @@ -5,6 +5,7 @@ Suite Setup New Browser headless=${HEADLESS} *** Variables *** ${CUSTOM_STATE_DIR} = ${OUTPUT_DIR}/custom_state +${RELATIVE_STATE_DIR} = relative_state_test ${SEED_INDEXED_DB} = async () => { const req = indexedDB.open('rfdb', 1); req.onupgradeneeded = () => req.result.createObjectStore('kv'); const db = await new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); await new Promise((res, rej) => { const tx = db.transaction('kv', 'readwrite'); tx.objectStore('kv').put('token-abc', 'auth'); tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error); }); db.close(); } ${READ_INDEXED_DB} = async () => { const req = indexedDB.open('rfdb', 1); req.onupgradeneeded = () => req.result.createObjectStore('kv'); const db = await new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); const value = await new Promise((res, rej) => { const tx = db.transaction('kv', 'readonly'); const get = tx.objectStore('kv').get('auth'); get.onsuccess = () => res(get.result); get.onerror = () => rej(get.error); }); db.close(); return value === undefined ? '' : value; } ${DELETE_INDEXED_DB} = async () => { await new Promise((res, rej) => { const req = indexedDB.deleteDatabase('rfdb'); req.onsuccess = () => res(); req.onerror = () => rej(req.error); req.onblocked = () => rej(new Error('blocked')); }); } @@ -45,6 +46,22 @@ Save Storage State To Given Path Should Be Equal ${returned} ${target} File Should Not Be Empty ${target} +Save And Set Storage State With A Relative Path + [Documentation] The node wrapper runs in its own working directory, so relative paths + ... must be resolved before they are sent over grpc. + New Context + New Page ${LOGIN_URL} + Add Cookies For Storage + VAR ${relative} = ${RELATIVE_STATE_DIR}/relative_auth.json + ${returned} = Save Storage State ${relative} + Should Be Equal ${returned} ${EXECDIR}/${relative} + File Should Not Be Empty ${EXECDIR}/${relative} + Delete All Cookies + Set Storage State ${relative} + ${cookie} = Get Cookie Foo + Should Be Equal ${cookie.value} Bar + [Teardown] Remove Directory ${EXECDIR}/${RELATIVE_STATE_DIR} recursive=True + Save Storage State To Given Path Overwrites Existing File New Context New Page ${LOGIN_URL} diff --git a/node/playwright-wrapper/playwright-state.ts b/node/playwright-wrapper/playwright-state.ts index fec3e3c41..a7d434041 100644 --- a/node/playwright-wrapper/playwright-state.ts +++ b/node/playwright-wrapper/playwright-state.ts @@ -1149,20 +1149,31 @@ export async function setStorageState( exists(context, 'Tried to set storage state but no context was open'); const stateFile = request.path; const timeout = request.timeout; + const restore = context.c.setStorageState(stateFile); + if (timeout <= 0) { + await restore; + return emptyWithLog('Current context state is set from: ' + stateFile); + } // https://github.com/microsoft/playwright/issues/42258: setStorageState never settles // while a page of the context holds an open IndexedDB connection, and it does not honor - // the context timeout, so it needs a timeout of its own. + // the context timeout, so it needs a timeout of its own. Racing does not cancel the + // restore, so it may still be applied once the page releases the connection. let timer: NodeJS.Timeout | undefined; const timeoutMessage = - `Set Storage State timed out after ${timeout} ms. If the state file contains IndexedDB, ` + - 'reload or close the pages of the context, or set the state into a new context, before calling this keyword.'; + `Set Storage State timed out after ${timeout} ms and the state was not restored. If the ` + + 'state file contains IndexedDB, reload or close the pages of the context, or set the state ' + + 'into a new context, before calling this keyword. This context may still have the state ' + + 'applied to it later on, so it should not be used anymore.'; try { await Promise.race([ - context.c.setStorageState(stateFile), + restore, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(timeoutMessage)), timeout); }), ]); + } catch (error) { + restore.catch(() => {}); + throw error; } finally { if (timer) clearTimeout(timer); } diff --git a/package-lock.json b/package-lock.json index 1b630e749..f1865ecf4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,7 +85,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -611,6 +610,29 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1764,6 +1786,7 @@ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -2182,7 +2205,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~8.3.0" } @@ -2207,7 +2229,6 @@ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2319,7 +2340,6 @@ "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/types": "8.67.0", @@ -2831,6 +2851,7 @@ "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" @@ -2841,21 +2862,24 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", @@ -2863,6 +2887,7 @@ "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", @@ -2874,7 +2899,8 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", @@ -2882,6 +2908,7 @@ "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -2895,6 +2922,7 @@ "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -2905,6 +2933,7 @@ "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -2914,7 +2943,8 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", @@ -2922,6 +2952,7 @@ "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -2939,6 +2970,7 @@ "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", @@ -2953,6 +2985,7 @@ "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -2966,6 +2999,7 @@ "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", @@ -2981,6 +3015,7 @@ "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" @@ -2991,14 +3026,16 @@ "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/abbrev": { "version": "3.0.1", @@ -3029,7 +3066,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3104,6 +3140,7 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -3122,6 +3159,7 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3138,7 +3176,8 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ansi-escapes": { "version": "4.3.2", @@ -3440,7 +3479,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", @@ -3623,6 +3661,7 @@ "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.0" } @@ -4017,6 +4056,7 @@ "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" @@ -4060,7 +4100,8 @@ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/es-object-atoms": { "version": "1.1.2", @@ -4082,7 +4123,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4169,7 +4209,6 @@ "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -4359,6 +4398,7 @@ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.8.x" } @@ -4495,7 +4535,8 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/fb-watchman": { "version": "2.0.2", @@ -5187,7 +5228,6 @@ "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -6098,6 +6138,7 @@ "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -6159,6 +6200,7 @@ "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -6174,6 +6216,7 @@ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -6990,7 +7033,6 @@ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7042,7 +7084,6 @@ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7129,6 +7170,7 @@ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7209,6 +7251,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -7247,6 +7290,7 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -7259,7 +7303,8 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/semver": { "version": "7.8.5", @@ -7648,6 +7693,7 @@ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" }, @@ -7689,6 +7735,7 @@ "integrity": "sha512-rGbJiKeQ4WDe3EXlDAIaQcwftVfv2Q8o1awFNfvXolJYKkb1AuZY1RTOmqx4LJXZENbWZA7eIsYGHuEzHsi1nQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -7707,7 +7754,8 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/terser/node_modules/source-map-support": { "version": "0.5.21", @@ -7715,6 +7763,7 @@ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -7924,7 +7973,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -8082,7 +8130,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8285,6 +8332,7 @@ "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2" }, @@ -8305,6 +8353,7 @@ "integrity": "sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", @@ -8349,6 +8398,7 @@ "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.13.0" } @@ -8359,6 +8409,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -8373,6 +8424,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } From ac993866f95a2edf03b000297b336090315826ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81?= Date: Sat, 15 Aug 2026 09:47:41 +0200 Subject: [PATCH 3/8] Declare the skipped test and resolve New Context storage state path The acceptance test that is disabled until playwright#42258 is fixed called Skip without declaring it in its documentation. robotstatuschecker derives the expected status from the documentation and defaults to PASS, so it turned the skip into a failure and every job running the acceptance tests ended with rc=1, even though Robot Framework itself reported no failures. New Context validated its storageState argument against the Robot Framework working directory but sent the path on unresolved, while the node wrapper runs with Browser/wrapper as its working directory. A relative path therefore passed validation and then failed in the node process with a raw ENOENT. Resolve it like Save Storage State and Set Storage State already do, and extend the relative path test to cover all three keywords. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SuiwgM8Rt9kd6K7TBtFMDz --- Browser/keywords/playwright_state.py | 12 +++++++----- atest/test/01_Browser_Management/storage_state.robot | 7 ++++++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/Browser/keywords/playwright_state.py b/Browser/keywords/playwright_state.py index 6ece87b6a..f9081778a 100755 --- a/Browser/keywords/playwright_state.py +++ b/Browser/keywords/playwright_state.py @@ -651,7 +651,7 @@ def new_context( | ``reducedMotion`` | Emulates the ``prefers-reduced-motion`` media feature, supported values are ``reduce`` and ``no-preference``. Defaults to ``no-preference``. | | ``screen`` | Emulates consistent window screen size available inside web page via window.screen. Is only used when the viewport is set. Example {'width': 414, 'height': 896} | | ``serviceWorkers`` | Whether to allow sites to register Service workers. Defaults to ``allow``. | - | ``storageState`` | Restores the storage state created by the `Save Storage State` keyword. Must be a full path to an existing file, otherwise the keyword fails. | + | ``storageState`` | Restores the storage state created by the `Save Storage State` keyword. Must be a path to an existing file, otherwise the keyword fails. Relative paths are resolved against the current working directory. | | ``timezoneId`` | Changes the timezone of the context. See [https://source.chromium.org/chromium/chromium/src/+/master:third_party/icu/source/data/misc/metaZones.txt|ICU`s metaZones.txt] for a list of supported timezone IDs. | | ``tracing`` | Boolean ``True`` (recommendation) or file path or directory where the [https://playwright.dev/docs/api/class-tracing/|tracing] file is saved. The string ``{contextid}`` will be replaced with the context id. Path to *.zip files can be absolute or relative to ${OUTPUT_DIR}. Path to folders can be absolute or relative to ${OUTPUT_DIR}/browser/traces. If boolean ``True`` or a directory is given, the trace file will automatically be named ``trace_{contextid}.zip``. Temporary trace files will be saved to ${OUTPUT_DIR}/browser/traces/temp. Tracing is automatically closed when context is closed. Temporary trace files will be automatically deleted at start of each test execution. Trace file can be opened after the test execution by running command from shell: ``rfbrowser show-trace /path/to/trace.zip``. Tracing can also be enabled by setting a Robot Framework variable or environment variable ``ROBOT_FRAMEWORK_BROWSER_TRACING`` to ``True``. | | ``userAgent`` | Specific user agent to use in this context. | @@ -886,10 +886,12 @@ def _set_context_options(self, params, httpCredentials, storageState): reduced_motion = str(params.get("reducedMotion")) reduced_motion = reduced_motion.replace("_", "-") params["reducedMotion"] = reduced_motion - if storageState and not storageState.is_file(): - raise ValueError( - f"storageState argument value '{storageState}' is not file, but it should be." - ) + if storageState: + if not storageState.is_file(): + raise ValueError( + f"storageState argument value '{storageState}' is not file, but it should be." + ) + params["storageState"] = storageState.resolve() if "httpCredentials" in params and params["httpCredentials"] is not None: secret = self.resolve_secret(httpCredentials, "httpCredentials") params["httpCredentials"] = secret diff --git a/atest/test/01_Browser_Management/storage_state.robot b/atest/test/01_Browser_Management/storage_state.robot index de033aedc..c28ca6062 100644 --- a/atest/test/01_Browser_Management/storage_state.robot +++ b/atest/test/01_Browser_Management/storage_state.robot @@ -46,7 +46,7 @@ Save Storage State To Given Path Should Be Equal ${returned} ${target} File Should Not Be Empty ${target} -Save And Set Storage State With A Relative Path +Storage State Keywords Accept A Relative Path [Documentation] The node wrapper runs in its own working directory, so relative paths ... must be resolved before they are sent over grpc. New Context @@ -60,6 +60,9 @@ Save And Set Storage State With A Relative Path Set Storage State ${relative} ${cookie} = Get Cookie Foo Should Be Equal ${cookie.value} Bar + New Context storageState=${relative} + ${cookie} = Get Cookie Key + Should Be Equal ${cookie.value} Value [Teardown] Remove Directory ${EXECDIR}/${RELATIVE_STATE_DIR} recursive=True Save Storage State To Given Path Overwrites Existing File @@ -134,6 +137,8 @@ Set Storage State Restores IndexedDB Into The Same Context ... page, which blocks the restore, so Set Storage State never finishes here. Enable ... this test, and remove the timeout from Set Storage State Times Out On IndexedDB ... Held By An Open Page, once Playwright closes that connection. + ... + ... SKIP Blocked by https://github.com/microsoft/playwright/issues/42258 [Tags] playwright-42258 Skip Blocked by https://github.com/microsoft/playwright/issues/42258 New Context From add4f0eb7270ee6d950b4b0aeb42edc589bf8298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81?= Date: Sat, 15 Aug 2026 10:40:29 +0200 Subject: [PATCH 4/8] Make the storage state tests platform independent The suite failed on Windows with four tests, all of them assertions that assumed forward slashes. Save Storage State returns the resolved path, which uses the native separator, while the expected values were built from Robot Framework variables with forward slashes. Normalize the expected values before comparing them. The two invalid path tests matched the full error message, which now contains a platform native separator because storageState and path are Path arguments rather than strings. Match the separator with the glob wildcard the expected error already supports. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SuiwgM8Rt9kd6K7TBtFMDz --- atest/test/01_Browser_Management/storage_state.robot | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/atest/test/01_Browser_Management/storage_state.robot b/atest/test/01_Browser_Management/storage_state.robot index c28ca6062..bc5b99e0b 100644 --- a/atest/test/01_Browser_Management/storage_state.robot +++ b/atest/test/01_Browser_Management/storage_state.robot @@ -28,7 +28,7 @@ Restore Storage State Restore Storage State With Invalid Path Run Keyword And Expect Error - ... ValueError: storageState argument value '/not/here' is not file, but it should be. + ... ValueError: storageState argument value '?not?here' is not file, but it should be. ... New Context storageState=/not/here Restore Storage State With Invalid File @@ -43,7 +43,8 @@ Save Storage State To Given Path Add Cookies For Storage VAR ${target} = ${CUSTOM_STATE_DIR}/auth.json ${returned} = Save Storage State ${target} - Should Be Equal ${returned} ${target} + ${expected} = Normalize Path ${target} + Should Be Equal ${returned} ${expected} File Should Not Be Empty ${target} Storage State Keywords Accept A Relative Path @@ -54,7 +55,8 @@ Storage State Keywords Accept A Relative Path Add Cookies For Storage VAR ${relative} = ${RELATIVE_STATE_DIR}/relative_auth.json ${returned} = Save Storage State ${relative} - Should Be Equal ${returned} ${EXECDIR}/${relative} + ${expected} = Normalize Path ${EXECDIR}/${relative} + Should Be Equal ${returned} ${expected} File Should Not Be Empty ${EXECDIR}/${relative} Delete All Cookies Set Storage State ${relative} @@ -102,7 +104,7 @@ Set Storage State Keeps The Context And Its Pages Open Set Storage State With Invalid Path Run Keyword And Expect Error - ... ValueError: path argument value '/not/here' is not file, but it should be. + ... ValueError: path argument value '?not?here' is not file, but it should be. ... Set Storage State /not/here Save Storage State With IndexedDB Restores IndexedDB From 2800037bbe46d7031fb4ce36128a092390b3587d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81?= Date: Sun, 16 Aug 2026 14:31:56 +0200 Subject: [PATCH 5/8] Add reload_pages to Set Storage State Restoring IndexedDB deletes the databases of the origin, which does not finish while any client of that origin holds an open connection to them. Applications which keep their connection open, the normal pattern when authentication tokens live in IndexedDB, therefore blocked the restore indefinitely. Set Storage State now navigates the pages of the affected origins to about:blank, restores the state, and navigates them back to the url they had before. The new reload_pages argument selects which pages that applies to: affected, the default, uses the origins which carry IndexedDB in the state file, all takes every page, and none navigates nothing. With reload_pages=none the keyword probes for open connections and fails immediately, naming the origin and the databases, instead of waiting for the timeout. The probe asks for a version upgrade and aborts it again, which leaves the databases untouched. On every failure after the pages were detached they are navigated back before the error is raised, so that catching the error does not leave the context on about:blank. Failures of that navigation are collected and appended to the original error rather than replacing it. Navigating back uses the library timeout, not the timeout argument, which belongs to the restore. A service worker holds its connection independently of the pages, so no value of reload_pages helps against it and the timeout remains as the last guard. Its message now explains that Playwright does not cancel the restore, so the state can still be applied later on. Also documents that sessionStorage is not part of a storage state and survives the keyword unchanged. --- Browser/keywords/playwright_state.py | 62 +++++--- Browser/utils/__init__.py | 1 + Browser/utils/data_types.py | 19 +++ .../01_Browser_Management/storage_state.robot | 74 +++++++++- node/dynamic-test-app/static/idb-holder-sw.js | 20 +++ node/playwright-wrapper/playwright-state.ts | 135 +++++++++++++++--- protobuf/playwright.proto | 2 + 7 files changed, 272 insertions(+), 41 deletions(-) create mode 100644 node/dynamic-test-app/static/idb-holder-sw.js diff --git a/Browser/keywords/playwright_state.py b/Browser/keywords/playwright_state.py index f9081778a..5ea9230c8 100755 --- a/Browser/keywords/playwright_state.py +++ b/Browser/keywords/playwright_state.py @@ -46,6 +46,7 @@ RecordHar, RecordVideo, ReduceMotion, + ReloadPages, SelectionType, ServiceWorkersPermissions, SupportedBrowsers, @@ -1753,7 +1754,12 @@ def _save_storage_state( return response.log @keyword(tags=("Setter", "BrowserControl")) - def set_storage_state(self, path: Path, timeout: timedelta | None = None) -> None: + def set_storage_state( + self, + path: Path, + timeout: timedelta | None = None, + reload_pages: ReloadPages = ReloadPages.affected, + ) -> None: """Restores a storage state file into the current active context. Clears the cookies, local storage, IndexedDB and virtual WebAuthn @@ -1764,35 +1770,50 @@ def set_storage_state(self, path: Path, timeout: timedelta | None = None) -> Non Pages that are already open keep the state they have loaded into memory. Reload them, with the `Reload` keyword, to make them see the restored - state. + state. Cookies and local storage are readable right away, without a + reload, but the application has read them long ago. + + Note that ``sessionStorage`` is not part of a storage state, neither + when saving nor when restoring. It survives this keyword unchanged, so + an application which keeps data of the previous user there still has it + after the state was replaced. | =Arguments= | =Description= | | ``path`` | Path to a state file created by `Save Storage State`. Relative paths are resolved against the current working directory. The keyword fails if the file does not exist. | | ``timeout`` | Time to wait for the state to be restored. If not defined, the library default timeout is used. | + | ``reload_pages`` | Which pages are reloaded while the state is restored, see `ReloadPages`. Only relevant when the state file contains IndexedDB. | == Restoring IndexedDB == - `Save Storage State` with ``indexedDB=True`` leaves an open IndexedDB - connection in the page, which is a - [https://github.com/microsoft/playwright/issues/42258|Playwright bug]. - Restoring a state file that contains IndexedDB into a context whose - pages still hold such a connection never finishes, and this keyword - fails when ``timeout`` expires. Avoid that by either reloading the - pages of the context before calling this keyword, or by restoring the - state into a `New Context`. + Restoring IndexedDB deletes the databases of the origin first, and that + does not finish while any client of that origin holds an open + connection to them. An application which keeps its connection open, + which is the normal pattern when authentication tokens are stored in + IndexedDB, therefore blocks the restore indefinitely. Playwright does + not time out on its own, see + [https://github.com/microsoft/playwright/issues/42258|playwright#42258]. + + This keyword works around that by navigating the pages of the affected + origins to ``about:blank``, restoring the state, and navigating them + back to the url they had before. Use ``reload_pages`` to control which + pages that applies to. A reload is needed in any case, because deleting + the databases closes the connection of the application as well. + + A service worker of the origin can hold a connection open too, and no + value of ``reload_pages`` helps against that, because a service worker + outlives the pages. The keyword then fails when ``timeout`` expires. Example: - | Test Case - | `New Context` - | `New Page` https://login.page.html + | `New Context` + | `New Page` https://login.page.html | # Perform login as first user - | ${user_a} = `Save Storage State` + | ${user_a} = `Save Storage State` indexedDB=True | # Perform login as second user - | ${user_b} = `Save Storage State` + | ${user_b} = `Save Storage State` indexedDB=True | # Switch back to the first user without creating a new context - | `Set Storage State` ${user_a} - | `Reload` - | `Get Text` id=current-user == userA + | `Set Storage State` ${user_a} + | `Reload` + | `Get Text` id=current-user == userA """ if not path.is_file(): raise ValueError( @@ -1801,7 +1822,10 @@ def set_storage_state(self, path: Path, timeout: timedelta | None = None) -> Non with self.playwright.grpc_channel() as stub: response = stub.SetStorageState( Request().SetStorageState( - path=str(path.resolve()), timeout=int(self.get_timeout(timeout)) + path=str(path.resolve()), + timeout=int(self.get_timeout(timeout)), + reloadPages=reload_pages.name, + navigationTimeout=int(self.get_timeout(None)), ) ) logger.info(response.log) diff --git a/Browser/utils/__init__.py b/Browser/utils/__init__.py index dc3ec0952..c7e37ed98 100644 --- a/Browser/utils/__init__.py +++ b/Browser/utils/__init__.py @@ -52,6 +52,7 @@ RecordHar, RecordVideo, ReduceMotion, + ReloadPages, ReducedMotion, RequestMethod, Scale, diff --git a/Browser/utils/data_types.py b/Browser/utils/data_types.py index bb6799b2a..d09519a72 100644 --- a/Browser/utils/data_types.py +++ b/Browser/utils/data_types.py @@ -1412,6 +1412,25 @@ class CoverageType(Enum): all = auto() +class ReloadPages(Enum): + """Defines which pages `Set Storage State` reloads while it restores the state. + + Restoring a state file that contains IndexedDB does not finish while a page + of the context holds an open connection to a database of that origin. To get + around that, the pages are navigated to ``about:blank``, the state is + restored, and they are navigated back to the url they had before. + + ``affected``: Reloads the pages whose origin has IndexedDB in the state file. + ``none``: Reloads nothing. The keyword fails immediately when it detects an + open connection which would block the restore. + ``all``: Reloads every page of the context. + """ + + affected = auto() + none = auto() + all = auto() + + class ClockType(Enum): """Defines how time is set. diff --git a/atest/test/01_Browser_Management/storage_state.robot b/atest/test/01_Browser_Management/storage_state.robot index bc5b99e0b..aa4308d2e 100644 --- a/atest/test/01_Browser_Management/storage_state.robot +++ b/atest/test/01_Browser_Management/storage_state.robot @@ -6,8 +6,10 @@ Suite Setup New Browser headless=${HEADLESS} *** Variables *** ${CUSTOM_STATE_DIR} = ${OUTPUT_DIR}/custom_state ${RELATIVE_STATE_DIR} = relative_state_test +${OTHER_ORIGIN_URL} = http://127.0.0.1:${SERVER_PORT}/dist/ ${SEED_INDEXED_DB} = async () => { const req = indexedDB.open('rfdb', 1); req.onupgradeneeded = () => req.result.createObjectStore('kv'); const db = await new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); await new Promise((res, rej) => { const tx = db.transaction('kv', 'readwrite'); tx.objectStore('kv').put('token-abc', 'auth'); tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error); }); db.close(); } ${READ_INDEXED_DB} = async () => { const req = indexedDB.open('rfdb', 1); req.onupgradeneeded = () => req.result.createObjectStore('kv'); const db = await new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); const value = await new Promise((res, rej) => { const tx = db.transaction('kv', 'readonly'); const get = tx.objectStore('kv').get('auth'); get.onsuccess = () => res(get.result); get.onerror = () => rej(get.error); }); db.close(); return value === undefined ? '' : value; } +${HOLD_INDEXED_DB} = async () => { const req = indexedDB.open('rfdb', 1); req.onupgradeneeded = () => req.result.createObjectStore('kv'); const db = await new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); await new Promise((res, rej) => { const tx = db.transaction('kv', 'readwrite'); tx.objectStore('kv').put('token-abc', 'auth'); tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error); }); window.__db = db; } ${DELETE_INDEXED_DB} = async () => { await new Promise((res, rej) => { const req = indexedDB.deleteDatabase('rfdb'); req.onsuccess = () => res(); req.onerror = () => rej(req.error); req.onblocked = () => rej(new Error('blocked')); }); } *** Test Cases *** @@ -153,16 +155,72 @@ Set Storage State Restores IndexedDB Into The Same Context ${value} = Evaluate JavaScript ${None} ${READ_INDEXED_DB} Should Be Equal ${value} token-abc -Set Storage State Times Out On IndexedDB Held By An Open Page - [Documentation] Guards the timeout that works around +Set Storage State Times Out On A Service Worker Holding IndexedDB + [Documentation] A service worker outlives the pages, so no value of reload_pages frees + ... the connection. This is the case which can only end in the timeout, see ... https://github.com/microsoft/playwright/issues/42258 New Context New Page ${LOGIN_URL} - Evaluate JavaScript ${None} ${SEED_INDEXED_DB} + Evaluate JavaScript ${None} ${HOLD_INDEXED_DB} + ${state} = Save Storage State indexedDB=True + New Context + New Page ${LOGIN_URL} + Register IndexedDB Holding Service Worker + Run Keyword And Expect Error + ... *timed out after 3000 ms*service worker*still running* + ... Set Storage State ${state} timeout=3s reload_pages=all + +Set Storage State Reloads The Affected Pages + [Documentation] The page holds an open IndexedDB connection, which blocks the restore + ... until the keyword navigates the page away and back again. + New Context + New Page ${LOGIN_URL} + Evaluate JavaScript ${None} ${HOLD_INDEXED_DB} + ${state} = Save Storage State indexedDB=True + New Context + ${page} = New Page ${LOGIN_URL} + Evaluate JavaScript ${None} ${DELETE_INDEXED_DB} + Evaluate JavaScript ${None} ${HOLD_INDEXED_DB} + Set Storage State ${state} + ${ids} = Get Page Ids + Should Contain ${ids} ${page}[page_id] + ${url} = Get Url + Should Be Equal ${url} ${LOGIN_URL} + ${value} = Evaluate JavaScript ${None} ${READ_INDEXED_DB} + Should Be Equal ${value} token-abc + +Set Storage State Fails Fast With Reload Pages None + New Context + New Page ${LOGIN_URL} + Evaluate JavaScript ${None} ${HOLD_INDEXED_DB} ${state} = Save Storage State indexedDB=True + New Context + New Page ${LOGIN_URL} + Evaluate JavaScript ${None} ${HOLD_INDEXED_DB} + ${start} = Get Time epoch Run Keyword And Expect Error - ... *Set Storage State timed out after 3000 ms* - ... Set Storage State ${state} timeout=3s + ... *holds an open connection to the IndexedDB database(s) rfdb*reload_pages=affected* + ... Set Storage State ${state} reload_pages=none + ${end} = Get Time epoch + Should Be True ${end} - ${start} < 5 Fail fast should not wait for the timeout + +Set Storage State Leaves Pages Of Other Origins Alone + [Documentation] Only the origins which carry IndexedDB in the state file can block the + ... restore, so pages of any other origin must not be touched. + New Context + New Page ${LOGIN_URL} + Evaluate JavaScript ${None} ${HOLD_INDEXED_DB} + ${state} = Save Storage State indexedDB=True + New Context + New Page ${LOGIN_URL} + ${other} = New Page ${OTHER_ORIGIN_URL} + Evaluate JavaScript ${None} ${HOLD_INDEXED_DB} + Set Storage State ${state} + Switch Page ${other}[page_id] + ${url} = Get Url + Should Be Equal ${url} ${OTHER_ORIGIN_URL} + ${still_held} = Evaluate JavaScript ${None} () => !!window.__db + Should Be True ${still_held} The page of the other origin was reloaded Save Storage State With Credentials Restores WebAuthn Credentials New Context @@ -187,6 +245,12 @@ Save Storage State Without Credentials Omits WebAuthn Credentials ... Get Credential rpId=localhost *** Keywords *** +Register IndexedDB Holding Service Worker + Evaluate JavaScript + ... ${None} + ... async () => { const reg = await navigator.serviceWorker.register('/idb-holder-sw.js'); await navigator.serviceWorker.ready; return reg.scope; } + Sleep 1s reason=let the worker open its connection + Add Cookies For Storage ${url} = Get Url Add Cookie Foo Bar url=${url} diff --git a/node/dynamic-test-app/static/idb-holder-sw.js b/node/dynamic-test-app/static/idb-holder-sw.js new file mode 100644 index 000000000..de4a2502f --- /dev/null +++ b/node/dynamic-test-app/static/idb-holder-sw.js @@ -0,0 +1,20 @@ +// Opens the 'rfdb' IndexedDB database and never closes the connection, so that a +// versionchange, as caused by deleting the database, stays blocked. Used to test that +// Set Storage State reports a service worker which blocks restoring the storage state. +let heldConnection; + +self.addEventListener('install', () => self.skipWaiting()); + +self.addEventListener('activate', (event) => + event.waitUntil( + (async () => { + await self.clients.claim(); + heldConnection = await new Promise((resolve, reject) => { + const request = indexedDB.open('rfdb', 1); + request.onupgradeneeded = () => request.result.createObjectStore('kv'); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + })(), + ), +); diff --git a/node/playwright-wrapper/playwright-state.ts b/node/playwright-wrapper/playwright-state.ts index a7d434041..b8da8d878 100644 --- a/node/playwright-wrapper/playwright-state.ts +++ b/node/playwright-wrapper/playwright-state.ts @@ -1140,6 +1140,48 @@ export async function saveStorageState( return emptyWithLog('Current context state is saved to: ' + stateFile); } +function indexedDbOrigins(stateFile: string): string[] { + try { + const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); + return (state.origins ?? []) + .filter((o: { indexedDB?: unknown[] }) => (o.indexedDB?.length ?? 0) > 0) + .map((o: { origin: string }) => o.origin); + } catch { + // Let setStorageState report an unreadable state file itself. + return []; + } +} + +function originOf(url: string): string | null { + try { + return new URL(url).origin; + } catch { + return null; + } +} + +// Asks for a version upgrade of every database of the origin and aborts it again. Only a +// client which holds an open connection makes that request fire 'blocked', so this reports +// exactly what stops setStorageState from finishing. Aborting in 'upgradeneeded' keeps the +// databases untouched. Must never run while a restore is pending: that crashes the browser. +const OPEN_CONNECTIONS_PROBE = `async () => { + if (!indexedDB.databases) return null; + const blocked = []; + for (const { name, version } of await indexedDB.databases()) { + const isBlocked = await new Promise((resolve) => { + const request = indexedDB.open(name, (version || 1) + 1); + let settled = false; + const done = (value) => { if (!settled) { settled = true; resolve(value); } }; + request.onblocked = () => done(true); + request.onupgradeneeded = () => { request.transaction.abort(); }; + request.onerror = (event) => { event.preventDefault(); done(false); }; + request.onsuccess = () => { request.result.close(); done(false); }; + }); + if (isBlocked) blocked.push(name); + } + return blocked; +}`; + export async function setStorageState( request: Request_SetStorageState, browserState?: BrowserState, @@ -1149,26 +1191,86 @@ export async function setStorageState( exists(context, 'Tried to set storage state but no context was open'); const stateFile = request.path; const timeout = request.timeout; - const restore = context.c.setStorageState(stateFile); - if (timeout <= 0) { - await restore; - return emptyWithLog('Current context state is set from: ' + stateFile); - } - // https://github.com/microsoft/playwright/issues/42258: setStorageState never settles - // while a page of the context holds an open IndexedDB connection, and it does not honor - // the context timeout, so it needs a timeout of its own. Racing does not cancel the - // restore, so it may still be applied once the page releases the connection. + const origins = indexedDbOrigins(stateFile); + const pages = context.c.pages().filter((page) => !page.isClosed()); + const affected = + request.reloadPages === 'all' + ? pages.filter((page) => originOf(page.url()) !== null) + : pages.filter((page) => origins.includes(originOf(page.url()) ?? '')); + + if (origins.length > 0 && request.reloadPages === 'none') { + for (const page of affected) { + const blocked: string[] | null = await page.evaluate(`(${OPEN_CONNECTIONS_PROBE})()`); + if (blocked?.length) { + throw new Error( + `Set Storage State cannot restore the state of ${originOf(page.url())}, because a client of ` + + `that origin holds an open connection to the IndexedDB database(s) ${blocked.join(', ')}. ` + + 'Restoring deletes those databases, which does not finish while a connection is open. ' + + 'Use reload_pages=affected to let this keyword navigate the pages away and back.', + ); + } + } + } + + const detached: { page: Page; url: string }[] = []; + if (origins.length > 0 && request.reloadPages !== 'none') { + for (const page of affected) { + const url = page.url(); + await page.goto('about:blank'); + detached.push({ page, url }); + } + } + + // Navigation only. Anything which touches IndexedDB from the page crashes the browser + // while a restore is pending, so the pages are never probed on this path. + const reattach = async (): Promise => { + const failed: string[] = []; + for (const { page, url } of detached) { + if (page.isClosed()) continue; + try { + await page.goto(url, { timeout: request.navigationTimeout }); + } catch { + failed.push(url); + } + } + return failed; + }; + + try { + await withTimeout(context.c.setStorageState(stateFile), timeout, stateFile); + } catch (error) { + const failed = await reattach(); + if (failed.length) { + throw new Error(`${(error as Error).message}\nThese pages were not restored: ${failed.join(', ')}`, { + cause: error, + }); + } + throw error; + } + const failed = await reattach(); + if (failed.length) { + throw new Error(`The state was restored, but these pages were not navigated back: ${failed.join(', ')}`); + } + return emptyWithLog('Current context state is set from: ' + stateFile); +} + +// https://github.com/microsoft/playwright/issues/42258: setStorageState never settles while a +// client of the origin holds an open IndexedDB connection, and it does not honor the context +// timeout. Racing does not cancel it, so it may still be applied later on. +async function withTimeout(restore: Promise, timeout: number, stateFile: string): Promise { + if (timeout <= 0) return restore; let timer: NodeJS.Timeout | undefined; - const timeoutMessage = - `Set Storage State timed out after ${timeout} ms and the state was not restored. If the ` + - 'state file contains IndexedDB, reload or close the pages of the context, or set the state ' + - 'into a new context, before calling this keyword. This context may still have the state ' + - 'applied to it later on, so it should not be used anymore.'; + const message = + `Set Storage State timed out after ${timeout} ms and the state of ${stateFile} was not restored. ` + + 'A client of the origin, a page or a service worker, holds an open IndexedDB connection which ' + + 'blocks the restore. Playwright does not cancel the restore, so it is still running and applies ' + + 'the state as soon as that connection closes, even long after this failure. Do not keep using ' + + 'this context after catching this error, its storage state can change at any time.'; try { await Promise.race([ restore, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(timeoutMessage)), timeout); + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeout); }), ]); } catch (error) { @@ -1177,7 +1279,6 @@ export async function setStorageState( } finally { if (timer) clearTimeout(timer); } - return emptyWithLog('Current context state is set from: ' + stateFile); } export async function startCoverage(request: Request_CoverageStart, state: PlaywrightState): Promise { diff --git a/protobuf/playwright.proto b/protobuf/playwright.proto index 3af0f540f..c2f18ab0b 100644 --- a/protobuf/playwright.proto +++ b/protobuf/playwright.proto @@ -129,6 +129,8 @@ message Request { message SetStorageState { string path = 1; int32 timeout = 2; + string reloadPages = 3; + int32 navigationTimeout = 4; } message FileBySelector { repeated string path = 1; From 0715223f807810a9af082d664038ced17aa93bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81?= Date: Sun, 16 Aug 2026 20:00:42 +0200 Subject: [PATCH 6/8] Keep the relative path test off the working directory The test wrote its state file to a directory relative to the working directory. In the docker image that is the root directory, so the run failed with a PermissionError and took the docker_image job with it. The relative path now points into the output directory, expressed relative to the working directory, which keeps the path under test relative while writing somewhere that is writable everywhere. --- .../test/01_Browser_Management/storage_state.robot | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/atest/test/01_Browser_Management/storage_state.robot b/atest/test/01_Browser_Management/storage_state.robot index aa4308d2e..0114108be 100644 --- a/atest/test/01_Browser_Management/storage_state.robot +++ b/atest/test/01_Browser_Management/storage_state.robot @@ -5,7 +5,6 @@ Suite Setup New Browser headless=${HEADLESS} *** Variables *** ${CUSTOM_STATE_DIR} = ${OUTPUT_DIR}/custom_state -${RELATIVE_STATE_DIR} = relative_state_test ${OTHER_ORIGIN_URL} = http://127.0.0.1:${SERVER_PORT}/dist/ ${SEED_INDEXED_DB} = async () => { const req = indexedDB.open('rfdb', 1); req.onupgradeneeded = () => req.result.createObjectStore('kv'); const db = await new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); await new Promise((res, rej) => { const tx = db.transaction('kv', 'readwrite'); tx.objectStore('kv').put('token-abc', 'auth'); tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error); }); db.close(); } ${READ_INDEXED_DB} = async () => { const req = indexedDB.open('rfdb', 1); req.onupgradeneeded = () => req.result.createObjectStore('kv'); const db = await new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); const value = await new Promise((res, rej) => { const tx = db.transaction('kv', 'readonly'); const get = tx.objectStore('kv').get('auth'); get.onsuccess = () => res(get.result); get.onerror = () => rej(get.error); }); db.close(); return value === undefined ? '' : value; } @@ -51,15 +50,19 @@ Save Storage State To Given Path Storage State Keywords Accept A Relative Path [Documentation] The node wrapper runs in its own working directory, so relative paths - ... must be resolved before they are sent over grpc. + ... must be resolved before they are sent over grpc. The relative path points into the + ... output directory, because the working directory is not writable everywhere, for + ... example in the docker image where it is the root directory. New Context New Page ${LOGIN_URL} Add Cookies For Storage - VAR ${relative} = ${RELATIVE_STATE_DIR}/relative_auth.json + VAR ${target} = ${OUTPUT_DIR}/relative_auth.json + VAR ${relative} = ${{ os.path.relpath(r"${target}") }} + Should Not Be Equal ${relative} ${target} The path under test must be relative ${returned} = Save Storage State ${relative} - ${expected} = Normalize Path ${EXECDIR}/${relative} + ${expected} = Normalize Path ${target} Should Be Equal ${returned} ${expected} - File Should Not Be Empty ${EXECDIR}/${relative} + File Should Not Be Empty ${target} Delete All Cookies Set Storage State ${relative} ${cookie} = Get Cookie Foo @@ -67,7 +70,6 @@ Storage State Keywords Accept A Relative Path New Context storageState=${relative} ${cookie} = Get Cookie Key Should Be Equal ${cookie.value} Value - [Teardown] Remove Directory ${EXECDIR}/${RELATIVE_STATE_DIR} recursive=True Save Storage State To Given Path Overwrites Existing File New Context From ea1540454f2aa06ebb5d1e44176490cef7b80302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81?= Date: Sun, 16 Aug 2026 20:35:54 +0200 Subject: [PATCH 7/8] added test for test gap --- .../01_Browser_Management/storage_state.robot | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/atest/test/01_Browser_Management/storage_state.robot b/atest/test/01_Browser_Management/storage_state.robot index 0114108be..8e3023253 100644 --- a/atest/test/01_Browser_Management/storage_state.robot +++ b/atest/test/01_Browser_Management/storage_state.robot @@ -9,6 +9,7 @@ ${OTHER_ORIGIN_URL} = http://127.0.0.1:${SERVER_PORT}/dist/ ${SEED_INDEXED_DB} = async () => { const req = indexedDB.open('rfdb', 1); req.onupgradeneeded = () => req.result.createObjectStore('kv'); const db = await new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); await new Promise((res, rej) => { const tx = db.transaction('kv', 'readwrite'); tx.objectStore('kv').put('token-abc', 'auth'); tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error); }); db.close(); } ${READ_INDEXED_DB} = async () => { const req = indexedDB.open('rfdb', 1); req.onupgradeneeded = () => req.result.createObjectStore('kv'); const db = await new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); const value = await new Promise((res, rej) => { const tx = db.transaction('kv', 'readonly'); const get = tx.objectStore('kv').get('auth'); get.onsuccess = () => res(get.result); get.onerror = () => rej(get.error); }); db.close(); return value === undefined ? '' : value; } ${HOLD_INDEXED_DB} = async () => { const req = indexedDB.open('rfdb', 1); req.onupgradeneeded = () => req.result.createObjectStore('kv'); const db = await new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); await new Promise((res, rej) => { const tx = db.transaction('kv', 'readwrite'); tx.objectStore('kv').put('token-abc', 'auth'); tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error); }); window.__db = db; } +${LIST_INDEXED_DB} = async () => (await indexedDB.databases()).map(db => db.name).join(',') ${DELETE_INDEXED_DB} = async () => { await new Promise((res, rej) => { const req = indexedDB.deleteDatabase('rfdb'); req.onsuccess = () => res(); req.onerror = () => rej(req.error); req.onblocked = () => rej(new Error('blocked')); }); } *** Test Cases *** @@ -137,6 +138,26 @@ Save Storage State Without IndexedDB Omits IndexedDB ${value} = Evaluate JavaScript ${None} ${READ_INDEXED_DB} Should Be Equal ${value} ${EMPTY} +Set Storage State Clears IndexedDB Without Reloading Any Page + [Documentation] Playwright clears the databases of the context through the storage layer, + ... which does not block on open connections. Only writing IndexedDB back needs a version + ... change, which is why only the origins carrying IndexedDB in the state file have to be + ... reloaded. If this ever starts to block, the selection of pages has to grow. + New Context + New Page ${LOGIN_URL} + ${state} = Save Storage State + New Context + New Page ${LOGIN_URL} + Evaluate JavaScript ${None} ${HOLD_INDEXED_DB} + ${before} = Evaluate JavaScript ${None} ${LIST_INDEXED_DB} + Should Be Equal ${before} rfdb + Set Storage State ${state} + ${still_open} = Evaluate JavaScript ${None} () => !!window.__db + Should Be True ${still_open} The page must not have been reloaded + Reload + ${after} = Evaluate JavaScript ${None} ${LIST_INDEXED_DB} + Should Be Equal ${after} ${EMPTY} + Set Storage State Restores IndexedDB Into The Same Context [Documentation] Disabled until https://github.com/microsoft/playwright/issues/42258 is fixed. ... Save Storage State with indexedDB=True leaves an open IndexedDB connection in the From 74955f3923ed06e8455a702e215d90aa58161eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81?= Date: Sun, 16 Aug 2026 21:32:21 +0200 Subject: [PATCH 8/8] fixed review findings --- Browser/gen_stub.py | 2 +- Browser/keywords/playwright_state.py | 13 +++- Browser/utils/__init__.py | 2 +- .../01_Browser_Management/storage_state.robot | 38 +++++++++--- node/dynamic-test-app/static/idb-holder-sw.js | 5 ++ node/playwright-wrapper/playwright-state.ts | 62 ++++++++++++++----- 6 files changed, 95 insertions(+), 27 deletions(-) diff --git a/Browser/gen_stub.py b/Browser/gen_stub.py index 6fbeb865e..89f1b796b 100644 --- a/Browser/gen_stub.py +++ b/Browser/gen_stub.py @@ -62,7 +62,7 @@ def parse_kw_stubs(): BrowserInfo, PageLoadStates,ViewportDimensions, ServiceWorkersPermissions, ReduceMotion, RecordVideo, RecordHar, Proxy, Permission, HttpCredentials, GeoLocation, ForcedColors, ColorScheme, ClientCertificate, HighlightMode, ScreenshotReturnType, - Scale, ScreenshotFileTypes, BoundingBox, ReducedMotion, Media, PdfMarging, + Scale, ScreenshotFileTypes, BoundingBox, ReducedMotion, ReloadPages, Media, PdfMarging, PdfFormat, CoverageType, RequestMethod, ElementState, ScrollPosition, SelectAttribute, SelectOptions, ConditionInputs, FileUploadBuffer, SelectAttribute ) diff --git a/Browser/keywords/playwright_state.py b/Browser/keywords/playwright_state.py index 5ea9230c8..ef899300e 100755 --- a/Browser/keywords/playwright_state.py +++ b/Browser/keywords/playwright_state.py @@ -1778,9 +1778,14 @@ def set_storage_state( an application which keeps data of the previous user there still has it after the state was replaced. + Restoring a state which was saved with ``credentials=True`` installs the + virtual WebAuthn authenticator into the context, the same way + `Install Credential` does. Real authenticators do not work in that + context afterwards. + | =Arguments= | =Description= | | ``path`` | Path to a state file created by `Save Storage State`. Relative paths are resolved against the current working directory. The keyword fails if the file does not exist. | - | ``timeout`` | Time to wait for the state to be restored. If not defined, the library default timeout is used. | + | ``timeout`` | Time to wait for the state to be restored. If not defined, the library default timeout is used. Pass 0 to disable the timeout. | | ``reload_pages`` | Which pages are reloaded while the state is restored, see `ReloadPages`. Only relevant when the state file contains IndexedDB. | == Restoring IndexedDB == @@ -1803,6 +1808,12 @@ def set_storage_state( value of ``reload_pages`` helps against that, because a service worker outlives the pages. The keyword then fails when ``timeout`` expires. + ``reload_pages=none`` detects a blocking connection up front and fails + immediately instead of waiting for the timeout. That detection needs + ``indexedDB.databases()``, which older browsers, Firefox before 126 + among them, do not have. There the keyword cannot tell whether a + connection blocks and falls back to waiting for ``timeout``. + Example: | `New Context` | `New Page` https://login.page.html diff --git a/Browser/utils/__init__.py b/Browser/utils/__init__.py index c7e37ed98..175609087 100644 --- a/Browser/utils/__init__.py +++ b/Browser/utils/__init__.py @@ -52,8 +52,8 @@ RecordHar, RecordVideo, ReduceMotion, - ReloadPages, ReducedMotion, + ReloadPages, RequestMethod, Scale, Scope, diff --git a/atest/test/01_Browser_Management/storage_state.robot b/atest/test/01_Browser_Management/storage_state.robot index 8e3023253..46c091086 100644 --- a/atest/test/01_Browser_Management/storage_state.robot +++ b/atest/test/01_Browser_Management/storage_state.robot @@ -4,13 +4,14 @@ Resource imports.resource Suite Setup New Browser headless=${HEADLESS} *** Variables *** -${CUSTOM_STATE_DIR} = ${OUTPUT_DIR}/custom_state -${OTHER_ORIGIN_URL} = http://127.0.0.1:${SERVER_PORT}/dist/ -${SEED_INDEXED_DB} = async () => { const req = indexedDB.open('rfdb', 1); req.onupgradeneeded = () => req.result.createObjectStore('kv'); const db = await new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); await new Promise((res, rej) => { const tx = db.transaction('kv', 'readwrite'); tx.objectStore('kv').put('token-abc', 'auth'); tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error); }); db.close(); } -${READ_INDEXED_DB} = async () => { const req = indexedDB.open('rfdb', 1); req.onupgradeneeded = () => req.result.createObjectStore('kv'); const db = await new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); const value = await new Promise((res, rej) => { const tx = db.transaction('kv', 'readonly'); const get = tx.objectStore('kv').get('auth'); get.onsuccess = () => res(get.result); get.onerror = () => rej(get.error); }); db.close(); return value === undefined ? '' : value; } -${HOLD_INDEXED_DB} = async () => { const req = indexedDB.open('rfdb', 1); req.onupgradeneeded = () => req.result.createObjectStore('kv'); const db = await new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); await new Promise((res, rej) => { const tx = db.transaction('kv', 'readwrite'); tx.objectStore('kv').put('token-abc', 'auth'); tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error); }); window.__db = db; } -${LIST_INDEXED_DB} = async () => (await indexedDB.databases()).map(db => db.name).join(',') -${DELETE_INDEXED_DB} = async () => { await new Promise((res, rej) => { const req = indexedDB.deleteDatabase('rfdb'); req.onsuccess = () => res(); req.onerror = () => rej(req.error); req.onblocked = () => rej(new Error('blocked')); }); } +${CUSTOM_STATE_DIR} = ${OUTPUT_DIR}/custom_state +${OTHER_ORIGIN_URL} = http://127.0.0.1:${SERVER_PORT}/dist/ +${SEED_INDEXED_DB} = async () => { const req = indexedDB.open('rfdb', 1); req.onupgradeneeded = () => req.result.createObjectStore('kv'); const db = await new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); await new Promise((res, rej) => { const tx = db.transaction('kv', 'readwrite'); tx.objectStore('kv').put('token-abc', 'auth'); tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error); }); db.close(); } +${READ_INDEXED_DB} = async () => { const req = indexedDB.open('rfdb', 1); req.onupgradeneeded = () => req.result.createObjectStore('kv'); const db = await new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); const value = await new Promise((res, rej) => { const tx = db.transaction('kv', 'readonly'); const get = tx.objectStore('kv').get('auth'); get.onsuccess = () => res(get.result); get.onerror = () => rej(get.error); }); db.close(); return value === undefined ? '' : value; } +${HOLD_INDEXED_DB} = async () => { const req = indexedDB.open('rfdb', 1); req.onupgradeneeded = () => req.result.createObjectStore('kv'); const db = await new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); await new Promise((res, rej) => { const tx = db.transaction('kv', 'readwrite'); tx.objectStore('kv').put('token-abc', 'auth'); tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error); }); window.__db = db; } +${SW_CONNECTION_STATUS} = async () => { const reg = await navigator.serviceWorker.ready; return await new Promise((res) => { const channel = new MessageChannel(); channel.port1.onmessage = (event) => res(event.data); reg.active.postMessage('status', [channel.port2]); }); } +${LIST_INDEXED_DB} = async () => (await indexedDB.databases()).map(db => db.name).join(',') +${DELETE_INDEXED_DB} = async () => { await new Promise((res, rej) => { const req = indexedDB.deleteDatabase('rfdb'); req.onsuccess = () => res(); req.onerror = () => rej(req.error); req.onblocked = () => rej(new Error('blocked')); }); } *** Test Cases *** Save Storage State @@ -245,6 +246,23 @@ Set Storage State Leaves Pages Of Other Origins Alone ${still_held} = Evaluate JavaScript ${None} () => !!window.__db Should Be True ${still_held} The page of the other origin was reloaded +Set Storage State Refuses To Run While A Timed Out Restore Is Still Going + [Documentation] A restore which timed out keeps running. Touching IndexedDB while it does + ... crashes the browser, so a second attempt on the same context has to be refused. + New Context + New Page ${LOGIN_URL} + Evaluate JavaScript ${None} ${HOLD_INDEXED_DB} + ${state} = Save Storage State indexedDB=True + New Context + New Page ${LOGIN_URL} + Register IndexedDB Holding Service Worker + Run Keyword And Expect Error + ... *timed out after 3000 ms* + ... Set Storage State ${state} timeout=3s reload_pages=all + Run Keyword And Expect Error + ... *previous Set Storage State on this context timed out and is still running* + ... Set Storage State ${state} reload_pages=none + Save Storage State With Credentials Restores WebAuthn Credentials New Context New Page ${LOGIN_URL} @@ -272,7 +290,11 @@ Register IndexedDB Holding Service Worker Evaluate JavaScript ... ${None} ... async () => { const reg = await navigator.serviceWorker.register('/idb-holder-sw.js'); await navigator.serviceWorker.ready; return reg.scope; } - Sleep 1s reason=let the worker open its connection + Wait Until Keyword Succeeds 10x 200ms Service Worker Should Hold The Connection + +Service Worker Should Hold The Connection + ${status} = Evaluate JavaScript ${None} ${SW_CONNECTION_STATUS} + Should Be Equal ${status} open Add Cookies For Storage ${url} = Get Url diff --git a/node/dynamic-test-app/static/idb-holder-sw.js b/node/dynamic-test-app/static/idb-holder-sw.js index de4a2502f..a0e3ec426 100644 --- a/node/dynamic-test-app/static/idb-holder-sw.js +++ b/node/dynamic-test-app/static/idb-holder-sw.js @@ -5,6 +5,11 @@ let heldConnection; self.addEventListener('install', () => self.skipWaiting()); +// Answers whether the connection is open yet, so that tests can wait for it instead of sleeping. +self.addEventListener('message', (event) => { + if (event.ports[0]) event.ports[0].postMessage(heldConnection ? 'open' : 'pending'); +}); + self.addEventListener('activate', (event) => event.waitUntil( (async () => { diff --git a/node/playwright-wrapper/playwright-state.ts b/node/playwright-wrapper/playwright-state.ts index b8da8d878..9a619b83b 100644 --- a/node/playwright-wrapper/playwright-state.ts +++ b/node/playwright-wrapper/playwright-state.ts @@ -579,6 +579,8 @@ type IndexedContext = { traceFile: string; pageStack: IndexedPage[]; options?: Record; + // A restore which timed out keeps running, see setStorageState. + storageStateRestorePending?: boolean; }; export type DownloadInfo = { @@ -1198,9 +1200,24 @@ export async function setStorageState( ? pages.filter((page) => originOf(page.url()) !== null) : pages.filter((page) => origins.includes(originOf(page.url()) ?? '')); + if (context.storageStateRestorePending) { + throw new Error( + 'A previous Set Storage State on this context timed out and is still running. Playwright does not ' + + 'cancel it, and touching IndexedDB while it runs crashes the browser, so this context cannot ' + + 'restore another storage state. Create a new context instead.', + ); + } + if (origins.length > 0 && request.reloadPages === 'none') { for (const page of affected) { + if (page.isClosed()) continue; const blocked: string[] | null = await page.evaluate(`(${OPEN_CONNECTIONS_PROBE})()`); + if (blocked === null) { + logger.info( + `This browser has no indexedDB.databases(), so reload_pages=none cannot tell whether a ` + + `connection of ${originOf(page.url())} blocks the restore. Waiting for the timeout instead.`, + ); + } if (blocked?.length) { throw new Error( `Set Storage State cannot restore the state of ${originOf(page.url())}, because a client of ` + @@ -1213,13 +1230,7 @@ export async function setStorageState( } const detached: { page: Page; url: string }[] = []; - if (origins.length > 0 && request.reloadPages !== 'none') { - for (const page of affected) { - const url = page.url(); - await page.goto('about:blank'); - detached.push({ page, url }); - } - } + const detachFailed: string[] = []; // Navigation only. Anything which touches IndexedDB from the page crashes the browser // while a restore is pending, so the pages are never probed on this path. @@ -1236,20 +1247,39 @@ export async function setStorageState( return failed; }; + const withPages = (message: string, failed: string[]): string => { + const stranded = [...detachFailed, ...failed]; + return stranded.length ? `${message}\nThese pages were not navigated back: ${stranded.join(', ')}` : message; + }; + try { - await withTimeout(context.c.setStorageState(stateFile), timeout, stateFile); - } catch (error) { - const failed = await reattach(); - if (failed.length) { - throw new Error(`${(error as Error).message}\nThese pages were not restored: ${failed.join(', ')}`, { - cause: error, - }); + if (origins.length > 0 && request.reloadPages !== 'none') { + for (const page of affected) { + if (page.isClosed()) continue; + const url = page.url(); + try { + await page.goto('about:blank'); + detached.push({ page, url }); + } catch { + // The page is gone or refuses to navigate. It cannot be restored either, + // so report it instead of leaving the caller to wonder. + detachFailed.push(url); + } + } } + const restore = context.c + .setStorageState(stateFile) + .finally(() => (context.storageStateRestorePending = false)); + context.storageStateRestorePending = true; + await withTimeout(restore, timeout, stateFile); + } catch (error) { + const message = withPages((error as Error).message, await reattach()); + if (message !== (error as Error).message) throw new Error(message, { cause: error }); throw error; } const failed = await reattach(); - if (failed.length) { - throw new Error(`The state was restored, but these pages were not navigated back: ${failed.join(', ')}`); + if (failed.length || detachFailed.length) { + throw new Error(withPages('The state was restored, but not every page is back on its url.', failed)); } return emptyWithLog('Current context state is set from: ' + stateFile); }