devharness 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/build/atomic-write.d.ts +28 -0
- package/build/atomic-write.d.ts.map +1 -0
- package/build/atomic-write.js +126 -0
- package/build/atomic-write.js.map +1 -0
- package/build/cdp-manager.d.ts +524 -0
- package/build/cdp-manager.d.ts.map +1 -0
- package/build/cdp-manager.js +1828 -0
- package/build/cdp-manager.js.map +1 -0
- package/build/cdp-manager.test.d.ts +6 -0
- package/build/cdp-manager.test.d.ts.map +1 -0
- package/build/cdp-manager.test.js +546 -0
- package/build/cdp-manager.test.js.map +1 -0
- package/build/chrome-launcher.d.ts +400 -0
- package/build/chrome-launcher.d.ts.map +1 -0
- package/build/chrome-launcher.js +1071 -0
- package/build/chrome-launcher.js.map +1 -0
- package/build/chrome-launcher.test.d.ts +11 -0
- package/build/chrome-launcher.test.d.ts.map +1 -0
- package/build/chrome-launcher.test.js +204 -0
- package/build/chrome-launcher.test.js.map +1 -0
- package/build/clickable-cache.d.ts +73 -0
- package/build/clickable-cache.d.ts.map +1 -0
- package/build/clickable-cache.js +90 -0
- package/build/clickable-cache.js.map +1 -0
- package/build/command-recorder.d.ts +401 -0
- package/build/command-recorder.d.ts.map +1 -0
- package/build/command-recorder.js +701 -0
- package/build/command-recorder.js.map +1 -0
- package/build/config.d.ts +390 -0
- package/build/config.d.ts.map +1 -0
- package/build/config.js +826 -0
- package/build/config.js.map +1 -0
- package/build/config.test.d.ts +2 -0
- package/build/config.test.d.ts.map +1 -0
- package/build/config.test.js +112 -0
- package/build/config.test.js.map +1 -0
- package/build/connection-manager.d.ts +159 -0
- package/build/connection-manager.d.ts.map +1 -0
- package/build/connection-manager.js +432 -0
- package/build/connection-manager.js.map +1 -0
- package/build/connection-manager.test.d.ts +11 -0
- package/build/connection-manager.test.d.ts.map +1 -0
- package/build/connection-manager.test.js +114 -0
- package/build/connection-manager.test.js.map +1 -0
- package/build/console-monitor.d.ts +150 -0
- package/build/console-monitor.d.ts.map +1 -0
- package/build/console-monitor.js +454 -0
- package/build/console-monitor.js.map +1 -0
- package/build/dashboard/bundle.js +1 -0
- package/build/dashboard/dashboard-client.d.ts +42 -0
- package/build/dashboard/dashboard-client.d.ts.map +1 -0
- package/build/dashboard/dashboard-client.js +184 -0
- package/build/dashboard/dashboard-client.js.map +1 -0
- package/build/dashboard/dashboard-hub.d.ts +50 -0
- package/build/dashboard/dashboard-hub.d.ts.map +1 -0
- package/build/dashboard/dashboard-hub.js +654 -0
- package/build/dashboard/dashboard-hub.js.map +1 -0
- package/build/dashboard/hub-lock.d.ts +16 -0
- package/build/dashboard/hub-lock.d.ts.map +1 -0
- package/build/dashboard/hub-lock.js +77 -0
- package/build/dashboard/hub-lock.js.map +1 -0
- package/build/dashboard/index.d.ts +25 -0
- package/build/dashboard/index.d.ts.map +1 -0
- package/build/dashboard/index.js +77 -0
- package/build/dashboard/index.js.map +1 -0
- package/build/dashboard/types.d.ts +83 -0
- package/build/dashboard/types.d.ts.map +1 -0
- package/build/dashboard/types.js +6 -0
- package/build/dashboard/types.js.map +1 -0
- package/build/debug-logger.d.ts +85 -0
- package/build/debug-logger.d.ts.map +1 -0
- package/build/debug-logger.js +195 -0
- package/build/debug-logger.js.map +1 -0
- package/build/debugger-aware-wrapper.d.ts +37 -0
- package/build/debugger-aware-wrapper.d.ts.map +1 -0
- package/build/debugger-aware-wrapper.js +128 -0
- package/build/debugger-aware-wrapper.js.map +1 -0
- package/build/dom-change-monitor.d.ts +92 -0
- package/build/dom-change-monitor.d.ts.map +1 -0
- package/build/dom-change-monitor.js +437 -0
- package/build/dom-change-monitor.js.map +1 -0
- package/build/element-collector.d.ts +16 -0
- package/build/element-collector.d.ts.map +1 -0
- package/build/element-collector.js +207 -0
- package/build/element-collector.js.map +1 -0
- package/build/error-helpers.d.ts +43 -0
- package/build/error-helpers.d.ts.map +1 -0
- package/build/error-helpers.js +143 -0
- package/build/error-helpers.js.map +1 -0
- package/build/formatters/console-formatter.d.ts +160 -0
- package/build/formatters/console-formatter.d.ts.map +1 -0
- package/build/formatters/console-formatter.js +338 -0
- package/build/formatters/console-formatter.js.map +1 -0
- package/build/helpers/parser-plugins.d.ts +44 -0
- package/build/helpers/parser-plugins.d.ts.map +1 -0
- package/build/helpers/parser-plugins.js +102 -0
- package/build/helpers/parser-plugins.js.map +1 -0
- package/build/helpers/paths.d.ts +79 -0
- package/build/helpers/paths.d.ts.map +1 -0
- package/build/helpers/paths.js +168 -0
- package/build/helpers/paths.js.map +1 -0
- package/build/helpers/process-liveness.d.ts +24 -0
- package/build/helpers/process-liveness.d.ts.map +1 -0
- package/build/helpers/process-liveness.js +36 -0
- package/build/helpers/process-liveness.js.map +1 -0
- package/build/helpers/process-liveness.test.d.ts +2 -0
- package/build/helpers/process-liveness.test.d.ts.map +1 -0
- package/build/helpers/process-liveness.test.js +40 -0
- package/build/helpers/process-liveness.test.js.map +1 -0
- package/build/helpers/sequence-tree.d.ts +27 -0
- package/build/helpers/sequence-tree.d.ts.map +1 -0
- package/build/helpers/sequence-tree.js +80 -0
- package/build/helpers/sequence-tree.js.map +1 -0
- package/build/helpers/sequence-tree.test.d.ts +2 -0
- package/build/helpers/sequence-tree.test.d.ts.map +1 -0
- package/build/helpers/sequence-tree.test.js +122 -0
- package/build/helpers/sequence-tree.test.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +2007 -0
- package/build/index.js.map +1 -0
- package/build/interaction-recorder.d.ts +214 -0
- package/build/interaction-recorder.d.ts.map +1 -0
- package/build/interaction-recorder.js +2007 -0
- package/build/interaction-recorder.js.map +1 -0
- package/build/issue-tracker.d.ts +107 -0
- package/build/issue-tracker.d.ts.map +1 -0
- package/build/issue-tracker.js +582 -0
- package/build/issue-tracker.js.map +1 -0
- package/build/issue-tracker.test.d.ts +2 -0
- package/build/issue-tracker.test.d.ts.map +1 -0
- package/build/issue-tracker.test.js +88 -0
- package/build/issue-tracker.test.js.map +1 -0
- package/build/launch-chrome-port.test.d.ts +14 -0
- package/build/launch-chrome-port.test.d.ts.map +1 -0
- package/build/launch-chrome-port.test.js +97 -0
- package/build/launch-chrome-port.test.js.map +1 -0
- package/build/log-processor/orchestrator.d.ts +32 -0
- package/build/log-processor/orchestrator.d.ts.map +1 -0
- package/build/log-processor/orchestrator.js +27 -0
- package/build/log-processor/orchestrator.js.map +1 -0
- package/build/logpoint-execution-tracker.d.ts +57 -0
- package/build/logpoint-execution-tracker.d.ts.map +1 -0
- package/build/logpoint-execution-tracker.js +112 -0
- package/build/logpoint-execution-tracker.js.map +1 -0
- package/build/mcp-supervisor.d.ts +3 -0
- package/build/mcp-supervisor.d.ts.map +1 -0
- package/build/mcp-supervisor.js +211 -0
- package/build/mcp-supervisor.js.map +1 -0
- package/build/messages.d.ts +165 -0
- package/build/messages.d.ts.map +1 -0
- package/build/messages.js +574 -0
- package/build/messages.js.map +1 -0
- package/build/network-monitor.d.ts +172 -0
- package/build/network-monitor.d.ts.map +1 -0
- package/build/network-monitor.js +350 -0
- package/build/network-monitor.js.map +1 -0
- package/build/overlays.d.ts +49 -0
- package/build/overlays.d.ts.map +1 -0
- package/build/overlays.js +409 -0
- package/build/overlays.js.map +1 -0
- package/build/persistent-profiles.test.d.ts +15 -0
- package/build/persistent-profiles.test.d.ts.map +1 -0
- package/build/persistent-profiles.test.js +470 -0
- package/build/persistent-profiles.test.js.map +1 -0
- package/build/port-reserver.d.ts +32 -0
- package/build/port-reserver.d.ts.map +1 -0
- package/build/port-reserver.js +118 -0
- package/build/port-reserver.js.map +1 -0
- package/build/puppeteer-manager.d.ts +47 -0
- package/build/puppeteer-manager.d.ts.map +1 -0
- package/build/puppeteer-manager.js +116 -0
- package/build/puppeteer-manager.js.map +1 -0
- package/build/recording-coverage.test.d.ts +12 -0
- package/build/recording-coverage.test.d.ts.map +1 -0
- package/build/recording-coverage.test.js +72 -0
- package/build/recording-coverage.test.js.map +1 -0
- package/build/reference-validator.d.ts +47 -0
- package/build/reference-validator.d.ts.map +1 -0
- package/build/reference-validator.js +86 -0
- package/build/reference-validator.js.map +1 -0
- package/build/reference-validator.test.d.ts +5 -0
- package/build/reference-validator.test.d.ts.map +1 -0
- package/build/reference-validator.test.js +243 -0
- package/build/reference-validator.test.js.map +1 -0
- package/build/replay-cursor.d.ts +27 -0
- package/build/replay-cursor.d.ts.map +1 -0
- package/build/replay-cursor.js +240 -0
- package/build/replay-cursor.js.map +1 -0
- package/build/runners/docker-compose-runner.d.ts +69 -0
- package/build/runners/docker-compose-runner.d.ts.map +1 -0
- package/build/runners/docker-compose-runner.js +438 -0
- package/build/runners/docker-compose-runner.js.map +1 -0
- package/build/runners/docker-runner.d.ts +65 -0
- package/build/runners/docker-runner.d.ts.map +1 -0
- package/build/runners/docker-runner.js +414 -0
- package/build/runners/docker-runner.js.map +1 -0
- package/build/runners/index.d.ts +19 -0
- package/build/runners/index.d.ts.map +1 -0
- package/build/runners/index.js +35 -0
- package/build/runners/index.js.map +1 -0
- package/build/runners/native-runner.d.ts +95 -0
- package/build/runners/native-runner.d.ts.map +1 -0
- package/build/runners/native-runner.js +370 -0
- package/build/runners/native-runner.js.map +1 -0
- package/build/runners/types.d.ts +147 -0
- package/build/runners/types.d.ts.map +1 -0
- package/build/runners/types.js +25 -0
- package/build/runners/types.js.map +1 -0
- package/build/self-restart.d.ts +12 -0
- package/build/self-restart.d.ts.map +1 -0
- package/build/self-restart.js +41 -0
- package/build/self-restart.js.map +1 -0
- package/build/self-restart.test.d.ts +2 -0
- package/build/self-restart.test.d.ts.map +1 -0
- package/build/self-restart.test.js +34 -0
- package/build/self-restart.test.js.map +1 -0
- package/build/server-claims.d.ts +161 -0
- package/build/server-claims.d.ts.map +1 -0
- package/build/server-claims.js +382 -0
- package/build/server-claims.js.map +1 -0
- package/build/server-claims.test.d.ts +2 -0
- package/build/server-claims.test.d.ts.map +1 -0
- package/build/server-claims.test.js +197 -0
- package/build/server-claims.test.js.map +1 -0
- package/build/server-manager.d.ts +438 -0
- package/build/server-manager.d.ts.map +1 -0
- package/build/server-manager.js +1674 -0
- package/build/server-manager.js.map +1 -0
- package/build/server-manager.test.d.ts +17 -0
- package/build/server-manager.test.d.ts.map +1 -0
- package/build/server-manager.test.js +122 -0
- package/build/server-manager.test.js.map +1 -0
- package/build/server-ownership.test.d.ts +2 -0
- package/build/server-ownership.test.d.ts.map +1 -0
- package/build/server-ownership.test.js +238 -0
- package/build/server-ownership.test.js.map +1 -0
- package/build/server-watcher.d.ts +21 -0
- package/build/server-watcher.d.ts.map +1 -0
- package/build/server-watcher.js +105 -0
- package/build/server-watcher.js.map +1 -0
- package/build/session-detector.d.ts +47 -0
- package/build/session-detector.d.ts.map +1 -0
- package/build/session-detector.js +229 -0
- package/build/session-detector.js.map +1 -0
- package/build/session-detector.test.d.ts +9 -0
- package/build/session-detector.test.d.ts.map +1 -0
- package/build/session-detector.test.js +45 -0
- package/build/session-detector.test.js.map +1 -0
- package/build/sourcemap-handler.d.ts +114 -0
- package/build/sourcemap-handler.d.ts.map +1 -0
- package/build/sourcemap-handler.js +479 -0
- package/build/sourcemap-handler.js.map +1 -0
- package/build/supervisor/child-manager.d.ts +43 -0
- package/build/supervisor/child-manager.d.ts.map +1 -0
- package/build/supervisor/child-manager.js +148 -0
- package/build/supervisor/child-manager.js.map +1 -0
- package/build/supervisor/child-manager.test.d.ts +2 -0
- package/build/supervisor/child-manager.test.d.ts.map +1 -0
- package/build/supervisor/child-manager.test.js +107 -0
- package/build/supervisor/child-manager.test.js.map +1 -0
- package/build/supervisor/client-watcher.d.ts +48 -0
- package/build/supervisor/client-watcher.d.ts.map +1 -0
- package/build/supervisor/client-watcher.js +186 -0
- package/build/supervisor/client-watcher.js.map +1 -0
- package/build/supervisor/client-watcher.test.d.ts +2 -0
- package/build/supervisor/client-watcher.test.d.ts.map +1 -0
- package/build/supervisor/client-watcher.test.js +121 -0
- package/build/supervisor/client-watcher.test.js.map +1 -0
- package/build/supervisor/idle-config.d.ts +30 -0
- package/build/supervisor/idle-config.d.ts.map +1 -0
- package/build/supervisor/idle-config.js +76 -0
- package/build/supervisor/idle-config.js.map +1 -0
- package/build/supervisor/idle-config.test.d.ts +2 -0
- package/build/supervisor/idle-config.test.d.ts.map +1 -0
- package/build/supervisor/idle-config.test.js +119 -0
- package/build/supervisor/idle-config.test.js.map +1 -0
- package/build/supervisor/ndjson-reader.d.ts +47 -0
- package/build/supervisor/ndjson-reader.d.ts.map +1 -0
- package/build/supervisor/ndjson-reader.js +75 -0
- package/build/supervisor/ndjson-reader.js.map +1 -0
- package/build/supervisor/ndjson-reader.test.d.ts +2 -0
- package/build/supervisor/ndjson-reader.test.d.ts.map +1 -0
- package/build/supervisor/ndjson-reader.test.js +92 -0
- package/build/supervisor/ndjson-reader.test.js.map +1 -0
- package/build/supervisor/pidfile.d.ts +11 -0
- package/build/supervisor/pidfile.d.ts.map +1 -0
- package/build/supervisor/pidfile.js +29 -0
- package/build/supervisor/pidfile.js.map +1 -0
- package/build/supervisor/pidfile.test.d.ts +8 -0
- package/build/supervisor/pidfile.test.d.ts.map +1 -0
- package/build/supervisor/pidfile.test.js +46 -0
- package/build/supervisor/pidfile.test.js.map +1 -0
- package/build/supervisor/restart-coordinator.d.ts +73 -0
- package/build/supervisor/restart-coordinator.d.ts.map +1 -0
- package/build/supervisor/restart-coordinator.js +300 -0
- package/build/supervisor/restart-coordinator.js.map +1 -0
- package/build/supervisor/restart-coordinator.test.d.ts +2 -0
- package/build/supervisor/restart-coordinator.test.d.ts.map +1 -0
- package/build/supervisor/restart-coordinator.test.js +434 -0
- package/build/supervisor/restart-coordinator.test.js.map +1 -0
- package/build/test-support/fake-execute-tool-call.d.ts +11 -0
- package/build/test-support/fake-execute-tool-call.d.ts.map +1 -0
- package/build/test-support/fake-execute-tool-call.js +19 -0
- package/build/test-support/fake-execute-tool-call.js.map +1 -0
- package/build/tool-error.d.ts +14 -0
- package/build/tool-error.d.ts.map +1 -0
- package/build/tool-error.js +18 -0
- package/build/tool-error.js.map +1 -0
- package/build/tool-response.d.ts +293 -0
- package/build/tool-response.d.ts.map +1 -0
- package/build/tool-response.js +365 -0
- package/build/tool-response.js.map +1 -0
- package/build/tools/assert-tools.d.ts +62 -0
- package/build/tools/assert-tools.d.ts.map +1 -0
- package/build/tools/assert-tools.js +330 -0
- package/build/tools/assert-tools.js.map +1 -0
- package/build/tools/breakpoint-await-abort.test.d.ts +2 -0
- package/build/tools/breakpoint-await-abort.test.d.ts.map +1 -0
- package/build/tools/breakpoint-await-abort.test.js +124 -0
- package/build/tools/breakpoint-await-abort.test.js.map +1 -0
- package/build/tools/breakpoint-tools.d.ts +99 -0
- package/build/tools/breakpoint-tools.d.ts.map +1 -0
- package/build/tools/breakpoint-tools.js +1197 -0
- package/build/tools/breakpoint-tools.js.map +1 -0
- package/build/tools/config-tools.d.ts +58 -0
- package/build/tools/config-tools.d.ts.map +1 -0
- package/build/tools/config-tools.js +180 -0
- package/build/tools/config-tools.js.map +1 -0
- package/build/tools/connection-routing.test.d.ts +12 -0
- package/build/tools/connection-routing.test.d.ts.map +1 -0
- package/build/tools/connection-routing.test.js +206 -0
- package/build/tools/connection-routing.test.js.map +1 -0
- package/build/tools/console-tools.d.ts +84 -0
- package/build/tools/console-tools.d.ts.map +1 -0
- package/build/tools/console-tools.js +241 -0
- package/build/tools/console-tools.js.map +1 -0
- package/build/tools/content-tools.d.ts +74 -0
- package/build/tools/content-tools.d.ts.map +1 -0
- package/build/tools/content-tools.js +582 -0
- package/build/tools/content-tools.js.map +1 -0
- package/build/tools/dashboard-tools.d.ts +38 -0
- package/build/tools/dashboard-tools.d.ts.map +1 -0
- package/build/tools/dashboard-tools.js +123 -0
- package/build/tools/dashboard-tools.js.map +1 -0
- package/build/tools/dom-tools.d.ts +41 -0
- package/build/tools/dom-tools.d.ts.map +1 -0
- package/build/tools/dom-tools.js +277 -0
- package/build/tools/dom-tools.js.map +1 -0
- package/build/tools/download-tools.d.ts +55 -0
- package/build/tools/download-tools.d.ts.map +1 -0
- package/build/tools/download-tools.js +280 -0
- package/build/tools/download-tools.js.map +1 -0
- package/build/tools/execution-tools.d.ts +40 -0
- package/build/tools/execution-tools.d.ts.map +1 -0
- package/build/tools/execution-tools.js +121 -0
- package/build/tools/execution-tools.js.map +1 -0
- package/build/tools/input-abort.test.d.ts +2 -0
- package/build/tools/input-abort.test.d.ts.map +1 -0
- package/build/tools/input-abort.test.js +227 -0
- package/build/tools/input-abort.test.js.map +1 -0
- package/build/tools/input-ambiguous-selector.test.d.ts +2 -0
- package/build/tools/input-ambiguous-selector.test.d.ts.map +1 -0
- package/build/tools/input-ambiguous-selector.test.js +34 -0
- package/build/tools/input-ambiguous-selector.test.js.map +1 -0
- package/build/tools/input-tools.d.ts +168 -0
- package/build/tools/input-tools.d.ts.map +1 -0
- package/build/tools/input-tools.js +1397 -0
- package/build/tools/input-tools.js.map +1 -0
- package/build/tools/inspection-tools.d.ts +112 -0
- package/build/tools/inspection-tools.d.ts.map +1 -0
- package/build/tools/inspection-tools.js +637 -0
- package/build/tools/inspection-tools.js.map +1 -0
- package/build/tools/interpolation.d.ts +30 -0
- package/build/tools/interpolation.d.ts.map +1 -0
- package/build/tools/interpolation.js +143 -0
- package/build/tools/interpolation.js.map +1 -0
- package/build/tools/issues-abort-hygiene.test.d.ts +2 -0
- package/build/tools/issues-abort-hygiene.test.d.ts.map +1 -0
- package/build/tools/issues-abort-hygiene.test.js +79 -0
- package/build/tools/issues-abort-hygiene.test.js.map +1 -0
- package/build/tools/issues-tools.d.ts +98 -0
- package/build/tools/issues-tools.d.ts.map +1 -0
- package/build/tools/issues-tools.js +821 -0
- package/build/tools/issues-tools.js.map +1 -0
- package/build/tools/issues-tools.test.d.ts +8 -0
- package/build/tools/issues-tools.test.d.ts.map +1 -0
- package/build/tools/issues-tools.test.js +184 -0
- package/build/tools/issues-tools.test.js.map +1 -0
- package/build/tools/modal-tools.d.ts +73 -0
- package/build/tools/modal-tools.d.ts.map +1 -0
- package/build/tools/modal-tools.js +161 -0
- package/build/tools/modal-tools.js.map +1 -0
- package/build/tools/network-tools.d.ts +72 -0
- package/build/tools/network-tools.d.ts.map +1 -0
- package/build/tools/network-tools.js +432 -0
- package/build/tools/network-tools.js.map +1 -0
- package/build/tools/page-tools.d.ts +78 -0
- package/build/tools/page-tools.d.ts.map +1 -0
- package/build/tools/page-tools.js +255 -0
- package/build/tools/page-tools.js.map +1 -0
- package/build/tools/plugin-tools.d.ts +16 -0
- package/build/tools/plugin-tools.d.ts.map +1 -0
- package/build/tools/plugin-tools.js +17 -0
- package/build/tools/plugin-tools.js.map +1 -0
- package/build/tools/replay-abort.test.d.ts +2 -0
- package/build/tools/replay-abort.test.d.ts.map +1 -0
- package/build/tools/replay-abort.test.js +259 -0
- package/build/tools/replay-abort.test.js.map +1 -0
- package/build/tools/replay-add-conditional.test.d.ts +2 -0
- package/build/tools/replay-add-conditional.test.d.ts.map +1 -0
- package/build/tools/replay-add-conditional.test.js +205 -0
- package/build/tools/replay-add-conditional.test.js.map +1 -0
- package/build/tools/replay-background-run.test.d.ts +2 -0
- package/build/tools/replay-background-run.test.d.ts.map +1 -0
- package/build/tools/replay-background-run.test.js +176 -0
- package/build/tools/replay-background-run.test.js.map +1 -0
- package/build/tools/replay-capture-variables.test.d.ts +2 -0
- package/build/tools/replay-capture-variables.test.d.ts.map +1 -0
- package/build/tools/replay-capture-variables.test.js +331 -0
- package/build/tools/replay-capture-variables.test.js.map +1 -0
- package/build/tools/replay-conditional-idb-and-launch.test.d.ts +11 -0
- package/build/tools/replay-conditional-idb-and-launch.test.d.ts.map +1 -0
- package/build/tools/replay-conditional-idb-and-launch.test.js +429 -0
- package/build/tools/replay-conditional-idb-and-launch.test.js.map +1 -0
- package/build/tools/replay-conditional-timeout.test.d.ts +14 -0
- package/build/tools/replay-conditional-timeout.test.d.ts.map +1 -0
- package/build/tools/replay-conditional-timeout.test.js +126 -0
- package/build/tools/replay-conditional-timeout.test.js.map +1 -0
- package/build/tools/replay-declare.test.d.ts +2 -0
- package/build/tools/replay-declare.test.d.ts.map +1 -0
- package/build/tools/replay-declare.test.js +106 -0
- package/build/tools/replay-declare.test.js.map +1 -0
- package/build/tools/replay-declared-browser-cleanup.test.d.ts +2 -0
- package/build/tools/replay-declared-browser-cleanup.test.d.ts.map +1 -0
- package/build/tools/replay-declared-browser-cleanup.test.js +103 -0
- package/build/tools/replay-declared-browser-cleanup.test.js.map +1 -0
- package/build/tools/replay-declared-profile.test.d.ts +2 -0
- package/build/tools/replay-declared-profile.test.d.ts.map +1 -0
- package/build/tools/replay-declared-profile.test.js +116 -0
- package/build/tools/replay-declared-profile.test.js.map +1 -0
- package/build/tools/replay-executor.d.ts +556 -0
- package/build/tools/replay-executor.d.ts.map +1 -0
- package/build/tools/replay-executor.js +2347 -0
- package/build/tools/replay-executor.js.map +1 -0
- package/build/tools/replay-folder-save.test.d.ts +2 -0
- package/build/tools/replay-folder-save.test.d.ts.map +1 -0
- package/build/tools/replay-folder-save.test.js +63 -0
- package/build/tools/replay-folder-save.test.js.map +1 -0
- package/build/tools/replay-formatters.d.ts +148 -0
- package/build/tools/replay-formatters.d.ts.map +1 -0
- package/build/tools/replay-formatters.js +856 -0
- package/build/tools/replay-formatters.js.map +1 -0
- package/build/tools/replay-kill-chrome-on-finish.test.d.ts +2 -0
- package/build/tools/replay-kill-chrome-on-finish.test.d.ts.map +1 -0
- package/build/tools/replay-kill-chrome-on-finish.test.js +175 -0
- package/build/tools/replay-kill-chrome-on-finish.test.js.map +1 -0
- package/build/tools/replay-multi-connection.test.d.ts +2 -0
- package/build/tools/replay-multi-connection.test.d.ts.map +1 -0
- package/build/tools/replay-multi-connection.test.js +547 -0
- package/build/tools/replay-multi-connection.test.js.map +1 -0
- package/build/tools/replay-output-and-record-options.test.d.ts +2 -0
- package/build/tools/replay-output-and-record-options.test.d.ts.map +1 -0
- package/build/tools/replay-output-and-record-options.test.js +240 -0
- package/build/tools/replay-output-and-record-options.test.js.map +1 -0
- package/build/tools/replay-production-error-shape.test.d.ts +7 -0
- package/build/tools/replay-production-error-shape.test.d.ts.map +1 -0
- package/build/tools/replay-production-error-shape.test.js +93 -0
- package/build/tools/replay-production-error-shape.test.js.map +1 -0
- package/build/tools/replay-rebase.test.d.ts +2 -0
- package/build/tools/replay-rebase.test.d.ts.map +1 -0
- package/build/tools/replay-rebase.test.js +58 -0
- package/build/tools/replay-rebase.test.js.map +1 -0
- package/build/tools/replay-retry-classification.test.d.ts +9 -0
- package/build/tools/replay-retry-classification.test.d.ts.map +1 -0
- package/build/tools/replay-retry-classification.test.js +57 -0
- package/build/tools/replay-retry-classification.test.js.map +1 -0
- package/build/tools/replay-run-registry.d.ts +55 -0
- package/build/tools/replay-run-registry.d.ts.map +1 -0
- package/build/tools/replay-run-registry.js +65 -0
- package/build/tools/replay-run-registry.js.map +1 -0
- package/build/tools/replay-sequence-reload.test.d.ts +2 -0
- package/build/tools/replay-sequence-reload.test.d.ts.map +1 -0
- package/build/tools/replay-sequence-reload.test.js +82 -0
- package/build/tools/replay-sequence-reload.test.js.map +1 -0
- package/build/tools/replay-socket-scope.test.d.ts +2 -0
- package/build/tools/replay-socket-scope.test.d.ts.map +1 -0
- package/build/tools/replay-socket-scope.test.js +75 -0
- package/build/tools/replay-socket-scope.test.js.map +1 -0
- package/build/tools/replay-step-connection.test.d.ts +2 -0
- package/build/tools/replay-step-connection.test.d.ts.map +1 -0
- package/build/tools/replay-step-connection.test.js +292 -0
- package/build/tools/replay-step-connection.test.js.map +1 -0
- package/build/tools/replay-step-timeout.test.d.ts +2 -0
- package/build/tools/replay-step-timeout.test.d.ts.map +1 -0
- package/build/tools/replay-step-timeout.test.js +204 -0
- package/build/tools/replay-step-timeout.test.js.map +1 -0
- package/build/tools/replay-tags.test.d.ts +2 -0
- package/build/tools/replay-tags.test.d.ts.map +1 -0
- package/build/tools/replay-tags.test.js +99 -0
- package/build/tools/replay-tags.test.js.map +1 -0
- package/build/tools/replay-teardown-and-foreach.test.d.ts +13 -0
- package/build/tools/replay-teardown-and-foreach.test.d.ts.map +1 -0
- package/build/tools/replay-teardown-and-foreach.test.js +318 -0
- package/build/tools/replay-teardown-and-foreach.test.js.map +1 -0
- package/build/tools/replay-tool-name-validation.test.d.ts +2 -0
- package/build/tools/replay-tool-name-validation.test.d.ts.map +1 -0
- package/build/tools/replay-tool-name-validation.test.js +280 -0
- package/build/tools/replay-tool-name-validation.test.js.map +1 -0
- package/build/tools/replay-tools.d.ts +288 -0
- package/build/tools/replay-tools.d.ts.map +1 -0
- package/build/tools/replay-tools.js +2994 -0
- package/build/tools/replay-tools.js.map +1 -0
- package/build/tools/request-abort.test.d.ts +2 -0
- package/build/tools/request-abort.test.d.ts.map +1 -0
- package/build/tools/request-abort.test.js +130 -0
- package/build/tools/request-abort.test.js.map +1 -0
- package/build/tools/request-tools.d.ts +56 -0
- package/build/tools/request-tools.d.ts.map +1 -0
- package/build/tools/request-tools.js +174 -0
- package/build/tools/request-tools.js.map +1 -0
- package/build/tools/screenshot-tools.d.ts +135 -0
- package/build/tools/screenshot-tools.d.ts.map +1 -0
- package/build/tools/screenshot-tools.js +636 -0
- package/build/tools/screenshot-tools.js.map +1 -0
- package/build/tools/server-tools.d.ts +88 -0
- package/build/tools/server-tools.d.ts.map +1 -0
- package/build/tools/server-tools.js +485 -0
- package/build/tools/server-tools.js.map +1 -0
- package/build/tools/source-tools.d.ts +46 -0
- package/build/tools/source-tools.d.ts.map +1 -0
- package/build/tools/source-tools.js +47 -0
- package/build/tools/source-tools.js.map +1 -0
- package/build/tools/storage-tools.d.ts +141 -0
- package/build/tools/storage-tools.d.ts.map +1 -0
- package/build/tools/storage-tools.js +894 -0
- package/build/tools/storage-tools.js.map +1 -0
- package/build/tools/storage-tools.test.d.ts +13 -0
- package/build/tools/storage-tools.test.d.ts.map +1 -0
- package/build/tools/storage-tools.test.js +707 -0
- package/build/tools/storage-tools.test.js.map +1 -0
- package/build/tools/tab-tools.d.ts +42 -0
- package/build/tools/tab-tools.d.ts.map +1 -0
- package/build/tools/tab-tools.js +296 -0
- package/build/tools/tab-tools.js.map +1 -0
- package/build/tools/wait-tools.d.ts +85 -0
- package/build/tools/wait-tools.d.ts.map +1 -0
- package/build/tools/wait-tools.js +236 -0
- package/build/tools/wait-tools.js.map +1 -0
- package/build/tools/wait-tools.test.d.ts +9 -0
- package/build/tools/wait-tools.test.d.ts.map +1 -0
- package/build/tools/wait-tools.test.js +193 -0
- package/build/tools/wait-tools.test.js.map +1 -0
- package/build/types.d.ts +133 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +5 -0
- package/build/types.js.map +1 -0
- package/build/ui-verifier.d.ts +88 -0
- package/build/ui-verifier.d.ts.map +1 -0
- package/build/ui-verifier.js +540 -0
- package/build/ui-verifier.js.map +1 -0
- package/build/utils/abort.d.ts +68 -0
- package/build/utils/abort.d.ts.map +1 -0
- package/build/utils/abort.js +159 -0
- package/build/utils/abort.js.map +1 -0
- package/build/utils/abort.test.d.ts +2 -0
- package/build/utils/abort.test.d.ts.map +1 -0
- package/build/utils/abort.test.js +174 -0
- package/build/utils/abort.test.js.map +1 -0
- package/build/utils/modal-detection-core.d.ts +150 -0
- package/build/utils/modal-detection-core.d.ts.map +1 -0
- package/build/utils/modal-detection-core.js +332 -0
- package/build/utils/modal-detection-core.js.map +1 -0
- package/build/utils/modal-detection-core.test.d.ts +5 -0
- package/build/utils/modal-detection-core.test.d.ts.map +1 -0
- package/build/utils/modal-detection-core.test.js +492 -0
- package/build/utils/modal-detection-core.test.js.map +1 -0
- package/build/utils/modal-detector.d.ts +29 -0
- package/build/utils/modal-detector.d.ts.map +1 -0
- package/build/utils/modal-detector.js +281 -0
- package/build/utils/modal-detector.js.map +1 -0
- package/build/utils/modal-dismissal.d.ts +32 -0
- package/build/utils/modal-dismissal.d.ts.map +1 -0
- package/build/utils/modal-dismissal.js +190 -0
- package/build/utils/modal-dismissal.js.map +1 -0
- package/build/utils/port-check.d.ts +31 -0
- package/build/utils/port-check.d.ts.map +1 -0
- package/build/utils/port-check.js +87 -0
- package/build/utils/port-check.js.map +1 -0
- package/build/utils/port-check.test.d.ts +2 -0
- package/build/utils/port-check.test.d.ts.map +1 -0
- package/build/utils/port-check.test.js +66 -0
- package/build/utils/port-check.test.js.map +1 -0
- package/build/utils/selector-resolver.d.ts +66 -0
- package/build/utils/selector-resolver.d.ts.map +1 -0
- package/build/utils/selector-resolver.js +200 -0
- package/build/utils/selector-resolver.js.map +1 -0
- package/build/validation-helpers.d.ts +41 -0
- package/build/validation-helpers.d.ts.map +1 -0
- package/build/validation-helpers.js +236 -0
- package/build/validation-helpers.js.map +1 -0
- package/build/window-sizing.d.ts +26 -0
- package/build/window-sizing.d.ts.map +1 -0
- package/build/window-sizing.js +87 -0
- package/build/window-sizing.js.map +1 -0
- package/build/window-sizing.test.d.ts +8 -0
- package/build/window-sizing.test.d.ts.map +1 -0
- package/build/window-sizing.test.js +113 -0
- package/build/window-sizing.test.js.map +1 -0
- package/docs/README.md +74 -0
- package/docs/automation.md +562 -0
- package/docs/debugging.md +229 -0
- package/docs/installation.md +133 -0
- package/docs/instructions.md +227 -0
- package/docs/mcp-instructions.md +15 -0
- package/docs/message-system.md +327 -0
- package/docs/messages.md +3480 -0
- package/docs/parser-plugins.md +132 -0
- package/docs/replay.md +1411 -0
- package/docs/troubleshooting.md +272 -0
- package/docs/working_with_llm_notes.md +5 -0
- package/package.json +87 -0
- package/skills/devharness/SKILL.md +195 -0
- package/skills/devharness/references/sequences.md +613 -0
- package/skills/devharness/references/tool-categories.md +99 -0
|
@@ -0,0 +1,2994 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command Replay Tools - Action router for sequence recording and playback
|
|
3
|
+
*/
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { selectSuiteFiles, sequenceFolders } from '../helpers/sequence-tree.js';
|
|
6
|
+
import { createTool } from '../validation-helpers.js';
|
|
7
|
+
import { createSuccessResponse, createErrorResponse } from '../messages.js';
|
|
8
|
+
import { showReplayOverlay } from '../interaction-recorder.js';
|
|
9
|
+
import { getIssue } from '../issue-tracker.js';
|
|
10
|
+
import { deriveConnectionReference, sanitizeReference } from '../reference-validator.js';
|
|
11
|
+
import { normalizeProfileName } from '../chrome-launcher.js';
|
|
12
|
+
import { runRegistry } from './replay-run-registry.js';
|
|
13
|
+
import { loadSequence, rebaseSequence, analyzeSequenceConnections, extractConnectionFromSequence, sequenceNeedsConnection, ensureConnection, navigateToStartUrl, executeSteps, executeSequenceWithPause, getDebugState, setReplayCursorCallbacks, injectReplayCursor, showClickEffect, showKeyPress, removeReplayCursor, autoLaunchChrome, commandNeedsBrowserConnection, analyzeRecordedStepConnections, commandTakesInjectedConnection, normalizeStepConnections, sanitizeConnectionMap, parseConnectionList, validateConditionSyntax, } from './replay-executor.js';
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// Step tool-name validation (bug-010)
|
|
16
|
+
// =============================================================================
|
|
17
|
+
/**
|
|
18
|
+
* Step "tools" that the replay executor handles itself instead of dispatching
|
|
19
|
+
* through the MCP tool map (see replay-executor.ts). These are always valid
|
|
20
|
+
* step names even though they are not registered tools.
|
|
21
|
+
*/
|
|
22
|
+
const VIRTUAL_STEP_TOOLS = new Set(['conditional', 'forEach']);
|
|
23
|
+
/** Levenshtein distance, used only to suggest a likely intended tool name. */
|
|
24
|
+
function editDistance(a, b) {
|
|
25
|
+
const prev = new Array(b.length + 1);
|
|
26
|
+
for (let j = 0; j <= b.length; j++)
|
|
27
|
+
prev[j] = j;
|
|
28
|
+
for (let i = 1; i <= a.length; i++) {
|
|
29
|
+
let diag = prev[0];
|
|
30
|
+
prev[0] = i;
|
|
31
|
+
for (let j = 1; j <= b.length; j++) {
|
|
32
|
+
const tmp = prev[j];
|
|
33
|
+
prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
34
|
+
diag = tmp;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return prev[b.length];
|
|
38
|
+
}
|
|
39
|
+
function suggestToolName(name, knownToolNames) {
|
|
40
|
+
const lower = name.toLowerCase();
|
|
41
|
+
let best;
|
|
42
|
+
for (const known of knownToolNames) {
|
|
43
|
+
const distance = editDistance(lower, known.toLowerCase());
|
|
44
|
+
if (distance <= 3 && (!best || distance < best.distance)) {
|
|
45
|
+
best = { name: known, distance };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return best?.name;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Find sequence steps whose `tool` is not a registered tool name.
|
|
52
|
+
*
|
|
53
|
+
* Only NAMES are validated - step params are deliberately not checked against
|
|
54
|
+
* the tools' zod schemas, because params legitimately contain interpolation
|
|
55
|
+
* tokens ({{var:...}}, {{timestamp}}) that are only substituted at run time,
|
|
56
|
+
* so a number-typed field can validly hold a string token at rest.
|
|
57
|
+
*/
|
|
58
|
+
export function findUnknownStepTools(commands, knownToolNames) {
|
|
59
|
+
const known = new Set(knownToolNames);
|
|
60
|
+
const unknown = [];
|
|
61
|
+
commands.forEach((cmd, i) => {
|
|
62
|
+
const name = typeof cmd?.tool === 'string' ? cmd.tool : String(cmd?.tool);
|
|
63
|
+
if (known.has(name) || VIRTUAL_STEP_TOOLS.has(name))
|
|
64
|
+
return;
|
|
65
|
+
unknown.push({ step: i + 1, tool: name, suggestion: suggestToolName(name, knownToolNames) });
|
|
66
|
+
});
|
|
67
|
+
return unknown;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Build the error response for a sequence containing unknown tool names.
|
|
71
|
+
* Uses a plain response rather than a message template because there is no
|
|
72
|
+
* template for this case yet (see report for the suggested SEQUENCE_UNKNOWN_TOOL entry).
|
|
73
|
+
*/
|
|
74
|
+
function unknownStepToolsError(action, sequenceName, unknown, knownToolNames) {
|
|
75
|
+
const plural = unknown.length === 1 ? '' : 's';
|
|
76
|
+
const lines = [
|
|
77
|
+
`Error: Sequence "${sequenceName}" references ${unknown.length} unknown tool name${plural}`,
|
|
78
|
+
`The "${action}" action was rejected before any step ran, so no browser state was changed.`,
|
|
79
|
+
'',
|
|
80
|
+
];
|
|
81
|
+
for (const u of unknown) {
|
|
82
|
+
lines.push(`- Step ${u.step}: \`${u.tool}\` is not a known tool${u.suggestion ? ` - did you mean \`${u.suggestion}\`?` : ''}`);
|
|
83
|
+
}
|
|
84
|
+
lines.push('');
|
|
85
|
+
lines.push('**Fix:** correct the `tool` field on the listed step(s).');
|
|
86
|
+
lines.push(`**Known tools:** ${knownToolNames.slice().sort().join(', ')}`);
|
|
87
|
+
return { content: [{ type: 'text', text: lines.join('\n') }], isError: true };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Validate every step's tool name in a sequence. Returns an error response when
|
|
91
|
+
* any name is unknown, or null when the sequence is fine (including when no
|
|
92
|
+
* tool-name provider was supplied, which keeps validation opt-in).
|
|
93
|
+
*/
|
|
94
|
+
function validateSequenceToolNames(sequence, action, getKnownToolNames) {
|
|
95
|
+
if (!getKnownToolNames)
|
|
96
|
+
return null;
|
|
97
|
+
const knownToolNames = getKnownToolNames();
|
|
98
|
+
if (!knownToolNames || knownToolNames.length === 0)
|
|
99
|
+
return null;
|
|
100
|
+
const unknown = findUnknownStepTools(sequence.commands ?? [], knownToolNames);
|
|
101
|
+
if (unknown.length === 0)
|
|
102
|
+
return null;
|
|
103
|
+
return unknownStepToolsError(action, sequence.name, unknown, knownToolNames);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Handle loadSequence error result - creates proper error response with template variables
|
|
107
|
+
*/
|
|
108
|
+
function handleLoadSequenceError(result, action) {
|
|
109
|
+
return createErrorResponse(result.errorCode, {
|
|
110
|
+
action,
|
|
111
|
+
message: result.error,
|
|
112
|
+
...result.templateVars
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
import { formatExecutionResults, formatPausedResponse, formatDebugState, formatBreakpointHit, formatClickValidationFailure, extractTextVariables, formatVariablePrompt, formatHistory, formatSequenceCreated, formatSequenceList, formatSequenceDetails, formatSavedSequencesList, formatActiveStatus, formatStepResults, formatInsertPrompt, formatInsertResult, formatConditionalAdded, formatDeclarations, formatEventsForReview, } from './replay-formatters.js';
|
|
116
|
+
import { readHistoryLines, getHistoryFilePath } from '../debug-logger.js';
|
|
117
|
+
import { startRecording, eventsToCommands, generateCondensedTimeline, isCommentEvent, } from '../interaction-recorder.js';
|
|
118
|
+
import { addIssue, initializeTracker, saveIssueSequence, } from '../issue-tracker.js';
|
|
119
|
+
import { configManager } from '../config.js';
|
|
120
|
+
// =============================================================================
|
|
121
|
+
// Schema Definition
|
|
122
|
+
// =============================================================================
|
|
123
|
+
const replaySchema = z.object({
|
|
124
|
+
action: z.enum([
|
|
125
|
+
'history', 'create', 'list', 'get', 'delete',
|
|
126
|
+
'export', 'load', 'listSaved', 'deleteSaved',
|
|
127
|
+
'run', 'runAll', 'step', 'finish', 'insert', 'addConditional', 'declare', 'status', 'cancel',
|
|
128
|
+
'repeat', 'runFromLog',
|
|
129
|
+
'recordInteraction'
|
|
130
|
+
]),
|
|
131
|
+
limit: z.number().optional().describe('Max items (default:50)'),
|
|
132
|
+
name: z.string().optional(),
|
|
133
|
+
description: z.string().optional(),
|
|
134
|
+
expectedOutcome: z.string().optional(),
|
|
135
|
+
startUrl: z.string().optional().describe('create: sequence start URL. run: replace the stored startUrl for this run only (e.g. a freshly minted link)'),
|
|
136
|
+
baseUrl: z.string().optional().describe('run: retarget the sequence at another deployment — every absolute URL (startUrl + command params) keeps its path/query but takes this origin. Not preserved across a mid-run pause/step resume'),
|
|
137
|
+
indices: z.array(z.number()).optional().describe('Command indices'),
|
|
138
|
+
lines: z.array(z.number()).optional().describe('Log line numbers'),
|
|
139
|
+
sequenceId: z.string().optional(),
|
|
140
|
+
runId: z.string().optional().describe('status/cancel: address a specific background run by the id that run returned'),
|
|
141
|
+
wait: z.boolean().optional().describe('run: block until the run completes and return the full result (pre-0.7 behaviour). Default false: return a runId immediately and execute in the background'),
|
|
142
|
+
global: z.boolean().optional().describe('Use ~/.cdp-tools/'),
|
|
143
|
+
format: z.enum(['sequence', 'playwright', 'puppeteer']).optional(),
|
|
144
|
+
filename: z.string().optional(),
|
|
145
|
+
intoHistory: z.boolean().optional(),
|
|
146
|
+
connectionReason: z.string().optional(),
|
|
147
|
+
requiredConnections: z.array(z.object({
|
|
148
|
+
reference: z.string().describe('Reference the steps use, e.g. "duo-member-two"'),
|
|
149
|
+
profile: z.string().optional().describe('Named persistent Chrome profile to come up on (launchChrome({ profile })). The durable identity: its storage survives between runs, so a device enrolled once stays enrolled'),
|
|
150
|
+
url: z.string().optional().describe("Opened on launch (defaults to the sequence's startUrl)"),
|
|
151
|
+
role: z.string().optional().describe('Why this browser exists, shown in the run summary'),
|
|
152
|
+
forceNewInstance: z.boolean().optional().describe('A distinct process rather than a tab. Default true, but false when profile is set - only one live Chrome may hold a profile'),
|
|
153
|
+
}).strict()).optional().describe('declare: the browsers this sequence needs. Replaces the whole list; [] clears it'),
|
|
154
|
+
tags: z.array(z.string()).optional().describe("declare: what kind of sequence this is, e.g. ['ui'] or ['contract','slow'] - replaces the whole list, [] clears it. runAll: run only sequences carrying at least one of these tags; the summary reports the split either way"),
|
|
155
|
+
requiredSockets: z.array(z.string()).optional().describe("declare: URL substrings of the WebSockets this sequence's assertions ride on, e.g. ['/api/sync/socket']. Match the app's own path, not the origin, so it survives baseUrl. Replaces the whole list; [] clears it"),
|
|
156
|
+
connections: z.record(z.string()).optional().describe("run: rebind a multi-connection sequence's recorded references onto this session - { \"<recorded reference>\": \"<reference here>\" }. Only needed when steps carry their own connectionReason (replay({action:'get', outputFormat:'commands'}) shows which)"),
|
|
157
|
+
record: z.boolean().optional(),
|
|
158
|
+
variables: z.record(z.string()).optional(),
|
|
159
|
+
stepTimeout: z.number().optional().describe('Per-step ms (default 30000). A step exceeding min(stepTimeout, remaining totalTimeout) fails the run at that step. wait steps are exempt (own timeoutMs) but still capped by totalTimeout'),
|
|
160
|
+
totalTimeout: z.number().optional().describe('Total ms'),
|
|
161
|
+
startFrom: z.number().optional().describe('Start step (1-indexed)'),
|
|
162
|
+
stepTo: z.number().optional().describe('Pause after step'),
|
|
163
|
+
stepCount: z.number().optional().describe('Steps to run'),
|
|
164
|
+
insertIndices: z.array(z.number()).optional(),
|
|
165
|
+
insertAfterStep: z.number().optional(),
|
|
166
|
+
condition: z.string().optional().describe("addConditional: the guard, e.g. '{{selector:.cookie-banner}}' or '{{!localStorage:token}}'"),
|
|
167
|
+
thenSequence: z.string().optional().describe('addConditional: name of the sequence to run when the condition holds'),
|
|
168
|
+
comment: z.string().optional().describe('addConditional: note stored on the step'),
|
|
169
|
+
overwrite: z.boolean().optional(),
|
|
170
|
+
newName: z.string().optional(),
|
|
171
|
+
showOverlay: z.boolean().optional(),
|
|
172
|
+
simplifyEvents: z.boolean().optional().describe('recordInteraction: collapse noisy raw events (default:true)'),
|
|
173
|
+
includeHovers: z.boolean().optional().describe('recordInteraction: keep mousemove steps (default:false)'),
|
|
174
|
+
outputFormat: z.enum(['events', 'commands', 'review', 'puppeteer', 'playwright']).optional()
|
|
175
|
+
.describe('get: commands|playwright|puppeteer. recordInteraction: events|commands|review (JSON dump, or a human-readable event walkthrough, appended to the summary)'),
|
|
176
|
+
preferCoordinates: z.boolean().optional().describe('recordInteraction: emit x,y clicks instead of selectors (default:false)'),
|
|
177
|
+
preferSelectors: z.boolean().optional().describe('recordInteraction: emit selector clicks even for canvas; wins over preferCoordinates (default:false)'),
|
|
178
|
+
issueId: z.number().optional(),
|
|
179
|
+
issueType: z.enum(['bug', 'feature']).optional(),
|
|
180
|
+
issueTitle: z.string().optional(),
|
|
181
|
+
showReplayOverlay: z.boolean().optional(),
|
|
182
|
+
showAll: z.boolean().optional().describe('Show all sequences including completed/fixed issues'),
|
|
183
|
+
requireSockets: z.boolean().optional().describe("run/runAll: fail the run if any WebSocket CLOSED or hit frame errors while it executed. Diffed against the start, so a socket already down is not blamed on this sequence, and it catches a drop that recovered before the last step - which a final assertion cannot see. Usually unnecessary: a sequence that sets `requiredSockets` (URL substrings of the sockets its assertions ride on) is checked without asking, and that check also fails when a declared socket is missing or never opened, which no closure count can detect"),
|
|
184
|
+
strict: z.enum(['errors', 'warnings']).optional().describe("run/runAll: fail the run when it PRODUCES console output - 'errors' fails on new console errors, 'warnings' also fails on new warnings. Counted per connection and diffed against the start of the run, so pre-existing noise is not blamed on this sequence. A sequence can be functionally correct and still be logging; strict is how you separate those questions"),
|
|
185
|
+
folder: z.string().optional().describe("runAll: sequences subfolder to run, relative to the sequences dir (e.g. 'spine'). Omit to run every sequence outside folders whose name starts with '_'. The whole tree is always LOADED first so name references (a conditional's then, a forEach's do) resolve wherever the helper lives"),
|
|
186
|
+
continueOnFailure: z.boolean().optional().describe('runAll: keep going after a sequence fails and report every result (default true). false stops at the first failure'),
|
|
187
|
+
killChromeOnFinish: z.boolean().optional().describe("run: after finishing (skipped on pause/abort), kill the browsers this run owns - its own connection plus any a launchChrome step actually created. A step that reached an already-bound reference only borrowed that browser and it is left running, so an instance you launched yourself survives. Also skipped for any browser whose port another live connection shares (a launchChrome step usually opens a tab in the same instance), and the run reports which connection kept it alive."),
|
|
188
|
+
}).strict();
|
|
189
|
+
async function handleHistory(args, recorder) {
|
|
190
|
+
const limit = args.limit || 50;
|
|
191
|
+
const history = recorder.getHistory(limit);
|
|
192
|
+
const stats = recorder.getStats();
|
|
193
|
+
// Mark history as viewed if we're in a paused sequence (enables insert)
|
|
194
|
+
if (recorder.getActiveSequence()) {
|
|
195
|
+
recorder.markHistoryViewed();
|
|
196
|
+
}
|
|
197
|
+
return { content: [{ type: 'text', text: formatHistory(history, stats.historyCount) }] };
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Decide whether an explicit batch-level `connectionReason` may replace the
|
|
201
|
+
* connections the commands were recorded against (`repeat`, `runFromLog`).
|
|
202
|
+
*
|
|
203
|
+
* Yes for a single-connection batch - that is what the parameter has always
|
|
204
|
+
* meant, and silently ignoring it (which is what "never overwrite a recorded
|
|
205
|
+
* connection" amounted to once history started retaining them) breaks a
|
|
206
|
+
* documented knob with no signal. No for a batch spanning several browsers:
|
|
207
|
+
* there is no honest single answer, and picking one reproduces bug-018.
|
|
208
|
+
*/
|
|
209
|
+
function resolveBatchOverride(commands, requested, action) {
|
|
210
|
+
if (!requested)
|
|
211
|
+
return { replaceRecorded: false };
|
|
212
|
+
const refs = new Set(commands
|
|
213
|
+
.filter(c => typeof c.params.connectionReason === 'string' && c.params.connectionReason.trim())
|
|
214
|
+
.map(c => sanitizeReference(c.params.connectionReason)));
|
|
215
|
+
if (refs.size > 1) {
|
|
216
|
+
return {
|
|
217
|
+
error: createErrorResponse('INVALID_PARAMETER', {
|
|
218
|
+
parameter: 'connectionReason',
|
|
219
|
+
value: requested,
|
|
220
|
+
message: `These commands were recorded against ${refs.size} different connections (${[...refs].join(', ')}), ` +
|
|
221
|
+
`so a single connectionReason cannot apply to all of them - running them in one browser would report success without ever using the second. ` +
|
|
222
|
+
`Omit connectionReason to replay each command against the connection it was recorded with, or ${action} the commands for one connection at a time.`
|
|
223
|
+
})
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return { replaceRecorded: true };
|
|
227
|
+
}
|
|
228
|
+
async function handleRepeat(args, recorder, executeToolCall) {
|
|
229
|
+
if (!args.indices || args.indices.length === 0) {
|
|
230
|
+
return createErrorResponse('MISSING_PARAMETER', {
|
|
231
|
+
action: 'repeat',
|
|
232
|
+
missing: 'indices',
|
|
233
|
+
message: 'The "repeat" action requires an "indices" array with command indices to execute'
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
// Get commands from history
|
|
237
|
+
const commands = [];
|
|
238
|
+
for (const idx of args.indices) {
|
|
239
|
+
const cmd = recorder.getCommand(idx);
|
|
240
|
+
if (!cmd) {
|
|
241
|
+
return createErrorResponse('INVALID_INDICES', {
|
|
242
|
+
message: `Command index ${idx} not found in history. Use replay({ action: "history" }) to see available commands.`
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
commands.push({ tool: cmd.tool, params: cmd.params, index: idx });
|
|
246
|
+
}
|
|
247
|
+
// A command replays against the connection it was RECORDED with when it has one
|
|
248
|
+
// (bug-018) - repeating a batch that spans two browsers used to resolve one
|
|
249
|
+
// connection for the whole batch and stamp it onto every command, silently
|
|
250
|
+
// running both browsers' steps in one. Only commands with no recorded
|
|
251
|
+
// connection need a batch-level one. No `connections` mapping here: repeat
|
|
252
|
+
// replays from this session's own history, so the recorded references are the
|
|
253
|
+
// live ones by construction.
|
|
254
|
+
const needsConnection = commands.some(cmd => commandNeedsBrowserConnection(cmd) && !cmd.params.connectionReason);
|
|
255
|
+
let connectionReason = args.connectionReason;
|
|
256
|
+
// An explicitly passed connectionReason must still mean "run these against
|
|
257
|
+
// that connection" - history now retains the recorded one for every browser
|
|
258
|
+
// command, so honouring only bare commands turned this documented parameter
|
|
259
|
+
// into a silent no-op. It can only be honoured when the batch is
|
|
260
|
+
// single-connection; overriding a two-browser batch is the collapse bug-018
|
|
261
|
+
// is about, so that combination is refused rather than silently picking one.
|
|
262
|
+
const override = resolveBatchOverride(commands, args.connectionReason, 'repeat');
|
|
263
|
+
if ('error' in override)
|
|
264
|
+
return override.error;
|
|
265
|
+
// Try to extract connection from commands if not provided
|
|
266
|
+
if (!connectionReason && needsConnection) {
|
|
267
|
+
// Check if any command creates a connection (launchChrome, connectDebugger)
|
|
268
|
+
const launchCmd = commands.find(c => c.tool === 'launchChrome' || c.tool === 'connectDebugger');
|
|
269
|
+
if (launchCmd && launchCmd.params.reference) {
|
|
270
|
+
connectionReason = launchCmd.params.reference;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (!connectionReason && needsConnection) {
|
|
274
|
+
return createErrorResponse('MISSING_PARAMETER', {
|
|
275
|
+
action: 'repeat',
|
|
276
|
+
missing: 'connectionReason',
|
|
277
|
+
message: 'These commands require a browser connection. Provide connectionReason parameter.'
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
// Execute commands
|
|
281
|
+
const results = [];
|
|
282
|
+
const startTime = Date.now();
|
|
283
|
+
for (const cmd of commands) {
|
|
284
|
+
try {
|
|
285
|
+
// Fill in a batch-level connection where the command has none, and replace
|
|
286
|
+
// the recorded one only when the caller explicitly asked to retarget a
|
|
287
|
+
// single-connection batch (see resolveBatchOverride).
|
|
288
|
+
const params = { ...cmd.params };
|
|
289
|
+
if (connectionReason && commandNeedsBrowserConnection(cmd) &&
|
|
290
|
+
(override.replaceRecorded || !params.connectionReason)) {
|
|
291
|
+
params.connectionReason = connectionReason;
|
|
292
|
+
}
|
|
293
|
+
await executeToolCall(cmd.tool, params);
|
|
294
|
+
results.push({ index: cmd.index, tool: cmd.tool, success: true });
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
results.push({ index: cmd.index, tool: cmd.tool, success: false, error: error.message || String(error) });
|
|
298
|
+
// Stop on first error
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const durationMs = Date.now() - startTime;
|
|
303
|
+
const successful = results.filter(r => r.success).length;
|
|
304
|
+
const failed = results.filter(r => !r.success).length;
|
|
305
|
+
// Format response
|
|
306
|
+
let response = failed > 0
|
|
307
|
+
? `**Repeat failed** at command #${results.find(r => !r.success)?.index}`
|
|
308
|
+
: `**Repeated ${successful} command${successful !== 1 ? 's' : ''}** in ${(durationMs / 1000).toFixed(1)}s`;
|
|
309
|
+
response += '\n';
|
|
310
|
+
results.forEach(r => {
|
|
311
|
+
const icon = r.success ? '✓' : '✗';
|
|
312
|
+
response += `\n#${r.index}. **${r.tool}** ${icon}`;
|
|
313
|
+
if (r.error) {
|
|
314
|
+
response += ` - ${r.error}`;
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
return { content: [{ type: 'text', text: response }] };
|
|
318
|
+
}
|
|
319
|
+
async function handleRunFromLog(args, executeToolCall) {
|
|
320
|
+
if (!args.lines || args.lines.length === 0) {
|
|
321
|
+
return createErrorResponse('MISSING_PARAMETER', {
|
|
322
|
+
action: 'runFromLog',
|
|
323
|
+
missing: 'lines',
|
|
324
|
+
message: `The "runFromLog" action requires a "lines" array with line numbers to execute from history.log (1-indexed, line 1 is most recent). File: ${getHistoryFilePath()}`
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
// Read commands from history.log file
|
|
328
|
+
const lineResults = await readHistoryLines(args.lines);
|
|
329
|
+
// Check for errors
|
|
330
|
+
const errors = lineResults.filter((r) => 'error' in r);
|
|
331
|
+
if (errors.length > 0) {
|
|
332
|
+
return createErrorResponse('INVALID_LINES', {
|
|
333
|
+
message: `Some lines could not be read from history.log:\n${errors.map(e => ` Line ${e.line}: ${e.error}`).join('\n')}`,
|
|
334
|
+
file: getHistoryFilePath()
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
const commands = lineResults;
|
|
338
|
+
// As in repeat: a logged command keeps the connection it was recorded with, so
|
|
339
|
+
// only the bare ones need a batch-level connection (bug-018).
|
|
340
|
+
const needsConnection = commands.some(cmd => commandNeedsBrowserConnection(cmd) && !cmd.params.connectionReason);
|
|
341
|
+
let connectionReason = args.connectionReason;
|
|
342
|
+
// Same rule as repeat: an explicit connectionReason retargets a
|
|
343
|
+
// single-connection batch, and is refused for a multi-connection one.
|
|
344
|
+
const override = resolveBatchOverride(commands, args.connectionReason, 'runFromLog');
|
|
345
|
+
if ('error' in override)
|
|
346
|
+
return override.error;
|
|
347
|
+
// Try to extract connection from commands if not provided
|
|
348
|
+
if (!connectionReason && needsConnection) {
|
|
349
|
+
const launchCmd = commands.find(c => c.tool === 'launchChrome' || c.tool === 'connectDebugger');
|
|
350
|
+
if (launchCmd && launchCmd.params.reference) {
|
|
351
|
+
connectionReason = launchCmd.params.reference;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (!connectionReason && needsConnection) {
|
|
355
|
+
return createErrorResponse('MISSING_PARAMETER', {
|
|
356
|
+
action: 'runFromLog',
|
|
357
|
+
missing: 'connectionReason',
|
|
358
|
+
message: 'These commands require a browser connection. Provide connectionReason parameter.'
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
// Execute commands
|
|
362
|
+
const results = [];
|
|
363
|
+
const startTime = Date.now();
|
|
364
|
+
for (const cmd of commands) {
|
|
365
|
+
try {
|
|
366
|
+
const params = { ...cmd.params };
|
|
367
|
+
if (connectionReason && commandNeedsBrowserConnection(cmd) &&
|
|
368
|
+
(override.replaceRecorded || !params.connectionReason)) {
|
|
369
|
+
params.connectionReason = connectionReason;
|
|
370
|
+
}
|
|
371
|
+
await executeToolCall(cmd.tool, params);
|
|
372
|
+
results.push({ line: cmd.line, tool: cmd.tool, success: true });
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
results.push({ line: cmd.line, tool: cmd.tool, success: false, error: error.message || String(error) });
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const durationMs = Date.now() - startTime;
|
|
380
|
+
const successful = results.filter(r => r.success).length;
|
|
381
|
+
const failed = results.filter(r => !r.success).length;
|
|
382
|
+
let response = failed > 0
|
|
383
|
+
? `**runFromLog failed** at line ${results.find(r => !r.success)?.line}`
|
|
384
|
+
: `**Executed ${successful} command${successful !== 1 ? 's' : ''} from history.log** in ${(durationMs / 1000).toFixed(1)}s`;
|
|
385
|
+
response += '\n';
|
|
386
|
+
results.forEach(r => {
|
|
387
|
+
const icon = r.success ? '✓' : '✗';
|
|
388
|
+
response += `\nL${r.line}. **${r.tool}** ${icon}`;
|
|
389
|
+
if (r.error) {
|
|
390
|
+
response += ` - ${r.error}`;
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
return { content: [{ type: 'text', text: response }] };
|
|
394
|
+
}
|
|
395
|
+
async function handleCreate(args, recorder, getKnownToolNames) {
|
|
396
|
+
if (!args.name) {
|
|
397
|
+
return createErrorResponse('MISSING_PARAMETER', {
|
|
398
|
+
action: 'create',
|
|
399
|
+
missing: 'name',
|
|
400
|
+
message: 'The "create" action requires a "name" parameter'
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
if (!args.indices || args.indices.length === 0) {
|
|
404
|
+
return createErrorResponse('MISSING_PARAMETER', {
|
|
405
|
+
action: 'create',
|
|
406
|
+
missing: 'indices',
|
|
407
|
+
message: 'The "create" action requires an "indices" array with at least one command index'
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
// Reject unknown tool names up front rather than failing mid-run (bug-010).
|
|
411
|
+
// The check runs inside createSequence, on the candidate, BEFORE it replaces any
|
|
412
|
+
// same-named sequence in memory - otherwise a bad create would delete the user's
|
|
413
|
+
// good sequence and then reject the new one, leaving them with neither.
|
|
414
|
+
let invalid = null;
|
|
415
|
+
const sequence = await recorder.createSequence(args.name, args.indices, {
|
|
416
|
+
description: args.description,
|
|
417
|
+
expectedOutcome: args.expectedOutcome,
|
|
418
|
+
startUrl: args.startUrl,
|
|
419
|
+
validate: (candidate) => {
|
|
420
|
+
invalid = validateSequenceToolNames(candidate, 'create', getKnownToolNames);
|
|
421
|
+
return invalid === null;
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
if (invalid)
|
|
425
|
+
return invalid;
|
|
426
|
+
if (!sequence) {
|
|
427
|
+
return createErrorResponse('INVALID_INDICES', {
|
|
428
|
+
message: 'One or more command indices are invalid. Use replay({ action: "history" }) to see available commands.'
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
// Recorded steps keep the connection they were driven against (bug-018). Hoist
|
|
432
|
+
// it back off when the whole sequence shares one, so the sequence stays
|
|
433
|
+
// portable and a run-level connectionReason still retargets it; keep it
|
|
434
|
+
// per-step only where the sequence genuinely spans connections.
|
|
435
|
+
const normalized = normalizeStepConnections(sequence.commands);
|
|
436
|
+
sequence.commands = normalized.commands;
|
|
437
|
+
// Remember what was hoisted - `insert` needs it to tell a same-browser insert
|
|
438
|
+
// from a cross-browser one (see handleInsert).
|
|
439
|
+
if (normalized.hoisted)
|
|
440
|
+
sequence.recordedConnection = normalized.hoisted;
|
|
441
|
+
return { content: [{ type: 'text', text: formatSequenceCreated(sequence) + formatConnectionNote(normalized) }] };
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Re-stamp the connection that `create` hoisted off the steps, so a merged
|
|
445
|
+
* command array is fully explicit about which browser each step belongs to.
|
|
446
|
+
* Without this a sequence's own bare steps read as "ambiguous" the moment
|
|
447
|
+
* anything connection-bearing is spliced in.
|
|
448
|
+
*/
|
|
449
|
+
function rehydrateStepConnections(sequence) {
|
|
450
|
+
const recorded = sequence.recordedConnection;
|
|
451
|
+
if (!recorded)
|
|
452
|
+
return sequence.commands;
|
|
453
|
+
return sequence.commands.map(cmd => commandTakesInjectedConnection(cmd) && !cmd.params.connectionReason
|
|
454
|
+
? { ...cmd, params: { ...cmd.params, connectionReason: recorded } }
|
|
455
|
+
: cmd);
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* What `create`/`insert` did with the recorded per-step connections, and what the
|
|
459
|
+
* user has to do about it on `run`. A multi-connection sequence is only portable
|
|
460
|
+
* if its references are rebound, and an ambiguous ("mixed") recording is worth
|
|
461
|
+
* saying out loud rather than guessing at.
|
|
462
|
+
*/
|
|
463
|
+
function formatConnectionNote(normalized) {
|
|
464
|
+
const { analysis, hoisted } = normalized;
|
|
465
|
+
const notes = [];
|
|
466
|
+
if (hoisted) {
|
|
467
|
+
return `\n\n**Connection:** every step ran against \`${hoisted}\`, so it was hoisted off the steps` +
|
|
468
|
+
` - the sequence is portable and \`replay({ action: 'run', connectionReason: '<other>' })\` retargets it.`;
|
|
469
|
+
}
|
|
470
|
+
if (analysis.multiConnection) {
|
|
471
|
+
notes.push(`\n\n**Multi-connection sequence:** steps keep their own connections (${analysis.references.map(r => `\`${r}\``).join(', ')}),` +
|
|
472
|
+
` so the recorded interleaving is reproduced instead of collapsing into one browser.` +
|
|
473
|
+
` A run-level \`connectionReason\` does NOT override them; in another session rebind them with` +
|
|
474
|
+
` \`replay({ action: 'run', name: '...', connections: { ${analysis.references.map(r => `"${r}": "<reference here>"`).join(', ')} } })\`.` +
|
|
475
|
+
` A reference that doesn't exist at run time fails that step rather than falling back.`);
|
|
476
|
+
}
|
|
477
|
+
// NOT an else-if. A sequence can be both, and that combination is the most
|
|
478
|
+
// dangerous one: bare steps in a two-browser sequence take whatever the
|
|
479
|
+
// run-level connection happens to be, so the same sequence sends them to a
|
|
480
|
+
// different browser depending on how it is run - silently, and green either
|
|
481
|
+
// way. Returning early on multiConnection used to make this warning
|
|
482
|
+
// unreachable in exactly the case that needs it.
|
|
483
|
+
if (analysis.mixed) {
|
|
484
|
+
notes.push(`\n\n**${analysis.multiConnection ? 'Some steps name no connection' : 'Mixed connections'}:** ` +
|
|
485
|
+
`steps naming ${analysis.references.map(r => `\`${r}\``).join(', ')} are pinned, but other browser steps name none` +
|
|
486
|
+
` (they ran against whichever connection was active at record time, which is not recorded).` +
|
|
487
|
+
` Those bare steps take the run-level connection, so ${analysis.multiConnection
|
|
488
|
+
? `they land in a DIFFERENT browser depending on the run-level \`connectionReason\` - and the run still reports success either way.`
|
|
489
|
+
: `a run-level \`connectionReason\` retargets them while the named steps stay put.`}` +
|
|
490
|
+
` Re-record passing \`connectionReason\` on every step to make this deterministic.`);
|
|
491
|
+
}
|
|
492
|
+
return notes.join('');
|
|
493
|
+
}
|
|
494
|
+
async function handleList(recorder) {
|
|
495
|
+
const sequences = recorder.listSequences();
|
|
496
|
+
return { content: [{ type: 'text', text: formatSequenceList(sequences) }] };
|
|
497
|
+
}
|
|
498
|
+
async function handleGet(args, recorder) {
|
|
499
|
+
// Use loadSequence to support both name (disk) and sequenceId (memory)
|
|
500
|
+
const loadResult = await loadSequence({ name: args.name, sequenceId: args.sequenceId }, recorder);
|
|
501
|
+
if (!loadResult.success) {
|
|
502
|
+
return handleLoadSequenceError(loadResult, 'get');
|
|
503
|
+
}
|
|
504
|
+
const sequence = loadResult.sequence;
|
|
505
|
+
// Raw input events are only ever held in memory during recordInteraction -
|
|
506
|
+
// a stored sequence keeps the converted commands, not the events. Say so
|
|
507
|
+
// instead of silently returning the detail view.
|
|
508
|
+
if (args.outputFormat === 'events') {
|
|
509
|
+
return createErrorResponse('INVALID_PARAMETER', {
|
|
510
|
+
parameter: 'outputFormat',
|
|
511
|
+
value: 'events',
|
|
512
|
+
message: 'A stored sequence holds commands, not raw input events. Use outputFormat: "commands" here, or outputFormat: "events" on action "recordInteraction" to dump the raw events of a live recording.'
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
// 'review' renders raw input events too, so it has the same problem as
|
|
516
|
+
// 'events' - say so instead of silently returning the detail view.
|
|
517
|
+
if (args.outputFormat === 'review') {
|
|
518
|
+
return createErrorResponse('INVALID_PARAMETER', {
|
|
519
|
+
parameter: 'outputFormat',
|
|
520
|
+
value: 'review',
|
|
521
|
+
message: 'The review walkthrough renders raw input events, and a stored sequence holds commands, not events. Use outputFormat: "commands" here, or outputFormat: "review" on action "recordInteraction" to review the events of a live recording.'
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
if (args.outputFormat === 'commands') {
|
|
525
|
+
let output = `**${sequence.name} - Commands (JSON)**\n\n`;
|
|
526
|
+
output += '```json\n';
|
|
527
|
+
output += JSON.stringify(sequence.commands, null, 2);
|
|
528
|
+
output += '\n```';
|
|
529
|
+
return { content: [{ type: 'text', text: output }] };
|
|
530
|
+
}
|
|
531
|
+
// Check if output format is specified for code export
|
|
532
|
+
if (args.outputFormat === 'playwright') {
|
|
533
|
+
const code = generatePlaywrightCode(sequence.commands, sequence.startUrl);
|
|
534
|
+
let output = `**${sequence.name} - Playwright Code**\n\n`;
|
|
535
|
+
output += '```typescript\n';
|
|
536
|
+
output += code;
|
|
537
|
+
output += '\n```';
|
|
538
|
+
return { content: [{ type: 'text', text: output }] };
|
|
539
|
+
}
|
|
540
|
+
if (args.outputFormat === 'puppeteer') {
|
|
541
|
+
const code = generatePuppeteerCode(sequence.commands, sequence.startUrl);
|
|
542
|
+
let output = `**${sequence.name} - Puppeteer Code**\n\n`;
|
|
543
|
+
output += '```javascript\n';
|
|
544
|
+
output += code;
|
|
545
|
+
output += '\n```';
|
|
546
|
+
return { content: [{ type: 'text', text: output }] };
|
|
547
|
+
}
|
|
548
|
+
return { content: [{ type: 'text', text: formatSequenceDetails(sequence) }] };
|
|
549
|
+
}
|
|
550
|
+
async function handleDelete(args, recorder) {
|
|
551
|
+
// Use loadSequence to support both name and sequenceId
|
|
552
|
+
const loadResult = await loadSequence({ name: args.name, sequenceId: args.sequenceId }, recorder);
|
|
553
|
+
if (!loadResult.success) {
|
|
554
|
+
return handleLoadSequenceError(loadResult, 'delete');
|
|
555
|
+
}
|
|
556
|
+
const sequence = loadResult.sequence;
|
|
557
|
+
const deleted = recorder.deleteSequence(sequence.id);
|
|
558
|
+
if (!deleted) {
|
|
559
|
+
return createErrorResponse('SEQUENCE_NOT_FOUND', {
|
|
560
|
+
sequenceId: sequence.id,
|
|
561
|
+
message: `Sequence "${sequence.name}" not found.`
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
return createSuccessResponse('SEQUENCE_DELETED', {
|
|
565
|
+
sequenceId: sequence.id,
|
|
566
|
+
name: sequence.name,
|
|
567
|
+
message: `Sequence "${sequence.name}" deleted successfully.`
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
async function handleExport(args, recorder) {
|
|
571
|
+
const loadResult = await loadSequence({ name: args.name, sequenceId: args.sequenceId }, recorder);
|
|
572
|
+
if (!loadResult.success) {
|
|
573
|
+
return handleLoadSequenceError(loadResult, 'export');
|
|
574
|
+
}
|
|
575
|
+
const sequence = loadResult.sequence;
|
|
576
|
+
const format = args.format || 'sequence';
|
|
577
|
+
const overwrite = args.overwrite ?? false;
|
|
578
|
+
// Always save sequence file first (for all formats)
|
|
579
|
+
const sequenceResult = await recorder.saveSequenceToDisk(sequence.id, args.global ?? false, overwrite);
|
|
580
|
+
if (!sequenceResult) {
|
|
581
|
+
return createErrorResponse('EXPORT_FAILED', { message: 'Sequence not found.' });
|
|
582
|
+
}
|
|
583
|
+
if (!sequenceResult.success) {
|
|
584
|
+
if (sequenceResult.conflict) {
|
|
585
|
+
return createSuccessResponse('EXPORT_CONFLICT', {
|
|
586
|
+
filepath: sequenceResult.filepath,
|
|
587
|
+
sequenceName: sequence.name,
|
|
588
|
+
format
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
return createErrorResponse('EXPORT_FAILED', { message: sequenceResult.error });
|
|
592
|
+
}
|
|
593
|
+
// If only exporting sequence JSON, we're done
|
|
594
|
+
if (format === 'sequence') {
|
|
595
|
+
const location = args.global ? 'global (~/.cdp-tools/sequences/)' : 'working directory';
|
|
596
|
+
return createSuccessResponse('EXPORT_SEQUENCE_SUCCESS', {
|
|
597
|
+
filename: sequenceResult.filepath,
|
|
598
|
+
location
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
// Export as Playwright or Puppeteer test
|
|
602
|
+
const replayConfig = configManager.getReplayConfig();
|
|
603
|
+
const isPlaywright = format === 'playwright';
|
|
604
|
+
const code = isPlaywright
|
|
605
|
+
? generatePlaywrightCode(sequence.commands, sequence.startUrl)
|
|
606
|
+
: generatePuppeteerCode(sequence.commands, sequence.startUrl);
|
|
607
|
+
const exportPath = isPlaywright ? replayConfig.playwrightExportPath : replayConfig.puppeteerExportPath;
|
|
608
|
+
const extension = isPlaywright ? '.spec.ts' : '.test.js';
|
|
609
|
+
const fs = await import('fs');
|
|
610
|
+
const path = await import('path');
|
|
611
|
+
const sanitizedName = sequence.name.replace(/[^a-zA-Z0-9-_]/g, '-');
|
|
612
|
+
const fullPath = path.resolve(exportPath, `${sanitizedName}${extension}`);
|
|
613
|
+
// Check for test file conflict
|
|
614
|
+
if (fs.existsSync(fullPath) && !overwrite) {
|
|
615
|
+
return createSuccessResponse('EXPORT_CONFLICT', {
|
|
616
|
+
filepath: fullPath,
|
|
617
|
+
sequenceName: sequence.name,
|
|
618
|
+
format
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
// Write the test file
|
|
622
|
+
const dir = path.dirname(fullPath);
|
|
623
|
+
if (!fs.existsSync(dir)) {
|
|
624
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
625
|
+
}
|
|
626
|
+
fs.writeFileSync(fullPath, code, 'utf-8');
|
|
627
|
+
return createSuccessResponse('EXPORT_SUCCESS', {
|
|
628
|
+
testFile: fullPath,
|
|
629
|
+
sequenceFile: sequenceResult.filepath,
|
|
630
|
+
format
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
async function handleLoad(args, recorder, getKnownToolNames) {
|
|
634
|
+
if (!args.filename) {
|
|
635
|
+
return createErrorResponse('MISSING_PARAMETER', {
|
|
636
|
+
action: 'load',
|
|
637
|
+
missing: 'filename',
|
|
638
|
+
message: 'The "load" action requires a "filename" parameter. Use listSaved to see available files.'
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
// Reject unknown tool names up front rather than failing mid-run (bug-010).
|
|
642
|
+
// Validation runs on the parsed candidate BEFORE it replaces any same-named
|
|
643
|
+
// sequence in memory, so a bad file can't evict a good in-memory sequence.
|
|
644
|
+
let invalid = null;
|
|
645
|
+
const sequence = await recorder.loadSequenceFromDisk(args.filename, {
|
|
646
|
+
validate: (candidate) => {
|
|
647
|
+
invalid = validateSequenceToolNames(candidate, 'load', getKnownToolNames);
|
|
648
|
+
return invalid === null;
|
|
649
|
+
},
|
|
650
|
+
});
|
|
651
|
+
if (invalid)
|
|
652
|
+
return invalid;
|
|
653
|
+
if (!sequence) {
|
|
654
|
+
return createErrorResponse('LOAD_FAILED', {
|
|
655
|
+
filename: args.filename,
|
|
656
|
+
error: 'File may not exist or be invalid.'
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
// If intoHistory is true, load commands into history without executing
|
|
660
|
+
if (args.intoHistory) {
|
|
661
|
+
let loadedCount = 0;
|
|
662
|
+
for (const cmd of sequence.commands) {
|
|
663
|
+
recorder.recordCommand(cmd.tool, cmd.params);
|
|
664
|
+
loadedCount++;
|
|
665
|
+
}
|
|
666
|
+
return createSuccessResponse('SEQUENCE_LOADED_INTO_HISTORY', {
|
|
667
|
+
sequenceId: sequence.id,
|
|
668
|
+
name: sequence.name,
|
|
669
|
+
commandCount: loadedCount,
|
|
670
|
+
message: `Loaded ${loadedCount} commands from "${sequence.name}" into history. Use replay({ action: 'history' }) to view.`
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
return createSuccessResponse('SEQUENCE_LOADED_FROM_DISK', {
|
|
674
|
+
sequenceId: sequence.id,
|
|
675
|
+
name: sequence.name,
|
|
676
|
+
commandCount: sequence.commands.length,
|
|
677
|
+
message: `Sequence "${sequence.name}" loaded successfully. Use replay({ action: 'run', sequenceId: '${sequence.id}' }) to execute.`
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
async function handleListSaved(args, recorder) {
|
|
681
|
+
const savedSequences = await recorder.listSavedSequencesOnDisk();
|
|
682
|
+
const issueSequences = await recorder.listIssueSequencesOnDisk();
|
|
683
|
+
const showAll = args.showAll ?? false;
|
|
684
|
+
return { content: [{ type: 'text', text: formatSavedSequencesList(savedSequences, issueSequences, showAll) }] };
|
|
685
|
+
}
|
|
686
|
+
async function handleDeleteSaved(args, recorder) {
|
|
687
|
+
if (!args.filename) {
|
|
688
|
+
return createErrorResponse('MISSING_PARAMETER', {
|
|
689
|
+
action: 'deleteSaved',
|
|
690
|
+
missing: 'filename',
|
|
691
|
+
message: 'The "deleteSaved" action requires a "filename" parameter'
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
const deleted = await recorder.deleteSequenceFromDisk(args.filename);
|
|
695
|
+
if (!deleted) {
|
|
696
|
+
return createErrorResponse('DELETE_FAILED', {
|
|
697
|
+
filename: args.filename,
|
|
698
|
+
message: `Failed to delete file "${args.filename}". File may not exist.`
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
return createSuccessResponse('SAVED_SEQUENCE_DELETED', {
|
|
702
|
+
filename: args.filename,
|
|
703
|
+
message: `Sequence file "${args.filename}" deleted successfully.`
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Live connection references sharing `port`, excluding `self`. Empty when the
|
|
708
|
+
* session cannot be read - an unreadable list must not stop a requested kill,
|
|
709
|
+
* only a KNOWN co-tenant does.
|
|
710
|
+
*/
|
|
711
|
+
async function connectionsSharingPort(executeToolCall, port, self) {
|
|
712
|
+
try {
|
|
713
|
+
const result = await executeToolCall('listConnections', {});
|
|
714
|
+
const parsed = parseConnectionList(result?.content?.[0]?.text || '');
|
|
715
|
+
if (!parsed)
|
|
716
|
+
return [];
|
|
717
|
+
return parsed
|
|
718
|
+
.filter(c => c.port === port
|
|
719
|
+
&& c.connected !== false
|
|
720
|
+
&& sanitizeReference(c.reference) !== sanitizeReference(self))
|
|
721
|
+
.map(c => c.reference);
|
|
722
|
+
}
|
|
723
|
+
catch {
|
|
724
|
+
return [];
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* References that sequences reached through `conditional` steps name, for
|
|
729
|
+
* validating `connections`. Resolution is memory-only and best-effort: a
|
|
730
|
+
* sequence that lives on disk isn't loaded here (that would register it as a
|
|
731
|
+
* side effect of validation), so `complete: false` says "this list may be
|
|
732
|
+
* short" and the caller must not treat a missing key as a typo.
|
|
733
|
+
*/
|
|
734
|
+
function collectNestedRebindableReferences(commands, recorder, depth = 0, seen = new Set()) {
|
|
735
|
+
// Must track the executor's own cap, not a hardcoded copy: with a raised
|
|
736
|
+
// maxConditionalDepth, references at runtime-reachable depths would be
|
|
737
|
+
// omitted while `complete` still claimed the list was exhaustive, and a valid
|
|
738
|
+
// rebinding key would be rejected as a typo.
|
|
739
|
+
if (depth >= configManager.getReplayConfig().maxConditionalDepth) {
|
|
740
|
+
return { references: [], complete: false };
|
|
741
|
+
}
|
|
742
|
+
const references = [];
|
|
743
|
+
let complete = true;
|
|
744
|
+
for (const cmd of commands) {
|
|
745
|
+
if (cmd.tool !== 'conditional')
|
|
746
|
+
continue;
|
|
747
|
+
const then = typeof cmd.params?.then === 'string' ? cmd.params.then : undefined;
|
|
748
|
+
if (!then || seen.has(then))
|
|
749
|
+
continue;
|
|
750
|
+
seen.add(then);
|
|
751
|
+
const nested = recorder.listSequences().find(s => s.name === then);
|
|
752
|
+
if (!nested) {
|
|
753
|
+
complete = false;
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
references.push(...analyzeRecordedStepConnections(nested.commands).references);
|
|
757
|
+
for (const c of nested.commands) {
|
|
758
|
+
if ((c.tool === 'launchChrome' || c.tool === 'connectDebugger') && typeof c.params.reference === 'string') {
|
|
759
|
+
references.push(sanitizeReference(c.params.reference));
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
const deeper = collectNestedRebindableReferences(nested.commands, recorder, depth + 1, seen);
|
|
763
|
+
references.push(...deeper.references);
|
|
764
|
+
complete = complete && deeper.complete;
|
|
765
|
+
}
|
|
766
|
+
return { references, complete };
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Run every sequence in a folder, in filename order, and report one line each.
|
|
770
|
+
*
|
|
771
|
+
* Two behaviours make this usable as a suite runner rather than a loop:
|
|
772
|
+
* - the ENTIRE tree is loaded before anything runs, so a sequence in spine/
|
|
773
|
+
* can still reference a helper in _helpers/ by name (conditional `then`,
|
|
774
|
+
* forEach `do`) — those resolve by sequence NAME, not by path;
|
|
775
|
+
* - a failure is recorded and the run continues (continueOnFailure, default
|
|
776
|
+
* true). A suite that stops at the first red tells you far less than one
|
|
777
|
+
* that finishes and shows you all of them.
|
|
778
|
+
*
|
|
779
|
+
* Folders whose name starts with '_' are loaded but never run on their own —
|
|
780
|
+
* that is where preamble/helper sequences live, which are meaningless in
|
|
781
|
+
* isolation and would fail if executed standalone.
|
|
782
|
+
*/
|
|
783
|
+
async function handleRunAll(args, recorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort) {
|
|
784
|
+
// Stay inside ONE root. listSavedSequencesOnDisk merges the project dir with
|
|
785
|
+
// ~/.cdp-tools/sequences, and a bare runAll that swept in the user's global
|
|
786
|
+
// sequences would execute unrelated suites from other projects — and a name
|
|
787
|
+
// colliding across the two roots would select twice.
|
|
788
|
+
const wantLocation = args.global ? 'global' : 'working-dir';
|
|
789
|
+
const onDisk = (await recorder.listSavedSequencesOnDisk())
|
|
790
|
+
.filter(e => e.location === wantLocation);
|
|
791
|
+
if (onDisk.length === 0) {
|
|
792
|
+
return createErrorResponse('INVALID_PARAMETER', {
|
|
793
|
+
parameter: 'folder',
|
|
794
|
+
value: String(args.folder ?? ''),
|
|
795
|
+
message: `No sequences found in the ${args.global ? 'global (~/.cdp-tools/sequences)' : 'project'} sequences directory. Export or record one first${args.global ? '' : ', or pass global:true to run the global ones'}.`
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
// Load everything first so cross-folder name references resolve.
|
|
799
|
+
for (const entry of onDisk) {
|
|
800
|
+
await recorder.loadSequenceFromDisk(entry.fullPath);
|
|
801
|
+
}
|
|
802
|
+
const folder = (args.folder || '').replace(/^\/+|\/+$/g, '');
|
|
803
|
+
const chosen = new Set(selectSuiteFiles(onDisk.map(e => e.filename), folder));
|
|
804
|
+
const tagsOf = (name) => recorder.listSequences().find(s => s.name === name)?.tags ?? [];
|
|
805
|
+
// Tag selection runs after the folder pick, so `folder` and `tags` compose:
|
|
806
|
+
// "the ui sequences in spine/" is one call, not a choice between two axes.
|
|
807
|
+
let wantTags = [];
|
|
808
|
+
if (args.tags !== undefined) {
|
|
809
|
+
const cleaned = normalizeTags(args.tags);
|
|
810
|
+
if ('error' in cleaned) {
|
|
811
|
+
return createErrorResponse('INVALID_PARAMETER', { parameter: 'tags', value: args.tags.join(', '), message: cleaned.error });
|
|
812
|
+
}
|
|
813
|
+
wantTags = cleaned.tags;
|
|
814
|
+
}
|
|
815
|
+
const inFolder = onDisk
|
|
816
|
+
.filter(e => chosen.has(e.filename))
|
|
817
|
+
.sort((a, b) => a.filename.localeCompare(b.filename));
|
|
818
|
+
const selected = wantTags.length === 0
|
|
819
|
+
? inFolder
|
|
820
|
+
: inFolder.filter(e => tagsOf(e.name).some(t => wantTags.includes(t)));
|
|
821
|
+
if (wantTags.length > 0 && selected.length === 0) {
|
|
822
|
+
const available = [...new Set(inFolder.flatMap(e => tagsOf(e.name)))].sort();
|
|
823
|
+
return createErrorResponse('INVALID_PARAMETER', {
|
|
824
|
+
parameter: 'tags',
|
|
825
|
+
value: wantTags.join(', '),
|
|
826
|
+
message: `No sequence ${folder ? `under "${folder}" ` : ''}carries ${wantTags.length > 1 ? 'any of those tags' : `the tag "${wantTags[0]}"`}. ` +
|
|
827
|
+
(available.length
|
|
828
|
+
? `Tags in use here: ${available.join(', ')}.`
|
|
829
|
+
: `No sequence here is tagged yet - set one with replay({ action: 'declare', name: '...', tags: ['ui'] }).`),
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
if (selected.length === 0) {
|
|
833
|
+
const folders = sequenceFolders(onDisk.map(e => e.filename));
|
|
834
|
+
return createErrorResponse('INVALID_PARAMETER', {
|
|
835
|
+
parameter: 'folder',
|
|
836
|
+
value: folder,
|
|
837
|
+
message: `No sequences under "${folder}". ` +
|
|
838
|
+
(folders.length ? `Available folders: ${folders.join(', ')}.` : 'No subfolders exist yet — sequences are all at the top level.')
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
const keepGoing = args.continueOnFailure !== false;
|
|
842
|
+
const results = [];
|
|
843
|
+
for (const entry of selected) {
|
|
844
|
+
if (abortSignal?.aborted) {
|
|
845
|
+
results.push({ filename: entry.filename, name: entry.name, ok: false, detail: 'cancelled before it ran' });
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
let ok = false;
|
|
849
|
+
let detail = '';
|
|
850
|
+
try {
|
|
851
|
+
// Reuse handleRun so a suite run and a single run cannot drift apart.
|
|
852
|
+
const res = await handleRun({
|
|
853
|
+
...args,
|
|
854
|
+
action: 'run',
|
|
855
|
+
folder: undefined,
|
|
856
|
+
continueOnFailure: undefined,
|
|
857
|
+
name: undefined,
|
|
858
|
+
sequenceId: entry.id,
|
|
859
|
+
wait: true,
|
|
860
|
+
// A suite has nobody to answer a prompt. Keeping the recorded values
|
|
861
|
+
// is the only unattended behaviour that still runs the sequence;
|
|
862
|
+
// leaving it undefined turns every parameterised sequence into a
|
|
863
|
+
// no-op that a caller then has to notice.
|
|
864
|
+
variables: args.variables ?? {},
|
|
865
|
+
// Per-run args that are actively wrong when fanned across a suite:
|
|
866
|
+
// killChromeOnFinish would tear down the browser between sequences and
|
|
867
|
+
// destroy the state a _helpers preamble just established, and
|
|
868
|
+
// startFrom/stepTo/stepCount/startUrl mean something only for one
|
|
869
|
+
// specific sequence.
|
|
870
|
+
killChromeOnFinish: undefined,
|
|
871
|
+
startFrom: undefined,
|
|
872
|
+
stepTo: undefined,
|
|
873
|
+
stepCount: undefined,
|
|
874
|
+
startUrl: undefined,
|
|
875
|
+
}, recorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort);
|
|
876
|
+
const text = (res?.content || []).map((c) => c?.text || '').join('\n');
|
|
877
|
+
// performRun stamps _meta.replay on every terminal response, so trust that
|
|
878
|
+
// over the prose. Regexing for a line starting with "Error:" both misses
|
|
879
|
+
// non-run outcomes (a variables prompt, a pause) and misfires on any step
|
|
880
|
+
// output that happens to echo one.
|
|
881
|
+
const meta = res?._meta?.replay;
|
|
882
|
+
if (meta && typeof meta.success === 'boolean') {
|
|
883
|
+
ok = meta.success === true && meta.paused !== true && meta.prompted !== true;
|
|
884
|
+
detail = meta.prompted
|
|
885
|
+
? 'did not run: it has recorded variables and none were supplied — pass variables:{} to keep the recorded values'
|
|
886
|
+
: meta.paused
|
|
887
|
+
? 'did not finish: the run PAUSED (stepTo, a breakpoint, or click validation) and is still open'
|
|
888
|
+
: (text.match(/\*\*Socket health failed\*\*[\s\S]*?(?=\n\n\*\*|$)/)?.[0]?.replace(/\s+/g, ' ').slice(0, 200)
|
|
889
|
+
|| text.match(/\*\*Strict run failed\*\*[\s\S]*/)?.[0]?.replace(/\s+/g, ' ').slice(0, 200)
|
|
890
|
+
|| text.match(/^\s*Error:.*$/m)?.[0]
|
|
891
|
+
|| `failed at ${meta.failedSteps ?? '?'} step(s)`).trim();
|
|
892
|
+
}
|
|
893
|
+
else {
|
|
894
|
+
// No _meta means this was not a terminal run response at all.
|
|
895
|
+
ok = false;
|
|
896
|
+
detail = (text.match(/^\s*Error:.*$/m)?.[0] || text.split('\n')[0] || 'no run result').trim();
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
catch (err) {
|
|
900
|
+
ok = false;
|
|
901
|
+
detail = `threw: ${err?.message || String(err)}`;
|
|
902
|
+
}
|
|
903
|
+
results.push({ filename: entry.filename, name: entry.name, ok, detail });
|
|
904
|
+
if (!ok && !keepGoing)
|
|
905
|
+
break;
|
|
906
|
+
}
|
|
907
|
+
const passed = results.filter(r => r.ok).length;
|
|
908
|
+
const failed = results.length - passed;
|
|
909
|
+
const scope = [
|
|
910
|
+
folder ? `folder "${folder}"` : 'all sequences',
|
|
911
|
+
wantTags.length ? `tagged ${wantTags.join(' or ')}` : '',
|
|
912
|
+
].filter(Boolean).join(', ');
|
|
913
|
+
// What the suite actually covered, reported every run rather than needing an
|
|
914
|
+
// audit to discover: "36 passed" reads as interface coverage whether or not
|
|
915
|
+
// any of it drove the interface.
|
|
916
|
+
const tagCounts = new Map();
|
|
917
|
+
let untagged = 0;
|
|
918
|
+
for (const r of results) {
|
|
919
|
+
const tags = tagsOf(r.name);
|
|
920
|
+
if (tags.length === 0)
|
|
921
|
+
untagged++;
|
|
922
|
+
for (const tag of tags)
|
|
923
|
+
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
|
|
924
|
+
}
|
|
925
|
+
const split = [
|
|
926
|
+
...[...tagCounts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([tag, n]) => `${n} ${tag}`),
|
|
927
|
+
...(untagged > 0 ? [`${untagged} untagged`] : []),
|
|
928
|
+
].join(', ');
|
|
929
|
+
const lines = results.map(r => `${r.ok ? 'PASS' : 'FAIL'} ${r.filename}${r.ok ? '' : ` — ${r.detail}`}`);
|
|
930
|
+
const skipped = selected.length - results.length;
|
|
931
|
+
return {
|
|
932
|
+
content: [{
|
|
933
|
+
type: 'text',
|
|
934
|
+
text: [
|
|
935
|
+
`runAll ${scope}: ${passed} passed, ${failed} failed${skipped > 0 ? `, ${skipped} not run (stopped at first failure)` : ''}` +
|
|
936
|
+
(split ? ` (${split})` : ''),
|
|
937
|
+
'',
|
|
938
|
+
...lines,
|
|
939
|
+
'',
|
|
940
|
+
`Loaded ${onDisk.length} sequence(s) from disk; ran ${results.length}.`,
|
|
941
|
+
].join('\n')
|
|
942
|
+
}],
|
|
943
|
+
...(failed > 0 ? { isError: true } : {})
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Launch the browsers a sequence declares it needs, if they are not live yet.
|
|
948
|
+
*
|
|
949
|
+
* Naming a connection on a step does not create it. Without this, a
|
|
950
|
+
* multi-browser sequence runs only when someone has already opened those
|
|
951
|
+
* browsers by hand — so an unattended suite run skips precisely the coverage
|
|
952
|
+
* that a single browser cannot provide.
|
|
953
|
+
*
|
|
954
|
+
* A caller's `connections` rebinding wins: the declaration supplies a default
|
|
955
|
+
* browser, it does not override where the caller wants the steps pointed.
|
|
956
|
+
*/
|
|
957
|
+
/**
|
|
958
|
+
* Connections a sequence actually loads the app in - the ones a declared
|
|
959
|
+
* WebSocket could plausibly belong to.
|
|
960
|
+
*
|
|
961
|
+
* A navigate step names its connection or takes the run's; either way the app
|
|
962
|
+
* comes up there and its sockets open. Everything else (asserting a captured
|
|
963
|
+
* value, waiting, inspecting) can take a connection without ever giving the
|
|
964
|
+
* transport a page to live on, so counting those made an idle browser look
|
|
965
|
+
* driven and failed the run for a socket nothing had asked it to open.
|
|
966
|
+
*
|
|
967
|
+
* Empty when the sequence never navigates - it is then driving a page someone
|
|
968
|
+
* else loaded, and the caller falls back to the wider rule rather than
|
|
969
|
+
* silently checking nothing.
|
|
970
|
+
*/
|
|
971
|
+
function navigatedConnections(commands, runConnection) {
|
|
972
|
+
const refs = [];
|
|
973
|
+
for (const cmd of commands) {
|
|
974
|
+
if (cmd.tool !== 'navigate')
|
|
975
|
+
continue;
|
|
976
|
+
const raw = cmd.params?.connectionReason;
|
|
977
|
+
const ref = typeof raw === 'string' && raw.trim() ? sanitizeReference(raw) : runConnection;
|
|
978
|
+
if (ref && !refs.includes(ref))
|
|
979
|
+
refs.push(ref);
|
|
980
|
+
}
|
|
981
|
+
return refs;
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Reject a declaration set whose profiles cannot mean what it says, before
|
|
985
|
+
* anything is launched.
|
|
986
|
+
*
|
|
987
|
+
* Two failures, both of which would otherwise surface later as something else:
|
|
988
|
+
*
|
|
989
|
+
* - **Two references on one profile.** Only one live Chrome may hold a profile,
|
|
990
|
+
* so the second launch fails - but the message would be about ports, not
|
|
991
|
+
* about a sequence asking two identities to be the same browser. Same shape
|
|
992
|
+
* as the `connections` rule that refuses collapsing two references into one.
|
|
993
|
+
*
|
|
994
|
+
* - **Rebinding a profile-bearing reference.** A rebind normally wins, because
|
|
995
|
+
* a declaration is only a default. A profile is not a default, it is an
|
|
996
|
+
* identity claim: pointing "device-a" at some other browser runs device-a's
|
|
997
|
+
* steps somewhere that is not device-a, and the run reports success. That is
|
|
998
|
+
* the class of lie the per-step connection rules exist to prevent.
|
|
999
|
+
*/
|
|
1000
|
+
function declaredProfileConflict(declared, connectionMap) {
|
|
1001
|
+
const byProfile = new Map();
|
|
1002
|
+
for (const decl of declared) {
|
|
1003
|
+
if (!decl.profile)
|
|
1004
|
+
continue;
|
|
1005
|
+
const reference = sanitizeReference(decl.reference);
|
|
1006
|
+
if (!reference)
|
|
1007
|
+
continue;
|
|
1008
|
+
const rebound = connectionMap?.[reference];
|
|
1009
|
+
if (rebound) {
|
|
1010
|
+
return `"${reference}" is declared on the persistent profile "${decl.profile}", so it names a specific browser identity, ` +
|
|
1011
|
+
`not a default - rebinding it onto "${rebound}" would run its steps in a browser that is not "${decl.profile}" and pass. ` +
|
|
1012
|
+
`Drop it from \`connections\`, or drop the profile from the declaration.`;
|
|
1013
|
+
}
|
|
1014
|
+
byProfile.set(decl.profile, [...(byProfile.get(decl.profile) || []), reference]);
|
|
1015
|
+
}
|
|
1016
|
+
for (const [profile, references] of byProfile) {
|
|
1017
|
+
if (references.length > 1) {
|
|
1018
|
+
return `${references.length} declared connections (${references.join(', ')}) name the same persistent profile "${profile}". ` +
|
|
1019
|
+
`Only one live Chrome may hold a profile, so they would be one browser - give each identity its own profile.`;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
return null;
|
|
1023
|
+
}
|
|
1024
|
+
async function ensureDeclaredConnections(sequence, executeToolCall, getPageForConnection, connectionMap) {
|
|
1025
|
+
const declared = sequence.requiredConnections;
|
|
1026
|
+
if (!Array.isArray(declared) || declared.length === 0)
|
|
1027
|
+
return { launched: [] };
|
|
1028
|
+
const conflict = declaredProfileConflict(declared, connectionMap);
|
|
1029
|
+
if (conflict)
|
|
1030
|
+
return { launched: [], error: `"${sequence.name}": ${conflict}`, invalid: true };
|
|
1031
|
+
const launched = [];
|
|
1032
|
+
for (const decl of declared) {
|
|
1033
|
+
const wanted = sanitizeReference(decl.reference);
|
|
1034
|
+
if (!wanted)
|
|
1035
|
+
continue;
|
|
1036
|
+
// Rebound onto an existing session connection: nothing to launch.
|
|
1037
|
+
const target = connectionMap?.[wanted] ?? wanted;
|
|
1038
|
+
if (connectionMap?.[wanted])
|
|
1039
|
+
continue;
|
|
1040
|
+
// Probing first is not enough: a reference can still resolve to a page after
|
|
1041
|
+
// the browser was killed out of band, and skipping the launch then fails the
|
|
1042
|
+
// first step that uses it. Attempt the launch and treat "already bound" as a
|
|
1043
|
+
// live browser to reuse.
|
|
1044
|
+
try {
|
|
1045
|
+
await executeToolCall('launchChrome', {
|
|
1046
|
+
reference: target,
|
|
1047
|
+
url: decl.url ?? sequence.startUrl,
|
|
1048
|
+
// A profile IS the browser this declaration wants, so a live Chrome
|
|
1049
|
+
// already running it is the target rather than something to spawn
|
|
1050
|
+
// beside - and only one Chrome may hold a profile, so forcing a second
|
|
1051
|
+
// process fails against the browser it was asking for.
|
|
1052
|
+
forceNewInstance: decl.profile
|
|
1053
|
+
? decl.forceNewInstance === true
|
|
1054
|
+
: decl.forceNewInstance !== false,
|
|
1055
|
+
...(decl.profile && { profile: decl.profile }),
|
|
1056
|
+
});
|
|
1057
|
+
launched.push(target);
|
|
1058
|
+
}
|
|
1059
|
+
catch (err) {
|
|
1060
|
+
const message = String(err?.message || err);
|
|
1061
|
+
if (/already bound/i.test(message)) {
|
|
1062
|
+
try {
|
|
1063
|
+
if (await getPageForConnection(target))
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
catch { /* fall through to the error below */ }
|
|
1067
|
+
}
|
|
1068
|
+
return {
|
|
1069
|
+
launched,
|
|
1070
|
+
error: `"${sequence.name}" needs the browser "${target}"${decl.role ? ` (${decl.role})` : ''} and launching it failed: ${err?.message || String(err)}`,
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
return { launched };
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Close browsers this run launched from a sequence's requiredConnections.
|
|
1078
|
+
*
|
|
1079
|
+
* The run created them, so the run owns them. Anything the caller supplied is
|
|
1080
|
+
* left alone. Without this a suite leaves a browser behind per multi-browser
|
|
1081
|
+
* sequence, and the next run silently reuses one holding state from before —
|
|
1082
|
+
* which is worse than the clutter, because it looks like a fresh browser.
|
|
1083
|
+
*/
|
|
1084
|
+
async function closeLaunchedConnections(launched, executeToolCall, getConnectionPort, sequenceName,
|
|
1085
|
+
/** How the run came to own these, for the kill reason and the closing note. */
|
|
1086
|
+
origin = 'declared and launched') {
|
|
1087
|
+
if (launched.length === 0 || !getConnectionPort)
|
|
1088
|
+
return '';
|
|
1089
|
+
const closed = [];
|
|
1090
|
+
for (const ref of launched) {
|
|
1091
|
+
try {
|
|
1092
|
+
const port = await getConnectionPort(ref);
|
|
1093
|
+
if (port === null)
|
|
1094
|
+
continue;
|
|
1095
|
+
const sharers = await connectionsSharingPort(executeToolCall, port, ref);
|
|
1096
|
+
if (sharers.length > 0)
|
|
1097
|
+
continue; // someone else is on this browser
|
|
1098
|
+
await executeToolCall('killChrome', { reason: `sequence "${sequenceName}" ${origin} ${ref}`, port });
|
|
1099
|
+
// Release the reference as well. Killing the process leaves the name
|
|
1100
|
+
// bound, and the next sequence in a suite declaring the same reference
|
|
1101
|
+
// then fails to launch against a browser that no longer exists.
|
|
1102
|
+
await executeToolCall('disconnectDebugger', { reference: ref }).catch(() => { });
|
|
1103
|
+
closed.push(ref);
|
|
1104
|
+
}
|
|
1105
|
+
catch {
|
|
1106
|
+
// Best-effort: a browser that will not close is not a run failure.
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
return closed.length ? `\n\n**Browsers closed** (${origin}): ${closed.join(', ')}` : '';
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Declared-browser cleanups owed by a run that PAUSED, keyed by run id (or by
|
|
1113
|
+
* sequence id for a `wait: true` pause, which registers no run record).
|
|
1114
|
+
*
|
|
1115
|
+
* A pause is the one outcome that deliberately keeps its browsers - they are
|
|
1116
|
+
* the state someone stopped to inspect. Every way out of a pause is terminal
|
|
1117
|
+
* though (cancel, step to the end, finish), and each used to drop the launched
|
|
1118
|
+
* references on the floor: the browsers stayed up and the next run reused one
|
|
1119
|
+
* carrying the previous run's state (issue #127).
|
|
1120
|
+
*/
|
|
1121
|
+
const pendingDeclaredCleanups = new Map();
|
|
1122
|
+
const cleanupKey = (runId, sequenceId) => runId ?? `seq:${sequenceId}`;
|
|
1123
|
+
/** Run and forget the cleanup a paused run left owing, if any. */
|
|
1124
|
+
async function drainDeclaredCleanup(runId, sequenceId) {
|
|
1125
|
+
const key = cleanupKey(runId, sequenceId);
|
|
1126
|
+
const cleanup = pendingDeclaredCleanups.get(key);
|
|
1127
|
+
if (!cleanup)
|
|
1128
|
+
return '';
|
|
1129
|
+
pendingDeclaredCleanups.delete(key);
|
|
1130
|
+
return cleanup().catch(() => '');
|
|
1131
|
+
}
|
|
1132
|
+
/** Console error/warning counts per connection, for a strict run's before/after. */
|
|
1133
|
+
async function snapshotConsole(refs, executeToolCall) {
|
|
1134
|
+
const out = {};
|
|
1135
|
+
for (const ref of refs) {
|
|
1136
|
+
try {
|
|
1137
|
+
const res = await executeToolCall('console', { action: 'list', limit: 1, connectionReason: ref });
|
|
1138
|
+
out[ref] = {
|
|
1139
|
+
errors: res?._meta?.console?.errorCount || 0,
|
|
1140
|
+
warnings: res?._meta?.console?.warnCount || 0,
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
catch {
|
|
1144
|
+
// A connection that cannot be read yet contributes nothing to the diff.
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
return out;
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* What a strict run should fail on: console output the sequence PRODUCED.
|
|
1151
|
+
*
|
|
1152
|
+
* Counted per connection and diffed against the start of the run, so noise that
|
|
1153
|
+
* was already on the page is not blamed on this sequence. Warnings count only
|
|
1154
|
+
* when strict is 'warnings' — a sequence can be functionally correct and still
|
|
1155
|
+
* be logging, and those are different questions.
|
|
1156
|
+
*/
|
|
1157
|
+
function strictConsoleFailures(before, after, includeWarnings) {
|
|
1158
|
+
const out = [];
|
|
1159
|
+
for (const ref of Object.keys(after)) {
|
|
1160
|
+
const b = before[ref] || { errors: 0, warnings: 0 };
|
|
1161
|
+
const errs = after[ref].errors - b.errors;
|
|
1162
|
+
const warns = after[ref].warnings - b.warnings;
|
|
1163
|
+
if (errs > 0)
|
|
1164
|
+
out.push(`${ref}: ${errs} new console error(s)`);
|
|
1165
|
+
if (includeWarnings && warns > 0)
|
|
1166
|
+
out.push(`${ref}: ${warns} new console warning(s)`);
|
|
1167
|
+
}
|
|
1168
|
+
return out;
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Every WebSocket per connection, for a run's before/after comparison.
|
|
1172
|
+
*
|
|
1173
|
+
* A connection that cannot be read is recorded as unreadable rather than
|
|
1174
|
+
* omitted. Omitting it silently disables the health check for that connection -
|
|
1175
|
+
* a run then passes because nothing was measured, which is the exact failure
|
|
1176
|
+
* the check exists to prevent.
|
|
1177
|
+
*/
|
|
1178
|
+
async function snapshotSockets(refs, executeToolCall) {
|
|
1179
|
+
const out = {};
|
|
1180
|
+
for (const ref of refs) {
|
|
1181
|
+
try {
|
|
1182
|
+
const res = await executeToolCall('network', { action: 'sockets', connectionReason: ref });
|
|
1183
|
+
const list = res?._meta?.socketList;
|
|
1184
|
+
out[ref] = Array.isArray(list)
|
|
1185
|
+
? list
|
|
1186
|
+
: { unreadable: res?.isError ? firstLine(res) : 'socket health was not reported' };
|
|
1187
|
+
}
|
|
1188
|
+
catch (error) {
|
|
1189
|
+
out[ref] = { unreadable: error?.message || String(error) };
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
return out;
|
|
1193
|
+
}
|
|
1194
|
+
/** First line of a tool response's text, for embedding in a failure message. */
|
|
1195
|
+
function firstLine(res) {
|
|
1196
|
+
const text = res?.content?.[0]?.text;
|
|
1197
|
+
return typeof text === 'string' ? text.split('\n')[0].slice(0, 120) : 'unreadable';
|
|
1198
|
+
}
|
|
1199
|
+
/** Shorten a socket URL for a failure message - the path is the identifying part. */
|
|
1200
|
+
function socketLabel(url) {
|
|
1201
|
+
try {
|
|
1202
|
+
const u = new URL(url);
|
|
1203
|
+
return `${u.pathname}${u.search}`.slice(0, 80) || url;
|
|
1204
|
+
}
|
|
1205
|
+
catch {
|
|
1206
|
+
return url.slice(0, 80);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Socket problems a run CAUSED, per socket.
|
|
1211
|
+
*
|
|
1212
|
+
* Diffed against the start so a socket that was already dead is not blamed on
|
|
1213
|
+
* this sequence. Two failures, and the sequence's declaration decides which
|
|
1214
|
+
* sockets are in scope:
|
|
1215
|
+
*
|
|
1216
|
+
* - one it depends on closed or hit frame errors mid-run. No assertion written
|
|
1217
|
+
* as a final step can see this: the app keeps rendering its last synced
|
|
1218
|
+
* snapshot after the socket dies, and a drop that recovered before the last
|
|
1219
|
+
* step leaves no trace at all.
|
|
1220
|
+
* - a declared socket is not open at the end. Absence and health are otherwise
|
|
1221
|
+
* indistinguishable - a transport that never came up closes nothing, so
|
|
1222
|
+
* counting closures alone passes an app that never connected.
|
|
1223
|
+
*
|
|
1224
|
+
* With no declaration (`requireSockets: true` on the run) every socket is in
|
|
1225
|
+
* scope for closures, but nothing can be required to exist - which socket ought
|
|
1226
|
+
* to be there is exactly what the declaration carries.
|
|
1227
|
+
*
|
|
1228
|
+
* Absence is returned separately because it is the one verdict worth waiting
|
|
1229
|
+
* on: sampled the instant the last step ends, it catches an app mid-reconnect
|
|
1230
|
+
* and calls a recovering transport a dead one. Closures and frame errors are
|
|
1231
|
+
* already-happened facts and never resolve by waiting.
|
|
1232
|
+
*/
|
|
1233
|
+
function socketFailures(before, after, required, requiredOn) {
|
|
1234
|
+
const out = [];
|
|
1235
|
+
const absent = [];
|
|
1236
|
+
const inScope = (url) => required.length === 0 || required.some(m => url.includes(m));
|
|
1237
|
+
const list = (v) => Array.isArray(v) ? v : [];
|
|
1238
|
+
for (const ref of Object.keys(after)) {
|
|
1239
|
+
const afterEntry = after[ref];
|
|
1240
|
+
if (!Array.isArray(afterEntry)) {
|
|
1241
|
+
out.push(`${ref}: could not read socket health - ${afterEntry.unreadable}`);
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
const was = new Map(list(before[ref]).map(s => [s.id, s]));
|
|
1245
|
+
const now = afterEntry;
|
|
1246
|
+
for (const sock of now) {
|
|
1247
|
+
if (!inScope(sock.url))
|
|
1248
|
+
continue;
|
|
1249
|
+
const prev = was.get(sock.id);
|
|
1250
|
+
// Three closes this run did not cause, all of them normal:
|
|
1251
|
+
// - already closed before the run started;
|
|
1252
|
+
// - torn down with its target, since a `navigate` replaces the page's
|
|
1253
|
+
// workers and takes their sockets with it;
|
|
1254
|
+
// - hung up by the page itself, which an app does on sign-out or an
|
|
1255
|
+
// identity change.
|
|
1256
|
+
// Whether a socket came back afterwards is the end-state check's
|
|
1257
|
+
// question, not this one's.
|
|
1258
|
+
const deliberate = sock.closedWithTarget || sock.clientClosed;
|
|
1259
|
+
if (sock.closed && !deliberate && !prev?.closed) {
|
|
1260
|
+
out.push(`${ref}: ${socketLabel(sock.url)} [${sock.target}] closed during the run`);
|
|
1261
|
+
}
|
|
1262
|
+
const newErrors = sock.errors - (prev?.errors || 0);
|
|
1263
|
+
if (newErrors > 0) {
|
|
1264
|
+
out.push(`${ref}: ${socketLabel(sock.url)} [${sock.target}] hit ${newErrors} frame error(s)`);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
for (const match of requiredOn.includes(ref) ? required : []) {
|
|
1268
|
+
const matching = now.filter(s => s.url.includes(match));
|
|
1269
|
+
if (!matching.some(s => !s.closed)) {
|
|
1270
|
+
absent.push(matching.length === 0
|
|
1271
|
+
? `${ref}: no WebSocket matching "${match}" was ever seen - the transport this sequence asserts on never opened`
|
|
1272
|
+
: `${ref}: no open WebSocket matching "${match}" at the end of the run (${matching.length} seen, all closed)`);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
return { settled: out, absent };
|
|
1277
|
+
}
|
|
1278
|
+
async function handleRun(args, recorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort) {
|
|
1279
|
+
// Load sequence
|
|
1280
|
+
const loadResult = await loadSequence({ name: args.name, sequenceId: args.sequenceId }, recorder);
|
|
1281
|
+
if (!loadResult.success) {
|
|
1282
|
+
return handleLoadSequenceError(loadResult, 'run');
|
|
1283
|
+
}
|
|
1284
|
+
// Run-time retarget: baseUrl swaps the origin of every absolute URL in the
|
|
1285
|
+
// sequence (startUrl + command params); startUrl replaces the entry URL
|
|
1286
|
+
// wholesale. Lets one recorded sequence run against any deployment.
|
|
1287
|
+
const sequence = (args.baseUrl || args.startUrl)
|
|
1288
|
+
? rebaseSequence(loadResult.sequence, { baseUrl: args.baseUrl, startUrl: args.startUrl })
|
|
1289
|
+
: loadResult.sequence;
|
|
1290
|
+
const commands = sequence.commands;
|
|
1291
|
+
const analysis = analyzeSequenceConnections(commands);
|
|
1292
|
+
// Determine connection reason
|
|
1293
|
+
let connectionReason = args.connectionReason || extractConnectionFromSequence(commands, analysis);
|
|
1294
|
+
// Validate connection requirement - fall back to a reason derived from the
|
|
1295
|
+
// sequence name so we can auto-launch Chrome instead of erroring out
|
|
1296
|
+
const needsConnection = sequenceNeedsConnection(commands);
|
|
1297
|
+
if (!connectionReason && !analysis.hasLaunchBeforeConnection && needsConnection) {
|
|
1298
|
+
connectionReason = deriveConnectionReference(sequence.name);
|
|
1299
|
+
}
|
|
1300
|
+
// Handle variable extraction and prompting
|
|
1301
|
+
const extractedVariables = extractTextVariables(commands);
|
|
1302
|
+
if (Object.keys(extractedVariables).length > 0 && args.variables === undefined) {
|
|
1303
|
+
const idParam = args.sequenceId || args.name;
|
|
1304
|
+
// Tag it: this response is a PROMPT, not a run. runAll has to be able to
|
|
1305
|
+
// tell "asked you a question" from "executed and passed", or a suite goes
|
|
1306
|
+
// green for a sequence that ran zero steps.
|
|
1307
|
+
return {
|
|
1308
|
+
content: [{ type: 'text', text: formatVariablePrompt(sequence.name, idParam, extractedVariables, connectionReason) }],
|
|
1309
|
+
_meta: { tool: 'replay', action: 'run', timestamp: Date.now(), replay: { success: false, prompted: true } }
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
// Validate the connection rebinding before any side effects. A key that names
|
|
1313
|
+
// no recorded reference is a typo the user needs to hear about now: silently
|
|
1314
|
+
// ignoring it would leave the step on its recorded reference and, in the worst
|
|
1315
|
+
// case, replay a cross-browser sequence in one browser (bug-018).
|
|
1316
|
+
const connectionMap = sanitizeConnectionMap(args.connections);
|
|
1317
|
+
if (connectionMap) {
|
|
1318
|
+
const recorded = analyzeRecordedStepConnections(commands);
|
|
1319
|
+
// A `conditional` step's sequence inherits this map, and a setup sequence
|
|
1320
|
+
// normally lives BEHIND the conditional - so its references have to count as
|
|
1321
|
+
// rebindable too, or the only rebindable ones are those needing no rebind.
|
|
1322
|
+
const nested = collectNestedRebindableReferences(commands, recorder);
|
|
1323
|
+
const launchRefs = commands
|
|
1324
|
+
.filter(c => (c.tool === 'launchChrome' || c.tool === 'connectDebugger') && typeof c.params.reference === 'string')
|
|
1325
|
+
.map(c => sanitizeReference(c.params.reference));
|
|
1326
|
+
const known = new Set([...recorded.references, ...launchRefs, ...nested.references]);
|
|
1327
|
+
// An unresolvable nested sequence (on disk, or created later) means we
|
|
1328
|
+
// cannot prove a key is a typo - and refusing a run over an unprovable
|
|
1329
|
+
// typo is worse than letting an unused mapping through.
|
|
1330
|
+
const unknown = nested.complete
|
|
1331
|
+
? Object.keys(connectionMap).filter(k => !known.has(k))
|
|
1332
|
+
: [];
|
|
1333
|
+
if (unknown.length > 0) {
|
|
1334
|
+
return createErrorResponse('INVALID_PARAMETER', {
|
|
1335
|
+
parameter: 'connections',
|
|
1336
|
+
value: unknown.join(', '),
|
|
1337
|
+
message: `No step in "${sequence.name}" is recorded against ${unknown.map(u => `"${u}"`).join(', ')}. ` +
|
|
1338
|
+
(known.size > 0
|
|
1339
|
+
? `Recorded references: ${[...known].join(', ')}. `
|
|
1340
|
+
: `No step in this sequence names a connection at all, so there is nothing to rebind - use connectionReason to set the run connection. `) +
|
|
1341
|
+
`Check replay({ action: 'get', name: '${sequence.name}', outputFormat: 'commands' }).`
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
// Two recorded connections rebound onto ONE live reference replays the whole
|
|
1345
|
+
// multi-browser sequence in a single browser and reports success - bug-018
|
|
1346
|
+
// exactly, re-entered through the API that exists to prevent it. Refuse.
|
|
1347
|
+
const byTarget = new Map();
|
|
1348
|
+
for (const [from, to] of Object.entries(connectionMap)) {
|
|
1349
|
+
if (!recorded.references.includes(from))
|
|
1350
|
+
continue; // launch-only rename, harmless
|
|
1351
|
+
byTarget.set(to, [...(byTarget.get(to) ?? []), from]);
|
|
1352
|
+
}
|
|
1353
|
+
const collapsed = [...byTarget.entries()].filter(([, froms]) => froms.length > 1);
|
|
1354
|
+
if (collapsed.length > 0) {
|
|
1355
|
+
return createErrorResponse('INVALID_PARAMETER', {
|
|
1356
|
+
parameter: 'connections',
|
|
1357
|
+
value: collapsed.map(([to, froms]) => `${froms.join(' + ')} -> ${to}`).join('; '),
|
|
1358
|
+
message: `That mapping would run ${collapsed.map(([to, froms]) => `${froms.length} recorded connections (${froms.join(', ')}) in the single browser "${to}"`).join('; ')}. ` +
|
|
1359
|
+
`"${sequence.name}" spans more than one browser precisely to test what crosses between them; collapsing it would make the run pass without ever involving a second browser. ` +
|
|
1360
|
+
`Give each recorded reference its own live reference, or launch another browser first.`
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
// The run-level connection may itself have been DERIVED from the sequence (a
|
|
1365
|
+
// launchChrome reference), in which case it is a recorded name and needs the
|
|
1366
|
+
// same rebinding as the steps - otherwise it points at a reference that does
|
|
1367
|
+
// not exist here, and the startUrl navigation and cursor injection silently
|
|
1368
|
+
// no-op against it. An explicitly passed connectionReason is already a live
|
|
1369
|
+
// reference and is left alone.
|
|
1370
|
+
if (connectionMap && !args.connectionReason && connectionReason) {
|
|
1371
|
+
connectionReason = connectionMap[sanitizeReference(connectionReason)] ?? connectionReason;
|
|
1372
|
+
}
|
|
1373
|
+
// Bring up any browser the sequence declares before the first step.
|
|
1374
|
+
const declaredConns = await ensureDeclaredConnections(sequence, executeToolCall, getPageForConnection, connectionMap);
|
|
1375
|
+
if (declaredConns.error) {
|
|
1376
|
+
// A declaration that contradicts itself is a bad sequence, not a missing
|
|
1377
|
+
// browser - saying "connection not found" would send you looking for one.
|
|
1378
|
+
return declaredConns.invalid
|
|
1379
|
+
? createErrorResponse('INVALID_PARAMETER', {
|
|
1380
|
+
parameter: 'requiredConnections',
|
|
1381
|
+
value: sequence.name,
|
|
1382
|
+
message: declaredConns.error,
|
|
1383
|
+
})
|
|
1384
|
+
// Not CONNECTION_NOT_FOUND: that template renders a generic "no active
|
|
1385
|
+
// browser connection" and drops the message, so every failed declaration
|
|
1386
|
+
// reported the same thing and never said which browser, which role, or
|
|
1387
|
+
// why the launch failed.
|
|
1388
|
+
: createErrorResponse('DECLARED_CONNECTION_FAILED', { message: declaredConns.error });
|
|
1389
|
+
}
|
|
1390
|
+
// Validate startFrom before any side effects, so both modes reject immediately
|
|
1391
|
+
if (args.startFrom && args.startFrom > sequence.commands.length) {
|
|
1392
|
+
return createErrorResponse('INVALID_START_FROM', {
|
|
1393
|
+
message: `startFrom (${args.startFrom}) exceeds sequence length (${sequence.commands.length})`
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
const deps = {
|
|
1397
|
+
args, recorder, executeToolCall, getPageForConnection, getConnectionPort,
|
|
1398
|
+
sequence, analysis, connectionReason, needsConnection,
|
|
1399
|
+
launchedConnections: new Set(),
|
|
1400
|
+
...(connectionMap && { connectionMap }),
|
|
1401
|
+
};
|
|
1402
|
+
// wait: true - pre-0.7 blocking behaviour, driven by the MCP request signal.
|
|
1403
|
+
// Also what nested `replay run` STEPS use (the executor injects wait: true),
|
|
1404
|
+
// so a run started from inside a sequence never registers as its own
|
|
1405
|
+
// top-level run and its caller keeps the result.
|
|
1406
|
+
// Connections a strict run watches: the run's own, plus every browser the
|
|
1407
|
+
// sequence declared.
|
|
1408
|
+
const watchedRefs = [...new Set([
|
|
1409
|
+
...(connectionReason ? [connectionReason] : []),
|
|
1410
|
+
...(sequence.requiredConnections || []).map(d => connectionMap?.[sanitizeReference(d.reference)] ?? sanitizeReference(d.reference)),
|
|
1411
|
+
])].filter(Boolean);
|
|
1412
|
+
// A sequence that declares the sockets its assertions ride on is checked
|
|
1413
|
+
// whether or not the caller asked - that is the point of declaring it.
|
|
1414
|
+
const requiredSockets = sequence.requiredSockets || [];
|
|
1415
|
+
const checkSockets = args.requireSockets === true || requiredSockets.length > 0;
|
|
1416
|
+
// Requiring a declared socket to EXIST only makes sense on the browsers this
|
|
1417
|
+
// sequence drives. A multi-browser sequence names its connections per step and
|
|
1418
|
+
// leaves the run's own connection idle - demanding a transport there fails a
|
|
1419
|
+
// healthy run for a browser that was never asked to do anything.
|
|
1420
|
+
//
|
|
1421
|
+
// "Drives" is read from the NAVIGATE steps, because a socket rides on a
|
|
1422
|
+
// loaded app: a connection that never navigated has no page for the
|
|
1423
|
+
// transport to belong to. Inferring it from connection injection instead
|
|
1424
|
+
// failed a healthy three-browser run - its 3 bare steps were `assert`s over
|
|
1425
|
+
// values already captured, which take a connection but load nothing, and
|
|
1426
|
+
// that was enough to demand a sync socket on the run's own idle browser.
|
|
1427
|
+
const stepRefs = analyzeRecordedStepConnections(commands);
|
|
1428
|
+
const navigatedRefs = navigatedConnections(commands, connectionReason);
|
|
1429
|
+
const namedRefs = navigatedRefs.length > 0
|
|
1430
|
+
? navigatedRefs.map(r => connectionMap?.[sanitizeReference(r)] ?? sanitizeReference(r))
|
|
1431
|
+
: stepRefs.references.length > 0 && !stepRefs.mixed
|
|
1432
|
+
? stepRefs.references.map(r => connectionMap?.[sanitizeReference(r)] ?? sanitizeReference(r))
|
|
1433
|
+
: watchedRefs;
|
|
1434
|
+
// Absence is only checked on connections that are BOTH driven and watched, so
|
|
1435
|
+
// a driven ref nobody snapshots would quietly drop out of the check - the
|
|
1436
|
+
// declaration silently stops being enforced, which is the failure this whole
|
|
1437
|
+
// check exists to prevent. Widen the watch list instead of narrowing the
|
|
1438
|
+
// verdict.
|
|
1439
|
+
const unwatchedDriven = namedRefs.filter(r => !watchedRefs.includes(r));
|
|
1440
|
+
if (unwatchedDriven.length > 0)
|
|
1441
|
+
watchedRefs.push(...unwatchedDriven);
|
|
1442
|
+
const drivenRefs = namedRefs;
|
|
1443
|
+
const consoleBefore = args.strict ? await snapshotConsole(watchedRefs, executeToolCall) : {};
|
|
1444
|
+
const socketsBefore = checkSockets ? await snapshotSockets(watchedRefs, executeToolCall) : {};
|
|
1445
|
+
/**
|
|
1446
|
+
* Post-run health verdicts, applied to whichever response the caller will
|
|
1447
|
+
* read. Same rule as a sequence's own teardown: a paused run is not over, and
|
|
1448
|
+
* its browsers are the state someone stopped to look at.
|
|
1449
|
+
*/
|
|
1450
|
+
const applyHealthChecks = async (response, outcome) => {
|
|
1451
|
+
if (outcome === 'paused')
|
|
1452
|
+
return true;
|
|
1453
|
+
let healthy = true;
|
|
1454
|
+
const fail = (heading, failures) => {
|
|
1455
|
+
if (failures.length === 0)
|
|
1456
|
+
return;
|
|
1457
|
+
healthy = false;
|
|
1458
|
+
if (response?.content?.[0]?.text === undefined)
|
|
1459
|
+
return;
|
|
1460
|
+
response.content[0].text += `\n\n${heading}\n${failures.map(f => `- ${f}`).join('\n')}`;
|
|
1461
|
+
response.isError = true;
|
|
1462
|
+
if (response._meta?.replay)
|
|
1463
|
+
response._meta.replay.success = false;
|
|
1464
|
+
};
|
|
1465
|
+
if (checkSockets) {
|
|
1466
|
+
// A declared socket missing at the last step may just be reconnecting, so
|
|
1467
|
+
// give it the same order of grace an in-page liveness assertion gets
|
|
1468
|
+
// rather than calling a recovering transport dead. Closures and frame
|
|
1469
|
+
// errors are settled facts - only absence is worth re-reading.
|
|
1470
|
+
let verdict = socketFailures(socketsBefore, await snapshotSockets(watchedRefs, executeToolCall), requiredSockets, drivenRefs);
|
|
1471
|
+
for (let attempt = 0; verdict.absent.length > 0 && attempt < 5; attempt++) {
|
|
1472
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
1473
|
+
verdict = socketFailures(socketsBefore, await snapshotSockets(watchedRefs, executeToolCall), requiredSockets, drivenRefs);
|
|
1474
|
+
}
|
|
1475
|
+
fail('**Socket health failed** - the transport did not stay up:', [...verdict.settled, ...verdict.absent]);
|
|
1476
|
+
}
|
|
1477
|
+
if (args.strict) {
|
|
1478
|
+
fail('**Strict run failed** - the sequence produced console output:', strictConsoleFailures(consoleBefore, await snapshotConsole(watchedRefs, executeToolCall), args.strict === 'warnings'));
|
|
1479
|
+
}
|
|
1480
|
+
return healthy;
|
|
1481
|
+
};
|
|
1482
|
+
// Closing the browsers the sequence declared, deferred so that every terminal
|
|
1483
|
+
// outcome uses one path: a pause hands the debt to `pendingDeclaredCleanups`
|
|
1484
|
+
// and whatever ends the pause pays it (issues #127, #137).
|
|
1485
|
+
const closeDeclared = () => closeLaunchedConnections(declaredConns.launched, executeToolCall, getConnectionPort, sequence.name);
|
|
1486
|
+
/**
|
|
1487
|
+
* Tear down the browsers this run owns: its own connection, plus any a step
|
|
1488
|
+
* CREATED. Runs AFTER the health verdicts, because both of them interrogate
|
|
1489
|
+
* the browser - a run with `killChromeOnFinish` and declared sockets used to
|
|
1490
|
+
* kill Chrome first and then report "could not read socket health -
|
|
1491
|
+
* Connection not found" as a socket FAILURE. Every such run failed, for a
|
|
1492
|
+
* reason that was an artefact of its own cleanup.
|
|
1493
|
+
*
|
|
1494
|
+
* Ownership is not guessed from the sequence text: a `launchChrome` step
|
|
1495
|
+
* hands back an existing browser when the reference is already bound, which
|
|
1496
|
+
* is the multi-device case where killing would destroy state the user cannot
|
|
1497
|
+
* get back. The launch response says which it was, and only the ones this run
|
|
1498
|
+
* created are killed (issue #103).
|
|
1499
|
+
*
|
|
1500
|
+
* The kill is by PORT, and other connections can share one - a `launchChrome`
|
|
1501
|
+
* step usually opens a TAB in the same instance - so the port is checked for
|
|
1502
|
+
* other tenants first.
|
|
1503
|
+
*/
|
|
1504
|
+
const killOwnedChrome = async () => {
|
|
1505
|
+
if (!args.killChromeOnFinish)
|
|
1506
|
+
return '';
|
|
1507
|
+
let note = '';
|
|
1508
|
+
if (connectionReason && getConnectionPort) {
|
|
1509
|
+
const port = await getConnectionPort(connectionReason);
|
|
1510
|
+
const sharers = port === null ? [] : await connectionsSharingPort(executeToolCall, port, connectionReason);
|
|
1511
|
+
if (sharers.length > 0) {
|
|
1512
|
+
note += `\n\n**Chrome left running** (port ${port} also serves ${sharers.join(', ')}, killChromeOnFinish)` +
|
|
1513
|
+
` - killing it would take those connections with it.`;
|
|
1514
|
+
}
|
|
1515
|
+
else if (port !== null) {
|
|
1516
|
+
const killResult = await executeToolCall('killChrome', {
|
|
1517
|
+
reason: `killChromeOnFinish: sequence "${sequence.name}" completed`,
|
|
1518
|
+
port,
|
|
1519
|
+
}).catch((error) => ({ isError: true, error }));
|
|
1520
|
+
note += killResult?.isError
|
|
1521
|
+
? `\n\n**Chrome kill failed** (${connectionReason}, port ${port}, killChromeOnFinish)`
|
|
1522
|
+
: `\n\n**Chrome killed** (${connectionReason}, port ${port}, killChromeOnFinish)`;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
// The run-level connection is handled above; everything else here is a
|
|
1526
|
+
// browser a step of this run opened and nobody else asked for.
|
|
1527
|
+
const stepOwned = [...deps.launchedConnections].filter(ref => ref !== connectionReason);
|
|
1528
|
+
note += await closeLaunchedConnections(stepOwned, executeToolCall, getConnectionPort, sequence.name, 'launched in a step');
|
|
1529
|
+
return note;
|
|
1530
|
+
};
|
|
1531
|
+
/** Everything a terminal run owes: verdicts first, then teardown. */
|
|
1532
|
+
const settle = async (response, outcome) => {
|
|
1533
|
+
const healthy = await applyHealthChecks(response, outcome);
|
|
1534
|
+
if (outcome === 'paused')
|
|
1535
|
+
return healthy;
|
|
1536
|
+
const notes = (await killOwnedChrome()) + (await closeDeclared());
|
|
1537
|
+
if (notes && response?.content?.[0]?.text !== undefined) {
|
|
1538
|
+
response.content[0].text += notes;
|
|
1539
|
+
}
|
|
1540
|
+
return healthy;
|
|
1541
|
+
};
|
|
1542
|
+
if (args.wait === true) {
|
|
1543
|
+
const { response, outcome } = await performRun(deps, abortSignal);
|
|
1544
|
+
await settle(response, outcome);
|
|
1545
|
+
if (outcome === 'paused') {
|
|
1546
|
+
pendingDeclaredCleanups.set(cleanupKey(undefined, sequence.id), closeDeclared);
|
|
1547
|
+
}
|
|
1548
|
+
return response;
|
|
1549
|
+
}
|
|
1550
|
+
// Background (default): register a run and return a handle immediately.
|
|
1551
|
+
const runId = runRegistry.newRunId();
|
|
1552
|
+
const controller = new AbortController();
|
|
1553
|
+
const record = {
|
|
1554
|
+
runId,
|
|
1555
|
+
sequenceId: sequence.id,
|
|
1556
|
+
sequenceName: sequence.name,
|
|
1557
|
+
connectionReason,
|
|
1558
|
+
status: 'running',
|
|
1559
|
+
startedAt: Date.now(),
|
|
1560
|
+
totalSteps: commands.length,
|
|
1561
|
+
currentStep: 0,
|
|
1562
|
+
results: [],
|
|
1563
|
+
controller,
|
|
1564
|
+
};
|
|
1565
|
+
runRegistry.register(record);
|
|
1566
|
+
performRun(deps, controller.signal, runId, (ev) => {
|
|
1567
|
+
record.currentStep = ev.step;
|
|
1568
|
+
record.currentTool = ev.tool;
|
|
1569
|
+
}).then(async ({ response, outcome, results }) => {
|
|
1570
|
+
// A background run is read through its record, so the verdicts have to land
|
|
1571
|
+
// there too - otherwise the same sequence passes or fails on `wait` alone.
|
|
1572
|
+
const healthy = await settle(response, outcome).catch(() => true);
|
|
1573
|
+
if (outcome === 'paused') {
|
|
1574
|
+
pendingDeclaredCleanups.set(cleanupKey(runId, sequence.id), closeDeclared);
|
|
1575
|
+
}
|
|
1576
|
+
record.finalResponse = response;
|
|
1577
|
+
if (results)
|
|
1578
|
+
record.results = results;
|
|
1579
|
+
record.endedAt = Date.now();
|
|
1580
|
+
// Still not derived by parsing the response: the check reports its own
|
|
1581
|
+
// verdict, and a run whose transport died did not complete successfully.
|
|
1582
|
+
record.status = !healthy && outcome === 'completed' ? 'failed' : outcome;
|
|
1583
|
+
}).catch(async (error) => {
|
|
1584
|
+
// A run that blew up still launched what it launched.
|
|
1585
|
+
await closeDeclared().catch(() => '');
|
|
1586
|
+
record.error = error?.message || String(error);
|
|
1587
|
+
record.endedAt = Date.now();
|
|
1588
|
+
record.status = 'failed';
|
|
1589
|
+
});
|
|
1590
|
+
const started = createSuccessResponse('REPLAY_RUN_STARTED', {
|
|
1591
|
+
runId,
|
|
1592
|
+
name: sequence.name,
|
|
1593
|
+
totalSteps: commands.length,
|
|
1594
|
+
connectionReason: connectionReason || 'none',
|
|
1595
|
+
});
|
|
1596
|
+
started._meta = {
|
|
1597
|
+
tool: 'replay', action: 'run', timestamp: Date.now(),
|
|
1598
|
+
replay: { runId, background: true, totalSteps: commands.length },
|
|
1599
|
+
};
|
|
1600
|
+
return started;
|
|
1601
|
+
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Execute a run to completion: connection setup, cursor/overlay, step
|
|
1604
|
+
* execution, post-run cleanup (cursor/overlay/tab, debug state,
|
|
1605
|
+
* killChromeOnFinish). Everything after the fast validation in handleRun.
|
|
1606
|
+
*
|
|
1607
|
+
* Used by both modes: awaited directly for wait: true, spawned in the
|
|
1608
|
+
* background otherwise. The returned outcome is authoritative for the run
|
|
1609
|
+
* record's terminal status - never derived by parsing the response.
|
|
1610
|
+
*/
|
|
1611
|
+
async function performRun(deps, abortSignal, runId, onProgress) {
|
|
1612
|
+
const { args, recorder, executeToolCall, getPageForConnection, getConnectionPort, sequence, analysis, connectionReason, needsConnection, connectionMap, launchedConnections } = deps;
|
|
1613
|
+
// Build execution context
|
|
1614
|
+
const ctx = {
|
|
1615
|
+
executeToolCall,
|
|
1616
|
+
commandRecorder: recorder,
|
|
1617
|
+
connectionReason: connectionReason,
|
|
1618
|
+
logPrefix: 'run',
|
|
1619
|
+
variableStore: {},
|
|
1620
|
+
launchedConnections,
|
|
1621
|
+
...(connectionMap && { connectionMap })
|
|
1622
|
+
};
|
|
1623
|
+
// Ensure connection is ready
|
|
1624
|
+
let didAutoLaunch = false;
|
|
1625
|
+
if (needsConnection && !analysis.hasLaunchBeforeConnection) {
|
|
1626
|
+
const connResult = await ensureConnection(ctx, needsConnection, analysis.hasLaunchBeforeConnection);
|
|
1627
|
+
if (!connResult.success) {
|
|
1628
|
+
return {
|
|
1629
|
+
outcome: 'failed',
|
|
1630
|
+
response: createErrorResponse('LAUNCH_FAILED', {
|
|
1631
|
+
message: connResult.error,
|
|
1632
|
+
suggestion: 'Launch Chrome manually first'
|
|
1633
|
+
})
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
didAutoLaunch = connResult.didAutoLaunch;
|
|
1637
|
+
}
|
|
1638
|
+
// Navigate to startUrl if needed
|
|
1639
|
+
const navResult = await navigateToStartUrl(ctx, sequence, analysis);
|
|
1640
|
+
if (!navResult.success) {
|
|
1641
|
+
// Close the tab if we auto-launched it
|
|
1642
|
+
if (didAutoLaunch && connectionReason) {
|
|
1643
|
+
await executeToolCall('tab', { action: 'close', reference: connectionReason }).catch(() => { });
|
|
1644
|
+
}
|
|
1645
|
+
return {
|
|
1646
|
+
outcome: 'failed',
|
|
1647
|
+
response: createErrorResponse('NAVIGATION_FAILED', {
|
|
1648
|
+
message: navResult.error,
|
|
1649
|
+
startUrl: sequence.startUrl
|
|
1650
|
+
})
|
|
1651
|
+
};
|
|
1652
|
+
}
|
|
1653
|
+
// Inject cursor if enabled in config
|
|
1654
|
+
let cursorPage = null;
|
|
1655
|
+
if (configManager.getReplayConfig().showCursor && connectionReason) {
|
|
1656
|
+
cursorPage = await getPageForConnection(connectionReason);
|
|
1657
|
+
if (cursorPage) {
|
|
1658
|
+
await injectReplayCursor(cursorPage);
|
|
1659
|
+
setReplayCursorCallbacks({
|
|
1660
|
+
onClickBefore: async (x, y, isRightClick) => {
|
|
1661
|
+
await showClickEffect(cursorPage, x, y, isRightClick);
|
|
1662
|
+
},
|
|
1663
|
+
onKeyPress: async (key) => {
|
|
1664
|
+
await showKeyPress(cursorPage, key);
|
|
1665
|
+
}
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
// Show replay overlay if requested (for issue verification)
|
|
1670
|
+
let cleanupReplayOverlay;
|
|
1671
|
+
if (args.showReplayOverlay && args.issueId && args.issueType && connectionReason) {
|
|
1672
|
+
const overlayPage = cursorPage || await getPageForConnection(connectionReason);
|
|
1673
|
+
if (overlayPage) {
|
|
1674
|
+
cleanupReplayOverlay = await showReplayOverlay(overlayPage, args.issueType, args.issueTitle || 'Verifying issue...', args.issueId);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
// Helper to clean up cursor, overlay, and optionally close tab
|
|
1678
|
+
const cleanup = async (closeTab = false) => {
|
|
1679
|
+
if (cursorPage) {
|
|
1680
|
+
await removeReplayCursor(cursorPage).catch(() => { });
|
|
1681
|
+
setReplayCursorCallbacks({});
|
|
1682
|
+
}
|
|
1683
|
+
if (cleanupReplayOverlay) {
|
|
1684
|
+
await cleanupReplayOverlay().catch(() => { });
|
|
1685
|
+
}
|
|
1686
|
+
if (closeTab && didAutoLaunch && connectionReason) {
|
|
1687
|
+
await executeToolCall('tab', { action: 'close', reference: connectionReason }).catch(() => { });
|
|
1688
|
+
}
|
|
1689
|
+
};
|
|
1690
|
+
// Calculate start step (convert 1-indexed to 0-indexed).
|
|
1691
|
+
// startFrom itself was validated in handleRun, before any side effects.
|
|
1692
|
+
const startStep = args.startFrom ? Math.max(0, args.startFrom - 1) : 0;
|
|
1693
|
+
// Register cleanup handler on abort signal BEFORE execution starts
|
|
1694
|
+
// This ensures cleanup runs even if the tool call is interrupted mid-execution
|
|
1695
|
+
if (abortSignal) {
|
|
1696
|
+
abortSignal.addEventListener('abort', () => { cleanup(true); }, { once: true });
|
|
1697
|
+
}
|
|
1698
|
+
// Execute the sequence
|
|
1699
|
+
const execResult = await executeSequenceWithPause({
|
|
1700
|
+
sequence,
|
|
1701
|
+
startStep,
|
|
1702
|
+
ctx,
|
|
1703
|
+
variables: args.variables,
|
|
1704
|
+
record: args.record,
|
|
1705
|
+
stepTimeout: args.stepTimeout,
|
|
1706
|
+
totalTimeout: args.totalTimeout,
|
|
1707
|
+
stepTo: args.stepTo,
|
|
1708
|
+
overrideConnectionReason: args.connectionReason,
|
|
1709
|
+
abortSignal,
|
|
1710
|
+
onProgress
|
|
1711
|
+
});
|
|
1712
|
+
// Handle abort - return early (cleanup already handled by abort signal listener)
|
|
1713
|
+
if (abortSignal?.aborted) {
|
|
1714
|
+
// results holds every step ATTEMPTED - failures and the abort marker
|
|
1715
|
+
// included - so its length is not a count of completed work. A run that
|
|
1716
|
+
// aborted while a step was failing reported that step as completed.
|
|
1717
|
+
const succeeded = execResult.results.filter(r => r.success).length;
|
|
1718
|
+
const failed = execResult.results.filter(r => !r.success).length;
|
|
1719
|
+
const abortedResponse = createSuccessResponse('REPLAY_ABORTED', {
|
|
1720
|
+
name: sequence.name,
|
|
1721
|
+
completedSteps: succeeded,
|
|
1722
|
+
totalSteps: sequence.commands.length,
|
|
1723
|
+
failedSteps: failed > 0 ? failed : null,
|
|
1724
|
+
message: 'Replay aborted by user'
|
|
1725
|
+
});
|
|
1726
|
+
abortedResponse._meta = {
|
|
1727
|
+
tool: 'replay', action: 'run', timestamp: Date.now(),
|
|
1728
|
+
replay: { success: false, totalSteps: sequence.commands.length, failedSteps: execResult.results.filter(r => !r.success).length, paused: true }
|
|
1729
|
+
};
|
|
1730
|
+
return { response: abortedResponse, outcome: 'cancelled', results: execResult.results };
|
|
1731
|
+
}
|
|
1732
|
+
// Handle breakpoint hit
|
|
1733
|
+
if (execResult.breakpointHit && connectionReason) {
|
|
1734
|
+
return { outcome: 'paused', results: execResult.results, response: { content: [{ type: 'text', text: formatBreakpointHit(sequence.name, execResult.results, execResult.totalCommands, execResult.durationMs, execResult.breakpointHit, connectionReason) }],
|
|
1735
|
+
_meta: {
|
|
1736
|
+
tool: 'replay', action: 'run', timestamp: Date.now(),
|
|
1737
|
+
replay: { success: false, totalSteps: sequence.commands.length, failedSteps: execResult.results.filter(r => !r.success).length, paused: true }
|
|
1738
|
+
}
|
|
1739
|
+
} };
|
|
1740
|
+
}
|
|
1741
|
+
// Handle click validation failure (pause for inspection/retry)
|
|
1742
|
+
if (execResult.clickValidationFailure && connectionReason) {
|
|
1743
|
+
// Set active sequence state so user can retry/continue
|
|
1744
|
+
const activeState = {
|
|
1745
|
+
sequenceId: sequence.id,
|
|
1746
|
+
sequenceName: sequence.name,
|
|
1747
|
+
currentStep: execResult.pausedAtStep - 1, // Back to failed step for retry
|
|
1748
|
+
totalSteps: sequence.commands.length,
|
|
1749
|
+
pausedAt: Date.now(),
|
|
1750
|
+
historyIndexAtPause: recorder.getHistory().length,
|
|
1751
|
+
connectionReason,
|
|
1752
|
+
runId,
|
|
1753
|
+
// step/finish must resolve per-step connections the way this run did
|
|
1754
|
+
...(connectionMap && { connectionMap }),
|
|
1755
|
+
};
|
|
1756
|
+
recorder.setActiveSequence(activeState);
|
|
1757
|
+
return { outcome: 'paused', results: execResult.results, response: { content: [{ type: 'text', text: formatClickValidationFailure(sequence, execResult.results, execResult.pausedAtStep, execResult.durationMs, execResult.clickValidationFailure, connectionReason) }],
|
|
1758
|
+
_meta: {
|
|
1759
|
+
tool: 'replay', action: 'run', timestamp: Date.now(),
|
|
1760
|
+
replay: { success: false, totalSteps: sequence.commands.length, failedSteps: execResult.results.filter(r => !r.success).length, paused: true }
|
|
1761
|
+
} } };
|
|
1762
|
+
}
|
|
1763
|
+
// Handle paused state (stepTo)
|
|
1764
|
+
if (execResult.pausedAtStep && execResult.activeSequenceState) {
|
|
1765
|
+
recorder.setActiveSequence({ ...execResult.activeSequenceState, runId });
|
|
1766
|
+
return { outcome: 'paused', results: execResult.results, response: { content: [{ type: 'text', text: formatPausedResponse(sequence, execResult.results, execResult.pausedAtStep, execResult.durationMs) }],
|
|
1767
|
+
_meta: {
|
|
1768
|
+
tool: 'replay', action: 'run', timestamp: Date.now(),
|
|
1769
|
+
replay: { success: false, totalSteps: sequence.commands.length, failedSteps: execResult.results.filter(r => !r.success).length, paused: true }
|
|
1770
|
+
}
|
|
1771
|
+
} };
|
|
1772
|
+
}
|
|
1773
|
+
// Clean up cursor and overlay
|
|
1774
|
+
await cleanup();
|
|
1775
|
+
// Format results
|
|
1776
|
+
let response = formatExecutionResults(sequence.name, execResult.results, execResult.totalCommands, execResult.durationMs, execResult.teardownResults
|
|
1777
|
+
? { results: execResult.teardownResults, ...(execResult.teardownFailed !== undefined ? { failed: execResult.teardownFailed } : {}) }
|
|
1778
|
+
: undefined);
|
|
1779
|
+
// Add debug state if successful
|
|
1780
|
+
const failed = execResult.results.filter(r => !r.success).length;
|
|
1781
|
+
if (connectionReason && failed === 0) {
|
|
1782
|
+
const debugState = await getDebugState(ctx);
|
|
1783
|
+
if (debugState) {
|
|
1784
|
+
response += formatDebugState(debugState, connectionReason);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
return {
|
|
1788
|
+
outcome: failed === 0 ? 'completed' : 'failed',
|
|
1789
|
+
results: execResult.results,
|
|
1790
|
+
response: { content: [{ type: 'text', text: response }],
|
|
1791
|
+
_meta: {
|
|
1792
|
+
tool: 'replay', action: 'run', timestamp: Date.now(),
|
|
1793
|
+
replay: { success: failed === 0, totalSteps: execResult.totalCommands, failedSteps: failed, paused: false }
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
};
|
|
1797
|
+
}
|
|
1798
|
+
/** One line per known run, newest first, for the no-runId status overview. */
|
|
1799
|
+
function formatRunsOverview(records) {
|
|
1800
|
+
const lines = records.map(r => {
|
|
1801
|
+
const progress = r.status === 'running' || r.status === 'cancelling'
|
|
1802
|
+
? ` - step ${r.currentStep}/${r.totalSteps}${r.currentTool ? ` (${r.currentTool})` : ''}`
|
|
1803
|
+
: ` - ${r.results.filter(s => s.success).length}/${r.totalSteps} steps ok`;
|
|
1804
|
+
return `- \`${r.runId}\` ${r.sequenceName}: **${r.status}**${progress}`;
|
|
1805
|
+
});
|
|
1806
|
+
return `**Runs** (details: \`replay({ action: 'status', runId: '...' })\`)\n${lines.join('\n')}`;
|
|
1807
|
+
}
|
|
1808
|
+
/** Full status for one run. For a settled run this includes the final result. */
|
|
1809
|
+
function formatRunRecord(record) {
|
|
1810
|
+
const elapsed = ((record.endedAt ?? Date.now()) - record.startedAt) / 1000;
|
|
1811
|
+
let text = `**Run \`${record.runId}\`** - ${record.sequenceName}: **${record.status}** (${elapsed.toFixed(1)}s)`;
|
|
1812
|
+
if (record.status === 'running' || record.status === 'cancelling') {
|
|
1813
|
+
text += record.currentStep > 0
|
|
1814
|
+
? `\n\nExecuting step ${record.currentStep}/${record.totalSteps}${record.currentTool ? ` (${record.currentTool})` : ''}.`
|
|
1815
|
+
: `\n\nSetting up (connection/navigation), no step started yet.`;
|
|
1816
|
+
text += `\n\nPoll again with \`replay({ action: 'status', runId: '${record.runId}' })\``;
|
|
1817
|
+
if (record.status === 'running') {
|
|
1818
|
+
text += ` or stop it with \`replay({ action: 'cancel', runId: '${record.runId}' })\`.`;
|
|
1819
|
+
}
|
|
1820
|
+
else {
|
|
1821
|
+
text += `. Cancel was requested; steps that support cancellation stop promptly, others at the next step boundary.`;
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
else if (record.finalResponse?.content?.[0]?.text) {
|
|
1825
|
+
text += `\n\n${record.finalResponse.content[0].text}`;
|
|
1826
|
+
if (record.status === 'paused') {
|
|
1827
|
+
text += `\n\nDrive the paused session with \`replay({ action: 'step' })\` / \`finish\`, or drop it with \`replay({ action: 'cancel', runId: '${record.runId}' })\`.`;
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
else if (record.error) {
|
|
1831
|
+
text += `\n\nRun failed before producing a result: ${record.error}`;
|
|
1832
|
+
}
|
|
1833
|
+
return {
|
|
1834
|
+
content: [{ type: 'text', text }],
|
|
1835
|
+
_meta: {
|
|
1836
|
+
tool: 'replay', action: 'status', timestamp: Date.now(),
|
|
1837
|
+
replay: {
|
|
1838
|
+
runId: record.runId,
|
|
1839
|
+
runStatus: record.status,
|
|
1840
|
+
currentStep: record.currentStep,
|
|
1841
|
+
totalSteps: record.totalSteps,
|
|
1842
|
+
...(record.finalResponse?._meta?.replay ?? {}),
|
|
1843
|
+
},
|
|
1844
|
+
},
|
|
1845
|
+
};
|
|
1846
|
+
}
|
|
1847
|
+
async function handleStatus(args, recorder) {
|
|
1848
|
+
if (args.runId) {
|
|
1849
|
+
const record = runRegistry.get(args.runId);
|
|
1850
|
+
if (!record) {
|
|
1851
|
+
return createErrorResponse('REPLAY_RUN_NOT_FOUND', { runId: args.runId });
|
|
1852
|
+
}
|
|
1853
|
+
return formatRunRecord(record);
|
|
1854
|
+
}
|
|
1855
|
+
const activeSeq = recorder.getActiveSequence();
|
|
1856
|
+
const runs = runRegistry.list();
|
|
1857
|
+
let text;
|
|
1858
|
+
if (activeSeq) {
|
|
1859
|
+
text = formatActiveStatus(activeSeq, recorder.getCommandsSincePause());
|
|
1860
|
+
}
|
|
1861
|
+
else {
|
|
1862
|
+
text = '**No active sequence.** Use `replay({ action: \'run\', name: \'...\', stepTo: N })` to start a step-through session.';
|
|
1863
|
+
}
|
|
1864
|
+
if (runs.length > 0) {
|
|
1865
|
+
text += `\n\n${formatRunsOverview(runs)}`;
|
|
1866
|
+
}
|
|
1867
|
+
return { content: [{ type: 'text', text }] };
|
|
1868
|
+
}
|
|
1869
|
+
/** Cancel one specific registered run, whatever state it is in. */
|
|
1870
|
+
async function cancelRunRecord(record, recorder) {
|
|
1871
|
+
if (record.status === 'running' || record.status === 'cancelling') {
|
|
1872
|
+
record.status = 'cancelling';
|
|
1873
|
+
record.controller.abort();
|
|
1874
|
+
return createSuccessResponse('REPLAY_RUN_CANCELLING', {
|
|
1875
|
+
runId: record.runId,
|
|
1876
|
+
name: record.sequenceName,
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
if (record.status === 'paused') {
|
|
1880
|
+
const activeSeq = recorder.getActiveSequence();
|
|
1881
|
+
if (activeSeq?.runId === record.runId) {
|
|
1882
|
+
recorder.setActiveSequence(null);
|
|
1883
|
+
}
|
|
1884
|
+
record.status = 'cancelled';
|
|
1885
|
+
record.endedAt = record.endedAt ?? Date.now();
|
|
1886
|
+
// Cancelling ends the run, so it cleans up like any other terminal outcome.
|
|
1887
|
+
const closedNote = await drainDeclaredCleanup(record.runId, record.sequenceId);
|
|
1888
|
+
const response = createSuccessResponse('REPLAY_RUN_CANCELLED', {
|
|
1889
|
+
runId: record.runId,
|
|
1890
|
+
name: record.sequenceName,
|
|
1891
|
+
});
|
|
1892
|
+
if (closedNote)
|
|
1893
|
+
response.content[0].text += closedNote;
|
|
1894
|
+
return response;
|
|
1895
|
+
}
|
|
1896
|
+
return createSuccessResponse('REPLAY_RUN_ALREADY_FINISHED', {
|
|
1897
|
+
runId: record.runId,
|
|
1898
|
+
name: record.sequenceName,
|
|
1899
|
+
status: record.status,
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
async function handleCancel(args, recorder) {
|
|
1903
|
+
// Explicit runId wins: cancel exactly that run.
|
|
1904
|
+
if (args.runId) {
|
|
1905
|
+
const record = runRegistry.get(args.runId);
|
|
1906
|
+
if (!record) {
|
|
1907
|
+
return createErrorResponse('REPLAY_RUN_NOT_FOUND', { runId: args.runId });
|
|
1908
|
+
}
|
|
1909
|
+
return cancelRunRecord(record, recorder);
|
|
1910
|
+
}
|
|
1911
|
+
// No runId: a paused step-through session takes precedence (pre-0.7
|
|
1912
|
+
// behaviour - `cancel` always meant "drop the paused session").
|
|
1913
|
+
const activeSeq = recorder.getActiveSequence();
|
|
1914
|
+
if (activeSeq) {
|
|
1915
|
+
if (activeSeq.runId) {
|
|
1916
|
+
const record = runRegistry.get(activeSeq.runId);
|
|
1917
|
+
if (record && record.status === 'paused') {
|
|
1918
|
+
record.status = 'cancelled';
|
|
1919
|
+
record.endedAt = record.endedAt ?? Date.now();
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
const name = activeSeq.sequenceName;
|
|
1923
|
+
recorder.setActiveSequence(null);
|
|
1924
|
+
// Terminal: close what the paused run launched, whichever way it paused
|
|
1925
|
+
// (a `wait: true` pause registers no run record, hence the sequence key).
|
|
1926
|
+
const closedNote = await drainDeclaredCleanup(activeSeq.runId, activeSeq.sequenceId);
|
|
1927
|
+
return { content: [{ type: 'text', text: `**Cancelled:** ${name}${closedNote}` }] };
|
|
1928
|
+
}
|
|
1929
|
+
// No paused session: fall through to background runs. Unambiguous only if
|
|
1930
|
+
// exactly one is still executing.
|
|
1931
|
+
const active = runRegistry.active();
|
|
1932
|
+
if (active.length === 1) {
|
|
1933
|
+
return cancelRunRecord(active[0], recorder);
|
|
1934
|
+
}
|
|
1935
|
+
if (active.length > 1) {
|
|
1936
|
+
return createErrorResponse('REPLAY_RUN_AMBIGUOUS', {
|
|
1937
|
+
count: active.length,
|
|
1938
|
+
runList: active.map(r => `\`${r.runId}\` (${r.sequenceName}, step ${r.currentStep}/${r.totalSteps})`).join(', '),
|
|
1939
|
+
});
|
|
1940
|
+
}
|
|
1941
|
+
return { content: [{ type: 'text', text: '**No active sequence to cancel.**' }] };
|
|
1942
|
+
}
|
|
1943
|
+
async function handleStep(args, recorder, executeToolCall) {
|
|
1944
|
+
const activeSeq = recorder.getActiveSequence();
|
|
1945
|
+
if (!activeSeq) {
|
|
1946
|
+
return createErrorResponse('NO_ACTIVE_SEQUENCE', {
|
|
1947
|
+
message: 'No active sequence to step through. Use run with stepTo first.'
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
const sequence = recorder.getSequence(activeSeq.sequenceId);
|
|
1951
|
+
if (!sequence) {
|
|
1952
|
+
recorder.setActiveSequence(null);
|
|
1953
|
+
return createErrorResponse('SEQUENCE_NOT_FOUND', {
|
|
1954
|
+
message: `Sequence ${activeSeq.sequenceId} no longer exists`
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
const commands = sequence.commands;
|
|
1958
|
+
const stepCount = args.stepCount || 1;
|
|
1959
|
+
const startStep = activeSeq.currentStep;
|
|
1960
|
+
const endStep = Math.min(startStep + stepCount, commands.length);
|
|
1961
|
+
if (startStep >= commands.length) {
|
|
1962
|
+
recorder.setActiveSequence(null);
|
|
1963
|
+
const closedNote = await drainDeclaredCleanup(activeSeq.runId, activeSeq.sequenceId);
|
|
1964
|
+
return { content: [{ type: 'text', text: `**Sequence complete.** All ${commands.length} steps executed.${closedNote}` }] };
|
|
1965
|
+
}
|
|
1966
|
+
const ctx = {
|
|
1967
|
+
executeToolCall,
|
|
1968
|
+
commandRecorder: recorder,
|
|
1969
|
+
connectionReason: activeSeq.connectionReason,
|
|
1970
|
+
logPrefix: 'step',
|
|
1971
|
+
variableStore: activeSeq.capturedVariables ?? (activeSeq.capturedVariables = {}),
|
|
1972
|
+
runTimestamp: activeSeq.runTimestamp ?? (activeSeq.runTimestamp = Date.now()),
|
|
1973
|
+
// per-step connections resolve exactly as they did in the run that paused
|
|
1974
|
+
...(activeSeq.connectionMap && { connectionMap: activeSeq.connectionMap })
|
|
1975
|
+
};
|
|
1976
|
+
const execResult = await executeSteps({
|
|
1977
|
+
sequence,
|
|
1978
|
+
startStep,
|
|
1979
|
+
endStep,
|
|
1980
|
+
ctx
|
|
1981
|
+
});
|
|
1982
|
+
const lastExecuted = execResult.results.length > 0 ? execResult.results[execResult.results.length - 1].step : startStep;
|
|
1983
|
+
const failed = execResult.results.some(r => !r.success);
|
|
1984
|
+
// Update active sequence state
|
|
1985
|
+
let closedNote = '';
|
|
1986
|
+
if (failed || lastExecuted >= commands.length) {
|
|
1987
|
+
recorder.setActiveSequence(null);
|
|
1988
|
+
// Stepping off the end (or onto a failure) ends the run: same cleanup a
|
|
1989
|
+
// straight-through run gets.
|
|
1990
|
+
closedNote = await drainDeclaredCleanup(activeSeq.runId, activeSeq.sequenceId);
|
|
1991
|
+
}
|
|
1992
|
+
else {
|
|
1993
|
+
recorder.updateActiveSequenceStep(lastExecuted);
|
|
1994
|
+
}
|
|
1995
|
+
return { content: [{ type: 'text', text: formatStepResults(sequence.name, execResult.results, startStep, commands.length, failed) + closedNote }] };
|
|
1996
|
+
}
|
|
1997
|
+
async function handleFinish(recorder, executeToolCall) {
|
|
1998
|
+
const activeSeq = recorder.getActiveSequence();
|
|
1999
|
+
if (!activeSeq) {
|
|
2000
|
+
return createErrorResponse('NO_ACTIVE_SEQUENCE', {
|
|
2001
|
+
message: 'No active sequence to finish. Use run with stepTo first.'
|
|
2002
|
+
});
|
|
2003
|
+
}
|
|
2004
|
+
const sequence = recorder.getSequence(activeSeq.sequenceId);
|
|
2005
|
+
if (!sequence) {
|
|
2006
|
+
recorder.setActiveSequence(null);
|
|
2007
|
+
return createErrorResponse('SEQUENCE_NOT_FOUND', {
|
|
2008
|
+
message: `Sequence ${activeSeq.sequenceId} no longer exists`
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
const commands = sequence.commands;
|
|
2012
|
+
const startStep = activeSeq.currentStep;
|
|
2013
|
+
if (startStep >= commands.length) {
|
|
2014
|
+
recorder.setActiveSequence(null);
|
|
2015
|
+
const alreadyDone = await drainDeclaredCleanup(activeSeq.runId, activeSeq.sequenceId);
|
|
2016
|
+
return { content: [{ type: 'text', text: `**Sequence already complete.** All ${commands.length} steps executed.${alreadyDone}` }] };
|
|
2017
|
+
}
|
|
2018
|
+
const ctx = {
|
|
2019
|
+
executeToolCall,
|
|
2020
|
+
commandRecorder: recorder,
|
|
2021
|
+
connectionReason: activeSeq.connectionReason,
|
|
2022
|
+
logPrefix: 'finish',
|
|
2023
|
+
variableStore: activeSeq.capturedVariables ?? (activeSeq.capturedVariables = {}),
|
|
2024
|
+
runTimestamp: activeSeq.runTimestamp ?? (activeSeq.runTimestamp = Date.now()),
|
|
2025
|
+
...(activeSeq.connectionMap && { connectionMap: activeSeq.connectionMap })
|
|
2026
|
+
};
|
|
2027
|
+
const execResult = await executeSteps({
|
|
2028
|
+
sequence,
|
|
2029
|
+
startStep,
|
|
2030
|
+
ctx
|
|
2031
|
+
});
|
|
2032
|
+
// Clear active sequence
|
|
2033
|
+
recorder.setActiveSequence(null);
|
|
2034
|
+
const closedNote = await drainDeclaredCleanup(activeSeq.runId, activeSeq.sequenceId);
|
|
2035
|
+
return { content: [{ type: 'text', text: formatExecutionResults(sequence.name, execResult.results, commands.length, execResult.durationMs) + closedNote }] };
|
|
2036
|
+
}
|
|
2037
|
+
async function handleInsert(args, recorder) {
|
|
2038
|
+
const activeSeq = recorder.getActiveSequence();
|
|
2039
|
+
if (!activeSeq) {
|
|
2040
|
+
return createErrorResponse('NO_ACTIVE_SEQUENCE', {
|
|
2041
|
+
message: 'No active sequence. Use run with stepTo first to pause a sequence.'
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
2044
|
+
const sequence = recorder.getSequence(activeSeq.sequenceId);
|
|
2045
|
+
if (!sequence) {
|
|
2046
|
+
return createErrorResponse('SEQUENCE_NOT_FOUND', {
|
|
2047
|
+
message: `Sequence ${activeSeq.sequenceId} no longer exists`
|
|
2048
|
+
});
|
|
2049
|
+
}
|
|
2050
|
+
const commandsSincePause = recorder.getCommandsSincePause();
|
|
2051
|
+
// If no insertIndices provided, show available commands
|
|
2052
|
+
if (!args.insertIndices || args.insertIndices.length === 0) {
|
|
2053
|
+
return { content: [{ type: 'text', text: formatInsertPrompt(sequence.name, commandsSincePause, activeSeq.currentStep, activeSeq.totalSteps) }] };
|
|
2054
|
+
}
|
|
2055
|
+
// Check if history was viewed first (required before insert with indices)
|
|
2056
|
+
if (!recorder.wasHistoryViewed()) {
|
|
2057
|
+
return {
|
|
2058
|
+
content: [{
|
|
2059
|
+
type: 'text',
|
|
2060
|
+
text: '**Run `replay({ action: \'history\' })` first** to see available commands and their indices before inserting.'
|
|
2061
|
+
}],
|
|
2062
|
+
isError: true
|
|
2063
|
+
};
|
|
2064
|
+
}
|
|
2065
|
+
// Validate indices
|
|
2066
|
+
const validIndices = args.insertIndices.filter(idx => commandsSincePause.some(cmd => cmd.index === idx));
|
|
2067
|
+
if (validIndices.length === 0) {
|
|
2068
|
+
let errorMsg = 'None of the provided indices are valid commands recorded since pause.\n\n';
|
|
2069
|
+
errorMsg += '**Run `replay({ action: \'history\' })` again** to see available commands and their indices.\n\n';
|
|
2070
|
+
if (commandsSincePause.length > 0) {
|
|
2071
|
+
errorMsg += `Valid indices since pause: ${commandsSincePause.map(c => c.index).join(', ')}`;
|
|
2072
|
+
}
|
|
2073
|
+
else {
|
|
2074
|
+
errorMsg += 'No commands have been recorded since the sequence was paused.';
|
|
2075
|
+
}
|
|
2076
|
+
return { content: [{ type: 'text', text: errorMsg }], isError: true };
|
|
2077
|
+
}
|
|
2078
|
+
// Get commands to insert
|
|
2079
|
+
const commandsToInsert = validIndices
|
|
2080
|
+
.map(idx => commandsSincePause.find(cmd => cmd.index === idx))
|
|
2081
|
+
.filter((cmd) => cmd !== undefined)
|
|
2082
|
+
.map(cmd => ({ tool: cmd.tool, params: cmd.params }));
|
|
2083
|
+
// Determine insert position
|
|
2084
|
+
const insertAfter = args.insertAfterStep !== undefined ? args.insertAfterStep : activeSeq.currentStep;
|
|
2085
|
+
// Build new commands array. Inserted history commands carry the connection they
|
|
2086
|
+
// were driven against (bug-018), so re-run the create-time normalization: an
|
|
2087
|
+
// insert into a single-connection sequence must not quietly pin those steps to
|
|
2088
|
+
// this session's reference and make the sequence unportable.
|
|
2089
|
+
// The sequence's own steps are bare because `create` hoisted their connection
|
|
2090
|
+
// off; re-stamp it first. Merging without that made every insert look
|
|
2091
|
+
// "ambiguous" (one named reference + bare steps), which blocks the hoist and
|
|
2092
|
+
// leaves the sequence half-pinned to this session's reference - unportable,
|
|
2093
|
+
// and green on a run that splits it across two browsers.
|
|
2094
|
+
const existingCommands = rehydrateStepConnections(sequence);
|
|
2095
|
+
const normalized = normalizeStepConnections([
|
|
2096
|
+
...existingCommands.slice(0, insertAfter),
|
|
2097
|
+
...commandsToInsert,
|
|
2098
|
+
...existingCommands.slice(insertAfter)
|
|
2099
|
+
]);
|
|
2100
|
+
const newCommands = normalized.commands;
|
|
2101
|
+
const connectionNote = formatConnectionNote(normalized);
|
|
2102
|
+
if (args.overwrite) {
|
|
2103
|
+
// Update existing sequence in place
|
|
2104
|
+
sequence.commands = newCommands;
|
|
2105
|
+
if (normalized.hoisted)
|
|
2106
|
+
sequence.recordedConnection = normalized.hoisted;
|
|
2107
|
+
else
|
|
2108
|
+
delete sequence.recordedConnection;
|
|
2109
|
+
return { content: [{ type: 'text', text: formatInsertResult(sequence.name, sequence.id, commandsToInsert.length, insertAfter, newCommands.length, true) + connectionNote }] };
|
|
2110
|
+
}
|
|
2111
|
+
else {
|
|
2112
|
+
// Create new sequence
|
|
2113
|
+
const newName = args.newName || `${sequence.name}-modified`;
|
|
2114
|
+
const newSequence = await recorder.createSequence(newName, [], { description: sequence.description, expectedOutcome: sequence.expectedOutcome, startUrl: sequence.startUrl });
|
|
2115
|
+
if (!newSequence) {
|
|
2116
|
+
return createErrorResponse('CREATE_FAILED', { message: 'Failed to create new sequence' });
|
|
2117
|
+
}
|
|
2118
|
+
// Manually set commands
|
|
2119
|
+
newSequence.commands = newCommands;
|
|
2120
|
+
if (normalized.hoisted)
|
|
2121
|
+
newSequence.recordedConnection = normalized.hoisted;
|
|
2122
|
+
return { content: [{ type: 'text', text: formatInsertResult(newName, newSequence.id, commandsToInsert.length, insertAfter, newCommands.length, false) + connectionNote }] };
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
/**
|
|
2126
|
+
* Add a `conditional` step to a sequence.
|
|
2127
|
+
*
|
|
2128
|
+
* `conditional` is a virtual step, never a registered tool, so it cannot be
|
|
2129
|
+
* recorded and cannot come out of `create`/`insert`. This is its only
|
|
2130
|
+
* authoring route.
|
|
2131
|
+
*/
|
|
2132
|
+
async function handleAddConditional(args, recorder) {
|
|
2133
|
+
if (!args.condition) {
|
|
2134
|
+
return createErrorResponse('MISSING_PARAMETER', {
|
|
2135
|
+
action: 'addConditional',
|
|
2136
|
+
missing: 'condition',
|
|
2137
|
+
message: 'The "addConditional" action requires a "condition" parameter, e.g. "{{selector:.cookie-banner}}"'
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
if (!args.thenSequence) {
|
|
2141
|
+
return createErrorResponse('MISSING_PARAMETER', {
|
|
2142
|
+
action: 'addConditional',
|
|
2143
|
+
missing: 'thenSequence',
|
|
2144
|
+
message: 'The "addConditional" action requires a "thenSequence" parameter naming the sequence to run when the condition holds'
|
|
2145
|
+
});
|
|
2146
|
+
}
|
|
2147
|
+
const loadResult = await loadSequence({ name: args.name, sequenceId: args.sequenceId }, recorder);
|
|
2148
|
+
if (!loadResult.success) {
|
|
2149
|
+
return handleLoadSequenceError(loadResult, 'addConditional');
|
|
2150
|
+
}
|
|
2151
|
+
const sequence = loadResult.sequence;
|
|
2152
|
+
const syntax = validateConditionSyntax(args.condition);
|
|
2153
|
+
if (!syntax.ok) {
|
|
2154
|
+
return createErrorResponse('INVALID_PARAMETER', {
|
|
2155
|
+
parameter: 'condition',
|
|
2156
|
+
value: args.condition,
|
|
2157
|
+
message: syntax.reason
|
|
2158
|
+
});
|
|
2159
|
+
}
|
|
2160
|
+
// Self-reference recurses until the depth cap truncates it.
|
|
2161
|
+
if (args.thenSequence === sequence.name) {
|
|
2162
|
+
return createErrorResponse('INVALID_PARAMETER', {
|
|
2163
|
+
parameter: 'thenSequence',
|
|
2164
|
+
value: args.thenSequence,
|
|
2165
|
+
message: `A conditional cannot branch to its own sequence ("${sequence.name}") - that recurses until maxConditionalDepth stops it.`
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
// The target resolves by name at run time, so an unchecked typo fails
|
|
2169
|
+
// halfway through a run.
|
|
2170
|
+
const inMemory = recorder.listSequences().some(s => s.name === args.thenSequence);
|
|
2171
|
+
const onDisk = await recorder.listSavedSequencesOnDisk();
|
|
2172
|
+
if (!inMemory && !onDisk.some(s => s.name === args.thenSequence)) {
|
|
2173
|
+
// A disk sequence is in memory once loaded, so the lists overlap.
|
|
2174
|
+
const available = [...new Set([
|
|
2175
|
+
...recorder.listSequences().map(s => s.name),
|
|
2176
|
+
...onDisk.map(s => s.name)
|
|
2177
|
+
])];
|
|
2178
|
+
return createErrorResponse('SEQUENCE_NOT_FOUND', {
|
|
2179
|
+
message: `No sequence named "${args.thenSequence}" to branch to. Available: ${available.join(', ') || 'none'}`
|
|
2180
|
+
});
|
|
2181
|
+
}
|
|
2182
|
+
const commands = sequence.commands;
|
|
2183
|
+
const insertAfter = args.insertAfterStep !== undefined ? args.insertAfterStep : commands.length;
|
|
2184
|
+
if (insertAfter < 0 || insertAfter > commands.length) {
|
|
2185
|
+
return createErrorResponse('INVALID_PARAMETER', {
|
|
2186
|
+
parameter: 'insertAfterStep',
|
|
2187
|
+
value: String(insertAfter),
|
|
2188
|
+
message: `insertAfterStep must be between 0 (before the first step) and ${commands.length} (after the last). Omit it to append.`
|
|
2189
|
+
});
|
|
2190
|
+
}
|
|
2191
|
+
const step = {
|
|
2192
|
+
tool: 'conditional',
|
|
2193
|
+
params: { if: args.condition, then: args.thenSequence },
|
|
2194
|
+
...(args.comment ? { comment: args.comment } : {})
|
|
2195
|
+
};
|
|
2196
|
+
sequence.commands = [
|
|
2197
|
+
...commands.slice(0, insertAfter),
|
|
2198
|
+
step,
|
|
2199
|
+
...commands.slice(insertAfter)
|
|
2200
|
+
];
|
|
2201
|
+
// Write back to the file this came from; a memory-only sequence waits for
|
|
2202
|
+
// `export`, which is where it gets its filename.
|
|
2203
|
+
let persisted;
|
|
2204
|
+
const existingFile = onDisk.find(s => s.name === sequence.name);
|
|
2205
|
+
if (existingFile) {
|
|
2206
|
+
const saved = await recorder.saveSequenceToDisk(sequence.id, existingFile.location === 'global', true);
|
|
2207
|
+
if (saved?.success)
|
|
2208
|
+
persisted = saved.filepath;
|
|
2209
|
+
}
|
|
2210
|
+
return {
|
|
2211
|
+
content: [{
|
|
2212
|
+
type: 'text',
|
|
2213
|
+
text: formatConditionalAdded({
|
|
2214
|
+
sequenceName: sequence.name,
|
|
2215
|
+
condition: args.condition,
|
|
2216
|
+
thenSequence: args.thenSequence,
|
|
2217
|
+
position: insertAfter,
|
|
2218
|
+
totalSteps: sequence.commands.length,
|
|
2219
|
+
persistedTo: persisted
|
|
2220
|
+
})
|
|
2221
|
+
}]
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
/**
|
|
2225
|
+
* Tidy a tag list into the form selection can rely on: trimmed, lowercased,
|
|
2226
|
+
* de-duplicated, order preserved.
|
|
2227
|
+
*
|
|
2228
|
+
* Case and stray whitespace are normalised rather than rejected because a tag
|
|
2229
|
+
* is matched, not displayed - `runAll({ tags: ['UI'] })` skipping a sequence
|
|
2230
|
+
* tagged `ui` would be a silent miss, which for a suite means quietly running
|
|
2231
|
+
* less than you asked for.
|
|
2232
|
+
*/
|
|
2233
|
+
function normalizeTags(tags) {
|
|
2234
|
+
const out = [];
|
|
2235
|
+
for (const raw of tags) {
|
|
2236
|
+
const tag = raw.trim().toLowerCase();
|
|
2237
|
+
if (!tag) {
|
|
2238
|
+
return { error: 'An empty tag cannot select anything - drop it, or pass [] to clear the list.' };
|
|
2239
|
+
}
|
|
2240
|
+
if (/\s/.test(tag)) {
|
|
2241
|
+
return { error: `"${raw.trim()}" contains a space. Tags are single words so they stay unambiguous in a filter - use a hyphen ("${tag.replace(/\s+/g, '-')}").` };
|
|
2242
|
+
}
|
|
2243
|
+
if (!out.includes(tag))
|
|
2244
|
+
out.push(tag);
|
|
2245
|
+
}
|
|
2246
|
+
return { tags: out };
|
|
2247
|
+
}
|
|
2248
|
+
/**
|
|
2249
|
+
* Set what a sequence DECLARES: the browsers it needs, the sockets its
|
|
2250
|
+
* assertions ride on, and what kind of sequence it is.
|
|
2251
|
+
*
|
|
2252
|
+
* Declarations cannot be recorded - they are statements about a run, not steps
|
|
2253
|
+
* in it - so before this the only way to add them was to open the JSON and
|
|
2254
|
+
* type them in, against advice that otherwise says to keep sequences inside
|
|
2255
|
+
* the tools. That also put them squarely in the path of the bug where an
|
|
2256
|
+
* edited file was shadowed by the copy in memory.
|
|
2257
|
+
*
|
|
2258
|
+
* Each list REPLACES its field, and `[]` clears it: a declaration set is a
|
|
2259
|
+
* whole statement about the run, and merging would make "remove the second
|
|
2260
|
+
* browser" unexpressible.
|
|
2261
|
+
*/
|
|
2262
|
+
async function handleDeclare(args, recorder) {
|
|
2263
|
+
if (args.requiredConnections === undefined && args.requiredSockets === undefined && args.tags === undefined) {
|
|
2264
|
+
return createErrorResponse('MISSING_PARAMETER', {
|
|
2265
|
+
action: 'declare',
|
|
2266
|
+
missing: 'requiredConnections, requiredSockets or tags',
|
|
2267
|
+
message: 'The "declare" action needs at least one of "requiredConnections" (browsers the sequence needs), ' +
|
|
2268
|
+
'"requiredSockets" (URL substrings of the WebSockets its assertions ride on), or "tags" (what kind of ' +
|
|
2269
|
+
'sequence this is, which runAll selects on). Pass [] to clear one.',
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
const loadResult = await loadSequence({ name: args.name, sequenceId: args.sequenceId }, recorder);
|
|
2273
|
+
if (!loadResult.success) {
|
|
2274
|
+
return handleLoadSequenceError(loadResult, 'declare');
|
|
2275
|
+
}
|
|
2276
|
+
const sequence = loadResult.sequence;
|
|
2277
|
+
if (args.requiredConnections !== undefined) {
|
|
2278
|
+
const seen = new Map();
|
|
2279
|
+
for (const decl of args.requiredConnections) {
|
|
2280
|
+
const reference = sanitizeReference(decl.reference);
|
|
2281
|
+
if (!reference) {
|
|
2282
|
+
return createErrorResponse('INVALID_PARAMETER', {
|
|
2283
|
+
parameter: 'requiredConnections',
|
|
2284
|
+
value: decl.reference,
|
|
2285
|
+
message: `"${decl.reference}" is not a usable connection reference.`,
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
if (seen.has(reference)) {
|
|
2289
|
+
return createErrorResponse('INVALID_PARAMETER', {
|
|
2290
|
+
parameter: 'requiredConnections',
|
|
2291
|
+
value: reference,
|
|
2292
|
+
message: `"${reference}" is declared twice. One entry per browser - a second entry cannot mean anything the first does not.`,
|
|
2293
|
+
});
|
|
2294
|
+
}
|
|
2295
|
+
seen.set(reference, decl.profile ?? '');
|
|
2296
|
+
if (decl.profile) {
|
|
2297
|
+
try {
|
|
2298
|
+
normalizeProfileName(decl.profile);
|
|
2299
|
+
}
|
|
2300
|
+
catch (err) {
|
|
2301
|
+
return createErrorResponse('INVALID_PARAMETER', {
|
|
2302
|
+
parameter: 'requiredConnections',
|
|
2303
|
+
value: decl.profile,
|
|
2304
|
+
message: err?.message || String(err),
|
|
2305
|
+
});
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
// Same rule the run enforces, applied at authoring time so it fails while
|
|
2310
|
+
// you are writing the declaration rather than on the next run.
|
|
2311
|
+
const conflict = declaredProfileConflict(args.requiredConnections, undefined);
|
|
2312
|
+
if (conflict) {
|
|
2313
|
+
return createErrorResponse('INVALID_PARAMETER', {
|
|
2314
|
+
parameter: 'requiredConnections',
|
|
2315
|
+
value: sequence.name,
|
|
2316
|
+
message: conflict,
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
2319
|
+
sequence.requiredConnections = args.requiredConnections.length > 0
|
|
2320
|
+
? args.requiredConnections.map(d => ({ ...d, reference: sanitizeReference(d.reference) }))
|
|
2321
|
+
: undefined;
|
|
2322
|
+
}
|
|
2323
|
+
if (args.requiredSockets !== undefined) {
|
|
2324
|
+
const blank = args.requiredSockets.find(s => s.trim().length === 0);
|
|
2325
|
+
if (blank !== undefined) {
|
|
2326
|
+
return createErrorResponse('INVALID_PARAMETER', {
|
|
2327
|
+
parameter: 'requiredSockets',
|
|
2328
|
+
value: '(empty string)',
|
|
2329
|
+
message: 'An empty socket pattern matches every socket, including the dev server\'s own - name the path your app uses, e.g. "/api/sync/socket".',
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
sequence.requiredSockets = args.requiredSockets.length > 0 ? args.requiredSockets : undefined;
|
|
2333
|
+
}
|
|
2334
|
+
if (args.tags !== undefined) {
|
|
2335
|
+
const cleaned = normalizeTags(args.tags);
|
|
2336
|
+
if ('error' in cleaned) {
|
|
2337
|
+
return createErrorResponse('INVALID_PARAMETER', {
|
|
2338
|
+
parameter: 'tags',
|
|
2339
|
+
value: args.tags.join(', '),
|
|
2340
|
+
message: cleaned.error,
|
|
2341
|
+
});
|
|
2342
|
+
}
|
|
2343
|
+
sequence.tags = cleaned.tags.length > 0 ? cleaned.tags : undefined;
|
|
2344
|
+
}
|
|
2345
|
+
// Write back to the file this came from; a memory-only sequence waits for
|
|
2346
|
+
// `export`, which is where it gets its filename.
|
|
2347
|
+
let persisted;
|
|
2348
|
+
const existingFile = (await recorder.listSavedSequencesOnDisk())
|
|
2349
|
+
.find(s => s.name === sequence.name);
|
|
2350
|
+
if (existingFile) {
|
|
2351
|
+
const saved = await recorder.saveSequenceToDisk(sequence.id, existingFile.location === 'global', true);
|
|
2352
|
+
if (saved?.success)
|
|
2353
|
+
persisted = saved.filepath;
|
|
2354
|
+
}
|
|
2355
|
+
return {
|
|
2356
|
+
content: [{
|
|
2357
|
+
type: 'text',
|
|
2358
|
+
text: formatDeclarations(sequence, persisted),
|
|
2359
|
+
}],
|
|
2360
|
+
};
|
|
2361
|
+
}
|
|
2362
|
+
// =============================================================================
|
|
2363
|
+
// Interaction Recording Handlers
|
|
2364
|
+
// =============================================================================
|
|
2365
|
+
async function handleRecordInteraction(args, executeToolCall, getPageForConnection, recorder, abortSignal) {
|
|
2366
|
+
if (!args.connectionReason) {
|
|
2367
|
+
return createErrorResponse('MISSING_PARAMETER', {
|
|
2368
|
+
action: 'recordInteraction',
|
|
2369
|
+
missing: 'connectionReason',
|
|
2370
|
+
message: 'The "recordInteraction" action requires a "connectionReason" to identify the browser tab'
|
|
2371
|
+
});
|
|
2372
|
+
}
|
|
2373
|
+
/**
|
|
2374
|
+
* The failure text, or null when the navigation worked. A failed goto THROWS
|
|
2375
|
+
* in production (executeToolCall rethrows isError), so the NAVIGATION_FAILED
|
|
2376
|
+
* responses below never fired and the recorder went on to record against
|
|
2377
|
+
* whatever page happened to be open - the same try/catch shape
|
|
2378
|
+
* navigateToStartUrl already uses.
|
|
2379
|
+
*/
|
|
2380
|
+
const navigateTo = async (url) => {
|
|
2381
|
+
try {
|
|
2382
|
+
const navResult = await executeToolCall('navigate', {
|
|
2383
|
+
action: 'goto',
|
|
2384
|
+
connectionReason: args.connectionReason,
|
|
2385
|
+
url
|
|
2386
|
+
});
|
|
2387
|
+
return navResult?.isError ? (navResult?.content?.[0]?.text || 'Unknown error') : null;
|
|
2388
|
+
}
|
|
2389
|
+
catch (navError) {
|
|
2390
|
+
return navError?.response?.content?.[0]?.text || navError?.message || 'Unknown error';
|
|
2391
|
+
}
|
|
2392
|
+
};
|
|
2393
|
+
if (!getPageForConnection) {
|
|
2394
|
+
return createErrorResponse('NOT_SUPPORTED', {
|
|
2395
|
+
message: 'Interaction recording is not supported in this context'
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2398
|
+
// If issueId is provided, look up the issue and use its details
|
|
2399
|
+
let issueId = args.issueId;
|
|
2400
|
+
let issueType = args.issueType;
|
|
2401
|
+
let issueTitle = args.issueTitle;
|
|
2402
|
+
let startUrl = args.startUrl;
|
|
2403
|
+
if (issueId) {
|
|
2404
|
+
const issue = await getIssue(issueId);
|
|
2405
|
+
if (!issue) {
|
|
2406
|
+
return createErrorResponse('ISSUES_NOT_FOUND', {
|
|
2407
|
+
id: issueId,
|
|
2408
|
+
message: `Issue #${issueId} not found`
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
// Use issue details (override any provided args)
|
|
2412
|
+
issueType = issue.type;
|
|
2413
|
+
issueTitle = issue.title;
|
|
2414
|
+
startUrl = startUrl || issue.startUrl; // Use provided startUrl or fall back to issue's startUrl
|
|
2415
|
+
}
|
|
2416
|
+
let page = await getPageForConnection(args.connectionReason);
|
|
2417
|
+
// Auto-launch Chrome if no connection found (requires startUrl)
|
|
2418
|
+
if (!page) {
|
|
2419
|
+
if (!startUrl) {
|
|
2420
|
+
return createErrorResponse('MISSING_PARAMETER', {
|
|
2421
|
+
action: 'recordInteraction',
|
|
2422
|
+
missing: 'startUrl',
|
|
2423
|
+
message: 'Chrome is not running. Provide a "startUrl" or "issueId" (with startUrl) to auto-launch Chrome and navigate before recording.'
|
|
2424
|
+
});
|
|
2425
|
+
}
|
|
2426
|
+
const launchResult = await autoLaunchChrome(executeToolCall, args.connectionReason, 'recordInteraction');
|
|
2427
|
+
if (!launchResult.success) {
|
|
2428
|
+
return createErrorResponse(launchResult.errorType, {
|
|
2429
|
+
reference: args.connectionReason,
|
|
2430
|
+
error: launchResult.error
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
2433
|
+
// Navigate to the startUrl
|
|
2434
|
+
const navFailure = await navigateTo(startUrl);
|
|
2435
|
+
if (navFailure) {
|
|
2436
|
+
return createErrorResponse('NAVIGATION_FAILED', {
|
|
2437
|
+
url: startUrl,
|
|
2438
|
+
message: `Failed to navigate to startUrl: ${navFailure}`
|
|
2439
|
+
});
|
|
2440
|
+
}
|
|
2441
|
+
// Try getting the page again after launch
|
|
2442
|
+
page = await getPageForConnection(args.connectionReason);
|
|
2443
|
+
if (!page) {
|
|
2444
|
+
return createErrorResponse('CONNECTION_NOT_FOUND', {
|
|
2445
|
+
connectionReason: args.connectionReason,
|
|
2446
|
+
message: 'Failed to connect to Chrome after auto-launch'
|
|
2447
|
+
});
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
else if (startUrl) {
|
|
2451
|
+
// Page already exists but startUrl provided - navigate to it
|
|
2452
|
+
const navFailure = await navigateTo(startUrl);
|
|
2453
|
+
if (navFailure) {
|
|
2454
|
+
return createErrorResponse('NAVIGATION_FAILED', {
|
|
2455
|
+
url: startUrl,
|
|
2456
|
+
message: `Failed to navigate to startUrl: ${navFailure}`
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
const showOverlay = args.showOverlay !== false;
|
|
2461
|
+
const sequenceName = args.name || (issueId ? `${issueType}-${issueId}-repro` : args.connectionReason);
|
|
2462
|
+
// startRecording now blocks until recording completes
|
|
2463
|
+
// If issueId is provided, startRecording will show a fullscreen overlay with issue details
|
|
2464
|
+
const result = await startRecording(page, args.connectionReason, {
|
|
2465
|
+
showOverlay,
|
|
2466
|
+
abortSignal,
|
|
2467
|
+
issueId
|
|
2468
|
+
});
|
|
2469
|
+
// Close the tab if requested by the recording result
|
|
2470
|
+
if (result.closeTab) {
|
|
2471
|
+
try {
|
|
2472
|
+
await executeToolCall('tab', {
|
|
2473
|
+
action: 'close',
|
|
2474
|
+
reference: args.connectionReason,
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
catch {
|
|
2478
|
+
// Non-fatal - tab may already be closed
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
if (!result.success) {
|
|
2482
|
+
if (result.cancelled) {
|
|
2483
|
+
return {
|
|
2484
|
+
content: [{
|
|
2485
|
+
type: 'text',
|
|
2486
|
+
text: '**Recording cancelled** - no sequence created.'
|
|
2487
|
+
}],
|
|
2488
|
+
// Structurally too: callers were deciding this by searching the
|
|
2489
|
+
// sentence for "cancelled", which any recorded page title could also
|
|
2490
|
+
// have contained.
|
|
2491
|
+
_meta: {
|
|
2492
|
+
tool: 'replay',
|
|
2493
|
+
action: 'recordInteraction',
|
|
2494
|
+
timestamp: Date.now(),
|
|
2495
|
+
replay: { totalSteps: 0, cancelled: true },
|
|
2496
|
+
},
|
|
2497
|
+
};
|
|
2498
|
+
}
|
|
2499
|
+
return createErrorResponse('RECORDING_FAILED', { message: result.error });
|
|
2500
|
+
}
|
|
2501
|
+
// Recording completed - create the sequence
|
|
2502
|
+
const recording = result.recording;
|
|
2503
|
+
const summary = recording.summary;
|
|
2504
|
+
const replayConfig = configManager.getReplayConfig();
|
|
2505
|
+
// Recording options come from args; the defaults are the values that used to
|
|
2506
|
+
// be hardcoded here, so omitting them keeps the previous behaviour.
|
|
2507
|
+
// preferSelectors wins over preferCoordinates when both are set.
|
|
2508
|
+
const commands = eventsToCommands(recording.events, {
|
|
2509
|
+
simplify: args.simplifyEvents ?? true,
|
|
2510
|
+
includeDelays: true,
|
|
2511
|
+
includeHovers: args.includeHovers ?? false,
|
|
2512
|
+
preferCoordinates: args.preferCoordinates ?? false,
|
|
2513
|
+
preferSelectors: args.preferSelectors ?? false,
|
|
2514
|
+
maxDelayMs: replayConfig.maxDelayMs,
|
|
2515
|
+
});
|
|
2516
|
+
// Generate condensed timeline
|
|
2517
|
+
const timeline = generateCondensedTimeline(recording.events);
|
|
2518
|
+
// Check for BUG and FEATURE comments
|
|
2519
|
+
const bugComments = recording.events
|
|
2520
|
+
.filter((e) => isCommentEvent(e) && e.category === 'bug');
|
|
2521
|
+
const featureComments = recording.events
|
|
2522
|
+
.filter((e) => isCommentEvent(e) && e.category === 'feature');
|
|
2523
|
+
const hasIssues = bugComments.length > 0 || featureComments.length > 0;
|
|
2524
|
+
// Build sequence data for saving
|
|
2525
|
+
const sequenceData = {
|
|
2526
|
+
id: `seq-${Date.now()}`,
|
|
2527
|
+
name: sequenceName,
|
|
2528
|
+
commands,
|
|
2529
|
+
createdAt: Date.now(),
|
|
2530
|
+
startUrl: recording.startUrl,
|
|
2531
|
+
description: `Recorded from ${args.connectionReason}`,
|
|
2532
|
+
};
|
|
2533
|
+
// Only create in-memory sequence if no issues (issues go to issues folder only)
|
|
2534
|
+
let sequence = null;
|
|
2535
|
+
if (!hasIssues && recorder) {
|
|
2536
|
+
// Delete existing sequence if overwriting
|
|
2537
|
+
if (args.overwrite && recorder.sequenceNameExists(sequenceName)) {
|
|
2538
|
+
const existingSeq = recorder.listSequences().find(s => s.name === sequenceName);
|
|
2539
|
+
if (existingSeq) {
|
|
2540
|
+
recorder.deleteSequence(existingSeq.id);
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
// Check for name conflict
|
|
2544
|
+
if (recorder.sequenceNameExists(sequenceName) && !args.overwrite) {
|
|
2545
|
+
return createSuccessResponse('RECORDING_NAME_CONFLICT', {
|
|
2546
|
+
sequenceName,
|
|
2547
|
+
connectionReason: args.connectionReason
|
|
2548
|
+
});
|
|
2549
|
+
}
|
|
2550
|
+
sequence = await recorder.createSequenceFromCommands(sequenceName, commands, {
|
|
2551
|
+
startUrl: recording.startUrl,
|
|
2552
|
+
description: `Recorded from ${args.connectionReason}`,
|
|
2553
|
+
});
|
|
2554
|
+
}
|
|
2555
|
+
const createdIssues = [];
|
|
2556
|
+
// Initialize issue tracker
|
|
2557
|
+
await initializeTracker();
|
|
2558
|
+
// Create issues and save sequences for each bug/feature comment
|
|
2559
|
+
// Each issue gets its own sequence with a unique ID
|
|
2560
|
+
for (const comment of [...bugComments, ...featureComments]) {
|
|
2561
|
+
const issueType = comment.category;
|
|
2562
|
+
// Create the issue first (with temp filename, will be updated by saveIssueSequence)
|
|
2563
|
+
const issue = await addIssue({
|
|
2564
|
+
type: issueType,
|
|
2565
|
+
title: comment.text,
|
|
2566
|
+
sequenceFile: '',
|
|
2567
|
+
recordingName: sequenceName,
|
|
2568
|
+
initialStatus: 'pending',
|
|
2569
|
+
startUrl: recording.startUrl || '',
|
|
2570
|
+
});
|
|
2571
|
+
// Create a unique sequence for this issue (each issue gets its own copy)
|
|
2572
|
+
const issueSequenceData = {
|
|
2573
|
+
...sequenceData,
|
|
2574
|
+
id: `seq-${Date.now()}-${issue.id}`,
|
|
2575
|
+
name: `${issueType}-${issue.id}-repro`,
|
|
2576
|
+
};
|
|
2577
|
+
// Save sequence and link to issue
|
|
2578
|
+
await saveIssueSequence(issue.id, issueType, comment.text, issueSequenceData);
|
|
2579
|
+
createdIssues.push({
|
|
2580
|
+
id: issue.id,
|
|
2581
|
+
type: issueType,
|
|
2582
|
+
title: comment.text,
|
|
2583
|
+
});
|
|
2584
|
+
}
|
|
2585
|
+
// If issueId provided, save sequence to issues folder and link to existing issue
|
|
2586
|
+
if (issueId && issueType && issueTitle) {
|
|
2587
|
+
await saveIssueSequence(issueId, issueType, issueTitle, sequenceData, `CDP Tools verification sequence for ${issueType} #${issueId}: ${issueTitle}`);
|
|
2588
|
+
}
|
|
2589
|
+
const response = createSuccessResponse('RECORDING_STOPPED', {
|
|
2590
|
+
name: sequence?.name || sequenceData.name,
|
|
2591
|
+
sequenceId: sequence?.id || sequenceData.id,
|
|
2592
|
+
duration: (recording.duration / 1000).toFixed(1),
|
|
2593
|
+
startUrl: recording.startUrl,
|
|
2594
|
+
commandCount: commands.length,
|
|
2595
|
+
clicks: summary.clicks,
|
|
2596
|
+
drags: summary.drags,
|
|
2597
|
+
scrolls: summary.scrolls,
|
|
2598
|
+
keyPresses: summary.keyPresses,
|
|
2599
|
+
navigations: summary.navigations > 0 ? summary.navigations : null,
|
|
2600
|
+
comments: summary.comments > 0 ? summary.comments : null,
|
|
2601
|
+
// Selector coverage. Only surfaced when some click fell back to raw
|
|
2602
|
+
// coordinates, because that is the only case the user can act on - a
|
|
2603
|
+
// fully selector-based recording needs no warning, and a warning that
|
|
2604
|
+
// fires every time stops being read.
|
|
2605
|
+
coordinateClicks: summary.coordinatesOnly > 0 ? summary.coordinatesOnly : null,
|
|
2606
|
+
coverageNote: summary.coordinatesOnly > 0
|
|
2607
|
+
? `${summary.selectorsAvailable}/${summary.clicks} clicks captured a selector; ${summary.coordinatesOnly} fell back to coordinates${summary.canvasInteractions > 0 ? ` (${summary.canvasInteractions} on canvas, where that is expected)` : ''}. Coordinate clicks break on re-render or layout change.`
|
|
2608
|
+
: null,
|
|
2609
|
+
timeline: timeline || null,
|
|
2610
|
+
bugCount: bugComments.length > 0 ? bugComments.length : null,
|
|
2611
|
+
featureCount: featureComments.length > 0 ? featureComments.length : null,
|
|
2612
|
+
hasIssues: createdIssues.length > 0,
|
|
2613
|
+
issuesCreatedList: createdIssues.length > 0
|
|
2614
|
+
? createdIssues.map(i => `#${i.id} (${i.type})`).join(', ')
|
|
2615
|
+
: null,
|
|
2616
|
+
});
|
|
2617
|
+
// outputFormat dumps the underlying data alongside the summary. The raw
|
|
2618
|
+
// events only exist here - a saved sequence keeps commands, not events.
|
|
2619
|
+
if (args.outputFormat === 'events') {
|
|
2620
|
+
response.content[0].text += `\n\n**Raw recorded events (${recording.events.length})**\n\n\`\`\`json\n${JSON.stringify(recording.events, null, 2)}\n\`\`\``;
|
|
2621
|
+
}
|
|
2622
|
+
else if (args.outputFormat === 'commands') {
|
|
2623
|
+
response.content[0].text += `\n\n**Commands (JSON)**\n\n\`\`\`json\n${JSON.stringify(commands, null, 2)}\n\`\`\``;
|
|
2624
|
+
}
|
|
2625
|
+
else if (args.outputFormat === 'review') {
|
|
2626
|
+
response.content[0].text += `\n\n**Event Review (${recording.events.length} raw events)**\n\n${formatEventsForReview(recording.events)}`;
|
|
2627
|
+
}
|
|
2628
|
+
else if (args.outputFormat === 'playwright') {
|
|
2629
|
+
response.content[0].text += `\n\n**Playwright Code**\n\n\`\`\`typescript\n${generatePlaywrightCode(commands, recording.startUrl)}\n\`\`\``;
|
|
2630
|
+
}
|
|
2631
|
+
else if (args.outputFormat === 'puppeteer') {
|
|
2632
|
+
response.content[0].text += `\n\n**Puppeteer Code**\n\n\`\`\`javascript\n${generatePuppeteerCode(commands, recording.startUrl)}\n\`\`\``;
|
|
2633
|
+
}
|
|
2634
|
+
return response;
|
|
2635
|
+
}
|
|
2636
|
+
/**
|
|
2637
|
+
* Escape a string for use in JavaScript code generation
|
|
2638
|
+
* Handles newlines, quotes, backslashes, and other special characters
|
|
2639
|
+
*/
|
|
2640
|
+
function escapeJsString(str) {
|
|
2641
|
+
return str
|
|
2642
|
+
.replace(/\\/g, '\\\\') // Backslashes first
|
|
2643
|
+
.replace(/'/g, "\\'") // Single quotes
|
|
2644
|
+
.replace(/\n/g, '\\n') // Newlines
|
|
2645
|
+
.replace(/\r/g, '\\r') // Carriage returns
|
|
2646
|
+
.replace(/\t/g, '\\t'); // Tabs
|
|
2647
|
+
}
|
|
2648
|
+
/**
|
|
2649
|
+
* Generate Puppeteer test code from sequence commands
|
|
2650
|
+
*/
|
|
2651
|
+
/**
|
|
2652
|
+
* One page variable per recorded connection, for the code generators.
|
|
2653
|
+
*
|
|
2654
|
+
* A sequence that drove two browsers has to generate two pages: emitting every
|
|
2655
|
+
* step against a single `page` is the bug-018 collapse relocated into the
|
|
2656
|
+
* exported test, and it is silent - the generated file looks perfectly
|
|
2657
|
+
* reasonable and passes while never involving the second browser. The first
|
|
2658
|
+
* recorded reference keeps the name `page` so single-connection output is
|
|
2659
|
+
* byte-identical to before.
|
|
2660
|
+
*/
|
|
2661
|
+
function buildPageVars(commands) {
|
|
2662
|
+
const { references, mixed } = analyzeRecordedStepConnections(commands);
|
|
2663
|
+
const vars = new Map();
|
|
2664
|
+
references.forEach((ref, i) => {
|
|
2665
|
+
vars.set(ref, i === 0
|
|
2666
|
+
? 'page'
|
|
2667
|
+
: 'page' + ref.split(/[^a-zA-Z0-9]+/).filter(Boolean).map(w => w[0].toUpperCase() + w.slice(1)).join(''));
|
|
2668
|
+
});
|
|
2669
|
+
return {
|
|
2670
|
+
references,
|
|
2671
|
+
mixed,
|
|
2672
|
+
multi: references.length > 1,
|
|
2673
|
+
/** The page a step runs against; bare steps fall back to the first page. */
|
|
2674
|
+
varFor: (cmd) => (typeof cmd.params.connectionReason === 'string' && vars.get(sanitizeReference(cmd.params.connectionReason))) || 'page',
|
|
2675
|
+
/** `page` is declared by the caller's preamble; these are the extras. */
|
|
2676
|
+
extras: references.slice(1).map(ref => ({ ref, name: vars.get(ref) })),
|
|
2677
|
+
};
|
|
2678
|
+
}
|
|
2679
|
+
/**
|
|
2680
|
+
* Retarget the lines a single command emitted onto its own page variable.
|
|
2681
|
+
* Done as a post-pass over the emitted slice so the (long, per-tool) generator
|
|
2682
|
+
* bodies stay untouched and keep emitting the plain `page`.
|
|
2683
|
+
*/
|
|
2684
|
+
function rewritePage(lines, from, pageVar) {
|
|
2685
|
+
if (pageVar === 'page')
|
|
2686
|
+
return;
|
|
2687
|
+
for (let i = from; i < lines.length; i++) {
|
|
2688
|
+
lines[i] = lines[i].replace(/\bpage\b/g, pageVar);
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
/** Header explaining a multi-browser export, so the collapse can't happen quietly. */
|
|
2692
|
+
function generatedCodeHeader(pages) {
|
|
2693
|
+
if (!pages.multi)
|
|
2694
|
+
return [];
|
|
2695
|
+
const out = [
|
|
2696
|
+
`// This sequence drove ${pages.references.length} browsers (${pages.references.join(', ')}).`,
|
|
2697
|
+
`// Each gets its own page below - do NOT merge them, the recording exists to`,
|
|
2698
|
+
`// test what crosses between them.`,
|
|
2699
|
+
];
|
|
2700
|
+
if (pages.mixed) {
|
|
2701
|
+
out.push(`// WARNING: some steps named no connection and are emitted against '${'page'}';`);
|
|
2702
|
+
out.push(`// check them by hand - which browser they belonged to was not recorded.`);
|
|
2703
|
+
}
|
|
2704
|
+
return out;
|
|
2705
|
+
}
|
|
2706
|
+
function generatePuppeteerCode(commands, startUrl) {
|
|
2707
|
+
const pages = buildPageVars(commands);
|
|
2708
|
+
const lines = [
|
|
2709
|
+
'// Generated from cdp-tools interaction recording',
|
|
2710
|
+
...generatedCodeHeader(pages),
|
|
2711
|
+
'const puppeteer = require(\'puppeteer\');',
|
|
2712
|
+
'',
|
|
2713
|
+
'async function runTest() {',
|
|
2714
|
+
' const browser = await puppeteer.launch({ headless: false });',
|
|
2715
|
+
' const page = await browser.newPage();',
|
|
2716
|
+
...pages.extras.map(e => ` const ${e.name} = await browser.newPage(); // ${e.ref}`),
|
|
2717
|
+
'',
|
|
2718
|
+
];
|
|
2719
|
+
if (startUrl) {
|
|
2720
|
+
lines.push(` await page.goto('${startUrl}');`);
|
|
2721
|
+
lines.push('');
|
|
2722
|
+
}
|
|
2723
|
+
let generatedSteps = 0;
|
|
2724
|
+
for (const cmd of commands) {
|
|
2725
|
+
// Everything this command emits is rewritten onto its own page below.
|
|
2726
|
+
const emittedFrom = lines.length;
|
|
2727
|
+
if (cmd.tool === 'navigate') {
|
|
2728
|
+
const { action, ...params } = cmd.params;
|
|
2729
|
+
if (action === 'goto' && params.url) {
|
|
2730
|
+
lines.push(` await page.goto('${params.url}');`);
|
|
2731
|
+
lines.push('');
|
|
2732
|
+
}
|
|
2733
|
+
else if (action === 'reload') {
|
|
2734
|
+
lines.push(` await page.reload();`);
|
|
2735
|
+
lines.push('');
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
else if (cmd.tool === 'input') {
|
|
2739
|
+
const { action, ...params } = cmd.params;
|
|
2740
|
+
switch (action) {
|
|
2741
|
+
case 'drag':
|
|
2742
|
+
lines.push(` // Drag from (${params.from.x}, ${params.from.y}) to (${params.to.x}, ${params.to.y})`);
|
|
2743
|
+
lines.push(` await page.mouse.move(${params.from.x}, ${params.from.y});`);
|
|
2744
|
+
lines.push(` await page.mouse.down();`);
|
|
2745
|
+
lines.push(` await page.mouse.move(${params.to.x}, ${params.to.y});`);
|
|
2746
|
+
lines.push(` await page.mouse.up();`);
|
|
2747
|
+
lines.push('');
|
|
2748
|
+
break;
|
|
2749
|
+
case 'scroll':
|
|
2750
|
+
lines.push(` // Scroll at (${params.x}, ${params.y})`);
|
|
2751
|
+
if (params.x !== undefined && params.y !== undefined) {
|
|
2752
|
+
lines.push(` await page.mouse.move(${params.x}, ${params.y});`);
|
|
2753
|
+
}
|
|
2754
|
+
lines.push(` await page.mouse.wheel({ deltaX: ${params.deltaX || 0}, deltaY: ${params.deltaY || 0} });`);
|
|
2755
|
+
lines.push('');
|
|
2756
|
+
break;
|
|
2757
|
+
case 'mousemove':
|
|
2758
|
+
lines.push(` await page.mouse.move(${params.x}, ${params.y});`);
|
|
2759
|
+
break;
|
|
2760
|
+
case 'click':
|
|
2761
|
+
if (typeof params.x === 'number' && typeof params.y === 'number') {
|
|
2762
|
+
lines.push(` await page.mouse.click(${params.x}, ${params.y});`);
|
|
2763
|
+
}
|
|
2764
|
+
else if (params.selector) {
|
|
2765
|
+
lines.push(` await page.click('${params.selector}');`);
|
|
2766
|
+
}
|
|
2767
|
+
lines.push('');
|
|
2768
|
+
break;
|
|
2769
|
+
case 'type':
|
|
2770
|
+
lines.push(` await page.keyboard.type('${escapeJsString(params.text)}');`);
|
|
2771
|
+
lines.push('');
|
|
2772
|
+
break;
|
|
2773
|
+
case 'press':
|
|
2774
|
+
lines.push(` await page.keyboard.press('${escapeJsString(params.key)}');`);
|
|
2775
|
+
lines.push('');
|
|
2776
|
+
break;
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
// Same rule as the Playwright generator: a dropped step leaves a hole.
|
|
2780
|
+
if (lines.length === emittedFrom) {
|
|
2781
|
+
lines.push(` // [not generated] ${describeUngeneratedStep(cmd)}`);
|
|
2782
|
+
}
|
|
2783
|
+
else {
|
|
2784
|
+
generatedSteps++;
|
|
2785
|
+
}
|
|
2786
|
+
rewritePage(lines, emittedFrom, pages.varFor(cmd));
|
|
2787
|
+
}
|
|
2788
|
+
lines.push(...ungeneratedTestGuard(generatedSteps, commands.length, Boolean(startUrl)));
|
|
2789
|
+
lines.push(' await browser.close();');
|
|
2790
|
+
lines.push('}');
|
|
2791
|
+
lines.push('');
|
|
2792
|
+
lines.push('runTest().catch(console.error);');
|
|
2793
|
+
return lines.join('\n');
|
|
2794
|
+
}
|
|
2795
|
+
function generatePlaywrightCode(commands, startUrl) {
|
|
2796
|
+
const pages = buildPageVars(commands);
|
|
2797
|
+
const lines = [
|
|
2798
|
+
'// Generated from cdp-tools interaction recording',
|
|
2799
|
+
...generatedCodeHeader(pages),
|
|
2800
|
+
"import { test, expect } from '@playwright/test';",
|
|
2801
|
+
'',
|
|
2802
|
+
// A second browser needs its own context, so the multi-connection form takes
|
|
2803
|
+
// the `browser` fixture instead of `page` and opens the pages itself.
|
|
2804
|
+
pages.multi
|
|
2805
|
+
? "test('recorded interaction', async ({ browser }) => {"
|
|
2806
|
+
: "test('recorded interaction', async ({ page }) => {",
|
|
2807
|
+
...(pages.multi ? [' const page = await (await browser.newContext()).newPage();'] : []),
|
|
2808
|
+
...pages.extras.map(e => ` const ${e.name} = await (await browser.newContext()).newPage(); // ${e.ref}`),
|
|
2809
|
+
];
|
|
2810
|
+
if (startUrl) {
|
|
2811
|
+
lines.push(` await page.goto('${startUrl}');`);
|
|
2812
|
+
lines.push('');
|
|
2813
|
+
}
|
|
2814
|
+
let generatedSteps = 0;
|
|
2815
|
+
for (const cmd of commands) {
|
|
2816
|
+
const emittedFrom = lines.length;
|
|
2817
|
+
// Add comment if present
|
|
2818
|
+
if (cmd.comment) {
|
|
2819
|
+
lines.push(` // ${cmd.comment}`);
|
|
2820
|
+
}
|
|
2821
|
+
// Add delay if present
|
|
2822
|
+
if (cmd.delay && cmd.delay > 100) {
|
|
2823
|
+
lines.push(` await page.waitForTimeout(${cmd.delay});`);
|
|
2824
|
+
}
|
|
2825
|
+
const bodyFrom = lines.length;
|
|
2826
|
+
if (cmd.tool === 'navigate') {
|
|
2827
|
+
const { action, ...params } = cmd.params;
|
|
2828
|
+
if (action === 'goto' && params.url) {
|
|
2829
|
+
lines.push(` await page.goto('${params.url}');`);
|
|
2830
|
+
lines.push('');
|
|
2831
|
+
}
|
|
2832
|
+
else if (action === 'reload') {
|
|
2833
|
+
lines.push(` await page.reload();`);
|
|
2834
|
+
lines.push('');
|
|
2835
|
+
}
|
|
2836
|
+
else if (action === 'back') {
|
|
2837
|
+
lines.push(` await page.goBack();`);
|
|
2838
|
+
lines.push('');
|
|
2839
|
+
}
|
|
2840
|
+
else if (action === 'forward') {
|
|
2841
|
+
lines.push(` await page.goForward();`);
|
|
2842
|
+
lines.push('');
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
else if (cmd.tool === 'input') {
|
|
2846
|
+
const { action, ...params } = cmd.params;
|
|
2847
|
+
switch (action) {
|
|
2848
|
+
case 'drag':
|
|
2849
|
+
lines.push(` // Drag from (${params.from.x}, ${params.from.y}) to (${params.to.x}, ${params.to.y})`);
|
|
2850
|
+
lines.push(` await page.mouse.move(${params.from.x}, ${params.from.y});`);
|
|
2851
|
+
lines.push(` await page.mouse.down();`);
|
|
2852
|
+
lines.push(` await page.mouse.move(${params.to.x}, ${params.to.y});`);
|
|
2853
|
+
lines.push(` await page.mouse.up();`);
|
|
2854
|
+
lines.push('');
|
|
2855
|
+
break;
|
|
2856
|
+
case 'scroll':
|
|
2857
|
+
lines.push(` // Scroll at (${params.x || 0}, ${params.y || 0})`);
|
|
2858
|
+
if (params.x !== undefined && params.y !== undefined) {
|
|
2859
|
+
lines.push(` await page.mouse.move(${params.x}, ${params.y});`);
|
|
2860
|
+
}
|
|
2861
|
+
lines.push(` await page.mouse.wheel(${params.deltaX || 0}, ${params.deltaY || 0});`);
|
|
2862
|
+
lines.push('');
|
|
2863
|
+
break;
|
|
2864
|
+
case 'mousemove':
|
|
2865
|
+
lines.push(` await page.mouse.move(${params.x}, ${params.y});`);
|
|
2866
|
+
break;
|
|
2867
|
+
case 'click':
|
|
2868
|
+
if (typeof params.x === 'number' && typeof params.y === 'number') {
|
|
2869
|
+
lines.push(` await page.mouse.click(${params.x}, ${params.y});`);
|
|
2870
|
+
}
|
|
2871
|
+
else if (params.selector) {
|
|
2872
|
+
lines.push(` await page.click('${params.selector}');`);
|
|
2873
|
+
}
|
|
2874
|
+
lines.push('');
|
|
2875
|
+
break;
|
|
2876
|
+
case 'type':
|
|
2877
|
+
// Playwright uses type() for key-by-key typing, fill() for setting value directly
|
|
2878
|
+
lines.push(` await page.keyboard.type('${escapeJsString(params.text)}');`);
|
|
2879
|
+
lines.push('');
|
|
2880
|
+
break;
|
|
2881
|
+
case 'press':
|
|
2882
|
+
lines.push(` await page.keyboard.press('${escapeJsString(params.key)}');`);
|
|
2883
|
+
lines.push('');
|
|
2884
|
+
break;
|
|
2885
|
+
case 'hover':
|
|
2886
|
+
if (params.selector) {
|
|
2887
|
+
lines.push(` await page.hover('${params.selector}');`);
|
|
2888
|
+
}
|
|
2889
|
+
lines.push('');
|
|
2890
|
+
break;
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
// A step with no Playwright equivalent (conditional, launchChrome, inspect,
|
|
2894
|
+
// storage, wait, breakpoint...) must leave a visible hole. Dropping it
|
|
2895
|
+
// silently is how a sequence turns into a test that passes without doing
|
|
2896
|
+
// anything it was recorded to do.
|
|
2897
|
+
if (lines.length === bodyFrom) {
|
|
2898
|
+
lines.push(` // [not generated] ${describeUngeneratedStep(cmd)}`);
|
|
2899
|
+
}
|
|
2900
|
+
else {
|
|
2901
|
+
generatedSteps++;
|
|
2902
|
+
}
|
|
2903
|
+
rewritePage(lines, emittedFrom, pages.varFor(cmd));
|
|
2904
|
+
}
|
|
2905
|
+
lines.push(...ungeneratedTestGuard(generatedSteps, commands.length, Boolean(startUrl)));
|
|
2906
|
+
lines.push('});');
|
|
2907
|
+
return lines.join('\n');
|
|
2908
|
+
}
|
|
2909
|
+
/** Names a step the generators have no equivalent for, for the emitted comment. */
|
|
2910
|
+
function describeUngeneratedStep(cmd) {
|
|
2911
|
+
const action = typeof cmd.params?.action === 'string' ? `({ action: '${cmd.params.action}' })` : '';
|
|
2912
|
+
const extra = cmd.tool === 'conditional' && cmd.params?.then
|
|
2913
|
+
? ` — runs the sequence "${cmd.params.then}" when ${cmd.params.if}`
|
|
2914
|
+
: '';
|
|
2915
|
+
return `${cmd.tool}${action}${extra}`;
|
|
2916
|
+
}
|
|
2917
|
+
/**
|
|
2918
|
+
* Body for a generated test that ended up with nothing to run. Returning an
|
|
2919
|
+
* empty test would export a permanently GREEN file - the failure mode this
|
|
2920
|
+
* whole tool exists to avoid - so the generated test fails and says why.
|
|
2921
|
+
*/
|
|
2922
|
+
function ungeneratedTestGuard(generatedSteps, totalSteps, hasStartUrl) {
|
|
2923
|
+
if (generatedSteps > 0 || hasStartUrl)
|
|
2924
|
+
return [];
|
|
2925
|
+
return [
|
|
2926
|
+
'',
|
|
2927
|
+
` throw new Error('cdp-tools: none of the ${totalSteps} recorded step(s) have a generated equivalent`
|
|
2928
|
+
+ ` (see the "[not generated]" comments above) - this exported test would otherwise pass without doing anything.`
|
|
2929
|
+
+ ` Run it with replay({ action: "run" }) instead.');`,
|
|
2930
|
+
];
|
|
2931
|
+
}
|
|
2932
|
+
// =============================================================================
|
|
2933
|
+
// Tool Export
|
|
2934
|
+
// =============================================================================
|
|
2935
|
+
export function createReplayTools(commandRecorder, executeToolCall, getPageForConnection, getConnectionPort,
|
|
2936
|
+
/**
|
|
2937
|
+
* Lazy provider for the set of registered tool names, used to reject sequence
|
|
2938
|
+
* steps naming a nonexistent tool at create/load time (bug-010). Lazy because
|
|
2939
|
+
* the tool map is built after this factory runs. When omitted, tool names are
|
|
2940
|
+
* not validated (previous behaviour).
|
|
2941
|
+
*/
|
|
2942
|
+
getKnownToolNames) {
|
|
2943
|
+
return {
|
|
2944
|
+
replay: createTool('Record and replay command sequences for testing and automation. Actions: repeat (immediately re-execute commands by history index - use this to repeat recent actions), history (view command history), recordInteraction (record real mouse/keyboard/navigation via a browser overlay - BLOCKS until the person finishes, so do not call it unattended; tune the capture with simplifyEvents/includeHovers/preferCoordinates/preferSelectors, and add outputFormat: events|commands|review|playwright|puppeteer to dump the recording - review is a human-readable walkthrough of the captured events), create (create sequence from history indices), list (list in-memory sequences), get (get sequence details; outputFormat: commands|playwright|puppeteer returns the raw command JSON or generated test code), delete (delete from memory), export (write a sequence to disk as sequence/playwright/puppeteer), load (load sequence from disk), listSaved (list saved files), deleteSaved (delete saved file), run (start executing a sequence in the background - returns a runId immediately; poll progress/results with status, stop it with cancel; wait: true blocks until completion and returns the full result), runAll (run every sequence in a folder of the sequences dir, or only those carrying a given tag - loads the whole tree first so cross-folder name references resolve, runs only the chosen folder, skips folders whose name starts with an underscore unless named explicitly, and reports a pass/fail line per sequence; continueOnFailure defaults true), runFromLog (execute commands from log lines), step (execute next N commands in a paused sequence), finish (complete remaining commands), insert (insert recorded commands into a sequence), addConditional (add a guarded branch step: condition + thenSequence, optionally insertAfterStep), declare (set what the sequence needs and what it is: requiredConnections - the browsers, optionally each on a persistent profile - requiredSockets - URL substrings of the WebSockets its assertions ride on - and tags, which runAll selects on; each list replaces the field, [] clears it, and the sequence is written back to its file), status (with runId: one run\'s progress or final result; without: paused session + recent runs), cancel (with runId: stop that run; without: drop the paused session, or the only executing run)', replaySchema, async (args, abortSignal) => {
|
|
2945
|
+
switch (args.action) {
|
|
2946
|
+
case 'history':
|
|
2947
|
+
return handleHistory(args, commandRecorder);
|
|
2948
|
+
case 'create':
|
|
2949
|
+
return handleCreate(args, commandRecorder, getKnownToolNames);
|
|
2950
|
+
case 'list':
|
|
2951
|
+
return handleList(commandRecorder);
|
|
2952
|
+
case 'get':
|
|
2953
|
+
return handleGet(args, commandRecorder);
|
|
2954
|
+
case 'delete':
|
|
2955
|
+
return handleDelete(args, commandRecorder);
|
|
2956
|
+
case 'export':
|
|
2957
|
+
return handleExport(args, commandRecorder);
|
|
2958
|
+
case 'load':
|
|
2959
|
+
return handleLoad(args, commandRecorder, getKnownToolNames);
|
|
2960
|
+
case 'listSaved':
|
|
2961
|
+
return handleListSaved(args, commandRecorder);
|
|
2962
|
+
case 'deleteSaved':
|
|
2963
|
+
return handleDeleteSaved(args, commandRecorder);
|
|
2964
|
+
case 'run':
|
|
2965
|
+
return handleRun(args, commandRecorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort);
|
|
2966
|
+
case 'runAll':
|
|
2967
|
+
return handleRunAll(args, commandRecorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort);
|
|
2968
|
+
case 'status':
|
|
2969
|
+
return handleStatus(args, commandRecorder);
|
|
2970
|
+
case 'step':
|
|
2971
|
+
return handleStep(args, commandRecorder, executeToolCall);
|
|
2972
|
+
case 'finish':
|
|
2973
|
+
return handleFinish(commandRecorder, executeToolCall);
|
|
2974
|
+
case 'insert':
|
|
2975
|
+
return handleInsert(args, commandRecorder);
|
|
2976
|
+
case 'addConditional':
|
|
2977
|
+
return handleAddConditional(args, commandRecorder);
|
|
2978
|
+
case 'declare':
|
|
2979
|
+
return handleDeclare(args, commandRecorder);
|
|
2980
|
+
case 'cancel':
|
|
2981
|
+
return handleCancel(args, commandRecorder);
|
|
2982
|
+
case 'repeat':
|
|
2983
|
+
return handleRepeat(args, commandRecorder, executeToolCall);
|
|
2984
|
+
case 'runFromLog':
|
|
2985
|
+
return handleRunFromLog(args, executeToolCall);
|
|
2986
|
+
case 'recordInteraction':
|
|
2987
|
+
return handleRecordInteraction(args, executeToolCall, getPageForConnection, commandRecorder, abortSignal);
|
|
2988
|
+
default:
|
|
2989
|
+
return createErrorResponse('INVALID_ACTION', { action: args.action });
|
|
2990
|
+
}
|
|
2991
|
+
}),
|
|
2992
|
+
};
|
|
2993
|
+
}
|
|
2994
|
+
//# sourceMappingURL=replay-tools.js.map
|