@vellumai/assistant 0.6.2 → 0.6.3
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/bun.lock +40 -40
- package/bunfig.toml +3 -0
- package/docs/architecture/memory.md +1 -1
- package/node_modules/@vellumai/ces-contracts/src/rpc.ts +42 -0
- package/openapi.yaml +184 -69
- package/package.json +41 -41
- package/scripts/generate-openapi.ts +1 -2
- package/src/__tests__/acp-session.test.ts +43 -0
- package/src/__tests__/app-builder-tool-scripts.test.ts +1 -0
- package/src/__tests__/app-executors.test.ts +1 -0
- package/src/__tests__/app-source-watcher.test.ts +37 -11
- package/src/__tests__/approval-routes-http.test.ts +178 -1
- package/src/__tests__/browser-fill-credential.test.ts +229 -94
- package/src/__tests__/browser-manager.test.ts +40 -27
- package/src/__tests__/catalog-files.test.ts +862 -0
- package/src/__tests__/channel-approvals.test.ts +53 -0
- package/src/__tests__/config-managed-gemini-defaults.test.ts +326 -0
- package/src/__tests__/config-schema-cmd.test.ts +2 -2
- package/src/__tests__/config-schema.test.ts +125 -48
- package/src/__tests__/confirmation-request-guardian-bridge.test.ts +23 -0
- package/src/__tests__/context-overflow-approval.test.ts +16 -1
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -1
- package/src/__tests__/conversation-agent-loop.test.ts +1 -1
- package/src/__tests__/conversation-analysis-routes.test.ts +2 -2
- package/src/__tests__/conversation-attachments.test.ts +80 -4
- package/src/__tests__/conversation-confirmation-signals.test.ts +155 -0
- package/src/__tests__/conversation-fork-crud.test.ts +17 -0
- package/src/__tests__/conversation-history-web-search.test.ts +1 -0
- package/src/__tests__/conversation-host-access-routes.test.ts +229 -0
- package/src/__tests__/conversation-inject-context.test.ts +103 -0
- package/src/__tests__/conversation-queue.test.ts +45 -2
- package/src/__tests__/conversation-routes-disk-view.test.ts +5 -0
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +16 -0
- package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
- package/src/__tests__/conversation-runtime-assembly.test.ts +269 -46
- package/src/__tests__/conversation-starter-routes.test.ts +126 -0
- package/src/__tests__/conversation-starters-cadence.test.ts +161 -0
- package/src/__tests__/conversation-store.test.ts +195 -0
- package/src/__tests__/conversation-workspace-cache-state.test.ts +193 -0
- package/src/__tests__/credential-execution-approval-bridge.test.ts +32 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/credential-vault-unit.test.ts +4 -4
- package/src/__tests__/credential-vault.test.ts +152 -13
- package/src/__tests__/credentials-cli.test.ts +2 -2
- package/src/__tests__/date-context.test.ts +4 -4
- package/src/__tests__/embedding-managed-proxy-selection.test.ts +256 -0
- package/src/__tests__/extension-id-sync-guard.test.ts +155 -0
- package/src/__tests__/fixtures/mock-chrome-extension.ts +375 -0
- package/src/__tests__/gateway-only-guard.test.ts +3 -0
- package/src/__tests__/gemini-provider.test.ts +2 -2
- package/src/__tests__/guardian-routing-invariants.test.ts +70 -2
- package/src/__tests__/headless-browser-interactions.test.ts +707 -371
- package/src/__tests__/headless-browser-navigate.test.ts +389 -47
- package/src/__tests__/headless-browser-read-tools.test.ts +266 -103
- package/src/__tests__/headless-browser-snapshot.test.ts +240 -77
- package/src/__tests__/host-bash-proxy.test.ts +150 -1
- package/src/__tests__/host-browser-e2e-cloud.test.ts +462 -0
- package/src/__tests__/host-browser-e2e-self-hosted-capability.test.ts +286 -0
- package/src/__tests__/host-browser-e2e-self-hosted.test.ts +374 -0
- package/src/__tests__/host-browser-event-routes.test.ts +350 -0
- package/src/__tests__/host-browser-proxy.test.ts +444 -0
- package/src/__tests__/host-browser-routes.test.ts +198 -0
- package/src/__tests__/host-browser-ws-events-e2e.test.ts +320 -0
- package/src/__tests__/host-cu-proxy.test.ts +171 -1
- package/src/__tests__/host-file-proxy.test.ts +185 -1
- package/src/__tests__/host-file-read-tool.test.ts +52 -0
- package/src/__tests__/host-proxy-interface.test.ts +165 -0
- package/src/__tests__/host-shell-tool.test.ts +1 -11
- package/src/__tests__/http-user-message-parity.test.ts +1 -0
- package/src/__tests__/integration-status.test.ts +6 -7
- package/src/__tests__/list-messages-tool-merge.test.ts +37 -12
- package/src/__tests__/mcp-client-auth.test.ts +40 -4
- package/src/__tests__/mcp-health-check.test.ts +10 -3
- package/src/__tests__/migration-cross-version-compatibility.test.ts +3 -1
- package/src/__tests__/migration-export-http.test.ts +61 -2
- package/src/__tests__/migration-export-streaming.test.ts +66 -0
- package/src/__tests__/migration-import-commit-http.test.ts +101 -1
- package/src/__tests__/native-host-marker-sync-guard.test.ts +157 -0
- package/src/__tests__/oauth-apps-routes.test.ts +17 -12
- package/src/__tests__/oauth-cli.test.ts +707 -60
- package/src/__tests__/oauth-connect-orchestrator.test.ts +116 -24
- package/src/__tests__/oauth-provider-seed-logos.test.ts +23 -0
- package/src/__tests__/oauth-provider-serializer.test.ts +146 -10
- package/src/__tests__/oauth-provider-visibility.test.ts +19 -21
- package/src/__tests__/oauth-providers-routes.test.ts +50 -14
- package/src/__tests__/oauth-store.test.ts +1386 -182
- package/src/__tests__/oauth2-gateway-transport.test.ts +211 -20
- package/src/__tests__/onboarding-template-contract.test.ts +75 -57
- package/src/__tests__/openai-provider.test.ts +2 -2
- package/src/__tests__/outlook-categories.test.ts +1 -1
- package/src/__tests__/outlook-client-automation.test.ts +1 -1
- package/src/__tests__/outlook-compose-tools.test.ts +1 -1
- package/src/__tests__/outlook-email-watcher.test.ts +1 -1
- package/src/__tests__/outlook-follow-up.test.ts +1 -1
- package/src/__tests__/outlook-messaging-provider.test.ts +2 -2
- package/src/__tests__/outlook-trash.test.ts +1 -1
- package/src/__tests__/outlook-unsubscribe.test.ts +1 -1
- package/src/__tests__/permission-checker-host-gate.test.ts +74 -14
- package/src/__tests__/permission-mode.test.ts +28 -56
- package/src/__tests__/platform-callback-registration.test.ts +19 -0
- package/src/__tests__/post-turn-tool-result-truncation.test.ts +296 -0
- package/src/__tests__/proxy-approval-callback.test.ts +18 -0
- package/src/__tests__/require-fresh-approval.test.ts +40 -1
- package/src/__tests__/sanitize-config-for-transfer.test.ts +132 -0
- package/src/__tests__/schedule-routes.test.ts +162 -0
- package/src/__tests__/secret-detection-handler.test.ts +84 -0
- package/src/__tests__/secret-ingress-http.test.ts +1 -0
- package/src/__tests__/send-endpoint-busy.test.ts +3 -0
- package/src/__tests__/set-permission-mode.test.ts +13 -250
- package/src/__tests__/skills-file-content-endpoint.test.ts +670 -0
- package/src/__tests__/skills-files-catalog-fallback.test.ts +450 -0
- package/src/__tests__/slack-channel-config.test.ts +12 -15
- package/src/__tests__/subagent-detail.test.ts +44 -2
- package/src/__tests__/subagent-disposal.test.ts +1 -0
- package/src/__tests__/subagent-fork-notifications.test.ts +291 -0
- package/src/__tests__/subagent-fork-spawn.test.ts +384 -0
- package/src/__tests__/subagent-manager-notify.test.ts +1 -0
- package/src/__tests__/subagent-notify-parent.test.ts +1 -0
- package/src/__tests__/subagent-spawn-tool-fork.test.ts +411 -0
- package/src/__tests__/subagent-tools.test.ts +1 -0
- package/src/__tests__/subagent-types.test.ts +1 -0
- package/src/__tests__/system-prompt-ask-mode.test.ts +27 -71
- package/src/__tests__/system-prompt.test.ts +72 -1
- package/src/__tests__/task-scheduler.test.ts +32 -6
- package/src/__tests__/telegram-config.test.ts +10 -13
- package/src/__tests__/terminal-tools.test.ts +9 -0
- package/src/__tests__/tool-approval-handler.test.ts +73 -0
- package/src/__tests__/tool-side-effects-slack-dm.test.ts +22 -0
- package/src/__tests__/top-level-renderer.test.ts +73 -1
- package/src/__tests__/transport-hints-queue.test.ts +14 -29
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +109 -0
- package/src/__tests__/v2-consent-policy.test.ts +103 -0
- package/src/acp/client-handler.ts +30 -4
- package/src/agent/loop.ts +12 -6
- package/src/approvals/guardian-request-resolvers.ts +21 -15
- package/src/browser-session/__tests__/manager.test.ts +297 -0
- package/src/browser-session/backends/cdp-inspect.ts +30 -0
- package/src/browser-session/backends/extension.ts +26 -0
- package/src/browser-session/backends/local.ts +24 -0
- package/src/browser-session/events.ts +164 -0
- package/src/browser-session/index.ts +27 -0
- package/src/browser-session/manager.ts +159 -0
- package/src/browser-session/types.ts +28 -0
- package/src/channels/__tests__/types.test.ts +134 -0
- package/src/channels/types.ts +53 -3
- package/src/cli/commands/browser-relay.ts +339 -409
- package/src/cli/commands/credentials.ts +3 -3
- package/src/cli/commands/email.ts +18 -13
- package/src/cli/commands/mcp.ts +16 -4
- package/src/cli/commands/oauth/__tests__/connect.test.ts +44 -44
- package/src/cli/commands/oauth/__tests__/disconnect.test.ts +21 -21
- package/src/cli/commands/oauth/__tests__/mode.test.ts +17 -17
- package/src/cli/commands/oauth/__tests__/ping.test.ts +16 -16
- package/src/cli/commands/oauth/__tests__/providers-delete.test.ts +31 -33
- package/src/cli/commands/oauth/__tests__/providers-register.test.ts +329 -0
- package/src/cli/commands/oauth/__tests__/providers-update.test.ts +116 -12
- package/src/cli/commands/oauth/__tests__/status.test.ts +10 -10
- package/src/cli/commands/oauth/__tests__/token.test.ts +7 -7
- package/src/cli/commands/oauth/apps.ts +7 -4
- package/src/cli/commands/oauth/connect.ts +6 -3
- package/src/cli/commands/oauth/disconnect.ts +1 -1
- package/src/cli/commands/oauth/providers.ts +200 -36
- package/src/cli/commands/oauth/shared.ts +5 -5
- package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +259 -0
- package/src/cli/commands/platform/index.ts +107 -10
- package/src/cli/commands/usage.ts +10 -9
- package/src/cli/lib/daemon-credential-client.ts +4 -0
- package/src/cli/program.ts +1 -1
- package/src/config/bundled-skills/app-builder/SKILL.md +26 -249
- package/src/config/bundled-skills/app-builder/references/CUSTOM_ROUTES.md +105 -0
- package/src/config/bundled-skills/app-builder/references/INTERACTION_HOOKS.md +56 -0
- package/src/config/bundled-skills/app-builder/references/WIDGETS.md +125 -0
- package/src/config/bundled-skills/contacts/SKILL.md +3 -0
- package/src/config/bundled-skills/document/SKILL.md +4 -0
- package/src/config/bundled-skills/gmail/SKILL.md +1 -1
- package/src/config/bundled-skills/outlook/SKILL.md +7 -0
- package/src/config/bundled-skills/subagent/SKILL.md +21 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +8 -4
- package/src/config/bundled-skills/tasks/SKILL.md +5 -0
- package/src/config/env-registry.ts +14 -0
- package/src/config/env.ts +21 -0
- package/src/config/feature-flag-registry.json +44 -5
- package/src/config/loader.ts +56 -1
- package/src/config/sanitize-for-transfer.ts +47 -0
- package/src/config/schema.ts +46 -5
- package/src/config/schemas/host-browser.ts +66 -0
- package/src/config/schemas/memory-lifecycle.ts +1 -1
- package/src/config/schemas/memory-retrieval.ts +103 -0
- package/src/config/schemas/security.ts +0 -6
- package/src/config/schemas/services.ts +8 -0
- package/src/config/types.ts +0 -1
- package/src/context/post-turn-tool-result-truncation.ts +176 -0
- package/src/context/window-manager.ts +19 -1
- package/src/credential-execution/approval-bridge.ts +49 -15
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +186 -0
- package/src/daemon/app-source-watcher.ts +35 -0
- package/src/daemon/context-overflow-approval.ts +5 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +17 -2
- package/src/daemon/conversation-agent-loop.ts +58 -24
- package/src/daemon/conversation-attachments.ts +40 -0
- package/src/daemon/conversation-process.ts +48 -1
- package/src/daemon/conversation-runtime-assembly.ts +118 -36
- package/src/daemon/conversation-surfaces.ts +37 -36
- package/src/daemon/conversation-tool-setup.ts +74 -8
- package/src/daemon/conversation-workspace.ts +12 -0
- package/src/daemon/conversation.ts +226 -8
- package/src/daemon/date-context.ts +10 -10
- package/src/daemon/first-greeting.ts +3 -2
- package/src/daemon/handlers/conversations.ts +9 -140
- package/src/daemon/handlers/shared.ts +58 -0
- package/src/daemon/handlers/skills.ts +232 -37
- package/src/daemon/host-bash-proxy.ts +48 -13
- package/src/daemon/host-browser-proxy.ts +191 -0
- package/src/daemon/host-cu-proxy.ts +36 -11
- package/src/daemon/host-file-proxy.ts +57 -9
- package/src/daemon/lifecycle.ts +65 -11
- package/src/daemon/message-protocol.ts +7 -0
- package/src/daemon/message-types/conversations.ts +55 -13
- package/src/daemon/message-types/host-browser.ts +100 -0
- package/src/daemon/message-types/messages.ts +5 -5
- package/src/daemon/message-types/skills.ts +10 -0
- package/src/daemon/message-types/subagents.ts +2 -0
- package/src/daemon/server.ts +92 -12
- package/src/daemon/tool-side-effects.ts +6 -0
- package/src/daemon/transport-hints.ts +5 -24
- package/src/inbound/platform-callback-registration.ts +18 -17
- package/src/mcp/client.ts +59 -24
- package/src/memory/app-store.ts +31 -1
- package/src/memory/conversation-crud.ts +23 -0
- package/src/memory/conversation-starters-cadence.ts +76 -0
- package/src/memory/conversation-title-service.ts +5 -2
- package/src/memory/db-init.ts +12 -0
- package/src/memory/embedding-backend.test.ts +75 -0
- package/src/memory/embedding-backend.ts +131 -5
- package/src/memory/embedding-gemini.test.ts +54 -0
- package/src/memory/embedding-gemini.ts +20 -9
- package/src/memory/embedding-local.ts +176 -17
- package/src/memory/graph/consolidation.ts +10 -23
- package/src/memory/graph/extraction-job.ts +15 -0
- package/src/memory/graph/retriever.ts +40 -22
- package/src/memory/graph/store.test.ts +7 -3
- package/src/memory/graph/store.ts +47 -12
- package/src/memory/llm-usage-store.ts +45 -4
- package/src/memory/migrations/213-oauth-providers-scope-separator.ts +13 -0
- package/src/memory/migrations/214-oauth-providers-refresh-url.ts +11 -0
- package/src/memory/migrations/215-oauth-providers-revoke.ts +14 -0
- package/src/memory/migrations/216-oauth-providers-token-auth-method.ts +30 -0
- package/src/memory/migrations/217-conversation-host-access.ts +40 -0
- package/src/memory/migrations/218-oauth-providers-logo-url.ts +11 -0
- package/src/memory/migrations/index.ts +6 -0
- package/src/memory/migrations/registry.ts +8 -0
- package/src/memory/schema/conversations.ts +1 -0
- package/src/memory/schema/oauth.ts +18 -13
- package/src/oauth/AGENTS.md +76 -0
- package/src/oauth/__tests__/identity-verifier.test.ts +24 -19
- package/src/oauth/__tests__/seed-providers-managed.test.ts +32 -0
- package/src/oauth/byo-connection.test.ts +8 -8
- package/src/oauth/byo-connection.ts +7 -7
- package/src/oauth/connect-orchestrator.ts +23 -21
- package/src/oauth/connect-types.ts +3 -3
- package/src/oauth/connection-resolver.test.ts +17 -4
- package/src/oauth/connection-resolver.ts +16 -16
- package/src/oauth/connection.ts +1 -1
- package/src/oauth/manual-token-connection.ts +13 -13
- package/src/oauth/oauth-store.ts +214 -100
- package/src/oauth/platform-connection.test.ts +3 -3
- package/src/oauth/platform-connection.ts +4 -4
- package/src/oauth/provider-serializer.ts +31 -5
- package/src/oauth/revoke.ts +76 -0
- package/src/oauth/seed-providers.ts +126 -87
- package/src/oauth/token-persistence.ts +1 -1
- package/src/permissions/permission-mode.ts +4 -11
- package/src/permissions/prompter.ts +13 -1
- package/src/permissions/v2-consent-policy.ts +87 -0
- package/src/prompts/system-prompt.ts +18 -21
- package/src/prompts/templates/BOOTSTRAP-REFERENCE.md +3 -65
- package/src/prompts/templates/BOOTSTRAP.md +59 -105
- package/src/providers/anthropic/client.ts +1 -0
- package/src/providers/types.ts +1 -1
- package/src/runtime/AGENTS.md +23 -0
- package/src/runtime/__tests__/browser-extension-pair-routes.test.ts +715 -0
- package/src/runtime/__tests__/capability-tokens.test.ts +258 -0
- package/src/runtime/__tests__/chrome-extension-registry.test.ts +518 -0
- package/src/runtime/assistant-event-hub.ts +2 -2
- package/src/runtime/auth/__tests__/guard-tests.test.ts +1 -0
- package/src/runtime/auth/__tests__/middleware.test.ts +116 -1
- package/src/runtime/auth/__tests__/route-policy.test.ts +8 -0
- package/src/runtime/auth/middleware.ts +98 -0
- package/src/runtime/auth/route-policy.ts +6 -7
- package/src/runtime/capability-tokens.ts +414 -0
- package/src/runtime/channel-approvals.ts +18 -5
- package/src/runtime/chrome-extension-registry.ts +332 -0
- package/src/runtime/confirmation-request-guardian-bridge.ts +6 -0
- package/src/runtime/guardian-decision-types.ts +7 -0
- package/src/runtime/http-server.ts +425 -70
- package/src/runtime/migrations/__tests__/rebind-secrets-credentials.test.ts +172 -0
- package/src/runtime/migrations/__tests__/vbundle-builder-credentials.test.ts +276 -0
- package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +162 -0
- package/src/runtime/migrations/migration-transport.ts +6 -0
- package/src/runtime/migrations/migration-wizard.ts +22 -2
- package/src/runtime/migrations/rebind-secrets-screen.ts +76 -15
- package/src/runtime/migrations/vbundle-builder.ts +145 -38
- package/src/runtime/migrations/vbundle-import-analyzer.ts +19 -0
- package/src/runtime/migrations/vbundle-importer.ts +55 -5
- package/src/runtime/pending-interactions.ts +29 -13
- package/src/runtime/routes/approval-routes.ts +90 -16
- package/src/runtime/routes/browser-cdp-routes.ts +229 -0
- package/src/runtime/routes/browser-extension-pair-routes.ts +497 -0
- package/src/runtime/routes/conversation-analysis-routes.ts +2 -1
- package/src/runtime/routes/conversation-management-routes.ts +108 -0
- package/src/runtime/routes/conversation-routes.ts +301 -27
- package/src/runtime/routes/conversation-starter-routes.ts +78 -16
- package/src/runtime/routes/guardian-action-routes.ts +24 -13
- package/src/runtime/routes/host-browser-routes.ts +279 -0
- package/src/runtime/routes/host-file-routes.ts +9 -1
- package/src/runtime/routes/identity-routes.ts +259 -16
- package/src/runtime/routes/log-export-routes.ts +42 -22
- package/src/runtime/routes/memory-item-routes.ts +1 -7
- package/src/runtime/routes/migration-routes.ts +87 -2
- package/src/runtime/routes/oauth-apps.ts +15 -17
- package/src/runtime/routes/oauth-providers.ts +4 -0
- package/src/runtime/routes/schedule-routes.ts +24 -11
- package/src/runtime/routes/settings-routes.ts +9 -97
- package/src/runtime/routes/skills-routes.ts +52 -2
- package/src/runtime/routes/subagents-routes.ts +14 -10
- package/src/runtime/routes/usage-routes.ts +8 -7
- package/src/runtime/routes/workspace-routes.test.ts +22 -0
- package/src/runtime/routes/workspace-routes.ts +8 -1
- package/src/runtime/routes/workspace-utils.ts +2 -0
- package/src/schedule/scheduler.ts +7 -5
- package/src/security/ces-credential-client.ts +20 -0
- package/src/security/ces-rpc-credential-backend.ts +17 -0
- package/src/security/credential-backend.ts +5 -0
- package/src/security/oauth2.ts +42 -25
- package/src/security/secure-keys.ts +118 -25
- package/src/security/token-manager.ts +23 -10
- package/src/skills/catalog-files.ts +492 -0
- package/src/subagent/manager.ts +131 -26
- package/src/subagent/types.ts +19 -0
- package/src/tools/apps/executors.ts +11 -2
- package/src/tools/browser/__tests__/auth-detector.test.ts +202 -108
- package/src/tools/browser/auth-detector.ts +43 -12
- package/src/tools/browser/browser-execution.ts +645 -340
- package/src/tools/browser/browser-manager.ts +36 -12
- package/src/tools/browser/cdp-client/__tests__/accessibility-snapshot.test.ts +318 -0
- package/src/tools/browser/cdp-client/__tests__/cdp-dom-helpers.test.ts +1175 -0
- package/src/tools/browser/cdp-client/__tests__/cdp-inspect-client.test.ts +870 -0
- package/src/tools/browser/cdp-client/__tests__/extension-cdp-client.test.ts +330 -0
- package/src/tools/browser/cdp-client/__tests__/factory.test.ts +377 -0
- package/src/tools/browser/cdp-client/__tests__/fixtures/ax-tree-nested-frames.json +64 -0
- package/src/tools/browser/cdp-client/__tests__/fixtures/ax-tree-simple.json +69 -0
- package/src/tools/browser/cdp-client/__tests__/local-cdp-client.test.ts +310 -0
- package/src/tools/browser/cdp-client/__tests__/types.test.ts +96 -0
- package/src/tools/browser/cdp-client/accessibility-snapshot.ts +387 -0
- package/src/tools/browser/cdp-client/cdp-dom-helpers.ts +695 -0
- package/src/tools/browser/cdp-client/cdp-inspect/__tests__/discovery.test.ts +743 -0
- package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +580 -0
- package/src/tools/browser/cdp-client/cdp-inspect/discovery.ts +578 -0
- package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +579 -0
- package/src/tools/browser/cdp-client/cdp-inspect-client.ts +635 -0
- package/src/tools/browser/cdp-client/errors.ts +34 -0
- package/src/tools/browser/cdp-client/extension-cdp-client.ts +125 -0
- package/src/tools/browser/cdp-client/factory.ts +204 -0
- package/src/tools/browser/cdp-client/index.ts +14 -0
- package/src/tools/browser/cdp-client/local-cdp-client.ts +187 -0
- package/src/tools/browser/cdp-client/types.ts +52 -0
- package/src/tools/filesystem/edit.ts +1 -1
- package/src/tools/filesystem/list.ts +1 -1
- package/src/tools/filesystem/read.ts +1 -1
- package/src/tools/filesystem/write.ts +2 -1
- package/src/tools/host-filesystem/edit.ts +1 -1
- package/src/tools/host-filesystem/read.ts +12 -15
- package/src/tools/host-filesystem/write.ts +1 -1
- package/src/tools/host-terminal/host-shell.ts +21 -16
- package/src/tools/permission-checker.ts +77 -82
- package/src/tools/registry.ts +0 -2
- package/src/tools/secret-detection-handler.ts +34 -0
- package/src/tools/shared/filesystem/image-read.ts +61 -40
- package/src/tools/subagent/spawn.ts +47 -3
- package/src/tools/subagent/status.ts +2 -0
- package/src/tools/system/register.ts +2 -16
- package/src/tools/terminal/safe-env.ts +7 -0
- package/src/tools/terminal/shell.ts +21 -16
- package/src/tools/tool-approval-handler.ts +48 -2
- package/src/tools/types.ts +2 -0
- package/src/util/platform.ts +14 -19
- package/src/workspace/top-level-renderer.ts +19 -1
- package/src/__tests__/chrome-cdp.test.ts +0 -419
- package/src/__tests__/permission-mode-sse.test.ts +0 -418
- package/src/__tests__/permission-mode-store.test.ts +0 -277
- package/src/browser-extension-relay/protocol.ts +0 -63
- package/src/browser-extension-relay/server.ts +0 -203
- package/src/config/schemas/sandbox.ts +0 -14
- package/src/permissions/permission-mode-store.ts +0 -180
- package/src/tools/browser/chrome-cdp.ts +0 -239
- package/src/tools/system/set-permission-mode.ts +0 -103
|
@@ -0,0 +1,1175 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
captureScreenshotJpeg,
|
|
5
|
+
dispatchClickAt,
|
|
6
|
+
dispatchHoverAt,
|
|
7
|
+
dispatchInsertText,
|
|
8
|
+
dispatchKeyPress,
|
|
9
|
+
dispatchWheelScroll,
|
|
10
|
+
evaluateExpression,
|
|
11
|
+
focusElement,
|
|
12
|
+
getCenterPoint,
|
|
13
|
+
getCurrentUrl,
|
|
14
|
+
getPageTitle,
|
|
15
|
+
navigateAndWait,
|
|
16
|
+
querySelectorBackendNodeId,
|
|
17
|
+
scrollIntoViewIfNeeded,
|
|
18
|
+
waitForSelector,
|
|
19
|
+
waitForText,
|
|
20
|
+
} from "../cdp-dom-helpers.js";
|
|
21
|
+
import { CdpError } from "../errors.js";
|
|
22
|
+
import type { CdpClient } from "../types.js";
|
|
23
|
+
|
|
24
|
+
// ── Test utilities ────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
type CdpCall = { method: string; params?: Record<string, unknown> };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Minimal in-memory fake CdpClient. The programmable `handler` is
|
|
30
|
+
* called for every `send` and must return the raw CDP result object
|
|
31
|
+
* (or throw). Every call is recorded on `calls` so tests can assert
|
|
32
|
+
* method order and param shape.
|
|
33
|
+
*/
|
|
34
|
+
function fakeCdp(
|
|
35
|
+
handler: (method: string, params?: Record<string, unknown>) => unknown,
|
|
36
|
+
): CdpClient & { calls: CdpCall[] } {
|
|
37
|
+
const calls: CdpCall[] = [];
|
|
38
|
+
return {
|
|
39
|
+
calls,
|
|
40
|
+
async send<T>(
|
|
41
|
+
method: string,
|
|
42
|
+
params?: Record<string, unknown>,
|
|
43
|
+
): Promise<T> {
|
|
44
|
+
calls.push({ method, params });
|
|
45
|
+
const value = handler(method, params);
|
|
46
|
+
return (await value) as T;
|
|
47
|
+
},
|
|
48
|
+
dispose() {},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── querySelectorBackendNodeId ────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
describe("querySelectorBackendNodeId", () => {
|
|
55
|
+
test("returns backendNodeId on happy path", async () => {
|
|
56
|
+
const cdp = fakeCdp((method) => {
|
|
57
|
+
switch (method) {
|
|
58
|
+
case "DOM.getDocument":
|
|
59
|
+
return { root: { nodeId: 1 } };
|
|
60
|
+
case "DOM.querySelector":
|
|
61
|
+
return { nodeId: 42 };
|
|
62
|
+
case "DOM.describeNode":
|
|
63
|
+
return { node: { backendNodeId: 777 } };
|
|
64
|
+
default:
|
|
65
|
+
throw new Error(`unexpected method: ${method}`);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const backendNodeId = await querySelectorBackendNodeId(cdp, "#submit");
|
|
70
|
+
|
|
71
|
+
expect(backendNodeId).toBe(777);
|
|
72
|
+
expect(cdp.calls.map((c) => c.method)).toEqual([
|
|
73
|
+
"DOM.getDocument",
|
|
74
|
+
"DOM.querySelector",
|
|
75
|
+
"DOM.describeNode",
|
|
76
|
+
]);
|
|
77
|
+
expect(cdp.calls[1]!.params).toEqual({ nodeId: 1, selector: "#submit" });
|
|
78
|
+
expect(cdp.calls[2]!.params).toEqual({ nodeId: 42, depth: 0 });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("throws CdpError with code 'cdp_error' when nodeId is 0", async () => {
|
|
82
|
+
const cdp = fakeCdp((method) => {
|
|
83
|
+
switch (method) {
|
|
84
|
+
case "DOM.getDocument":
|
|
85
|
+
return { root: { nodeId: 1 } };
|
|
86
|
+
case "DOM.querySelector":
|
|
87
|
+
return { nodeId: 0 };
|
|
88
|
+
default:
|
|
89
|
+
throw new Error(`unexpected method: ${method}`);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await expect(
|
|
94
|
+
querySelectorBackendNodeId(cdp, "#missing"),
|
|
95
|
+
).rejects.toMatchObject({
|
|
96
|
+
name: "CdpError",
|
|
97
|
+
code: "cdp_error",
|
|
98
|
+
cdpMethod: "DOM.querySelector",
|
|
99
|
+
cdpParams: { selector: "#missing" },
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ── scrollIntoViewIfNeeded ────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
describe("scrollIntoViewIfNeeded", () => {
|
|
107
|
+
test("sends DOM.scrollIntoViewIfNeeded with backendNodeId", async () => {
|
|
108
|
+
const cdp = fakeCdp(() => ({}));
|
|
109
|
+
await scrollIntoViewIfNeeded(cdp, 99);
|
|
110
|
+
expect(cdp.calls).toEqual([
|
|
111
|
+
{ method: "DOM.scrollIntoViewIfNeeded", params: { backendNodeId: 99 } },
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("propagates transport errors from the client", async () => {
|
|
116
|
+
const cdp = fakeCdp(() => {
|
|
117
|
+
throw new CdpError("transport_error", "socket closed");
|
|
118
|
+
});
|
|
119
|
+
await expect(scrollIntoViewIfNeeded(cdp, 99)).rejects.toMatchObject({
|
|
120
|
+
name: "CdpError",
|
|
121
|
+
code: "transport_error",
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ── getCenterPoint ────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
describe("getCenterPoint", () => {
|
|
129
|
+
test("returns midpoint of the content quad", async () => {
|
|
130
|
+
// Content quad: (10,20) (30,20) (30,40) (10,40)
|
|
131
|
+
// Midpoint: ((10+30)/2, (20+40)/2) = (20, 30)
|
|
132
|
+
const cdp = fakeCdp(() => ({
|
|
133
|
+
model: { content: [10, 20, 30, 20, 30, 40, 10, 40] },
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
const point = await getCenterPoint(cdp, 55);
|
|
137
|
+
|
|
138
|
+
expect(point).toEqual({ x: 20, y: 30 });
|
|
139
|
+
expect(cdp.calls[0]).toEqual({
|
|
140
|
+
method: "DOM.getBoxModel",
|
|
141
|
+
params: { backendNodeId: 55 },
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("throws CdpError when DOM.getBoxModel rejects", async () => {
|
|
146
|
+
const cdp = fakeCdp(() => {
|
|
147
|
+
throw new CdpError("cdp_error", "Could not compute box model.");
|
|
148
|
+
});
|
|
149
|
+
await expect(getCenterPoint(cdp, 55)).rejects.toMatchObject({
|
|
150
|
+
name: "CdpError",
|
|
151
|
+
code: "cdp_error",
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ── focusElement ──────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
describe("focusElement", () => {
|
|
159
|
+
test("sends DOM.focus with backendNodeId", async () => {
|
|
160
|
+
const cdp = fakeCdp(() => ({}));
|
|
161
|
+
await focusElement(cdp, 123);
|
|
162
|
+
expect(cdp.calls).toEqual([
|
|
163
|
+
{ method: "DOM.focus", params: { backendNodeId: 123 } },
|
|
164
|
+
]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("propagates errors from the client", async () => {
|
|
168
|
+
const cdp = fakeCdp(() => {
|
|
169
|
+
throw new CdpError("cdp_error", "Element is not focusable");
|
|
170
|
+
});
|
|
171
|
+
await expect(focusElement(cdp, 123)).rejects.toMatchObject({
|
|
172
|
+
name: "CdpError",
|
|
173
|
+
code: "cdp_error",
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ── dispatchClickAt ───────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
describe("dispatchClickAt", () => {
|
|
181
|
+
test("emits exactly three Input.dispatchMouseEvent calls in order", async () => {
|
|
182
|
+
const cdp = fakeCdp(() => ({}));
|
|
183
|
+
|
|
184
|
+
await dispatchClickAt(cdp, { x: 100, y: 200 });
|
|
185
|
+
|
|
186
|
+
expect(cdp.calls).toHaveLength(3);
|
|
187
|
+
expect(cdp.calls[0]).toEqual({
|
|
188
|
+
method: "Input.dispatchMouseEvent",
|
|
189
|
+
params: {
|
|
190
|
+
x: 100,
|
|
191
|
+
y: 200,
|
|
192
|
+
button: "left",
|
|
193
|
+
clickCount: 1,
|
|
194
|
+
type: "mouseMoved",
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
expect(cdp.calls[1]).toEqual({
|
|
198
|
+
method: "Input.dispatchMouseEvent",
|
|
199
|
+
params: {
|
|
200
|
+
x: 100,
|
|
201
|
+
y: 200,
|
|
202
|
+
button: "left",
|
|
203
|
+
clickCount: 1,
|
|
204
|
+
type: "mousePressed",
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
expect(cdp.calls[2]).toEqual({
|
|
208
|
+
method: "Input.dispatchMouseEvent",
|
|
209
|
+
params: {
|
|
210
|
+
x: 100,
|
|
211
|
+
y: 200,
|
|
212
|
+
button: "left",
|
|
213
|
+
clickCount: 1,
|
|
214
|
+
type: "mouseReleased",
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("propagates errors from the client", async () => {
|
|
220
|
+
const cdp = fakeCdp(() => {
|
|
221
|
+
throw new CdpError("transport_error", "send failed");
|
|
222
|
+
});
|
|
223
|
+
await expect(dispatchClickAt(cdp, { x: 1, y: 2 })).rejects.toMatchObject({
|
|
224
|
+
name: "CdpError",
|
|
225
|
+
code: "transport_error",
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ── dispatchHoverAt ───────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
describe("dispatchHoverAt", () => {
|
|
233
|
+
test("emits a single mouseMoved event", async () => {
|
|
234
|
+
const cdp = fakeCdp(() => ({}));
|
|
235
|
+
|
|
236
|
+
await dispatchHoverAt(cdp, { x: 10, y: 20 });
|
|
237
|
+
|
|
238
|
+
expect(cdp.calls).toEqual([
|
|
239
|
+
{
|
|
240
|
+
method: "Input.dispatchMouseEvent",
|
|
241
|
+
params: { type: "mouseMoved", x: 10, y: 20, button: "none" },
|
|
242
|
+
},
|
|
243
|
+
]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("propagates errors from the client", async () => {
|
|
247
|
+
const cdp = fakeCdp(() => {
|
|
248
|
+
throw new CdpError("cdp_error", "boom");
|
|
249
|
+
});
|
|
250
|
+
await expect(dispatchHoverAt(cdp, { x: 10, y: 20 })).rejects.toMatchObject({
|
|
251
|
+
name: "CdpError",
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ── dispatchInsertText ────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
describe("dispatchInsertText", () => {
|
|
259
|
+
test("sends a single Input.insertText with the expected text", async () => {
|
|
260
|
+
const cdp = fakeCdp(() => ({}));
|
|
261
|
+
|
|
262
|
+
await dispatchInsertText(cdp, "hello world");
|
|
263
|
+
|
|
264
|
+
expect(cdp.calls).toEqual([
|
|
265
|
+
{ method: "Input.insertText", params: { text: "hello world" } },
|
|
266
|
+
]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("propagates errors from the client", async () => {
|
|
270
|
+
const cdp = fakeCdp(() => {
|
|
271
|
+
throw new CdpError("cdp_error", "boom");
|
|
272
|
+
});
|
|
273
|
+
await expect(dispatchInsertText(cdp, "x")).rejects.toMatchObject({
|
|
274
|
+
name: "CdpError",
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// ── dispatchKeyPress ──────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
describe("dispatchKeyPress", () => {
|
|
282
|
+
test("Enter sends keyDown + char + keyUp with windowsVirtualKeyCode 13", async () => {
|
|
283
|
+
const cdp = fakeCdp(() => ({}));
|
|
284
|
+
|
|
285
|
+
await dispatchKeyPress(cdp, "Enter");
|
|
286
|
+
|
|
287
|
+
// Enter is text-producing (\r) so we get keyDown + char + keyUp.
|
|
288
|
+
expect(cdp.calls).toHaveLength(3);
|
|
289
|
+
for (const call of cdp.calls) {
|
|
290
|
+
expect(call.method).toBe("Input.dispatchKeyEvent");
|
|
291
|
+
const params = call.params as Record<string, unknown>;
|
|
292
|
+
expect(params.key).toBe("Enter");
|
|
293
|
+
expect(params.code).toBe("Enter");
|
|
294
|
+
expect(params.windowsVirtualKeyCode).toBe(13);
|
|
295
|
+
expect(params.text).toBe("\r");
|
|
296
|
+
}
|
|
297
|
+
expect((cdp.calls[0]!.params as Record<string, unknown>).type).toBe(
|
|
298
|
+
"keyDown",
|
|
299
|
+
);
|
|
300
|
+
expect((cdp.calls[1]!.params as Record<string, unknown>).type).toBe("char");
|
|
301
|
+
expect((cdp.calls[2]!.params as Record<string, unknown>).type).toBe(
|
|
302
|
+
"keyUp",
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("'a' sends keyCode 65, code KeyA, and a char event", async () => {
|
|
307
|
+
const cdp = fakeCdp(() => ({}));
|
|
308
|
+
|
|
309
|
+
await dispatchKeyPress(cdp, "a");
|
|
310
|
+
|
|
311
|
+
expect(cdp.calls).toHaveLength(3);
|
|
312
|
+
for (const call of cdp.calls) {
|
|
313
|
+
const params = call.params as Record<string, unknown>;
|
|
314
|
+
expect(params.key).toBe("a");
|
|
315
|
+
expect(params.code).toBe("KeyA");
|
|
316
|
+
expect(params.windowsVirtualKeyCode).toBe(65);
|
|
317
|
+
expect(params.text).toBe("a");
|
|
318
|
+
}
|
|
319
|
+
expect((cdp.calls[1]!.params as Record<string, unknown>).type).toBe("char");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("ArrowDown sends keyCode 40 and NO char event", async () => {
|
|
323
|
+
const cdp = fakeCdp(() => ({}));
|
|
324
|
+
|
|
325
|
+
await dispatchKeyPress(cdp, "ArrowDown");
|
|
326
|
+
|
|
327
|
+
// ArrowDown is non-printing → no char event (just keyDown + keyUp).
|
|
328
|
+
expect(cdp.calls).toHaveLength(2);
|
|
329
|
+
for (const call of cdp.calls) {
|
|
330
|
+
const params = call.params as Record<string, unknown>;
|
|
331
|
+
expect(params.key).toBe("ArrowDown");
|
|
332
|
+
expect(params.code).toBe("ArrowDown");
|
|
333
|
+
expect(params.windowsVirtualKeyCode).toBe(40);
|
|
334
|
+
expect(params.text).toBeUndefined();
|
|
335
|
+
}
|
|
336
|
+
expect((cdp.calls[0]!.params as Record<string, unknown>).type).toBe(
|
|
337
|
+
"keyDown",
|
|
338
|
+
);
|
|
339
|
+
expect((cdp.calls[1]!.params as Record<string, unknown>).type).toBe(
|
|
340
|
+
"keyUp",
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("digit '7' sends keyCode 55 and code Digit7", async () => {
|
|
345
|
+
const cdp = fakeCdp(() => ({}));
|
|
346
|
+
|
|
347
|
+
await dispatchKeyPress(cdp, "7");
|
|
348
|
+
|
|
349
|
+
expect(cdp.calls).toHaveLength(3);
|
|
350
|
+
const params = cdp.calls[0]!.params as Record<string, unknown>;
|
|
351
|
+
expect(params.key).toBe("7");
|
|
352
|
+
expect(params.code).toBe("Digit7");
|
|
353
|
+
expect(params.windowsVirtualKeyCode).toBe(55);
|
|
354
|
+
expect(params.text).toBe("7");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("Tab sends keyCode 9 and text '\\t'", async () => {
|
|
358
|
+
const cdp = fakeCdp(() => ({}));
|
|
359
|
+
|
|
360
|
+
await dispatchKeyPress(cdp, "Tab");
|
|
361
|
+
|
|
362
|
+
expect(cdp.calls).toHaveLength(3);
|
|
363
|
+
const params = cdp.calls[0]!.params as Record<string, unknown>;
|
|
364
|
+
expect(params.windowsVirtualKeyCode).toBe(9);
|
|
365
|
+
expect(params.text).toBe("\t");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("Escape sends keyCode 27 and NO char event", async () => {
|
|
369
|
+
const cdp = fakeCdp(() => ({}));
|
|
370
|
+
|
|
371
|
+
await dispatchKeyPress(cdp, "Escape");
|
|
372
|
+
|
|
373
|
+
expect(cdp.calls).toHaveLength(2);
|
|
374
|
+
const params = cdp.calls[0]!.params as Record<string, unknown>;
|
|
375
|
+
expect(params.windowsVirtualKeyCode).toBe(27);
|
|
376
|
+
expect(params.text).toBeUndefined();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("'Space' (Playwright convention) sends code 'Space' and keyCode 32", async () => {
|
|
380
|
+
// Playwright callers pass "Space" as the key name, but
|
|
381
|
+
// `event.key` is actually " ". Verify both invariants.
|
|
382
|
+
const cdp = fakeCdp(() => ({}));
|
|
383
|
+
|
|
384
|
+
await dispatchKeyPress(cdp, "Space");
|
|
385
|
+
|
|
386
|
+
expect(cdp.calls).toHaveLength(3);
|
|
387
|
+
for (const call of cdp.calls) {
|
|
388
|
+
const params = call.params as Record<string, unknown>;
|
|
389
|
+
expect(params.key).toBe(" ");
|
|
390
|
+
expect(params.code).toBe("Space");
|
|
391
|
+
expect(params.windowsVirtualKeyCode).toBe(32);
|
|
392
|
+
expect(params.text).toBe(" ");
|
|
393
|
+
}
|
|
394
|
+
expect((cdp.calls[1]!.params as Record<string, unknown>).type).toBe("char");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("literal ' ' also maps to code 'Space'", async () => {
|
|
398
|
+
// Passing a literal space character should produce the same
|
|
399
|
+
// event shape as "Space" — not fall through to the generic
|
|
400
|
+
// printable-ASCII path which emits `code: ""`.
|
|
401
|
+
const cdp = fakeCdp(() => ({}));
|
|
402
|
+
|
|
403
|
+
await dispatchKeyPress(cdp, " ");
|
|
404
|
+
|
|
405
|
+
const params = cdp.calls[0]!.params as Record<string, unknown>;
|
|
406
|
+
expect(params.code).toBe("Space");
|
|
407
|
+
expect(params.windowsVirtualKeyCode).toBe(32);
|
|
408
|
+
expect(params.text).toBe(" ");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("Home/End/PageUp/PageDown send the right keyCodes (no char event)", async () => {
|
|
412
|
+
const cases: Array<[string, number]> = [
|
|
413
|
+
["Home", 36],
|
|
414
|
+
["End", 35],
|
|
415
|
+
["PageUp", 33],
|
|
416
|
+
["PageDown", 34],
|
|
417
|
+
];
|
|
418
|
+
for (const [key, expectedKeyCode] of cases) {
|
|
419
|
+
const cdp = fakeCdp(() => ({}));
|
|
420
|
+
await dispatchKeyPress(cdp, key);
|
|
421
|
+
// Navigation keys are non-printing → keyDown + keyUp only.
|
|
422
|
+
expect(cdp.calls).toHaveLength(2);
|
|
423
|
+
const params = cdp.calls[0]!.params as Record<string, unknown>;
|
|
424
|
+
expect(params.key).toBe(key);
|
|
425
|
+
expect(params.code).toBe(key);
|
|
426
|
+
expect(params.windowsVirtualKeyCode).toBe(expectedKeyCode);
|
|
427
|
+
expect(params.text).toBeUndefined();
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("Insert sends keyCode 45 and no char event", async () => {
|
|
432
|
+
const cdp = fakeCdp(() => ({}));
|
|
433
|
+
await dispatchKeyPress(cdp, "Insert");
|
|
434
|
+
expect(cdp.calls).toHaveLength(2);
|
|
435
|
+
const params = cdp.calls[0]!.params as Record<string, unknown>;
|
|
436
|
+
expect(params.windowsVirtualKeyCode).toBe(45);
|
|
437
|
+
expect(params.text).toBeUndefined();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("F1-F12 send the right Windows virtual keyCodes", async () => {
|
|
441
|
+
const cases: Array<[string, number]> = [
|
|
442
|
+
["F1", 112],
|
|
443
|
+
["F2", 113],
|
|
444
|
+
["F3", 114],
|
|
445
|
+
["F4", 115],
|
|
446
|
+
["F5", 116],
|
|
447
|
+
["F6", 117],
|
|
448
|
+
["F7", 118],
|
|
449
|
+
["F8", 119],
|
|
450
|
+
["F9", 120],
|
|
451
|
+
["F10", 121],
|
|
452
|
+
["F11", 122],
|
|
453
|
+
["F12", 123],
|
|
454
|
+
];
|
|
455
|
+
for (const [key, expectedKeyCode] of cases) {
|
|
456
|
+
const cdp = fakeCdp(() => ({}));
|
|
457
|
+
await dispatchKeyPress(cdp, key);
|
|
458
|
+
expect(cdp.calls).toHaveLength(2);
|
|
459
|
+
const params = cdp.calls[0]!.params as Record<string, unknown>;
|
|
460
|
+
expect(params.key).toBe(key);
|
|
461
|
+
expect(params.code).toBe(key);
|
|
462
|
+
expect(params.windowsVirtualKeyCode).toBe(expectedKeyCode);
|
|
463
|
+
expect(params.text).toBeUndefined();
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("unknown multi-character key falls back to minimal payload", async () => {
|
|
468
|
+
const cdp = fakeCdp(() => ({}));
|
|
469
|
+
// Suppress the console.warn for the duration of the call. F19
|
|
470
|
+
// is not in the static map and cannot be derived dynamically.
|
|
471
|
+
const originalWarn = console.warn;
|
|
472
|
+
console.warn = () => {};
|
|
473
|
+
try {
|
|
474
|
+
await dispatchKeyPress(cdp, "F19");
|
|
475
|
+
} finally {
|
|
476
|
+
console.warn = originalWarn;
|
|
477
|
+
}
|
|
478
|
+
expect(cdp.calls).toHaveLength(2);
|
|
479
|
+
expect(cdp.calls[0]!.params).toEqual({ type: "keyDown", key: "F19" });
|
|
480
|
+
expect(cdp.calls[1]!.params).toEqual({ type: "keyUp", key: "F19" });
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("propagates errors from the client", async () => {
|
|
484
|
+
const cdp = fakeCdp(() => {
|
|
485
|
+
throw new CdpError("cdp_error", "boom");
|
|
486
|
+
});
|
|
487
|
+
await expect(dispatchKeyPress(cdp, "a")).rejects.toMatchObject({
|
|
488
|
+
name: "CdpError",
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// ── dispatchWheelScroll ───────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
describe("dispatchWheelScroll", () => {
|
|
496
|
+
test("emits a mouseWheel event with the requested delta", async () => {
|
|
497
|
+
const cdp = fakeCdp(() => ({}));
|
|
498
|
+
|
|
499
|
+
await dispatchWheelScroll(
|
|
500
|
+
cdp,
|
|
501
|
+
{ x: 100, y: 200 },
|
|
502
|
+
{ deltaX: 0, deltaY: 500 },
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
expect(cdp.calls).toEqual([
|
|
506
|
+
{
|
|
507
|
+
method: "Input.dispatchMouseEvent",
|
|
508
|
+
params: {
|
|
509
|
+
type: "mouseWheel",
|
|
510
|
+
x: 100,
|
|
511
|
+
y: 200,
|
|
512
|
+
deltaX: 0,
|
|
513
|
+
deltaY: 500,
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
]);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("propagates errors from the client", async () => {
|
|
520
|
+
const cdp = fakeCdp(() => {
|
|
521
|
+
throw new CdpError("transport_error", "boom");
|
|
522
|
+
});
|
|
523
|
+
await expect(
|
|
524
|
+
dispatchWheelScroll(cdp, { x: 0, y: 0 }, { deltaX: 0, deltaY: 10 }),
|
|
525
|
+
).rejects.toMatchObject({ name: "CdpError", code: "transport_error" });
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// ── getCurrentUrl ─────────────────────────────────────────────────────
|
|
530
|
+
|
|
531
|
+
describe("getCurrentUrl", () => {
|
|
532
|
+
test("returns the result.value from Runtime.evaluate", async () => {
|
|
533
|
+
const cdp = fakeCdp((method, params) => {
|
|
534
|
+
expect(method).toBe("Runtime.evaluate");
|
|
535
|
+
expect(params).toEqual({
|
|
536
|
+
expression: "document.location.href",
|
|
537
|
+
returnByValue: true,
|
|
538
|
+
});
|
|
539
|
+
return { result: { value: "https://example.com/" } };
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const url = await getCurrentUrl(cdp);
|
|
543
|
+
expect(url).toBe("https://example.com/");
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test("propagates errors from the client", async () => {
|
|
547
|
+
const cdp = fakeCdp(() => {
|
|
548
|
+
throw new CdpError("transport_error", "boom");
|
|
549
|
+
});
|
|
550
|
+
await expect(getCurrentUrl(cdp)).rejects.toMatchObject({
|
|
551
|
+
name: "CdpError",
|
|
552
|
+
code: "transport_error",
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// ── getPageTitle ──────────────────────────────────────────────────────
|
|
558
|
+
|
|
559
|
+
describe("getPageTitle", () => {
|
|
560
|
+
test("returns the result.value from Runtime.evaluate", async () => {
|
|
561
|
+
const cdp = fakeCdp((method, params) => {
|
|
562
|
+
expect(method).toBe("Runtime.evaluate");
|
|
563
|
+
expect(params).toEqual({
|
|
564
|
+
expression: "document.title",
|
|
565
|
+
returnByValue: true,
|
|
566
|
+
});
|
|
567
|
+
return { result: { value: "My Page" } };
|
|
568
|
+
});
|
|
569
|
+
expect(await getPageTitle(cdp)).toBe("My Page");
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("returns empty string when result value is missing", async () => {
|
|
573
|
+
const cdp = fakeCdp(() => ({ result: { value: undefined } }));
|
|
574
|
+
expect(await getPageTitle(cdp)).toBe("");
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("propagates errors from the client", async () => {
|
|
578
|
+
const cdp = fakeCdp(() => {
|
|
579
|
+
throw new CdpError("transport_error", "boom");
|
|
580
|
+
});
|
|
581
|
+
await expect(getPageTitle(cdp)).rejects.toMatchObject({
|
|
582
|
+
name: "CdpError",
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// ── evaluateExpression ────────────────────────────────────────────────
|
|
588
|
+
|
|
589
|
+
describe("evaluateExpression", () => {
|
|
590
|
+
test("returns result.value on happy path with default opts", async () => {
|
|
591
|
+
const cdp = fakeCdp((method, params) => {
|
|
592
|
+
expect(method).toBe("Runtime.evaluate");
|
|
593
|
+
expect(params).toEqual({
|
|
594
|
+
expression: "1 + 2",
|
|
595
|
+
returnByValue: true,
|
|
596
|
+
awaitPromise: true,
|
|
597
|
+
userGesture: true,
|
|
598
|
+
});
|
|
599
|
+
return { result: { value: 3 } };
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const value = await evaluateExpression<number>(cdp, "1 + 2");
|
|
603
|
+
expect(value).toBe(3);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("honors awaitPromise: false override", async () => {
|
|
607
|
+
const cdp = fakeCdp(() => ({ result: { value: "ok" } }));
|
|
608
|
+
await evaluateExpression<string>(cdp, "'ok'", { awaitPromise: false });
|
|
609
|
+
expect(cdp.calls[0]!.params).toMatchObject({
|
|
610
|
+
awaitPromise: false,
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("throws CdpError when exceptionDetails is present", async () => {
|
|
615
|
+
const cdp = fakeCdp(() => ({
|
|
616
|
+
result: { value: undefined },
|
|
617
|
+
exceptionDetails: {
|
|
618
|
+
text: "Uncaught",
|
|
619
|
+
exception: { description: "ReferenceError: foo is not defined" },
|
|
620
|
+
},
|
|
621
|
+
}));
|
|
622
|
+
|
|
623
|
+
await expect(evaluateExpression(cdp, "foo")).rejects.toMatchObject({
|
|
624
|
+
name: "CdpError",
|
|
625
|
+
code: "cdp_error",
|
|
626
|
+
message: "ReferenceError: foo is not defined",
|
|
627
|
+
cdpMethod: "Runtime.evaluate",
|
|
628
|
+
cdpParams: { expression: "foo" },
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("falls back to exceptionDetails.text if no description", async () => {
|
|
633
|
+
const cdp = fakeCdp(() => ({
|
|
634
|
+
result: { value: undefined },
|
|
635
|
+
exceptionDetails: { text: "Uncaught SyntaxError" },
|
|
636
|
+
}));
|
|
637
|
+
await expect(evaluateExpression(cdp, "???")).rejects.toMatchObject({
|
|
638
|
+
message: "Uncaught SyntaxError",
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// ── captureScreenshotJpeg ─────────────────────────────────────────────
|
|
644
|
+
|
|
645
|
+
describe("captureScreenshotJpeg", () => {
|
|
646
|
+
test("returns a Buffer with decoded bytes", async () => {
|
|
647
|
+
const rawBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); // JPEG SOI + APP0
|
|
648
|
+
const base64 = rawBytes.toString("base64");
|
|
649
|
+
|
|
650
|
+
const cdp = fakeCdp((method, params) => {
|
|
651
|
+
expect(method).toBe("Page.captureScreenshot");
|
|
652
|
+
expect(params).toEqual({
|
|
653
|
+
format: "jpeg",
|
|
654
|
+
quality: 80,
|
|
655
|
+
captureBeyondViewport: false,
|
|
656
|
+
});
|
|
657
|
+
return { data: base64 };
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
const buf = await captureScreenshotJpeg(cdp);
|
|
661
|
+
expect(Buffer.isBuffer(buf)).toBe(true);
|
|
662
|
+
expect(buf.equals(rawBytes)).toBe(true);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test("forwards quality + fullPage options", async () => {
|
|
666
|
+
const cdp = fakeCdp(() => ({ data: "" }));
|
|
667
|
+
await captureScreenshotJpeg(cdp, { quality: 50, fullPage: true });
|
|
668
|
+
expect(cdp.calls[0]!.params).toEqual({
|
|
669
|
+
format: "jpeg",
|
|
670
|
+
quality: 50,
|
|
671
|
+
captureBeyondViewport: true,
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test("propagates errors from the client", async () => {
|
|
676
|
+
const cdp = fakeCdp(() => {
|
|
677
|
+
throw new CdpError("transport_error", "boom");
|
|
678
|
+
});
|
|
679
|
+
await expect(captureScreenshotJpeg(cdp)).rejects.toMatchObject({
|
|
680
|
+
name: "CdpError",
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// ── navigateAndWait ───────────────────────────────────────────────────
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Build a programmable fake for `navigateAndWait` tests. Handles the
|
|
689
|
+
* standard shape: pre-nav `document.location.href` read, then
|
|
690
|
+
* `Page.navigate`, then any number of combined `readyState + href`
|
|
691
|
+
* poll evaluations.
|
|
692
|
+
*
|
|
693
|
+
* The `poll` handler is invoked once per combined readyState/href
|
|
694
|
+
* evaluate and must return `{ readyState, href }` (to continue the
|
|
695
|
+
* poll) or throw (to simulate a transient CDP error).
|
|
696
|
+
*/
|
|
697
|
+
function fakeNavCdp(opts: {
|
|
698
|
+
urlBeforeNav?: string;
|
|
699
|
+
navResponse?: { frameId?: string; errorText?: string };
|
|
700
|
+
poll: (
|
|
701
|
+
callIndex: number,
|
|
702
|
+
) => { readyState: string; href: string } | { throwError: CdpError };
|
|
703
|
+
onNavigate?: () => void;
|
|
704
|
+
}) {
|
|
705
|
+
let pollCalls = 0;
|
|
706
|
+
return fakeCdp((method, params) => {
|
|
707
|
+
if (method === "Page.navigate") {
|
|
708
|
+
opts.onNavigate?.();
|
|
709
|
+
return opts.navResponse ?? {};
|
|
710
|
+
}
|
|
711
|
+
if (method === "Runtime.evaluate") {
|
|
712
|
+
const expr = (params as { expression: string }).expression;
|
|
713
|
+
if (expr === "document.location.href") {
|
|
714
|
+
return { result: { value: opts.urlBeforeNav ?? "about:blank" } };
|
|
715
|
+
}
|
|
716
|
+
if (expr === "document.title") {
|
|
717
|
+
return { result: { value: "" } };
|
|
718
|
+
}
|
|
719
|
+
if (expr.includes("readyState") && expr.includes("href")) {
|
|
720
|
+
const res = opts.poll(pollCalls++);
|
|
721
|
+
if ("throwError" in res) throw res.throwError;
|
|
722
|
+
return { result: { value: res } };
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
throw new Error(
|
|
726
|
+
`unexpected: ${method} ${JSON.stringify((params as Record<string, unknown>)?.expression ?? params)}`,
|
|
727
|
+
);
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
describe("navigateAndWait", () => {
|
|
732
|
+
test("calls Page.navigate and returns finalUrl once readyState is complete and URL has committed", async () => {
|
|
733
|
+
const cdp = fakeNavCdp({
|
|
734
|
+
urlBeforeNav: "https://example.com/start",
|
|
735
|
+
poll: () => ({
|
|
736
|
+
readyState: "complete",
|
|
737
|
+
href: "https://example.com/final",
|
|
738
|
+
}),
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
const result = await navigateAndWait(cdp, "https://example.com/target", {
|
|
742
|
+
timeoutMs: 5_000,
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
expect(result).toEqual({
|
|
746
|
+
finalUrl: "https://example.com/final",
|
|
747
|
+
timedOut: false,
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
const navigateCalls = cdp.calls.filter((c) => c.method === "Page.navigate");
|
|
751
|
+
expect(navigateCalls).toHaveLength(1);
|
|
752
|
+
expect(navigateCalls[0]!.params).toEqual({
|
|
753
|
+
url: "https://example.com/target",
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test("resolves when readyState becomes interactive (not just complete)", async () => {
|
|
758
|
+
const cdp = fakeNavCdp({
|
|
759
|
+
urlBeforeNav: "https://prev",
|
|
760
|
+
poll: () => ({ readyState: "interactive", href: "https://x" }),
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
const result = await navigateAndWait(cdp, "https://x", {
|
|
764
|
+
timeoutMs: 5_000,
|
|
765
|
+
});
|
|
766
|
+
expect(result.timedOut).toBe(false);
|
|
767
|
+
expect(result.finalUrl).toBe("https://x");
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
test("returns timedOut: true when readyState never becomes ready", async () => {
|
|
771
|
+
const cdp = fakeNavCdp({
|
|
772
|
+
urlBeforeNav: "about:blank",
|
|
773
|
+
poll: () => ({ readyState: "loading", href: "https://slow" }),
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
// Use a tiny timeout so the test finishes quickly.
|
|
777
|
+
const result = await navigateAndWait(cdp, "https://slow", {
|
|
778
|
+
timeoutMs: 50,
|
|
779
|
+
});
|
|
780
|
+
expect(result.timedOut).toBe(true);
|
|
781
|
+
// finalUrl should come from the in-loop observations (the last
|
|
782
|
+
// href we successfully saw), not a post-loop fresh read.
|
|
783
|
+
expect(result.finalUrl).toBe("https://slow");
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test("throws CdpError with code 'aborted' when signal fires", async () => {
|
|
787
|
+
const controller = new AbortController();
|
|
788
|
+
const cdp = fakeNavCdp({
|
|
789
|
+
urlBeforeNav: "about:blank",
|
|
790
|
+
onNavigate: () => controller.abort(),
|
|
791
|
+
poll: () => ({ readyState: "loading", href: "https://x" }),
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
await expect(
|
|
795
|
+
navigateAndWait(
|
|
796
|
+
cdp,
|
|
797
|
+
"https://x",
|
|
798
|
+
{ timeoutMs: 5_000 },
|
|
799
|
+
controller.signal,
|
|
800
|
+
),
|
|
801
|
+
).rejects.toMatchObject({
|
|
802
|
+
name: "CdpError",
|
|
803
|
+
code: "aborted",
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test("throws CdpError when Page.navigate returns errorText", async () => {
|
|
808
|
+
// CDP signals DNS / connection errors via `errorText` rather than
|
|
809
|
+
// throwing — navigateAndWait must surface this instead of polling
|
|
810
|
+
// the OLD page's readyState (which is "complete") and reporting
|
|
811
|
+
// success with the stale URL.
|
|
812
|
+
const cdp = fakeNavCdp({
|
|
813
|
+
urlBeforeNav: "about:blank",
|
|
814
|
+
navResponse: {
|
|
815
|
+
frameId: "f1",
|
|
816
|
+
errorText: "net::ERR_CONNECTION_REFUSED",
|
|
817
|
+
},
|
|
818
|
+
poll: () => ({ readyState: "complete", href: "should not be read" }),
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
await expect(
|
|
822
|
+
navigateAndWait(cdp, "https://nope.invalid", { timeoutMs: 5_000 }),
|
|
823
|
+
).rejects.toMatchObject({
|
|
824
|
+
name: "CdpError",
|
|
825
|
+
code: "cdp_error",
|
|
826
|
+
message: "net::ERR_CONNECTION_REFUSED",
|
|
827
|
+
cdpMethod: "Page.navigate",
|
|
828
|
+
cdpParams: { url: "https://nope.invalid" },
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
// Should NOT have polled readyState (only the pre-nav
|
|
832
|
+
// `document.location.href` read is allowed before the navigate
|
|
833
|
+
// attempt).
|
|
834
|
+
const pollEvals = cdp.calls.filter((c) => {
|
|
835
|
+
if (c.method !== "Runtime.evaluate") return false;
|
|
836
|
+
const expr = (c.params as { expression?: string } | undefined)
|
|
837
|
+
?.expression;
|
|
838
|
+
return typeof expr === "string" && expr.includes("readyState");
|
|
839
|
+
});
|
|
840
|
+
expect(pollEvals).toHaveLength(0);
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
test("waits for URL to commit before accepting readyState=complete (same-origin race)", async () => {
|
|
844
|
+
// Reproduces the bug where a same-origin navigation resolves
|
|
845
|
+
// `Page.navigate` but the polling loop sees the OLD page's
|
|
846
|
+
// "complete" readyState and the OLD URL for the first few polls
|
|
847
|
+
// before the new document commits.
|
|
848
|
+
//
|
|
849
|
+
// First 3 polls: old page's "complete" + old URL.
|
|
850
|
+
// Fourth poll: new document fully committed.
|
|
851
|
+
const cdp = fakeNavCdp({
|
|
852
|
+
urlBeforeNav: "https://example.com/page1",
|
|
853
|
+
poll: (callIndex) => {
|
|
854
|
+
if (callIndex < 3) {
|
|
855
|
+
return {
|
|
856
|
+
readyState: "complete",
|
|
857
|
+
href: "https://example.com/page1",
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
return { readyState: "complete", href: "https://example.com/page2" };
|
|
861
|
+
},
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
const result = await navigateAndWait(cdp, "https://example.com/page2", {
|
|
865
|
+
timeoutMs: 5_000,
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
expect(result).toEqual({
|
|
869
|
+
finalUrl: "https://example.com/page2",
|
|
870
|
+
timedOut: false,
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
// The loop must have polled at least 4 times — proof that it
|
|
874
|
+
// did not break on the first "complete" observation against the
|
|
875
|
+
// old URL.
|
|
876
|
+
const pollEvals = cdp.calls.filter(
|
|
877
|
+
(c) =>
|
|
878
|
+
c.method === "Runtime.evaluate" &&
|
|
879
|
+
typeof (c.params as { expression?: string })?.expression === "string" &&
|
|
880
|
+
(c.params as { expression: string }).expression.includes("readyState"),
|
|
881
|
+
);
|
|
882
|
+
expect(pollEvals.length).toBeGreaterThanOrEqual(4);
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
test("retries on transient CdpError during context transition", async () => {
|
|
886
|
+
// After `Page.navigate`, `Runtime.evaluate` can fail transiently
|
|
887
|
+
// while the old execution context is torn down and before the
|
|
888
|
+
// new one is created. navigateAndWait must catch that error and
|
|
889
|
+
// keep polling.
|
|
890
|
+
const cdp = fakeNavCdp({
|
|
891
|
+
urlBeforeNav: "https://prev",
|
|
892
|
+
poll: (callIndex) => {
|
|
893
|
+
if (callIndex === 0) {
|
|
894
|
+
return {
|
|
895
|
+
throwError: new CdpError(
|
|
896
|
+
"cdp_error",
|
|
897
|
+
"Execution context was destroyed.",
|
|
898
|
+
),
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
if (callIndex === 1) {
|
|
902
|
+
return {
|
|
903
|
+
throwError: new CdpError(
|
|
904
|
+
"cdp_error",
|
|
905
|
+
"Cannot find context with specified id",
|
|
906
|
+
),
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
return { readyState: "complete", href: "https://new" };
|
|
910
|
+
},
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
const result = await navigateAndWait(cdp, "https://new", {
|
|
914
|
+
timeoutMs: 5_000,
|
|
915
|
+
});
|
|
916
|
+
expect(result).toEqual({
|
|
917
|
+
finalUrl: "https://new",
|
|
918
|
+
timedOut: false,
|
|
919
|
+
});
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
test("same-URL reload falls back to readyState-only polling", async () => {
|
|
923
|
+
// When the target URL matches the pre-nav URL (e.g. a reload),
|
|
924
|
+
// there's no URL-change signal to wait for. The commit check
|
|
925
|
+
// must fall back to readyState-only so reloads don't loop
|
|
926
|
+
// forever.
|
|
927
|
+
const cdp = fakeNavCdp({
|
|
928
|
+
urlBeforeNav: "https://example.com/same",
|
|
929
|
+
poll: () => ({
|
|
930
|
+
readyState: "complete",
|
|
931
|
+
href: "https://example.com/same",
|
|
932
|
+
}),
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
const result = await navigateAndWait(cdp, "https://example.com/same", {
|
|
936
|
+
timeoutMs: 5_000,
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
expect(result).toEqual({
|
|
940
|
+
finalUrl: "https://example.com/same",
|
|
941
|
+
timedOut: false,
|
|
942
|
+
});
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
test("falls back to readyState-only when pre-nav URL read fails", async () => {
|
|
946
|
+
// If we can't read the pre-nav URL (e.g. fresh about:blank with
|
|
947
|
+
// no Runtime context), commit detection has no baseline and must
|
|
948
|
+
// fall back to readyState-only.
|
|
949
|
+
let sawPreNavRead = false;
|
|
950
|
+
const cdp = fakeCdp((method, params) => {
|
|
951
|
+
if (method === "Page.navigate") return {};
|
|
952
|
+
if (method === "Runtime.evaluate") {
|
|
953
|
+
const expr = (params as { expression: string }).expression;
|
|
954
|
+
if (expr === "document.location.href" && !sawPreNavRead) {
|
|
955
|
+
sawPreNavRead = true;
|
|
956
|
+
throw new CdpError("cdp_error", "no context");
|
|
957
|
+
}
|
|
958
|
+
if (expr.includes("readyState") && expr.includes("href")) {
|
|
959
|
+
return {
|
|
960
|
+
result: { value: { readyState: "complete", href: "https://x" } },
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
throw new Error(
|
|
965
|
+
`unexpected: ${method} ${JSON.stringify((params as Record<string, unknown>)?.expression ?? params)}`,
|
|
966
|
+
);
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
const result = await navigateAndWait(cdp, "https://x", {
|
|
970
|
+
timeoutMs: 5_000,
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
expect(result).toEqual({
|
|
974
|
+
finalUrl: "https://x",
|
|
975
|
+
timedOut: false,
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
test("reports last observed href on timeout instead of racing a post-loop read", async () => {
|
|
980
|
+
// If polling never reaches readyState-ready + committed, the
|
|
981
|
+
// final URL should come from the last in-loop observation (so
|
|
982
|
+
// the caller doesn't race the commit window again via a
|
|
983
|
+
// post-loop `getCurrentUrl`).
|
|
984
|
+
const observedHrefs: string[] = [];
|
|
985
|
+
const cdp = fakeNavCdp({
|
|
986
|
+
urlBeforeNav: "https://pre",
|
|
987
|
+
poll: (callIndex) => {
|
|
988
|
+
// Keep returning "loading" but advance the URL so we can
|
|
989
|
+
// verify the fallback uses the last observation.
|
|
990
|
+
const href = `https://loading/${callIndex}`;
|
|
991
|
+
observedHrefs.push(href);
|
|
992
|
+
return { readyState: "loading", href };
|
|
993
|
+
},
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
const result = await navigateAndWait(cdp, "https://target", {
|
|
997
|
+
timeoutMs: 50,
|
|
998
|
+
});
|
|
999
|
+
expect(result.timedOut).toBe(true);
|
|
1000
|
+
// finalUrl should match the last href we returned from the poll
|
|
1001
|
+
// handler (not the pre-nav URL, and not a separately-read value).
|
|
1002
|
+
expect(observedHrefs.length).toBeGreaterThan(0);
|
|
1003
|
+
expect(result.finalUrl).toBe(observedHrefs[observedHrefs.length - 1]!);
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
test("re-throws non-CdpError exceptions from the polling evaluate", async () => {
|
|
1007
|
+
// Only CdpErrors are retry-worthy; unexpected JS errors should
|
|
1008
|
+
// propagate so they're not swallowed.
|
|
1009
|
+
const cdp = fakeNavCdp({
|
|
1010
|
+
urlBeforeNav: "https://pre",
|
|
1011
|
+
poll: () => {
|
|
1012
|
+
throw new Error("unexpected programmer error");
|
|
1013
|
+
},
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
await expect(
|
|
1017
|
+
navigateAndWait(cdp, "https://target", { timeoutMs: 5_000 }),
|
|
1018
|
+
).rejects.toThrow("unexpected programmer error");
|
|
1019
|
+
});
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
// ── waitForSelector ───────────────────────────────────────────────────
|
|
1023
|
+
|
|
1024
|
+
describe("waitForSelector", () => {
|
|
1025
|
+
test("resolves when the selector appears on the 2nd poll (default visible state)", async () => {
|
|
1026
|
+
let evalCount = 0;
|
|
1027
|
+
let lastExpression = "";
|
|
1028
|
+
const cdp = fakeCdp((method, params) => {
|
|
1029
|
+
if (method === "Runtime.evaluate") {
|
|
1030
|
+
evalCount++;
|
|
1031
|
+
lastExpression = (params as { expression: string }).expression;
|
|
1032
|
+
// First poll: not present. Second poll: present.
|
|
1033
|
+
return { result: { value: evalCount >= 2 } };
|
|
1034
|
+
}
|
|
1035
|
+
if (method === "DOM.getDocument") return { root: { nodeId: 1 } };
|
|
1036
|
+
if (method === "DOM.querySelector") return { nodeId: 9 };
|
|
1037
|
+
if (method === "DOM.describeNode")
|
|
1038
|
+
return { node: { backendNodeId: 321 } };
|
|
1039
|
+
throw new Error(`unexpected: ${method}`);
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
const backendNodeId = await waitForSelector(cdp, "#ready", 5_000);
|
|
1043
|
+
expect(backendNodeId).toBe(321);
|
|
1044
|
+
expect(evalCount).toBeGreaterThanOrEqual(2);
|
|
1045
|
+
// Default state is "visible" — the polling expression must check
|
|
1046
|
+
// bounding box + display + visibility, not just `!== null`.
|
|
1047
|
+
expect(lastExpression).toContain("getBoundingClientRect");
|
|
1048
|
+
expect(lastExpression).toContain("display");
|
|
1049
|
+
expect(lastExpression).toContain("visibility");
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
test("with state: 'attached' polls DOM existence only", async () => {
|
|
1053
|
+
let evalCount = 0;
|
|
1054
|
+
let lastExpression = "";
|
|
1055
|
+
const cdp = fakeCdp((method, params) => {
|
|
1056
|
+
if (method === "Runtime.evaluate") {
|
|
1057
|
+
evalCount++;
|
|
1058
|
+
lastExpression = (params as { expression: string }).expression;
|
|
1059
|
+
return { result: { value: true } };
|
|
1060
|
+
}
|
|
1061
|
+
if (method === "DOM.getDocument") return { root: { nodeId: 1 } };
|
|
1062
|
+
if (method === "DOM.querySelector") return { nodeId: 9 };
|
|
1063
|
+
if (method === "DOM.describeNode")
|
|
1064
|
+
return { node: { backendNodeId: 555 } };
|
|
1065
|
+
throw new Error(`unexpected: ${method}`);
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
const backendNodeId = await waitForSelector(
|
|
1069
|
+
cdp,
|
|
1070
|
+
"#exists",
|
|
1071
|
+
5_000,
|
|
1072
|
+
undefined,
|
|
1073
|
+
{ state: "attached" },
|
|
1074
|
+
);
|
|
1075
|
+
expect(backendNodeId).toBe(555);
|
|
1076
|
+
expect(evalCount).toBeGreaterThanOrEqual(1);
|
|
1077
|
+
// Attached state must use the simple `!== null` check, not the
|
|
1078
|
+
// bounding-box / computed-style probe.
|
|
1079
|
+
expect(lastExpression).toBe(`document.querySelector("#exists") !== null`);
|
|
1080
|
+
expect(lastExpression).not.toContain("getBoundingClientRect");
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
test("default state polls until the visible-state probe returns true", async () => {
|
|
1084
|
+
let evalCount = 0;
|
|
1085
|
+
const cdp = fakeCdp((method, params) => {
|
|
1086
|
+
if (method === "Runtime.evaluate") {
|
|
1087
|
+
evalCount++;
|
|
1088
|
+
const expression = (params as { expression: string }).expression;
|
|
1089
|
+
// Sanity-check: the polling expression must be the visible
|
|
1090
|
+
// probe, not the simple existence check.
|
|
1091
|
+
expect(expression).toContain("getBoundingClientRect");
|
|
1092
|
+
// Element exists in DOM but isn't yet visible until the third
|
|
1093
|
+
// poll.
|
|
1094
|
+
return { result: { value: evalCount >= 3 } };
|
|
1095
|
+
}
|
|
1096
|
+
if (method === "DOM.getDocument") return { root: { nodeId: 1 } };
|
|
1097
|
+
if (method === "DOM.querySelector") return { nodeId: 9 };
|
|
1098
|
+
if (method === "DOM.describeNode")
|
|
1099
|
+
return { node: { backendNodeId: 999 } };
|
|
1100
|
+
throw new Error(`unexpected: ${method}`);
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
const backendNodeId = await waitForSelector(cdp, "#hydrating", 5_000);
|
|
1104
|
+
expect(backendNodeId).toBe(999);
|
|
1105
|
+
expect(evalCount).toBeGreaterThanOrEqual(3);
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
test("throws CdpError on timeout", async () => {
|
|
1109
|
+
const cdp = fakeCdp((method) => {
|
|
1110
|
+
if (method === "Runtime.evaluate") return { result: { value: false } };
|
|
1111
|
+
throw new Error(`unexpected: ${method}`);
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
await expect(waitForSelector(cdp, "#nope", 50)).rejects.toMatchObject({
|
|
1115
|
+
name: "CdpError",
|
|
1116
|
+
code: "cdp_error",
|
|
1117
|
+
message: "Timed out waiting for #nope",
|
|
1118
|
+
});
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
test("throws CdpError with code 'aborted' when signal fires", async () => {
|
|
1122
|
+
const controller = new AbortController();
|
|
1123
|
+
controller.abort();
|
|
1124
|
+
const cdp = fakeCdp(() => ({ result: { value: false } }));
|
|
1125
|
+
await expect(
|
|
1126
|
+
waitForSelector(cdp, "#x", 5_000, controller.signal),
|
|
1127
|
+
).rejects.toMatchObject({
|
|
1128
|
+
name: "CdpError",
|
|
1129
|
+
code: "aborted",
|
|
1130
|
+
});
|
|
1131
|
+
});
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
// ── waitForText ───────────────────────────────────────────────────────
|
|
1135
|
+
|
|
1136
|
+
describe("waitForText", () => {
|
|
1137
|
+
test("resolves when the text is found", async () => {
|
|
1138
|
+
let count = 0;
|
|
1139
|
+
const cdp = fakeCdp((method) => {
|
|
1140
|
+
if (method === "Runtime.evaluate") {
|
|
1141
|
+
count++;
|
|
1142
|
+
return { result: { value: count >= 2 } };
|
|
1143
|
+
}
|
|
1144
|
+
throw new Error(`unexpected: ${method}`);
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
await waitForText(cdp, "hello", 5_000);
|
|
1148
|
+
expect(count).toBeGreaterThanOrEqual(2);
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
test("throws CdpError on timeout", async () => {
|
|
1152
|
+
const cdp = fakeCdp((method) => {
|
|
1153
|
+
if (method === "Runtime.evaluate") return { result: { value: false } };
|
|
1154
|
+
throw new Error(`unexpected: ${method}`);
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
await expect(waitForText(cdp, "never-here", 50)).rejects.toMatchObject({
|
|
1158
|
+
name: "CdpError",
|
|
1159
|
+
code: "cdp_error",
|
|
1160
|
+
message: "Timed out waiting for text: never-here",
|
|
1161
|
+
});
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
test("throws CdpError with code 'aborted' when signal fires", async () => {
|
|
1165
|
+
const controller = new AbortController();
|
|
1166
|
+
controller.abort();
|
|
1167
|
+
const cdp = fakeCdp(() => ({ result: { value: false } }));
|
|
1168
|
+
await expect(
|
|
1169
|
+
waitForText(cdp, "x", 5_000, controller.signal),
|
|
1170
|
+
).rejects.toMatchObject({
|
|
1171
|
+
name: "CdpError",
|
|
1172
|
+
code: "aborted",
|
|
1173
|
+
});
|
|
1174
|
+
});
|
|
1175
|
+
});
|