tabwright 1.0.0 → 1.0.1
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/dist/aria-snapshot.d.ts +0 -1
- package/dist/aria-snapshot.js +9 -1
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts +0 -1
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js +0 -1
- package/dist/bippy.js +1 -1
- package/dist/browser-config.d.ts +0 -1
- package/dist/browser-config.js +0 -1
- package/dist/browser-install.d.ts +0 -1
- package/dist/browser-install.js +0 -1
- package/dist/browser-launch.d.ts +0 -1
- package/dist/browser-launch.js +0 -1
- package/dist/builtin-capabilities.d.ts +0 -1
- package/dist/builtin-capabilities.js +0 -1
- package/dist/capability-agent-skill.d.ts +0 -1
- package/dist/capability-agent-skill.js +0 -1
- package/dist/capability-auth-state.d.ts +0 -1
- package/dist/capability-auth-state.js +0 -1
- package/dist/capability-auth.d.ts +0 -1
- package/dist/capability-auth.js +0 -1
- package/dist/capability-options.d.ts +0 -1
- package/dist/capability-options.js +0 -1
- package/dist/capability-package.d.ts +0 -1
- package/dist/capability-package.js +0 -1
- package/dist/capability-registry.d.ts +0 -1
- package/dist/capability-registry.js +0 -1
- package/dist/capability-runner.d.ts +0 -1
- package/dist/capability-runner.js +0 -1
- package/dist/capability-studio.d.ts +0 -1
- package/dist/capability-studio.js +0 -1
- package/dist/cdp-log.d.ts +0 -1
- package/dist/cdp-log.js +0 -1
- package/dist/cdp-relay.d.ts +0 -1
- package/dist/cdp-relay.js +0 -1
- package/dist/cdp-session.d.ts +0 -1
- package/dist/cdp-session.js +0 -1
- package/dist/cdp-types.d.ts +0 -1
- package/dist/cdp-types.js +0 -1
- package/dist/chrome-discovery.d.ts +0 -1
- package/dist/chrome-discovery.js +0 -1
- package/dist/clean-html.d.ts +0 -1
- package/dist/clean-html.js +0 -1
- package/dist/cli.d.ts +0 -1
- package/dist/cli.js +9 -2
- package/dist/cloud-client.d.ts +0 -1
- package/dist/cloud-client.js +0 -1
- package/dist/create-logger.d.ts +0 -1
- package/dist/create-logger.js +0 -1
- package/dist/debugger-examples-types.d.ts +0 -1
- package/dist/debugger-examples-types.js +0 -1
- package/dist/debugger-examples.d.ts +0 -1
- package/dist/debugger-examples.js +0 -1
- package/dist/debugger.d.ts +0 -1
- package/dist/debugger.js +0 -1
- package/dist/diff-utils.d.ts +0 -1
- package/dist/diff-utils.js +0 -1
- package/dist/doctor.d.ts +0 -1
- package/dist/doctor.js +5 -7
- package/dist/editor-examples.d.ts +0 -1
- package/dist/editor-examples.js +0 -1
- package/dist/editor.d.ts +0 -1
- package/dist/editor.js +0 -1
- package/dist/env-compat.d.ts +0 -1
- package/dist/env-compat.js +0 -1
- package/dist/executor.d.ts +0 -1
- package/dist/executor.js +1 -2
- package/dist/extension/background.js +7 -3
- package/dist/extension/manifest.json +1 -1
- package/dist/ghost-browser.d.ts +0 -1
- package/dist/ghost-browser.js +0 -1
- package/dist/ghost-cursor-controller.d.ts +0 -1
- package/dist/ghost-cursor-controller.js +0 -1
- package/dist/ghost-cursor.d.ts +0 -1
- package/dist/ghost-cursor.js +0 -1
- package/dist/htmlrewrite.d.ts +0 -1
- package/dist/htmlrewrite.js +0 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/kill-port.d.ts +0 -1
- package/dist/kill-port.js +0 -1
- package/dist/kitty-graphics.d.ts +0 -1
- package/dist/kitty-graphics.js +0 -1
- package/dist/mcp.d.ts +0 -1
- package/dist/mcp.js +0 -1
- package/dist/package-paths.d.ts +0 -1
- package/dist/package-paths.js +0 -1
- package/dist/page-markdown.d.ts +0 -1
- package/dist/page-markdown.js +0 -1
- package/dist/performance-examples.d.ts +0 -1
- package/dist/performance-examples.js +0 -1
- package/dist/playwright-import.d.ts +0 -1
- package/dist/playwright-import.js +0 -1
- package/dist/product-paths.d.ts +0 -1
- package/dist/product-paths.js +0 -1
- package/dist/protocol.d.ts +0 -1
- package/dist/protocol.js +0 -1
- package/dist/react-source.d.ts +0 -1
- package/dist/react-source.js +0 -1
- package/dist/readability.js +1 -1
- package/dist/relay-client.d.ts +0 -1
- package/dist/relay-client.js +0 -1
- package/dist/relay-state.d.ts +0 -1
- package/dist/relay-state.js +0 -1
- package/dist/replay-ai-index.d.ts +0 -1
- package/dist/replay-ai-index.js +0 -1
- package/dist/replay-eval.d.ts +0 -1
- package/dist/replay-eval.js +0 -1
- package/dist/replay-handoff.d.ts +0 -1
- package/dist/replay-handoff.js +0 -1
- package/dist/replay-workflow-compiler.d.ts +0 -1
- package/dist/replay-workflow-compiler.js +0 -1
- package/dist/rrweb-recording-relay.d.ts +0 -1
- package/dist/rrweb-recording-relay.js +0 -1
- package/dist/rrweb-recording.d.ts +0 -1
- package/dist/rrweb-recording.js +0 -1
- package/dist/scoped-fs.d.ts +0 -1
- package/dist/scoped-fs.js +0 -1
- package/dist/selector-generator.js +1 -1
- package/dist/start-relay-server.d.ts +0 -1
- package/dist/start-relay-server.js +0 -1
- package/dist/styles-examples.d.ts +0 -1
- package/dist/styles-examples.js +0 -1
- package/dist/styles.d.ts +0 -1
- package/dist/styles.js +0 -1
- package/dist/tabwright-agent-skill.d.ts +0 -1
- package/dist/tabwright-agent-skill.js +0 -1
- package/dist/test-declarations.d.ts +0 -1
- package/dist/test-declarations.js +0 -1
- package/dist/utils.d.ts +0 -1
- package/dist/utils.js +2 -2
- package/dist/wait-for-page-load.d.ts +0 -1
- package/dist/wait-for-page-load.js +0 -1
- package/dist/workflow-capability.d.ts +0 -1
- package/dist/workflow-capability.js +0 -1
- package/package.json +9 -4
- package/readme.md +35 -0
- package/dist/aria-snapshot.d.ts.map +0 -1
- package/dist/aria-snapshot.js.map +0 -1
- package/dist/aria-snapshot.test.d.ts +0 -2
- package/dist/aria-snapshot.test.d.ts.map +0 -1
- package/dist/aria-snapshot.test.js +0 -270
- package/dist/aria-snapshot.test.js.map +0 -1
- package/dist/aria-snapshot.unit.test.d.ts +0 -2
- package/dist/aria-snapshot.unit.test.d.ts.map +0 -1
- package/dist/aria-snapshot.unit.test.js +0 -542
- package/dist/aria-snapshot.unit.test.js.map +0 -1
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts.map +0 -1
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js.map +0 -1
- package/dist/browser-config.d.ts.map +0 -1
- package/dist/browser-config.js.map +0 -1
- package/dist/browser-install.d.ts.map +0 -1
- package/dist/browser-install.js.map +0 -1
- package/dist/browser-launch.d.ts.map +0 -1
- package/dist/browser-launch.js.map +0 -1
- package/dist/builtin-capabilities.d.ts.map +0 -1
- package/dist/builtin-capabilities.js.map +0 -1
- package/dist/capability-agent-skill.d.ts.map +0 -1
- package/dist/capability-agent-skill.js.map +0 -1
- package/dist/capability-auth-state.d.ts.map +0 -1
- package/dist/capability-auth-state.js.map +0 -1
- package/dist/capability-auth.d.ts.map +0 -1
- package/dist/capability-auth.js.map +0 -1
- package/dist/capability-cli-confirmation.test.d.ts +0 -2
- package/dist/capability-cli-confirmation.test.d.ts.map +0 -1
- package/dist/capability-cli-confirmation.test.js +0 -79
- package/dist/capability-cli-confirmation.test.js.map +0 -1
- package/dist/capability-options.d.ts.map +0 -1
- package/dist/capability-options.js.map +0 -1
- package/dist/capability-options.test.d.ts +0 -2
- package/dist/capability-options.test.d.ts.map +0 -1
- package/dist/capability-options.test.js +0 -81
- package/dist/capability-options.test.js.map +0 -1
- package/dist/capability-package.d.ts.map +0 -1
- package/dist/capability-package.js.map +0 -1
- package/dist/capability-package.test.d.ts +0 -2
- package/dist/capability-package.test.d.ts.map +0 -1
- package/dist/capability-package.test.js +0 -179
- package/dist/capability-package.test.js.map +0 -1
- package/dist/capability-registry.d.ts.map +0 -1
- package/dist/capability-registry.js.map +0 -1
- package/dist/capability-registry.test.d.ts +0 -2
- package/dist/capability-registry.test.d.ts.map +0 -1
- package/dist/capability-registry.test.js +0 -1683
- package/dist/capability-registry.test.js.map +0 -1
- package/dist/capability-runner.d.ts.map +0 -1
- package/dist/capability-runner.js.map +0 -1
- package/dist/capability-search-relevance.test.d.ts +0 -2
- package/dist/capability-search-relevance.test.d.ts.map +0 -1
- package/dist/capability-search-relevance.test.js +0 -67
- package/dist/capability-search-relevance.test.js.map +0 -1
- package/dist/capability-studio.d.ts.map +0 -1
- package/dist/capability-studio.js.map +0 -1
- package/dist/capability-studio.test.d.ts +0 -2
- package/dist/capability-studio.test.d.ts.map +0 -1
- package/dist/capability-studio.test.js +0 -41
- package/dist/capability-studio.test.js.map +0 -1
- package/dist/cdp-log.d.ts.map +0 -1
- package/dist/cdp-log.js.map +0 -1
- package/dist/cdp-log.test.d.ts +0 -2
- package/dist/cdp-log.test.d.ts.map +0 -1
- package/dist/cdp-log.test.js +0 -109
- package/dist/cdp-log.test.js.map +0 -1
- package/dist/cdp-relay.d.ts.map +0 -1
- package/dist/cdp-relay.js.map +0 -1
- package/dist/cdp-session.d.ts.map +0 -1
- package/dist/cdp-session.js.map +0 -1
- package/dist/cdp-types.d.ts.map +0 -1
- package/dist/cdp-types.js.map +0 -1
- package/dist/channel-owner-inspect.test.d.ts +0 -2
- package/dist/channel-owner-inspect.test.d.ts.map +0 -1
- package/dist/channel-owner-inspect.test.js +0 -75
- package/dist/channel-owner-inspect.test.js.map +0 -1
- package/dist/chrome-discovery.d.ts.map +0 -1
- package/dist/chrome-discovery.js.map +0 -1
- package/dist/chrome-discovery.test.d.ts +0 -2
- package/dist/chrome-discovery.test.d.ts.map +0 -1
- package/dist/chrome-discovery.test.js +0 -41
- package/dist/chrome-discovery.test.js.map +0 -1
- package/dist/clean-html.d.ts.map +0 -1
- package/dist/clean-html.js.map +0 -1
- package/dist/cli-help.test.d.ts +0 -2
- package/dist/cli-help.test.d.ts.map +0 -1
- package/dist/cli-help.test.js +0 -98
- package/dist/cli-help.test.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/cloud-client.d.ts.map +0 -1
- package/dist/cloud-client.js.map +0 -1
- package/dist/create-logger.d.ts.map +0 -1
- package/dist/create-logger.js.map +0 -1
- package/dist/debugger-examples-types.d.ts.map +0 -1
- package/dist/debugger-examples-types.js.map +0 -1
- package/dist/debugger-examples.d.ts.map +0 -1
- package/dist/debugger-examples.js.map +0 -1
- package/dist/debugger.d.ts.map +0 -1
- package/dist/debugger.js.map +0 -1
- package/dist/diff-utils.d.ts.map +0 -1
- package/dist/diff-utils.js.map +0 -1
- package/dist/diff-utils.test.d.ts +0 -2
- package/dist/diff-utils.test.d.ts.map +0 -1
- package/dist/diff-utils.test.js +0 -137
- package/dist/diff-utils.test.js.map +0 -1
- package/dist/doctor.d.ts.map +0 -1
- package/dist/doctor.js.map +0 -1
- package/dist/doctor.test.d.ts +0 -2
- package/dist/doctor.test.d.ts.map +0 -1
- package/dist/doctor.test.js +0 -208
- package/dist/doctor.test.js.map +0 -1
- package/dist/editor-examples.d.ts.map +0 -1
- package/dist/editor-examples.js.map +0 -1
- package/dist/editor.d.ts.map +0 -1
- package/dist/editor.js.map +0 -1
- package/dist/env-compat.d.ts.map +0 -1
- package/dist/env-compat.js.map +0 -1
- package/dist/executor.d.ts.map +0 -1
- package/dist/executor.js.map +0 -1
- package/dist/executor.unit.test.d.ts +0 -2
- package/dist/executor.unit.test.d.ts.map +0 -1
- package/dist/executor.unit.test.js +0 -143
- package/dist/executor.unit.test.js.map +0 -1
- package/dist/extension-connection.test.d.ts +0 -2
- package/dist/extension-connection.test.d.ts.map +0 -1
- package/dist/extension-connection.test.js +0 -708
- package/dist/extension-connection.test.js.map +0 -1
- package/dist/extension-icon-warning.test.d.ts +0 -2
- package/dist/extension-icon-warning.test.d.ts.map +0 -1
- package/dist/extension-icon-warning.test.js +0 -33
- package/dist/extension-icon-warning.test.js.map +0 -1
- package/dist/ghost-browser.d.ts.map +0 -1
- package/dist/ghost-browser.js.map +0 -1
- package/dist/ghost-cursor-controller.d.ts.map +0 -1
- package/dist/ghost-cursor-controller.js.map +0 -1
- package/dist/ghost-cursor.d.ts.map +0 -1
- package/dist/ghost-cursor.js.map +0 -1
- package/dist/htmlrewrite.d.ts.map +0 -1
- package/dist/htmlrewrite.js.map +0 -1
- package/dist/htmlrewrite.test.d.ts +0 -2
- package/dist/htmlrewrite.test.d.ts.map +0 -1
- package/dist/htmlrewrite.test.js +0 -7975
- package/dist/htmlrewrite.test.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/kill-port.d.ts.map +0 -1
- package/dist/kill-port.js.map +0 -1
- package/dist/kitty-graphics.d.ts.map +0 -1
- package/dist/kitty-graphics.js.map +0 -1
- package/dist/kitty-graphics.test.d.ts +0 -2
- package/dist/kitty-graphics.test.d.ts.map +0 -1
- package/dist/kitty-graphics.test.js +0 -125
- package/dist/kitty-graphics.test.js.map +0 -1
- package/dist/locator-selector.test.d.ts +0 -2
- package/dist/locator-selector.test.d.ts.map +0 -1
- package/dist/locator-selector.test.js +0 -98
- package/dist/locator-selector.test.js.map +0 -1
- package/dist/mcp-client.d.ts +0 -20
- package/dist/mcp-client.d.ts.map +0 -1
- package/dist/mcp-client.js +0 -56
- package/dist/mcp-client.js.map +0 -1
- package/dist/mcp.d.ts.map +0 -1
- package/dist/mcp.js.map +0 -1
- package/dist/on-mouse-action.test.d.ts +0 -2
- package/dist/on-mouse-action.test.d.ts.map +0 -1
- package/dist/on-mouse-action.test.js +0 -180
- package/dist/on-mouse-action.test.js.map +0 -1
- package/dist/options-page.test.d.ts +0 -2
- package/dist/options-page.test.d.ts.map +0 -1
- package/dist/options-page.test.js +0 -773
- package/dist/options-page.test.js.map +0 -1
- package/dist/package-paths.d.ts.map +0 -1
- package/dist/package-paths.js.map +0 -1
- package/dist/page-markdown.d.ts.map +0 -1
- package/dist/page-markdown.js.map +0 -1
- package/dist/performance-examples.d.ts.map +0 -1
- package/dist/performance-examples.js.map +0 -1
- package/dist/playwright-import.d.ts.map +0 -1
- package/dist/playwright-import.js.map +0 -1
- package/dist/popup-relocation.test.d.ts +0 -7
- package/dist/popup-relocation.test.d.ts.map +0 -1
- package/dist/popup-relocation.test.js +0 -139
- package/dist/popup-relocation.test.js.map +0 -1
- package/dist/product-paths.d.ts.map +0 -1
- package/dist/product-paths.js.map +0 -1
- package/dist/product-paths.test.d.ts +0 -2
- package/dist/product-paths.test.d.ts.map +0 -1
- package/dist/product-paths.test.js +0 -39
- package/dist/product-paths.test.js.map +0 -1
- package/dist/protocol.d.ts.map +0 -1
- package/dist/protocol.js.map +0 -1
- package/dist/protocol.test.d.ts +0 -2
- package/dist/protocol.test.d.ts.map +0 -1
- package/dist/protocol.test.js +0 -31
- package/dist/protocol.test.js.map +0 -1
- package/dist/react-source.d.ts.map +0 -1
- package/dist/react-source.js.map +0 -1
- package/dist/relay-client.d.ts.map +0 -1
- package/dist/relay-client.js.map +0 -1
- package/dist/relay-client.test.d.ts +0 -2
- package/dist/relay-client.test.d.ts.map +0 -1
- package/dist/relay-client.test.js +0 -305
- package/dist/relay-client.test.js.map +0 -1
- package/dist/relay-core.test.d.ts +0 -2
- package/dist/relay-core.test.d.ts.map +0 -1
- package/dist/relay-core.test.js +0 -1427
- package/dist/relay-core.test.js.map +0 -1
- package/dist/relay-navigation.test.d.ts +0 -2
- package/dist/relay-navigation.test.d.ts.map +0 -1
- package/dist/relay-navigation.test.js +0 -661
- package/dist/relay-navigation.test.js.map +0 -1
- package/dist/relay-session.test.d.ts +0 -2
- package/dist/relay-session.test.d.ts.map +0 -1
- package/dist/relay-session.test.js +0 -1121
- package/dist/relay-session.test.js.map +0 -1
- package/dist/relay-state.d.ts.map +0 -1
- package/dist/relay-state.js.map +0 -1
- package/dist/relay-state.test.d.ts +0 -2
- package/dist/relay-state.test.d.ts.map +0 -1
- package/dist/relay-state.test.js +0 -594
- package/dist/relay-state.test.js.map +0 -1
- package/dist/replay-ai-index.d.ts.map +0 -1
- package/dist/replay-ai-index.js.map +0 -1
- package/dist/replay-ai-index.test.d.ts +0 -2
- package/dist/replay-ai-index.test.d.ts.map +0 -1
- package/dist/replay-ai-index.test.js +0 -255
- package/dist/replay-ai-index.test.js.map +0 -1
- package/dist/replay-cli.test.d.ts +0 -2
- package/dist/replay-cli.test.d.ts.map +0 -1
- package/dist/replay-cli.test.js +0 -275
- package/dist/replay-cli.test.js.map +0 -1
- package/dist/replay-eval.d.ts.map +0 -1
- package/dist/replay-eval.js.map +0 -1
- package/dist/replay-handoff.d.ts.map +0 -1
- package/dist/replay-handoff.js.map +0 -1
- package/dist/replay-handoff.test.d.ts +0 -2
- package/dist/replay-handoff.test.d.ts.map +0 -1
- package/dist/replay-handoff.test.js +0 -123
- package/dist/replay-handoff.test.js.map +0 -1
- package/dist/replay-workflow-compiler.d.ts.map +0 -1
- package/dist/replay-workflow-compiler.js.map +0 -1
- package/dist/replay-workflow-compiler.test.d.ts +0 -2
- package/dist/replay-workflow-compiler.test.d.ts.map +0 -1
- package/dist/replay-workflow-compiler.test.js +0 -223
- package/dist/replay-workflow-compiler.test.js.map +0 -1
- package/dist/rrweb-recording-relay.d.ts.map +0 -1
- package/dist/rrweb-recording-relay.js.map +0 -1
- package/dist/rrweb-recording-relay.test.d.ts +0 -2
- package/dist/rrweb-recording-relay.test.d.ts.map +0 -1
- package/dist/rrweb-recording-relay.test.js +0 -85
- package/dist/rrweb-recording-relay.test.js.map +0 -1
- package/dist/rrweb-recording.d.ts.map +0 -1
- package/dist/rrweb-recording.js.map +0 -1
- package/dist/scoped-fs.d.ts.map +0 -1
- package/dist/scoped-fs.js.map +0 -1
- package/dist/scoped-fs.test.d.ts +0 -5
- package/dist/scoped-fs.test.d.ts.map +0 -1
- package/dist/scoped-fs.test.js +0 -73
- package/dist/scoped-fs.test.js.map +0 -1
- package/dist/skill-stub.test.d.ts +0 -2
- package/dist/skill-stub.test.d.ts.map +0 -1
- package/dist/skill-stub.test.js +0 -22
- package/dist/skill-stub.test.js.map +0 -1
- package/dist/snapshot-tools.test.d.ts +0 -2
- package/dist/snapshot-tools.test.d.ts.map +0 -1
- package/dist/snapshot-tools.test.js +0 -856
- package/dist/snapshot-tools.test.js.map +0 -1
- package/dist/start-relay-server.d.ts.map +0 -1
- package/dist/start-relay-server.js.map +0 -1
- package/dist/styles-examples.d.ts.map +0 -1
- package/dist/styles-examples.js.map +0 -1
- package/dist/styles.d.ts.map +0 -1
- package/dist/styles.js.map +0 -1
- package/dist/tabwright-agent-skill.d.ts.map +0 -1
- package/dist/tabwright-agent-skill.js.map +0 -1
- package/dist/tabwright-agent-skill.test.d.ts +0 -2
- package/dist/tabwright-agent-skill.test.d.ts.map +0 -1
- package/dist/tabwright-agent-skill.test.js +0 -51
- package/dist/tabwright-agent-skill.test.js.map +0 -1
- package/dist/test-declarations.d.ts.map +0 -1
- package/dist/test-declarations.js.map +0 -1
- package/dist/test-utils.d.ts +0 -69
- package/dist/test-utils.d.ts.map +0 -1
- package/dist/test-utils.js +0 -448
- package/dist/test-utils.js.map +0 -1
- package/dist/test-utils.test.d.ts +0 -2
- package/dist/test-utils.test.d.ts.map +0 -1
- package/dist/test-utils.test.js +0 -55
- package/dist/test-utils.test.js.map +0 -1
- package/dist/utils.d.ts.map +0 -1
- package/dist/utils.js.map +0 -1
- package/dist/wait-for-page-load.d.ts.map +0 -1
- package/dist/wait-for-page-load.js.map +0 -1
- package/dist/workflow-capability.d.ts.map +0 -1
- package/dist/workflow-capability.js.map +0 -1
- package/dist/workflow-script-flow.test.d.ts +0 -2
- package/dist/workflow-script-flow.test.d.ts.map +0 -1
- package/dist/workflow-script-flow.test.js +0 -285
- package/dist/workflow-script-flow.test.js.map +0 -1
- package/src/__snapshots__/x.com.processed.html +0 -1003
- package/src/__snapshots__/x.com.processed.withStyles.html +0 -2720
- package/src/a11y-client.ts +0 -241
- package/src/aria-snapshot.test.ts +0 -311
- package/src/aria-snapshot.ts +0 -1681
- package/src/aria-snapshot.unit.test.ts +0 -615
- package/src/aria-snapshots/github-interactive.txt +0 -193
- package/src/aria-snapshots/github-raw.txt +0 -309
- package/src/aria-snapshots/hackernews-interactive.txt +0 -477
- package/src/aria-snapshots/hackernews-raw.txt +0 -428
- package/src/aria-snapshots/prosemirror-interactive.txt +0 -66
- package/src/aria-snapshots/prosemirror-raw.txt +0 -145
- 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 +0 -5
- package/src/assets/cursors/screen-studio/pointer-macos-tahoe.svg +0 -18
- package/src/assets/x.com.html +0 -32946
- package/src/browser-config.ts +0 -179
- package/src/browser-install.ts +0 -284
- package/src/browser-launch.ts +0 -75
- package/src/builtin-capabilities.ts +0 -2765
- package/src/capability-agent-skill.ts +0 -305
- package/src/capability-auth-state.ts +0 -197
- package/src/capability-auth.ts +0 -249
- package/src/capability-cli-confirmation.test.ts +0 -90
- package/src/capability-options.test.ts +0 -89
- package/src/capability-options.ts +0 -57
- package/src/capability-package.test.ts +0 -204
- package/src/capability-package.ts +0 -555
- package/src/capability-registry.test.ts +0 -1905
- package/src/capability-registry.ts +0 -1234
- package/src/capability-runner.ts +0 -801
- package/src/capability-search-relevance.test.ts +0 -73
- package/src/capability-studio.test.ts +0 -45
- package/src/capability-studio.ts +0 -641
- package/src/cdp-log.test.ts +0 -131
- package/src/cdp-log.ts +0 -112
- package/src/cdp-relay.ts +0 -2944
- package/src/cdp-session.ts +0 -77
- package/src/cdp-types.ts +0 -158
- package/src/channel-owner-inspect.test.ts +0 -94
- package/src/chrome-discovery.test.ts +0 -50
- package/src/chrome-discovery.ts +0 -220
- package/src/clean-html.ts +0 -141
- package/src/cli-help.test.ts +0 -123
- package/src/cli.ts +0 -3181
- package/src/cloud-client.ts +0 -179
- package/src/create-logger.ts +0 -38
- package/src/debugger-examples-types.ts +0 -16
- package/src/debugger-examples.ts +0 -66
- package/src/debugger.ts +0 -708
- package/src/diff-utils.test.ts +0 -152
- package/src/diff-utils.ts +0 -70
- package/src/doctor.test.ts +0 -244
- package/src/doctor.ts +0 -321
- package/src/editor-examples.ts +0 -158
- package/src/editor.ts +0 -426
- package/src/env-compat.ts +0 -28
- package/src/executor.ts +0 -1824
- package/src/executor.unit.test.ts +0 -168
- package/src/extension-connection.test.ts +0 -855
- package/src/extension-icon-warning.test.ts +0 -45
- package/src/ghost-browser.ts +0 -149
- package/src/ghost-cursor-client.ts +0 -494
- package/src/ghost-cursor-controller.ts +0 -129
- package/src/ghost-cursor.ts +0 -123
- package/src/htmlrewrite.test.ts +0 -8008
- package/src/htmlrewrite.ts +0 -390
- package/src/index.ts +0 -53
- package/src/kill-port.ts +0 -219
- package/src/kitty-graphics.test.ts +0 -139
- package/src/kitty-graphics.ts +0 -79
- package/src/locator-selector.test.ts +0 -119
- package/src/mcp-client.ts +0 -78
- package/src/mcp.ts +0 -629
- package/src/on-mouse-action.test.ts +0 -226
- package/src/options-page.test.ts +0 -839
- package/src/package-paths.ts +0 -38
- package/src/page-markdown.ts +0 -240
- package/src/performance-examples.ts +0 -186
- package/src/playwright-import.ts +0 -59
- package/src/popup-relocation.test.ts +0 -163
- package/src/product-paths.test.ts +0 -47
- package/src/product-paths.ts +0 -28
- package/src/protocol.test.ts +0 -52
- package/src/protocol.ts +0 -351
- package/src/react-source.ts +0 -379
- package/src/relay-client.test.ts +0 -375
- package/src/relay-client.ts +0 -572
- package/src/relay-core.test.ts +0 -1603
- package/src/relay-navigation.test.ts +0 -790
- package/src/relay-session.test.ts +0 -1375
- package/src/relay-state.test.ts +0 -729
- package/src/relay-state.ts +0 -561
- package/src/replay-ai-index.test.ts +0 -278
- package/src/replay-ai-index.ts +0 -872
- package/src/replay-cli.test.ts +0 -325
- package/src/replay-eval.ts +0 -1039
- package/src/replay-handoff.test.ts +0 -163
- package/src/replay-handoff.ts +0 -109
- package/src/replay-workflow-compiler.test.ts +0 -250
- package/src/replay-workflow-compiler.ts +0 -700
- package/src/resource.md +0 -409
- package/src/rrweb-recording-relay.test.ts +0 -99
- package/src/rrweb-recording-relay.ts +0 -400
- package/src/rrweb-recording.ts +0 -322
- package/src/scoped-fs.test.ts +0 -80
- package/src/scoped-fs.ts +0 -420
- package/src/skill-stub.test.ts +0 -24
- package/src/snapshot-tools.test.ts +0 -1017
- package/src/snapshots/hacker-news-accessibility-full.md +0 -81
- package/src/snapshots/hacker-news-accessibility-interactive.md +0 -43
- package/src/snapshots/page-markdown-output.txt +0 -16
- package/src/snapshots/shadcn-ui-accessibility-full.md +0 -201
- package/src/snapshots/shadcn-ui-accessibility-interactive.md +0 -109
- package/src/start-relay-server.ts +0 -69
- package/src/styles-examples.ts +0 -84
- package/src/styles.ts +0 -343
- package/src/tabwright-agent-skill.test.ts +0 -61
- package/src/tabwright-agent-skill.ts +0 -105
- package/src/test-declarations.ts +0 -13
- package/src/test-utils.test.ts +0 -70
- package/src/test-utils.ts +0 -566
- package/src/utils.ts +0 -78
- package/src/wait-for-page-load.ts +0 -198
- package/src/workflow-capability.ts +0 -417
- package/src/workflow-script-flow.test.ts +0 -388
package/src/executor.ts
DELETED
|
@@ -1,1824 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PlaywrightExecutor - Manages browser connection and code execution per session.
|
|
3
|
-
* Used by both MCP and CLI to execute Playwright code with persistent state.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { Page, Frame, Browser, BrowserContext, Locator, FrameLocator, ElementHandle } from '@xmorse/playwright-core'
|
|
7
|
-
import { getChromium, isPatchrightEnabled } from './playwright-import.js'
|
|
8
|
-
import crypto from 'node:crypto'
|
|
9
|
-
import fs from 'node:fs'
|
|
10
|
-
import path from 'node:path'
|
|
11
|
-
import os from 'node:os'
|
|
12
|
-
import util from 'node:util'
|
|
13
|
-
import { createRequire } from 'node:module'
|
|
14
|
-
import { fileURLToPath } from 'node:url'
|
|
15
|
-
import vm from 'node:vm'
|
|
16
|
-
import * as acorn from 'acorn'
|
|
17
|
-
import { createSmartDiff } from './diff-utils.js'
|
|
18
|
-
import { getCdpUrl, parseRelayHost, shouldAutoEnableTabwright } from './utils.js'
|
|
19
|
-
import { getExtensionOutdatedWarning } from './relay-client.js'
|
|
20
|
-
import { waitForPageLoad, WaitForPageLoadOptions, WaitForPageLoadResult } from './wait-for-page-load.js'
|
|
21
|
-
import { ICDPSession, getCDPSessionForPage } from './cdp-session.js'
|
|
22
|
-
import { Debugger } from './debugger.js'
|
|
23
|
-
import { Editor } from './editor.js'
|
|
24
|
-
import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js'
|
|
25
|
-
import { getReactSource, getReactComponentInfo, type ReactSourceLocation } from './react-source.js'
|
|
26
|
-
import { ScopedFS } from './scoped-fs.js'
|
|
27
|
-
import {
|
|
28
|
-
screenshotWithAccessibilityLabels,
|
|
29
|
-
getAriaSnapshot,
|
|
30
|
-
resizeImageForAgent,
|
|
31
|
-
type ScreenshotResult,
|
|
32
|
-
type SnapshotFormat,
|
|
33
|
-
} from './aria-snapshot.js'
|
|
34
|
-
import { createGhostBrowserChrome, type GhostBrowserCommandResult } from './ghost-browser.js'
|
|
35
|
-
export type { SnapshotFormat }
|
|
36
|
-
import { getCleanHTML, type GetCleanHTMLOptions } from './clean-html.js'
|
|
37
|
-
import { getPageMarkdown, type GetPageMarkdownOptions } from './page-markdown.js'
|
|
38
|
-
import { createReplayApi } from './rrweb-recording.js'
|
|
39
|
-
import {
|
|
40
|
-
saveWorkflowCapability,
|
|
41
|
-
saveWorkflowFromRecording,
|
|
42
|
-
type SaveWorkflowCapabilityOptions,
|
|
43
|
-
type SaveWorkflowFromRecordingOptions,
|
|
44
|
-
} from './workflow-capability.js'
|
|
45
|
-
import { type GhostCursorClientOptions } from './ghost-cursor.js'
|
|
46
|
-
import { GhostCursorController } from './ghost-cursor-controller.js'
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const __filename = fileURLToPath(import.meta.url)
|
|
50
|
-
const __dirname = path.dirname(__filename)
|
|
51
|
-
|
|
52
|
-
const require = createRequire(import.meta.url)
|
|
53
|
-
|
|
54
|
-
export class CodeExecutionTimeoutError extends Error {
|
|
55
|
-
constructor(timeout: number) {
|
|
56
|
-
super(`Code execution timed out after ${timeout}ms`)
|
|
57
|
-
this.name = 'CodeExecutionTimeoutError'
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const usefulGlobals = {
|
|
62
|
-
setTimeout,
|
|
63
|
-
setInterval,
|
|
64
|
-
clearTimeout,
|
|
65
|
-
clearInterval,
|
|
66
|
-
URL,
|
|
67
|
-
URLSearchParams,
|
|
68
|
-
fetch,
|
|
69
|
-
Buffer,
|
|
70
|
-
TextEncoder,
|
|
71
|
-
TextDecoder,
|
|
72
|
-
crypto,
|
|
73
|
-
AbortController,
|
|
74
|
-
AbortSignal,
|
|
75
|
-
structuredClone,
|
|
76
|
-
process,
|
|
77
|
-
} as const
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Parse code and check if it's a single expression that should be auto-returned.
|
|
81
|
-
* Returns the exact expression source (without trailing semicolon) using AST
|
|
82
|
-
* node offsets, or null if the code should not be auto-wrapped. See #58.
|
|
83
|
-
*/
|
|
84
|
-
export function getAutoReturnExpression(code: string): string | null {
|
|
85
|
-
try {
|
|
86
|
-
const ast = acorn.parse(code, {
|
|
87
|
-
ecmaVersion: 'latest',
|
|
88
|
-
allowAwaitOutsideFunction: true,
|
|
89
|
-
allowReturnOutsideFunction: true,
|
|
90
|
-
sourceType: 'script',
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
// Must be exactly one statement
|
|
94
|
-
if (ast.body.length !== 1) {
|
|
95
|
-
return null
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const stmt = ast.body[0]
|
|
99
|
-
|
|
100
|
-
// If it's already a return statement, don't auto-wrap
|
|
101
|
-
if (stmt.type === 'ReturnStatement') {
|
|
102
|
-
return null
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Must be an ExpressionStatement
|
|
106
|
-
if (stmt.type !== 'ExpressionStatement') {
|
|
107
|
-
return null
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Don't auto-return side-effect expressions
|
|
111
|
-
const expr = stmt.expression
|
|
112
|
-
if (
|
|
113
|
-
expr.type === 'AssignmentExpression' ||
|
|
114
|
-
expr.type === 'UpdateExpression' ||
|
|
115
|
-
(expr.type === 'UnaryExpression' && expr.operator === 'delete')
|
|
116
|
-
) {
|
|
117
|
-
return null
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Don't auto-return sequence expressions that contain assignments
|
|
121
|
-
if (expr.type === 'SequenceExpression') {
|
|
122
|
-
const hasAssignment = expr.expressions.some((e) => e.type === 'AssignmentExpression')
|
|
123
|
-
if (hasAssignment) {
|
|
124
|
-
return null
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Use the expression node's start/end offsets to extract just the expression
|
|
129
|
-
// source, excluding any trailing semicolon. This is more robust than regex.
|
|
130
|
-
return code.slice(expr.start, expr.end)
|
|
131
|
-
} catch {
|
|
132
|
-
// Parse failed, don't auto-return
|
|
133
|
-
return null
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Backward-compatible helper: returns true if code should be auto-wrapped. */
|
|
138
|
-
export function shouldAutoReturn(code: string): boolean {
|
|
139
|
-
return getAutoReturnExpression(code) !== null
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Wraps user code in an async IIFE for vm execution.
|
|
144
|
-
* Uses AST node offsets to extract the expression without trailing semicolons,
|
|
145
|
-
* avoiding SyntaxError when embedding inside `return await (...)`. See #58.
|
|
146
|
-
*/
|
|
147
|
-
export function wrapCode(code: string): string {
|
|
148
|
-
const expr = getAutoReturnExpression(code)
|
|
149
|
-
if (expr !== null) {
|
|
150
|
-
return `(async () => { return await (${expr}) })()`
|
|
151
|
-
}
|
|
152
|
-
return `(async () => { ${code} })()`
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const EXTENSION_NOT_CONNECTED_ERROR = `The Tabwright Chrome extension is not connected. Make sure you have:
|
|
156
|
-
1. Installed the extension: https://chromewebstore.google.com/detail/tabwright/jfeammnjpkecdekppnclgkkffahnhfhe
|
|
157
|
-
2. Clicked the extension icon on a tab to enable it (or refreshed the page if just installed)
|
|
158
|
-
3. Or use a cloud browser instead: run \`tabwright cloud login\` in your terminal to rent a browser in the cloud, with auto CAPTCHA solving, residential proxies and anti-detection built in`
|
|
159
|
-
|
|
160
|
-
const NO_PAGES_AVAILABLE_ERROR =
|
|
161
|
-
'No Playwright pages are available. Enable Tabwright on a tab or unset TABWRIGHT_AUTO_ENABLE=false to auto-create one.'
|
|
162
|
-
|
|
163
|
-
const CLOUD_SESSION_EXPIRED_ERROR =
|
|
164
|
-
'Cloud browser session expired or was destroyed. Create a new session with: tabwright session new --browser cloud'
|
|
165
|
-
|
|
166
|
-
/** Patterns that indicate the browser/page/context was closed or the WebSocket died.
|
|
167
|
-
* Used to detect cloud VM expiration vs other Playwright errors. */
|
|
168
|
-
const DISCONNECTION_PATTERNS = [
|
|
169
|
-
'browser has been closed',
|
|
170
|
-
'browser.close',
|
|
171
|
-
'Target page, context or browser has been closed',
|
|
172
|
-
'Target closed',
|
|
173
|
-
'connection refused',
|
|
174
|
-
'WebSocket is not open',
|
|
175
|
-
'WebSocket error',
|
|
176
|
-
'connect ECONNREFUSED',
|
|
177
|
-
'Session closed',
|
|
178
|
-
'Connection closed',
|
|
179
|
-
'NS_ERROR_NET_RESET',
|
|
180
|
-
]
|
|
181
|
-
|
|
182
|
-
function isDisconnectionError(error: Error): boolean {
|
|
183
|
-
const msg = error.message || ''
|
|
184
|
-
const stack = error.stack || ''
|
|
185
|
-
const matchesHere = DISCONNECTION_PATTERNS.some((pattern) => {
|
|
186
|
-
return msg.includes(pattern) || stack.includes(pattern)
|
|
187
|
-
})
|
|
188
|
-
if (matchesHere) return true
|
|
189
|
-
// Walk the cause chain — ensureConnection wraps the real WebSocket error
|
|
190
|
-
// in a new Error with { cause }, so we need to check nested causes too.
|
|
191
|
-
if (error.cause instanceof Error) {
|
|
192
|
-
return isDisconnectionError(error.cause)
|
|
193
|
-
}
|
|
194
|
-
return false
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const MAX_LOGS_PER_PAGE = 5000
|
|
198
|
-
|
|
199
|
-
const ALLOWED_MODULES = new Set([
|
|
200
|
-
'path',
|
|
201
|
-
'node:path',
|
|
202
|
-
'url',
|
|
203
|
-
'node:url',
|
|
204
|
-
'querystring',
|
|
205
|
-
'node:querystring',
|
|
206
|
-
'punycode',
|
|
207
|
-
'node:punycode',
|
|
208
|
-
'crypto',
|
|
209
|
-
'node:crypto',
|
|
210
|
-
'buffer',
|
|
211
|
-
'node:buffer',
|
|
212
|
-
'string_decoder',
|
|
213
|
-
'node:string_decoder',
|
|
214
|
-
'util',
|
|
215
|
-
'node:util',
|
|
216
|
-
'assert',
|
|
217
|
-
'node:assert',
|
|
218
|
-
'events',
|
|
219
|
-
'node:events',
|
|
220
|
-
'timers',
|
|
221
|
-
'node:timers',
|
|
222
|
-
'stream',
|
|
223
|
-
'node:stream',
|
|
224
|
-
'zlib',
|
|
225
|
-
'node:zlib',
|
|
226
|
-
'http',
|
|
227
|
-
'node:http',
|
|
228
|
-
'https',
|
|
229
|
-
'node:https',
|
|
230
|
-
'http2',
|
|
231
|
-
'node:http2',
|
|
232
|
-
'os',
|
|
233
|
-
'node:os',
|
|
234
|
-
'fs',
|
|
235
|
-
'node:fs',
|
|
236
|
-
])
|
|
237
|
-
|
|
238
|
-
export interface ExecuteScreenshot {
|
|
239
|
-
path: string
|
|
240
|
-
base64: string
|
|
241
|
-
mimeType: 'image/png'
|
|
242
|
-
snapshot: string
|
|
243
|
-
labelCount: number
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
export interface ExecuteResult {
|
|
247
|
-
text: string
|
|
248
|
-
images: Array<{ data: string; mimeType: string }>
|
|
249
|
-
screenshots: ExecuteScreenshot[]
|
|
250
|
-
isError: boolean
|
|
251
|
-
structuredResult?: unknown
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
export interface ExecuteOptions {
|
|
255
|
-
includeStructuredResult?: boolean
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
interface WarningEvent {
|
|
259
|
-
id: number
|
|
260
|
-
message: string
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
interface WarningScope {
|
|
264
|
-
cursor: number
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
export interface ExecutorLogger {
|
|
268
|
-
log(...args: any[]): void
|
|
269
|
-
error(...args: any[]): void
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
export interface CdpConfig {
|
|
273
|
-
host?: string
|
|
274
|
-
port?: number
|
|
275
|
-
token?: string
|
|
276
|
-
extensionId?: string | null
|
|
277
|
-
/** Direct CDP WebSocket URL — bypasses relay + extension, connects straight to Chrome */
|
|
278
|
-
directCdpUrl?: string
|
|
279
|
-
/** Launch a headless Chrome via chromium.launch() instead of connecting to an existing one.
|
|
280
|
-
* Uses direct Playwright browser management, no extension or relay CDP routing needed. */
|
|
281
|
-
headless?: boolean
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
export interface SessionMetadata {
|
|
285
|
-
extensionId: string | null
|
|
286
|
-
browser: string | null
|
|
287
|
-
profile: { email: string; id: string } | null
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
export interface SessionInfo {
|
|
291
|
-
id: string
|
|
292
|
-
stateKeys: string[]
|
|
293
|
-
extensionId: string | null
|
|
294
|
-
browser: string | null
|
|
295
|
-
profile: { email: string; id: string } | null
|
|
296
|
-
cwd: string | null
|
|
297
|
-
connected: boolean
|
|
298
|
-
pageUrl: string | null
|
|
299
|
-
pagesCount: number
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
export interface CloudSessionInfo {
|
|
303
|
-
/** Timestamp (epoch ms) when the BU VM will hard-timeout */
|
|
304
|
-
timeoutAt?: number
|
|
305
|
-
/** Whether proxy is enabled — when true, images/video/fonts are blocked to save bandwidth.
|
|
306
|
-
* Set to false via --disable-proxy-bandwidth-acceleration to allow all resources. */
|
|
307
|
-
blockProxyResources?: boolean
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
export interface ExecutorOptions {
|
|
311
|
-
cdpConfig: CdpConfig
|
|
312
|
-
sessionMetadata?: SessionMetadata
|
|
313
|
-
logger?: ExecutorLogger
|
|
314
|
-
/** Working directory for scoped fs access */
|
|
315
|
-
cwd?: string
|
|
316
|
-
/** Set when this executor is connected to a cloud Browser Use VM */
|
|
317
|
-
cloudSession?: CloudSessionInfo
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
function isRegExp(value: any): value is RegExp {
|
|
321
|
-
return (
|
|
322
|
-
typeof value === 'object' && value !== null && typeof value.test === 'function' && typeof value.exec === 'function'
|
|
323
|
-
)
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function isPromise(value: any): value is Promise<unknown> {
|
|
327
|
-
return typeof value === 'object' && value !== null && typeof value.then === 'function'
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Duck-type check for a Playwright ChannelOwner (Response, Page, Browser,
|
|
332
|
-
* Request, Frame, BrowserContext, etc.). Used to skip auto-printing these
|
|
333
|
-
* objects from the REPL — they're meant for programmatic use, and dumping
|
|
334
|
-
* them risks leaking internal fields. Users can still `console.log(obj)` to
|
|
335
|
-
* inspect them via the safe handler in playwright-core. See issue #82.
|
|
336
|
-
*/
|
|
337
|
-
export function isPlaywrightChannelOwner(value: any): boolean {
|
|
338
|
-
return (
|
|
339
|
-
value !== null &&
|
|
340
|
-
typeof value === 'object' &&
|
|
341
|
-
typeof value._type === 'string' &&
|
|
342
|
-
typeof value._guid === 'string' &&
|
|
343
|
-
value._connection !== undefined
|
|
344
|
-
)
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
export class PlaywrightExecutor {
|
|
348
|
-
private isConnected = false
|
|
349
|
-
private page: Page | null = null
|
|
350
|
-
private browser: Browser | null = null
|
|
351
|
-
private context: BrowserContext | null = null
|
|
352
|
-
|
|
353
|
-
private userState: Record<string, any> = {}
|
|
354
|
-
private browserLogs: Map<Page, string[]> = new Map()
|
|
355
|
-
// Tracks the index up to which getLatestLogs({ sinceLastCall: true }) has
|
|
356
|
-
// returned logs. 0 means "return everything" (first call gets full buffer).
|
|
357
|
-
// When addBrowserLog shifts old entries (cap at MAX_LOGS_PER_PAGE), cursors
|
|
358
|
-
// are decremented so they stay in sync with the array.
|
|
359
|
-
private pageLogCursor: Map<Page, number> = new Map()
|
|
360
|
-
private lastSnapshots: WeakMap<Page, Map<string, string>> = new WeakMap()
|
|
361
|
-
private lastRefToLocator: WeakMap<Page, Map<string, string>> = new WeakMap()
|
|
362
|
-
private warningEvents: WarningEvent[] = []
|
|
363
|
-
private nextWarningEventId = 0
|
|
364
|
-
private lastDeliveredWarningEventId = 0
|
|
365
|
-
|
|
366
|
-
private activeWarningScopes = new Set<WarningScope>()
|
|
367
|
-
private pagesWithListeners = new WeakSet<Page>()
|
|
368
|
-
private suppressPageCloseWarnings = false
|
|
369
|
-
|
|
370
|
-
private scopedFs: ScopedFS
|
|
371
|
-
private sandboxedRequire: NodeRequire
|
|
372
|
-
|
|
373
|
-
private cdpConfig: CdpConfig
|
|
374
|
-
private logger: ExecutorLogger
|
|
375
|
-
private sessionMetadata: SessionMetadata
|
|
376
|
-
private sessionCwd: string | null
|
|
377
|
-
private hasWarnedExtensionOutdated = false
|
|
378
|
-
|
|
379
|
-
private ghostCursorController: GhostCursorController
|
|
380
|
-
/** Non-null when this executor is backed by a cloud Browser Use VM */
|
|
381
|
-
private cloudSession: CloudSessionInfo | null
|
|
382
|
-
/** Last minute bucket for which a cloud timeout warning was enqueued (dedup) */
|
|
383
|
-
private lastCloudTimeoutWarningMinute: number | null = null
|
|
384
|
-
|
|
385
|
-
constructor(options: ExecutorOptions) {
|
|
386
|
-
this.cdpConfig = options.cdpConfig
|
|
387
|
-
this.logger = options.logger || { log: console.log, error: console.error }
|
|
388
|
-
this.sessionMetadata = options.sessionMetadata || { extensionId: null, browser: null, profile: null }
|
|
389
|
-
this.sessionCwd = options.cwd ? path.resolve(options.cwd) : null
|
|
390
|
-
this.cloudSession = options.cloudSession || null
|
|
391
|
-
// ScopedFS expects an array of allowed directories. If cwd is provided, use it; otherwise use defaults.
|
|
392
|
-
this.scopedFs = new ScopedFS(
|
|
393
|
-
this.sessionCwd ? [this.sessionCwd, '/tmp', os.tmpdir()] : undefined,
|
|
394
|
-
this.sessionCwd || undefined,
|
|
395
|
-
)
|
|
396
|
-
this.sandboxedRequire = this.createSandboxedRequire(require)
|
|
397
|
-
this.ghostCursorController = new GhostCursorController({
|
|
398
|
-
logger: {
|
|
399
|
-
error: (...args: unknown[]) => {
|
|
400
|
-
this.logger.error(...args)
|
|
401
|
-
},
|
|
402
|
-
},
|
|
403
|
-
})
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
private createSandboxedRequire(originalRequire: NodeRequire): NodeRequire {
|
|
407
|
-
const scopedFs = this.scopedFs
|
|
408
|
-
const sandboxedRequire = ((id: string) => {
|
|
409
|
-
if (!ALLOWED_MODULES.has(id)) {
|
|
410
|
-
const error = new Error(
|
|
411
|
-
`Module "${id}" is not allowed in the sandbox. ` +
|
|
412
|
-
`Only safe Node.js built-ins are permitted: ${[...ALLOWED_MODULES].filter((m) => !m.startsWith('node:')).join(', ')}`,
|
|
413
|
-
)
|
|
414
|
-
error.name = 'ModuleNotAllowedError'
|
|
415
|
-
throw error
|
|
416
|
-
}
|
|
417
|
-
if (id === 'fs' || id === 'node:fs') {
|
|
418
|
-
return scopedFs
|
|
419
|
-
}
|
|
420
|
-
return originalRequire(id)
|
|
421
|
-
}) as NodeRequire
|
|
422
|
-
|
|
423
|
-
sandboxedRequire.resolve = originalRequire.resolve
|
|
424
|
-
sandboxedRequire.cache = originalRequire.cache
|
|
425
|
-
sandboxedRequire.extensions = originalRequire.extensions
|
|
426
|
-
sandboxedRequire.main = originalRequire.main
|
|
427
|
-
|
|
428
|
-
return sandboxedRequire
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
private async setDeviceScaleFactorForMacOS(context: BrowserContext): Promise<void> {
|
|
432
|
-
if (os.platform() !== 'darwin') {
|
|
433
|
-
return
|
|
434
|
-
}
|
|
435
|
-
const options = (context as any)._options
|
|
436
|
-
if (!options || options.deviceScaleFactor === 2) {
|
|
437
|
-
return
|
|
438
|
-
}
|
|
439
|
-
options.deviceScaleFactor = 2
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/** Block images, video, and font resources via Network.setBlockedURLs to save
|
|
443
|
-
* residential proxy bandwidth. Single CDP command, zero per-request overhead.
|
|
444
|
-
* Applied per-context on every page (existing and future). */
|
|
445
|
-
private async applyProxyResourceBlocking(context: BrowserContext): Promise<void> {
|
|
446
|
-
// URL patterns using the URLPattern spec syntax (absolute patterns).
|
|
447
|
-
// Covers the vast majority of image/video/font resources by file extension.
|
|
448
|
-
const blockedPatterns = [
|
|
449
|
-
// Images (SVGs excluded — lightweight and often used for icons/UI)
|
|
450
|
-
'*.png', '*.jpg', '*.jpeg', '*.gif', '*.webp', '*.ico', '*.bmp', '*.avif',
|
|
451
|
-
]
|
|
452
|
-
|
|
453
|
-
const applyToPage = async (page: Page) => {
|
|
454
|
-
try {
|
|
455
|
-
const cdpSession = await page.context().newCDPSession(page)
|
|
456
|
-
await cdpSession.send('Network.enable')
|
|
457
|
-
await cdpSession.send('Network.setBlockedURLs', {
|
|
458
|
-
urls: blockedPatterns,
|
|
459
|
-
})
|
|
460
|
-
await cdpSession.detach()
|
|
461
|
-
} catch (err) {
|
|
462
|
-
// Best-effort: don't break the session if blocking fails
|
|
463
|
-
this.logger.error('Failed to apply proxy resource blocking:', err)
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// Apply to existing pages
|
|
468
|
-
const pages = context.pages().filter((p) => !p.isClosed())
|
|
469
|
-
await Promise.all(pages.map(applyToPage))
|
|
470
|
-
|
|
471
|
-
// Apply to future pages
|
|
472
|
-
context.on('page', (page) => {
|
|
473
|
-
applyToPage(page)
|
|
474
|
-
})
|
|
475
|
-
|
|
476
|
-
this.logger.log('Proxy bandwidth acceleration enabled: blocking raster images')
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
private clearUserState() {
|
|
480
|
-
Object.keys(this.userState).forEach((key) => delete this.userState[key])
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
private clearConnectionState() {
|
|
484
|
-
this.isConnected = false
|
|
485
|
-
this.browser = null
|
|
486
|
-
this.page = null
|
|
487
|
-
this.context = null
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
enqueueWarning(message: string) {
|
|
491
|
-
this.nextWarningEventId += 1
|
|
492
|
-
this.warningEvents.push({ id: this.nextWarningEventId, message })
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
/** Update the cloud session timeout from external tracking (relay timer). */
|
|
496
|
-
updateCloudTimeout(timeoutAt: number) {
|
|
497
|
-
if (this.cloudSession) {
|
|
498
|
-
this.cloudSession.timeoutAt = timeoutAt
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
private beginWarningScope(): WarningScope {
|
|
503
|
-
// Use lastDeliveredWarningEventId as cursor (not nextWarningEventId) so
|
|
504
|
-
// warnings enqueued by the relay interval between execute() calls are
|
|
505
|
-
// picked up by the next scope. Using nextWarningEventId would skip them.
|
|
506
|
-
const scope: WarningScope = {
|
|
507
|
-
cursor: this.lastDeliveredWarningEventId,
|
|
508
|
-
}
|
|
509
|
-
this.activeWarningScopes.add(scope)
|
|
510
|
-
return scope
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
private flushWarningsForScope(scope: WarningScope): string {
|
|
514
|
-
const relevantWarnings = this.warningEvents.filter((warning) => {
|
|
515
|
-
return warning.id > scope.cursor
|
|
516
|
-
})
|
|
517
|
-
const latestWarningId = relevantWarnings.at(-1)?.id
|
|
518
|
-
if (latestWarningId && latestWarningId > this.lastDeliveredWarningEventId) {
|
|
519
|
-
this.lastDeliveredWarningEventId = latestWarningId
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
this.activeWarningScopes.delete(scope)
|
|
523
|
-
this.pruneDeliveredWarnings()
|
|
524
|
-
|
|
525
|
-
if (relevantWarnings.length === 0) {
|
|
526
|
-
return ''
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
return `${relevantWarnings.map((warning) => `[WARNING] ${warning.message}`).join('\n')}\n`
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
private pruneDeliveredWarnings() {
|
|
533
|
-
const activeCursors = [...this.activeWarningScopes].map((scope) => {
|
|
534
|
-
return scope.cursor
|
|
535
|
-
})
|
|
536
|
-
const minActiveCursor = activeCursors.length > 0 ? Math.min(...activeCursors) : this.lastDeliveredWarningEventId
|
|
537
|
-
const pruneBeforeOrAt = Math.min(this.lastDeliveredWarningEventId, minActiveCursor)
|
|
538
|
-
this.warningEvents = this.warningEvents.filter((warning) => {
|
|
539
|
-
return warning.id > pruneBeforeOrAt
|
|
540
|
-
})
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
private warnIfExtensionOutdated(playwriterVersion: string | null) {
|
|
544
|
-
if (this.hasWarnedExtensionOutdated) {
|
|
545
|
-
return
|
|
546
|
-
}
|
|
547
|
-
const warning = getExtensionOutdatedWarning(playwriterVersion)
|
|
548
|
-
if (warning) {
|
|
549
|
-
this.logger.log(warning)
|
|
550
|
-
// Enqueue so MCP agents see version-skew messages in their next execute
|
|
551
|
-
// response — logger.log alone only reaches stdout, not the LLM.
|
|
552
|
-
this.enqueueWarning(warning)
|
|
553
|
-
this.hasWarnedExtensionOutdated = true
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
private setupPageListeners(page: Page) {
|
|
558
|
-
if (this.pagesWithListeners.has(page)) {
|
|
559
|
-
return
|
|
560
|
-
}
|
|
561
|
-
this.pagesWithListeners.add(page)
|
|
562
|
-
this.setupPageCloseDetection(page)
|
|
563
|
-
this.setupPageConsoleListener(page)
|
|
564
|
-
this.setupNewPageLogging(page)
|
|
565
|
-
this.ghostCursorController.attachToPage({ page })
|
|
566
|
-
page.on('close', () => {
|
|
567
|
-
this.ghostCursorController.detachFromPage({ page })
|
|
568
|
-
})
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
private setupPageCloseDetection(page: Page) {
|
|
572
|
-
page.on('close', () => {
|
|
573
|
-
const stateKeysForClosedPage = Object.entries(this.userState)
|
|
574
|
-
.filter(([, value]) => {
|
|
575
|
-
return value === page
|
|
576
|
-
})
|
|
577
|
-
.map(([key]) => key)
|
|
578
|
-
|
|
579
|
-
const wasCurrentPage = this.page === page
|
|
580
|
-
let replacementPageInfo: { index: string; url: string } | null = null
|
|
581
|
-
|
|
582
|
-
if (wasCurrentPage) {
|
|
583
|
-
this.page = null
|
|
584
|
-
const context = this.context || page.context()
|
|
585
|
-
const openPages = context.pages().filter((candidate) => {
|
|
586
|
-
return !candidate.isClosed()
|
|
587
|
-
})
|
|
588
|
-
if (openPages.length > 0) {
|
|
589
|
-
const replacementPage = openPages[0]
|
|
590
|
-
this.page = replacementPage
|
|
591
|
-
const replacementIndex = context.pages().indexOf(replacementPage)
|
|
592
|
-
replacementPageInfo = {
|
|
593
|
-
index: replacementIndex >= 0 ? String(replacementIndex) : 'unknown',
|
|
594
|
-
url: replacementPage.url() || 'unknown',
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
if (!this.isConnected || this.suppressPageCloseWarnings || stateKeysForClosedPage.length === 0) {
|
|
600
|
-
return
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
const stateKeyLabel = stateKeysForClosedPage.map((key) => `state.${key}`).join(', ')
|
|
604
|
-
const closedUrl = page.url() || 'unknown'
|
|
605
|
-
|
|
606
|
-
if (!wasCurrentPage) {
|
|
607
|
-
this.enqueueWarning(
|
|
608
|
-
`Page closed (url: ${closedUrl}) for ${stateKeyLabel}. ` +
|
|
609
|
-
`Assign a new open page to ${stateKeyLabel} before reusing it.`,
|
|
610
|
-
)
|
|
611
|
-
return
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
if (replacementPageInfo) {
|
|
615
|
-
this.enqueueWarning(
|
|
616
|
-
`The current page in ${stateKeyLabel} was closed (url: ${closedUrl}). ` +
|
|
617
|
-
`Switched active page to index ${replacementPageInfo.index} (url: ${replacementPageInfo.url}). ` +
|
|
618
|
-
`Reassign ${stateKeyLabel} before using it again.`,
|
|
619
|
-
)
|
|
620
|
-
return
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
this.enqueueWarning(
|
|
624
|
-
`The current page in ${stateKeyLabel} was closed (url: ${closedUrl}). ` +
|
|
625
|
-
`No open pages remain. Open a tab with Tabwright enabled, then reassign ${stateKeyLabel}.`,
|
|
626
|
-
)
|
|
627
|
-
})
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
private setupNewPageLogging(page: Page) {
|
|
631
|
-
// page.on('popup') fires for window.open, target=_blank, and cmd+click
|
|
632
|
-
// (but not context.newPage() or CDP reconnection). The extension
|
|
633
|
-
// auto-relocates popups to tabs, so these pages are controllable via
|
|
634
|
-
// context.pages(). Enqueue synchronously so the warning lands in the
|
|
635
|
-
// enclosing execute() call's scope. initialUrl may be 'about:blank'
|
|
636
|
-
// for blank-then-scripted popups.
|
|
637
|
-
page.on('popup', (popup) => {
|
|
638
|
-
const pages = popup.context().pages()
|
|
639
|
-
const rawIndex = pages.indexOf(popup)
|
|
640
|
-
const pageIndex = rawIndex >= 0 ? String(rawIndex) : 'unknown'
|
|
641
|
-
const initialUrl = popup.url() || 'about:blank'
|
|
642
|
-
this.enqueueWarning(
|
|
643
|
-
`New page opened from current page (index ${pageIndex}, initial url: ${initialUrl}). ` +
|
|
644
|
-
`Access it via context.pages()[${pageIndex}] to interact with it.`,
|
|
645
|
-
)
|
|
646
|
-
})
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
private setupPageConsoleListener(page: Page) {
|
|
650
|
-
if (!this.browserLogs.has(page)) {
|
|
651
|
-
this.browserLogs.set(page, [])
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
// Logs are NOT cleared on navigation so that getLatestLogs({ sinceLastCall: true })
|
|
655
|
-
// can return errors from the previous page load. The MAX_LOGS_PER_PAGE cap (5000)
|
|
656
|
-
// prevents unbounded growth; old entries are shifted out in addBrowserLog.
|
|
657
|
-
|
|
658
|
-
page.on('close', () => {
|
|
659
|
-
this.browserLogs.delete(page)
|
|
660
|
-
this.pageLogCursor.delete(page)
|
|
661
|
-
})
|
|
662
|
-
|
|
663
|
-
page.on('console', (msg) => {
|
|
664
|
-
try {
|
|
665
|
-
const logEntry = `[${msg.type()}] ${msg.text()}`
|
|
666
|
-
this.addBrowserLog({ page, logEntry })
|
|
667
|
-
} catch (e) {
|
|
668
|
-
this.logger.error('[Executor] Failed to get console message text:', e)
|
|
669
|
-
}
|
|
670
|
-
})
|
|
671
|
-
|
|
672
|
-
page.on('pageerror', (error) => {
|
|
673
|
-
this.addBrowserLog({ page, logEntry: `[pageerror] ${error.message}` })
|
|
674
|
-
})
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
private addBrowserLog(options: { page: Page; logEntry: string }) {
|
|
678
|
-
if (!this.browserLogs.has(options.page)) {
|
|
679
|
-
this.browserLogs.set(options.page, [])
|
|
680
|
-
}
|
|
681
|
-
const pageLogs = this.browserLogs.get(options.page)!
|
|
682
|
-
pageLogs.push(options.logEntry)
|
|
683
|
-
if (pageLogs.length > MAX_LOGS_PER_PAGE) {
|
|
684
|
-
pageLogs.shift()
|
|
685
|
-
// Decrement cursor so it stays in sync with the shifted array.
|
|
686
|
-
// Clamp to 0 so the cursor never goes negative.
|
|
687
|
-
const cursor = this.pageLogCursor.get(options.page)
|
|
688
|
-
if (cursor !== undefined && cursor > 0) {
|
|
689
|
-
this.pageLogCursor.set(options.page, cursor - 1)
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
private pagesRelatedToPage(page: Page): Page[] {
|
|
695
|
-
const frameUrls = new Set(
|
|
696
|
-
page
|
|
697
|
-
.frames()
|
|
698
|
-
.map((frame) => {
|
|
699
|
-
return frame.url()
|
|
700
|
-
})
|
|
701
|
-
.filter((url) => {
|
|
702
|
-
return url && url !== 'about:blank'
|
|
703
|
-
}),
|
|
704
|
-
)
|
|
705
|
-
|
|
706
|
-
return page
|
|
707
|
-
.context()
|
|
708
|
-
.pages()
|
|
709
|
-
.filter((candidate) => {
|
|
710
|
-
return candidate === page || frameUrls.has(candidate.url())
|
|
711
|
-
})
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
private async checkExtensionStatus(): Promise<{
|
|
715
|
-
connected: boolean
|
|
716
|
-
activeTargets: number
|
|
717
|
-
playwriterVersion: string | null
|
|
718
|
-
}> {
|
|
719
|
-
const { host = '127.0.0.1', port = 19988, extensionId, token } = this.cdpConfig
|
|
720
|
-
const { httpBaseUrl } = parseRelayHost(host, port)
|
|
721
|
-
const notConnected = { connected: false, activeTargets: 0, playwriterVersion: null }
|
|
722
|
-
const headers: Record<string, string> = {}
|
|
723
|
-
const effectiveToken = token || process.env.TABWRIGHT_TOKEN || process.env.PLAYWRITER_TOKEN
|
|
724
|
-
if (effectiveToken) {
|
|
725
|
-
headers['Authorization'] = `Bearer ${effectiveToken}`
|
|
726
|
-
}
|
|
727
|
-
try {
|
|
728
|
-
if (extensionId) {
|
|
729
|
-
const response = await fetch(`${httpBaseUrl}/extensions/status`, {
|
|
730
|
-
signal: AbortSignal.timeout(2000),
|
|
731
|
-
headers,
|
|
732
|
-
})
|
|
733
|
-
if (!response.ok) {
|
|
734
|
-
const fallback = await fetch(`${httpBaseUrl}/extension/status`, {
|
|
735
|
-
signal: AbortSignal.timeout(2000),
|
|
736
|
-
headers,
|
|
737
|
-
})
|
|
738
|
-
if (!fallback.ok) {
|
|
739
|
-
return notConnected
|
|
740
|
-
}
|
|
741
|
-
return (await fallback.json()) as {
|
|
742
|
-
connected: boolean
|
|
743
|
-
activeTargets: number
|
|
744
|
-
playwriterVersion: string | null
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
const data = (await response.json()) as {
|
|
748
|
-
extensions: Array<{
|
|
749
|
-
extensionId: string
|
|
750
|
-
stableKey?: string
|
|
751
|
-
activeTargets: number
|
|
752
|
-
playwriterVersion?: string | null
|
|
753
|
-
}>
|
|
754
|
-
}
|
|
755
|
-
const extension = data.extensions.find((item) => {
|
|
756
|
-
return item.extensionId === extensionId || item.stableKey === extensionId
|
|
757
|
-
})
|
|
758
|
-
if (!extension) {
|
|
759
|
-
return notConnected
|
|
760
|
-
}
|
|
761
|
-
return {
|
|
762
|
-
connected: true,
|
|
763
|
-
activeTargets: extension.activeTargets,
|
|
764
|
-
playwriterVersion: extension?.playwriterVersion || null,
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
const response = await fetch(`${httpBaseUrl}/extension/status`, {
|
|
769
|
-
signal: AbortSignal.timeout(2000),
|
|
770
|
-
headers,
|
|
771
|
-
})
|
|
772
|
-
if (!response.ok) {
|
|
773
|
-
return notConnected
|
|
774
|
-
}
|
|
775
|
-
return (await response.json()) as { connected: boolean; activeTargets: number; playwriterVersion: string | null }
|
|
776
|
-
} catch {
|
|
777
|
-
return notConnected
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
private isDirectCdpMode(): boolean {
|
|
782
|
-
return !!this.cdpConfig.directCdpUrl
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
private isHeadlessMode(): boolean {
|
|
786
|
-
return !!this.cdpConfig.headless
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
/**
|
|
790
|
-
* Connect to Chrome and set up context/page. Shared by ensureConnection and reset.
|
|
791
|
-
* In headless mode, launches Chrome via chromium.launch().
|
|
792
|
-
* In direct CDP mode, connects straight to Chrome's WebSocket.
|
|
793
|
-
* In extension mode, checks extension status then connects via relay.
|
|
794
|
-
*/
|
|
795
|
-
private async connectToBrowser(): Promise<{ browser: Browser; page: Page; context: BrowserContext }> {
|
|
796
|
-
// Headless mode: launch Chrome directly via Playwright (no extension, no relay CDP routing)
|
|
797
|
-
if (this.isHeadlessMode()) {
|
|
798
|
-
return this.connectHeadlessBrowser()
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
if (this.isDirectCdpMode()) {
|
|
802
|
-
// Direct CDP: connect straight to Chrome, no relay or extension needed
|
|
803
|
-
const chromium = await getChromium()
|
|
804
|
-
const browser = await chromium.connectOverCDP(this.cdpConfig.directCdpUrl!)
|
|
805
|
-
|
|
806
|
-
browser.on('disconnected', () => {
|
|
807
|
-
this.logger.log('Browser disconnected, clearing connection state')
|
|
808
|
-
this.clearConnectionState()
|
|
809
|
-
})
|
|
810
|
-
|
|
811
|
-
const contexts = browser.contexts()
|
|
812
|
-
const context = contexts.length > 0 ? contexts[0] : await browser.newContext()
|
|
813
|
-
|
|
814
|
-
context.setDefaultTimeout(60000)
|
|
815
|
-
context.setDefaultNavigationTimeout(10000)
|
|
816
|
-
|
|
817
|
-
context.on('page', (page) => {
|
|
818
|
-
this.setupPageListeners(page)
|
|
819
|
-
})
|
|
820
|
-
|
|
821
|
-
context.pages().forEach((p) => this.setupPageListeners(p))
|
|
822
|
-
|
|
823
|
-
// In direct CDP mode, pages are always available (all tabs visible).
|
|
824
|
-
// Use the first non-closed page, or create one.
|
|
825
|
-
const pages = context.pages().filter((p) => !p.isClosed())
|
|
826
|
-
const page = pages.length > 0 ? pages[0] : await context.newPage()
|
|
827
|
-
this.setupPageListeners(page)
|
|
828
|
-
|
|
829
|
-
await this.setDeviceScaleFactorForMacOS(context)
|
|
830
|
-
|
|
831
|
-
// Block images, video, and fonts for cloud sessions with proxy enabled
|
|
832
|
-
// to reduce residential proxy bandwidth costs. Uses Network.setBlockedURLs
|
|
833
|
-
// which is a single fire-and-forget CDP command with zero per-request overhead.
|
|
834
|
-
if (this.cloudSession?.blockProxyResources) {
|
|
835
|
-
await this.applyProxyResourceBlocking(context)
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
return { browser, page, context }
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
// Extension mode: check status first for better error messages
|
|
842
|
-
const extensionStatus = await this.checkExtensionStatus()
|
|
843
|
-
if (!extensionStatus.connected) {
|
|
844
|
-
throw new Error(EXTENSION_NOT_CONNECTED_ERROR)
|
|
845
|
-
}
|
|
846
|
-
this.warnIfExtensionOutdated(extensionStatus.playwriterVersion)
|
|
847
|
-
|
|
848
|
-
const cdpUrl = getCdpUrl(this.cdpConfig)
|
|
849
|
-
const chromium = await getChromium()
|
|
850
|
-
const browser = await chromium.connectOverCDP(cdpUrl)
|
|
851
|
-
|
|
852
|
-
browser.on('disconnected', () => {
|
|
853
|
-
this.logger.log('Browser disconnected, clearing connection state')
|
|
854
|
-
this.clearConnectionState()
|
|
855
|
-
})
|
|
856
|
-
|
|
857
|
-
const contexts = browser.contexts()
|
|
858
|
-
const context = contexts.length > 0 ? contexts[0] : await browser.newContext()
|
|
859
|
-
|
|
860
|
-
// Action timeout (click, fill, hover, etc.) is longer to tolerate slower
|
|
861
|
-
// SPA/Turbo navigations and post-click settling on real sites.
|
|
862
|
-
// Navigation timeout (goto, reload) remains separate.
|
|
863
|
-
context.setDefaultTimeout(60000)
|
|
864
|
-
context.setDefaultNavigationTimeout(10000)
|
|
865
|
-
|
|
866
|
-
context.on('page', (page) => {
|
|
867
|
-
this.setupPageListeners(page)
|
|
868
|
-
})
|
|
869
|
-
|
|
870
|
-
context.pages().forEach((p) => this.setupPageListeners(p))
|
|
871
|
-
const page = await this.ensurePageForContext({ context, timeout: 10000 })
|
|
872
|
-
|
|
873
|
-
await this.setDeviceScaleFactorForMacOS(context)
|
|
874
|
-
|
|
875
|
-
return { browser, page, context }
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
/**
|
|
879
|
-
* Launch a headless Chrome via chromium.launch(). No extension, no relay CDP routing.
|
|
880
|
-
* Reuses an existing shared browser if one was already launched for headless mode.
|
|
881
|
-
* Does NOT add per-session disconnect listeners to avoid accumulation on the shared
|
|
882
|
-
* browser; instead, ensureConnection checks browser.isConnected() on each call.
|
|
883
|
-
*/
|
|
884
|
-
private async connectHeadlessBrowser(): Promise<{ browser: Browser; page: Page; context: BrowserContext }> {
|
|
885
|
-
const browser = await PlaywrightExecutor.getOrLaunchHeadlessBrowser()
|
|
886
|
-
|
|
887
|
-
const context = await browser.newContext()
|
|
888
|
-
context.setDefaultTimeout(60000)
|
|
889
|
-
context.setDefaultNavigationTimeout(10000)
|
|
890
|
-
|
|
891
|
-
context.on('page', (page) => {
|
|
892
|
-
this.setupPageListeners(page)
|
|
893
|
-
})
|
|
894
|
-
|
|
895
|
-
const page = await context.newPage()
|
|
896
|
-
this.setupPageListeners(page)
|
|
897
|
-
|
|
898
|
-
await this.setDeviceScaleFactorForMacOS(context)
|
|
899
|
-
|
|
900
|
-
return { browser, page, context }
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
/** Shared headless browser instance across all headless sessions.
|
|
904
|
-
* Uses a launch promise to prevent concurrent first-session races from
|
|
905
|
-
* spawning multiple browsers. The disconnect handler is registered once
|
|
906
|
-
* at launch time and clears both statics so the next session relaunches. */
|
|
907
|
-
private static _sharedHeadlessBrowser: Browser | null = null
|
|
908
|
-
private static _sharedHeadlessBrowserPromise: Promise<Browser> | null = null
|
|
909
|
-
|
|
910
|
-
private static async getOrLaunchHeadlessBrowser(): Promise<Browser> {
|
|
911
|
-
// Check the cached browser is actually alive (not just non-null after a crash)
|
|
912
|
-
if (PlaywrightExecutor._sharedHeadlessBrowser?.isConnected()) {
|
|
913
|
-
return PlaywrightExecutor._sharedHeadlessBrowser
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
// Deduplicate concurrent launches: second caller awaits the first's promise
|
|
917
|
-
if (PlaywrightExecutor._sharedHeadlessBrowserPromise) {
|
|
918
|
-
return PlaywrightExecutor._sharedHeadlessBrowserPromise
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
const launchPromise = (async () => {
|
|
922
|
-
const chromium = await getChromium()
|
|
923
|
-
const { resolveBrowserExecutablePath } = await import('./browser-config.js')
|
|
924
|
-
const executablePath = resolveBrowserExecutablePath()
|
|
925
|
-
|
|
926
|
-
const browser = await chromium.launch({
|
|
927
|
-
headless: true,
|
|
928
|
-
executablePath,
|
|
929
|
-
})
|
|
930
|
-
|
|
931
|
-
// Single handler registered once per browser lifetime.
|
|
932
|
-
// Clears both statics so the next headless session relaunches.
|
|
933
|
-
browser.on('disconnected', () => {
|
|
934
|
-
PlaywrightExecutor._sharedHeadlessBrowser = null
|
|
935
|
-
PlaywrightExecutor._sharedHeadlessBrowserPromise = null
|
|
936
|
-
})
|
|
937
|
-
|
|
938
|
-
PlaywrightExecutor._sharedHeadlessBrowser = browser
|
|
939
|
-
// Clear the promise now that the browser is cached; future callers
|
|
940
|
-
// use _sharedHeadlessBrowser directly. Concurrent waiters already
|
|
941
|
-
// hold a reference to launchPromise so they still resolve correctly.
|
|
942
|
-
PlaywrightExecutor._sharedHeadlessBrowserPromise = null
|
|
943
|
-
return browser
|
|
944
|
-
})()
|
|
945
|
-
|
|
946
|
-
PlaywrightExecutor._sharedHeadlessBrowserPromise = launchPromise
|
|
947
|
-
try {
|
|
948
|
-
return await launchPromise
|
|
949
|
-
} catch (error) {
|
|
950
|
-
PlaywrightExecutor._sharedHeadlessBrowserPromise = null
|
|
951
|
-
throw error
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
/** Close the headless context for this session (called on session delete). */
|
|
956
|
-
async closeHeadlessContext(): Promise<void> {
|
|
957
|
-
if (!this.isHeadlessMode() || !this.context) {
|
|
958
|
-
return
|
|
959
|
-
}
|
|
960
|
-
await this.context.close().catch((e) => {
|
|
961
|
-
this.logger.error('Error closing headless context:', e)
|
|
962
|
-
})
|
|
963
|
-
this.clearConnectionState()
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
/** Close the shared headless browser (called on relay shutdown). */
|
|
967
|
-
static async closeSharedHeadlessBrowser(): Promise<void> {
|
|
968
|
-
if (PlaywrightExecutor._sharedHeadlessBrowser) {
|
|
969
|
-
await PlaywrightExecutor._sharedHeadlessBrowser.close().catch(() => {})
|
|
970
|
-
PlaywrightExecutor._sharedHeadlessBrowser = null
|
|
971
|
-
PlaywrightExecutor._sharedHeadlessBrowserPromise = null
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
private async ensureConnection(): Promise<{ browser: Browser; page: Page }> {
|
|
976
|
-
// In headless mode, also check the shared browser is still alive.
|
|
977
|
-
// After a crash, isConnected() returns false and we need to reconnect.
|
|
978
|
-
const browserAlive = this.isHeadlessMode() ? this.browser?.isConnected() : true
|
|
979
|
-
if (this.isConnected && this.browser && this.page && browserAlive) {
|
|
980
|
-
return { browser: this.browser, page: this.page }
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
try {
|
|
984
|
-
const { browser, page, context } = await this.connectToBrowser()
|
|
985
|
-
|
|
986
|
-
this.browser = browser
|
|
987
|
-
this.page = page
|
|
988
|
-
this.context = context
|
|
989
|
-
this.isConnected = true
|
|
990
|
-
|
|
991
|
-
return { browser, page }
|
|
992
|
-
} catch (error) {
|
|
993
|
-
// Cloud sessions that fail to connect are likely expired VMs.
|
|
994
|
-
// Give a clear error instead of a cryptic WebSocket/connection error.
|
|
995
|
-
if (this.cloudSession && error instanceof Error && isDisconnectionError(error)) {
|
|
996
|
-
throw new Error(CLOUD_SESSION_EXPIRED_ERROR, { cause: error })
|
|
997
|
-
}
|
|
998
|
-
throw error
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
private async getCurrentPage(timeout = 10000): Promise<Page> {
|
|
1003
|
-
if (this.page && !this.page.isClosed()) {
|
|
1004
|
-
return this.page
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
if (this.browser) {
|
|
1008
|
-
const contexts = this.browser.contexts()
|
|
1009
|
-
if (contexts.length > 0) {
|
|
1010
|
-
const context = contexts[0]
|
|
1011
|
-
this.context = context
|
|
1012
|
-
const pages = context.pages().filter((p) => !p.isClosed())
|
|
1013
|
-
if (pages.length > 0) {
|
|
1014
|
-
const page = pages[0]
|
|
1015
|
-
await page.waitForLoadState('domcontentloaded', { timeout }).catch(() => {})
|
|
1016
|
-
this.page = page
|
|
1017
|
-
return page
|
|
1018
|
-
}
|
|
1019
|
-
const page = await this.ensurePageForContext({ context, timeout })
|
|
1020
|
-
this.page = page
|
|
1021
|
-
return page
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
throw new Error(NO_PAGES_AVAILABLE_ERROR)
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
async reset(): Promise<{ page: Page; context: BrowserContext }> {
|
|
1029
|
-
this.suppressPageCloseWarnings = true
|
|
1030
|
-
try {
|
|
1031
|
-
if (this.isHeadlessMode()) {
|
|
1032
|
-
// In headless mode, only close this session's context, not the shared browser.
|
|
1033
|
-
// Other headless sessions share the same browser instance.
|
|
1034
|
-
if (this.context) {
|
|
1035
|
-
await this.context.close().catch((e) => {
|
|
1036
|
-
this.logger.error('Error closing context:', e)
|
|
1037
|
-
})
|
|
1038
|
-
}
|
|
1039
|
-
} else if (this.browser) {
|
|
1040
|
-
await this.browser.close()
|
|
1041
|
-
}
|
|
1042
|
-
} catch (e) {
|
|
1043
|
-
this.logger.error('Error closing browser:', e)
|
|
1044
|
-
} finally {
|
|
1045
|
-
this.suppressPageCloseWarnings = false
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
this.clearConnectionState()
|
|
1049
|
-
this.clearUserState()
|
|
1050
|
-
|
|
1051
|
-
const { browser, page, context } = await this.connectToBrowser()
|
|
1052
|
-
|
|
1053
|
-
this.browser = browser
|
|
1054
|
-
this.page = page
|
|
1055
|
-
this.context = context
|
|
1056
|
-
this.isConnected = true
|
|
1057
|
-
|
|
1058
|
-
return { page, context }
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
async execute(code: string, timeout = 10000, options: ExecuteOptions = {}): Promise<ExecuteResult> {
|
|
1062
|
-
const consoleLogs: Array<{ method: string; args: any[] }> = []
|
|
1063
|
-
const warningScope = this.beginWarningScope()
|
|
1064
|
-
|
|
1065
|
-
const formatConsoleLogs = (logs: Array<{ method: string; args: any[] }>, prefix = 'Console output') => {
|
|
1066
|
-
if (logs.length === 0) {
|
|
1067
|
-
return ''
|
|
1068
|
-
}
|
|
1069
|
-
let text = `${prefix}:\n`
|
|
1070
|
-
logs.forEach(({ method, args }) => {
|
|
1071
|
-
const formattedArgs = args
|
|
1072
|
-
.map((arg) => {
|
|
1073
|
-
if (typeof arg === 'string') return arg
|
|
1074
|
-
return util.inspect(arg, {
|
|
1075
|
-
depth: 4,
|
|
1076
|
-
colors: false,
|
|
1077
|
-
maxArrayLength: 100,
|
|
1078
|
-
maxStringLength: 1000,
|
|
1079
|
-
breakLength: 80,
|
|
1080
|
-
})
|
|
1081
|
-
})
|
|
1082
|
-
.join(' ')
|
|
1083
|
-
text += `[${method}] ${formattedArgs}\n`
|
|
1084
|
-
})
|
|
1085
|
-
return text + '\n'
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
try {
|
|
1089
|
-
// Warn if cloud VM is approaching its hard timeout (deduped by minute bucket)
|
|
1090
|
-
if (this.cloudSession?.timeoutAt) {
|
|
1091
|
-
const remainingMs = this.cloudSession.timeoutAt - Date.now()
|
|
1092
|
-
if (remainingMs <= 0) {
|
|
1093
|
-
throw new Error(CLOUD_SESSION_EXPIRED_ERROR)
|
|
1094
|
-
}
|
|
1095
|
-
if (remainingMs < 5 * 60_000) {
|
|
1096
|
-
const mins = Math.ceil(remainingMs / 60_000)
|
|
1097
|
-
if (this.lastCloudTimeoutWarningMinute !== mins) {
|
|
1098
|
-
this.lastCloudTimeoutWarningMinute = mins
|
|
1099
|
-
this.enqueueWarning(
|
|
1100
|
-
`Cloud browser expires in ~${mins} minute${mins === 1 ? '' : 's'}. ` +
|
|
1101
|
-
`Create a new session soon with: tabwright session new --browser cloud`,
|
|
1102
|
-
)
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
await this.ensureConnection()
|
|
1108
|
-
const page = await this.getCurrentPage(timeout)
|
|
1109
|
-
const context = this.context || page.context()
|
|
1110
|
-
|
|
1111
|
-
this.logger.log('Executing code:', code)
|
|
1112
|
-
|
|
1113
|
-
const customConsole = {
|
|
1114
|
-
log: (...args: any[]) => {
|
|
1115
|
-
consoleLogs.push({ method: 'log', args })
|
|
1116
|
-
},
|
|
1117
|
-
info: (...args: any[]) => {
|
|
1118
|
-
consoleLogs.push({ method: 'info', args })
|
|
1119
|
-
},
|
|
1120
|
-
warn: (...args: any[]) => {
|
|
1121
|
-
consoleLogs.push({ method: 'warn', args })
|
|
1122
|
-
},
|
|
1123
|
-
error: (...args: any[]) => {
|
|
1124
|
-
consoleLogs.push({ method: 'error', args })
|
|
1125
|
-
},
|
|
1126
|
-
debug: (...args: any[]) => {
|
|
1127
|
-
consoleLogs.push({ method: 'debug', args })
|
|
1128
|
-
},
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
const snapshot = async (options: {
|
|
1132
|
-
page?: Page
|
|
1133
|
-
/** Optional frame to scope the snapshot (e.g. from iframe.contentFrame() or page.frames()) */
|
|
1134
|
-
frame?: Frame | FrameLocator
|
|
1135
|
-
/** Optional locator to scope the snapshot to a subtree */
|
|
1136
|
-
locator?: Locator
|
|
1137
|
-
search?: string | RegExp
|
|
1138
|
-
showDiffSinceLastCall?: boolean
|
|
1139
|
-
/** Snapshot format (currently raw only) */
|
|
1140
|
-
format?: SnapshotFormat
|
|
1141
|
-
/** Only include interactive elements (default: true) */
|
|
1142
|
-
interactiveOnly?: boolean
|
|
1143
|
-
}) => {
|
|
1144
|
-
const {
|
|
1145
|
-
page: targetPage,
|
|
1146
|
-
frame,
|
|
1147
|
-
locator,
|
|
1148
|
-
search,
|
|
1149
|
-
showDiffSinceLastCall = !search,
|
|
1150
|
-
interactiveOnly = false,
|
|
1151
|
-
} = options
|
|
1152
|
-
const resolvedPage = targetPage || page
|
|
1153
|
-
if (!resolvedPage) {
|
|
1154
|
-
throw new Error('snapshot requires a page')
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
// Use new in-page implementation via getAriaSnapshot
|
|
1158
|
-
const {
|
|
1159
|
-
snapshot: rawSnapshot,
|
|
1160
|
-
refs,
|
|
1161
|
-
getSelectorForRef,
|
|
1162
|
-
} = await getAriaSnapshot({
|
|
1163
|
-
page: resolvedPage,
|
|
1164
|
-
frame,
|
|
1165
|
-
locator,
|
|
1166
|
-
interactiveOnly,
|
|
1167
|
-
})
|
|
1168
|
-
const snapshotStr = rawSnapshot.toWellFormed?.() ?? rawSnapshot
|
|
1169
|
-
|
|
1170
|
-
const refToLocator = new Map<string, string>()
|
|
1171
|
-
for (const entry of refs) {
|
|
1172
|
-
const locatorStr = getSelectorForRef(entry.ref)
|
|
1173
|
-
if (locatorStr) {
|
|
1174
|
-
refToLocator.set(entry.shortRef, locatorStr)
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
this.lastRefToLocator.set(resolvedPage, refToLocator)
|
|
1178
|
-
|
|
1179
|
-
const shouldCacheSnapshot = !frame
|
|
1180
|
-
// Cache keyed by locator selector so full-page and locator-scoped snapshots
|
|
1181
|
-
// don't pollute each other's diff baselines
|
|
1182
|
-
const snapshotKey = locator ? `locator:${locator.selector()}` : 'page'
|
|
1183
|
-
let pageSnapshots = this.lastSnapshots.get(resolvedPage)
|
|
1184
|
-
if (!pageSnapshots) {
|
|
1185
|
-
pageSnapshots = new Map()
|
|
1186
|
-
this.lastSnapshots.set(resolvedPage, pageSnapshots)
|
|
1187
|
-
}
|
|
1188
|
-
const previousSnapshot = shouldCacheSnapshot ? pageSnapshots.get(snapshotKey) : undefined
|
|
1189
|
-
if (shouldCacheSnapshot) {
|
|
1190
|
-
pageSnapshots.set(snapshotKey, snapshotStr)
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
// Diff defaults off when search is provided, but agent can explicitly enable both
|
|
1194
|
-
if (showDiffSinceLastCall && previousSnapshot && shouldCacheSnapshot) {
|
|
1195
|
-
const diffResult = createSmartDiff({
|
|
1196
|
-
oldContent: previousSnapshot,
|
|
1197
|
-
newContent: snapshotStr,
|
|
1198
|
-
label: 'snapshot',
|
|
1199
|
-
})
|
|
1200
|
-
if (diffResult.type === 'no-change') {
|
|
1201
|
-
return 'No changes since last snapshot. Use showDiffSinceLastCall: false to see full content.'
|
|
1202
|
-
}
|
|
1203
|
-
return diffResult.content
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
if (!search) {
|
|
1207
|
-
return `${snapshotStr}\n\nuse refToLocator({ ref: 'e3' }) to get locators for ref strings.`
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
const lines = snapshotStr.split('\n')
|
|
1211
|
-
const matchIndices: number[] = []
|
|
1212
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1213
|
-
const line = lines[i]
|
|
1214
|
-
const isMatch = isRegExp(search) ? search.test(line) : line.includes(search)
|
|
1215
|
-
if (isMatch) {
|
|
1216
|
-
matchIndices.push(i)
|
|
1217
|
-
if (matchIndices.length >= 10) break
|
|
1218
|
-
}
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
if (matchIndices.length === 0) {
|
|
1222
|
-
return 'No matches found'
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
const CONTEXT_LINES = 5
|
|
1226
|
-
const includedLines = new Set<number>()
|
|
1227
|
-
for (const idx of matchIndices) {
|
|
1228
|
-
const start = Math.max(0, idx - CONTEXT_LINES)
|
|
1229
|
-
const end = Math.min(lines.length - 1, idx + CONTEXT_LINES)
|
|
1230
|
-
for (let i = start; i <= end; i++) {
|
|
1231
|
-
includedLines.add(i)
|
|
1232
|
-
}
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
const sortedIndices = [...includedLines].sort((a, b) => a - b)
|
|
1236
|
-
const result: string[] = []
|
|
1237
|
-
for (let i = 0; i < sortedIndices.length; i++) {
|
|
1238
|
-
const lineIdx = sortedIndices[i]
|
|
1239
|
-
if (i > 0 && sortedIndices[i - 1] !== lineIdx - 1) {
|
|
1240
|
-
result.push('---')
|
|
1241
|
-
}
|
|
1242
|
-
result.push(lines[lineIdx])
|
|
1243
|
-
}
|
|
1244
|
-
return result.join('\n')
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
const refToLocator = (options: { ref: string; page?: Page }): string | null => {
|
|
1248
|
-
const targetPage = options.page || page
|
|
1249
|
-
const map = this.lastRefToLocator.get(targetPage)
|
|
1250
|
-
if (!map) {
|
|
1251
|
-
return null
|
|
1252
|
-
}
|
|
1253
|
-
return map.get(options.ref) ?? null
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
const getLocatorStringForElement = async (element: any) => {
|
|
1257
|
-
if (!element || typeof element.evaluate !== 'function') {
|
|
1258
|
-
throw new Error('getLocatorStringForElement: argument must be a Playwright Locator or ElementHandle')
|
|
1259
|
-
}
|
|
1260
|
-
const elementPage = element.page ? element.page() : page
|
|
1261
|
-
const hasGenerator = await elementPage.evaluate(() => !!(globalThis as any).__selectorGenerator)
|
|
1262
|
-
if (!hasGenerator) {
|
|
1263
|
-
const scriptPath = path.join(__dirname, '..', 'dist', 'selector-generator.js')
|
|
1264
|
-
const scriptContent = fs.readFileSync(scriptPath, 'utf-8')
|
|
1265
|
-
const cdp = await getCDPSession({ page: elementPage })
|
|
1266
|
-
await cdp.send('Runtime.evaluate', { expression: scriptContent })
|
|
1267
|
-
}
|
|
1268
|
-
return await element.evaluate((el: any) => {
|
|
1269
|
-
const { createSelectorGenerator, toLocator } = (globalThis as any).__selectorGenerator
|
|
1270
|
-
const generator = createSelectorGenerator(globalThis)
|
|
1271
|
-
const result = generator(el)
|
|
1272
|
-
return toLocator(result.selector, 'javascript')
|
|
1273
|
-
})
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
const getLatestLogs = async (options?: {
|
|
1277
|
-
page?: Page
|
|
1278
|
-
count?: number
|
|
1279
|
-
search?: string | RegExp
|
|
1280
|
-
// When true, only return logs added since the last getLatestLogs call
|
|
1281
|
-
// with sinceLastCall: true. First call returns all buffered logs.
|
|
1282
|
-
// Cursors are tracked per page so navigations and new logs are
|
|
1283
|
-
// never missed. Useful for checking page errors after each action.
|
|
1284
|
-
sinceLastCall?: boolean
|
|
1285
|
-
}) => {
|
|
1286
|
-
const { page: filterPage, count, search, sinceLastCall = false } = options || {}
|
|
1287
|
-
let allLogs: string[] = []
|
|
1288
|
-
|
|
1289
|
-
// Collect logs, optionally slicing from cursor when sinceLastCall is set
|
|
1290
|
-
const collectLogs = (targetPage: Page): string[] => {
|
|
1291
|
-
const logs = this.browserLogs.get(targetPage) || []
|
|
1292
|
-
if (!sinceLastCall) {
|
|
1293
|
-
return logs
|
|
1294
|
-
}
|
|
1295
|
-
const cursor = this.pageLogCursor.get(targetPage) || 0
|
|
1296
|
-
return logs.slice(cursor)
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
if (filterPage) {
|
|
1300
|
-
const relatedPages = this.pagesRelatedToPage(filterPage)
|
|
1301
|
-
allLogs = relatedPages.flatMap((relatedPage) => {
|
|
1302
|
-
return collectLogs(relatedPage)
|
|
1303
|
-
})
|
|
1304
|
-
} else {
|
|
1305
|
-
for (const [p] of this.browserLogs) {
|
|
1306
|
-
allLogs.push(...collectLogs(p))
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
// Advance cursors after collecting so next sinceLastCall call starts fresh
|
|
1311
|
-
if (sinceLastCall) {
|
|
1312
|
-
const pagesToAdvance = filterPage
|
|
1313
|
-
? this.pagesRelatedToPage(filterPage)
|
|
1314
|
-
: [...this.browserLogs.keys()]
|
|
1315
|
-
for (const p of pagesToAdvance) {
|
|
1316
|
-
const logs = this.browserLogs.get(p)
|
|
1317
|
-
if (logs) {
|
|
1318
|
-
this.pageLogCursor.set(p, logs.length)
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
if (search) {
|
|
1324
|
-
const matchIndices: number[] = []
|
|
1325
|
-
for (let i = 0; i < allLogs.length; i++) {
|
|
1326
|
-
const log = allLogs[i]
|
|
1327
|
-
const isMatch = typeof search === 'string' ? log.includes(search) : isRegExp(search) && search.test(log)
|
|
1328
|
-
if (isMatch) matchIndices.push(i)
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
const CONTEXT_LINES = 5
|
|
1332
|
-
const includedIndices = new Set<number>()
|
|
1333
|
-
for (const idx of matchIndices) {
|
|
1334
|
-
const start = Math.max(0, idx - CONTEXT_LINES)
|
|
1335
|
-
const end = Math.min(allLogs.length - 1, idx + CONTEXT_LINES)
|
|
1336
|
-
for (let i = start; i <= end; i++) {
|
|
1337
|
-
includedIndices.add(i)
|
|
1338
|
-
}
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
const sortedIndices = [...includedIndices].sort((a, b) => a - b)
|
|
1342
|
-
const result: string[] = []
|
|
1343
|
-
for (let i = 0; i < sortedIndices.length; i++) {
|
|
1344
|
-
const logIdx = sortedIndices[i]
|
|
1345
|
-
if (i > 0 && sortedIndices[i - 1] !== logIdx - 1) {
|
|
1346
|
-
result.push('---')
|
|
1347
|
-
}
|
|
1348
|
-
result.push(allLogs[logIdx])
|
|
1349
|
-
}
|
|
1350
|
-
allLogs = result
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
return count !== undefined ? allLogs.slice(-count) : allLogs
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
const clearAllLogs = () => {
|
|
1357
|
-
this.browserLogs.clear()
|
|
1358
|
-
this.pageLogCursor.clear()
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
const getCDPSession = async (options: { page: Page }) => {
|
|
1362
|
-
if (options.page.isClosed()) {
|
|
1363
|
-
throw new Error('Cannot create CDP session for closed page')
|
|
1364
|
-
}
|
|
1365
|
-
return await getCDPSessionForPage({ page: options.page })
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
const createDebugger = (options: { cdp: ICDPSession }) => new Debugger(options)
|
|
1369
|
-
const createEditor = (options: { cdp: ICDPSession }) => new Editor(options)
|
|
1370
|
-
|
|
1371
|
-
const getStylesForLocatorFn = async (options: { locator: any }) => {
|
|
1372
|
-
const cdp = await getCDPSession({ page: options.locator.page() })
|
|
1373
|
-
return getStylesForLocator({ locator: options.locator, cdp })
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
|
-
const getReactSourceFn = async (options: { locator: any }) => {
|
|
1377
|
-
const cdp = await getCDPSession({ page: options.locator.page() })
|
|
1378
|
-
return getReactSource({ locator: options.locator, cdp })
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
const getReactComponentInfoFn = async (options: { locator: Locator | ElementHandle }) => {
|
|
1382
|
-
const targetPage = await (async (): Promise<Page | null> => {
|
|
1383
|
-
if ('page' in options.locator) {
|
|
1384
|
-
return options.locator.page()
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
return (await options.locator.ownerFrame())?.page() ?? null
|
|
1388
|
-
})()
|
|
1389
|
-
if (!targetPage) {
|
|
1390
|
-
throw new Error('Could not get page from locator')
|
|
1391
|
-
}
|
|
1392
|
-
const cdp = await getCDPSession({ page: targetPage })
|
|
1393
|
-
return getReactComponentInfo({ locator: options.locator, cdp })
|
|
1394
|
-
}
|
|
1395
|
-
|
|
1396
|
-
const inspectPinnedElement = async (pageUrl: string, elementExpression: string) => {
|
|
1397
|
-
const targetPage = context.pages().find((candidate) => candidate.url() === pageUrl) || context.pages()[0]
|
|
1398
|
-
if (!targetPage) {
|
|
1399
|
-
throw new Error('No Playwright pages are available')
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
this.userState.page = targetPage
|
|
1403
|
-
const handle = (await targetPage.evaluateHandle((expression) => {
|
|
1404
|
-
return Function(`return (${expression})`)()
|
|
1405
|
-
}, elementExpression)).asElement()
|
|
1406
|
-
|
|
1407
|
-
const result = await (async () => {
|
|
1408
|
-
if (!handle) {
|
|
1409
|
-
return { url: targetPage.url(), outerHTML: null, react: null }
|
|
1410
|
-
}
|
|
1411
|
-
return {
|
|
1412
|
-
url: targetPage.url(),
|
|
1413
|
-
outerHTML: await handle.evaluate((el) => el.outerHTML),
|
|
1414
|
-
react: await getReactComponentInfoFn({ locator: handle }),
|
|
1415
|
-
}
|
|
1416
|
-
})()
|
|
1417
|
-
|
|
1418
|
-
console.log(result)
|
|
1419
|
-
return result
|
|
1420
|
-
}
|
|
1421
|
-
|
|
1422
|
-
const screenshotCollector: ScreenshotResult[] = []
|
|
1423
|
-
// Separate collector for images produced by resizeImageForAgent() calls.
|
|
1424
|
-
// These get merged into result.images so the CLI can emit them via Kitty Graphics.
|
|
1425
|
-
const resizedImageCollector: Array<{ data: string; mimeType: string }> = []
|
|
1426
|
-
|
|
1427
|
-
const resizeImageForAgentFn: typeof resizeImageForAgent = async (options) => {
|
|
1428
|
-
const result = await resizeImageForAgent(options)
|
|
1429
|
-
resizedImageCollector.push({ data: result.buffer.toString('base64'), mimeType: result.mimeType })
|
|
1430
|
-
return result
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
|
-
const screenshotWithAccessibilityLabelsFn = async (options: { page: Page; interactiveOnly?: boolean }) => {
|
|
1434
|
-
return screenshotWithAccessibilityLabels({
|
|
1435
|
-
...options,
|
|
1436
|
-
collector: screenshotCollector,
|
|
1437
|
-
logger: {
|
|
1438
|
-
info: (...args) => {
|
|
1439
|
-
this.logger.error('[tabwright]', ...args)
|
|
1440
|
-
},
|
|
1441
|
-
error: (...args) => {
|
|
1442
|
-
this.logger.error('[tabwright]', ...args)
|
|
1443
|
-
},
|
|
1444
|
-
},
|
|
1445
|
-
})
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
const relayPort = this.cdpConfig.port || 19988
|
|
1449
|
-
const self = this
|
|
1450
|
-
const ghostCursorController = this.ghostCursorController
|
|
1451
|
-
|
|
1452
|
-
const showGhostCursor = async (options?: ({ page?: Page } & GhostCursorClientOptions)) => {
|
|
1453
|
-
const targetPage = options?.page || page
|
|
1454
|
-
const cursorOptions: GhostCursorClientOptions | undefined = (() => {
|
|
1455
|
-
if (!options) {
|
|
1456
|
-
return undefined
|
|
1457
|
-
}
|
|
1458
|
-
|
|
1459
|
-
const { page: _ignoredPage, ...rest } = options
|
|
1460
|
-
return rest
|
|
1461
|
-
})()
|
|
1462
|
-
|
|
1463
|
-
await ghostCursorController.show({ page: targetPage, cursorOptions })
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
const hideGhostCursor = async (options?: { page?: Page }) => {
|
|
1467
|
-
const targetPage = options?.page || page
|
|
1468
|
-
await ghostCursorController.hide({ page: targetPage })
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
const replayApi = createReplayApi({
|
|
1472
|
-
context,
|
|
1473
|
-
defaultPage: page,
|
|
1474
|
-
relayPort,
|
|
1475
|
-
})
|
|
1476
|
-
const workflow = {
|
|
1477
|
-
saveCapability: (input: Omit<SaveWorkflowCapabilityOptions, 'cwd'>) => {
|
|
1478
|
-
return saveWorkflowCapability({
|
|
1479
|
-
...input,
|
|
1480
|
-
cwd: self.sessionCwd || process.cwd(),
|
|
1481
|
-
})
|
|
1482
|
-
},
|
|
1483
|
-
saveFromRecording: (input: Omit<SaveWorkflowFromRecordingOptions, 'cwd'>) => {
|
|
1484
|
-
return saveWorkflowFromRecording({
|
|
1485
|
-
...input,
|
|
1486
|
-
cwd: self.sessionCwd || process.cwd(),
|
|
1487
|
-
})
|
|
1488
|
-
},
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
// Ghost Browser API - creates chrome object that mirrors Ghost Browser's APIs
|
|
1492
|
-
// See extension/src/ghost-browser-api.d.ts for full API documentation
|
|
1493
|
-
const chromeGhostBrowser = createGhostBrowserChrome(async (namespace, method, args) => {
|
|
1494
|
-
const cdp = await getCDPSession({ page })
|
|
1495
|
-
const result = await cdp.send('ghost-browser' as any, { namespace, method, args })
|
|
1496
|
-
const typed = result as GhostBrowserCommandResult
|
|
1497
|
-
if (!typed.success) {
|
|
1498
|
-
throw new Error(typed.error || `Ghost Browser API call failed: ${namespace}.${method}`)
|
|
1499
|
-
}
|
|
1500
|
-
return typed.result
|
|
1501
|
-
})
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
let vmContextObj: any = {
|
|
1505
|
-
page,
|
|
1506
|
-
context,
|
|
1507
|
-
browser: this.browser,
|
|
1508
|
-
state: this.userState,
|
|
1509
|
-
console: customConsole,
|
|
1510
|
-
snapshot,
|
|
1511
|
-
accessibilitySnapshot: snapshot, // backward compat alias
|
|
1512
|
-
refToLocator,
|
|
1513
|
-
getCleanHTML,
|
|
1514
|
-
getPageMarkdown,
|
|
1515
|
-
getLocatorStringForElement,
|
|
1516
|
-
getLatestLogs,
|
|
1517
|
-
clearAllLogs,
|
|
1518
|
-
waitForPageLoad,
|
|
1519
|
-
getCDPSession,
|
|
1520
|
-
createDebugger,
|
|
1521
|
-
createEditor,
|
|
1522
|
-
getStylesForLocator: getStylesForLocatorFn,
|
|
1523
|
-
formatStylesAsText,
|
|
1524
|
-
getReactSource: getReactSourceFn,
|
|
1525
|
-
getReactComponentInfo: getReactComponentInfoFn,
|
|
1526
|
-
inspectPinnedElement,
|
|
1527
|
-
screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn,
|
|
1528
|
-
resizeImageForAgent: resizeImageForAgentFn,
|
|
1529
|
-
// Backward-compatible alias for resizeImageForAgent
|
|
1530
|
-
resizeImage: resizeImageForAgentFn,
|
|
1531
|
-
ghostCursor: {
|
|
1532
|
-
show: showGhostCursor,
|
|
1533
|
-
hide: hideGhostCursor,
|
|
1534
|
-
},
|
|
1535
|
-
replay: {
|
|
1536
|
-
start: replayApi.start,
|
|
1537
|
-
stop: replayApi.stop,
|
|
1538
|
-
isRecording: replayApi.isRecording,
|
|
1539
|
-
cancel: replayApi.cancel,
|
|
1540
|
-
list: replayApi.list,
|
|
1541
|
-
events: replayApi.events,
|
|
1542
|
-
},
|
|
1543
|
-
workflow,
|
|
1544
|
-
// Backward-compatible aliases
|
|
1545
|
-
startReplay: replayApi.start,
|
|
1546
|
-
stopReplay: replayApi.stop,
|
|
1547
|
-
isReplayRecording: replayApi.isRecording,
|
|
1548
|
-
cancelReplay: replayApi.cancel,
|
|
1549
|
-
listReplays: replayApi.list,
|
|
1550
|
-
saveWorkflowCapability: workflow.saveCapability,
|
|
1551
|
-
saveWorkflowFromRecording: workflow.saveFromRecording,
|
|
1552
|
-
resetPlaywright: async () => {
|
|
1553
|
-
const { page: newPage, context: newContext } = await self.reset()
|
|
1554
|
-
vmContextObj.page = newPage
|
|
1555
|
-
vmContextObj.context = newContext
|
|
1556
|
-
vmContextObj.browser = self.browser
|
|
1557
|
-
return { page: newPage, context: newContext }
|
|
1558
|
-
},
|
|
1559
|
-
require: this.sandboxedRequire,
|
|
1560
|
-
import: (specifier: string) => import(specifier),
|
|
1561
|
-
// Ghost Browser API - only works in Ghost Browser, mirrors chrome.ghostPublicAPI etc
|
|
1562
|
-
chrome: chromeGhostBrowser,
|
|
1563
|
-
...usefulGlobals,
|
|
1564
|
-
// Expose process with safety overrides:
|
|
1565
|
-
// - cwd() returns the session's cwd instead of the relay server's cwd
|
|
1566
|
-
// - exit() is blocked to prevent killing the relay server
|
|
1567
|
-
// - chdir() is blocked to prevent affecting other sessions
|
|
1568
|
-
process: new Proxy(process, {
|
|
1569
|
-
get(target, prop, receiver) {
|
|
1570
|
-
if (prop === 'cwd') return () => self.sessionCwd || target.cwd()
|
|
1571
|
-
if (prop === 'exit') return () => { throw new Error('process.exit() is not allowed in the sandbox') }
|
|
1572
|
-
if (prop === 'chdir') return () => { throw new Error('process.chdir() is not allowed in the sandbox, use a new session with a different cwd instead') }
|
|
1573
|
-
return Reflect.get(target, prop, receiver)
|
|
1574
|
-
},
|
|
1575
|
-
}),
|
|
1576
|
-
}
|
|
1577
|
-
|
|
1578
|
-
const vmContext = vm.createContext(vmContextObj)
|
|
1579
|
-
const autoReturnExpr = getAutoReturnExpression(code)
|
|
1580
|
-
const wrappedCode = autoReturnExpr !== null
|
|
1581
|
-
? `(async () => { return await (${autoReturnExpr}) })()`
|
|
1582
|
-
: `(async () => { ${code} })()`
|
|
1583
|
-
const hasExplicitReturn = autoReturnExpr !== null || /\breturn\b/.test(code)
|
|
1584
|
-
|
|
1585
|
-
const result = await Promise.race([
|
|
1586
|
-
vm.runInContext(wrappedCode, vmContext, { timeout, displayErrors: true }),
|
|
1587
|
-
new Promise((_, reject) => setTimeout(() => reject(new CodeExecutionTimeoutError(timeout)), timeout)),
|
|
1588
|
-
])
|
|
1589
|
-
|
|
1590
|
-
let responseText = formatConsoleLogs(consoleLogs)
|
|
1591
|
-
|
|
1592
|
-
// Only show return value if user explicitly used return
|
|
1593
|
-
let structuredResult: unknown
|
|
1594
|
-
if (hasExplicitReturn) {
|
|
1595
|
-
const resolvedResult = isPromise(result) ? await result : result
|
|
1596
|
-
// Auto-returned Playwright handles (Response, Page, Browser, Request,
|
|
1597
|
-
// Frame, etc.) are silently skipped — they're programmatic references,
|
|
1598
|
-
// not useful display data. Users can `console.log(response)` or
|
|
1599
|
-
// return specific fields (`return response.url()`) to see values.
|
|
1600
|
-
// See issue #82.
|
|
1601
|
-
if (resolvedResult !== undefined && !isPlaywrightChannelOwner(resolvedResult)) {
|
|
1602
|
-
if (options.includeStructuredResult) {
|
|
1603
|
-
structuredResult = resolvedResult
|
|
1604
|
-
}
|
|
1605
|
-
const formatted =
|
|
1606
|
-
typeof resolvedResult === 'string'
|
|
1607
|
-
? resolvedResult
|
|
1608
|
-
: util.inspect(resolvedResult, {
|
|
1609
|
-
depth: 4,
|
|
1610
|
-
colors: false,
|
|
1611
|
-
maxArrayLength: 100,
|
|
1612
|
-
maxStringLength: 1000,
|
|
1613
|
-
breakLength: 80,
|
|
1614
|
-
})
|
|
1615
|
-
if (formatted.trim()) {
|
|
1616
|
-
responseText += `[return value] ${formatted}\n`
|
|
1617
|
-
}
|
|
1618
|
-
}
|
|
1619
|
-
}
|
|
1620
|
-
|
|
1621
|
-
responseText += this.flushWarningsForScope(warningScope)
|
|
1622
|
-
|
|
1623
|
-
if (!responseText.trim()) {
|
|
1624
|
-
responseText = 'Code executed successfully (no output)'
|
|
1625
|
-
}
|
|
1626
|
-
|
|
1627
|
-
const MAX_LENGTH = 10000
|
|
1628
|
-
let finalText = responseText.trim()
|
|
1629
|
-
if (finalText.length > MAX_LENGTH) {
|
|
1630
|
-
finalText =
|
|
1631
|
-
finalText.slice(0, MAX_LENGTH) +
|
|
1632
|
-
`\n\n[Truncated to ${MAX_LENGTH} characters. Use search to find specific content]`
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
|
-
const images = [
|
|
1636
|
-
...screenshotCollector.map((s) => ({ data: s.base64, mimeType: s.mimeType })),
|
|
1637
|
-
...resizedImageCollector,
|
|
1638
|
-
]
|
|
1639
|
-
const screenshots: ExecuteScreenshot[] = screenshotCollector.map((s) => ({
|
|
1640
|
-
path: s.path,
|
|
1641
|
-
base64: s.base64,
|
|
1642
|
-
mimeType: s.mimeType,
|
|
1643
|
-
snapshot: s.snapshot,
|
|
1644
|
-
labelCount: s.labelCount,
|
|
1645
|
-
}))
|
|
1646
|
-
|
|
1647
|
-
return options.includeStructuredResult
|
|
1648
|
-
? { text: finalText, images, screenshots, isError: false, structuredResult }
|
|
1649
|
-
: { text: finalText, images, screenshots, isError: false }
|
|
1650
|
-
} catch (error: any) {
|
|
1651
|
-
const errorStack = error.stack || error.message
|
|
1652
|
-
const isTimeoutError =
|
|
1653
|
-
error instanceof CodeExecutionTimeoutError || error?.name === 'TimeoutError' || error?.name === 'AbortError'
|
|
1654
|
-
|
|
1655
|
-
this.logger.error('Error in execute:', errorStack)
|
|
1656
|
-
|
|
1657
|
-
const logsText = formatConsoleLogs(consoleLogs, 'Console output (before error)')
|
|
1658
|
-
const warningText = this.flushWarningsForScope(warningScope)
|
|
1659
|
-
|
|
1660
|
-
// Cloud sessions: disconnection errors mean the VM expired or was destroyed.
|
|
1661
|
-
// Give a clear actionable message instead of a generic "call reset" hint.
|
|
1662
|
-
const isDisconnect = error instanceof Error && isDisconnectionError(error)
|
|
1663
|
-
const resetHint = (() => {
|
|
1664
|
-
if (isTimeoutError) return ''
|
|
1665
|
-
if (this.cloudSession && isDisconnect) {
|
|
1666
|
-
return `\n\n[Cloud browser expired or disconnected. Create a new session with: tabwright session new --browser cloud]`
|
|
1667
|
-
}
|
|
1668
|
-
return '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call reset to reconnect.]'
|
|
1669
|
-
})()
|
|
1670
|
-
|
|
1671
|
-
// timeout stacks are internal noise (Promise.race / setTimeout); only show the message
|
|
1672
|
-
const errorText = isTimeoutError ? error.message : errorStack
|
|
1673
|
-
return {
|
|
1674
|
-
text: `${logsText}${warningText}\nError executing code: ${errorText}${resetHint}`,
|
|
1675
|
-
images: [],
|
|
1676
|
-
screenshots: [],
|
|
1677
|
-
isError: true,
|
|
1678
|
-
}
|
|
1679
|
-
}
|
|
1680
|
-
}
|
|
1681
|
-
|
|
1682
|
-
// When extension is connected but has no pages, auto-create unless PLAYWRITER_AUTO_ENABLE=false disables it.
|
|
1683
|
-
// In direct CDP mode, always create a page (no extension check needed).
|
|
1684
|
-
private async ensurePageForContext(options: { context: BrowserContext; timeout: number }): Promise<Page> {
|
|
1685
|
-
const { context, timeout } = options
|
|
1686
|
-
const pages = context.pages().filter((p) => !p.isClosed())
|
|
1687
|
-
if (pages.length > 0) {
|
|
1688
|
-
return pages[0]
|
|
1689
|
-
}
|
|
1690
|
-
|
|
1691
|
-
// Direct CDP mode: always create a new page, no extension involved
|
|
1692
|
-
if (this.isDirectCdpMode()) {
|
|
1693
|
-
const page = await context.newPage()
|
|
1694
|
-
this.setupPageListeners(page)
|
|
1695
|
-
await page.waitForLoadState('domcontentloaded', { timeout }).catch(() => {})
|
|
1696
|
-
return page
|
|
1697
|
-
}
|
|
1698
|
-
|
|
1699
|
-
const extensionStatus = await this.checkExtensionStatus()
|
|
1700
|
-
if (!extensionStatus.connected) {
|
|
1701
|
-
throw new Error(EXTENSION_NOT_CONNECTED_ERROR)
|
|
1702
|
-
}
|
|
1703
|
-
|
|
1704
|
-
if (!shouldAutoEnableTabwright()) {
|
|
1705
|
-
const waitTimeoutMs = Math.min(timeout, 1000)
|
|
1706
|
-
const startTime = Date.now()
|
|
1707
|
-
while (Date.now() - startTime < waitTimeoutMs) {
|
|
1708
|
-
const availablePages = context.pages().filter((p) => !p.isClosed())
|
|
1709
|
-
if (availablePages.length > 0) {
|
|
1710
|
-
return availablePages[0]
|
|
1711
|
-
}
|
|
1712
|
-
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
1713
|
-
}
|
|
1714
|
-
throw new Error(NO_PAGES_AVAILABLE_ERROR)
|
|
1715
|
-
}
|
|
1716
|
-
|
|
1717
|
-
const page = await context.newPage()
|
|
1718
|
-
this.setupPageListeners(page)
|
|
1719
|
-
const pageUrl = page.url()
|
|
1720
|
-
if (pageUrl === 'about:blank') {
|
|
1721
|
-
return page
|
|
1722
|
-
}
|
|
1723
|
-
|
|
1724
|
-
// Avoid burning the full timeout on about:blank-like pages.
|
|
1725
|
-
await page.waitForLoadState('domcontentloaded', { timeout }).catch(() => {})
|
|
1726
|
-
return page
|
|
1727
|
-
}
|
|
1728
|
-
|
|
1729
|
-
/** Get info about current connection state */
|
|
1730
|
-
getStatus(): { connected: boolean; pageUrl: string | null; pagesCount: number } {
|
|
1731
|
-
return {
|
|
1732
|
-
connected: this.isConnected,
|
|
1733
|
-
pageUrl: this.page?.url() || null,
|
|
1734
|
-
pagesCount: this.context?.pages().length || 0,
|
|
1735
|
-
}
|
|
1736
|
-
}
|
|
1737
|
-
|
|
1738
|
-
/** Get keys of user-defined state */
|
|
1739
|
-
getStateKeys(): string[] {
|
|
1740
|
-
return Object.keys(this.userState)
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
getSessionMetadata(): SessionMetadata {
|
|
1744
|
-
return this.sessionMetadata
|
|
1745
|
-
}
|
|
1746
|
-
|
|
1747
|
-
getSessionInfo({ id }: { id: string }): SessionInfo {
|
|
1748
|
-
const status = this.getStatus()
|
|
1749
|
-
return {
|
|
1750
|
-
id,
|
|
1751
|
-
stateKeys: this.getStateKeys(),
|
|
1752
|
-
extensionId: this.sessionMetadata.extensionId,
|
|
1753
|
-
browser: this.sessionMetadata.browser,
|
|
1754
|
-
profile: this.sessionMetadata.profile,
|
|
1755
|
-
cwd: this.sessionCwd,
|
|
1756
|
-
connected: status.connected,
|
|
1757
|
-
pageUrl: status.pageUrl,
|
|
1758
|
-
pagesCount: status.pagesCount,
|
|
1759
|
-
}
|
|
1760
|
-
}
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
|
-
/**
|
|
1764
|
-
* Session manager for multiple executors, keyed by session ID.
|
|
1765
|
-
*/
|
|
1766
|
-
export class ExecutorManager {
|
|
1767
|
-
private executors = new Map<string, PlaywrightExecutor>()
|
|
1768
|
-
private cdpConfig: CdpConfig | ((sessionId: string) => CdpConfig)
|
|
1769
|
-
private logger: ExecutorLogger
|
|
1770
|
-
|
|
1771
|
-
constructor(options: { cdpConfig: CdpConfig | ((sessionId: string) => CdpConfig); logger?: ExecutorLogger }) {
|
|
1772
|
-
this.cdpConfig = options.cdpConfig
|
|
1773
|
-
this.logger = options.logger || { log: console.log, error: console.error }
|
|
1774
|
-
}
|
|
1775
|
-
|
|
1776
|
-
getExecutor(options: {
|
|
1777
|
-
sessionId: string
|
|
1778
|
-
cwd?: string
|
|
1779
|
-
sessionMetadata?: SessionMetadata
|
|
1780
|
-
/** Override cdpConfig for this session (e.g. direct CDP connection) */
|
|
1781
|
-
cdpConfig?: CdpConfig
|
|
1782
|
-
/** Cloud session info (set when connecting to a Browser Use VM) */
|
|
1783
|
-
cloudSession?: CloudSessionInfo
|
|
1784
|
-
}): PlaywrightExecutor {
|
|
1785
|
-
const { sessionId, cwd, sessionMetadata } = options
|
|
1786
|
-
let executor = this.executors.get(sessionId)
|
|
1787
|
-
if (!executor) {
|
|
1788
|
-
const cdpConfig = (() => {
|
|
1789
|
-
// Per-session override takes priority (used for direct CDP sessions)
|
|
1790
|
-
if (options.cdpConfig) {
|
|
1791
|
-
return options.cdpConfig
|
|
1792
|
-
}
|
|
1793
|
-
const baseConfig = typeof this.cdpConfig === 'function' ? this.cdpConfig(sessionId) : this.cdpConfig
|
|
1794
|
-
if (sessionMetadata?.extensionId) {
|
|
1795
|
-
return { ...baseConfig, extensionId: sessionMetadata.extensionId }
|
|
1796
|
-
}
|
|
1797
|
-
return baseConfig
|
|
1798
|
-
})()
|
|
1799
|
-
executor = new PlaywrightExecutor({
|
|
1800
|
-
cdpConfig,
|
|
1801
|
-
sessionMetadata,
|
|
1802
|
-
logger: this.logger,
|
|
1803
|
-
cwd,
|
|
1804
|
-
cloudSession: options.cloudSession,
|
|
1805
|
-
})
|
|
1806
|
-
this.executors.set(sessionId, executor)
|
|
1807
|
-
}
|
|
1808
|
-
return executor
|
|
1809
|
-
}
|
|
1810
|
-
|
|
1811
|
-
deleteExecutor(sessionId: string): boolean {
|
|
1812
|
-
return this.executors.delete(sessionId)
|
|
1813
|
-
}
|
|
1814
|
-
|
|
1815
|
-
getSession(sessionId: string): PlaywrightExecutor | null {
|
|
1816
|
-
return this.executors.get(sessionId) || null
|
|
1817
|
-
}
|
|
1818
|
-
|
|
1819
|
-
listSessions(): SessionInfo[] {
|
|
1820
|
-
return [...this.executors.entries()].map(([id, executor]) => {
|
|
1821
|
-
return executor.getSessionInfo({ id })
|
|
1822
|
-
})
|
|
1823
|
-
}
|
|
1824
|
-
}
|