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,2347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay Executor - Core execution engine for command sequences
|
|
3
|
+
*/
|
|
4
|
+
import { abortableDelayResult } from '../utils/abort.js';
|
|
5
|
+
import { debugLog } from '../debug-logger.js';
|
|
6
|
+
import { sanitizeReference, requireValidReference } from '../reference-validator.js';
|
|
7
|
+
import { checkUrlPort } from '../utils/port-check.js';
|
|
8
|
+
import { configManager } from '../config.js';
|
|
9
|
+
import { interpolateParams } from './interpolation.js';
|
|
10
|
+
import { getMessage, isElementNotFoundFailure } from '../messages.js';
|
|
11
|
+
// Re-export replay cursor functions
|
|
12
|
+
export { injectReplayCursor, showClickEffect, showKeyPress, removeReplayCursor } from '../replay-cursor.js';
|
|
13
|
+
let replayCursorCallbacks = {};
|
|
14
|
+
export function setReplayCursorCallbacks(callbacks) {
|
|
15
|
+
replayCursorCallbacks = callbacks;
|
|
16
|
+
}
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// Constants
|
|
19
|
+
// =============================================================================
|
|
20
|
+
/**
|
|
21
|
+
* Which tools a sequence step's `saveAs` can capture from, and what a capture
|
|
22
|
+
* actually stores. Each entry pulls the value out of the tool's structured
|
|
23
|
+
* `_meta` - never out of its display text - and returns `undefined` when this
|
|
24
|
+
* particular call produced nothing capturable (wrong action, older response).
|
|
25
|
+
*
|
|
26
|
+
* `request` stores the whole response object, so later steps address into it
|
|
27
|
+
* ({{var:login.body.token}}). `inspect` stores the evaluated value itself, so
|
|
28
|
+
* a captured string is usable as {{var:pairingUrl}} directly.
|
|
29
|
+
*
|
|
30
|
+
* Adding `dom`/`content` later is a matter of adding an entry here plus the
|
|
31
|
+
* matching `_meta` on that tool.
|
|
32
|
+
*/
|
|
33
|
+
const CAPTURE_SOURCES = {
|
|
34
|
+
request: (meta) => meta?.request
|
|
35
|
+
? { found: true, value: meta.request }
|
|
36
|
+
: { found: false },
|
|
37
|
+
inspect: (meta) => meta?.inspect
|
|
38
|
+
? { found: true, value: meta.inspect.value }
|
|
39
|
+
: { found: false },
|
|
40
|
+
};
|
|
41
|
+
/** Human-readable list of what supports saveAs, for error messages. */
|
|
42
|
+
const CAPTURE_CAPABLE_TOOLS = Object.keys(CAPTURE_SOURCES).join(', ');
|
|
43
|
+
/**
|
|
44
|
+
* Iterations a `forEach` will run before stopping, unless the step raises it
|
|
45
|
+
* with `maxItems`. A backstop against a source that unexpectedly returns
|
|
46
|
+
* thousands of rows, not a considered limit - the stop is always logged.
|
|
47
|
+
*/
|
|
48
|
+
const DEFAULT_FOREACH_MAX_ITEMS = 100;
|
|
49
|
+
/**
|
|
50
|
+
* Teardown's default total budget. Separate from the run's `totalTimeout`
|
|
51
|
+
* because it must survive that budget being exhausted.
|
|
52
|
+
*/
|
|
53
|
+
const DEFAULT_TEARDOWN_TIMEOUT = 60000;
|
|
54
|
+
/**
|
|
55
|
+
* Resolve what a step's `saveAs` should write to the variable store.
|
|
56
|
+
* A `saveAs` that cannot be honoured is an error, not a silent no-op: the
|
|
57
|
+
* later {{var:...}} step would otherwise fail somewhere far away with a
|
|
58
|
+
* confusing "no variable named" message.
|
|
59
|
+
*/
|
|
60
|
+
export function captureVariable(tool, params, result) {
|
|
61
|
+
const source = CAPTURE_SOURCES[tool];
|
|
62
|
+
if (!source) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
error: `saveAs is not supported on "${tool}" steps (supported: ${CAPTURE_CAPABLE_TOOLS})`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const captured = source(result?._meta);
|
|
69
|
+
if (!captured.found) {
|
|
70
|
+
const action = params.action ? ` (action: ${params.action})` : '';
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
error: `saveAs: "${tool}"${action} returned no capturable result` +
|
|
74
|
+
(tool === 'inspect' ? ' - only inspect({ action: "evaluateExpression" }) can be captured' : ''),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return { ok: true, value: captured.value };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Tools that can only run against a *browser*. Used to decide whether a sequence
|
|
81
|
+
* needs Chrome auto-launched (analyzeSequenceConnections / sequenceNeedsConnection
|
|
82
|
+
* and the auto-launch paths in replay-tools).
|
|
83
|
+
*
|
|
84
|
+
* Deliberately excludes tools that are equally valid against a Node target
|
|
85
|
+
* (`inspect`, `execution`, `breakpoint`, `getSourceCode`, `request`) - listing
|
|
86
|
+
* those here would make a Node-only sequence spuriously launch Chrome.
|
|
87
|
+
*/
|
|
88
|
+
export const TOOLS_NEEDING_CONNECTION = [
|
|
89
|
+
'navigate', 'content', 'input', 'console', 'network', 'dom', 'screenshot', 'storage'
|
|
90
|
+
];
|
|
91
|
+
/**
|
|
92
|
+
* Tools whose params accept a `connectionReason` and should therefore have the
|
|
93
|
+
* run-level connection injected when the step doesn't name one itself. Superset of
|
|
94
|
+
* TOOLS_NEEDING_CONNECTION: it adds the target-agnostic (Chrome *or* Node) debugging
|
|
95
|
+
* tools, which need to be pinned to the run's target but must NOT drag a browser
|
|
96
|
+
* launch in with them.
|
|
97
|
+
*
|
|
98
|
+
* `request` is handled separately - only `destination: 'browser'` takes a connection.
|
|
99
|
+
*/
|
|
100
|
+
export const TOOLS_ACCEPTING_CONNECTION = [
|
|
101
|
+
...TOOLS_NEEDING_CONNECTION,
|
|
102
|
+
'inspect', 'execution', 'breakpoint', 'getSourceCode', 'detectModals', 'dismissModal', 'assert',
|
|
103
|
+
'wait'
|
|
104
|
+
];
|
|
105
|
+
/**
|
|
106
|
+
* Whether a single step requires a *browser* connection (drives Chrome
|
|
107
|
+
* auto-launch). Param-aware variant of `TOOLS_NEEDING_CONNECTION.includes(tool)`:
|
|
108
|
+
* `wait` is browser-bound only in its selector/selectorGone forms.
|
|
109
|
+
* - `wait({ ms })` is a plain sleep and must not drag a Chrome launch in.
|
|
110
|
+
* - `wait({ expression })` is target-agnostic (valid against Node too), so it
|
|
111
|
+
* behaves like `inspect`: the run connection is injected, but it never
|
|
112
|
+
* forces a browser launch on its own.
|
|
113
|
+
*/
|
|
114
|
+
export function commandNeedsBrowserConnection(cmd) {
|
|
115
|
+
if (cmd.tool === 'wait') {
|
|
116
|
+
const p = cmd.params || {};
|
|
117
|
+
return p.selector !== undefined || p.selectorGone !== undefined;
|
|
118
|
+
}
|
|
119
|
+
return TOOLS_NEEDING_CONNECTION.includes(cmd.tool);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Whether a bare step will have the run-level connection injected into it -
|
|
123
|
+
* i.e. whether leaving it bare is AMBIGUOUS about which browser it belongs to.
|
|
124
|
+
*
|
|
125
|
+
* Deliberately wider than `commandNeedsBrowserConnection`, which answers a
|
|
126
|
+
* different question (does this drag a Chrome launch in?). `inspect`,
|
|
127
|
+
* `execution`, `storage` and friends take an optional connectionReason, so a
|
|
128
|
+
* recording made without one captures nothing about which browser it ran
|
|
129
|
+
* against - and on replay it silently lands wherever the run-level connection
|
|
130
|
+
* points. Measuring ambiguity with the narrower predicate missed exactly those
|
|
131
|
+
* tools, which are the ones people actually leave bare.
|
|
132
|
+
*/
|
|
133
|
+
export function commandTakesInjectedConnection(cmd) {
|
|
134
|
+
// wait({ ms }) is a plain sleep - no connection is injected, nothing ambiguous.
|
|
135
|
+
if (cmd.tool === 'wait')
|
|
136
|
+
return (cmd.params || {}).ms === undefined;
|
|
137
|
+
return TOOLS_ACCEPTING_CONNECTION.includes(cmd.tool);
|
|
138
|
+
}
|
|
139
|
+
/** The condition types `evaluateCondition` knows how to answer. */
|
|
140
|
+
export const CONDITION_TYPES = ['selector', 'url', 'cookie', 'localStorage', 'indexedDB'];
|
|
141
|
+
/** Shape of a handlebar condition: `{{type:value}}` or `{{!type:value}}`. */
|
|
142
|
+
const CONDITION_PATTERN = /^\{\{(!?)(\w+):(.+)\}\}$/;
|
|
143
|
+
/**
|
|
144
|
+
* Check a condition at authoring time: shape, type, and the `url`/`indexedDB`
|
|
145
|
+
* sub-forms. A value holding a `{{var:...}}` token is skipped - it is
|
|
146
|
+
* substituted at run time, so its final shape is unknowable here.
|
|
147
|
+
*/
|
|
148
|
+
export function validateConditionSyntax(condition, maxRegexLength = configManager.getReplayConfig().maxRegexLength) {
|
|
149
|
+
const match = condition.match(CONDITION_PATTERN);
|
|
150
|
+
if (!match) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
reason: `Invalid condition format: "${condition}". Expected {{type:value}} or {{!type:value}}. Supported types: ${CONDITION_TYPES.join(', ')}`
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const [, , type, value] = match;
|
|
157
|
+
if (!CONDITION_TYPES.includes(type)) {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
reason: `Unknown condition type: "${type}". Supported types: ${CONDITION_TYPES.join(', ')}`
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const interpolated = value.includes('{{');
|
|
164
|
+
if (type === 'url' && value.startsWith('matches:') && !interpolated) {
|
|
165
|
+
const pattern = value.substring('matches:'.length);
|
|
166
|
+
if (pattern.length > maxRegexLength) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
reason: `Regex pattern too long (${pattern.length} chars, max ${maxRegexLength}). Simplify the pattern or increase maxRegexLength in config.`
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
new RegExp(pattern);
|
|
174
|
+
}
|
|
175
|
+
catch (regexError) {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
reason: `Invalid regex pattern "${pattern}": ${regexError.message}. Check syntax at https://regex101.com (JavaScript flavor).`
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (type === 'indexedDB' && !interpolated) {
|
|
183
|
+
const [db, store, ...rest] = value.split('/');
|
|
184
|
+
if (!db || !store) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
reason: `Invalid indexedDB condition "${value}". Expected {{indexedDB:DB/STORE/KEY}} or {{indexedDB:DB/STORE}}.`
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (rest.length > 0 && !rest.join('/')) {
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
reason: `Invalid indexedDB condition "${value}": the key is empty.`
|
|
194
|
+
+ ` Use {{indexedDB:${db}/${store}}} to ask whether the store holds anything.`
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { ok: true };
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Evaluate a handlebar-style condition
|
|
202
|
+
* Supported patterns:
|
|
203
|
+
* {{selector:CSS_SELECTOR}} - true if element exists
|
|
204
|
+
* {{!selector:CSS_SELECTOR}} - true if element does NOT exist
|
|
205
|
+
* {{url:contains:STRING}} - true if URL contains string
|
|
206
|
+
* {{url:matches:REGEX}} - true if URL matches regex
|
|
207
|
+
* {{cookie:NAME}} - true if cookie exists
|
|
208
|
+
* {{!cookie:NAME}} - true if cookie does NOT exist
|
|
209
|
+
* {{localStorage:KEY}} - true if localStorage key exists
|
|
210
|
+
* {{!localStorage:KEY}} - true if localStorage key does NOT exist
|
|
211
|
+
* {{indexedDB:DB/STORE/KEY}} - true if that IndexedDB record exists
|
|
212
|
+
* {{indexedDB:DB/STORE}} - true if that object store holds any record
|
|
213
|
+
* {{!indexedDB:...}} - negation of either form
|
|
214
|
+
*/
|
|
215
|
+
export async function evaluateCondition(condition, ctx) {
|
|
216
|
+
const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
|
|
217
|
+
const replayConfig = configManager.getReplayConfig();
|
|
218
|
+
// Parse the handlebar pattern
|
|
219
|
+
const match = condition.match(CONDITION_PATTERN);
|
|
220
|
+
if (!match) {
|
|
221
|
+
return {
|
|
222
|
+
met: false,
|
|
223
|
+
reason: `Invalid condition format: "${condition}". Expected {{type:value}} or {{!type:value}}. Supported types: ${CONDITION_TYPES.join(', ')}`,
|
|
224
|
+
isError: true
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const [, negated, type, value] = match;
|
|
228
|
+
const isNegated = negated === '!';
|
|
229
|
+
await debugLog(logPrefix, `Evaluating condition: ${type}${isNegated ? ' (negated)' : ''} = ${value}`);
|
|
230
|
+
try {
|
|
231
|
+
let conditionMet = false;
|
|
232
|
+
switch (type) {
|
|
233
|
+
case 'selector': {
|
|
234
|
+
// Absence is the answer, not a broken condition: it arrives as a thrown
|
|
235
|
+
// ToolError, which any other failure does too - hence the classifier.
|
|
236
|
+
const probeSelector = async () => {
|
|
237
|
+
try {
|
|
238
|
+
const res = await executeToolCall('dom', {
|
|
239
|
+
action: 'querySelector',
|
|
240
|
+
selector: value,
|
|
241
|
+
connectionReason
|
|
242
|
+
});
|
|
243
|
+
return { res };
|
|
244
|
+
}
|
|
245
|
+
catch (selectorError) {
|
|
246
|
+
return {
|
|
247
|
+
failure: selectorError?.message || String(selectorError),
|
|
248
|
+
errorId: selectorError?.response?._errorId,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
const attempt = await probeSelector();
|
|
253
|
+
if (attempt.failure) {
|
|
254
|
+
if (isElementNotFoundFailure({ errorId: attempt.errorId, text: attempt.failure })) {
|
|
255
|
+
conditionMet = false;
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
met: false,
|
|
260
|
+
reason: `Error evaluating selector condition "${value}": ${attempt.failure}`,
|
|
261
|
+
isError: true
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
conditionMet = (attempt.res?.content?.[0]?.text || '').includes('Element found');
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
case 'url': {
|
|
268
|
+
const pageInfo = await executeToolCall('navigate', {
|
|
269
|
+
action: 'info',
|
|
270
|
+
connectionReason
|
|
271
|
+
});
|
|
272
|
+
// From `_meta` where it exists: the text fallback stops the URL at the
|
|
273
|
+
// first comma or space, so a data: URL or a `?ids=1,2` query compared as
|
|
274
|
+
// a truncated prefix - `{{url:EXACT}}` could never match one.
|
|
275
|
+
const currentUrl = pageInfo?._meta?.navigate?.url ?? '';
|
|
276
|
+
if (value.startsWith('contains:')) {
|
|
277
|
+
const searchStr = value.substring('contains:'.length);
|
|
278
|
+
conditionMet = currentUrl.includes(searchStr);
|
|
279
|
+
}
|
|
280
|
+
else if (value.startsWith('matches:')) {
|
|
281
|
+
const pattern = value.substring('matches:'.length);
|
|
282
|
+
// Check regex length limit
|
|
283
|
+
if (pattern.length > replayConfig.maxRegexLength) {
|
|
284
|
+
return {
|
|
285
|
+
met: false,
|
|
286
|
+
reason: `Regex pattern too long (${pattern.length} chars, max ${replayConfig.maxRegexLength}). Simplify the pattern or increase maxRegexLength in config.`,
|
|
287
|
+
isError: true
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
// Safely compile regex
|
|
291
|
+
let regex;
|
|
292
|
+
try {
|
|
293
|
+
regex = new RegExp(pattern);
|
|
294
|
+
}
|
|
295
|
+
catch (regexError) {
|
|
296
|
+
return {
|
|
297
|
+
met: false,
|
|
298
|
+
reason: `Invalid regex pattern "${pattern}": ${regexError.message}. Check syntax at https://regex101.com (JavaScript flavor).`,
|
|
299
|
+
isError: true
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
conditionMet = regex.test(currentUrl);
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
conditionMet = currentUrl === value;
|
|
306
|
+
}
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
case 'cookie': {
|
|
310
|
+
const result = await executeToolCall('storage', {
|
|
311
|
+
action: 'getCookies',
|
|
312
|
+
connectionReason
|
|
313
|
+
});
|
|
314
|
+
// Names from `_meta`: grepping the rendered JSON matched another
|
|
315
|
+
// cookie's VALUE, and the `name=wanted` form matched any cookie whose
|
|
316
|
+
// name merely ENDS with it.
|
|
317
|
+
conditionMet = (result?._meta?.storage?.cookieNames ?? []).includes(value);
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
case 'localStorage': {
|
|
321
|
+
const result = await executeToolCall('storage', {
|
|
322
|
+
action: 'getLocalStorage',
|
|
323
|
+
key: value,
|
|
324
|
+
connectionReason
|
|
325
|
+
});
|
|
326
|
+
// Presence from `_meta`. The old text test read the whole rendered
|
|
327
|
+
// response, so a key whose VALUE was "null" - or contained "not found",
|
|
328
|
+
// or any OTHER key's value did - reported the key as missing, and a
|
|
329
|
+
// `{{localStorage:...}}` guard skipped work it should have done. An
|
|
330
|
+
// empty string is a stored value and counts as present.
|
|
331
|
+
conditionMet = result?._meta?.storage?.found === true;
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
case 'indexedDB': {
|
|
335
|
+
// DB/STORE/KEY, where the key may itself contain slashes. Two segments
|
|
336
|
+
// ask "does this store hold anything at all".
|
|
337
|
+
const segments = value.split('/');
|
|
338
|
+
const [db, store, ...rest] = segments;
|
|
339
|
+
const key = rest.join('/');
|
|
340
|
+
if (!db || !store) {
|
|
341
|
+
return {
|
|
342
|
+
met: false,
|
|
343
|
+
reason: `Invalid indexedDB condition "${value}". Expected {{indexedDB:DB/STORE/KEY}} or {{indexedDB:DB/STORE}}.`,
|
|
344
|
+
isError: true
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
// A key segment that interpolated to nothing must NOT quietly become the
|
|
348
|
+
// store form ("is anything in here"), which answers a different question
|
|
349
|
+
// and would flip a setup decision with no signal.
|
|
350
|
+
if (rest.length > 0 && !key) {
|
|
351
|
+
return {
|
|
352
|
+
met: false,
|
|
353
|
+
reason: `Invalid indexedDB condition "${value}": the key is empty.`
|
|
354
|
+
+ ` Use {{indexedDB:${db}/${store}}} to ask whether the store holds anything,`
|
|
355
|
+
+ ` or check the {{var:...}} that produced the key.`,
|
|
356
|
+
isError: true
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
// A database or store that doesn't exist yet is the record being
|
|
360
|
+
// ABSENT, not a broken condition: that is the state a fresh profile is
|
|
361
|
+
// in, and the state a setup sequence exists to heal. It arrives as a
|
|
362
|
+
// thrown ToolError, like any other tool failure.
|
|
363
|
+
const probe = async (probeKey) => {
|
|
364
|
+
try {
|
|
365
|
+
const res = probeKey !== undefined
|
|
366
|
+
? await executeToolCall('storage', { action: 'idbGet', db, store, key: probeKey, connectionReason })
|
|
367
|
+
: await executeToolCall('storage', { action: 'idbGetAll', db, store, limit: 1, connectionReason });
|
|
368
|
+
return { res };
|
|
369
|
+
}
|
|
370
|
+
catch (idbError) {
|
|
371
|
+
return { failure: idbError?.message || String(idbError) };
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
const isAbsence = (failure) => /does not exist|not found in database|no object store/i.test(failure);
|
|
375
|
+
/**
|
|
376
|
+
* Presence comes from the tool's structured `_meta`, never from its
|
|
377
|
+
* rendered text: a record whose VALUE contains "No record found for
|
|
378
|
+
* this key." (or "**Count:** 0") read as absent when this grepped the
|
|
379
|
+
* markdown.
|
|
380
|
+
*/
|
|
381
|
+
const presentIn = (res) => {
|
|
382
|
+
const meta = res?._meta?.storage;
|
|
383
|
+
return key ? meta?.found === true : (meta?.count ?? 0) > 0;
|
|
384
|
+
};
|
|
385
|
+
let attempt = await probe(key || undefined);
|
|
386
|
+
if (attempt.failure) {
|
|
387
|
+
if (isAbsence(attempt.failure)) {
|
|
388
|
+
conditionMet = false;
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
met: false,
|
|
393
|
+
reason: `Error evaluating indexedDB condition "${value}": ${attempt.failure}`,
|
|
394
|
+
isError: true
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
conditionMet = presentIn(attempt.res);
|
|
398
|
+
// A condition is written as text, so a numerically-keyed store ("42")
|
|
399
|
+
// would never match its own record - IndexedDB keys 42 and "42" are
|
|
400
|
+
// different keys. Retry as a number before concluding absence.
|
|
401
|
+
if (!conditionMet && key && /^-?\d+(\.\d+)?$/.test(key)) {
|
|
402
|
+
const numeric = await probe(Number(key));
|
|
403
|
+
if (!numeric.failure && presentIn(numeric.res)) {
|
|
404
|
+
await debugLog(logPrefix, `indexedDB key "${key}" matched as a number, not a string`);
|
|
405
|
+
conditionMet = true;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
default:
|
|
411
|
+
return {
|
|
412
|
+
met: false,
|
|
413
|
+
reason: `Unknown condition type: "${type}". Supported types: ${CONDITION_TYPES.join(', ')}`,
|
|
414
|
+
isError: true
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
// Apply negation
|
|
418
|
+
const finalResult = isNegated ? !conditionMet : conditionMet;
|
|
419
|
+
await debugLog(logPrefix, `Condition ${condition} = ${finalResult}`);
|
|
420
|
+
if (finalResult) {
|
|
421
|
+
return { met: true };
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
return { met: false };
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
// Tool execution errors are real errors, not just "condition not met"
|
|
429
|
+
return {
|
|
430
|
+
met: false,
|
|
431
|
+
reason: `Error evaluating ${type} condition: ${error.message}`,
|
|
432
|
+
isError: true
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Shared preparation for any sequence run INSIDE another one (`conditional`'s
|
|
438
|
+
* `then`, `forEach`'s `do`): decide which of its `launchChrome` steps still
|
|
439
|
+
* apply, and which browser its bare steps belong to.
|
|
440
|
+
*
|
|
441
|
+
* Drop launchChrome steps whose browser already exists - the caller handed us a
|
|
442
|
+
* live connection and relaunching it would throw the session away. Keep the ones
|
|
443
|
+
* whose reference is NOT live: a setup sequence that spans two browsers has to be
|
|
444
|
+
* able to create the second one, or it can only ever heal identity in browsers
|
|
445
|
+
* that happened to be open already. Probed only when there is a launch to reason
|
|
446
|
+
* about, so the common nested call costs no extra tool call.
|
|
447
|
+
*/
|
|
448
|
+
async function prepareNestedSequence(sequence, ctx, label, logPrefix) {
|
|
449
|
+
const liveRefs = sequence.commands.some(cmd => cmd.tool === 'launchChrome')
|
|
450
|
+
? await probeLiveConnectionReferences(ctx.executeToolCall)
|
|
451
|
+
: null;
|
|
452
|
+
const keptLaunches = [];
|
|
453
|
+
const filteredCommands = sequence.commands.filter(cmd => {
|
|
454
|
+
if (cmd.tool !== 'launchChrome')
|
|
455
|
+
return true;
|
|
456
|
+
const recorded = typeof cmd.params?.reference === 'string'
|
|
457
|
+
? sanitizeReference(cmd.params.reference)
|
|
458
|
+
: undefined;
|
|
459
|
+
// No reference to reason about, or no readable connection list: fall back to
|
|
460
|
+
// the old always-drop behaviour rather than risk killing the live browser.
|
|
461
|
+
if (!recorded || !liveRefs)
|
|
462
|
+
return false;
|
|
463
|
+
const resolved = ctx.connectionMap?.[recorded] ?? recorded;
|
|
464
|
+
if (liveRefs.has(resolved))
|
|
465
|
+
return false;
|
|
466
|
+
debugLog(logPrefix, `Keeping launchChrome for "${resolved}" in nested sequence "${label}": no such connection in this session`);
|
|
467
|
+
keptLaunches.push(resolved);
|
|
468
|
+
return true;
|
|
469
|
+
});
|
|
470
|
+
// A launch we KEPT created a browser that only this sub-sequence knows about,
|
|
471
|
+
// and `create` hoists a uniform connection OFF the steps - so the setup
|
|
472
|
+
// sequence is a launch followed by BARE steps. Left on the parent's
|
|
473
|
+
// connection, those steps run in the caller's browser: the run creates a
|
|
474
|
+
// browser, does nothing in it, and reports success. Bind the sub-run to the
|
|
475
|
+
// browser it just launched, exactly as a top-level run of that sequence would
|
|
476
|
+
// (extractConnectionFromSequence).
|
|
477
|
+
//
|
|
478
|
+
// Only for a launch we kept. A launch that was DROPPED means the browser
|
|
479
|
+
// already existed, and re-pointing bare steps at it would hijack a nested
|
|
480
|
+
// login/setup sequence that has always run in whatever browser called it.
|
|
481
|
+
const nestedAnalysis = analyzeSequenceConnections(filteredCommands);
|
|
482
|
+
const launchedConnection = extractConnectionFromSequence(filteredCommands, nestedAnalysis);
|
|
483
|
+
const nestedConnection = launchedConnection && keptLaunches.includes(launchedConnection)
|
|
484
|
+
? launchedConnection
|
|
485
|
+
: undefined;
|
|
486
|
+
if (nestedConnection) {
|
|
487
|
+
await debugLog(logPrefix, `Nested sequence "${label}" runs against the browser it launched ("${nestedConnection}"), not the caller's "${ctx.connectionReason}"`);
|
|
488
|
+
}
|
|
489
|
+
return { filteredSequence: { ...sequence, commands: filteredCommands }, filteredCommands, nestedConnection };
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Execute a conditional flow - runs a sequence if condition is met
|
|
493
|
+
*/
|
|
494
|
+
export async function executeConditionalFlow(condition, sequenceName, ctx, recorder,
|
|
495
|
+
/**
|
|
496
|
+
* The parent run's timeout budget, so substeps are bounded the way the
|
|
497
|
+
* caller asked rather than silently falling back to the defaults.
|
|
498
|
+
*
|
|
499
|
+
* `totalTimeout` must be the parent's REMAINING budget, not a fresh copy of
|
|
500
|
+
* its original value - otherwise wrapping steps in a conditional becomes a
|
|
501
|
+
* way to extend the total, and a caller who set a tight bound to fail fast
|
|
502
|
+
* would not get it.
|
|
503
|
+
*/
|
|
504
|
+
budget,
|
|
505
|
+
/**
|
|
506
|
+
* The parent RUN's signal. Without it a nested sequence is deaf to
|
|
507
|
+
* `replay cancel` even at its own step boundaries - the substep loop would
|
|
508
|
+
* run to completion after the user cancelled.
|
|
509
|
+
*/
|
|
510
|
+
abortSignal) {
|
|
511
|
+
const { logPrefix = 'executor' } = ctx;
|
|
512
|
+
const replayConfig = configManager.getReplayConfig();
|
|
513
|
+
const currentDepth = ctx.conditionalDepth ?? 0;
|
|
514
|
+
const callStack = ctx.conditionalCallStack ?? [];
|
|
515
|
+
// Check recursion depth limit - allows oscillating patterns (A→B→A) up to max depth
|
|
516
|
+
if (currentDepth >= replayConfig.maxConditionalDepth) {
|
|
517
|
+
const chain = [...callStack, sequenceName].join(' → ');
|
|
518
|
+
await debugLog(logPrefix, `Conditional depth limit exceeded: ${chain}`);
|
|
519
|
+
return {
|
|
520
|
+
success: false,
|
|
521
|
+
executed: false,
|
|
522
|
+
sequenceName,
|
|
523
|
+
error: `Conditional depth limit (${replayConfig.maxConditionalDepth}) reached: ${chain}. Increase maxConditionalDepth in config if this is intentional.`
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
// Evaluate the condition
|
|
527
|
+
const condResult = await evaluateCondition(condition, ctx);
|
|
528
|
+
if (!condResult.met) {
|
|
529
|
+
// Check if this is an error vs genuine "condition not met"
|
|
530
|
+
if ('isError' in condResult && condResult.isError) {
|
|
531
|
+
// This is an ERROR - fail the sequence
|
|
532
|
+
await debugLog(logPrefix, `Condition evaluation ERROR: ${condResult.reason}`);
|
|
533
|
+
return {
|
|
534
|
+
success: false,
|
|
535
|
+
executed: false,
|
|
536
|
+
sequenceName,
|
|
537
|
+
error: `Condition evaluation failed: ${condResult.reason}`
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
// Genuine "condition not met" - this is success (we correctly evaluated and skipped)
|
|
541
|
+
if ('reason' in condResult && condResult.reason) {
|
|
542
|
+
await debugLog(logPrefix, `Condition not met: ${condResult.reason}`);
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
await debugLog(logPrefix, `Condition ${condition} not met, skipping sequence ${sequenceName}`);
|
|
546
|
+
}
|
|
547
|
+
return { success: true, executed: false, sequenceName };
|
|
548
|
+
}
|
|
549
|
+
await debugLog(logPrefix, `Condition ${condition} met, loading sequence: ${sequenceName}`);
|
|
550
|
+
// Load the sequence
|
|
551
|
+
const loadResult = await loadSequence({ name: sequenceName }, recorder);
|
|
552
|
+
if (!loadResult.success) {
|
|
553
|
+
return { success: false, executed: false, sequenceName, error: `Sequence "${sequenceName}" not found: ${loadResult.error}` };
|
|
554
|
+
}
|
|
555
|
+
const { filteredSequence, nestedConnection, filteredCommands } = await prepareNestedSequence(loadResult.sequence, ctx, sequenceName, logPrefix);
|
|
556
|
+
await debugLog(logPrefix, `Executing conditional sequence "${filteredSequence.name}" with ${filteredCommands.length} commands (depth: ${currentDepth + 1})`);
|
|
557
|
+
// Execute the sequence with updated call stack and depth
|
|
558
|
+
const execResult = await executeSteps({
|
|
559
|
+
sequence: filteredSequence,
|
|
560
|
+
startStep: 0,
|
|
561
|
+
ctx: {
|
|
562
|
+
...ctx,
|
|
563
|
+
...(nestedConnection ? { connectionReason: nestedConnection } : {}),
|
|
564
|
+
conditionalDepth: currentDepth + 1,
|
|
565
|
+
conditionalCallStack: [...callStack, sequenceName]
|
|
566
|
+
},
|
|
567
|
+
// Omitted keys fall back to executeSteps' own defaults, so an unbudgeted
|
|
568
|
+
// caller behaves exactly as before.
|
|
569
|
+
...(budget?.stepTimeout !== undefined ? { stepTimeout: budget.stepTimeout } : {}),
|
|
570
|
+
...(budget?.totalTimeout !== undefined ? { totalTimeout: budget.totalTimeout } : {}),
|
|
571
|
+
abortSignal,
|
|
572
|
+
});
|
|
573
|
+
// Check for failures
|
|
574
|
+
const failedStep = execResult.results.find(r => !r.success);
|
|
575
|
+
if (failedStep) {
|
|
576
|
+
// Don't wrap errors that are already from nested conditionals - just pass through
|
|
577
|
+
const isNestedConditionalError = failedStep.tool === 'conditional' ||
|
|
578
|
+
failedStep.error?.includes('Conditional depth limit') ||
|
|
579
|
+
failedStep.error?.includes('Condition evaluation failed');
|
|
580
|
+
const error = isNestedConditionalError
|
|
581
|
+
? failedStep.error
|
|
582
|
+
: `Conditional sequence "${sequenceName}" failed at step ${failedStep.step} (${failedStep.tool}): ${failedStep.error}`;
|
|
583
|
+
return {
|
|
584
|
+
success: false,
|
|
585
|
+
executed: true,
|
|
586
|
+
sequenceName,
|
|
587
|
+
substeps: execResult.results,
|
|
588
|
+
error,
|
|
589
|
+
durationMs: execResult.durationMs
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
await debugLog(logPrefix, `Conditional sequence completed successfully in ${execResult.durationMs}ms`);
|
|
593
|
+
return {
|
|
594
|
+
success: true,
|
|
595
|
+
executed: true,
|
|
596
|
+
sequenceName,
|
|
597
|
+
substeps: execResult.results,
|
|
598
|
+
durationMs: execResult.durationMs
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Resolve a `forEach` source to the array it enumerates.
|
|
603
|
+
*
|
|
604
|
+
* Two forms, deliberately no more. `{{var:name}}` reads an array a previous
|
|
605
|
+
* `saveAs` step captured - which is how anything non-DOM is enumerated, since
|
|
606
|
+
* `inspect({ action: 'evaluateExpression' })` can already return exactly the
|
|
607
|
+
* list the caller wants and is a recordable step. `{{selectorAll:CSS}}` covers
|
|
608
|
+
* the DOM case without making the caller hand-write an evaluate for it.
|
|
609
|
+
*
|
|
610
|
+
* Note the asymmetry with `conditional`'s conditions: those ask whether one
|
|
611
|
+
* named thing exists, so they can't express "give me every X". That gap is the
|
|
612
|
+
* whole reason this step exists.
|
|
613
|
+
*/
|
|
614
|
+
export async function resolveForEachItems(source, ctx) {
|
|
615
|
+
// Already an array: `{{var:rows}}` is a whole-string token, so the run's
|
|
616
|
+
// normal param interpolation has resolved it before this step is dispatched -
|
|
617
|
+
// and it preserves type, so what arrives IS the captured array. That is the
|
|
618
|
+
// common path; the string form below only survives for a nested path that
|
|
619
|
+
// resolved to something odd, and for direct calls.
|
|
620
|
+
if (Array.isArray(source))
|
|
621
|
+
return { ok: true, items: source };
|
|
622
|
+
if (typeof source !== 'string') {
|
|
623
|
+
return {
|
|
624
|
+
ok: false,
|
|
625
|
+
error: `forEach: source resolved to ${source === null ? 'null' : typeof source}, not an array. Expected {{var:name}} pointing at an array, or {{selectorAll:CSS}}.`,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
const varMatch = source.match(/^\{\{var:([^}]+)\}\}$/);
|
|
629
|
+
if (varMatch) {
|
|
630
|
+
const path = varMatch[1].trim();
|
|
631
|
+
const [head, ...rest] = path.split('.');
|
|
632
|
+
let value = ctx.variableStore?.[head];
|
|
633
|
+
if (value === undefined) {
|
|
634
|
+
return { ok: false, error: `forEach: no variable named "${head}". Capture one first with a { saveAs } step.` };
|
|
635
|
+
}
|
|
636
|
+
for (const key of rest) {
|
|
637
|
+
value = value?.[key];
|
|
638
|
+
if (value === undefined) {
|
|
639
|
+
return { ok: false, error: `forEach: "${path}" is undefined on the captured variable.` };
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (!Array.isArray(value)) {
|
|
643
|
+
return { ok: false, error: `forEach: "${path}" is ${typeof value}, not an array. The source must resolve to an array.` };
|
|
644
|
+
}
|
|
645
|
+
return { ok: true, items: value };
|
|
646
|
+
}
|
|
647
|
+
const selectorMatch = source.match(/^\{\{selectorAll:(.+)\}\}$/);
|
|
648
|
+
if (selectorMatch) {
|
|
649
|
+
const selector = selectorMatch[1];
|
|
650
|
+
// Elements themselves can't cross the CDP boundary, so each item is a plain
|
|
651
|
+
// descriptor. `index` is what a `do` sequence uses to address the element
|
|
652
|
+
// again (:nth-of-type and friends); text/id/class cover the common filters.
|
|
653
|
+
const expression = `(() => Array.from(document.querySelectorAll(${JSON.stringify(selector)})).map((el, index) => ({
|
|
654
|
+
index,
|
|
655
|
+
text: (el.textContent || '').trim(),
|
|
656
|
+
id: el.id || null,
|
|
657
|
+
className: typeof el.className === 'string' ? el.className : null,
|
|
658
|
+
href: el.getAttribute && el.getAttribute('href'),
|
|
659
|
+
value: 'value' in el ? el.value : undefined,
|
|
660
|
+
})))()`;
|
|
661
|
+
try {
|
|
662
|
+
const result = await ctx.executeToolCall('inspect', {
|
|
663
|
+
action: 'evaluateExpression',
|
|
664
|
+
expression,
|
|
665
|
+
...(ctx.connectionReason ? { connectionReason: ctx.connectionReason } : {}),
|
|
666
|
+
});
|
|
667
|
+
const value = result?._meta?.inspect?.value;
|
|
668
|
+
if (!Array.isArray(value)) {
|
|
669
|
+
return { ok: false, error: `forEach: {{selectorAll:${selector}}} did not evaluate to a list.` };
|
|
670
|
+
}
|
|
671
|
+
return { ok: true, items: value };
|
|
672
|
+
}
|
|
673
|
+
catch (err) {
|
|
674
|
+
return { ok: false, error: `forEach: enumerating {{selectorAll:${selector}}} failed: ${err?.message || String(err)}` };
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
ok: false,
|
|
679
|
+
error: `forEach: unrecognised source "${source}". Expected {{var:name}} (an array captured by a previous saveAs) or {{selectorAll:CSS}}.`,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Evaluate a `where` predicate for one item.
|
|
684
|
+
*
|
|
685
|
+
* The predicate is JavaScript with `item` and `index` in scope, evaluated in the
|
|
686
|
+
* page - NOT the `{{...}}` condition grammar. Conditions probe the browser for
|
|
687
|
+
* one named thing; a filter has to read fields off an arbitrary object, which
|
|
688
|
+
* that grammar cannot express, and inventing a second mini-language to sit
|
|
689
|
+
* beside it would leave two half-expressive syntaxes instead of one real one.
|
|
690
|
+
*/
|
|
691
|
+
async function evaluateForEachFilter(where, item, index, ctx) {
|
|
692
|
+
const expression = `(() => { const item = ${JSON.stringify(item)}; const index = ${index}; return !!(${where}); })()`;
|
|
693
|
+
try {
|
|
694
|
+
const result = await ctx.executeToolCall('inspect', {
|
|
695
|
+
action: 'evaluateExpression',
|
|
696
|
+
expression,
|
|
697
|
+
...(ctx.connectionReason ? { connectionReason: ctx.connectionReason } : {}),
|
|
698
|
+
});
|
|
699
|
+
return { ok: true, keep: result?._meta?.inspect?.value === true };
|
|
700
|
+
}
|
|
701
|
+
catch (err) {
|
|
702
|
+
// A filter that cannot be evaluated is an error, not a quiet "exclude" -
|
|
703
|
+
// the same rule conditions follow. Silently dropping every item would make
|
|
704
|
+
// a typo'd predicate look like an empty result set.
|
|
705
|
+
return { ok: false, error: `forEach: where "${where}" could not be evaluated: ${err?.message || String(err)}` };
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Run a sequence once per item of an enumerated source.
|
|
710
|
+
*
|
|
711
|
+
* Each iteration binds the item to `as` in the run's variable store (and its
|
|
712
|
+
* position to `<as>Index`), so the body addresses it with {{var:<as>.field}}
|
|
713
|
+
* exactly like any captured variable. The binding is REPLACED per iteration
|
|
714
|
+
* rather than scoped, because the variable store is shared by reference across
|
|
715
|
+
* nested runs - which also means a body's own `saveAs` captures survive into
|
|
716
|
+
* the next iteration, and a caller relying on that should say so.
|
|
717
|
+
*/
|
|
718
|
+
export async function executeForEachFlow(params, ctx, recorder,
|
|
719
|
+
/** The parent's REMAINING budget - a loop must not extend the total. */
|
|
720
|
+
budget, abortSignal) {
|
|
721
|
+
const { logPrefix = 'executor' } = ctx;
|
|
722
|
+
const replayConfig = configManager.getReplayConfig();
|
|
723
|
+
const startedAt = Date.now();
|
|
724
|
+
const sequenceName = params.do;
|
|
725
|
+
const currentDepth = ctx.conditionalDepth ?? 0;
|
|
726
|
+
const callStack = ctx.conditionalCallStack ?? [];
|
|
727
|
+
// Shares the conditional depth budget: a loop body that loops is the same
|
|
728
|
+
// runaway risk, and one cap is easier to reason about than two.
|
|
729
|
+
if (currentDepth >= replayConfig.maxConditionalDepth) {
|
|
730
|
+
const chain = [...callStack, sequenceName].join(' → ');
|
|
731
|
+
return {
|
|
732
|
+
success: false, sequenceName, itemsFound: 0, iterations: 0,
|
|
733
|
+
error: `Nesting depth limit (${replayConfig.maxConditionalDepth}) reached: ${chain}. Increase maxConditionalDepth in config if this is intentional.`,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
const resolved = await resolveForEachItems(params.in, ctx);
|
|
737
|
+
if (!resolved.ok) {
|
|
738
|
+
return { success: false, sequenceName, itemsFound: 0, iterations: 0, error: resolved.error };
|
|
739
|
+
}
|
|
740
|
+
const itemsFound = resolved.items.length;
|
|
741
|
+
const cap = params.maxItems ?? DEFAULT_FOREACH_MAX_ITEMS;
|
|
742
|
+
const loadResult = await loadSequence({ name: sequenceName }, recorder);
|
|
743
|
+
if (!loadResult.success) {
|
|
744
|
+
return {
|
|
745
|
+
success: false, sequenceName, itemsFound, iterations: 0,
|
|
746
|
+
error: `Sequence "${sequenceName}" not found: ${loadResult.error}`,
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
const { filteredSequence, nestedConnection } = await prepareNestedSequence(loadResult.sequence, ctx, sequenceName, logPrefix);
|
|
750
|
+
const substeps = [];
|
|
751
|
+
let iterations = 0;
|
|
752
|
+
for (let index = 0; index < itemsFound; index++) {
|
|
753
|
+
if (abortSignal?.aborted) {
|
|
754
|
+
return {
|
|
755
|
+
success: false, sequenceName, itemsFound, iterations, substeps,
|
|
756
|
+
error: 'Replay aborted by user', durationMs: Date.now() - startedAt,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
if (iterations >= cap) {
|
|
760
|
+
await debugLog(logPrefix, `forEach: stopping at maxItems (${cap}) with ${itemsFound - index} item(s) unvisited`);
|
|
761
|
+
break;
|
|
762
|
+
}
|
|
763
|
+
const item = resolved.items[index];
|
|
764
|
+
if (params.where) {
|
|
765
|
+
const filtered = await evaluateForEachFilter(params.where, item, index, ctx);
|
|
766
|
+
if (!filtered.ok) {
|
|
767
|
+
return {
|
|
768
|
+
success: false, sequenceName, itemsFound, iterations, substeps,
|
|
769
|
+
error: filtered.error, durationMs: Date.now() - startedAt,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
if (!filtered.keep)
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
const store = (ctx.variableStore ??= {});
|
|
776
|
+
store[params.as] = item;
|
|
777
|
+
store[`${params.as}Index`] = index;
|
|
778
|
+
iterations++;
|
|
779
|
+
const elapsed = Date.now() - startedAt;
|
|
780
|
+
const execResult = await executeSteps({
|
|
781
|
+
sequence: filteredSequence,
|
|
782
|
+
startStep: 0,
|
|
783
|
+
ctx: {
|
|
784
|
+
...ctx,
|
|
785
|
+
...(nestedConnection ? { connectionReason: nestedConnection } : {}),
|
|
786
|
+
conditionalDepth: currentDepth + 1,
|
|
787
|
+
conditionalCallStack: [...callStack, sequenceName],
|
|
788
|
+
},
|
|
789
|
+
...(budget?.stepTimeout !== undefined ? { stepTimeout: budget.stepTimeout } : {}),
|
|
790
|
+
...(budget?.totalTimeout !== undefined
|
|
791
|
+
? { totalTimeout: Math.max(0, budget.totalTimeout - elapsed) }
|
|
792
|
+
: {}),
|
|
793
|
+
abortSignal,
|
|
794
|
+
});
|
|
795
|
+
substeps.push(...execResult.results);
|
|
796
|
+
const failedStep = execResult.results.find(r => !r.success);
|
|
797
|
+
if (failedStep) {
|
|
798
|
+
return {
|
|
799
|
+
success: false, sequenceName, itemsFound, iterations, substeps,
|
|
800
|
+
error: `forEach body "${sequenceName}" failed on item ${index + 1}/${itemsFound} at step ${failedStep.step} (${failedStep.tool}): ${failedStep.error}`,
|
|
801
|
+
durationMs: Date.now() - startedAt,
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
await debugLog(logPrefix, `forEach over ${itemsFound} item(s) ran ${iterations} iteration(s) of "${sequenceName}"`);
|
|
806
|
+
return { success: true, sequenceName, itemsFound, iterations, substeps, durationMs: Date.now() - startedAt };
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Load a sequence from memory (by sequenceId) or disk (by name)
|
|
810
|
+
*/
|
|
811
|
+
export async function loadSequence(args, recorder) {
|
|
812
|
+
if (args.sequenceId) {
|
|
813
|
+
// Disk wins when the file is newer: memory used to shadow an edited
|
|
814
|
+
// sequence for the whole session, so a re-run silently executed the old
|
|
815
|
+
// version (issue #134).
|
|
816
|
+
const sequence = await recorder.getFreshSequence(args.sequenceId);
|
|
817
|
+
if (!sequence) {
|
|
818
|
+
return {
|
|
819
|
+
success: false,
|
|
820
|
+
error: `Sequence "${args.sequenceId}" not found in memory. Use listSaved to see disk sequences or list for memory sequences.`,
|
|
821
|
+
errorCode: 'SEQUENCE_NOT_FOUND'
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
return { success: true, sequence };
|
|
825
|
+
}
|
|
826
|
+
if (args.name) {
|
|
827
|
+
// Check memory first
|
|
828
|
+
const memorySequences = recorder.listSequences();
|
|
829
|
+
const memoryMatch = memorySequences.find(s => s.name === args.name);
|
|
830
|
+
if (memoryMatch) {
|
|
831
|
+
const current = await recorder.getFreshSequence(memoryMatch.id) ?? memoryMatch;
|
|
832
|
+
await debugLog('executor', `Found sequence "${current.name}" in memory`);
|
|
833
|
+
return { success: true, sequence: current };
|
|
834
|
+
}
|
|
835
|
+
// Then check disk (loadSequenceFromDisk has fuzzy matching built in)
|
|
836
|
+
const sequence = await recorder.loadSequenceFromDisk(args.name);
|
|
837
|
+
if (!sequence) {
|
|
838
|
+
const savedSequences = await recorder.listSavedSequencesOnDisk();
|
|
839
|
+
const availableNames = [
|
|
840
|
+
...memorySequences.map(s => s.name),
|
|
841
|
+
...savedSequences.map(s => s.name)
|
|
842
|
+
].join(', ');
|
|
843
|
+
return {
|
|
844
|
+
success: false,
|
|
845
|
+
error: `No sequence found matching "${args.name}". Available: ${availableNames || 'none'}`,
|
|
846
|
+
errorCode: 'SEQUENCE_NOT_FOUND'
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
await debugLog('executor', `Loaded sequence "${sequence.name}" via fuzzy match for "${args.name}"`);
|
|
850
|
+
return { success: true, sequence };
|
|
851
|
+
}
|
|
852
|
+
return {
|
|
853
|
+
success: false,
|
|
854
|
+
error: 'Either "name" or "sequenceId" parameter is required.',
|
|
855
|
+
errorCode: 'MISSING_PARAMETER',
|
|
856
|
+
templateVars: { missing: 'name or sequenceId' }
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
// =============================================================================
|
|
860
|
+
// Run-time Rebasing
|
|
861
|
+
// =============================================================================
|
|
862
|
+
/**
|
|
863
|
+
* Return a deep copy of the sequence retargeted at another deployment:
|
|
864
|
+
* every absolute http(s) URL — the startUrl and any string param in any
|
|
865
|
+
* command (navigate goto, request url, ...) — keeps its path/query/hash but
|
|
866
|
+
* takes `baseUrl`'s origin. Relative URLs are untouched (they already follow
|
|
867
|
+
* the page origin). An explicit `startUrl` replaces the sequence's startUrl
|
|
868
|
+
* wholesale, after rebasing, for runs whose entry point differs per target
|
|
869
|
+
* (e.g. a freshly minted share link). The stored sequence is never mutated —
|
|
870
|
+
* loadSequence can return the recorder's in-memory object.
|
|
871
|
+
*/
|
|
872
|
+
export function rebaseSequence(sequence, overrides) {
|
|
873
|
+
const origin = overrides.baseUrl ? new URL(overrides.baseUrl).origin : null;
|
|
874
|
+
const rebase = (value) => {
|
|
875
|
+
if (!origin || !/^https?:\/\//i.test(value))
|
|
876
|
+
return value;
|
|
877
|
+
try {
|
|
878
|
+
const u = new URL(value);
|
|
879
|
+
return origin + u.pathname + u.search + u.hash;
|
|
880
|
+
}
|
|
881
|
+
catch {
|
|
882
|
+
return value;
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
const walk = (value) => {
|
|
886
|
+
if (typeof value === 'string')
|
|
887
|
+
return rebase(value);
|
|
888
|
+
if (Array.isArray(value))
|
|
889
|
+
return value.map(walk);
|
|
890
|
+
if (value && typeof value === 'object') {
|
|
891
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, walk(v)]));
|
|
892
|
+
}
|
|
893
|
+
return value;
|
|
894
|
+
};
|
|
895
|
+
return {
|
|
896
|
+
...sequence,
|
|
897
|
+
startUrl: overrides.startUrl ?? (sequence.startUrl ? rebase(sequence.startUrl) : sequence.startUrl),
|
|
898
|
+
commands: sequence.commands.map(cmd => ({
|
|
899
|
+
...cmd,
|
|
900
|
+
params: walk(cmd.params),
|
|
901
|
+
})),
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
// =============================================================================
|
|
905
|
+
// Connection Analysis
|
|
906
|
+
// =============================================================================
|
|
907
|
+
/**
|
|
908
|
+
* Analyze sequence commands to find launchChrome and determine connection requirements
|
|
909
|
+
*/
|
|
910
|
+
export function analyzeSequenceConnections(commands) {
|
|
911
|
+
let launchChromeIndex = -1;
|
|
912
|
+
let firstConnectionToolIndex = -1;
|
|
913
|
+
for (let i = 0; i < commands.length; i++) {
|
|
914
|
+
if (commands[i].tool === 'launchChrome' && launchChromeIndex === -1) {
|
|
915
|
+
launchChromeIndex = i;
|
|
916
|
+
}
|
|
917
|
+
if (commandNeedsBrowserConnection(commands[i]) && firstConnectionToolIndex === -1) {
|
|
918
|
+
firstConnectionToolIndex = i;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
const hasLaunchBeforeConnection = launchChromeIndex !== -1 &&
|
|
922
|
+
(firstConnectionToolIndex === -1 || launchChromeIndex < firstConnectionToolIndex);
|
|
923
|
+
return { launchChromeIndex, firstConnectionToolIndex, hasLaunchBeforeConnection };
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Extract connectionReason from sequence's launchChrome command if present
|
|
927
|
+
*/
|
|
928
|
+
export function extractConnectionFromSequence(commands, analysis) {
|
|
929
|
+
if (analysis.hasLaunchBeforeConnection) {
|
|
930
|
+
const launchParams = commands[analysis.launchChromeIndex].params;
|
|
931
|
+
if (launchParams.reference) {
|
|
932
|
+
return sanitizeReference(launchParams.reference);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return undefined;
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* What connections a recorded/stored sequence's steps name.
|
|
939
|
+
*/
|
|
940
|
+
export function analyzeRecordedStepConnections(commands) {
|
|
941
|
+
const references = [];
|
|
942
|
+
let bareBrowserSteps = 0;
|
|
943
|
+
for (const cmd of commands) {
|
|
944
|
+
const raw = cmd.params?.connectionReason;
|
|
945
|
+
if (typeof raw === 'string' && raw.trim()) {
|
|
946
|
+
const ref = sanitizeReference(raw);
|
|
947
|
+
if (!references.includes(ref))
|
|
948
|
+
references.push(ref);
|
|
949
|
+
}
|
|
950
|
+
else if (commandTakesInjectedConnection(cmd)) {
|
|
951
|
+
bareBrowserSteps++;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
return {
|
|
955
|
+
references,
|
|
956
|
+
...(references.length === 1 ? { uniform: references[0] } : {}),
|
|
957
|
+
mixed: references.length > 0 && bareBrowserSteps > 0,
|
|
958
|
+
multiConnection: references.length > 1,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Hoist a uniform per-step connection back off the steps so the sequence stays
|
|
963
|
+
* portable: `replay({ action: 'run', connectionReason: 'other' })` can then
|
|
964
|
+
* retarget the whole thing. Steps keep their own connection only where the
|
|
965
|
+
* sequence genuinely spans connections (or where it is ambiguous - see
|
|
966
|
+
* `mixed`), which is the case a run-level connection cannot express.
|
|
967
|
+
*
|
|
968
|
+
* Returns a new command array; the input is never mutated.
|
|
969
|
+
*/
|
|
970
|
+
export function normalizeStepConnections(commands) {
|
|
971
|
+
const analysis = analyzeRecordedStepConnections(commands);
|
|
972
|
+
if (analysis.uniform === undefined || analysis.mixed) {
|
|
973
|
+
return { commands, analysis };
|
|
974
|
+
}
|
|
975
|
+
const hoisted = analysis.uniform;
|
|
976
|
+
const stripped = commands.map(cmd => {
|
|
977
|
+
if (cmd.params?.connectionReason === undefined)
|
|
978
|
+
return cmd;
|
|
979
|
+
const { connectionReason, ...rest } = cmd.params;
|
|
980
|
+
return { ...cmd, params: rest };
|
|
981
|
+
});
|
|
982
|
+
return { commands: stripped, hoisted, analysis };
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Normalize a recorded-reference -> session-reference map (both sides sanitized,
|
|
986
|
+
* so `{ 'Duo Member Two': 'My Second Browser' }` works the same as the
|
|
987
|
+
* hyphenated form). Returns undefined for an empty/absent map.
|
|
988
|
+
*/
|
|
989
|
+
export function sanitizeConnectionMap(map) {
|
|
990
|
+
if (!map)
|
|
991
|
+
return undefined;
|
|
992
|
+
const out = {};
|
|
993
|
+
for (const [from, to] of Object.entries(map)) {
|
|
994
|
+
if (typeof to !== 'string' || !to.trim())
|
|
995
|
+
continue;
|
|
996
|
+
out[sanitizeReference(from)] = sanitizeReference(to);
|
|
997
|
+
}
|
|
998
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Check if sequence needs a connection
|
|
1002
|
+
*/
|
|
1003
|
+
export function sequenceNeedsConnection(commands) {
|
|
1004
|
+
return commands.some(cmd => commandNeedsBrowserConnection(cmd) && !cmd.params.connectionReason);
|
|
1005
|
+
}
|
|
1006
|
+
// =============================================================================
|
|
1007
|
+
// Connection Management
|
|
1008
|
+
// =============================================================================
|
|
1009
|
+
/**
|
|
1010
|
+
* The connection references live in this session, as `listConnections` reports
|
|
1011
|
+
* them. Returns null when that cannot be determined (probe failed, or a stubbed
|
|
1012
|
+
* executeToolCall returned nothing parseable) - callers must treat null as
|
|
1013
|
+
* "unknown" and NOT as "empty", or every per-step connection would be rejected.
|
|
1014
|
+
*/
|
|
1015
|
+
export async function probeLiveConnectionReferences(executeToolCall) {
|
|
1016
|
+
try {
|
|
1017
|
+
const result = await executeToolCall('listConnections', {});
|
|
1018
|
+
const text = result?.content?.[0]?.text || '';
|
|
1019
|
+
const parsed = parseConnectionList(text);
|
|
1020
|
+
if (!parsed)
|
|
1021
|
+
return null;
|
|
1022
|
+
// A connection whose socket has already dropped is not somewhere a step can
|
|
1023
|
+
// run, so it must not count as live - otherwise a healing sequence skips the
|
|
1024
|
+
// launch that would have replaced it.
|
|
1025
|
+
const refs = new Set();
|
|
1026
|
+
for (const c of parsed) {
|
|
1027
|
+
if (c.connected !== false)
|
|
1028
|
+
refs.add(sanitizeReference(c.reference));
|
|
1029
|
+
}
|
|
1030
|
+
return refs.size > 0 ? refs : null;
|
|
1031
|
+
}
|
|
1032
|
+
catch {
|
|
1033
|
+
return null;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* The `connections` array out of a `listConnections` response, or null when the
|
|
1038
|
+
* response carries no parseable JSON block (a stub, or a future format) - null
|
|
1039
|
+
* means "unknown", never "empty".
|
|
1040
|
+
*/
|
|
1041
|
+
export function parseConnectionList(text) {
|
|
1042
|
+
const block = text.match(/```json\s*([\s\S]*?)```/);
|
|
1043
|
+
if (!block)
|
|
1044
|
+
return null;
|
|
1045
|
+
try {
|
|
1046
|
+
const parsed = JSON.parse(block[1]);
|
|
1047
|
+
const list = parsed?.connections;
|
|
1048
|
+
if (!Array.isArray(list))
|
|
1049
|
+
return null;
|
|
1050
|
+
return list.filter((c) => typeof c?.reference === 'string');
|
|
1051
|
+
}
|
|
1052
|
+
catch {
|
|
1053
|
+
return null;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Resolve a step's RECORDED connectionReason onto this session, and refuse to
|
|
1058
|
+
* proceed if it doesn't exist here (bug-018).
|
|
1059
|
+
*
|
|
1060
|
+
* Falling back to the run-level connection with a warning is exactly the failure
|
|
1061
|
+
* this exists to prevent: a sequence whose purpose is proving something crosses
|
|
1062
|
+
* a browser boundary would run entirely in one browser and still pass.
|
|
1063
|
+
*/
|
|
1064
|
+
export function formatMissingStepConnection(opts) {
|
|
1065
|
+
const { step, tool, recorded, resolved, mapped, runConnection, live } = opts;
|
|
1066
|
+
const via = mapped ? ` (mapped from recorded "${recorded}")` : '';
|
|
1067
|
+
return [
|
|
1068
|
+
`Step ${step} (${tool}) needs connection "${resolved}"${via}, which does not exist in this session.`,
|
|
1069
|
+
`Active connections: ${live.length ? live.join(', ') : 'none'}.`,
|
|
1070
|
+
`The step names its own connection, so it is NOT run against` +
|
|
1071
|
+
` the run-level connection${runConnection ? ` "${runConnection}"` : ''} - that would replay a` +
|
|
1072
|
+
` multi-browser sequence in a single browser and report success.`,
|
|
1073
|
+
`Either create it (launchChrome({ reference: "${resolved}" })) or rebind it:` +
|
|
1074
|
+
` replay({ action: 'run', ..., connections: { "${recorded}": "<a reference from this session>" } }).`,
|
|
1075
|
+
].join(' ');
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Check if debugger is paused and return breakpoint info if so
|
|
1079
|
+
*/
|
|
1080
|
+
export async function checkIfPaused(ctx) {
|
|
1081
|
+
const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
|
|
1082
|
+
if (!connectionReason)
|
|
1083
|
+
return null;
|
|
1084
|
+
try {
|
|
1085
|
+
const callStackResult = await executeToolCall('inspect', {
|
|
1086
|
+
action: 'getCallStack',
|
|
1087
|
+
connectionReason
|
|
1088
|
+
});
|
|
1089
|
+
const callStackText = callStackResult?.content?.[0]?.text || '';
|
|
1090
|
+
const isPaused = callStackText.includes('callFrameId');
|
|
1091
|
+
if (isPaused) {
|
|
1092
|
+
// Extract pause location from call stack - try header format first, then JSON
|
|
1093
|
+
let url = 'unknown';
|
|
1094
|
+
let lineNumber = 0;
|
|
1095
|
+
let columnNumber;
|
|
1096
|
+
let functionName;
|
|
1097
|
+
// Try header format: "Paused at: http://localhost:3101/client.js:6"
|
|
1098
|
+
const pausedAtMatch = callStackText.match(/Paused at:\s*([^:\s]+):(\d+)/);
|
|
1099
|
+
if (pausedAtMatch) {
|
|
1100
|
+
url = pausedAtMatch[1];
|
|
1101
|
+
lineNumber = parseInt(pausedAtMatch[2], 10);
|
|
1102
|
+
}
|
|
1103
|
+
// Try JSON format for more details
|
|
1104
|
+
const sourceMatch = callStackText.match(/"source":\s*"([^"]+)"/);
|
|
1105
|
+
const lineMatch = callStackText.match(/"line":\s*(\d+)/);
|
|
1106
|
+
const colMatch = callStackText.match(/"column":\s*(\d+)/);
|
|
1107
|
+
const funcMatch = callStackText.match(/"functionName":\s*"([^"]+)"/);
|
|
1108
|
+
if (sourceMatch)
|
|
1109
|
+
url = sourceMatch[1];
|
|
1110
|
+
if (lineMatch)
|
|
1111
|
+
lineNumber = parseInt(lineMatch[1], 10);
|
|
1112
|
+
if (colMatch)
|
|
1113
|
+
columnNumber = parseInt(colMatch[1], 10);
|
|
1114
|
+
if (funcMatch && funcMatch[1])
|
|
1115
|
+
functionName = funcMatch[1];
|
|
1116
|
+
return { url, lineNumber, columnNumber, functionName };
|
|
1117
|
+
}
|
|
1118
|
+
return null;
|
|
1119
|
+
}
|
|
1120
|
+
catch {
|
|
1121
|
+
return null;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Check if debugger is paused and auto-resume if so
|
|
1126
|
+
*/
|
|
1127
|
+
export async function resumeIfPaused(ctx) {
|
|
1128
|
+
const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
|
|
1129
|
+
if (!connectionReason)
|
|
1130
|
+
return;
|
|
1131
|
+
const pauseInfo = await checkIfPaused(ctx);
|
|
1132
|
+
if (pauseInfo) {
|
|
1133
|
+
debugLog(logPrefix, `Debugger is paused at ${pauseInfo.url}:${pauseInfo.lineNumber}, auto-resuming`);
|
|
1134
|
+
try {
|
|
1135
|
+
await executeToolCall('execution', {
|
|
1136
|
+
action: 'resume',
|
|
1137
|
+
connectionReason
|
|
1138
|
+
});
|
|
1139
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
1140
|
+
}
|
|
1141
|
+
catch {
|
|
1142
|
+
// Ignore resume errors
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Validate reference and auto-launch Chrome if needed.
|
|
1148
|
+
* This is the shared helper for all auto-launch scenarios.
|
|
1149
|
+
*/
|
|
1150
|
+
export async function autoLaunchChrome(executeToolCall, connectionReason, logPrefix = 'auto-launch', forceNewInstance = false) {
|
|
1151
|
+
// Validate connectionReason before launch (throws InvalidReferenceError if invalid)
|
|
1152
|
+
requireValidReference(connectionReason);
|
|
1153
|
+
await debugLog(logPrefix, `Auto-launching Chrome with reference: ${connectionReason} (forceNewInstance=${forceNewInstance})`);
|
|
1154
|
+
// A launch failure arrives as a THROW in production (executeToolCall rethrows
|
|
1155
|
+
// isError) - and this helper is called from inside ensureConnection's catch,
|
|
1156
|
+
// so letting it through escaped the run entirely: the caller's LAUNCH_FAILED
|
|
1157
|
+
// handling, and its "launch Chrome manually first" suggestion, never ran and
|
|
1158
|
+
// the user saw a raw tool error instead.
|
|
1159
|
+
try {
|
|
1160
|
+
await executeToolCall('launchChrome', { reference: connectionReason, forceNewInstance });
|
|
1161
|
+
}
|
|
1162
|
+
catch (launchError) {
|
|
1163
|
+
return {
|
|
1164
|
+
success: false,
|
|
1165
|
+
error: `Failed to auto-launch Chrome: ${launchError?.response?.content?.[0]?.text || launchError?.message || 'Unknown error'}`,
|
|
1166
|
+
errorType: 'LAUNCH_FAILED'
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
await debugLog(logPrefix, `Chrome launched successfully with reference: ${connectionReason}`);
|
|
1170
|
+
return { success: true };
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Ensure a connection is available, auto-launching Chrome if needed
|
|
1174
|
+
*/
|
|
1175
|
+
export async function ensureConnection(ctx, needsConnection, hasLaunchBeforeConnection) {
|
|
1176
|
+
const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
|
|
1177
|
+
if (!needsConnection || hasLaunchBeforeConnection) {
|
|
1178
|
+
return { success: true, didAutoLaunch: false };
|
|
1179
|
+
}
|
|
1180
|
+
try {
|
|
1181
|
+
await debugLog(logPrefix, `Checking connection: ${connectionReason}`);
|
|
1182
|
+
const infoResult = await executeToolCall('navigate', { action: 'info', connectionReason });
|
|
1183
|
+
// In production this throws instead, into the same catch below; the check
|
|
1184
|
+
// is for a caller wired not to rethrow.
|
|
1185
|
+
if (infoResult?.isError) {
|
|
1186
|
+
throw new Error('Connection not active');
|
|
1187
|
+
}
|
|
1188
|
+
await debugLog(logPrefix, `Connection ${connectionReason} is active`);
|
|
1189
|
+
// Auto-resume if paused at a breakpoint
|
|
1190
|
+
await resumeIfPaused(ctx);
|
|
1191
|
+
return { success: true, didAutoLaunch: false };
|
|
1192
|
+
}
|
|
1193
|
+
catch {
|
|
1194
|
+
await debugLog(logPrefix, `Connection ${connectionReason} not active, launching Chrome...`);
|
|
1195
|
+
// Sequence runs always get a fresh Chrome process, not a tab in an existing one
|
|
1196
|
+
const launchResult = await autoLaunchChrome(executeToolCall, connectionReason, logPrefix, true);
|
|
1197
|
+
if (!launchResult.success) {
|
|
1198
|
+
return { success: false, error: launchResult.error };
|
|
1199
|
+
}
|
|
1200
|
+
return { success: true, didAutoLaunch: true };
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Check if a URL's port is open (for localhost URLs only)
|
|
1205
|
+
* Returns success if port is open or URL is not localhost
|
|
1206
|
+
*/
|
|
1207
|
+
export async function checkPortBeforeNavigation(url, logPrefix = 'executor') {
|
|
1208
|
+
const portCheck = await checkUrlPort(url, 2000);
|
|
1209
|
+
// null means non-localhost URL - skip check
|
|
1210
|
+
if (portCheck === null) {
|
|
1211
|
+
return { success: true };
|
|
1212
|
+
}
|
|
1213
|
+
if (!portCheck.open) {
|
|
1214
|
+
const error = `Port ${portCheck.port} is not open on ${portCheck.host}`;
|
|
1215
|
+
await debugLog(logPrefix, error);
|
|
1216
|
+
return { success: false, error };
|
|
1217
|
+
}
|
|
1218
|
+
await debugLog(logPrefix, `Port ${portCheck.port} is open on ${portCheck.host}`);
|
|
1219
|
+
return { success: true };
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Navigate to startUrl if sequence has one and doesn't start with navigate
|
|
1223
|
+
*/
|
|
1224
|
+
export async function navigateToStartUrl(ctx, sequence, analysis) {
|
|
1225
|
+
const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
|
|
1226
|
+
if (!sequence.startUrl || !connectionReason) {
|
|
1227
|
+
return { success: true };
|
|
1228
|
+
}
|
|
1229
|
+
const commands = sequence.commands;
|
|
1230
|
+
const firstNavigateIndex = commands.findIndex(cmd => cmd.tool === 'navigate' && cmd.params.action === 'goto');
|
|
1231
|
+
const startsWithNavigate = firstNavigateIndex === 0 ||
|
|
1232
|
+
(analysis.hasLaunchBeforeConnection && firstNavigateIndex === analysis.launchChromeIndex + 1);
|
|
1233
|
+
if (startsWithNavigate) {
|
|
1234
|
+
return { success: true };
|
|
1235
|
+
}
|
|
1236
|
+
// Check if port is open before navigating (localhost only)
|
|
1237
|
+
const portCheck = await checkPortBeforeNavigation(sequence.startUrl, logPrefix);
|
|
1238
|
+
if (!portCheck.success) {
|
|
1239
|
+
return portCheck;
|
|
1240
|
+
}
|
|
1241
|
+
await debugLog(logPrefix, `Auto-navigating to startUrl: ${sequence.startUrl}`);
|
|
1242
|
+
try {
|
|
1243
|
+
await executeToolCall('navigate', {
|
|
1244
|
+
action: 'goto',
|
|
1245
|
+
url: sequence.startUrl,
|
|
1246
|
+
connectionReason
|
|
1247
|
+
});
|
|
1248
|
+
await debugLog(logPrefix, `Navigated to startUrl: ${sequence.startUrl}`);
|
|
1249
|
+
return { success: true };
|
|
1250
|
+
}
|
|
1251
|
+
catch (navError) {
|
|
1252
|
+
return {
|
|
1253
|
+
success: false,
|
|
1254
|
+
error: `Failed to navigate to startUrl: ${navError.message}`
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
// =============================================================================
|
|
1259
|
+
// Command Execution
|
|
1260
|
+
// =============================================================================
|
|
1261
|
+
/**
|
|
1262
|
+
* Execute a single command with retry logic for element not found errors
|
|
1263
|
+
*/
|
|
1264
|
+
export async function executeCommandWithRetry(executeToolCall, tool, params, logPrefix = 'executor',
|
|
1265
|
+
/**
|
|
1266
|
+
* The RUN's signal, forwarded to the tool handler so handlers that honour
|
|
1267
|
+
* it (currently `wait`) are interrupted mid-step by `replay cancel`. Note
|
|
1268
|
+
* this helper still RESOLVES `{ success: false }` when a handler throws an
|
|
1269
|
+
* abort - executeSteps consults the signal on the failure path to classify
|
|
1270
|
+
* it as "Replay aborted by user" rather than a genuine step failure.
|
|
1271
|
+
*/
|
|
1272
|
+
abortSignal) {
|
|
1273
|
+
const isRetryableAction = tool === 'input' && ['click', 'type', 'hover'].includes(params.action);
|
|
1274
|
+
const maxRetries = isRetryableAction ? 5 : 1;
|
|
1275
|
+
const retryDelayMs = 500;
|
|
1276
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1277
|
+
// A tool error arrives as an EXCEPTION: executeToolCall raises a ToolError
|
|
1278
|
+
// for any isError response. The catch is what drives element-not-found
|
|
1279
|
+
// retries (e.g. an async-rendered button that hasn't mounted yet).
|
|
1280
|
+
let result;
|
|
1281
|
+
try {
|
|
1282
|
+
result = await executeToolCall(tool, params, abortSignal);
|
|
1283
|
+
}
|
|
1284
|
+
catch (err) {
|
|
1285
|
+
const errorText = err?.response?.content?.[0]?.text || err?.message || '';
|
|
1286
|
+
// A bare "not found" also matched CONNECTION_NOT_FOUND, SEQUENCE_NOT_FOUND
|
|
1287
|
+
// and friends, so a click against a dead connection burned all five
|
|
1288
|
+
// retries and 2.5s before reporting what was wrong on the first attempt.
|
|
1289
|
+
const isElementNotFound = isElementNotFoundFailure({
|
|
1290
|
+
errorId: err?.response?._errorId,
|
|
1291
|
+
text: errorText,
|
|
1292
|
+
});
|
|
1293
|
+
if (isRetryableAction && isElementNotFound && attempt < maxRetries) {
|
|
1294
|
+
debugLog(logPrefix, `Element not found, retrying... (attempt ${attempt}/${maxRetries})`);
|
|
1295
|
+
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
return { success: false, error: errorText.split('\n')[0] || 'Unknown error' };
|
|
1299
|
+
}
|
|
1300
|
+
return { success: true, result };
|
|
1301
|
+
}
|
|
1302
|
+
return { success: false, error: 'Max retries exceeded' };
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Validate that navigation succeeded (page loaded correctly)
|
|
1306
|
+
*/
|
|
1307
|
+
export async function validateNavigation(ctx, expectedUrl) {
|
|
1308
|
+
const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
|
|
1309
|
+
try {
|
|
1310
|
+
const infoResult = await executeToolCall('navigate', {
|
|
1311
|
+
action: 'info',
|
|
1312
|
+
connectionReason
|
|
1313
|
+
});
|
|
1314
|
+
const infoText = infoResult?.content?.[0]?.text || '';
|
|
1315
|
+
// Check for common error patterns
|
|
1316
|
+
if (infoText.includes('about:blank') && expectedUrl && !expectedUrl.includes('about:blank')) {
|
|
1317
|
+
return { success: false, error: 'Page failed to load (stuck on about:blank)' };
|
|
1318
|
+
}
|
|
1319
|
+
// Check for Chrome error pages
|
|
1320
|
+
if (infoText.includes('chrome-error://') || infoText.includes('ERR_')) {
|
|
1321
|
+
const errMatch = infoText.match(/(ERR_[A-Z_]+)/);
|
|
1322
|
+
return { success: false, error: `Page failed to load: ${errMatch?.[1] || 'connection error'}` };
|
|
1323
|
+
}
|
|
1324
|
+
// Check title for error indicators
|
|
1325
|
+
const titleMatch = infoText.match(/\*\*Title:\*\*\s*([^\n]+)/);
|
|
1326
|
+
const title = titleMatch?.[1]?.toLowerCase() || '';
|
|
1327
|
+
if (title.includes("site can't be reached") || title.includes("this site can't be reached")) {
|
|
1328
|
+
return { success: false, error: 'Site cannot be reached' };
|
|
1329
|
+
}
|
|
1330
|
+
debugLog(logPrefix, `Navigation validated successfully`);
|
|
1331
|
+
return { success: true };
|
|
1332
|
+
}
|
|
1333
|
+
catch (err) {
|
|
1334
|
+
debugLog(logPrefix, `Navigation validation failed: ${err.message}`);
|
|
1335
|
+
return { success: false, error: `Could not validate navigation: ${err.message}` };
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Wait for an element to appear
|
|
1340
|
+
*/
|
|
1341
|
+
export async function waitForElement(ctx, selector) {
|
|
1342
|
+
const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
|
|
1343
|
+
debugLog(logPrefix, `Waiting for element: ${selector}`);
|
|
1344
|
+
let retries = 5;
|
|
1345
|
+
while (retries > 0) {
|
|
1346
|
+
try {
|
|
1347
|
+
const result = await executeToolCall('dom', {
|
|
1348
|
+
action: 'querySelector',
|
|
1349
|
+
selector,
|
|
1350
|
+
connectionReason
|
|
1351
|
+
});
|
|
1352
|
+
if (result && !result.isError) {
|
|
1353
|
+
debugLog(logPrefix, `Element ${selector} found`);
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
catch {
|
|
1358
|
+
// Ignore errors during wait
|
|
1359
|
+
}
|
|
1360
|
+
retries--;
|
|
1361
|
+
if (retries > 0) {
|
|
1362
|
+
debugLog(logPrefix, `Element ${selector} not found, waiting... (${retries} retries left)`);
|
|
1363
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
debugLog(logPrefix, `Warning: Element ${selector} not found after waiting`);
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Validate typed text was entered correctly
|
|
1370
|
+
*/
|
|
1371
|
+
export async function validateTypedText(ctx, selector, expectedText, append = false) {
|
|
1372
|
+
const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
|
|
1373
|
+
debugLog(logPrefix, `Validating typed text in ${selector}${append ? ' (append mode)' : ''}`);
|
|
1374
|
+
try {
|
|
1375
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
1376
|
+
// Check .value for inputs/textareas, fall back to .innerText for contenteditable elements (e.g., ProseMirror)
|
|
1377
|
+
const evalResult = await executeToolCall('inspect', {
|
|
1378
|
+
action: 'evaluateExpression',
|
|
1379
|
+
expression: `(() => {
|
|
1380
|
+
const el = document.querySelector('${selector.replace(/'/g, "\\'")}');
|
|
1381
|
+
if (!el) return '';
|
|
1382
|
+
// For input/textarea, use .value
|
|
1383
|
+
if (el.value !== undefined && el.value !== '') return el.value;
|
|
1384
|
+
// For contenteditable (ProseMirror, etc.), use innerText
|
|
1385
|
+
if (el.isContentEditable || el.contentEditable === 'true') return el.innerText?.trim() || '';
|
|
1386
|
+
return el.value || '';
|
|
1387
|
+
})()`,
|
|
1388
|
+
connectionReason
|
|
1389
|
+
});
|
|
1390
|
+
let actualValue = '';
|
|
1391
|
+
if (evalResult?.content?.[0]?.text) {
|
|
1392
|
+
const codeBlockMatch = evalResult.content[0].text.match(/```(?:json)?\n([\s\S]*?)\n```/);
|
|
1393
|
+
if (codeBlockMatch) {
|
|
1394
|
+
actualValue = codeBlockMatch[1].trim();
|
|
1395
|
+
if (actualValue.startsWith('"') && actualValue.endsWith('"')) {
|
|
1396
|
+
actualValue = JSON.parse(actualValue);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
// In append mode, check if the field ends with the expected text
|
|
1401
|
+
// In replace mode, check for exact match
|
|
1402
|
+
if (append) {
|
|
1403
|
+
if (!actualValue.endsWith(expectedText)) {
|
|
1404
|
+
debugLog(logPrefix, `Text validation failed (append): expected to end with "${expectedText}", got "${actualValue}"`);
|
|
1405
|
+
throw new Error(`Text validation failed for ${selector}: expected to end with "${expectedText}", got "${actualValue}"`);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
else {
|
|
1409
|
+
if (actualValue !== expectedText) {
|
|
1410
|
+
debugLog(logPrefix, `Text validation failed: expected "${expectedText}", got "${actualValue}"`);
|
|
1411
|
+
throw new Error(`Text validation failed for ${selector}: expected "${expectedText}", got "${actualValue}"`);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
debugLog(logPrefix, `Text validated: "${actualValue}" ${append ? 'ends with' : 'matches'} expected`);
|
|
1415
|
+
}
|
|
1416
|
+
catch (error) {
|
|
1417
|
+
if (error.message?.includes('Text validation failed')) {
|
|
1418
|
+
throw error;
|
|
1419
|
+
}
|
|
1420
|
+
debugLog(logPrefix, `Warning: Could not validate typed text: ${error}`);
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Capture pre-click state for delta comparison
|
|
1425
|
+
*/
|
|
1426
|
+
export async function capturePreClickState(ctx) {
|
|
1427
|
+
const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
|
|
1428
|
+
let consoleErrorCount = 0;
|
|
1429
|
+
let consoleWarnCount = 0;
|
|
1430
|
+
let consoleTotalCount = 0;
|
|
1431
|
+
let networkRequestCount = 0;
|
|
1432
|
+
let url = '';
|
|
1433
|
+
try {
|
|
1434
|
+
// Get console counts via _meta
|
|
1435
|
+
const consoleResult = await executeToolCall('console', {
|
|
1436
|
+
action: 'list', limit: 1, connectionReason
|
|
1437
|
+
});
|
|
1438
|
+
consoleErrorCount = consoleResult?._meta?.console?.errorCount || 0;
|
|
1439
|
+
consoleWarnCount = consoleResult?._meta?.console?.warnCount || 0;
|
|
1440
|
+
consoleTotalCount = consoleResult?._meta?.console?.totalCount || 0;
|
|
1441
|
+
}
|
|
1442
|
+
catch {
|
|
1443
|
+
debugLog(logPrefix, 'Warning: Could not get pre-click console state');
|
|
1444
|
+
}
|
|
1445
|
+
try {
|
|
1446
|
+
// Get network request count via _meta
|
|
1447
|
+
const networkResult = await executeToolCall('network', {
|
|
1448
|
+
action: 'list', limit: 1, connectionReason
|
|
1449
|
+
});
|
|
1450
|
+
networkRequestCount = networkResult?._meta?.network?.totalCount || 0;
|
|
1451
|
+
}
|
|
1452
|
+
catch {
|
|
1453
|
+
debugLog(logPrefix, 'Warning: Could not get pre-click network state');
|
|
1454
|
+
}
|
|
1455
|
+
try {
|
|
1456
|
+
// Get current URL via _meta
|
|
1457
|
+
const pageResult = await executeToolCall('navigate', {
|
|
1458
|
+
action: 'info', connectionReason
|
|
1459
|
+
});
|
|
1460
|
+
url = pageResult?._meta?.navigate?.url || '';
|
|
1461
|
+
}
|
|
1462
|
+
catch {
|
|
1463
|
+
debugLog(logPrefix, 'Warning: Could not get pre-click URL');
|
|
1464
|
+
}
|
|
1465
|
+
return { consoleErrorCount, consoleWarnCount, consoleTotalCount, networkRequestCount, url };
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Validate click action results
|
|
1469
|
+
*/
|
|
1470
|
+
export async function validateClickAction(ctx, preState, clickResult, config) {
|
|
1471
|
+
const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
|
|
1472
|
+
const errors = [];
|
|
1473
|
+
const warnings = [];
|
|
1474
|
+
const info = [];
|
|
1475
|
+
// Get structured data from _meta
|
|
1476
|
+
const clickMeta = clickResult?._meta?.click;
|
|
1477
|
+
// Small delay before validation
|
|
1478
|
+
if (config.postClickDelayMs > 0) {
|
|
1479
|
+
await new Promise(r => setTimeout(r, config.postClickDelayMs));
|
|
1480
|
+
}
|
|
1481
|
+
// 1. Check if click had any effect (DOM changes)
|
|
1482
|
+
if (config.requireDomChanges && clickMeta?.domChanges) {
|
|
1483
|
+
if (clickMeta.domChanges.mutationCount === 0) {
|
|
1484
|
+
const msg = 'Click had no DOM effect (0 mutations)';
|
|
1485
|
+
if (config.domChangesFailMode === 'error') {
|
|
1486
|
+
errors.push(msg);
|
|
1487
|
+
}
|
|
1488
|
+
else {
|
|
1489
|
+
warnings.push(msg);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
// 2. Check for navigation and validate it
|
|
1494
|
+
if (config.validateNavigation && clickMeta?.navigationOccurred) {
|
|
1495
|
+
const navResult = await validateNavigation(ctx);
|
|
1496
|
+
if (!navResult.success) {
|
|
1497
|
+
errors.push(`Navigation failed: ${navResult.error}`);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
// 3. Check for new console messages
|
|
1501
|
+
try {
|
|
1502
|
+
const consoleResult = await executeToolCall('console', {
|
|
1503
|
+
action: 'list', limit: 1, connectionReason
|
|
1504
|
+
});
|
|
1505
|
+
const newErrorCount = consoleResult?._meta?.console?.errorCount || 0;
|
|
1506
|
+
const newWarnCount = consoleResult?._meta?.console?.warnCount || 0;
|
|
1507
|
+
const newTotalCount = consoleResult?._meta?.console?.totalCount || 0;
|
|
1508
|
+
// Report new errors (respecting failOnConsoleErrors config)
|
|
1509
|
+
if (config.failOnConsoleErrors && newErrorCount > preState.consoleErrorCount) {
|
|
1510
|
+
const diff = newErrorCount - preState.consoleErrorCount;
|
|
1511
|
+
const msg = `${diff} new console error(s) after click`;
|
|
1512
|
+
if (config.consoleErrorsFailMode === 'error') {
|
|
1513
|
+
errors.push(msg);
|
|
1514
|
+
}
|
|
1515
|
+
else {
|
|
1516
|
+
warnings.push(msg);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
// Report new warnings as info
|
|
1520
|
+
if (newWarnCount > preState.consoleWarnCount) {
|
|
1521
|
+
const diff = newWarnCount - preState.consoleWarnCount;
|
|
1522
|
+
info.push(`${diff} new console warning(s)`);
|
|
1523
|
+
}
|
|
1524
|
+
// Report other new messages (log/info) as info
|
|
1525
|
+
const newLogInfoCount = (newTotalCount - newErrorCount - newWarnCount) -
|
|
1526
|
+
(preState.consoleTotalCount - preState.consoleErrorCount - preState.consoleWarnCount);
|
|
1527
|
+
if (newLogInfoCount > 0) {
|
|
1528
|
+
info.push(`${newLogInfoCount} new console log(s)`);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
catch {
|
|
1532
|
+
debugLog(logPrefix, 'Warning: Could not check console after click');
|
|
1533
|
+
}
|
|
1534
|
+
// 4. Check for network request failures
|
|
1535
|
+
if (config.validateNetworkPayload) {
|
|
1536
|
+
try {
|
|
1537
|
+
const networkResult = await executeToolCall('network', {
|
|
1538
|
+
action: 'list', connectionReason
|
|
1539
|
+
});
|
|
1540
|
+
const newCount = networkResult?._meta?.network?.totalCount || 0;
|
|
1541
|
+
if (newCount > preState.networkRequestCount) {
|
|
1542
|
+
// Check for failed POST requests
|
|
1543
|
+
const failedResult = await executeToolCall('network', {
|
|
1544
|
+
action: 'search', method: 'POST', statusCode: '4', connectionReason
|
|
1545
|
+
});
|
|
1546
|
+
const failedCount = failedResult?._meta?.network?.matchCount || 0;
|
|
1547
|
+
if (failedCount > 0) {
|
|
1548
|
+
const msg = 'POST request returned 4xx error';
|
|
1549
|
+
if (config.networkFailMode === 'error') {
|
|
1550
|
+
errors.push(msg);
|
|
1551
|
+
}
|
|
1552
|
+
else {
|
|
1553
|
+
warnings.push(msg);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
catch {
|
|
1559
|
+
debugLog(logPrefix, 'Warning: Could not check network after click');
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
// Log validation result
|
|
1563
|
+
if (errors.length > 0) {
|
|
1564
|
+
debugLog(logPrefix, `Click validation failed: ${errors.join('; ')}`);
|
|
1565
|
+
}
|
|
1566
|
+
else if (warnings.length > 0) {
|
|
1567
|
+
debugLog(logPrefix, `Click validation warnings: ${warnings.join('; ')}`);
|
|
1568
|
+
}
|
|
1569
|
+
return {
|
|
1570
|
+
valid: errors.length === 0,
|
|
1571
|
+
errors,
|
|
1572
|
+
warnings,
|
|
1573
|
+
info
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
/**
|
|
1577
|
+
* Gather diagnostic information after a failure
|
|
1578
|
+
*/
|
|
1579
|
+
async function gatherDiagnostics(ctx) {
|
|
1580
|
+
const { executeToolCall, connectionReason } = ctx;
|
|
1581
|
+
if (!connectionReason)
|
|
1582
|
+
return '';
|
|
1583
|
+
try {
|
|
1584
|
+
const consoleResult = await executeToolCall('console', {
|
|
1585
|
+
action: 'list',
|
|
1586
|
+
type: 'error',
|
|
1587
|
+
connectionReason
|
|
1588
|
+
});
|
|
1589
|
+
// Counts come from `_meta`, as validateClickAction's do. Counting
|
|
1590
|
+
// `**error**` in the rendered console text also counted the word inside a
|
|
1591
|
+
// logged MESSAGE, and `\d{3}` over the network text matched any three
|
|
1592
|
+
// digits anywhere - a timestamp, a byte count, an id in a URL.
|
|
1593
|
+
const errorCount = consoleResult?._meta?.console?.errorCount ?? 0;
|
|
1594
|
+
// `network search` REQUIRES a pattern and reads statusCode as an exact code
|
|
1595
|
+
// or an "Nxx" class. Asking for `{ statusCode: '4' }` with no pattern was
|
|
1596
|
+
// rejected outright, so this whole helper threw and every step failure was
|
|
1597
|
+
// reported with no page state at all - and had it got through, '4' would
|
|
1598
|
+
// have matched no request either.
|
|
1599
|
+
const countRequests = async (statusCode) => {
|
|
1600
|
+
const result = await executeToolCall('network', {
|
|
1601
|
+
action: 'search',
|
|
1602
|
+
pattern: '.',
|
|
1603
|
+
statusCode,
|
|
1604
|
+
connectionReason
|
|
1605
|
+
});
|
|
1606
|
+
return result?._meta?.network?.matchCount ?? 0;
|
|
1607
|
+
};
|
|
1608
|
+
const failedRequests = (await countRequests('4xx')) + (await countRequests('5xx'));
|
|
1609
|
+
const interactiveResult = await executeToolCall('content', {
|
|
1610
|
+
action: 'findInteractive',
|
|
1611
|
+
connectionReason
|
|
1612
|
+
});
|
|
1613
|
+
const interactiveCount = interactiveResult?._meta?.content?.totalCount ?? 'unknown';
|
|
1614
|
+
return ` | Page state: ${interactiveCount} interactive elements, ${errorCount} console errors, ${failedRequests} failed requests`;
|
|
1615
|
+
}
|
|
1616
|
+
catch (err) {
|
|
1617
|
+
// Say why. Swallowing this silently is how a malformed network probe hid
|
|
1618
|
+
// for as long as it did: every step failure simply carried no page state,
|
|
1619
|
+
// and nothing anywhere said the probe had failed.
|
|
1620
|
+
debugLog(ctx.logPrefix || 'executor', `Could not gather diagnostics: ${err?.message || err}`);
|
|
1621
|
+
return '';
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* Execute a range of steps from a sequence
|
|
1626
|
+
*/
|
|
1627
|
+
export async function executeSteps(options) {
|
|
1628
|
+
const { sequence, startStep, endStep, ctx, variables, record, stepTimeout = 30000, totalTimeout = 300000, overrideConnectionReason, abortSignal, onProgress } = options;
|
|
1629
|
+
const { executeToolCall, commandRecorder, connectionReason, connectionMap, logPrefix = 'executor' } = ctx;
|
|
1630
|
+
const commands = sequence.commands;
|
|
1631
|
+
const targetEnd = endStep ?? commands.length;
|
|
1632
|
+
const results = [];
|
|
1633
|
+
const startTime = Date.now();
|
|
1634
|
+
// A sequence whose steps name more than one connection can only be replayed
|
|
1635
|
+
// faithfully against those connections. `overrideConnectionReason` (the
|
|
1636
|
+
// run-level connectionReason) must therefore NOT be stamped onto its
|
|
1637
|
+
// launchChrome steps - that would point every launch at one reference and
|
|
1638
|
+
// collapse the very interleaving the sequence exists to reproduce (bug-018).
|
|
1639
|
+
const recordedConnections = analyzeRecordedStepConnections(commands);
|
|
1640
|
+
/** Recorded reference -> this session's reference. */
|
|
1641
|
+
const mapConnection = (ref) => connectionMap?.[sanitizeReference(ref)] ?? sanitizeReference(ref);
|
|
1642
|
+
// Live-connection references, probed lazily and re-probed on a miss (a step
|
|
1643
|
+
// earlier in the sequence may have launched the browser a later step needs).
|
|
1644
|
+
let liveConnections = null;
|
|
1645
|
+
const stepConnectionExists = async (ref) => {
|
|
1646
|
+
if (!liveConnections?.has(ref)) {
|
|
1647
|
+
liveConnections = await probeLiveConnectionReferences(executeToolCall);
|
|
1648
|
+
}
|
|
1649
|
+
// null = could not determine. Treat as "unknown", never as "absent": the
|
|
1650
|
+
// tool call itself still fails loudly (CONNECTION_NOT_FOUND) if the
|
|
1651
|
+
// reference really is missing, and it never silently falls back.
|
|
1652
|
+
if (liveConnections === null)
|
|
1653
|
+
return { known: true, live: [] };
|
|
1654
|
+
return { known: liveConnections.has(ref), live: [...liveConnections] };
|
|
1655
|
+
};
|
|
1656
|
+
// {{timestamp}} must be stable across every step of a run (including a
|
|
1657
|
+
// later step/finish call), not recomputed per-step - cache once on ctx.
|
|
1658
|
+
const runTimestamp = ctx.runTimestamp ?? (ctx.runTimestamp = Date.now());
|
|
1659
|
+
// Seed the captured-variable store ONCE, on the caller's own ctx, and use
|
|
1660
|
+
// this single object everywhere below (interpolation, per-step ctx clones,
|
|
1661
|
+
// captures). Creating it lazily at a capture site would attach it to
|
|
1662
|
+
// whichever ctx happened to be in hand - for a nested sequence that is the
|
|
1663
|
+
// child's clone, so the parent would silently never see the capture.
|
|
1664
|
+
const variableStore = (ctx.variableStore ??= {});
|
|
1665
|
+
// Track breakpoints set during this sequence run (url:line format)
|
|
1666
|
+
const expectedBreakpoints = new Set();
|
|
1667
|
+
// Auto-resume if debugger is paused from a previous run
|
|
1668
|
+
if (connectionReason && startStep === 0) {
|
|
1669
|
+
await resumeIfPaused(ctx);
|
|
1670
|
+
}
|
|
1671
|
+
// Timeout helper. On timeout the losing tool call is NOT cancelled (handlers
|
|
1672
|
+
// receive the RUN's signal, but the step timer is not wired to it), so it may
|
|
1673
|
+
// still settle in the background - swallow its eventual rejection so it can't
|
|
1674
|
+
// surface as an unhandled rejection after the run has already reported the
|
|
1675
|
+
// timeout. The run stops at the timed-out step, so no later step races
|
|
1676
|
+
// against the orphan.
|
|
1677
|
+
const executeWithTimeout = async (promise, timeoutMs, timeoutMessage) => {
|
|
1678
|
+
let timeoutId;
|
|
1679
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
1680
|
+
timeoutId = setTimeout(() => {
|
|
1681
|
+
promise.catch(() => { });
|
|
1682
|
+
reject(new Error(timeoutMessage));
|
|
1683
|
+
}, timeoutMs);
|
|
1684
|
+
});
|
|
1685
|
+
try {
|
|
1686
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
1687
|
+
}
|
|
1688
|
+
finally {
|
|
1689
|
+
clearTimeout(timeoutId);
|
|
1690
|
+
}
|
|
1691
|
+
};
|
|
1692
|
+
for (let i = startStep; i < targetEnd; i++) {
|
|
1693
|
+
const cmd = commands[i];
|
|
1694
|
+
// Check if aborted
|
|
1695
|
+
if (abortSignal?.aborted) {
|
|
1696
|
+
debugLog(logPrefix, `Replay aborted at step ${i + 1}`);
|
|
1697
|
+
results.push({
|
|
1698
|
+
step: i + 1,
|
|
1699
|
+
tool: cmd.tool,
|
|
1700
|
+
success: false,
|
|
1701
|
+
error: 'Replay aborted by user'
|
|
1702
|
+
});
|
|
1703
|
+
break;
|
|
1704
|
+
}
|
|
1705
|
+
// Check total timeout
|
|
1706
|
+
const elapsed = Date.now() - startTime;
|
|
1707
|
+
if (elapsed >= totalTimeout) {
|
|
1708
|
+
debugLog(logPrefix, `Total timeout exceeded after ${elapsed}ms`);
|
|
1709
|
+
results.push({
|
|
1710
|
+
step: i + 1,
|
|
1711
|
+
tool: cmd.tool,
|
|
1712
|
+
success: false,
|
|
1713
|
+
error: `Total timeout exceeded (${totalTimeout}ms)`
|
|
1714
|
+
});
|
|
1715
|
+
break;
|
|
1716
|
+
}
|
|
1717
|
+
// Wait for delay if specified (for recorded interactions)
|
|
1718
|
+
if (cmd.delay && cmd.delay > 0) {
|
|
1719
|
+
debugLog(logPrefix, `Waiting ${cmd.delay}ms before step ${i + 1}`);
|
|
1720
|
+
const wasAborted = await abortableDelayResult(cmd.delay, abortSignal);
|
|
1721
|
+
if (wasAborted)
|
|
1722
|
+
continue;
|
|
1723
|
+
}
|
|
1724
|
+
onProgress?.({ step: i + 1, totalSteps: commands.length, tool: cmd.tool });
|
|
1725
|
+
try {
|
|
1726
|
+
// Log comment if present
|
|
1727
|
+
if (cmd.comment) {
|
|
1728
|
+
debugLog(logPrefix, `Comment: ${cmd.comment}`);
|
|
1729
|
+
}
|
|
1730
|
+
debugLog(logPrefix, `Executing step ${i + 1}/${commands.length}: ${cmd.tool}`);
|
|
1731
|
+
// Build params
|
|
1732
|
+
let params = { ...cmd.params };
|
|
1733
|
+
// Resolve {{var:name.path}} / {{timestamp}} tokens against the run's
|
|
1734
|
+
// variable store. Throws InterpolationError on an unresolvable token -
|
|
1735
|
+
// caught by this step's try/catch below, same as any other step failure.
|
|
1736
|
+
params = interpolateParams(params, variableStore, runTimestamp);
|
|
1737
|
+
// Apply variable substitutions
|
|
1738
|
+
if (variables && cmd.tool === 'input' && params.action === 'type' && params.text) {
|
|
1739
|
+
const varName = `var_${i}_${params.selector?.replace(/[^a-zA-Z0-9]/g, '_') || 'text'}`;
|
|
1740
|
+
if (variables[varName] !== undefined) {
|
|
1741
|
+
params.text = variables[varName];
|
|
1742
|
+
debugLog(logPrefix, `Substituted ${varName}: "${params.text}"`);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
// A nested `replay run` STEP must block: `run` is background-by-default
|
|
1746
|
+
// for direct callers, but a sequence step that starts another sequence
|
|
1747
|
+
// needs its result (the parent's success depends on it). Without this a
|
|
1748
|
+
// nested run would register as its own top-level run and the step would
|
|
1749
|
+
// "succeed" instantly, fire-and-forget. An explicit wait:false on the
|
|
1750
|
+
// step is honoured for callers who genuinely want that.
|
|
1751
|
+
if (cmd.tool === 'replay' && params.action === 'run' && params.wait === undefined) {
|
|
1752
|
+
params.wait = true;
|
|
1753
|
+
}
|
|
1754
|
+
// A per-step connectionReason is a reference from the RECORDING session, so
|
|
1755
|
+
// rebind it onto this one before anything uses it, then require that it
|
|
1756
|
+
// actually exists here. There is deliberately no fallback to the run-level
|
|
1757
|
+
// connection: that is precisely how a two-browser sequence used to replay
|
|
1758
|
+
// green in one browser (bug-018).
|
|
1759
|
+
if (typeof params.connectionReason === 'string' && params.connectionReason.trim()) {
|
|
1760
|
+
const recorded = sanitizeReference(params.connectionReason);
|
|
1761
|
+
const resolved = mapConnection(recorded);
|
|
1762
|
+
params.connectionReason = resolved;
|
|
1763
|
+
// Checked for ANY step naming a connection other than the run's, not
|
|
1764
|
+
// just multi-connection sequences: a single-reference sequence pointed
|
|
1765
|
+
// at a browser that isn't here otherwise fails deep inside the tool
|
|
1766
|
+
// with a generic "Not connected to browser" and never names the
|
|
1767
|
+
// connection it wanted.
|
|
1768
|
+
if (resolved !== connectionReason) {
|
|
1769
|
+
const { known, live } = await stepConnectionExists(resolved);
|
|
1770
|
+
if (!known) {
|
|
1771
|
+
results.push({
|
|
1772
|
+
step: i + 1,
|
|
1773
|
+
tool: cmd.tool,
|
|
1774
|
+
success: false,
|
|
1775
|
+
error: formatMissingStepConnection({
|
|
1776
|
+
step: i + 1,
|
|
1777
|
+
tool: cmd.tool,
|
|
1778
|
+
recorded,
|
|
1779
|
+
resolved,
|
|
1780
|
+
mapped: resolved !== recorded,
|
|
1781
|
+
runConnection: connectionReason,
|
|
1782
|
+
live,
|
|
1783
|
+
}),
|
|
1784
|
+
});
|
|
1785
|
+
break;
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
// launchChrome/connectDebugger steps CREATE the reference, so a mapping has
|
|
1790
|
+
// to rename the launch too or the sequence would open the recorded name and
|
|
1791
|
+
// then drive a differently-named one.
|
|
1792
|
+
let launchRenamedByMap = false;
|
|
1793
|
+
if ((cmd.tool === 'launchChrome' || cmd.tool === 'connectDebugger') &&
|
|
1794
|
+
typeof params.reference === 'string' && connectionMap) {
|
|
1795
|
+
const mappedRef = connectionMap[sanitizeReference(params.reference)];
|
|
1796
|
+
if (mappedRef) {
|
|
1797
|
+
params.reference = mappedRef;
|
|
1798
|
+
launchRenamedByMap = true;
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
// Inject the run-level connectionReason for tools that accept one, unless the
|
|
1802
|
+
// step names its own (per-step connection wins - multi-device sequences).
|
|
1803
|
+
if (connectionReason && TOOLS_ACCEPTING_CONNECTION.includes(cmd.tool) && !params.connectionReason) {
|
|
1804
|
+
params.connectionReason = connectionReason;
|
|
1805
|
+
}
|
|
1806
|
+
// request({ destination: 'browser' }) needs a connectionReason too, but request
|
|
1807
|
+
// is deliberately in neither list (destination:'node' sequences must not force a
|
|
1808
|
+
// Chrome auto-launch, and destination:'node' takes no connection at all)
|
|
1809
|
+
if (cmd.tool === 'request' && params.destination === 'browser' && !params.connectionReason && connectionReason) {
|
|
1810
|
+
params.connectionReason = connectionReason;
|
|
1811
|
+
}
|
|
1812
|
+
// The connection this step actually runs against: its own if it named one,
|
|
1813
|
+
// otherwise the run-level connection. Everything wrapped around the step -
|
|
1814
|
+
// pre/post-click state, navigation + typed-text validation, pause detection,
|
|
1815
|
+
// failure diagnostics - must observe THIS connection, not the run-level one.
|
|
1816
|
+
// Helpers keep reading ctx.connectionReason; we just hand them a ctx whose
|
|
1817
|
+
// connection is the step's (bug-009).
|
|
1818
|
+
const stepConnection = params.connectionReason || connectionReason;
|
|
1819
|
+
const stepCtx = stepConnection === connectionReason
|
|
1820
|
+
? ctx
|
|
1821
|
+
: {
|
|
1822
|
+
...ctx,
|
|
1823
|
+
connectionReason: stepConnection,
|
|
1824
|
+
// share the run's variable store with the clone, don't fork it
|
|
1825
|
+
variableStore,
|
|
1826
|
+
};
|
|
1827
|
+
// Override launchChrome reference if custom connectionReason provided.
|
|
1828
|
+
// Skipped for multi-connection sequences: stamping one reference onto every
|
|
1829
|
+
// launch would collapse them into a single browser (see recordedConnections).
|
|
1830
|
+
// An explicit `connections` entry for this launch is the more specific
|
|
1831
|
+
// instruction and must win - otherwise the map renames the launch and this
|
|
1832
|
+
// silently renames it back, with the two writers disagreeing and no signal.
|
|
1833
|
+
if (cmd.tool === 'launchChrome' && overrideConnectionReason) {
|
|
1834
|
+
if (recordedConnections.multiConnection) {
|
|
1835
|
+
debugLog(logPrefix, `Not overriding launchChrome reference "${params.reference}" with "${overrideConnectionReason}": sequence spans ${recordedConnections.references.length} connections`);
|
|
1836
|
+
}
|
|
1837
|
+
else if (launchRenamedByMap) {
|
|
1838
|
+
debugLog(logPrefix, `Not overriding launchChrome reference "${params.reference}" with "${overrideConnectionReason}": connections mapping already rebound this launch`);
|
|
1839
|
+
}
|
|
1840
|
+
else {
|
|
1841
|
+
params.reference = overrideConnectionReason;
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
// Handle stale callFrameId for getVariables
|
|
1845
|
+
if (cmd.tool === 'inspect' && params.action === 'getVariables' && params.callFrameId && stepConnection) {
|
|
1846
|
+
debugLog(logPrefix, `Refreshing stale callFrameId`);
|
|
1847
|
+
try {
|
|
1848
|
+
const callStackResult = await executeToolCall('inspect', {
|
|
1849
|
+
action: 'getCallStack',
|
|
1850
|
+
connectionReason: stepConnection
|
|
1851
|
+
});
|
|
1852
|
+
const callStackText = callStackResult?.content?.[0]?.text || '';
|
|
1853
|
+
const callFrameIdMatch = callStackText.match(/"callFrameId":\s*"([^"]+)"/);
|
|
1854
|
+
if (callFrameIdMatch?.[1]) {
|
|
1855
|
+
params.callFrameId = callFrameIdMatch[1];
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
catch (err) {
|
|
1859
|
+
debugLog(logPrefix, `Warning: Failed to get fresh callFrameId: ${err.message}`);
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
// Check port before navigate goto (localhost only)
|
|
1863
|
+
if (cmd.tool === 'navigate' && params.action === 'goto' && params.url) {
|
|
1864
|
+
const portCheck = await checkPortBeforeNavigation(params.url, logPrefix);
|
|
1865
|
+
if (!portCheck.success) {
|
|
1866
|
+
results.push({
|
|
1867
|
+
step: i + 1,
|
|
1868
|
+
tool: cmd.tool,
|
|
1869
|
+
success: false,
|
|
1870
|
+
error: portCheck.error
|
|
1871
|
+
});
|
|
1872
|
+
break;
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
// Replay cursor visual feedback
|
|
1876
|
+
if (cmd.tool === 'input') {
|
|
1877
|
+
if (params.action === 'click' && typeof params.x === 'number' && typeof params.y === 'number') {
|
|
1878
|
+
// Coordinate-based click - show cursor effect
|
|
1879
|
+
if (replayCursorCallbacks.onClickBefore) {
|
|
1880
|
+
await replayCursorCallbacks.onClickBefore(params.x, params.y, false);
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
else if (params.action === 'press' && params.key) {
|
|
1884
|
+
// Key press - show key indicator
|
|
1885
|
+
if (replayCursorCallbacks.onKeyPress) {
|
|
1886
|
+
await replayCursorCallbacks.onKeyPress(params.key);
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
// Handle conditional command specially
|
|
1891
|
+
if (cmd.tool === 'conditional') {
|
|
1892
|
+
// Validate required parameters
|
|
1893
|
+
if (!params.if || typeof params.if !== 'string') {
|
|
1894
|
+
results.push({
|
|
1895
|
+
step: i + 1,
|
|
1896
|
+
tool: cmd.tool,
|
|
1897
|
+
success: false,
|
|
1898
|
+
error: 'Conditional command requires "if" parameter with a condition string. Expected format: {{selector:.class}}, {{url:contains:text}}, {{cookie:name}}, or {{localStorage:key}}'
|
|
1899
|
+
});
|
|
1900
|
+
break;
|
|
1901
|
+
}
|
|
1902
|
+
if (!params.then || typeof params.then !== 'string') {
|
|
1903
|
+
results.push({
|
|
1904
|
+
step: i + 1,
|
|
1905
|
+
tool: cmd.tool,
|
|
1906
|
+
success: false,
|
|
1907
|
+
error: 'Conditional command requires "then" parameter with the sequence name to execute when condition is met'
|
|
1908
|
+
});
|
|
1909
|
+
break;
|
|
1910
|
+
}
|
|
1911
|
+
const condResult = await executeConditionalFlow(params.if, params.then, stepCtx, commandRecorder,
|
|
1912
|
+
// Remaining, not the original: nesting must not extend the total.
|
|
1913
|
+
{ stepTimeout, totalTimeout: Math.max(0, totalTimeout - (Date.now() - startTime)) },
|
|
1914
|
+
// The run's signal, so cancel reaches the nested sequence too.
|
|
1915
|
+
abortSignal);
|
|
1916
|
+
// Build the step result with substeps
|
|
1917
|
+
const stepResult = {
|
|
1918
|
+
step: i + 1,
|
|
1919
|
+
tool: cmd.tool,
|
|
1920
|
+
success: condResult.success,
|
|
1921
|
+
sequenceName: condResult.sequenceName,
|
|
1922
|
+
conditionMet: condResult.executed,
|
|
1923
|
+
substeps: condResult.substeps,
|
|
1924
|
+
error: condResult.error
|
|
1925
|
+
};
|
|
1926
|
+
results.push(stepResult);
|
|
1927
|
+
if (!condResult.success) {
|
|
1928
|
+
break;
|
|
1929
|
+
}
|
|
1930
|
+
const substepCount = condResult.substeps?.length || 0;
|
|
1931
|
+
debugLog(logPrefix, `Step ${i + 1} completed: conditional ${condResult.executed ? `ran ${substepCount} substeps` : 'skipped (condition not met)'}`);
|
|
1932
|
+
continue; // Skip the regular execution path
|
|
1933
|
+
}
|
|
1934
|
+
// Handle forEach the same way - a virtual step the executor runs itself.
|
|
1935
|
+
if (cmd.tool === 'forEach') {
|
|
1936
|
+
// `in` is checked for presence only, not for being a string: whole-string
|
|
1937
|
+
// {{var:}} interpolation has already turned it into the captured array.
|
|
1938
|
+
const missing = ['in', 'as', 'do'].filter(k => k === 'in' ? params[k] === undefined || params[k] === '' : typeof params[k] !== 'string' || !params[k]);
|
|
1939
|
+
if (missing.length > 0) {
|
|
1940
|
+
results.push({
|
|
1941
|
+
step: i + 1,
|
|
1942
|
+
tool: cmd.tool,
|
|
1943
|
+
success: false,
|
|
1944
|
+
error: `forEach requires ${missing.map(m => `"${m}"`).join(', ')}. Expected { in: '{{var:rows}}' | '{{selectorAll:CSS}}', as: 'row', do: '<sequence name>' }`
|
|
1945
|
+
});
|
|
1946
|
+
break;
|
|
1947
|
+
}
|
|
1948
|
+
const loopResult = await executeForEachFlow({ in: params.in, as: params.as, do: params.do, where: params.where, maxItems: params.maxItems }, stepCtx, commandRecorder,
|
|
1949
|
+
// Remaining, not the original: looping must not extend the total.
|
|
1950
|
+
{ stepTimeout, totalTimeout: Math.max(0, totalTimeout - (Date.now() - startTime)) }, abortSignal);
|
|
1951
|
+
results.push({
|
|
1952
|
+
step: i + 1,
|
|
1953
|
+
tool: cmd.tool,
|
|
1954
|
+
success: loopResult.success,
|
|
1955
|
+
sequenceName: loopResult.sequenceName,
|
|
1956
|
+
itemsFound: loopResult.itemsFound,
|
|
1957
|
+
iterations: loopResult.iterations,
|
|
1958
|
+
substeps: loopResult.substeps,
|
|
1959
|
+
error: loopResult.error
|
|
1960
|
+
});
|
|
1961
|
+
if (!loopResult.success) {
|
|
1962
|
+
break;
|
|
1963
|
+
}
|
|
1964
|
+
debugLog(logPrefix, `Step ${i + 1} completed: forEach ran ${loopResult.iterations}/${loopResult.itemsFound} item(s) of "${loopResult.sequenceName}"`);
|
|
1965
|
+
continue; // Skip the regular execution path
|
|
1966
|
+
}
|
|
1967
|
+
// Capture pre-click state for validation
|
|
1968
|
+
let preClickState = null;
|
|
1969
|
+
const clickConfig = configManager.getClickValidationConfig();
|
|
1970
|
+
if (cmd.tool === 'input' && params.action === 'click' && stepConnection && clickConfig.enabled) {
|
|
1971
|
+
preClickState = await capturePreClickState(stepCtx);
|
|
1972
|
+
}
|
|
1973
|
+
// Execute with retry, raced against the per-step timeout so a hung tool
|
|
1974
|
+
// call fails its own step instead of hanging the whole run.
|
|
1975
|
+
//
|
|
1976
|
+
// The bound is min(stepTimeout, remaining totalTimeout), computed fresh
|
|
1977
|
+
// here (after any cmd.delay) so the delay doesn't inflate the budget.
|
|
1978
|
+
//
|
|
1979
|
+
// `wait` steps are exempt from stepTimeout: wait carries its own
|
|
1980
|
+
// documented timeoutMs bound (default 15000) and fails itself on expiry;
|
|
1981
|
+
// racing stepTimeout against it would silently override that parameter
|
|
1982
|
+
// for waits longer than 30s. They are still capped by remaining
|
|
1983
|
+
// totalTimeout as a backstop.
|
|
1984
|
+
//
|
|
1985
|
+
// Breakpoint pauses are NOT affected: input tools detect a pause and
|
|
1986
|
+
// return immediately (pausedAtBreakpoint / pausedDuringClick), so a
|
|
1987
|
+
// legitimate pause never blocks inside the tool call - it is handled by
|
|
1988
|
+
// checkIfPaused after the step. replay({action:'step'}) pauses between
|
|
1989
|
+
// steps, outside this race.
|
|
1990
|
+
const remainingTotal = Math.max(1, totalTimeout - (Date.now() - startTime));
|
|
1991
|
+
const boundedByTotal = cmd.tool === 'wait' || remainingTotal < stepTimeout;
|
|
1992
|
+
const stepBound = cmd.tool === 'wait' ? remainingTotal : Math.min(stepTimeout, remainingTotal);
|
|
1993
|
+
const execResult = await executeWithTimeout(executeCommandWithRetry(executeToolCall, cmd.tool, params, logPrefix, abortSignal), stepBound, getMessage('REPLAY_STEP_TIMEOUT', {
|
|
1994
|
+
step: i + 1,
|
|
1995
|
+
tool: cmd.tool,
|
|
1996
|
+
timeoutMs: stepBound,
|
|
1997
|
+
limitSource: boundedByTotal ? 'remaining totalTimeout' : 'stepTimeout',
|
|
1998
|
+
}));
|
|
1999
|
+
if (!execResult.success) {
|
|
2000
|
+
// A step that failed while the run signal is aborted is the CANCEL
|
|
2001
|
+
// surfacing (e.g. the wait handler throwing an abort mid-poll), not a
|
|
2002
|
+
// genuine failure: report the canonical abort message and skip
|
|
2003
|
+
// diagnostics - the user cancelled, don't interrogate a browser they
|
|
2004
|
+
// may already be tearing down.
|
|
2005
|
+
if (abortSignal?.aborted) {
|
|
2006
|
+
debugLog(logPrefix, `Replay aborted during step ${i + 1}`);
|
|
2007
|
+
results.push({
|
|
2008
|
+
step: i + 1,
|
|
2009
|
+
tool: cmd.tool,
|
|
2010
|
+
success: false,
|
|
2011
|
+
error: 'Replay aborted by user'
|
|
2012
|
+
});
|
|
2013
|
+
break;
|
|
2014
|
+
}
|
|
2015
|
+
const diagnostics = await gatherDiagnostics(stepCtx);
|
|
2016
|
+
results.push({
|
|
2017
|
+
step: i + 1,
|
|
2018
|
+
tool: cmd.tool,
|
|
2019
|
+
success: false,
|
|
2020
|
+
error: `${execResult.error}${diagnostics}`
|
|
2021
|
+
});
|
|
2022
|
+
break;
|
|
2023
|
+
}
|
|
2024
|
+
// Note a browser this step created, so the run can close what it opened
|
|
2025
|
+
// and leave what it borrowed. The reference is read from the response
|
|
2026
|
+
// rather than the params: `reused: true` means the reference already
|
|
2027
|
+
// existed and the browser is someone else's (issue #103).
|
|
2028
|
+
if (cmd.tool === 'launchChrome' && ctx.launchedConnections) {
|
|
2029
|
+
const launchMeta = execResult.result?._meta?.launchChrome;
|
|
2030
|
+
if (launchMeta?.reference && launchMeta.reused === false) {
|
|
2031
|
+
ctx.launchedConnections.add(launchMeta.reference);
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
// Track breakpoints set by this sequence
|
|
2035
|
+
// Due to CDP line number handling (0-based vs 1-based) and resolution to nearest valid line,
|
|
2036
|
+
// we track the requested line and ±1 variants to handle edge cases
|
|
2037
|
+
if (cmd.tool === 'breakpoint' && params.action === 'set' && params.url) {
|
|
2038
|
+
const requestedLine = params.lineNumber;
|
|
2039
|
+
// Try to get the actual resolved line from the response
|
|
2040
|
+
// Format: "Breakpoint set at URL:LINE" or "CDP resolved to line LINE"
|
|
2041
|
+
const responseText = execResult.result?.content?.[0]?.text || '';
|
|
2042
|
+
const setAtMatch = responseText.match(/Breakpoint set at [^:]+:(\d+)/);
|
|
2043
|
+
const resolvedMatch = responseText.match(/CDP resolved to line (\d+)/);
|
|
2044
|
+
const reportedLine = resolvedMatch
|
|
2045
|
+
? parseInt(resolvedMatch[1], 10)
|
|
2046
|
+
: setAtMatch
|
|
2047
|
+
? parseInt(setAtMatch[1], 10)
|
|
2048
|
+
: requestedLine;
|
|
2049
|
+
// Track the reported line
|
|
2050
|
+
expectedBreakpoints.add(`${params.url}:${reportedLine}`);
|
|
2051
|
+
debugLog(logPrefix, `Tracking expected breakpoint: ${params.url}:${reportedLine}`);
|
|
2052
|
+
// Also track ±1 to handle 0-based/1-based conversion edge cases
|
|
2053
|
+
expectedBreakpoints.add(`${params.url}:${reportedLine - 1}`);
|
|
2054
|
+
expectedBreakpoints.add(`${params.url}:${reportedLine + 1}`);
|
|
2055
|
+
// Track requested line if different
|
|
2056
|
+
if (requestedLine !== reportedLine) {
|
|
2057
|
+
expectedBreakpoints.add(`${params.url}:${requestedLine}`);
|
|
2058
|
+
expectedBreakpoints.add(`${params.url}:${requestedLine - 1}`);
|
|
2059
|
+
expectedBreakpoints.add(`${params.url}:${requestedLine + 1}`);
|
|
2060
|
+
}
|
|
2061
|
+
debugLog(logPrefix, `Expected breakpoints for ${params.url}: ${[...expectedBreakpoints].filter(k => k.startsWith(params.url)).map(k => k.split(':').pop()).join(', ')}`);
|
|
2062
|
+
}
|
|
2063
|
+
// Post-step validation (before marking as success)
|
|
2064
|
+
if (cmd.tool === 'navigate' && stepConnection) {
|
|
2065
|
+
// Validate navigation succeeded
|
|
2066
|
+
const expectedUrl = params.action === 'goto' ? params.url : undefined;
|
|
2067
|
+
const navValidation = await validateNavigation(stepCtx, expectedUrl);
|
|
2068
|
+
if (!navValidation.success) {
|
|
2069
|
+
throw new Error(navValidation.error || 'Navigation failed');
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
// Click validation (after successful execution)
|
|
2073
|
+
if (cmd.tool === 'input' && params.action === 'click' && stepConnection && preClickState && clickConfig.enabled) {
|
|
2074
|
+
const clickValidation = await validateClickAction(stepCtx, preClickState, execResult.result, clickConfig);
|
|
2075
|
+
// Log info messages (console activity)
|
|
2076
|
+
for (const infoMsg of clickValidation.info) {
|
|
2077
|
+
debugLog(logPrefix, `Click info: ${infoMsg}`);
|
|
2078
|
+
}
|
|
2079
|
+
// Log warnings
|
|
2080
|
+
for (const warn of clickValidation.warnings) {
|
|
2081
|
+
debugLog(logPrefix, `Click warning: ${warn}`);
|
|
2082
|
+
}
|
|
2083
|
+
// Handle errors - pause sequence for inspection/retry instead of failing
|
|
2084
|
+
if (!clickValidation.valid) {
|
|
2085
|
+
debugLog(logPrefix, `Click validation failed at step ${i + 1}, pausing for inspection`);
|
|
2086
|
+
// Mark this step as failed but allow retry
|
|
2087
|
+
results.push({
|
|
2088
|
+
step: i + 1,
|
|
2089
|
+
tool: cmd.tool,
|
|
2090
|
+
success: false,
|
|
2091
|
+
error: `Click validation: ${clickValidation.errors.join('; ')}`
|
|
2092
|
+
});
|
|
2093
|
+
return {
|
|
2094
|
+
results,
|
|
2095
|
+
totalCommands: commands.length,
|
|
2096
|
+
durationMs: Date.now() - startTime,
|
|
2097
|
+
pausedAtStep: i + 1,
|
|
2098
|
+
clickValidationFailure: {
|
|
2099
|
+
step: i + 1,
|
|
2100
|
+
selector: params.selector || 'unknown',
|
|
2101
|
+
errors: clickValidation.errors,
|
|
2102
|
+
warnings: clickValidation.warnings,
|
|
2103
|
+
info: clickValidation.info,
|
|
2104
|
+
}
|
|
2105
|
+
};
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
// Capture a { saveAs } step's result into the run's variable store.
|
|
2109
|
+
// Before the step is marked successful: a saveAs that cannot be honoured
|
|
2110
|
+
// is a step failure (throw -> the catch below records it and stops the
|
|
2111
|
+
// run), not a silent no-op that would surface later as a confusing
|
|
2112
|
+
// "no variable named ..." interpolation error.
|
|
2113
|
+
if (params.saveAs) {
|
|
2114
|
+
const captured = captureVariable(cmd.tool, params, execResult.result);
|
|
2115
|
+
if (!captured.ok) {
|
|
2116
|
+
throw new Error(captured.error);
|
|
2117
|
+
}
|
|
2118
|
+
variableStore[params.saveAs] = captured.value;
|
|
2119
|
+
debugLog(logPrefix, `Captured variable "${params.saveAs}" from step ${i + 1} (${cmd.tool})`);
|
|
2120
|
+
}
|
|
2121
|
+
// Record command if enabled (preserve delay and comment)
|
|
2122
|
+
if (record) {
|
|
2123
|
+
commandRecorder.recordCommand(cmd.tool, params, {
|
|
2124
|
+
delay: cmd.delay,
|
|
2125
|
+
comment: cmd.comment
|
|
2126
|
+
});
|
|
2127
|
+
}
|
|
2128
|
+
results.push({ step: i + 1, tool: cmd.tool, success: true });
|
|
2129
|
+
debugLog(logPrefix, `Step ${i + 1} completed successfully`);
|
|
2130
|
+
// Check if we hit a breakpoint after this step (on the step's own connection)
|
|
2131
|
+
if (stepConnection) {
|
|
2132
|
+
const breakpointInfo = await checkIfPaused(stepCtx);
|
|
2133
|
+
if (breakpointInfo) {
|
|
2134
|
+
const breakpointKey = `${breakpointInfo.url}:${breakpointInfo.lineNumber}`;
|
|
2135
|
+
const isExpected = expectedBreakpoints.has(breakpointKey);
|
|
2136
|
+
debugLog(logPrefix, `Breakpoint hit at ${breakpointKey} (expected: ${isExpected})`);
|
|
2137
|
+
if (isExpected) {
|
|
2138
|
+
// Expected breakpoint from this sequence - continue execution
|
|
2139
|
+
debugLog(logPrefix, `Expected breakpoint hit, continuing sequence`);
|
|
2140
|
+
}
|
|
2141
|
+
else {
|
|
2142
|
+
// Unexpected breakpoint - stop and return
|
|
2143
|
+
return {
|
|
2144
|
+
results,
|
|
2145
|
+
totalCommands: commands.length,
|
|
2146
|
+
durationMs: Date.now() - startTime,
|
|
2147
|
+
breakpointHit: breakpointInfo
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
// Post-step async operations (after marking success)
|
|
2153
|
+
if (cmd.tool === 'input' && params.action === 'type' && params.selector && stepConnection) {
|
|
2154
|
+
await validateTypedText(stepCtx, params.selector, params.text || '', params.append === true);
|
|
2155
|
+
}
|
|
2156
|
+
// Pre-fetch next element after navigation/click. The wait happens where the
|
|
2157
|
+
// NEXT step will run, so it follows that step's connection, not this one's.
|
|
2158
|
+
const isNavigationAction = cmd.tool === 'navigate' ||
|
|
2159
|
+
(cmd.tool === 'input' && params.action === 'click');
|
|
2160
|
+
if (isNavigationAction && i + 1 < commands.length) {
|
|
2161
|
+
const nextCmd = commands[i + 1];
|
|
2162
|
+
// Rebind the same way the step itself will be, or this pre-emptive wait
|
|
2163
|
+
// polls a recorded reference that may not exist in this session - which
|
|
2164
|
+
// costs the step its whole settle budget before being swallowed.
|
|
2165
|
+
const nextConnection = nextCmd.params.connectionReason
|
|
2166
|
+
? mapConnection(nextCmd.params.connectionReason)
|
|
2167
|
+
: connectionReason;
|
|
2168
|
+
if (nextCmd.tool === 'input' && nextCmd.params.selector && nextConnection) {
|
|
2169
|
+
const nextCtx = nextConnection === connectionReason
|
|
2170
|
+
? ctx
|
|
2171
|
+
: { ...ctx, connectionReason: nextConnection, variableStore };
|
|
2172
|
+
await waitForElement(nextCtx, nextCmd.params.selector);
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
catch (error) {
|
|
2177
|
+
// Same classification as the resolved-failure path above: an exception
|
|
2178
|
+
// thrown while the run signal is aborted is the cancel surfacing.
|
|
2179
|
+
if (abortSignal?.aborted) {
|
|
2180
|
+
debugLog(logPrefix, `Replay aborted during step ${i + 1}`);
|
|
2181
|
+
results.push({
|
|
2182
|
+
step: i + 1,
|
|
2183
|
+
tool: cmd.tool,
|
|
2184
|
+
success: false,
|
|
2185
|
+
error: 'Replay aborted by user'
|
|
2186
|
+
});
|
|
2187
|
+
break;
|
|
2188
|
+
}
|
|
2189
|
+
debugLog(logPrefix, `Error at step ${i + 1}: ${error.message}`);
|
|
2190
|
+
results.push({
|
|
2191
|
+
step: i + 1,
|
|
2192
|
+
tool: cmd.tool,
|
|
2193
|
+
success: false,
|
|
2194
|
+
error: error.message || 'Unknown error'
|
|
2195
|
+
});
|
|
2196
|
+
break;
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
// The loop is over. Every path that reaches here is terminal for the MAIN
|
|
2200
|
+
// steps - success, a failed step, an abort, or the total timeout - EXCEPT a
|
|
2201
|
+
// clean stop at `endStep`, which is a stepTo pause with more steps to come.
|
|
2202
|
+
// The pause paths that return early above (breakpoint, click validation)
|
|
2203
|
+
// never reach here, which is what we want: the run is not over, so cleaning
|
|
2204
|
+
// up would destroy the state the user paused to look at.
|
|
2205
|
+
const stoppedShortOfEnd = targetEnd < commands.length;
|
|
2206
|
+
const anyFailed = results.some(r => !r.success);
|
|
2207
|
+
const isPaused = stoppedShortOfEnd && !anyFailed;
|
|
2208
|
+
const teardownOutcome = isPaused
|
|
2209
|
+
? undefined
|
|
2210
|
+
: await runTeardown(sequence, {
|
|
2211
|
+
ctx,
|
|
2212
|
+
stepTimeout,
|
|
2213
|
+
teardownTimeout: options.teardownTimeout,
|
|
2214
|
+
overrideConnectionReason,
|
|
2215
|
+
variables,
|
|
2216
|
+
record,
|
|
2217
|
+
});
|
|
2218
|
+
return {
|
|
2219
|
+
results,
|
|
2220
|
+
totalCommands: commands.length,
|
|
2221
|
+
durationMs: Date.now() - startTime,
|
|
2222
|
+
...(teardownOutcome ? { teardownResults: teardownOutcome.results, teardownFailed: teardownOutcome.failed } : {})
|
|
2223
|
+
};
|
|
2224
|
+
}
|
|
2225
|
+
/**
|
|
2226
|
+
* Run a sequence's `teardown` steps, if it has any.
|
|
2227
|
+
*
|
|
2228
|
+
* Three properties matter, and each one is a way this feature fails if it is
|
|
2229
|
+
* missed:
|
|
2230
|
+
*
|
|
2231
|
+
* - **Its own budget.** `teardownTimeout` is deliberately not drawn from the
|
|
2232
|
+
* run's `totalTimeout`. The commonest reason a run needs cleaning up after is
|
|
2233
|
+
* that it timed out, and sharing the budget would skip teardown exactly then.
|
|
2234
|
+
* - **No abort signal.** The run's signal is not passed down. `replay cancel`
|
|
2235
|
+
* must stop the work, not the cleanup - a cancelled run is precisely one that
|
|
2236
|
+
* has left something behind.
|
|
2237
|
+
* - **The variable store, shared.** Teardown reads `ctx.variableStore`, so it
|
|
2238
|
+
* can revoke whatever setup minted, even though the step that captured it may
|
|
2239
|
+
* have run long before the failure.
|
|
2240
|
+
*
|
|
2241
|
+
* Teardown is always best-effort. A killed process takes any pending teardown
|
|
2242
|
+
* with it, so it reduces accumulation and cannot guarantee a clean world -
|
|
2243
|
+
* assertions that depend on nothing being left over are still wrong.
|
|
2244
|
+
*/
|
|
2245
|
+
async function runTeardown(sequence, opts) {
|
|
2246
|
+
const teardown = sequence.teardown;
|
|
2247
|
+
if (!teardown || teardown.length === 0)
|
|
2248
|
+
return undefined;
|
|
2249
|
+
const logPrefix = opts.ctx.logPrefix ?? 'executor';
|
|
2250
|
+
await debugLog(logPrefix, `Running ${teardown.length} teardown step(s) for "${sequence.name}"`);
|
|
2251
|
+
// `teardown: undefined` on the synthetic sequence: without it the teardown
|
|
2252
|
+
// run would reach this same code and run the teardown again, forever.
|
|
2253
|
+
const result = await executeSteps({
|
|
2254
|
+
sequence: { ...sequence, commands: teardown, teardown: undefined },
|
|
2255
|
+
startStep: 0,
|
|
2256
|
+
ctx: opts.ctx,
|
|
2257
|
+
stepTimeout: opts.stepTimeout,
|
|
2258
|
+
totalTimeout: opts.teardownTimeout ?? DEFAULT_TEARDOWN_TIMEOUT,
|
|
2259
|
+
...(opts.overrideConnectionReason ? { overrideConnectionReason: opts.overrideConnectionReason } : {}),
|
|
2260
|
+
...(opts.variables ? { variables: opts.variables } : {}),
|
|
2261
|
+
...(opts.record !== undefined ? { record: opts.record } : {}),
|
|
2262
|
+
});
|
|
2263
|
+
const failed = result.results.some(r => !r.success);
|
|
2264
|
+
if (failed) {
|
|
2265
|
+
await debugLog(logPrefix, `Teardown for "${sequence.name}" had failing steps - reported alongside, not folded into the run's verdict`);
|
|
2266
|
+
}
|
|
2267
|
+
return { results: result.results, failed };
|
|
2268
|
+
}
|
|
2269
|
+
/**
|
|
2270
|
+
* Execute a sequence with pause support (stepTo)
|
|
2271
|
+
*/
|
|
2272
|
+
export async function executeSequenceWithPause(options) {
|
|
2273
|
+
const { sequence, ctx, stepTo } = options;
|
|
2274
|
+
const { commandRecorder, connectionReason } = ctx;
|
|
2275
|
+
const result = await executeSteps({
|
|
2276
|
+
...options,
|
|
2277
|
+
endStep: stepTo
|
|
2278
|
+
});
|
|
2279
|
+
// If we stopped at stepTo and didn't fail, set up paused state
|
|
2280
|
+
if (stepTo !== undefined && result.results.length > 0) {
|
|
2281
|
+
const lastResult = result.results[result.results.length - 1];
|
|
2282
|
+
if (lastResult.success && lastResult.step >= stepTo) {
|
|
2283
|
+
const activeState = {
|
|
2284
|
+
sequenceId: sequence.id,
|
|
2285
|
+
sequenceName: sequence.name,
|
|
2286
|
+
connectionReason: connectionReason || '',
|
|
2287
|
+
currentStep: lastResult.step,
|
|
2288
|
+
totalSteps: sequence.commands.length,
|
|
2289
|
+
pausedAt: Date.now(),
|
|
2290
|
+
historyIndexAtPause: commandRecorder.getCurrentHistoryIndex(),
|
|
2291
|
+
capturedVariables: ctx.variableStore,
|
|
2292
|
+
runTimestamp: ctx.runTimestamp,
|
|
2293
|
+
// step/finish must resolve per-step connections the way this run did
|
|
2294
|
+
...(ctx.connectionMap && { connectionMap: ctx.connectionMap }),
|
|
2295
|
+
};
|
|
2296
|
+
result.pausedAtStep = lastResult.step;
|
|
2297
|
+
result.activeSequenceState = activeState;
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
return result;
|
|
2301
|
+
}
|
|
2302
|
+
/**
|
|
2303
|
+
* Get current debug state (breakpoints, pause status)
|
|
2304
|
+
*/
|
|
2305
|
+
export async function getDebugState(ctx) {
|
|
2306
|
+
const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
|
|
2307
|
+
if (!connectionReason)
|
|
2308
|
+
return null;
|
|
2309
|
+
try {
|
|
2310
|
+
const breakpointResult = await executeToolCall('breakpoint', {
|
|
2311
|
+
action: 'list',
|
|
2312
|
+
connectionReason
|
|
2313
|
+
});
|
|
2314
|
+
const breakpointText = breakpointResult?.content?.[0]?.text || '';
|
|
2315
|
+
const totalMatch = breakpointText.match(/\*\*Total:\*\*\s*(\d+)/);
|
|
2316
|
+
const breakpointCount = totalMatch ? parseInt(totalMatch[1], 10) : 0;
|
|
2317
|
+
// "Not paused" is the ANSWER, not a failure - but inspect.getCallStack
|
|
2318
|
+
// reports it as an error response, which the live executeToolCall rethrows.
|
|
2319
|
+
// Letting it reach the outer catch threw away the breakpoint count already
|
|
2320
|
+
// read and reported no debug state at all for the ordinary unpaused run,
|
|
2321
|
+
// which is the run this exists to describe.
|
|
2322
|
+
let callStackText = '';
|
|
2323
|
+
try {
|
|
2324
|
+
const callStackResult = await executeToolCall('inspect', {
|
|
2325
|
+
action: 'getCallStack',
|
|
2326
|
+
connectionReason
|
|
2327
|
+
});
|
|
2328
|
+
callStackText = callStackResult?.content?.[0]?.text || '';
|
|
2329
|
+
}
|
|
2330
|
+
catch (callStackError) {
|
|
2331
|
+
if (callStackError?.response?._errorId !== 'NOT_PAUSED')
|
|
2332
|
+
throw callStackError;
|
|
2333
|
+
}
|
|
2334
|
+
const isPaused = callStackText.includes('callFrameId');
|
|
2335
|
+
let pauseLocation;
|
|
2336
|
+
if (isPaused) {
|
|
2337
|
+
const pauseLocationMatch = callStackText.match(/Paused at:\s*([^\n]+)/);
|
|
2338
|
+
pauseLocation = pauseLocationMatch ? pauseLocationMatch[1] : 'unknown location';
|
|
2339
|
+
}
|
|
2340
|
+
return { isPaused, pauseLocation, breakpointCount };
|
|
2341
|
+
}
|
|
2342
|
+
catch (err) {
|
|
2343
|
+
debugLog(logPrefix, `Could not get debug state: ${err.message}`);
|
|
2344
|
+
return null;
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
//# sourceMappingURL=replay-executor.js.map
|