tabwright 1.0.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/LICENSE +21 -0
- package/bin.js +4 -0
- package/dist/a11y-client.js +204 -0
- package/dist/agent-skills/tabwright/SKILL.md +119 -0
- package/dist/aria-snapshot.d.ts +262 -0
- package/dist/aria-snapshot.d.ts.map +1 -0
- package/dist/aria-snapshot.js +1256 -0
- package/dist/aria-snapshot.js.map +1 -0
- package/dist/aria-snapshot.test.d.ts +2 -0
- package/dist/aria-snapshot.test.d.ts.map +1 -0
- package/dist/aria-snapshot.test.js +270 -0
- package/dist/aria-snapshot.test.js.map +1 -0
- package/dist/aria-snapshot.unit.test.d.ts +2 -0
- package/dist/aria-snapshot.unit.test.d.ts.map +1 -0
- package/dist/aria-snapshot.unit.test.js +542 -0
- package/dist/aria-snapshot.unit.test.js.map +1 -0
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts +5 -0
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts.map +1 -0
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js +5 -0
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js.map +1 -0
- package/dist/bippy.js +894 -0
- package/dist/browser-config.d.ts +13 -0
- package/dist/browser-config.d.ts.map +1 -0
- package/dist/browser-config.js +126 -0
- package/dist/browser-config.js.map +1 -0
- package/dist/browser-install.d.ts +16 -0
- package/dist/browser-install.d.ts.map +1 -0
- package/dist/browser-install.js +238 -0
- package/dist/browser-install.js.map +1 -0
- package/dist/browser-launch.d.ts +17 -0
- package/dist/browser-launch.d.ts.map +1 -0
- package/dist/browser-launch.js +44 -0
- package/dist/browser-launch.js.map +1 -0
- package/dist/builtin-capabilities.d.ts +28 -0
- package/dist/builtin-capabilities.d.ts.map +1 -0
- package/dist/builtin-capabilities.js +2673 -0
- package/dist/builtin-capabilities.js.map +1 -0
- package/dist/capability-agent-skill.d.ts +60 -0
- package/dist/capability-agent-skill.d.ts.map +1 -0
- package/dist/capability-agent-skill.js +225 -0
- package/dist/capability-agent-skill.js.map +1 -0
- package/dist/capability-auth-state.d.ts +34 -0
- package/dist/capability-auth-state.d.ts.map +1 -0
- package/dist/capability-auth-state.js +157 -0
- package/dist/capability-auth-state.js.map +1 -0
- package/dist/capability-auth.d.ts +36 -0
- package/dist/capability-auth.d.ts.map +1 -0
- package/dist/capability-auth.js +198 -0
- package/dist/capability-auth.js.map +1 -0
- package/dist/capability-cli-confirmation.test.d.ts +2 -0
- package/dist/capability-cli-confirmation.test.d.ts.map +1 -0
- package/dist/capability-cli-confirmation.test.js +79 -0
- package/dist/capability-cli-confirmation.test.js.map +1 -0
- package/dist/capability-options.d.ts +20 -0
- package/dist/capability-options.d.ts.map +1 -0
- package/dist/capability-options.js +31 -0
- package/dist/capability-options.js.map +1 -0
- package/dist/capability-options.test.d.ts +2 -0
- package/dist/capability-options.test.d.ts.map +1 -0
- package/dist/capability-options.test.js +81 -0
- package/dist/capability-options.test.js.map +1 -0
- package/dist/capability-package.d.ts +27 -0
- package/dist/capability-package.d.ts.map +1 -0
- package/dist/capability-package.js +444 -0
- package/dist/capability-package.js.map +1 -0
- package/dist/capability-package.test.d.ts +2 -0
- package/dist/capability-package.test.d.ts.map +1 -0
- package/dist/capability-package.test.js +179 -0
- package/dist/capability-package.test.js.map +1 -0
- package/dist/capability-registry.d.ts +262 -0
- package/dist/capability-registry.d.ts.map +1 -0
- package/dist/capability-registry.js +931 -0
- package/dist/capability-registry.js.map +1 -0
- package/dist/capability-registry.test.d.ts +2 -0
- package/dist/capability-registry.test.d.ts.map +1 -0
- package/dist/capability-registry.test.js +1683 -0
- package/dist/capability-registry.test.js.map +1 -0
- package/dist/capability-runner.d.ts +99 -0
- package/dist/capability-runner.d.ts.map +1 -0
- package/dist/capability-runner.js +608 -0
- package/dist/capability-runner.js.map +1 -0
- package/dist/capability-search-relevance.test.d.ts +2 -0
- package/dist/capability-search-relevance.test.d.ts.map +1 -0
- package/dist/capability-search-relevance.test.js +67 -0
- package/dist/capability-search-relevance.test.js.map +1 -0
- package/dist/capability-studio.d.ts +11 -0
- package/dist/capability-studio.d.ts.map +1 -0
- package/dist/capability-studio.js +593 -0
- package/dist/capability-studio.js.map +1 -0
- package/dist/capability-studio.test.d.ts +2 -0
- package/dist/capability-studio.test.d.ts.map +1 -0
- package/dist/capability-studio.test.js +41 -0
- package/dist/capability-studio.test.js.map +1 -0
- package/dist/cdp-log.d.ts +19 -0
- package/dist/cdp-log.d.ts.map +1 -0
- package/dist/cdp-log.js +83 -0
- package/dist/cdp-log.js.map +1 -0
- package/dist/cdp-log.test.d.ts +2 -0
- package/dist/cdp-log.test.d.ts.map +1 -0
- package/dist/cdp-log.test.js +109 -0
- package/dist/cdp-log.test.js.map +1 -0
- package/dist/cdp-relay.d.ts +19 -0
- package/dist/cdp-relay.d.ts.map +1 -0
- package/dist/cdp-relay.js +2375 -0
- package/dist/cdp-relay.js.map +1 -0
- package/dist/cdp-session.d.ts +37 -0
- package/dist/cdp-session.d.ts.map +1 -0
- package/dist/cdp-session.js +36 -0
- package/dist/cdp-session.js.map +1 -0
- package/dist/cdp-types.d.ts +65 -0
- package/dist/cdp-types.d.ts.map +1 -0
- package/dist/cdp-types.js +91 -0
- package/dist/cdp-types.js.map +1 -0
- package/dist/channel-owner-inspect.test.d.ts +2 -0
- package/dist/channel-owner-inspect.test.d.ts.map +1 -0
- package/dist/channel-owner-inspect.test.js +75 -0
- package/dist/channel-owner-inspect.test.js.map +1 -0
- package/dist/chrome-discovery.d.ts +65 -0
- package/dist/chrome-discovery.d.ts.map +1 -0
- package/dist/chrome-discovery.js +173 -0
- package/dist/chrome-discovery.js.map +1 -0
- package/dist/chrome-discovery.test.d.ts +2 -0
- package/dist/chrome-discovery.test.d.ts.map +1 -0
- package/dist/chrome-discovery.test.js +41 -0
- package/dist/chrome-discovery.test.js.map +1 -0
- package/dist/clean-html.d.ts +11 -0
- package/dist/clean-html.d.ts.map +1 -0
- package/dist/clean-html.js +106 -0
- package/dist/clean-html.js.map +1 -0
- package/dist/cli-help.test.d.ts +2 -0
- package/dist/cli-help.test.d.ts.map +1 -0
- package/dist/cli-help.test.js +98 -0
- package/dist/cli-help.test.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +2709 -0
- package/dist/cli.js.map +1 -0
- package/dist/cloud-client.d.ts +56 -0
- package/dist/cloud-client.d.ts.map +1 -0
- package/dist/cloud-client.js +127 -0
- package/dist/cloud-client.js.map +1 -0
- package/dist/create-logger.d.ts +9 -0
- package/dist/create-logger.d.ts.map +1 -0
- package/dist/create-logger.js +27 -0
- package/dist/create-logger.js.map +1 -0
- package/dist/debugger-api.md +458 -0
- package/dist/debugger-examples-types.d.ts +24 -0
- package/dist/debugger-examples-types.d.ts.map +1 -0
- package/dist/debugger-examples-types.js +2 -0
- package/dist/debugger-examples-types.js.map +1 -0
- package/dist/debugger-examples.d.ts +6 -0
- package/dist/debugger-examples.d.ts.map +1 -0
- package/dist/debugger-examples.js +53 -0
- package/dist/debugger-examples.js.map +1 -0
- package/dist/debugger.d.ts +381 -0
- package/dist/debugger.d.ts.map +1 -0
- package/dist/debugger.js +630 -0
- package/dist/debugger.js.map +1 -0
- package/dist/diff-utils.d.ts +20 -0
- package/dist/diff-utils.d.ts.map +1 -0
- package/dist/diff-utils.js +50 -0
- package/dist/diff-utils.js.map +1 -0
- package/dist/diff-utils.test.d.ts +2 -0
- package/dist/diff-utils.test.d.ts.map +1 -0
- package/dist/diff-utils.test.js +137 -0
- package/dist/diff-utils.test.js.map +1 -0
- package/dist/doctor.d.ts +44 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +246 -0
- package/dist/doctor.js.map +1 -0
- package/dist/doctor.test.d.ts +2 -0
- package/dist/doctor.test.d.ts.map +1 -0
- package/dist/doctor.test.js +208 -0
- package/dist/doctor.test.js.map +1 -0
- package/dist/editor-api.md +374 -0
- package/dist/editor-examples.d.ts +11 -0
- package/dist/editor-examples.d.ts.map +1 -0
- package/dist/editor-examples.js +124 -0
- package/dist/editor-examples.js.map +1 -0
- package/dist/editor.d.ts +203 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +363 -0
- package/dist/editor.js.map +1 -0
- package/dist/env-compat.d.ts +2 -0
- package/dist/env-compat.d.ts.map +1 -0
- package/dist/env-compat.js +27 -0
- package/dist/env-compat.js.map +1 -0
- package/dist/executor.d.ts +229 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/executor.js +1465 -0
- package/dist/executor.js.map +1 -0
- package/dist/executor.unit.test.d.ts +2 -0
- package/dist/executor.unit.test.d.ts.map +1 -0
- package/dist/executor.unit.test.js +143 -0
- package/dist/executor.unit.test.js.map +1 -0
- package/dist/extension/_locales/en/messages.json +278 -0
- package/dist/extension/_locales/zh_CN/messages.json +256 -0
- package/dist/extension/assets/options-CYH96Lcx.css +1349 -0
- package/dist/extension/background.js +3994 -0
- package/dist/extension/icons/SolarCursorSquareBold.png +0 -0
- package/dist/extension/icons/icon-black-128.png +0 -0
- package/dist/extension/icons/icon-black-16.png +0 -0
- package/dist/extension/icons/icon-black-32.png +0 -0
- package/dist/extension/icons/icon-black-48.png +0 -0
- package/dist/extension/icons/icon-gray-128.png +0 -0
- package/dist/extension/icons/icon-gray-16.png +0 -0
- package/dist/extension/icons/icon-gray-32.png +0 -0
- package/dist/extension/icons/icon-gray-48.png +0 -0
- package/dist/extension/icons/icon-green-128.png +0 -0
- package/dist/extension/icons/icon-green-16.png +0 -0
- package/dist/extension/icons/icon-green-32.png +0 -0
- package/dist/extension/icons/icon-green-48.png +0 -0
- package/dist/extension/icons/tabwright-icon-black.png +0 -0
- package/dist/extension/icons/tabwright-icon-gray-disabled.png +0 -0
- package/dist/extension/icons/tabwright-icon-gray-disabled.svg +1 -0
- package/dist/extension/manifest.json +54 -0
- package/dist/extension/options.js +16145 -0
- package/dist/extension/rrweb-recorder.js +12952 -0
- package/dist/extension/src/options.html +165 -0
- package/dist/extension/src/prism-bash.min.js +1 -0
- package/dist/extension/src/prism.min.js +1 -0
- package/dist/extension/src/welcome.html +396 -0
- package/dist/extension-connection.test.d.ts +2 -0
- package/dist/extension-connection.test.d.ts.map +1 -0
- package/dist/extension-connection.test.js +708 -0
- package/dist/extension-connection.test.js.map +1 -0
- package/dist/extension-icon-warning.test.d.ts +2 -0
- package/dist/extension-icon-warning.test.d.ts.map +1 -0
- package/dist/extension-icon-warning.test.js +33 -0
- package/dist/extension-icon-warning.test.js.map +1 -0
- package/dist/ghost-browser.d.ts +62 -0
- package/dist/ghost-browser.d.ts.map +1 -0
- package/dist/ghost-browser.js +107 -0
- package/dist/ghost-browser.js.map +1 -0
- package/dist/ghost-cursor-client.js +374 -0
- package/dist/ghost-cursor-controller.d.ts +46 -0
- package/dist/ghost-cursor-controller.d.ts.map +1 -0
- package/dist/ghost-cursor-controller.js +98 -0
- package/dist/ghost-cursor-controller.js.map +1 -0
- package/dist/ghost-cursor.d.ts +27 -0
- package/dist/ghost-cursor.d.ts.map +1 -0
- package/dist/ghost-cursor.js +79 -0
- package/dist/ghost-cursor.js.map +1 -0
- package/dist/htmlrewrite.d.ts +8 -0
- package/dist/htmlrewrite.d.ts.map +1 -0
- package/dist/htmlrewrite.js +339 -0
- package/dist/htmlrewrite.js.map +1 -0
- package/dist/htmlrewrite.test.d.ts +2 -0
- package/dist/htmlrewrite.test.d.ts.map +1 -0
- package/dist/htmlrewrite.test.js +7975 -0
- package/dist/htmlrewrite.test.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/kill-port.d.ts +29 -0
- package/dist/kill-port.d.ts.map +1 -0
- package/dist/kill-port.js +190 -0
- package/dist/kill-port.js.map +1 -0
- package/dist/kitty-graphics.d.ts +22 -0
- package/dist/kitty-graphics.d.ts.map +1 -0
- package/dist/kitty-graphics.js +71 -0
- package/dist/kitty-graphics.js.map +1 -0
- package/dist/kitty-graphics.test.d.ts +2 -0
- package/dist/kitty-graphics.test.d.ts.map +1 -0
- package/dist/kitty-graphics.test.js +125 -0
- package/dist/kitty-graphics.test.js.map +1 -0
- package/dist/locator-selector.test.d.ts +2 -0
- package/dist/locator-selector.test.d.ts.map +1 -0
- package/dist/locator-selector.test.js +98 -0
- package/dist/locator-selector.test.js.map +1 -0
- package/dist/mcp-client.d.ts +20 -0
- package/dist/mcp-client.d.ts.map +1 -0
- package/dist/mcp-client.js +56 -0
- package/dist/mcp-client.js.map +1 -0
- package/dist/mcp.d.ts +6 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +511 -0
- package/dist/mcp.js.map +1 -0
- package/dist/on-mouse-action.test.d.ts +2 -0
- package/dist/on-mouse-action.test.d.ts.map +1 -0
- package/dist/on-mouse-action.test.js +180 -0
- package/dist/on-mouse-action.test.js.map +1 -0
- package/dist/options-page.test.d.ts +2 -0
- package/dist/options-page.test.d.ts.map +1 -0
- package/dist/options-page.test.js +773 -0
- package/dist/options-page.test.js.map +1 -0
- package/dist/package-paths.d.ts +4 -0
- package/dist/package-paths.d.ts.map +1 -0
- package/dist/package-paths.js +30 -0
- package/dist/package-paths.js.map +1 -0
- package/dist/page-markdown.d.ts +40 -0
- package/dist/page-markdown.d.ts.map +1 -0
- package/dist/page-markdown.js +181 -0
- package/dist/page-markdown.js.map +1 -0
- package/dist/performance-examples.d.ts +5 -0
- package/dist/performance-examples.d.ts.map +1 -0
- package/dist/performance-examples.js +112 -0
- package/dist/performance-examples.js.map +1 -0
- package/dist/performance-profiling.md +417 -0
- package/dist/playwright-import.d.ts +19 -0
- package/dist/playwright-import.d.ts.map +1 -0
- package/dist/playwright-import.js +40 -0
- package/dist/playwright-import.js.map +1 -0
- package/dist/popup-relocation.test.d.ts +7 -0
- package/dist/popup-relocation.test.d.ts.map +1 -0
- package/dist/popup-relocation.test.js +139 -0
- package/dist/popup-relocation.test.js.map +1 -0
- package/dist/product-paths.d.ts +9 -0
- package/dist/product-paths.d.ts.map +1 -0
- package/dist/product-paths.js +24 -0
- package/dist/product-paths.js.map +1 -0
- package/dist/product-paths.test.d.ts +2 -0
- package/dist/product-paths.test.d.ts.map +1 -0
- package/dist/product-paths.test.js +39 -0
- package/dist/product-paths.test.js.map +1 -0
- package/dist/prompt.md +1004 -0
- package/dist/protocol.d.ts +248 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +59 -0
- package/dist/protocol.js.map +1 -0
- package/dist/protocol.test.d.ts +2 -0
- package/dist/protocol.test.d.ts.map +1 -0
- package/dist/protocol.test.js +31 -0
- package/dist/protocol.test.js.map +1 -0
- package/dist/react-source.d.ts +57 -0
- package/dist/react-source.d.ts.map +1 -0
- package/dist/react-source.js +254 -0
- package/dist/react-source.js.map +1 -0
- package/dist/readability.js +1674 -0
- package/dist/relay-client.d.ts +104 -0
- package/dist/relay-client.d.ts.map +1 -0
- package/dist/relay-client.js +426 -0
- package/dist/relay-client.js.map +1 -0
- package/dist/relay-client.test.d.ts +2 -0
- package/dist/relay-client.test.d.ts.map +1 -0
- package/dist/relay-client.test.js +305 -0
- package/dist/relay-client.test.js.map +1 -0
- package/dist/relay-core.test.d.ts +2 -0
- package/dist/relay-core.test.d.ts.map +1 -0
- package/dist/relay-core.test.js +1427 -0
- package/dist/relay-core.test.js.map +1 -0
- package/dist/relay-navigation.test.d.ts +2 -0
- package/dist/relay-navigation.test.d.ts.map +1 -0
- package/dist/relay-navigation.test.js +661 -0
- package/dist/relay-navigation.test.js.map +1 -0
- package/dist/relay-session.test.d.ts +2 -0
- package/dist/relay-session.test.d.ts.map +1 -0
- package/dist/relay-session.test.js +1121 -0
- package/dist/relay-session.test.js.map +1 -0
- package/dist/relay-state.d.ts +178 -0
- package/dist/relay-state.d.ts.map +1 -0
- package/dist/relay-state.js +346 -0
- package/dist/relay-state.js.map +1 -0
- package/dist/relay-state.test.d.ts +2 -0
- package/dist/relay-state.test.d.ts.map +1 -0
- package/dist/relay-state.test.js +594 -0
- package/dist/relay-state.test.js.map +1 -0
- package/dist/replay-ai-index.d.ts +99 -0
- package/dist/replay-ai-index.d.ts.map +1 -0
- package/dist/replay-ai-index.js +677 -0
- package/dist/replay-ai-index.js.map +1 -0
- package/dist/replay-ai-index.test.d.ts +2 -0
- package/dist/replay-ai-index.test.d.ts.map +1 -0
- package/dist/replay-ai-index.test.js +255 -0
- package/dist/replay-ai-index.test.js.map +1 -0
- package/dist/replay-cli.test.d.ts +2 -0
- package/dist/replay-cli.test.d.ts.map +1 -0
- package/dist/replay-cli.test.js +275 -0
- package/dist/replay-cli.test.js.map +1 -0
- package/dist/replay-eval.d.ts +44 -0
- package/dist/replay-eval.d.ts.map +1 -0
- package/dist/replay-eval.js +902 -0
- package/dist/replay-eval.js.map +1 -0
- package/dist/replay-handoff.d.ts +29 -0
- package/dist/replay-handoff.d.ts.map +1 -0
- package/dist/replay-handoff.js +83 -0
- package/dist/replay-handoff.js.map +1 -0
- package/dist/replay-handoff.test.d.ts +2 -0
- package/dist/replay-handoff.test.d.ts.map +1 -0
- package/dist/replay-handoff.test.js +123 -0
- package/dist/replay-handoff.test.js.map +1 -0
- package/dist/replay-workflow-compiler.d.ts +44 -0
- package/dist/replay-workflow-compiler.d.ts.map +1 -0
- package/dist/replay-workflow-compiler.js +601 -0
- package/dist/replay-workflow-compiler.js.map +1 -0
- package/dist/replay-workflow-compiler.test.d.ts +2 -0
- package/dist/replay-workflow-compiler.test.d.ts.map +1 -0
- package/dist/replay-workflow-compiler.test.js +223 -0
- package/dist/replay-workflow-compiler.test.js.map +1 -0
- package/dist/rrweb-recording-relay.d.ts +56 -0
- package/dist/rrweb-recording-relay.d.ts.map +1 -0
- package/dist/rrweb-recording-relay.js +299 -0
- package/dist/rrweb-recording-relay.js.map +1 -0
- package/dist/rrweb-recording-relay.test.d.ts +2 -0
- package/dist/rrweb-recording-relay.test.d.ts.map +1 -0
- package/dist/rrweb-recording-relay.test.js +85 -0
- package/dist/rrweb-recording-relay.test.js.map +1 -0
- package/dist/rrweb-recording.d.ts +104 -0
- package/dist/rrweb-recording.d.ts.map +1 -0
- package/dist/rrweb-recording.js +180 -0
- package/dist/rrweb-recording.js.map +1 -0
- package/dist/scoped-fs.d.ts +95 -0
- package/dist/scoped-fs.d.ts.map +1 -0
- package/dist/scoped-fs.js +358 -0
- package/dist/scoped-fs.js.map +1 -0
- package/dist/scoped-fs.test.d.ts +5 -0
- package/dist/scoped-fs.test.d.ts.map +1 -0
- package/dist/scoped-fs.test.js +73 -0
- package/dist/scoped-fs.test.js.map +1 -0
- package/dist/selector-generator.js +7378 -0
- package/dist/skill-stub.test.d.ts +2 -0
- package/dist/skill-stub.test.d.ts.map +1 -0
- package/dist/skill-stub.test.js +22 -0
- package/dist/skill-stub.test.js.map +1 -0
- package/dist/snapshot-tools.test.d.ts +2 -0
- package/dist/snapshot-tools.test.d.ts.map +1 -0
- package/dist/snapshot-tools.test.js +856 -0
- package/dist/snapshot-tools.test.js.map +1 -0
- package/dist/start-relay-server.d.ts +6 -0
- package/dist/start-relay-server.d.ts.map +1 -0
- package/dist/start-relay-server.js +57 -0
- package/dist/start-relay-server.js.map +1 -0
- package/dist/styles-api.md +124 -0
- package/dist/styles-examples.d.ts +8 -0
- package/dist/styles-examples.d.ts.map +1 -0
- package/dist/styles-examples.js +64 -0
- package/dist/styles-examples.js.map +1 -0
- package/dist/styles.d.ts +27 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +231 -0
- package/dist/styles.js.map +1 -0
- package/dist/tabwright-agent-skill.d.ts +30 -0
- package/dist/tabwright-agent-skill.d.ts.map +1 -0
- package/dist/tabwright-agent-skill.js +74 -0
- package/dist/tabwright-agent-skill.js.map +1 -0
- package/dist/tabwright-agent-skill.test.d.ts +2 -0
- package/dist/tabwright-agent-skill.test.d.ts.map +1 -0
- package/dist/tabwright-agent-skill.test.js +51 -0
- package/dist/tabwright-agent-skill.test.js.map +1 -0
- package/dist/test-declarations.d.ts +13 -0
- package/dist/test-declarations.d.ts.map +1 -0
- package/dist/test-declarations.js +2 -0
- package/dist/test-declarations.js.map +1 -0
- package/dist/test-utils.d.ts +69 -0
- package/dist/test-utils.d.ts.map +1 -0
- package/dist/test-utils.js +448 -0
- package/dist/test-utils.js.map +1 -0
- package/dist/test-utils.test.d.ts +2 -0
- package/dist/test-utils.test.d.ts.map +1 -0
- package/dist/test-utils.test.js +55 -0
- package/dist/test-utils.test.js.map +1 -0
- package/dist/utils.d.ts +26 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +60 -0
- package/dist/utils.js.map +1 -0
- package/dist/wait-for-page-load.d.ts +16 -0
- package/dist/wait-for-page-load.d.ts.map +1 -0
- package/dist/wait-for-page-load.js +146 -0
- package/dist/wait-for-page-load.js.map +1 -0
- package/dist/workflow-capability.d.ts +72 -0
- package/dist/workflow-capability.d.ts.map +1 -0
- package/dist/workflow-capability.js +316 -0
- package/dist/workflow-capability.js.map +1 -0
- package/dist/workflow-script-flow.test.d.ts +2 -0
- package/dist/workflow-script-flow.test.d.ts.map +1 -0
- package/dist/workflow-script-flow.test.js +285 -0
- package/dist/workflow-script-flow.test.js.map +1 -0
- package/package.json +80 -0
- package/src/__snapshots__/x.com.processed.html +1003 -0
- package/src/__snapshots__/x.com.processed.withStyles.html +2720 -0
- package/src/a11y-client.ts +241 -0
- package/src/agent-skills/conan-config/SKILL.md +141 -0
- package/src/agent-skills/conan-config/agents/openai.yaml +4 -0
- package/src/aria-snapshot.test.ts +311 -0
- package/src/aria-snapshot.ts +1681 -0
- package/src/aria-snapshot.unit.test.ts +615 -0
- package/src/aria-snapshots/github-interactive.txt +193 -0
- package/src/aria-snapshots/github-raw.txt +309 -0
- package/src/aria-snapshots/hackernews-interactive.txt +477 -0
- package/src/aria-snapshots/hackernews-raw.txt +428 -0
- package/src/aria-snapshots/prosemirror-interactive.txt +66 -0
- package/src/aria-snapshots/prosemirror-raw.txt +145 -0
- package/src/assets/aria-labels-hacker-news.png +0 -0
- package/src/assets/aria-labels-old-reddit.png +0 -0
- package/src/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.ts +5 -0
- package/src/assets/cursors/screen-studio/pointer-macos-tahoe.svg +18 -0
- package/src/assets/x.com.html +32946 -0
- package/src/browser-config.ts +179 -0
- package/src/browser-install.ts +284 -0
- package/src/browser-launch.ts +75 -0
- package/src/builtin-capabilities.ts +2765 -0
- package/src/capability-agent-skill.ts +305 -0
- package/src/capability-auth-state.ts +197 -0
- package/src/capability-auth.ts +249 -0
- package/src/capability-cli-confirmation.test.ts +90 -0
- package/src/capability-options.test.ts +89 -0
- package/src/capability-options.ts +57 -0
- package/src/capability-package.test.ts +204 -0
- package/src/capability-package.ts +555 -0
- package/src/capability-registry.test.ts +1905 -0
- package/src/capability-registry.ts +1234 -0
- package/src/capability-runner.ts +801 -0
- package/src/capability-search-relevance.test.ts +73 -0
- package/src/capability-studio.test.ts +45 -0
- package/src/capability-studio.ts +641 -0
- package/src/cdp-log.test.ts +131 -0
- package/src/cdp-log.ts +112 -0
- package/src/cdp-relay.ts +2944 -0
- package/src/cdp-session.ts +77 -0
- package/src/cdp-types.ts +158 -0
- package/src/channel-owner-inspect.test.ts +94 -0
- package/src/chrome-discovery.test.ts +50 -0
- package/src/chrome-discovery.ts +220 -0
- package/src/clean-html.ts +141 -0
- package/src/cli-help.test.ts +123 -0
- package/src/cli.ts +3181 -0
- package/src/cloud-client.ts +179 -0
- package/src/create-logger.ts +38 -0
- package/src/debugger-examples-types.ts +16 -0
- package/src/debugger-examples.ts +66 -0
- package/src/debugger.ts +708 -0
- package/src/diff-utils.test.ts +152 -0
- package/src/diff-utils.ts +70 -0
- package/src/doctor.test.ts +244 -0
- package/src/doctor.ts +321 -0
- package/src/editor-examples.ts +158 -0
- package/src/editor.ts +426 -0
- package/src/env-compat.ts +28 -0
- package/src/executor.ts +1824 -0
- package/src/executor.unit.test.ts +168 -0
- package/src/extension-connection.test.ts +855 -0
- package/src/extension-icon-warning.test.ts +45 -0
- package/src/ghost-browser.ts +149 -0
- package/src/ghost-cursor-client.ts +494 -0
- package/src/ghost-cursor-controller.ts +129 -0
- package/src/ghost-cursor.ts +123 -0
- package/src/htmlrewrite.test.ts +8008 -0
- package/src/htmlrewrite.ts +390 -0
- package/src/index.ts +53 -0
- package/src/kill-port.ts +219 -0
- package/src/kitty-graphics.test.ts +139 -0
- package/src/kitty-graphics.ts +79 -0
- package/src/locator-selector.test.ts +119 -0
- package/src/mcp-client.ts +78 -0
- package/src/mcp.ts +629 -0
- package/src/on-mouse-action.test.ts +226 -0
- package/src/options-page.test.ts +839 -0
- package/src/package-paths.ts +38 -0
- package/src/page-markdown.ts +240 -0
- package/src/performance-examples.ts +186 -0
- package/src/playwright-import.ts +59 -0
- package/src/popup-relocation.test.ts +163 -0
- package/src/product-paths.test.ts +47 -0
- package/src/product-paths.ts +28 -0
- package/src/protocol.test.ts +52 -0
- package/src/protocol.ts +351 -0
- package/src/react-source.ts +379 -0
- package/src/relay-client.test.ts +375 -0
- package/src/relay-client.ts +572 -0
- package/src/relay-core.test.ts +1603 -0
- package/src/relay-navigation.test.ts +790 -0
- package/src/relay-session.test.ts +1375 -0
- package/src/relay-state.test.ts +729 -0
- package/src/relay-state.ts +561 -0
- package/src/replay-ai-index.test.ts +278 -0
- package/src/replay-ai-index.ts +872 -0
- package/src/replay-cli.test.ts +325 -0
- package/src/replay-eval.ts +1039 -0
- package/src/replay-handoff.test.ts +163 -0
- package/src/replay-handoff.ts +109 -0
- package/src/replay-workflow-compiler.test.ts +250 -0
- package/src/replay-workflow-compiler.ts +700 -0
- package/src/resource.md +409 -0
- package/src/rrweb-recording-relay.test.ts +99 -0
- package/src/rrweb-recording-relay.ts +400 -0
- package/src/rrweb-recording.ts +322 -0
- package/src/scoped-fs.test.ts +80 -0
- package/src/scoped-fs.ts +420 -0
- package/src/skill-stub.test.ts +24 -0
- package/src/skill.md +1413 -0
- package/src/snapshot-tools.test.ts +1017 -0
- package/src/snapshots/hacker-news-accessibility-full.md +81 -0
- package/src/snapshots/hacker-news-accessibility-interactive.md +43 -0
- package/src/snapshots/page-markdown-output.txt +16 -0
- package/src/snapshots/shadcn-ui-accessibility-full.md +201 -0
- package/src/snapshots/shadcn-ui-accessibility-interactive.md +109 -0
- package/src/start-relay-server.ts +69 -0
- package/src/styles-examples.ts +84 -0
- package/src/styles.ts +343 -0
- package/src/tabwright-agent-skill.test.ts +61 -0
- package/src/tabwright-agent-skill.ts +105 -0
- package/src/test-declarations.ts +13 -0
- package/src/test-utils.test.ts +70 -0
- package/src/test-utils.ts +566 -0
- package/src/utils.ts +78 -0
- package/src/wait-for-page-load.ts +198 -0
- package/src/workflow-capability.ts +417 -0
- package/src/workflow-script-flow.test.ts +388 -0
|
@@ -0,0 +1,1681 @@
|
|
|
1
|
+
// Accessibility snapshot pipeline: build raw AX tree, filter to a
|
|
2
|
+
// tree (interactive-only, labels/contexts, wrapper hoisting, ignored
|
|
3
|
+
// indent preservation), then render lines and locators.
|
|
4
|
+
import type { Page, Locator, ElementHandle, Frame, FrameLocator } from '@xmorse/playwright-core'
|
|
5
|
+
import crypto from 'node:crypto'
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import type { Protocol } from 'devtools-protocol'
|
|
10
|
+
import { Sema } from 'async-sema'
|
|
11
|
+
import type { ICDPSession } from './cdp-session.js'
|
|
12
|
+
import { getCDPSessionForPage } from './cdp-session.js'
|
|
13
|
+
|
|
14
|
+
// Import sharp at module level - resolves to null if not available
|
|
15
|
+
const sharpPromise = import('sharp')
|
|
16
|
+
.then((m) => {
|
|
17
|
+
return m.default
|
|
18
|
+
})
|
|
19
|
+
.catch(() => {
|
|
20
|
+
return null
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Snapshot Format Types
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
export type SnapshotFormat = 'raw'
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_SNAPSHOT_FORMAT: SnapshotFormat = 'raw'
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// A11y Client Code Loading
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
let a11yClientCode: string | null = null
|
|
36
|
+
|
|
37
|
+
function getA11yClientCode(): string {
|
|
38
|
+
if (a11yClientCode) {
|
|
39
|
+
return a11yClientCode
|
|
40
|
+
}
|
|
41
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
|
42
|
+
const a11yClientPath = path.join(currentDir, '..', 'dist', 'a11y-client.js')
|
|
43
|
+
a11yClientCode = fs.readFileSync(a11yClientPath, 'utf-8')
|
|
44
|
+
return a11yClientCode
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function ensureA11yClient(page: Page): Promise<void> {
|
|
48
|
+
const hasA11y = await page.evaluate(() => !!(globalThis as any).__a11y)
|
|
49
|
+
if (!hasA11y) {
|
|
50
|
+
const code = getA11yClientCode()
|
|
51
|
+
await page.evaluate(code)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ============================================================================
|
|
56
|
+
// Types
|
|
57
|
+
// ============================================================================
|
|
58
|
+
|
|
59
|
+
export interface AriaRef {
|
|
60
|
+
role: string
|
|
61
|
+
name: string
|
|
62
|
+
ref: string
|
|
63
|
+
shortRef: string
|
|
64
|
+
backendNodeId?: Protocol.DOM.BackendNodeId
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type AriaRefDraft = Omit<AriaRef, 'shortRef'> & { selector?: string }
|
|
68
|
+
|
|
69
|
+
export type AriaSnapshotNode = {
|
|
70
|
+
role: string
|
|
71
|
+
name: string
|
|
72
|
+
locator?: string
|
|
73
|
+
ref?: string
|
|
74
|
+
shortRef?: string
|
|
75
|
+
backendNodeId?: Protocol.DOM.BackendNodeId
|
|
76
|
+
children: AriaSnapshotNode[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ScreenshotResult {
|
|
80
|
+
path: string
|
|
81
|
+
base64: string
|
|
82
|
+
mimeType: 'image/png'
|
|
83
|
+
snapshot: string
|
|
84
|
+
labelCount: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ============================================================================
|
|
88
|
+
// Image Resize Utility
|
|
89
|
+
// ============================================================================
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* LLM-optimal max dimension. Claude auto-resizes images larger than 1568px
|
|
93
|
+
* on any edge, adding latency. Token cost: (width * height) / 750.
|
|
94
|
+
*/
|
|
95
|
+
export const LLM_MAX_DIMENSION = 1568
|
|
96
|
+
|
|
97
|
+
export interface ResizeImageOptions {
|
|
98
|
+
/** Input: file path or Buffer */
|
|
99
|
+
input: string | Buffer
|
|
100
|
+
/** Max pixels on longest edge. Default 1568 (Claude-optimal).
|
|
101
|
+
* Ignored if explicit width/height provided. */
|
|
102
|
+
maxDimension?: number
|
|
103
|
+
/** Explicit target width in px (aspect ratio preserved unless both width+height set) */
|
|
104
|
+
width?: number
|
|
105
|
+
/** Explicit target height in px */
|
|
106
|
+
height?: number
|
|
107
|
+
/** How to fit when both width+height specified. Default 'inside' (preserve aspect ratio, no crop) */
|
|
108
|
+
fit?: 'inside' | 'cover' | 'contain' | 'fill'
|
|
109
|
+
/** JPEG quality 1-100. Default 80 */
|
|
110
|
+
quality?: number
|
|
111
|
+
/** Output format. Default 'jpeg'. Use 'png' for lossless output (e.g. Kitty Graphics). */
|
|
112
|
+
format?: 'jpeg' | 'png'
|
|
113
|
+
/** Output file path. Defaults to overwriting the input file (when input is a path) */
|
|
114
|
+
output?: string
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface ResizeImageResult {
|
|
118
|
+
buffer: Buffer
|
|
119
|
+
mimeType: 'image/png' | 'image/jpeg'
|
|
120
|
+
/** Only set if output path was provided */
|
|
121
|
+
path?: string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resize an image using sharp. Useful for shrinking screenshots before reading
|
|
126
|
+
* them back into context so they consume fewer tokens.
|
|
127
|
+
*
|
|
128
|
+
* Default behavior (no width/height): fits within 1568×1568px, preserving
|
|
129
|
+
* aspect ratio, never upscales. This is optimal for Claude vision.
|
|
130
|
+
*
|
|
131
|
+
* Explicit width/height: resizes to those dimensions using the fit strategy.
|
|
132
|
+
*/
|
|
133
|
+
export async function resizeImageForAgent(options: ResizeImageOptions): Promise<ResizeImageResult> {
|
|
134
|
+
const sharp = await sharpPromise
|
|
135
|
+
if (!sharp) {
|
|
136
|
+
throw new Error('sharp is not installed — install it with: pnpm add sharp')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const inputBuffer = (() => {
|
|
140
|
+
if (Buffer.isBuffer(options.input)) {
|
|
141
|
+
return options.input
|
|
142
|
+
}
|
|
143
|
+
return fs.readFileSync(options.input)
|
|
144
|
+
})()
|
|
145
|
+
|
|
146
|
+
const quality = options.quality ?? 80
|
|
147
|
+
const hasExplicitDimensions = options.width !== undefined || options.height !== undefined
|
|
148
|
+
|
|
149
|
+
const fit = options.fit ?? 'inside'
|
|
150
|
+
const resizeOpts = (() => {
|
|
151
|
+
if (hasExplicitDimensions) {
|
|
152
|
+
return {
|
|
153
|
+
width: options.width,
|
|
154
|
+
height: options.height,
|
|
155
|
+
fit,
|
|
156
|
+
withoutEnlargement: false,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const max = options.maxDimension ?? LLM_MAX_DIMENSION
|
|
160
|
+
return {
|
|
161
|
+
width: max,
|
|
162
|
+
height: max,
|
|
163
|
+
fit,
|
|
164
|
+
withoutEnlargement: true,
|
|
165
|
+
}
|
|
166
|
+
})()
|
|
167
|
+
|
|
168
|
+
const fmt = options.format ?? 'png'
|
|
169
|
+
const pipeline = sharp(inputBuffer).resize(resizeOpts)
|
|
170
|
+
const buffer = await (fmt === 'png' ? pipeline.png() : pipeline.jpeg({ quality })).toBuffer()
|
|
171
|
+
|
|
172
|
+
// Default: overwrite input file. When input is a Buffer, no file is written
|
|
173
|
+
// unless output is explicitly set.
|
|
174
|
+
const outputPath = (() => {
|
|
175
|
+
if (options.output) {
|
|
176
|
+
return options.output
|
|
177
|
+
}
|
|
178
|
+
if (typeof options.input === 'string') {
|
|
179
|
+
return options.input
|
|
180
|
+
}
|
|
181
|
+
return undefined
|
|
182
|
+
})()
|
|
183
|
+
|
|
184
|
+
if (outputPath) {
|
|
185
|
+
fs.writeFileSync(outputPath, buffer)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const mimeType: 'image/png' | 'image/jpeg' = fmt === 'png' ? 'image/png' : 'image/jpeg'
|
|
189
|
+
return {
|
|
190
|
+
buffer,
|
|
191
|
+
mimeType,
|
|
192
|
+
...(outputPath ? { path: outputPath } : {}),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface AriaSnapshotResult {
|
|
197
|
+
snapshot: string
|
|
198
|
+
tree: AriaSnapshotNode[]
|
|
199
|
+
refs: AriaRef[]
|
|
200
|
+
refToElement: Map<string, { role: string; name: string; shortRef: string }>
|
|
201
|
+
refToSelector: Map<string, string>
|
|
202
|
+
/**
|
|
203
|
+
* Get a CSS selector for a ref. Use with page.locator().
|
|
204
|
+
* For stable test IDs, returns [data-testid="..."] or [id="..."]
|
|
205
|
+
* For fallback refs, returns a role-based selector.
|
|
206
|
+
*/
|
|
207
|
+
getSelectorForRef: (ref: string) => string | null
|
|
208
|
+
getRefsForLocators: (locators: Array<Locator | ElementHandle>) => Promise<Array<AriaRef | null>>
|
|
209
|
+
getRefForLocator: (locator: Locator | ElementHandle) => Promise<AriaRef | null>
|
|
210
|
+
getRefStringForLocator: (locator: Locator | ElementHandle) => Promise<string | null>
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
type LabelBox = {
|
|
214
|
+
x: number
|
|
215
|
+
y: number
|
|
216
|
+
width: number
|
|
217
|
+
height: number
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
type AriaLabel = {
|
|
221
|
+
ref: string
|
|
222
|
+
role: string
|
|
223
|
+
box: LabelBox
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function buildShortRefMap({ refs }: { refs: Array<{ ref: string }> }): Map<string, string> {
|
|
227
|
+
const map = new Map<string, string>()
|
|
228
|
+
refs.forEach((entry, index) => {
|
|
229
|
+
map.set(entry.ref, `e${index + 1}`)
|
|
230
|
+
})
|
|
231
|
+
return map
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Roles that represent interactive elements
|
|
235
|
+
const INTERACTIVE_ROLES = new Set([
|
|
236
|
+
'button',
|
|
237
|
+
'link',
|
|
238
|
+
'textbox',
|
|
239
|
+
'combobox',
|
|
240
|
+
'searchbox',
|
|
241
|
+
'checkbox',
|
|
242
|
+
'radio',
|
|
243
|
+
'slider',
|
|
244
|
+
'spinbutton',
|
|
245
|
+
'switch',
|
|
246
|
+
'menuitem',
|
|
247
|
+
'menuitemcheckbox',
|
|
248
|
+
'menuitemradio',
|
|
249
|
+
'option',
|
|
250
|
+
'tab',
|
|
251
|
+
'treeitem',
|
|
252
|
+
'img',
|
|
253
|
+
'video',
|
|
254
|
+
'audio',
|
|
255
|
+
])
|
|
256
|
+
|
|
257
|
+
const LABEL_ROLES = new Set(['labeltext'])
|
|
258
|
+
|
|
259
|
+
const MAX_LABEL_POSITION_CONCURRENCY = 24
|
|
260
|
+
const BOX_MODEL_TIMEOUT_MS = 5000
|
|
261
|
+
|
|
262
|
+
const CONTEXT_ROLES = new Set([
|
|
263
|
+
'navigation',
|
|
264
|
+
'main',
|
|
265
|
+
'contentinfo',
|
|
266
|
+
'banner',
|
|
267
|
+
'form',
|
|
268
|
+
'section',
|
|
269
|
+
'region',
|
|
270
|
+
'list',
|
|
271
|
+
'listitem',
|
|
272
|
+
'table',
|
|
273
|
+
'rowgroup',
|
|
274
|
+
'row',
|
|
275
|
+
'cell',
|
|
276
|
+
])
|
|
277
|
+
|
|
278
|
+
const SKIP_WRAPPER_ROLES = new Set(['generic', 'group', 'none', 'presentation'])
|
|
279
|
+
|
|
280
|
+
const TEST_ID_ATTRS = [
|
|
281
|
+
'data-testid',
|
|
282
|
+
'data-test-id',
|
|
283
|
+
'data-test',
|
|
284
|
+
'data-cy',
|
|
285
|
+
'data-pw',
|
|
286
|
+
'data-qa',
|
|
287
|
+
'data-e2e',
|
|
288
|
+
'data-automation-id',
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
type DomNodeInfo = {
|
|
292
|
+
nodeId: Protocol.DOM.NodeId
|
|
293
|
+
parentId?: Protocol.DOM.NodeId
|
|
294
|
+
backendNodeId: Protocol.DOM.BackendNodeId
|
|
295
|
+
nodeName: string
|
|
296
|
+
attributes: Map<string, string>
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function toAttributeMap(attributes?: string[]): Map<string, string> {
|
|
300
|
+
const result = new Map<string, string>()
|
|
301
|
+
if (!attributes) {
|
|
302
|
+
return result
|
|
303
|
+
}
|
|
304
|
+
for (let i = 0; i < attributes.length; i += 2) {
|
|
305
|
+
const name = attributes[i]
|
|
306
|
+
const value = attributes[i + 1]
|
|
307
|
+
if (name) {
|
|
308
|
+
result.set(name, value ?? '')
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return result
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function getStableRefFromAttributes(attributes: Map<string, string>): { value: string; attr: string } | null {
|
|
315
|
+
// Test IDs first: they are explicitly placed by developers for automation
|
|
316
|
+
// and more stable than id which is often auto-generated by frameworks
|
|
317
|
+
for (const attr of TEST_ID_ATTRS) {
|
|
318
|
+
const value = attributes.get(attr)
|
|
319
|
+
if (value) {
|
|
320
|
+
return { value, attr }
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const id = attributes.get('id')
|
|
324
|
+
if (id) {
|
|
325
|
+
return { value: id, attr: 'id' }
|
|
326
|
+
}
|
|
327
|
+
return null
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function escapeLocatorValue(value: string): string {
|
|
331
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function buildLocatorFromStable(stable: { value: string; attr: string }): string {
|
|
335
|
+
const escaped = escapeLocatorValue(stable.value)
|
|
336
|
+
return `[${stable.attr}="${escaped}"]`
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function buildBaseLocator({
|
|
340
|
+
role,
|
|
341
|
+
name,
|
|
342
|
+
stable,
|
|
343
|
+
isPromotedContentEditable,
|
|
344
|
+
}: {
|
|
345
|
+
role: string
|
|
346
|
+
name: string
|
|
347
|
+
stable: { value: string; attr: string } | null
|
|
348
|
+
isPromotedContentEditable?: boolean
|
|
349
|
+
}): string {
|
|
350
|
+
if (stable) {
|
|
351
|
+
return buildLocatorFromStable(stable)
|
|
352
|
+
}
|
|
353
|
+
// For promoted contenteditable elements (bare <div contenteditable="true"> without
|
|
354
|
+
// role="textbox"), use CSS attribute selector. Playwright's role=textbox selector
|
|
355
|
+
// won't match these because Chrome doesn't assign an implicit textbox role to
|
|
356
|
+
// contenteditable divs. Elements that already had role="textbox" use the normal
|
|
357
|
+
// role-based locator since Playwright matches those correctly.
|
|
358
|
+
if (isPromotedContentEditable) {
|
|
359
|
+
return `[contenteditable="true"]`
|
|
360
|
+
}
|
|
361
|
+
const trimmedName = name.trim()
|
|
362
|
+
if (trimmedName.length > 0) {
|
|
363
|
+
const escapedName = escapeLocatorValue(trimmedName)
|
|
364
|
+
return `role=${role}[name="${escapedName}"]`
|
|
365
|
+
}
|
|
366
|
+
return `role=${role}`
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function getAxValueString(value?: Protocol.Accessibility.AXValue): string {
|
|
370
|
+
if (!value) {
|
|
371
|
+
return ''
|
|
372
|
+
}
|
|
373
|
+
const raw = value.value
|
|
374
|
+
if (typeof raw === 'string') {
|
|
375
|
+
return raw
|
|
376
|
+
}
|
|
377
|
+
if (raw === undefined || raw === null) {
|
|
378
|
+
return ''
|
|
379
|
+
}
|
|
380
|
+
return String(raw)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function getAxRole(node: Protocol.Accessibility.AXNode): string {
|
|
384
|
+
const role = getAxValueString(node.role)
|
|
385
|
+
return role.toLowerCase()
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export type SnapshotLine = {
|
|
389
|
+
text: string
|
|
390
|
+
baseLocator?: string
|
|
391
|
+
hasChildren?: boolean
|
|
392
|
+
role?: string
|
|
393
|
+
name?: string
|
|
394
|
+
indent?: number
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export type SnapshotNode = {
|
|
398
|
+
role: string
|
|
399
|
+
name: string
|
|
400
|
+
baseLocator?: string
|
|
401
|
+
ref?: string
|
|
402
|
+
backendNodeId?: Protocol.DOM.BackendNodeId
|
|
403
|
+
indentOffset?: number
|
|
404
|
+
ignored?: boolean
|
|
405
|
+
children: SnapshotNode[]
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function buildSnapshotLine({
|
|
409
|
+
role,
|
|
410
|
+
name,
|
|
411
|
+
baseLocator,
|
|
412
|
+
indent,
|
|
413
|
+
hasChildren,
|
|
414
|
+
}: {
|
|
415
|
+
role: string
|
|
416
|
+
name: string
|
|
417
|
+
baseLocator?: string
|
|
418
|
+
indent: number
|
|
419
|
+
hasChildren: boolean
|
|
420
|
+
}): SnapshotLine {
|
|
421
|
+
const prefix = ' '.repeat(indent)
|
|
422
|
+
let text = `${prefix}- ${role}`
|
|
423
|
+
if (name) {
|
|
424
|
+
const escapedName = name.replace(/"/g, '\\"')
|
|
425
|
+
text += ` "${escapedName}"`
|
|
426
|
+
}
|
|
427
|
+
return { text, baseLocator, hasChildren, role, name, indent }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function buildTextLine(text: string, indent: number): SnapshotLine {
|
|
431
|
+
const prefix = ' '.repeat(indent)
|
|
432
|
+
const escaped = text.replace(/"/g, '\\"')
|
|
433
|
+
return { text: `${prefix}- text: "${escaped}"` }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function buildSnapshotLines(nodes: SnapshotNode[], indent = 0): SnapshotLine[] {
|
|
437
|
+
return nodes.flatMap((node) => {
|
|
438
|
+
const nodeIndent = indent + (node.indentOffset ?? 0)
|
|
439
|
+
const line =
|
|
440
|
+
node.role === 'text'
|
|
441
|
+
? buildTextLine(node.name, nodeIndent)
|
|
442
|
+
: buildSnapshotLine({
|
|
443
|
+
role: node.role,
|
|
444
|
+
name: node.name,
|
|
445
|
+
baseLocator: node.baseLocator,
|
|
446
|
+
indent: nodeIndent,
|
|
447
|
+
hasChildren: node.children.length > 0,
|
|
448
|
+
})
|
|
449
|
+
return [line, ...buildSnapshotLines(node.children, nodeIndent + 1)]
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function shiftIndent(nodes: SnapshotNode[], offset: number): SnapshotNode[] {
|
|
454
|
+
return nodes.map((node) => {
|
|
455
|
+
return { ...node, indentOffset: (node.indentOffset ?? 0) + offset }
|
|
456
|
+
})
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function buildRawSnapshotTree(options: {
|
|
460
|
+
nodeId: Protocol.Accessibility.AXNodeId
|
|
461
|
+
axById: Map<Protocol.Accessibility.AXNodeId, Protocol.Accessibility.AXNode>
|
|
462
|
+
isNodeInScope: (node: Protocol.Accessibility.AXNode) => boolean
|
|
463
|
+
}): SnapshotNode | null {
|
|
464
|
+
const node = options.axById.get(options.nodeId)
|
|
465
|
+
if (!node) {
|
|
466
|
+
return null
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const role = getAxRole(node)
|
|
470
|
+
const name = getAxValueString(node.name).trim()
|
|
471
|
+
const children = (node.childIds ?? [])
|
|
472
|
+
.map((childId) => {
|
|
473
|
+
return buildRawSnapshotTree({
|
|
474
|
+
nodeId: childId,
|
|
475
|
+
axById: options.axById,
|
|
476
|
+
isNodeInScope: options.isNodeInScope,
|
|
477
|
+
})
|
|
478
|
+
})
|
|
479
|
+
.filter(isTruthy)
|
|
480
|
+
|
|
481
|
+
const inScope = options.isNodeInScope(node) || children.length > 0
|
|
482
|
+
if (!inScope) {
|
|
483
|
+
return null
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return {
|
|
487
|
+
role,
|
|
488
|
+
name,
|
|
489
|
+
backendNodeId: node.backendDOMNodeId,
|
|
490
|
+
ignored: node.ignored,
|
|
491
|
+
children,
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function filterInteractiveSnapshotTree(options: {
|
|
496
|
+
node: SnapshotNode
|
|
497
|
+
ancestorNames: string[]
|
|
498
|
+
labelContext: boolean
|
|
499
|
+
refFilter?: (entry: { role: string; name: string }) => boolean
|
|
500
|
+
domByBackendId: Map<Protocol.DOM.BackendNodeId, DomNodeInfo>
|
|
501
|
+
promotedContentEditableIds?: Set<Protocol.DOM.BackendNodeId>
|
|
502
|
+
createRefForNode: (options: {
|
|
503
|
+
backendNodeId?: Protocol.DOM.BackendNodeId
|
|
504
|
+
role: string
|
|
505
|
+
name: string
|
|
506
|
+
}) => string | null
|
|
507
|
+
}): { nodes: SnapshotNode[]; names: Set<string> } {
|
|
508
|
+
const role = options.node.role
|
|
509
|
+
const name = options.node.name
|
|
510
|
+
const hasName = name.length > 0
|
|
511
|
+
const nextAncestors = hasName ? [...options.ancestorNames, name] : options.ancestorNames
|
|
512
|
+
|
|
513
|
+
const isLabel = LABEL_ROLES.has(role)
|
|
514
|
+
const nextLabelContext = options.labelContext || isLabel
|
|
515
|
+
|
|
516
|
+
const childResults = options.node.children.map((child) => {
|
|
517
|
+
return filterInteractiveSnapshotTree({
|
|
518
|
+
node: child,
|
|
519
|
+
ancestorNames: nextAncestors,
|
|
520
|
+
labelContext: nextLabelContext,
|
|
521
|
+
refFilter: options.refFilter,
|
|
522
|
+
domByBackendId: options.domByBackendId,
|
|
523
|
+
promotedContentEditableIds: options.promotedContentEditableIds,
|
|
524
|
+
createRefForNode: options.createRefForNode,
|
|
525
|
+
})
|
|
526
|
+
})
|
|
527
|
+
const childNodes = childResults.flatMap((result) => {
|
|
528
|
+
return result.nodes
|
|
529
|
+
})
|
|
530
|
+
const childNames = childResults.reduce((acc, result) => {
|
|
531
|
+
result.names.forEach((childName) => {
|
|
532
|
+
acc.add(childName)
|
|
533
|
+
})
|
|
534
|
+
return acc
|
|
535
|
+
}, new Set<string>())
|
|
536
|
+
|
|
537
|
+
if (options.node.ignored) {
|
|
538
|
+
return { nodes: shiftIndent(childNodes, 1), names: childNames }
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (isTextRole(role)) {
|
|
542
|
+
if (!hasName) {
|
|
543
|
+
return { nodes: childNodes, names: childNames }
|
|
544
|
+
}
|
|
545
|
+
if (!options.labelContext) {
|
|
546
|
+
return { nodes: childNodes, names: childNames }
|
|
547
|
+
}
|
|
548
|
+
const isRedundantText = options.ancestorNames.some((ancestor) => {
|
|
549
|
+
return ancestor.includes(name) || name.includes(ancestor)
|
|
550
|
+
})
|
|
551
|
+
if (isRedundantText) {
|
|
552
|
+
return { nodes: childNodes, names: childNames }
|
|
553
|
+
}
|
|
554
|
+
const names = new Set(childNames)
|
|
555
|
+
names.add(name)
|
|
556
|
+
const textNode: SnapshotNode = { role: 'text', name, children: [] }
|
|
557
|
+
return { nodes: [textNode], names }
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const hasChildren = childNodes.length > 0
|
|
561
|
+
const nameToUse = hasName && (childNames.has(name) || isSubstringOfAny(name, childNames)) ? '' : name
|
|
562
|
+
const hasNameToUse = nameToUse.length > 0
|
|
563
|
+
const isWrapper = SKIP_WRAPPER_ROLES.has(role)
|
|
564
|
+
const isInteractive = INTERACTIVE_ROLES.has(role)
|
|
565
|
+
const isContext = CONTEXT_ROLES.has(role)
|
|
566
|
+
const passesRefFilter = !options.refFilter || options.refFilter({ role, name })
|
|
567
|
+
const includeInteractive = isInteractive && passesRefFilter
|
|
568
|
+
const shouldInclude = includeInteractive || isLabel || isContext || hasChildren
|
|
569
|
+
if (!shouldInclude) {
|
|
570
|
+
return { nodes: childNodes, names: childNames }
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (!includeInteractive && !isLabel && !isContext) {
|
|
574
|
+
if (!hasChildren) {
|
|
575
|
+
return { nodes: [], names: childNames }
|
|
576
|
+
}
|
|
577
|
+
return { nodes: childNodes, names: childNames }
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (isWrapper && !hasNameToUse) {
|
|
581
|
+
if (!hasChildren) {
|
|
582
|
+
return { nodes: [], names: childNames }
|
|
583
|
+
}
|
|
584
|
+
return { nodes: childNodes, names: childNames }
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
let baseLocator: string | undefined
|
|
588
|
+
let ref: string | null = null
|
|
589
|
+
if (includeInteractive) {
|
|
590
|
+
const domInfo = options.node.backendNodeId ? options.domByBackendId.get(options.node.backendNodeId) : undefined
|
|
591
|
+
const stable = domInfo ? getStableRefFromAttributes(domInfo.attributes) : null
|
|
592
|
+
const isPromoted = options.node.backendNodeId != null && (options.promotedContentEditableIds?.has(options.node.backendNodeId) ?? false)
|
|
593
|
+
baseLocator = buildBaseLocator({ role, name, stable, isPromotedContentEditable: isPromoted })
|
|
594
|
+
ref = options.createRefForNode({ backendNodeId: options.node.backendNodeId, role, name })
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const nodeEntry: SnapshotNode = {
|
|
598
|
+
role,
|
|
599
|
+
name: nameToUse,
|
|
600
|
+
baseLocator,
|
|
601
|
+
ref: ref ?? undefined,
|
|
602
|
+
backendNodeId: options.node.backendNodeId,
|
|
603
|
+
children: childNodes,
|
|
604
|
+
}
|
|
605
|
+
const names = new Set(childNames)
|
|
606
|
+
if (hasNameToUse) {
|
|
607
|
+
names.add(nameToUse)
|
|
608
|
+
}
|
|
609
|
+
return { nodes: [nodeEntry], names }
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export function filterFullSnapshotTree(options: {
|
|
613
|
+
node: SnapshotNode
|
|
614
|
+
ancestorNames: string[]
|
|
615
|
+
refFilter?: (entry: { role: string; name: string }) => boolean
|
|
616
|
+
domByBackendId: Map<Protocol.DOM.BackendNodeId, DomNodeInfo>
|
|
617
|
+
promotedContentEditableIds?: Set<Protocol.DOM.BackendNodeId>
|
|
618
|
+
createRefForNode: (options: {
|
|
619
|
+
backendNodeId?: Protocol.DOM.BackendNodeId
|
|
620
|
+
role: string
|
|
621
|
+
name: string
|
|
622
|
+
}) => string | null
|
|
623
|
+
}): { nodes: SnapshotNode[]; names: Set<string> } {
|
|
624
|
+
const role = options.node.role
|
|
625
|
+
const name = options.node.name
|
|
626
|
+
const hasName = name.length > 0
|
|
627
|
+
const nextAncestors = hasName ? [...options.ancestorNames, name] : options.ancestorNames
|
|
628
|
+
|
|
629
|
+
const childResults = options.node.children.map((child) => {
|
|
630
|
+
return filterFullSnapshotTree({
|
|
631
|
+
node: child,
|
|
632
|
+
ancestorNames: nextAncestors,
|
|
633
|
+
refFilter: options.refFilter,
|
|
634
|
+
domByBackendId: options.domByBackendId,
|
|
635
|
+
promotedContentEditableIds: options.promotedContentEditableIds,
|
|
636
|
+
createRefForNode: options.createRefForNode,
|
|
637
|
+
})
|
|
638
|
+
})
|
|
639
|
+
const childNodes = childResults.flatMap((result) => {
|
|
640
|
+
return result.nodes
|
|
641
|
+
})
|
|
642
|
+
const childNames = childResults.reduce((acc, result) => {
|
|
643
|
+
result.names.forEach((childName) => {
|
|
644
|
+
acc.add(childName)
|
|
645
|
+
})
|
|
646
|
+
return acc
|
|
647
|
+
}, new Set<string>())
|
|
648
|
+
|
|
649
|
+
if (options.node.ignored) {
|
|
650
|
+
return { nodes: shiftIndent(childNodes, 1), names: childNames }
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (isTextRole(role)) {
|
|
654
|
+
if (!hasName) {
|
|
655
|
+
return { nodes: childNodes, names: childNames }
|
|
656
|
+
}
|
|
657
|
+
const isRedundantText = options.ancestorNames.some((ancestor) => {
|
|
658
|
+
return ancestor.includes(name) || name.includes(ancestor)
|
|
659
|
+
})
|
|
660
|
+
if (isRedundantText) {
|
|
661
|
+
return { nodes: childNodes, names: childNames }
|
|
662
|
+
}
|
|
663
|
+
const names = new Set(childNames)
|
|
664
|
+
names.add(name)
|
|
665
|
+
const textNode: SnapshotNode = { role: 'text', name, children: [] }
|
|
666
|
+
return { nodes: [textNode], names }
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const hasChildren = childNodes.length > 0
|
|
670
|
+
const nameToUse = hasName && (childNames.has(name) || isSubstringOfAny(name, childNames)) ? '' : name
|
|
671
|
+
const hasNameToUse = nameToUse.length > 0
|
|
672
|
+
const isWrapper = SKIP_WRAPPER_ROLES.has(role)
|
|
673
|
+
const isInteractive = INTERACTIVE_ROLES.has(role)
|
|
674
|
+
const passesRefFilter = !options.refFilter || options.refFilter({ role, name })
|
|
675
|
+
const includeInteractive = isInteractive && passesRefFilter
|
|
676
|
+
const shouldInclude = includeInteractive || hasNameToUse || hasChildren
|
|
677
|
+
if (!shouldInclude) {
|
|
678
|
+
return { nodes: childNodes, names: childNames }
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (isWrapper && !hasNameToUse) {
|
|
682
|
+
if (!hasChildren) {
|
|
683
|
+
return { nodes: [], names: childNames }
|
|
684
|
+
}
|
|
685
|
+
return { nodes: childNodes, names: childNames }
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
let baseLocator: string | undefined
|
|
689
|
+
let ref: string | null = null
|
|
690
|
+
if (includeInteractive) {
|
|
691
|
+
const domInfo = options.node.backendNodeId ? options.domByBackendId.get(options.node.backendNodeId) : undefined
|
|
692
|
+
const stable = domInfo ? getStableRefFromAttributes(domInfo.attributes) : null
|
|
693
|
+
const isPromoted = options.node.backendNodeId != null && (options.promotedContentEditableIds?.has(options.node.backendNodeId) ?? false)
|
|
694
|
+
baseLocator = buildBaseLocator({ role, name, stable, isPromotedContentEditable: isPromoted })
|
|
695
|
+
ref = options.createRefForNode({ backendNodeId: options.node.backendNodeId, role, name })
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const nodeEntry: SnapshotNode = {
|
|
699
|
+
role,
|
|
700
|
+
name: nameToUse,
|
|
701
|
+
baseLocator,
|
|
702
|
+
ref: ref ?? undefined,
|
|
703
|
+
backendNodeId: options.node.backendNodeId,
|
|
704
|
+
children: childNodes,
|
|
705
|
+
}
|
|
706
|
+
const names = new Set(childNames)
|
|
707
|
+
if (hasNameToUse) {
|
|
708
|
+
names.add(nameToUse)
|
|
709
|
+
}
|
|
710
|
+
return { nodes: [nodeEntry], names }
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function buildLocatorLineText({ line, locator }: { line: SnapshotLine; locator: string }): string {
|
|
714
|
+
const prefix = ' '.repeat(line.indent ?? 0)
|
|
715
|
+
const role = line.role ?? ''
|
|
716
|
+
const name = line.name ?? ''
|
|
717
|
+
const escapedName = name.replace(/"/g, '\\"')
|
|
718
|
+
|
|
719
|
+
const hasRoleInLocator = role ? locator.includes(role) : false
|
|
720
|
+
const hasNameInLocator = name ? locator.includes(escapedName) : false
|
|
721
|
+
|
|
722
|
+
const parts: string[] = []
|
|
723
|
+
if (role && !hasRoleInLocator) {
|
|
724
|
+
parts.push(role)
|
|
725
|
+
}
|
|
726
|
+
if (name && !hasNameInLocator) {
|
|
727
|
+
parts.push(`"${escapedName}"`)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const base = parts.length > 0 ? `${prefix}- ${parts.join(' ')}` : `${prefix}-`
|
|
731
|
+
return `${base} ${locator}`
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
export function finalizeSnapshotOutput(
|
|
735
|
+
lines: SnapshotLine[],
|
|
736
|
+
nodes: SnapshotNode[],
|
|
737
|
+
shortRefMap: Map<string, string>,
|
|
738
|
+
): { snapshot: string; tree: AriaSnapshotNode[] } {
|
|
739
|
+
const locatorCounts = lines.reduce<Map<string, number>>((acc, line) => {
|
|
740
|
+
if (!line.baseLocator) {
|
|
741
|
+
return acc
|
|
742
|
+
}
|
|
743
|
+
acc.set(line.baseLocator, (acc.get(line.baseLocator) ?? 0) + 1)
|
|
744
|
+
return acc
|
|
745
|
+
}, new Map<string, number>())
|
|
746
|
+
|
|
747
|
+
const locatorIndices = new Map<string, number>()
|
|
748
|
+
const locatorSequence = lines.reduce<string[]>((acc, line) => {
|
|
749
|
+
if (!line.baseLocator) {
|
|
750
|
+
return acc
|
|
751
|
+
}
|
|
752
|
+
const count = locatorCounts.get(line.baseLocator) ?? 0
|
|
753
|
+
const index = locatorIndices.get(line.baseLocator) ?? 0
|
|
754
|
+
locatorIndices.set(line.baseLocator, index + 1)
|
|
755
|
+
const locator = count > 1 ? `${line.baseLocator} >> nth=${index}` : line.baseLocator
|
|
756
|
+
acc.push(locator)
|
|
757
|
+
return acc
|
|
758
|
+
}, [])
|
|
759
|
+
|
|
760
|
+
let lineLocatorIndex = 0
|
|
761
|
+
const snapshot = lines
|
|
762
|
+
.map((line) => {
|
|
763
|
+
let text = line.text
|
|
764
|
+
if (line.baseLocator) {
|
|
765
|
+
const locator = locatorSequence[lineLocatorIndex]
|
|
766
|
+
lineLocatorIndex += 1
|
|
767
|
+
text = buildLocatorLineText({ line, locator })
|
|
768
|
+
}
|
|
769
|
+
if (line.hasChildren) {
|
|
770
|
+
text += ':'
|
|
771
|
+
}
|
|
772
|
+
return text
|
|
773
|
+
})
|
|
774
|
+
.join('\n')
|
|
775
|
+
|
|
776
|
+
let nodeLocatorIndex = 0
|
|
777
|
+
const applyLocators = (items: SnapshotNode[]): AriaSnapshotNode[] => {
|
|
778
|
+
return items.map((item) => {
|
|
779
|
+
const locator = item.baseLocator ? locatorSequence[nodeLocatorIndex++] : undefined
|
|
780
|
+
const children = applyLocators(item.children)
|
|
781
|
+
return {
|
|
782
|
+
role: item.role,
|
|
783
|
+
name: item.name,
|
|
784
|
+
locator,
|
|
785
|
+
ref: item.ref,
|
|
786
|
+
shortRef: item.ref ? (shortRefMap.get(item.ref) ?? item.ref) : undefined,
|
|
787
|
+
backendNodeId: item.backendNodeId,
|
|
788
|
+
children,
|
|
789
|
+
}
|
|
790
|
+
})
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return { snapshot, tree: applyLocators(nodes) }
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function buildDomIndex(nodes: Protocol.DOM.Node[]): {
|
|
797
|
+
domById: Map<Protocol.DOM.NodeId, DomNodeInfo>
|
|
798
|
+
domByBackendId: Map<Protocol.DOM.BackendNodeId, DomNodeInfo>
|
|
799
|
+
childrenByParent: Map<Protocol.DOM.NodeId, Protocol.DOM.NodeId[]>
|
|
800
|
+
} {
|
|
801
|
+
const domById = new Map<Protocol.DOM.NodeId, DomNodeInfo>()
|
|
802
|
+
const domByBackendId = new Map<Protocol.DOM.BackendNodeId, DomNodeInfo>()
|
|
803
|
+
const childrenByParent = new Map<Protocol.DOM.NodeId, Protocol.DOM.NodeId[]>()
|
|
804
|
+
|
|
805
|
+
for (const node of nodes) {
|
|
806
|
+
const info: DomNodeInfo = {
|
|
807
|
+
nodeId: node.nodeId,
|
|
808
|
+
parentId: node.parentId,
|
|
809
|
+
backendNodeId: node.backendNodeId,
|
|
810
|
+
nodeName: node.nodeName,
|
|
811
|
+
attributes: toAttributeMap(node.attributes),
|
|
812
|
+
}
|
|
813
|
+
domById.set(node.nodeId, info)
|
|
814
|
+
domByBackendId.set(node.backendNodeId, info)
|
|
815
|
+
if (node.parentId) {
|
|
816
|
+
if (!childrenByParent.has(node.parentId)) {
|
|
817
|
+
childrenByParent.set(node.parentId, [])
|
|
818
|
+
}
|
|
819
|
+
childrenByParent.get(node.parentId)!.push(node.nodeId)
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
return { domById, domByBackendId, childrenByParent }
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function findScopeRootNodeId(
|
|
827
|
+
nodes: Protocol.DOM.Node[],
|
|
828
|
+
attrName: string,
|
|
829
|
+
attrValue: string,
|
|
830
|
+
): Protocol.DOM.NodeId | null {
|
|
831
|
+
for (const node of nodes) {
|
|
832
|
+
if (!node.attributes) {
|
|
833
|
+
continue
|
|
834
|
+
}
|
|
835
|
+
for (let i = 0; i < node.attributes.length; i += 2) {
|
|
836
|
+
const name = node.attributes[i]
|
|
837
|
+
const value = node.attributes[i + 1]
|
|
838
|
+
if (name === attrName && value === attrValue) {
|
|
839
|
+
return node.nodeId
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return null
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function buildBackendIdSet(
|
|
847
|
+
rootNodeId: Protocol.DOM.NodeId,
|
|
848
|
+
childrenByParent: Map<Protocol.DOM.NodeId, Protocol.DOM.NodeId[]>,
|
|
849
|
+
domById: Map<Protocol.DOM.NodeId, DomNodeInfo>,
|
|
850
|
+
): Set<Protocol.DOM.BackendNodeId> {
|
|
851
|
+
const result = new Set<Protocol.DOM.BackendNodeId>()
|
|
852
|
+
const stack: Protocol.DOM.NodeId[] = [rootNodeId]
|
|
853
|
+
while (stack.length > 0) {
|
|
854
|
+
const current = stack.pop()
|
|
855
|
+
if (current === undefined) {
|
|
856
|
+
continue
|
|
857
|
+
}
|
|
858
|
+
const node = domById.get(current)
|
|
859
|
+
if (node) {
|
|
860
|
+
result.add(node.backendNodeId)
|
|
861
|
+
}
|
|
862
|
+
const children = childrenByParent.get(current)
|
|
863
|
+
if (children && children.length > 0) {
|
|
864
|
+
stack.push(...children)
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
return result
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function isTextRole(role: string): boolean {
|
|
871
|
+
return role === 'statictext' || role === 'inlinetextbox'
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Per HTML spec, contenteditable is editable when the attribute is present
|
|
875
|
+
// with value "true", "" (empty string), or "plaintext-only". Bare attribute
|
|
876
|
+
// (no value) is also treated as empty string by the browser DOM parser.
|
|
877
|
+
function isContentEditable(value: string | undefined | null): boolean {
|
|
878
|
+
if (value == null) {
|
|
879
|
+
return false
|
|
880
|
+
}
|
|
881
|
+
const v = value.trim().toLowerCase()
|
|
882
|
+
return v === '' || v === 'true' || v === 'plaintext-only'
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function isSubstringOfAny(needle: string, haystack: Set<string>): boolean {
|
|
886
|
+
for (const str of haystack) {
|
|
887
|
+
if (str.includes(needle)) {
|
|
888
|
+
return true
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
return false
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// ============================================================================
|
|
895
|
+
// Frame Resolution
|
|
896
|
+
// ============================================================================
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Resolve a FrameLocator to an actual Frame object. FrameLocator (returned by
|
|
900
|
+
* locator.contentFrame()) is a scoping helper that doesn't have CDP methods like
|
|
901
|
+
* frameId(). We need the real Frame from page.frames() for OOPIF session attachment.
|
|
902
|
+
*
|
|
903
|
+
* If a Frame is passed directly, it's returned as-is.
|
|
904
|
+
* If a FrameLocator is passed, we find the matching Frame by locating the iframe
|
|
905
|
+
* element and matching its src URL against page.frames().
|
|
906
|
+
*/
|
|
907
|
+
async function resolveFrame({ frame, page }: { frame?: Frame | FrameLocator; page: Page }): Promise<Frame | undefined> {
|
|
908
|
+
if (!frame) {
|
|
909
|
+
return undefined
|
|
910
|
+
}
|
|
911
|
+
// Frame has frameId(), FrameLocator does not
|
|
912
|
+
if (typeof (frame as Frame).frameId === 'function') {
|
|
913
|
+
return frame as Frame
|
|
914
|
+
}
|
|
915
|
+
// It's a FrameLocator — resolve to a Frame via the owner iframe element.
|
|
916
|
+
// Use elementHandle().contentFrame() which returns the actual Frame object
|
|
917
|
+
// directly from the <iframe> element, avoiding ambiguity when multiple
|
|
918
|
+
// frames share the same origin (e.g. framer plugin iframes).
|
|
919
|
+
const frameLocator = frame as FrameLocator
|
|
920
|
+
const owner = frameLocator.owner()
|
|
921
|
+
const handle = await owner.elementHandle()
|
|
922
|
+
if (!handle) {
|
|
923
|
+
throw new Error('Could not resolve FrameLocator to a Frame: iframe element not found')
|
|
924
|
+
}
|
|
925
|
+
const resolved = await handle.contentFrame()
|
|
926
|
+
if (!resolved) {
|
|
927
|
+
throw new Error('Could not resolve FrameLocator to a Frame: contentFrame() returned null')
|
|
928
|
+
}
|
|
929
|
+
return resolved
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// ============================================================================
|
|
933
|
+
// Main Functions
|
|
934
|
+
// ============================================================================
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Get an accessibility snapshot with utilities to look up refs for elements.
|
|
938
|
+
* Uses the browser accessibility tree (CDP) and maps nodes to DOM attributes.
|
|
939
|
+
*
|
|
940
|
+
* Refs are generated from stable test IDs when available (data-testid, data-test-id, etc.)
|
|
941
|
+
* or fall back to e1, e2, e3...
|
|
942
|
+
*
|
|
943
|
+
* @param page - Playwright page
|
|
944
|
+
* @param locator - Optional locator to scope the snapshot to a subtree
|
|
945
|
+
* @param refFilter - Optional filter for which elements get refs
|
|
946
|
+
*
|
|
947
|
+
* @example
|
|
948
|
+
* ```ts
|
|
949
|
+
* const { snapshot, getSelectorForRef } = await getAriaSnapshot({ page })
|
|
950
|
+
* // Snapshot shows locators like [id="submit-btn"] or role=button[name="Submit"]
|
|
951
|
+
* const selector = getSelectorForRef('submit-btn')
|
|
952
|
+
* await page.locator(selector).click()
|
|
953
|
+
* ```
|
|
954
|
+
*/
|
|
955
|
+
export async function getAriaSnapshot({
|
|
956
|
+
page,
|
|
957
|
+
frame,
|
|
958
|
+
locator,
|
|
959
|
+
refFilter,
|
|
960
|
+
interactiveOnly = false,
|
|
961
|
+
cdp,
|
|
962
|
+
}: {
|
|
963
|
+
page: Page
|
|
964
|
+
frame?: Frame | FrameLocator
|
|
965
|
+
locator?: Locator
|
|
966
|
+
refFilter?: (info: { role: string; name: string }) => boolean
|
|
967
|
+
interactiveOnly?: boolean
|
|
968
|
+
cdp?: ICDPSession
|
|
969
|
+
}): Promise<AriaSnapshotResult> {
|
|
970
|
+
const session = cdp || (await getCDPSessionForPage({ page }))
|
|
971
|
+
|
|
972
|
+
// Resolve FrameLocator to an actual Frame. FrameLocator (from locator.contentFrame())
|
|
973
|
+
// is a scoping helper without CDP access. We need the real Frame from page.frames()
|
|
974
|
+
// which has frameId() for OOPIF session attachment.
|
|
975
|
+
const resolvedFrame = await resolveFrame({ frame, page })
|
|
976
|
+
|
|
977
|
+
// For cross-origin iframes (OOPIFs), we need to attach to the iframe's target
|
|
978
|
+
// to get a separate CDP session. Same-origin iframes can use frameId directly.
|
|
979
|
+
let oopifSessionId: string | null = null
|
|
980
|
+
const frameId = resolvedFrame?.frameId() ?? null
|
|
981
|
+
|
|
982
|
+
if (frameId) {
|
|
983
|
+
const { targetInfos } = (await session.send('Target.getTargets')) as Protocol.Target.GetTargetsResponse
|
|
984
|
+
const frameUrl = resolvedFrame!.url()
|
|
985
|
+
const iframeTarget = targetInfos.find((t) => {
|
|
986
|
+
return t.type === 'iframe' && t.url === frameUrl
|
|
987
|
+
})
|
|
988
|
+
if (iframeTarget) {
|
|
989
|
+
const { sessionId } = (await session.send('Target.attachToTarget', {
|
|
990
|
+
targetId: iframeTarget.targetId,
|
|
991
|
+
flatten: true,
|
|
992
|
+
})) as Protocol.Target.AttachToTargetResponse
|
|
993
|
+
oopifSessionId = sessionId
|
|
994
|
+
await session.send('Runtime.runIfWaitingForDebugger', undefined, oopifSessionId)
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
await session.send('DOM.enable', undefined, oopifSessionId)
|
|
999
|
+
await session.send('Accessibility.enable', undefined, oopifSessionId)
|
|
1000
|
+
const scopeAttr = 'data-pw-scope'
|
|
1001
|
+
const scopeValue = crypto.randomUUID()
|
|
1002
|
+
let scopeApplied = false
|
|
1003
|
+
const scopeLocator = locator
|
|
1004
|
+
|
|
1005
|
+
try {
|
|
1006
|
+
if (scopeLocator) {
|
|
1007
|
+
await scopeLocator.evaluate(
|
|
1008
|
+
(element, data) => {
|
|
1009
|
+
element.setAttribute(data.attr, data.value)
|
|
1010
|
+
},
|
|
1011
|
+
{ attr: scopeAttr, value: scopeValue },
|
|
1012
|
+
)
|
|
1013
|
+
scopeApplied = true
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const { nodes: domNodes } = (await session.send(
|
|
1017
|
+
'DOM.getFlattenedDocument',
|
|
1018
|
+
{ depth: -1, pierce: true },
|
|
1019
|
+
oopifSessionId,
|
|
1020
|
+
)) as Protocol.DOM.GetFlattenedDocumentResponse
|
|
1021
|
+
const { domById, domByBackendId, childrenByParent } = buildDomIndex(domNodes)
|
|
1022
|
+
|
|
1023
|
+
let scopeRootNodeId: Protocol.DOM.NodeId | null = null
|
|
1024
|
+
let scopeRootBackendId: Protocol.DOM.BackendNodeId | null = null
|
|
1025
|
+
if (scopeLocator) {
|
|
1026
|
+
scopeRootNodeId = findScopeRootNodeId(domNodes, scopeAttr, scopeValue)
|
|
1027
|
+
if (scopeRootNodeId) {
|
|
1028
|
+
const scopeNode = domById.get(scopeRootNodeId)
|
|
1029
|
+
if (scopeNode) {
|
|
1030
|
+
scopeRootBackendId = scopeNode.backendNodeId
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const allowedBackendIds = scopeRootNodeId ? buildBackendIdSet(scopeRootNodeId, childrenByParent, domById) : null
|
|
1036
|
+
|
|
1037
|
+
const axParams = !oopifSessionId && frameId ? { frameId } : undefined
|
|
1038
|
+
const { nodes: axNodes } = (await session.send(
|
|
1039
|
+
'Accessibility.getFullAXTree',
|
|
1040
|
+
axParams,
|
|
1041
|
+
oopifSessionId,
|
|
1042
|
+
)) as Protocol.Accessibility.GetFullAXTreeResponse
|
|
1043
|
+
|
|
1044
|
+
const axById = new Map<Protocol.Accessibility.AXNodeId, Protocol.Accessibility.AXNode>()
|
|
1045
|
+
for (const node of axNodes) {
|
|
1046
|
+
axById.set(node.nodeId, node)
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Index AX nodes by backendDOMNodeId for O(1) lookups during promotion
|
|
1050
|
+
// and root finding (instead of repeated O(n) axNodes.find() calls)
|
|
1051
|
+
const axByBackendId = new Map<Protocol.DOM.BackendNodeId, Protocol.Accessibility.AXNode>()
|
|
1052
|
+
for (const node of axNodes) {
|
|
1053
|
+
if (node.backendDOMNodeId) {
|
|
1054
|
+
axByBackendId.set(node.backendDOMNodeId, node)
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Promote contenteditable elements that Chrome's AX tree doesn't classify as
|
|
1059
|
+
// interactive. Rich text editors (ProseMirror, Tiptap, Slate, Lexical, etc.) use
|
|
1060
|
+
// bare <div contenteditable="true"> without role="textbox", so Chrome reports
|
|
1061
|
+
// them as "generic" and they become invisible in the snapshot. We detect these
|
|
1062
|
+
// via the DOM tree and override the AX role to "textbox" so they appear as
|
|
1063
|
+
// interactive elements the AI can target.
|
|
1064
|
+
const promotedContentEditableIds = new Set<Protocol.DOM.BackendNodeId>()
|
|
1065
|
+
for (const [, domInfo] of domByBackendId) {
|
|
1066
|
+
if (!isContentEditable(domInfo.attributes.get('contenteditable'))) {
|
|
1067
|
+
continue
|
|
1068
|
+
}
|
|
1069
|
+
const axNode = axByBackendId.get(domInfo.backendNodeId)
|
|
1070
|
+
if (!axNode) {
|
|
1071
|
+
continue
|
|
1072
|
+
}
|
|
1073
|
+
const currentRole = getAxRole(axNode)
|
|
1074
|
+
if (INTERACTIVE_ROLES.has(currentRole)) {
|
|
1075
|
+
continue
|
|
1076
|
+
}
|
|
1077
|
+
axNode.role = { type: 'role', value: 'textbox' }
|
|
1078
|
+
promotedContentEditableIds.add(domInfo.backendNodeId)
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const findRootAxNodeId = (): Protocol.Accessibility.AXNodeId | null => {
|
|
1082
|
+
if (scopeRootBackendId) {
|
|
1083
|
+
const scoped = axByBackendId.get(scopeRootBackendId)
|
|
1084
|
+
if (scoped) {
|
|
1085
|
+
return scoped.nodeId
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
const rootWebArea = axNodes.find((node) => {
|
|
1089
|
+
return getAxRole(node) === 'rootwebarea'
|
|
1090
|
+
})
|
|
1091
|
+
if (rootWebArea) {
|
|
1092
|
+
return rootWebArea.nodeId
|
|
1093
|
+
}
|
|
1094
|
+
const webArea = axNodes.find((node) => {
|
|
1095
|
+
return getAxRole(node) === 'webarea'
|
|
1096
|
+
})
|
|
1097
|
+
if (webArea) {
|
|
1098
|
+
return webArea.nodeId
|
|
1099
|
+
}
|
|
1100
|
+
const topLevel = axNodes.find((node) => {
|
|
1101
|
+
return !node.parentId
|
|
1102
|
+
})
|
|
1103
|
+
return topLevel ? topLevel.nodeId : null
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const rootAxNodeId = findRootAxNodeId()
|
|
1107
|
+
|
|
1108
|
+
const refCounts = new Map<string, number>()
|
|
1109
|
+
let fallbackCounter = 0
|
|
1110
|
+
const refs: AriaRefDraft[] = []
|
|
1111
|
+
|
|
1112
|
+
const createRefForNode = (options: {
|
|
1113
|
+
backendNodeId?: Protocol.DOM.BackendNodeId
|
|
1114
|
+
role: string
|
|
1115
|
+
name: string
|
|
1116
|
+
}): string | null => {
|
|
1117
|
+
if (!INTERACTIVE_ROLES.has(options.role)) {
|
|
1118
|
+
return null
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
const domInfo = options.backendNodeId ? domByBackendId.get(options.backendNodeId) : undefined
|
|
1122
|
+
const stable = domInfo ? getStableRefFromAttributes(domInfo.attributes) : null
|
|
1123
|
+
let baseRef = stable?.value
|
|
1124
|
+
if (!baseRef) {
|
|
1125
|
+
fallbackCounter += 1
|
|
1126
|
+
baseRef = `e${fallbackCounter}`
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const count = refCounts.get(baseRef) ?? 0
|
|
1130
|
+
refCounts.set(baseRef, count + 1)
|
|
1131
|
+
const ref = count === 0 ? baseRef : `${baseRef}-${count + 1}`
|
|
1132
|
+
|
|
1133
|
+
let selector: string | undefined
|
|
1134
|
+
if (stable && count === 0) {
|
|
1135
|
+
selector = buildLocatorFromStable(stable)
|
|
1136
|
+
}
|
|
1137
|
+
// For promoted contenteditable elements without a stable selector, store
|
|
1138
|
+
// [contenteditable="true"] so getSelectorForRef() doesn't fall back to
|
|
1139
|
+
// role=textbox which Playwright can't match on bare contenteditable divs.
|
|
1140
|
+
if (!selector && options.backendNodeId != null && promotedContentEditableIds.has(options.backendNodeId)) {
|
|
1141
|
+
selector = '[contenteditable="true"]'
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
refs.push({ ref, role: options.role, name: options.name, selector, backendNodeId: options.backendNodeId })
|
|
1145
|
+
return ref
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
const isNodeInScope = (node: Protocol.Accessibility.AXNode): boolean => {
|
|
1149
|
+
if (!allowedBackendIds) {
|
|
1150
|
+
return true
|
|
1151
|
+
}
|
|
1152
|
+
if (!node.backendDOMNodeId) {
|
|
1153
|
+
return false
|
|
1154
|
+
}
|
|
1155
|
+
return allowedBackendIds.has(node.backendDOMNodeId)
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
let snapshotNodes: SnapshotNode[] = []
|
|
1159
|
+
if (rootAxNodeId) {
|
|
1160
|
+
const rootNode = axById.get(rootAxNodeId)
|
|
1161
|
+
const rootRole = rootNode ? getAxRole(rootNode) : ''
|
|
1162
|
+
const rawRoots =
|
|
1163
|
+
rootNode && (rootRole === 'rootwebarea' || rootRole === 'webarea') && rootNode.childIds
|
|
1164
|
+
? rootNode.childIds
|
|
1165
|
+
.map((childId) => {
|
|
1166
|
+
return buildRawSnapshotTree({ nodeId: childId, axById, isNodeInScope })
|
|
1167
|
+
})
|
|
1168
|
+
.filter(isTruthy)
|
|
1169
|
+
: [buildRawSnapshotTree({ nodeId: rootAxNodeId, axById, isNodeInScope })].filter(isTruthy)
|
|
1170
|
+
|
|
1171
|
+
const filtered = rawRoots.flatMap((rawNode) => {
|
|
1172
|
+
if (interactiveOnly) {
|
|
1173
|
+
return filterInteractiveSnapshotTree({
|
|
1174
|
+
node: rawNode,
|
|
1175
|
+
ancestorNames: [],
|
|
1176
|
+
labelContext: false,
|
|
1177
|
+
refFilter,
|
|
1178
|
+
domByBackendId,
|
|
1179
|
+
promotedContentEditableIds,
|
|
1180
|
+
createRefForNode,
|
|
1181
|
+
}).nodes
|
|
1182
|
+
}
|
|
1183
|
+
return filterFullSnapshotTree({
|
|
1184
|
+
node: rawNode,
|
|
1185
|
+
ancestorNames: [],
|
|
1186
|
+
refFilter,
|
|
1187
|
+
domByBackendId,
|
|
1188
|
+
promotedContentEditableIds,
|
|
1189
|
+
createRefForNode,
|
|
1190
|
+
}).nodes
|
|
1191
|
+
})
|
|
1192
|
+
snapshotNodes = filtered
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const snapshotLines = buildSnapshotLines(snapshotNodes)
|
|
1196
|
+
|
|
1197
|
+
const shortRefMap = buildShortRefMap({ refs })
|
|
1198
|
+
const finalized = finalizeSnapshotOutput(snapshotLines, snapshotNodes, shortRefMap)
|
|
1199
|
+
const refsWithShortRef: Array<AriaRef & { selector?: string }> = refs.map((entry) => {
|
|
1200
|
+
return {
|
|
1201
|
+
...entry,
|
|
1202
|
+
shortRef: shortRefMap.get(entry.ref) ?? entry.ref,
|
|
1203
|
+
}
|
|
1204
|
+
})
|
|
1205
|
+
const result = { snapshot: finalized.snapshot, tree: finalized.tree, refs: refsWithShortRef }
|
|
1206
|
+
|
|
1207
|
+
// Build refToElement map
|
|
1208
|
+
const refToElement = new Map<string, { role: string; name: string; shortRef: string }>()
|
|
1209
|
+
const refToSelector = new Map<string, string>()
|
|
1210
|
+
for (const { ref, role, name, shortRef } of result.refs) {
|
|
1211
|
+
if (!refFilter || refFilter({ role, name })) {
|
|
1212
|
+
refToElement.set(ref, { role, name, shortRef })
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
for (const { ref, selector } of result.refs) {
|
|
1217
|
+
if (!selector) {
|
|
1218
|
+
continue
|
|
1219
|
+
}
|
|
1220
|
+
refToSelector.set(ref, selector)
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
const snapshot = result.snapshot
|
|
1224
|
+
|
|
1225
|
+
const getSelectorForRef = (ref: string): string | null => {
|
|
1226
|
+
const mapped = refToSelector.get(ref)
|
|
1227
|
+
if (mapped) {
|
|
1228
|
+
return mapped
|
|
1229
|
+
}
|
|
1230
|
+
const info = refToElement.get(ref)
|
|
1231
|
+
if (!info) {
|
|
1232
|
+
return null
|
|
1233
|
+
}
|
|
1234
|
+
const escapedName = info.name.replace(/"/g, '\\"')
|
|
1235
|
+
return `role=${info.role}[name="${escapedName}"]`
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const getRefsForLocators = async (locators: Array<Locator | ElementHandle>): Promise<Array<AriaRef | null>> => {
|
|
1239
|
+
if (locators.length === 0) {
|
|
1240
|
+
return []
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const targetHandles = await Promise.all(
|
|
1244
|
+
locators.map(async (loc) => {
|
|
1245
|
+
try {
|
|
1246
|
+
return 'elementHandle' in loc
|
|
1247
|
+
? await (loc as Locator).elementHandle({ timeout: 1000 })
|
|
1248
|
+
: (loc as ElementHandle)
|
|
1249
|
+
} catch {
|
|
1250
|
+
return null
|
|
1251
|
+
}
|
|
1252
|
+
}),
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
const matchingRefs = await page.evaluate(
|
|
1256
|
+
({ targets, refData }) => {
|
|
1257
|
+
return targets.map((target) => {
|
|
1258
|
+
if (!target) {
|
|
1259
|
+
return null
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const testIdAttrs = [
|
|
1263
|
+
'data-testid',
|
|
1264
|
+
'data-test-id',
|
|
1265
|
+
'data-test',
|
|
1266
|
+
'data-cy',
|
|
1267
|
+
'data-pw',
|
|
1268
|
+
'data-qa',
|
|
1269
|
+
'data-e2e',
|
|
1270
|
+
'data-automation-id',
|
|
1271
|
+
]
|
|
1272
|
+
for (const attr of testIdAttrs) {
|
|
1273
|
+
const value = target.getAttribute(attr)
|
|
1274
|
+
if (value) {
|
|
1275
|
+
const match = refData.find((ref) => {
|
|
1276
|
+
return ref.ref === value || ref.ref.startsWith(value)
|
|
1277
|
+
})
|
|
1278
|
+
if (match) {
|
|
1279
|
+
return match.ref
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
const id = target.getAttribute('id')
|
|
1285
|
+
if (id) {
|
|
1286
|
+
const match = refData.find((ref) => {
|
|
1287
|
+
return ref.ref === id || ref.ref.startsWith(id)
|
|
1288
|
+
})
|
|
1289
|
+
if (match) {
|
|
1290
|
+
return match.ref
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
return null
|
|
1295
|
+
})
|
|
1296
|
+
},
|
|
1297
|
+
{
|
|
1298
|
+
targets: targetHandles,
|
|
1299
|
+
refData: result.refs,
|
|
1300
|
+
},
|
|
1301
|
+
)
|
|
1302
|
+
|
|
1303
|
+
return matchingRefs.map((ref) => {
|
|
1304
|
+
if (!ref) {
|
|
1305
|
+
return null
|
|
1306
|
+
}
|
|
1307
|
+
const info = refToElement.get(ref)
|
|
1308
|
+
return info ? { ...info, ref } : null
|
|
1309
|
+
})
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
return {
|
|
1313
|
+
snapshot,
|
|
1314
|
+
tree: result.tree,
|
|
1315
|
+
refs: result.refs,
|
|
1316
|
+
refToElement,
|
|
1317
|
+
refToSelector,
|
|
1318
|
+
getSelectorForRef,
|
|
1319
|
+
getRefsForLocators,
|
|
1320
|
+
getRefForLocator: async (loc) => (await getRefsForLocators([loc]))[0],
|
|
1321
|
+
getRefStringForLocator: async (loc) => (await getRefsForLocators([loc]))[0]?.ref ?? null,
|
|
1322
|
+
}
|
|
1323
|
+
} finally {
|
|
1324
|
+
if (scopeApplied && scopeLocator) {
|
|
1325
|
+
await scopeLocator.evaluate((element, attr) => {
|
|
1326
|
+
element.removeAttribute(attr)
|
|
1327
|
+
}, scopeAttr)
|
|
1328
|
+
}
|
|
1329
|
+
if (oopifSessionId) {
|
|
1330
|
+
await session.send('Target.detachFromTarget', { sessionId: oopifSessionId }).catch((e) => {
|
|
1331
|
+
console.error('[aria-snapshot] Failed to detach OOPIF session:', oopifSessionId, e)
|
|
1332
|
+
})
|
|
1333
|
+
}
|
|
1334
|
+
if (!cdp) {
|
|
1335
|
+
await session.detach()
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function buildBoxFromQuad(quad?: number[]): LabelBox | null {
|
|
1341
|
+
if (!quad || quad.length < 8) {
|
|
1342
|
+
return null
|
|
1343
|
+
}
|
|
1344
|
+
const xs = [quad[0], quad[2], quad[4], quad[6]]
|
|
1345
|
+
const ys = [quad[1], quad[3], quad[5], quad[7]]
|
|
1346
|
+
const left = Math.min(...xs)
|
|
1347
|
+
const right = Math.max(...xs)
|
|
1348
|
+
const top = Math.min(...ys)
|
|
1349
|
+
const bottom = Math.max(...ys)
|
|
1350
|
+
return {
|
|
1351
|
+
x: left,
|
|
1352
|
+
y: top,
|
|
1353
|
+
width: Math.max(0, right - left),
|
|
1354
|
+
height: Math.max(0, bottom - top),
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function isTruthy<T>(value: T): value is NonNullable<T> {
|
|
1359
|
+
return Boolean(value)
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
async function getLabelBoxesForRefs({
|
|
1363
|
+
page,
|
|
1364
|
+
refs,
|
|
1365
|
+
maxConcurrency = MAX_LABEL_POSITION_CONCURRENCY,
|
|
1366
|
+
logger,
|
|
1367
|
+
cdp,
|
|
1368
|
+
}: {
|
|
1369
|
+
page: Page
|
|
1370
|
+
refs: AriaRef[]
|
|
1371
|
+
maxConcurrency?: number
|
|
1372
|
+
logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }
|
|
1373
|
+
cdp?: ICDPSession
|
|
1374
|
+
}): Promise<AriaLabel[]> {
|
|
1375
|
+
const log = logger?.info ?? logger?.error ?? console.error
|
|
1376
|
+
const session = cdp || (await getCDPSessionForPage({ page }))
|
|
1377
|
+
const sema = new Sema(maxConcurrency)
|
|
1378
|
+
const labelRefs = refs.filter((ref) => {
|
|
1379
|
+
return Boolean(ref.backendNodeId) && INTERACTIVE_ROLES.has(ref.role)
|
|
1380
|
+
})
|
|
1381
|
+
|
|
1382
|
+
log(`[getLabelBoxesForRefs] processing ${labelRefs.length} interactive refs (concurrency: ${maxConcurrency})`)
|
|
1383
|
+
const startTime = Date.now()
|
|
1384
|
+
let completed = 0
|
|
1385
|
+
let timedOut = 0
|
|
1386
|
+
let failed = 0
|
|
1387
|
+
|
|
1388
|
+
try {
|
|
1389
|
+
const labels = await Promise.all(
|
|
1390
|
+
labelRefs.map(async (ref) => {
|
|
1391
|
+
if (!ref.backendNodeId) {
|
|
1392
|
+
return null
|
|
1393
|
+
}
|
|
1394
|
+
await sema.acquire()
|
|
1395
|
+
try {
|
|
1396
|
+
const response = await Promise.race([
|
|
1397
|
+
session.send('DOM.getBoxModel', {
|
|
1398
|
+
backendNodeId: ref.backendNodeId,
|
|
1399
|
+
}) as Promise<Protocol.DOM.GetBoxModelResponse>,
|
|
1400
|
+
new Promise<null>((resolve) => {
|
|
1401
|
+
setTimeout(() => {
|
|
1402
|
+
resolve(null)
|
|
1403
|
+
}, BOX_MODEL_TIMEOUT_MS)
|
|
1404
|
+
}),
|
|
1405
|
+
])
|
|
1406
|
+
completed++
|
|
1407
|
+
if (completed % 50 === 0 || completed === labelRefs.length) {
|
|
1408
|
+
log(
|
|
1409
|
+
`[getLabelBoxesForRefs] progress: ${completed}/${labelRefs.length} (${timedOut} timeouts, ${failed} errors) - ${Date.now() - startTime}ms`,
|
|
1410
|
+
)
|
|
1411
|
+
}
|
|
1412
|
+
if (!response) {
|
|
1413
|
+
timedOut++
|
|
1414
|
+
return null
|
|
1415
|
+
}
|
|
1416
|
+
const box = buildBoxFromQuad(response.model.border)
|
|
1417
|
+
if (!box) {
|
|
1418
|
+
return null
|
|
1419
|
+
}
|
|
1420
|
+
return { ref: ref.ref, role: ref.role, box }
|
|
1421
|
+
} catch (error) {
|
|
1422
|
+
completed++
|
|
1423
|
+
failed++
|
|
1424
|
+
return null
|
|
1425
|
+
} finally {
|
|
1426
|
+
sema.release()
|
|
1427
|
+
}
|
|
1428
|
+
}),
|
|
1429
|
+
)
|
|
1430
|
+
log(
|
|
1431
|
+
`[getLabelBoxesForRefs] done: ${completed} completed, ${timedOut} timeouts, ${failed} errors - ${Date.now() - startTime}ms`,
|
|
1432
|
+
)
|
|
1433
|
+
return labels.filter(isTruthy)
|
|
1434
|
+
} finally {
|
|
1435
|
+
if (!cdp) {
|
|
1436
|
+
await session.detach()
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* Show Vimium-style labels on interactive elements.
|
|
1443
|
+
* Labels are colored badges positioned above each element showing the ref.
|
|
1444
|
+
* Use with screenshots so agents can see which elements are interactive.
|
|
1445
|
+
*
|
|
1446
|
+
* Labels auto-hide after 30 seconds to prevent stale labels.
|
|
1447
|
+
* Call this function again if the page HTML changes to get fresh labels.
|
|
1448
|
+
*
|
|
1449
|
+
* @param page - Playwright page
|
|
1450
|
+
* @param locator - Optional locator to scope labels to a subtree
|
|
1451
|
+
* @param interactiveOnly - Only show labels for interactive elements (default: true)
|
|
1452
|
+
*
|
|
1453
|
+
* @example
|
|
1454
|
+
* ```ts
|
|
1455
|
+
* const { snapshot, labelCount } = await showAriaRefLabels({ page })
|
|
1456
|
+
* await page.screenshot({ path: '/tmp/screenshot.png' })
|
|
1457
|
+
* // Agent sees [submit-btn] label on "Submit" button
|
|
1458
|
+
* await page.locator('[data-testid="submit-btn"]').click()
|
|
1459
|
+
* ```
|
|
1460
|
+
*/
|
|
1461
|
+
export async function showAriaRefLabels({
|
|
1462
|
+
page,
|
|
1463
|
+
locator,
|
|
1464
|
+
interactiveOnly = true,
|
|
1465
|
+
logger,
|
|
1466
|
+
}: {
|
|
1467
|
+
page: Page
|
|
1468
|
+
locator?: Locator
|
|
1469
|
+
interactiveOnly?: boolean
|
|
1470
|
+
logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }
|
|
1471
|
+
}): Promise<{
|
|
1472
|
+
snapshot: string
|
|
1473
|
+
labelCount: number
|
|
1474
|
+
}> {
|
|
1475
|
+
const startTime = Date.now()
|
|
1476
|
+
const log = logger?.info ?? logger?.error ?? console.error
|
|
1477
|
+
|
|
1478
|
+
log(`[showAriaRefLabels] starting...`)
|
|
1479
|
+
await ensureA11yClient(page)
|
|
1480
|
+
log(`[showAriaRefLabels] ensureA11yClient: ${Date.now() - startTime}ms`)
|
|
1481
|
+
|
|
1482
|
+
const cdpStart = Date.now()
|
|
1483
|
+
const cdp = await getCDPSessionForPage({ page })
|
|
1484
|
+
log(`[showAriaRefLabels] getCDPSessionForPage: ${Date.now() - cdpStart}ms`)
|
|
1485
|
+
|
|
1486
|
+
try {
|
|
1487
|
+
const snapshotStart = Date.now()
|
|
1488
|
+
const { snapshot, refs } = await getAriaSnapshot({ page, locator, interactiveOnly, cdp })
|
|
1489
|
+
const shortRefMap = new Map(
|
|
1490
|
+
refs.map((entry) => {
|
|
1491
|
+
return [entry.ref, entry.shortRef]
|
|
1492
|
+
}),
|
|
1493
|
+
)
|
|
1494
|
+
const interactiveRefs = refs.filter((ref) => Boolean(ref.backendNodeId) && INTERACTIVE_ROLES.has(ref.role))
|
|
1495
|
+
log(
|
|
1496
|
+
`[showAriaRefLabels] getAriaSnapshot: ${Date.now() - snapshotStart}ms (${refs.length} refs, ${interactiveRefs.length} interactive)`,
|
|
1497
|
+
)
|
|
1498
|
+
|
|
1499
|
+
const rootHandle = locator ? await locator.elementHandle() : null
|
|
1500
|
+
|
|
1501
|
+
const labelsStart = Date.now()
|
|
1502
|
+
const labels = await getLabelBoxesForRefs({ page, refs, logger, cdp })
|
|
1503
|
+
const shortLabels = labels.map((label) => {
|
|
1504
|
+
return {
|
|
1505
|
+
...label,
|
|
1506
|
+
ref: shortRefMap.get(label.ref) ?? label.ref,
|
|
1507
|
+
}
|
|
1508
|
+
})
|
|
1509
|
+
log(`[showAriaRefLabels] getLabelBoxesForRefs: ${Date.now() - labelsStart}ms (${labels.length} boxes)`)
|
|
1510
|
+
|
|
1511
|
+
const renderStart = Date.now()
|
|
1512
|
+
const labelCount = await page.evaluate(
|
|
1513
|
+
({ entries, root, interactiveOnly: intOnly }) => {
|
|
1514
|
+
const a11y = (
|
|
1515
|
+
globalThis as {
|
|
1516
|
+
__a11y?: {
|
|
1517
|
+
renderA11yLabels?: (labels: typeof entries) => number
|
|
1518
|
+
computeA11ySnapshot?: (options: { root: unknown; interactiveOnly: boolean; renderLabels: boolean }) => {
|
|
1519
|
+
labelCount: number
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
).__a11y
|
|
1524
|
+
if (a11y?.renderA11yLabels) {
|
|
1525
|
+
return a11y.renderA11yLabels(entries)
|
|
1526
|
+
}
|
|
1527
|
+
if (a11y?.computeA11ySnapshot) {
|
|
1528
|
+
const rootElement = root || document.body
|
|
1529
|
+
return a11y.computeA11ySnapshot({ root: rootElement, interactiveOnly: intOnly, renderLabels: true })
|
|
1530
|
+
.labelCount
|
|
1531
|
+
}
|
|
1532
|
+
throw new Error('a11y client not loaded')
|
|
1533
|
+
},
|
|
1534
|
+
{ entries: shortLabels, root: rootHandle, interactiveOnly },
|
|
1535
|
+
)
|
|
1536
|
+
|
|
1537
|
+
log(`[showAriaRefLabels] renderA11yLabels: ${Date.now() - renderStart}ms (${labelCount} labels)`)
|
|
1538
|
+
log(`[showAriaRefLabels] total: ${Date.now() - startTime}ms`)
|
|
1539
|
+
|
|
1540
|
+
return { snapshot, labelCount }
|
|
1541
|
+
} finally {
|
|
1542
|
+
await cdp.detach()
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/**
|
|
1547
|
+
* Remove all aria ref labels from the page.
|
|
1548
|
+
*/
|
|
1549
|
+
export async function hideAriaRefLabels({ page }: { page: Page }): Promise<void> {
|
|
1550
|
+
await page.evaluate(() => {
|
|
1551
|
+
const a11y = (globalThis as any).__a11y
|
|
1552
|
+
if (a11y) {
|
|
1553
|
+
a11y.hideA11yLabels()
|
|
1554
|
+
} else {
|
|
1555
|
+
// Fallback if client not loaded
|
|
1556
|
+
const doc = document
|
|
1557
|
+
const win = window as any
|
|
1558
|
+
const timerKey = '__tabwright_labels_timer__'
|
|
1559
|
+
if (win[timerKey]) {
|
|
1560
|
+
win.clearTimeout(win[timerKey])
|
|
1561
|
+
win[timerKey] = null
|
|
1562
|
+
}
|
|
1563
|
+
doc.getElementById('__tabwright_labels__')?.remove()
|
|
1564
|
+
}
|
|
1565
|
+
})
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/**
|
|
1569
|
+
* Take a screenshot with accessibility labels overlaid on interactive elements.
|
|
1570
|
+
* Shows Vimium-style labels, captures the screenshot, then removes the labels.
|
|
1571
|
+
* The screenshot is automatically included in the MCP response.
|
|
1572
|
+
*
|
|
1573
|
+
* @param page - Playwright page
|
|
1574
|
+
* @param locator - Optional locator to scope labels to a subtree
|
|
1575
|
+
* @param collector - Array to collect screenshots (passed by MCP execute tool)
|
|
1576
|
+
*
|
|
1577
|
+
* @example
|
|
1578
|
+
* ```ts
|
|
1579
|
+
* await screenshotWithAccessibilityLabels({ page })
|
|
1580
|
+
* // Screenshot is automatically included in the MCP response
|
|
1581
|
+
* // Use ref from the snapshot to interact with elements
|
|
1582
|
+
* await page.locator('[data-testid="submit-btn"]').click()
|
|
1583
|
+
* ```
|
|
1584
|
+
*/
|
|
1585
|
+
export async function screenshotWithAccessibilityLabels({
|
|
1586
|
+
page,
|
|
1587
|
+
locator,
|
|
1588
|
+
interactiveOnly = true,
|
|
1589
|
+
collector,
|
|
1590
|
+
logger,
|
|
1591
|
+
}: {
|
|
1592
|
+
page: Page
|
|
1593
|
+
locator?: Locator
|
|
1594
|
+
interactiveOnly?: boolean
|
|
1595
|
+
collector: ScreenshotResult[]
|
|
1596
|
+
logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }
|
|
1597
|
+
}): Promise<void> {
|
|
1598
|
+
const log = logger?.info ?? logger?.error
|
|
1599
|
+
const showLabelsStart = Date.now()
|
|
1600
|
+
const { snapshot, labelCount } = await showAriaRefLabels({ page, locator, interactiveOnly, logger })
|
|
1601
|
+
if (log) {
|
|
1602
|
+
log(`showAriaRefLabels: ${Date.now() - showLabelsStart}ms`)
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// Generate unique filename with timestamp
|
|
1606
|
+
const timestamp = Date.now()
|
|
1607
|
+
const random = Math.random().toString(36).slice(2, 6)
|
|
1608
|
+
const filename = `tabwright-screenshot-${timestamp}-${random}.png`
|
|
1609
|
+
|
|
1610
|
+
// Use ./tmp folder (gitignored) instead of system temp
|
|
1611
|
+
const tmpDir = path.join(process.cwd(), 'tmp')
|
|
1612
|
+
if (!fs.existsSync(tmpDir)) {
|
|
1613
|
+
fs.mkdirSync(tmpDir, { recursive: true })
|
|
1614
|
+
}
|
|
1615
|
+
const screenshotPath = path.join(tmpDir, filename)
|
|
1616
|
+
|
|
1617
|
+
// Get viewport size to clip screenshot to visible area
|
|
1618
|
+
const viewport = (await page.evaluate('({ width: window.innerWidth, height: window.innerHeight })')) as {
|
|
1619
|
+
width: number
|
|
1620
|
+
height: number
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// Check if sharp is available for resizing
|
|
1624
|
+
const sharp = await sharpPromise
|
|
1625
|
+
|
|
1626
|
+
// Clip dimensions: if sharp unavailable, limit capture area to LLM_MAX_DIMENSION
|
|
1627
|
+
const clipWidth = sharp ? viewport.width : Math.min(viewport.width, LLM_MAX_DIMENSION)
|
|
1628
|
+
const clipHeight = sharp ? viewport.height : Math.min(viewport.height, LLM_MAX_DIMENSION)
|
|
1629
|
+
|
|
1630
|
+
// Take viewport screenshot as PNG for Kitty Graphics Protocol compatibility.
|
|
1631
|
+
// PNG is lossless and the only format extracted by kitty-graphics-agent (f=100).
|
|
1632
|
+
const screenshotStart = Date.now()
|
|
1633
|
+
const rawBuffer = await page.screenshot({
|
|
1634
|
+
type: 'png',
|
|
1635
|
+
scale: 'css',
|
|
1636
|
+
clip: { x: 0, y: 0, width: clipWidth, height: clipHeight },
|
|
1637
|
+
})
|
|
1638
|
+
if (log) {
|
|
1639
|
+
log(`page.screenshot: ${Date.now() - screenshotStart}ms`)
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// Resize with resizeImage if sharp available, otherwise use clipped raw buffer
|
|
1643
|
+
const resizeStart = Date.now()
|
|
1644
|
+
const buffer = await (async () => {
|
|
1645
|
+
if (!sharp) {
|
|
1646
|
+
logger?.error?.('[tabwright] sharp not available, using clipped screenshot (max', LLM_MAX_DIMENSION, 'px)')
|
|
1647
|
+
return rawBuffer
|
|
1648
|
+
}
|
|
1649
|
+
try {
|
|
1650
|
+
const result = await resizeImageForAgent({ input: rawBuffer, format: 'png' })
|
|
1651
|
+
return result.buffer
|
|
1652
|
+
} catch (err) {
|
|
1653
|
+
logger?.error?.('[tabwright] sharp resize failed, using raw buffer:', err)
|
|
1654
|
+
return rawBuffer
|
|
1655
|
+
}
|
|
1656
|
+
})()
|
|
1657
|
+
if (log) {
|
|
1658
|
+
log(`screenshot resize: ${Date.now() - resizeStart}ms`)
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// Save to file
|
|
1662
|
+
fs.writeFileSync(screenshotPath, buffer)
|
|
1663
|
+
|
|
1664
|
+
// Convert to base64
|
|
1665
|
+
const base64 = buffer.toString('base64')
|
|
1666
|
+
|
|
1667
|
+
// Hide labels
|
|
1668
|
+
await hideAriaRefLabels({ page })
|
|
1669
|
+
|
|
1670
|
+
// Add to collector array
|
|
1671
|
+
collector.push({
|
|
1672
|
+
path: screenshotPath,
|
|
1673
|
+
base64,
|
|
1674
|
+
mimeType: 'image/png',
|
|
1675
|
+
snapshot,
|
|
1676
|
+
labelCount,
|
|
1677
|
+
})
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// Re-export for backward compatibility
|
|
1681
|
+
export { getAriaSnapshot as getAriaSnapshotWithRefs }
|