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 77758bdb9..ef899300e 100755 --- a/Browser/keywords/playwright_state.py +++ b/Browser/keywords/playwright_state.py @@ -46,6 +46,7 @@ RecordHar, RecordVideo, ReduceMotion, + ReloadPages, SelectionType, ServiceWorkersPermissions, SupportedBrowsers, @@ -610,7 +611,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, @@ -651,7 +652,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 +887,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 Path(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 @@ -1677,7 +1680,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 +1701,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. 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. | + + 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 +1732,115 @@ 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.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) - 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, + 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 + 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. 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. + + 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. 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 == + + 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. + + ``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 + | # Perform login as first user + | ${user_a} = `Save Storage State` indexedDB=True + | # Perform login as second user + | ${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 + """ + 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.resolve()), + timeout=int(self.get_timeout(timeout)), + reloadPages=reload_pages.name, + navigationTimeout=int(self.get_timeout(None)), + ) + ) + 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/Browser/utils/__init__.py b/Browser/utils/__init__.py index dc3ec0952..175609087 100644 --- a/Browser/utils/__init__.py +++ b/Browser/utils/__init__.py @@ -53,6 +53,7 @@ RecordVideo, ReduceMotion, ReducedMotion, + ReloadPages, RequestMethod, Scale, Scope, 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 1a7f6d913..46c091086 100644 --- a/atest/test/01_Browser_Management/storage_state.robot +++ b/atest/test/01_Browser_Management/storage_state.robot @@ -3,6 +3,16 @@ 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; } +${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 New Context @@ -10,7 +20,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} @@ -21,7 +31,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 @@ -30,7 +40,262 @@ 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} + ${expected} = Normalize Path ${target} + Should Be Equal ${returned} ${expected} + File Should Not Be Empty ${target} + +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. 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 ${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 ${target} + Should Be Equal ${returned} ${expected} + File Should Not Be Empty ${target} + Delete All Cookies + 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 + +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 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 + ... 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 + 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 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} ${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 + ... *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 + +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} + 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 *** +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; } + 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 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..a0e3ec426 --- /dev/null +++ b/node/dynamic-test-app/static/idb-holder-sw.js @@ -0,0 +1,25 @@ +// 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()); + +// 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 () => { + 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/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..9a619b83b 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, @@ -577,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 = { @@ -1123,17 +1127,190 @@ 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); } +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, +): 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; + 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 (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 ` + + `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 }[] = []; + 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. + 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; + }; + + 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 { + 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 || 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); +} + +// 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 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(message)), timeout); + }), + ]); + } catch (error) { + restore.catch(() => {}); + throw error; + } finally { + if (timer) clearTimeout(timer); + } +} + export async function startCoverage(request: Request_CoverageStart, state: PlaywrightState): Promise { const activePage = state.getActivePage(); exists(activePage, 'Could not find active page'); diff --git a/protobuf/playwright.proto b/protobuf/playwright.proto index f11abb43b..c2f18ab0b 100644 --- a/protobuf/playwright.proto +++ b/protobuf/playwright.proto @@ -121,6 +121,17 @@ 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; + string reloadPages = 3; + int32 navigationTimeout = 4; + } message FileBySelector { repeated string path = 1; string selector = 2; @@ -641,7 +652,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);