mcp-accessibility-scanner 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -140,7 +140,9 @@ npx mcp-accessibility-scanner --extension
140
140
  Set `PLAYWRIGHT_MCP_EXTENSION_TOKEN` to the token shown by the extension to bypass the connection approval dialog. The relay's CDP WebSocket endpoint always requires a separate random token, generated per relay and appended automatically for the server's own connection. This CDP token is never passed in Chrome's launch arguments or extension URL; the extension approval token cannot authenticate a CDP client.
141
141
  Token-bypass connections are not background-safe: Chrome focuses the connection tab and window, and client-created tabs remain open after disconnect ([upstream limitation](https://github.com/microsoft/playwright/issues/42343)).
142
142
  With a token, the extension must connect and finish setup within 30 seconds after the connection page opens. Failed attempts release the relay so the next tool call can retry. Without a token, manual approval waits until you approve or cancel the call.
143
- When `--user-data-dir` contains multiple Chrome profiles, the profile with the extension installed is selected automatically, preferring Chrome's last-used profile.
143
+ When `--user-data-dir` contains multiple Chrome profiles, the profile with the extension installed is selected automatically, preferring Chrome's last-used profile. Pass `--profile-dir-name` (or set `PLAYWRIGHT_MCP_PROFILE_DIR_NAME`) to select a profile explicitly; it requires `--user-data-dir` and accepts a Chrome profile directory name such as `Default` or `Profile 1` (see "Profile Path" at `chrome://version`). The `PLAYWRIGHT_MCP_EXTENSION_TOKEN` approval token is specific to the profile, so when selecting a profile explicitly, use the token shown in that profile.
144
+
145
+ Packed extensions require an enabled record in the profile's preferences; a leftover extension directory alone does not count as installed. Profiles whose preferences mark the extension disabled or uninstalled are excluded from automatic selection and rejected for explicit selection. Explicit selection with a custom executable skips local installation checks. A whitespace-only `PLAYWRIGHT_MCP_PROFILE_DIR_NAME` is treated as unset; other string environment variables retain their existing blank-value handling.
144
146
 
145
147
  ### Discovering available tools (`list-tools` subcommand)
146
148
 
@@ -203,7 +205,8 @@ Create a `config.json` file with the following options:
203
205
  "timeouts": {
204
206
  "navigationTimeout": 60000,
205
207
  "defaultTimeout": 5000,
206
- "settle": 500
208
+ "settle": 500,
209
+ "idle": 0
207
210
  },
208
211
  "network": {
209
212
  "allowedOrigins": ["example.com", "trusted-site.com"],
@@ -229,18 +232,23 @@ Create a `config.json` file with the following options:
229
232
  - `browser.cdpTimeout`: Maximum time in milliseconds to wait when connecting to the CDP endpoint (default: `30000`)
230
233
  - `browser.cdpLaunch`: Launch a Chromium-family desktop app with CDP enabled, wait for the endpoint, and manage the child process lifecycle
231
234
  - CDP attach modes preserve the target browser's existing default-context settings instead of applying Playwright's defaults.
232
- - `browser.contextOptions.storageState`: Start each session from a recorded Playwright storage state; applied in every mode except `--extension` (fresh contexts receive it at creation, reused contexts via `setStorageState()`). Sessions that share one reused context (non-isolated CDP modes) get the state applied once per context a session joining a live context inherits its current state, not a fresh copy of the file; see [Auditing pages behind a login](#auditing-pages-behind-a-login)
235
+ - `browser.contextOptions.storageState`: Start a fresh context from a recorded Playwright storage state. Imports into existing CDP or VS Code contexts are rejected; use `--isolated` for CDP or sign in interactively. The default persistent mode uses a fresh disposable profile. See [Auditing pages behind a login](#auditing-pages-behind-a-login).
236
+ - `browser.profileDirName`: Chrome profile directory name used in extension mode, for example `Default` or `Profile 1` (CLI: `--profile-dir-name`, env: `PLAYWRIGHT_MCP_PROFILE_DIR_NAME`). Requires `--user-data-dir` and extension mode (`--extension` or `--connect-tool`); defaults to the last-used profile that has the extension installed.
233
237
  - `timeouts.navigationTimeout`: Maximum time for page navigation in milliseconds (default: `60000`)
234
238
  - `timeouts.defaultTimeout`: Default timeout for Playwright operations in milliseconds (default: `5000`)
235
239
  - `timeouts.settle`: How long to wait after every action before responding (default: `500`). An action that finishes quietly is first watched for up to 100ms (or the settle delay, whichever is shorter) so scheduled network work can still be awaited before the settle delay.
240
+ - `timeouts.idle`: Release the default browser context after this many idle milliseconds (default: `0`, disabled). Accepts integers from `0` to `2147483647`.
236
241
  - `network.allowedOrigins`: List of origins to allow (blocks all others if specified)
237
242
  - `network.blockedOrigins`: List of origins to block
238
243
  - `snapshot.boxes`: Include each element's viewport-relative bounding box as `[box=x,y,width,height]` in snapshots (default: `false`; CLI: `--snapshot-boxes`, env: `PLAYWRIGHT_MCP_SNAPSHOT_BOXES=1`)
244
+ - `imageResponses`: `allow` (default) returns text and images; `omit` excludes images; `only` omits text from successful responses containing images. Errors, browser lifecycle notices, and responses without images (including full-page screenshots) keep their text. Structured results and resource links are always preserved. In `only` mode, screenshot save-path text, generated code and any accompanying text-only findings are omitted; use `allow` if you need them. Interactive mode rejects `only` because its REPL prints text only; use `allow` or `omit` there. `auto` remains a legacy alias for `allow`. CLI: `--image-responses only`; env: `PLAYWRIGHT_MCP_IMAGE_RESPONSES=only`. Precedence: CLI, then environment, then config file.
239
245
  - `server.authToken`: When set, Streamable HTTP requests (`--port`) require `Authorization: Bearer <token>` or return `401` (env: `PLAYWRIGHT_MCP_AUTH_TOKEN`). Blank or malformed tokens fail at startup. The scheme is case-insensitive; the token is exact. Bearer auth does not encrypt traffic: authenticated listeners must bind to loopback, such as `--host 127.0.0.1`; use a TLS reverse proxy for remote access. The printed client config includes a header placeholder to replace locally, without logging the secret. Unset keeps unauthenticated access.
240
246
  - `outputDir`: Directory for output files — reports, screenshots, traces, and session logs (CLI: `--output-dir`, env: `PLAYWRIGHT_MCP_OUTPUT_DIR`). Defaults to a fresh directory under the system temp folder, resolved once per server run so all of a run's artifacts land together. The output location is always server configuration; the deprecated MCP roots capability (client workspace folders) is no longer consulted.
241
247
 
242
248
  CLI equivalents are also available: `--cdp-launch-command`, `--cdp-launch-args`, `--cdp-launch-cwd`, `--cdp-launch-port`, `--cdp-launch-startup-timeout`, `--cdp-endpoint`, `--cdp-header` (repeat for multiple headers, e.g. `--cdp-header "Authorization: Bearer <token>"`), and `--cdp-timeout`. The CDP headers and timeout can also be set via the `PLAYWRIGHT_MCP_CDP_HEADERS` (one `Name: Value` entry per line) and `PLAYWRIGHT_MCP_CDP_TIMEOUT` environment variables.
243
249
 
250
+ If CDP attachment times out after the WebSocket connects, an existing sleeping or unresponsive tab may be blocking Playwright's browser initialization ([upstream report](https://github.com/microsoft/playwright/issues/42730)). Use an explicit positive `--cdp-timeout` to bound the attempt. Inspect or wake the affected tabs yourself, or attach to a separate disposable browser. `noDefaults` and `--isolated` do not skip initialization of existing tabs; the server does not close your tabs or bypass Playwright's initialization to work around this.
251
+
244
252
  For remote HTTP access, configure the TLS reverse proxy explicitly. For example, with the MCP server bound using `--host 127.0.0.1 --port 8931` and `PLAYWRIGHT_MCP_AUTH_TOKEN` set:
245
253
 
246
254
  - Accept only your configured public hostname over HTTPS and forward `/mcp` to `http://127.0.0.1:8931/mcp`.
@@ -254,6 +262,8 @@ Caller-supplied screenshot, PDF, scan-page-matrix, and audit report filenames us
254
262
 
255
263
  Use `--timeout-settle` or `PLAYWRIGHT_MCP_TIMEOUT_SETTLE` to override the post-action settle delay. It applies after every action so delayed DOM-only updates are included in the response; a short observation window also catches scheduled requests and waits for them before that delay.
256
264
 
265
+ Use `--timeout-idle 300000`, `timeouts.idle`, or `PLAYWRIGHT_MCP_TIMEOUT_IDLE` to release the default browser after five idle minutes. Shared contexts stay open while any client is working; the idle window starts after the last tool call or download finishes. Explicit recordings prevent idle release until `browser_stop_recording` finishes; passive `--save-session` capture does not. Cleanup finalizes traces. The next browser tool call reopens the connection and includes a note to navigate again and refresh element references. Attached CDP, extension, and VS Code browsers are disconnected; their external pages remain open. Close and session-management tools do not relaunch an idle browser. Explicit `browser_session_open` handles keep their separate `PLAYWRIGHT_MCP_BROWSER_SESSION_TTL_MS` behavior. Zero disables this feature; blank environment values leave the existing configuration unchanged.
266
+
257
267
  The VS Code `browser_connect` tool accepts only `playwright` or `playwright-core` libraries and loopback WebSocket URLs. Set `PLAYWRIGHT_MCP_VSCODE_ALLOW_REMOTE=1` to allow remote endpoints, which must use `wss:`. URL userinfo credentials are rejected.
258
268
 
259
269
  #### HTTP Heartbeat
@@ -314,10 +324,14 @@ PLAYWRIGHT_MCP_ISOLATED=true PLAYWRIGHT_MCP_STORAGE_STATE=./auth.json npx mcp-ac
314
324
 
315
325
  > **Every supported mode handles the state — by applying it or refusing it.**
316
326
  >
327
+ > **Playwright 1.63.0 safety restriction:** importing into an existing context is rejected before taking a rollback snapshot or resetting any storage. On this pin, snapshot capture can execute service-worker-served scripts for a previously visited origin whose tab is no longer open ([upstream fix](https://github.com/microsoft/playwright/pull/42664)). Use a fresh context, or omit `--storage-state` and sign in interactively. Service workers are not disabled. A future dependency upgrade must also pass the recorder/shared-client checks in [#218](https://github.com/JustasMonkev/mcp-accessibility-scanner/issues/218) and IndexedDB checks in [#224](https://github.com/JustasMonkev/mcp-accessibility-scanner/issues/224) before this restriction is reconsidered.
328
+ >
329
+ > **IndexedDB snapshot limitation:** on pinned Playwright 1.63.0 with Chromium 153.0.8010.12 and Firefox 155.0, `storageState({ indexedDB: true })` loses `Map` and `Set` contents. Both `newContext({ storageState })` and `setStorageState()` restore them as empty plain objects; ordinary JSON records survive. Fresh contexts protect existing browser data, but cannot recover values already lost during capture. The [upstream fix](https://github.com/microsoft/playwright/pull/42707) is merged but is not in this pin. Before allowing imports into existing contexts again, verify both restore paths preserve Map/Set types and entries on each supported engine, including after a failed import. The real-browser regression in `tests/browser-failures.integration.test.ts` checks that rejecting an import leaves the original Map/Set records intact and that isolated JSON IndexedDB imports still work.
330
+ >
317
331
  > - **Fresh-context modes** (`--isolated`, the remote-endpoint mode, or either CDP mode combined with `--isolated`): the context is created with the storage state directly.
318
332
  > - **Default persistent-profile mode with `--storage-state`**: the session runs in a fresh, disposable profile — unique to that session and removed when it closes — built from the state, so the recorded state is provably the only session data (without `--storage-state` the regular persistent profile is used and survives restarts, as before). Any page the launch opened (for example from a URL in `browser.launchOptions.args`) is parked on a blank replacement before the state lands, then the replacement is navigated to the same URL, so a still-running anonymous page cannot overwrite the recorded identity and a scan never reads its DOM. This also means `--storage-state` cannot be combined with `--user-data-dir` (a user-supplied profile carries its own session and will not be wiped; the server refuses the combination).
319
- > - **CDP modes without `--isolated`**: the state is installed into the browser's existing context with Playwright's `setStorageState()`. Cookies are fully reset; origin storage (localStorage/IndexedDB) is reset for the origins recorded in the state *plus* any origins the Playwright connection has already seen — including pages open in the attached browser at connect time, whose storage can therefore be cleared even when the state omits them. Only origins from the profile's earlier history that this connection never saw survive untouched — cut in both directions, so treat an attached browser's storage as neither fully preserved nor fully reset, and add `--isolated` when you need a clean, fully-defined session. Pages already open in the attached browser are replaced with fresh tabs navigated to the same URLs so a scan never sees the previous identity's UI — and the old pages close *before* the state is installed, because a still-running page could otherwise persist the previous identity back into the freshly applied cookies or localStorage, which no later tab replacement could undo. A fresh tab also starts with empty per-tab `sessionStorage` (which sits outside Playwright storage states and would survive an in-place reload, where the old page's own scripts could even write the previous identity back between a clear and the reload), a replacement that fails to load is left blank or closed rather than left on a stale document, and these navigations run under a configured `--allowed-origins`/`--blocked-origins` policy just like every later navigation. The state is applied once per shared context: concurrent MCP sessions attached without `--isolated` share the browser's context, so a session joining while another is active inherits that context's live state (including anything the first session changed or cleared) rather than a fresh copy of the recorded file — add `--isolated` when every session must start from the recorded baseline.
320
- > - **`--extension`** (with or without `--isolated`) is the one exception: it works through the browser you are already running, where wiping cookies to install a recorded state is not an acceptable side effect, so the server refuses to start rather than doing that silently. There, sign in interactively instead — the persistent profile also keeps the session across restarts.
333
+ > - **CDP modes without `--isolated` and the VS Code provider**: `--storage-state` is rejected when the browser already has a context. Add `--isolated` in CDP mode to create a fresh context; otherwise omit the state and sign in interactively. If the browser exposes no context, the server creates one with the state. CDP sessions joining that same server-created context inherit its live state without resetting it.
334
+ > - **`--extension`** (with or without `--isolated`) refuses storage imports entirely: it works through the browser you are already running, where wiping cookies to install a recorded state is not an acceptable side effect, so the server refuses to start rather than doing that silently. There, sign in interactively instead — the persistent profile also keeps the session across restarts.
321
335
 
322
336
  ### Keep the crawl from destroying its own session
323
337
 
@@ -337,6 +351,8 @@ The check compares which cookies the crawled URLs carry, not their values, so a
337
351
 
338
352
  ## Available Tools
339
353
 
354
+ Page-registered WebMCP tools are not currently exposed. See the [WebMCP adoption decision](https://github.com/JustasMonkev/mcp-accessibility-scanner/blob/main/docs/decisions/001-webmcp-adoption.md) for the deferral and conditions for revisiting an opt-in capability.
355
+
340
356
  The MCP server provides comprehensive browser automation and accessibility scanning tools:
341
357
 
342
358
  ### Core Accessibility Tool
@@ -355,7 +371,7 @@ Performs a comprehensive accessibility scan on the current page using Axe-core.
355
371
  **Annotated screenshots:**
356
372
  When `annotateScreenshot` is `true`, each violating element is outlined and labelled with the rule ids it failed, a full-page PNG is written to the MCP output directory (`scan-page-annotated-{timestamp}-{token}.png`) and returned as a resource link, and the markers are then removed so the page is left exactly as it was. The markers are drawn in an out-of-flow overlay clipped to each element's own box, so they never reflow the page. The overlay uses a fresh id per scan, is placed in the browser's top layer so it stays visible over an open dialog, popover or fullscreen element, and compensates for a CSS `zoom` or a scaled ancestor so markers line up with what is rendered.
357
373
  An element that fails several rules gets one box listing every rule id, and elements inside open shadow roots are marked by walking the shadow path Axe reports.
358
- Running animations are paused before the elements are measured and resumed after the capture, so a moving target keeps its marker. The markers themselves live in a shadow root under an overlay whose own styles are `!important`, so page CSS cannot restyle or hide what the report counts, and each rule label sits outside the clipped box so it stays readable on an element smaller than its own label.
374
+ Running animations are frozen at their current time before the elements are measured and resumed after the capture, so a moving target keeps its marker. The markers themselves live in a shadow root under an overlay whose own styles are `!important`, so page CSS cannot restyle or hide what the report counts, and each rule label sits outside the clipped box so it stays readable on an element smaller than its own label.
359
375
  At most 50 elements are annotated per scan. The result text always reports how many nodes were marked out of the total, plus how many were left out because they exceeded the limit, were hidden, zero-size or off-canvas (a full-page screenshot is clipped to the document box), or were inside an iframe (cross-frame selectors cannot be resolved from the top document).
360
376
 
361
377
  **Supported Violation Tags:**
@@ -588,6 +604,7 @@ Evaluate a JavaScript expression on the page, or on a specific element when a `r
588
604
  Take a screenshot of the current page.
589
605
  - Parameters: `filename` (optional), `type` (`png`, `jpeg`, or `webp`), `scale` (`css` or `device`, default `css`), `fullPage` (optional), `element`/`ref` pair (for element screenshots)
590
606
  - `scale: device` captures a high-resolution screenshot using device pixels (accounts for the device pixel ratio); `scale: css` keeps the image sized in CSS pixels.
607
+ - An empty capture is an error, and its output file is removed, including automatically named files. The requested format is never silently changed. If a WebP capture is empty, reduce its dimensions or explicitly request PNG/JPEG.
591
608
 
592
609
  #### `browser_pdf_save`
593
610
  Save page as PDF.
@@ -599,6 +616,12 @@ This tool requires `--caps pdf` in the CLI.
599
616
  Install the configured browser engine (use when browser executable is missing).
600
617
  - Parameters: none
601
618
 
619
+ Disabled by default. Enable it at server startup with `--caps install`, `PLAYWRIGHT_MCP_CAPS=install`, or `"capabilities": ["install"]` in the config file. Explicit `core-install` settings remain supported as a deprecated alias; use `install` in new configurations. Without this opt-in, the tool is neither listed nor callable; existing browser installations can still be used.
620
+
621
+ This tool invokes Playwright's installer, which downloads executable code. In [Playwright 1.63.0](https://github.com/microsoft/playwright/blob/v1.63.0/packages/playwright-core/src/server/registry/oopDownloadBrowserMain.ts), browser archives have no checksum or signature verification before extraction; the default download hosts use HTTPS. Only enable installation when you trust the download source and TLS configuration, including any custom `PLAYWRIGHT_DOWNLOAD_HOST`, browser-specific host overrides, or TLS-inspecting proxy. Do not disable TLS certificate validation.
622
+
623
+ For deployments that require independently verified binaries, provision the browser through your trusted deployment process and use `--executable-path` or an existing browser connection. The capability opt-in limits MCP-triggered installation; it does not add archive verification or change manual, CI, or Docker build downloads.
624
+
602
625
  ### Browser Management
603
626
 
604
627
  #### `browser_close`
@@ -675,6 +698,7 @@ Handle browser dialogs (alerts, confirms, prompts).
675
698
  #### `browser_file_upload`
676
699
  Upload files to the page.
677
700
  - Parameters: `paths` (array of absolute file paths)
701
+ - If `setFiles` fails, the chooser stays available for another upload attempt; `paths: []` clears the selection and completes the chooser. A successful upload clears only that chooser and waits for page activity and the configured settle delay.
678
702
 
679
703
  #### `browser_verify_element_visible`
680
704
  Verify an element by ARIA role/name.
@@ -771,6 +795,48 @@ cd mcp-accessibility-scanner
771
795
  npm install
772
796
  ```
773
797
 
798
+ ### Playwright upgrade gate
799
+
800
+ The September 16, 2026 review keeps `playwright` and `playwright-core` paired at
801
+ **1.63.0**, the [latest stable release](https://github.com/microsoft/playwright/releases/tag/v1.63.0)
802
+ on that date. Keep the local `InputRecorder` hub and the existing factory reference
803
+ counts: multiple MCP clients share one client-side browser context, while the hub
804
+ multiplexes session logs and explicit recordings and excludes sibling tool actions.
805
+ A dependency bump alone must not change that ownership model.
806
+
807
+ Before adopting a stable release containing [upstream #42627](https://github.com/microsoft/playwright/pull/42627),
808
+ adapt the hub from `_enableRecorder` / `_disableRecorder` to
809
+ `_startRecording({ language: 'javascript' }, sink)` / `_stopRecording()` and verify
810
+ the per-client event contract against the installed runtime. Do not ship a
811
+ prerelease bump or an untested method-name fallback. Migrating to the separate
812
+ connections in [#42622](https://github.com/microsoft/playwright/pull/42622) is a
813
+ separate ownership change requiring the same lifecycle checks.
814
+
815
+ Install the pinned Chromium browser, then run the real recorder gate alongside
816
+ its failure and concurrency tests:
817
+
818
+ ```bash
819
+ npx playwright install chromium
820
+ npx vitest run tests/recorder.integration.test.ts tests/context.test.ts tests/browserSessions.test.ts tests/browserContextFactory.test.ts tests/tools-recorder.test.ts tests/sessionLog.test.ts
821
+ ```
822
+
823
+ The real-browser tests cover concurrent starts, duplicate-start rejection,
824
+ shared CDP clients, sibling-action attribution, stop/disconnect/restart,
825
+ `--save-session`, and recording across stateless explicit sessions. Existing unit
826
+ tests also cover failed-start recovery and overlapping start/stop. The recorder
827
+ must receive the final input event before stop; unbuffered input delivered after
828
+ stop begins is excluded, while buffered clicks/navigation get a 500 ms drain.
829
+
830
+ Also recheck the dependency fixes motivating the upgrade. On 1.63.0 Chromium,
831
+ both full-page and oversized element screenshots changed `navigator.maxTouchPoints`
832
+ from 1 to 0 and `(pointer: coarse)` from true to false; navigation restored the
833
+ properties ([#42617](https://github.com/microsoft/playwright/pull/42617)).
834
+ The fixed-header/smooth-scroll retry fixture clicked successfully but emitted 19
835
+ scroll events rather than instant jumps ([#42626](https://github.com/microsoft/playwright/pull/42626)).
836
+ A candidate upgrade must preserve touch properties after full-page and element
837
+ screenshots and navigation, and complete retry scrolling without smooth animation.
838
+ These are dependency limitations; the recorder gate alone does not verify them.
839
+
774
840
  ### MCP harnesses
775
841
 
776
842
  The npm wrappers build first, then the direct harness calls every exposed MCP
package/config.d.ts CHANGED
@@ -25,7 +25,7 @@ export type ToolCapability =
25
25
  | 'files'
26
26
  | 'install'
27
27
  | 'testing'
28
- | 'core-install'
28
+ | 'core-install' // Deprecated alias for 'install'; requires explicit opt-in.
29
29
  | 'core-tabs'
30
30
  | 'devtools'
31
31
  | 'vision'
@@ -57,6 +57,13 @@ export type Config = {
57
57
  */
58
58
  userDataDir?: string;
59
59
 
60
+ /**
61
+ * Chrome profile directory name used in extension mode (for example
62
+ * "Default" or "Profile 1"); defaults to the last-used profile that
63
+ * has the extension installed.
64
+ */
65
+ profileDirName?: string;
66
+
60
67
  /**
61
68
  * Launch options passed to
62
69
  * @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context
@@ -185,9 +192,11 @@ export type Config = {
185
192
  };
186
193
 
187
194
  /**
188
- * Whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.
195
+ * Image response policy. Defaults to "allow"; "auto" is a legacy alias for "allow".
196
+ * "omit" excludes images. "only" omits text from successful responses containing images,
197
+ * but preserves errors, browser lifecycle notices, structured content and resource links. Responses without images keep text.
189
198
  */
190
- imageResponses?: 'allow' | 'omit' | 'auto';
199
+ imageResponses?: 'allow' | 'omit' | 'auto' | 'only';
191
200
 
192
201
  snapshot?: {
193
202
  /**
@@ -215,5 +224,11 @@ export type Config = {
215
224
  * How long to wait after each action for triggered work to settle before responding. Defaults to 500ms.
216
225
  */
217
226
  settle?: number;
227
+
228
+ /**
229
+ * Release the default browser context after this many idle milliseconds. Zero (the default) disables it.
230
+ * Explicit browser sessions retain their separate idle TTL.
231
+ */
232
+ idle?: number;
218
233
  };
219
234
  };
@@ -25,17 +25,7 @@ import { logUnhandledError, testDebug } from './utils/log.js';
25
25
  import { createGuid, createHash, createShortGuid } from './utils/guid.js';
26
26
  import { outputFile } from './config.js';
27
27
  import { ensureNetworkPolicyRoutes } from './networkPolicy.js';
28
- /**
29
- * Throws when a storage state is configured but `factory` will not apply it. A
30
- * factory that neither creates a fresh context with the state nor applies it to
31
- * the context it reuses would drop it without a word and audit the site as an
32
- * anonymous user, which looks exactly like a successful run. Every factory in
33
- * this file applies it one way or the other; the extension factory cannot — it
34
- * works through the user's own running browser, where clearing every origin's
35
- * cookies to install the recorded state is not an acceptable side effect.
36
- * Callers pass the remedy that fits the mode they selected — the factory that
37
- * creates the context is not always the one `contextFactory()` built.
38
- */
28
+ /** Rejects factories that would silently ignore a configured storage state. */
39
29
  export function assertStorageStateSupported(config, factory, remedy) {
40
30
  if (config.browser.contextOptions?.storageState && !factory.appliesStorageState)
41
31
  throw new Error(`Storage state cannot be applied in this mode. ${remedy}`);
@@ -54,142 +44,10 @@ export function assertStorageStateDoesNotResetUserProfile(config, remedy) {
54
44
  }
55
45
  export function contextFactory(config) {
56
46
  const factory = createContextFactory(config);
57
- // Every built-in factory now applies a storage state; the guard stays so a
58
- // future factory that forgets to declare support rejects the option instead
59
- // of silently dropping it.
47
+ // Factories must apply the state to a fresh context or reject unsafe reuse.
60
48
  assertStorageStateSupported(config, factory, 'Drop the storage state and sign in interactively before auditing.');
61
49
  return factory;
62
50
  }
63
- // The rules addCookies enforces client-side (verified against Playwright
64
- // 1.61.1: empty or missing domain/path without a url, a url combined with a
65
- // domain or a path, about:blank/data:/unparseable urls, an expires other
66
- // than -1 or a positive number up to Playwright's ceiling, and sameSite
67
- // outside Strict/Lax/None are all rejected there — non-http(s) url schemes
68
- // and malformed origin strings fail browser-side during the apply). Failing
69
- // them here keeps the failure ahead of the cache clear; anything these
70
- // checks miss still fails inside setStorageState.
71
- const cookieUrlProblem = (url) => {
72
- if (typeof url !== 'string')
73
- return 'is not a string';
74
- if (url === 'about:blank')
75
- return 'cannot be about:blank';
76
- if (url.startsWith('data:'))
77
- return 'cannot be a data: URL';
78
- try {
79
- const parsed = new URL(url);
80
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
81
- return `must be an http(s) URL, not ${parsed.protocol}`;
82
- }
83
- catch {
84
- return 'is not a valid absolute URL';
85
- }
86
- return null;
87
- };
88
- const isValidIndexedDBKey = (value) => typeof value === 'string'
89
- || (typeof value === 'number' && Number.isFinite(value))
90
- || (value instanceof Date && Number.isFinite(value.getTime()))
91
- || value instanceof ArrayBuffer
92
- || ArrayBuffer.isView(value)
93
- || (Array.isArray(value) && value.every(isValidIndexedDBKey));
94
- const indexedDBIdentifier = /^[$_\p{ID_Start}][$\u200C\u200D\p{ID_Continue}]*$/u;
95
- const isValidIndexedDBKeyPath = (value) => typeof value === 'string' && (value === '' || value.split('.').every(part => indexedDBIdentifier.test(part)));
96
- const isValidIndexedDBKeyPathArray = (value) => Array.isArray(value) && !!value.length && value.every(isValidIndexedDBKeyPath);
97
- function assertValidStorageState(state) {
98
- for (const value of state.cookies ?? []) {
99
- const cookie = value;
100
- const problem = !cookie || typeof cookie !== 'object'
101
- ? 'a cookie entry is not an object'
102
- : !cookie.url && (!cookie.domain || !cookie.path)
103
- ? `cookie "${String(cookie.name ?? '')}" should have a url or a domain/path pair`
104
- : cookie.url && cookie.domain
105
- ? `cookie "${String(cookie.name ?? '')}" should have either a url or a domain, not both`
106
- : cookie.url && cookie.path
107
- ? `cookie "${String(cookie.name ?? '')}" should have either a url or a path, not both`
108
- : cookie.url !== undefined && cookieUrlProblem(cookie.url)
109
- ? `cookie "${String(cookie.name ?? '')}" has a url that ${cookieUrlProblem(cookie.url)}`
110
- : cookie.expires !== undefined && (typeof cookie.expires !== 'number' || Number.isNaN(cookie.expires) || (cookie.expires !== -1 && (cookie.expires <= 0 || cookie.expires > 253402300799)))
111
- ? `cookie "${String(cookie.name ?? '')}" should have a valid expires — only -1 or a positive unix timestamp in seconds up to 253402300799 (9999-12-31T23:59:59Z, Playwright's own ceiling) is allowed`
112
- : cookie.sameSite !== undefined && !['Strict', 'Lax', 'None'].includes(cookie.sameSite)
113
- ? `cookie "${String(cookie.name ?? '')}" has sameSite "${String(cookie.sameSite)}", expected one of Strict|Lax|None`
114
- : null;
115
- if (problem)
116
- throw new Error(`Invalid storage state: ${problem}. Nothing was changed — the state is validated before the apply, because setStorageState() clears the attached context's HTTP cache and cookie jar before it validates, and the cache cannot be restored.`);
117
- }
118
- for (const value of state.origins ?? []) {
119
- const entry = value;
120
- // Restoring an origin's storage navigates Playwright's temporary page to
121
- // it — a malformed or non-http(s) origin fails that navigation after the
122
- // clear.
123
- const problem = !entry || typeof entry !== 'object'
124
- ? 'an origins entry is not an object'
125
- : typeof entry.origin !== 'string' || cookieUrlProblem(entry.origin)
126
- ? `origins entry "${String(entry?.origin ?? '')}" is not an absolute http(s) URL`
127
- : null;
128
- if (problem)
129
- throw new Error(`Invalid storage state: ${problem}. Nothing was changed — the state is validated before the apply, because setStorageState() clears the attached context's HTTP cache and cookie jar before it validates, and the cache cannot be restored.`);
130
- const databaseNames = new Set();
131
- for (const value of Array.isArray(entry?.indexedDB) ? entry.indexedDB : []) {
132
- const database = value;
133
- const stores = Array.isArray(database.stores) ? database.stores : [];
134
- let indexedDBProblem = !Number.isSafeInteger(database.version) || database.version <= 0
135
- ? `IndexedDB database "${String(database.name ?? '')}" should have a positive integer version`
136
- : databaseNames.has(String(database.name))
137
- ? `IndexedDB database name "${String(database.name)}" is duplicated`
138
- : null;
139
- databaseNames.add(String(database.name));
140
- const storeNames = new Set();
141
- for (const store of stores) {
142
- const storeName = String(store.name);
143
- indexedDBProblem ??= storeNames.has(storeName)
144
- ? `IndexedDB object store name "${storeName}" is duplicated in database "${String(database.name)}"`
145
- : store.keyPath !== undefined && !isValidIndexedDBKeyPath(store.keyPath)
146
- ? `IndexedDB object store "${storeName}" has an invalid key path`
147
- : store.keyPathArray !== undefined && !isValidIndexedDBKeyPathArray(store.keyPathArray)
148
- ? `IndexedDB object store "${storeName}" has an invalid array key path`
149
- : store.autoIncrement && (store.keyPath === '' || Array.isArray(store.keyPathArray))
150
- ? `IndexedDB object store "${storeName}" cannot combine autoIncrement with an empty or array key path`
151
- : null;
152
- storeNames.add(storeName);
153
- const recordKeys = new Set();
154
- const hasInlineKey = store.keyPath !== undefined || store.keyPathArray !== undefined;
155
- for (const value of Array.isArray(store.records) ? store.records : []) {
156
- const record = value;
157
- const hasExternalKey = (record.key !== undefined && record.key !== null)
158
- || (record.keyEncoded !== undefined && record.keyEncoded !== null);
159
- const key = record.key ?? record.keyEncoded;
160
- const serializedKey = hasExternalKey ? JSON.stringify(key) ?? String(key) : '';
161
- indexedDBProblem ??= record.key !== undefined && record.key !== null && !isValidIndexedDBKey(record.key)
162
- ? `IndexedDB object store "${storeName}" has an invalid external record key`
163
- : hasInlineKey && hasExternalKey
164
- ? `IndexedDB object store "${storeName}" has an inline key path but record also supplies an external key`
165
- : !hasInlineKey && !store.autoIncrement && !hasExternalKey
166
- ? `IndexedDB object store "${storeName}" requires an external key for every record`
167
- : hasExternalKey && recordKeys.has(serializedKey)
168
- ? `IndexedDB object store "${storeName}" has duplicate record key ${serializedKey}`
169
- : null;
170
- if (hasExternalKey)
171
- recordKeys.add(serializedKey);
172
- }
173
- const indexNames = new Set();
174
- for (const index of Array.isArray(store.indexes) ? store.indexes : []) {
175
- const indexName = String(index.name);
176
- indexedDBProblem ??= indexNames.has(indexName)
177
- ? `IndexedDB index name "${indexName}" is duplicated in object store "${storeName}"`
178
- : index.keyPath !== undefined && !isValidIndexedDBKeyPath(index.keyPath)
179
- ? `IndexedDB index "${indexName}" has an invalid key path`
180
- : index.keyPathArray !== undefined && !isValidIndexedDBKeyPathArray(index.keyPathArray)
181
- ? `IndexedDB index "${indexName}" has an invalid array key path`
182
- : index.multiEntry && Array.isArray(index.keyPathArray)
183
- ? `IndexedDB index "${indexName}" cannot combine multiEntry with an array key path`
184
- : null;
185
- indexNames.add(indexName);
186
- }
187
- }
188
- if (indexedDBProblem)
189
- throw new Error(`Invalid storage state: ${indexedDBProblem}. Nothing was changed — the state is validated before the apply, because setStorageState() clears the attached context's HTTP cache and cookie jar before it validates, and the cache cannot be restored.`);
190
- }
191
- }
192
- }
193
51
  /**
194
52
  * Replaces every open page of `browserContext` with a blank fresh tab and
195
53
  * returns the fresh tabs paired with the URL each replaced page showed.
@@ -283,150 +141,10 @@ async function navigateReplacementPages(replaced) {
283
141
  }
284
142
  }));
285
143
  }
286
- /**
287
- * Lands the configured storage state in a context the browser already had.
288
- * `setStorageState()` clears the context's cookies, local storage and IndexedDB
289
- * and installs the recorded state the documented semantics of the option the
290
- * caller asked for, applied to a context `newContext()` never sees: the CDP
291
- * modes without --isolated reuse the browser's existing context, and
292
- * launchPersistentContext() silently ignores a storageState option (verified
293
- * against Playwright 1.61.1).
294
- */
295
- export async function applyStorageStateToReusedContext(config, browserContext) {
296
- const storageState = config.browser.contextOptions?.storageState;
297
- if (!storageState)
298
- return;
299
- const parsedState = await (async () => {
300
- try {
301
- return typeof storageState === 'string'
302
- ? JSON.parse(await fs.promises.readFile(storageState, 'utf-8'))
303
- : storageState;
304
- }
305
- catch (error) {
306
- // Letting setStorageState() discover the bad file would fail inside
307
- // the apply block, whose catch answers every failure with a rollback —
308
- // and the rollback's own setStorageState() clears the attached
309
- // context's HTTP cache. A config error that changed nothing must not
310
- // cost the running application its cache.
311
- throw new Error(`The storage state file could not be read or parsed: ${error instanceof Error ? error.message : String(error)}. Nothing was changed.`);
312
- }
313
- })();
314
- // Playwright validates cookies only while installing them — after the
315
- // attached context's HTTP cache and cookie jar are already cleared — so a
316
- // semantically invalid cookie (bad expires, missing domain/path) would
317
- // fail the apply with the cache unrestorably gone. Checked up front, with
318
- // the same rules addCookies enforces (verified against 1.61.1).
319
- assertValidStorageState(parsedState);
320
- // setStorageState needs a temporary page whenever the state carries origins
321
- // or the context has visited any — and by the time that page creation fails
322
- // on a target without Target.createTarget, the HTTP cache is already cleared
323
- // and cannot be put back. Probe the page creation first, so such targets are
324
- // rejected before anything is mutated. When neither signal indicates a page
325
- // will be needed (cookie-only state, no pages open), the probe is skipped so
326
- // that case keeps working on those targets.
327
- const stateHasOrigins = (parsedState.origins?.length ?? 0) > 0;
328
- const hasLoadedPages = browserContext.pages().some(page => page.url() && page.url() !== 'about:blank');
329
- if (stateHasOrigins || hasLoadedPages) {
330
- try {
331
- const probe = await browserContext.newPage();
332
- await probe.close();
333
- }
334
- catch (error) {
335
- const message = error instanceof Error ? error.message : String(error);
336
- if (message.includes('Target.createTarget'))
337
- throw new Error(`The attached browser cannot open the temporary page Playwright needs to apply the storage state's origin data (Electron targets do not support Target.createTarget). Nothing was changed. Drop the storage state and sign in inside the app instead. Original error: ${message}`);
338
- throw error;
339
- }
340
- }
341
- // setStorageState replaces the cookie jar and then rewrites origin storage
342
- // one origin at a time, so a failure partway would otherwise leave the
343
- // attached browser holding a mixture of old and recorded state while the
344
- // operation reports failure. Both layers are snapshotted first: the cookie
345
- // jar through pure protocol calls that work everywhere (kept as the
346
- // fallback for a restore whose own origin phase fails), and origin storage
347
- // through storageState() below.
348
- const originalCookies = await browserContext.cookies();
349
- // The snapshot doubles as a probe for origins this connection has already
350
- // visited while its pages have since closed or gone blank: for those,
351
- // storageState() opens the same temporary page the forward apply will need
352
- // — the newPage probe above cannot see them — but unlike setStorageState()
353
- // it mutates nothing, so a target that cannot create pages (Electron) is
354
- // rejected here with everything intact, not after the forward apply has
355
- // cleared the HTTP cache. Any other snapshot failure also aborts: without
356
- // the full snapshot, a partial forward apply could only be rolled back to
357
- // cookies, leaving the attached browser's origin storage part old, part
358
- // recorded.
359
- const originalState = await browserContext.storageState({ indexedDB: true }).catch((error) => {
360
- const message = error instanceof Error ? error.message : String(error);
361
- if (message.includes('Target.createTarget'))
362
- throw new Error(`The attached browser cannot open the temporary page Playwright needs to reset origin storage for origins this connection has already visited (Electron targets do not support Target.createTarget). Nothing was changed. Drop the storage state and sign in inside the app instead. Original error: ${message}`);
363
- throw new Error(`Snapshotting the context's current storage for rollback failed, so the storage state was not applied — a partial apply could not have been undone. Nothing was changed. Retry, or use --isolated for a fresh context. Original error: ${message}`);
364
- });
365
- // Pages that were already open still render the previous identity — and
366
- // their scripts keep running: a page that periodically persists
367
- // authentication into cookies or localStorage would overwrite the state
368
- // being installed if it were still alive during setStorageState(), and
369
- // replacing its tab afterwards cannot undo writes already made into
370
- // context-wide storage. Every open page is therefore replaced with a blank
371
- // fresh tab FIRST — the old document closes before the state lands, and
372
- // only blank replacements (which run no scripts) survive the apply — and
373
- // the replacements are navigated to the pages they replaced only once the
374
- // recorded state is in place.
375
- const replaced = await replaceOpenPagesWithBlankTabs(browserContext);
376
- const policyRequired = !!(config.network?.allowedOrigins?.length || config.network?.blockedOrigins?.length);
377
- let replacementNavigationSafe = !policyRequired;
378
- try {
379
- // The state validated above is the state applied: handing the path back
380
- // to Playwright would re-read the file here, and a file replaced since
381
- // that read would skip the cookie/origin validation and the page-creation
382
- // probe only to fail after the cache clear those exist to prevent.
383
- await browserContext.setStorageState(parsedState);
384
- // The replacement navigations run inside the factory, before Context
385
- // ensures the configured origin allowlist/blocklist — and the recorded
386
- // credentials are already in place by now. The policy is installed here
387
- // (permanently — page scripts can queue requests that fire after the
388
- // navigation settles, so removing the handlers before Context re-ensures
389
- // the same policy would open a window to a blocked origin;
390
- // ensureNetworkPolicyRoutes installs once per context, so Context's later
391
- // call is a no-op). Installed after setStorageState() so an abort-all
392
- // route cannot interfere with the temporary page Playwright drives to
393
- // restore origin storage.
394
- await ensureNetworkPolicyRoutes(config, browserContext);
395
- replacementNavigationSafe = true;
396
- await navigateReplacementPages(replaced);
397
- }
398
- catch (error) {
399
- // Prefer the full-state rollback; fall back to cookies-only when its
400
- // reapplication is itself impossible on this target.
401
- const restoredFully = await browserContext.setStorageState(originalState).then(() => true, () => false);
402
- const restoredCookies = restoredFully || await browserContext.clearCookies()
403
- .then(() => originalCookies.length ? browserContext.addCookies(originalCookies) : undefined)
404
- .then(() => true, () => false);
405
- // The old pages were closed before the apply and cannot be handed back.
406
- // Navigate their replacements only when no policy was required or its
407
- // installation succeeded; otherwise restored credentials stay offline.
408
- if (replacementNavigationSafe)
409
- await navigateReplacementPages(replaced);
410
- else
411
- await Promise.all(replaced.map(({ page }) => page.close().catch(() => { })));
412
- // Restoring origin storage (localStorage/IndexedDB) makes Playwright open a
413
- // temporary page; a CDP target that cannot create one — Electron has no
414
- // Target.createTarget — fails here. Cookie-only states need no page and
415
- // still work on such targets, so name that remedy instead of surfacing the
416
- // raw protocol error.
417
- const message = error instanceof Error ? error.message : String(error);
418
- if (message.includes('Target.createTarget')) {
419
- const rollbackNote = restoredFully
420
- ? 'The context\'s original storage state was restored.'
421
- : restoredCookies
422
- ? 'The context\'s original cookies were restored.'
423
- : 'Restoring the context\'s original cookies also failed; its cookie jar may now hold the recorded state.';
424
- throw new Error(`The attached browser cannot open the temporary page Playwright needs to reset origin storage (Electron targets do not support Target.createTarget). Drop the storage state and sign in inside the app instead — a cookies-only state helps only while the attached target has no pages open and the connection has visited no origin, because clearing storage for an already-visited origin needs the same temporary page. ${rollbackNote} Original error: ${message}`);
425
- }
426
- if (!restoredFully && restoredCookies)
427
- throw new Error(`${message} The context's original cookies were restored, but origin storage may retain partially applied state.`);
428
- throw error;
429
- }
144
+ /** Existing contexts can contain service workers and storage we cannot safely snapshot. */
145
+ export function assertReusedContextStorageStateSupported(config) {
146
+ if (config.browser.contextOptions?.storageState)
147
+ throw new Error('Cannot apply --storage-state to an existing browser context on Playwright 1.63.0: its rollback snapshot can run service-worker-served scripts and alter storage. Use a fresh context (for CDP, add --isolated), or omit --storage-state and sign in interactively. No storage snapshot or reset was attempted.');
430
148
  }
431
149
  function createContextFactory(config) {
432
150
  if (config.browser.remoteEndpoint)
@@ -615,17 +333,10 @@ class IsolatedContextFactory extends BaseContextFactory {
615
333
  }
616
334
  }
617
335
  class CdpContextFactory extends BaseContextFactory {
618
- // The isolated path creates a fresh context with the state; the attach path
619
- // applies it to the browser's existing context via setStorageState().
336
+ // Fresh contexts accept storage state; existing external contexts reject it.
620
337
  appliesStorageState = true;
621
- // One attached browser and its default context serves every session
622
- // this factory creates. Re-running the global setStorageState() for a
623
- // second session would wipe the first session's live cookies and origin
624
- // storage mid-audit and reload its pages, so the state is applied once per
625
- // context object: later sessions join the live shared state. Keyed weakly —
626
- // a reconnect yields a fresh context object, so the slate resets with the
627
- // connection — and a failed apply is forgotten so the next session retries.
628
- _storageStateApplied = new WeakMap();
338
+ // Contexts created by this factory already received the configured state.
339
+ _createdContexts = new WeakSet();
629
340
  // Serializes the no-context fallback the same way: two sessions arriving at
630
341
  // a contextless target must share one created context, not race two.
631
342
  _fallbackContext = new WeakMap();
@@ -725,15 +436,13 @@ class CdpContextFactory extends BaseContextFactory {
725
436
  const existing = browser.contexts()[0];
726
437
  // An attached browser can expose no context at all; a fresh one created
727
438
  // with the configured options (storage state included) beats handing an
728
- // undefined context to the caller. The created context immediately seeds
729
- // the applied-state memo a later session will find it as the browser's
730
- // existing context, and must join it rather than reset it — and the
731
- // creation itself is memoized so concurrent arrivals share one context.
439
+ // undefined context to the caller. Track ownership so later sessions may
440
+ // join that context; memoize creation so concurrent arrivals share it.
732
441
  if (!existing) {
733
442
  let creating = this._fallbackContext.get(browser);
734
443
  if (!creating) {
735
444
  creating = browser.newContext(this.config.browser.contextOptions).then(created => {
736
- this._storageStateApplied.set(created, Promise.resolve());
445
+ this._createdContexts.add(created);
737
446
  // Evict on close, or a context closed externally (while the
738
447
  // connection lives on) would keep being handed out of this memo to
739
448
  // every later session — contexts() no longer lists it, so only the
@@ -746,15 +455,8 @@ class CdpContextFactory extends BaseContextFactory {
746
455
  }
747
456
  return await creating;
748
457
  }
749
- // The shared promise also serializes two sessions arriving at once: both
750
- // await the same application instead of racing two global resets.
751
- let applied = this._storageStateApplied.get(existing);
752
- if (!applied) {
753
- applied = applyStorageStateToReusedContext(this.config, existing);
754
- this._storageStateApplied.set(existing, applied);
755
- applied.catch(() => this._storageStateApplied.delete(existing));
756
- }
757
- await applied;
458
+ if (!this._createdContexts.has(existing))
459
+ assertReusedContextStorageStateSupported(this.config);
758
460
  return existing;
759
461
  }
760
462
  }
@@ -877,7 +579,7 @@ class CdpLaunchContextFactory {
877
579
  else {
878
580
  const existing = browser.contexts()[0];
879
581
  if (existing) {
880
- await applyStorageStateToReusedContext(this.config, existing);
582
+ assertReusedContextStorageStateSupported(this.config);
881
583
  browserContext = existing;
882
584
  }
883
585
  else {