mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
125
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4834d8b5fa | ||
|
|
b4006d5b85 | ||
|
|
2d1e5c6d9d | ||
|
|
282e6f5623 | ||
|
|
bd68c293b6 | ||
|
|
35a20582ad | ||
|
|
4e3365450a | ||
|
|
df5ed1a495 | ||
|
|
eefbe03258 | ||
|
|
1d275f047c | ||
|
|
388b3ae2d5 | ||
|
|
36d0d91419 | ||
|
|
bcb9beb4f8 | ||
|
|
34ccb96fd6 | ||
|
|
8dfa097e55 | ||
|
|
383e3128eb | ||
|
|
2d2636030d | ||
|
|
a28b9d55e7 | ||
|
|
c39874591f | ||
|
|
1485addfd5 | ||
|
|
90b0ff301b | ||
|
|
e74f354e90 | ||
|
|
05c036518a | ||
|
|
020fb3212e | ||
|
|
0822c53c65 | ||
|
|
4450fbe28c | ||
|
|
a3bb81bfab | ||
|
|
caea98cd52 | ||
|
|
187b088013 | ||
|
|
5046b5ab44 | ||
|
|
e8137b42cc | ||
|
|
cb5dc148d5 | ||
|
|
3c5f838c56 | ||
|
|
9337bd557b | ||
|
|
162a31ae92 | ||
|
|
2f84234a31 | ||
|
|
72c10aab75 | ||
|
|
1eeb55af02 | ||
|
|
4992a54864 | ||
|
|
8b195eb0df | ||
|
|
eae4db1729 | ||
|
|
e961395be9 | ||
|
|
a6d1d02acc | ||
|
|
5ebe609c40 | ||
|
|
321494d93f | ||
|
|
f2184e74c5 | ||
|
|
eeb633d68e | ||
|
|
6d181105f2 | ||
|
|
ebad0ed1b5 | ||
|
|
498f8fb4a7 | ||
|
|
cef59137e4 | ||
|
|
f70978bfcf | ||
|
|
1498cd425c | ||
|
|
e831d65db3 | ||
|
|
47ac947f18 | ||
|
|
69d64c4eb1 | ||
|
|
99c997d6d5 | ||
|
|
93853ebc65 | ||
|
|
248bb90a2d | ||
|
|
6e6c178434 | ||
|
|
386ef91c24 | ||
|
|
0dc11b30ea | ||
|
|
759eebd4b6 | ||
|
|
fd3ce202a1 | ||
|
|
618bd6a0df | ||
|
|
c455a09fc3 | ||
|
|
b6be356e40 | ||
|
|
945bf1523d | ||
|
|
a928a9fb76 | ||
|
|
0b2d7e95cc | ||
|
|
556bea1645 | ||
|
|
0302d5592b | ||
|
|
eb34ec0dbf | ||
|
|
43067b6bfe | ||
|
|
6cc0d38489 | ||
|
|
0b71963344 | ||
|
|
59e2c3224e | ||
|
|
d70df753f6 | ||
|
|
66ae555e40 | ||
|
|
fee7c0983c | ||
|
|
51473c01ff | ||
|
|
49744233f7 | ||
|
|
95571f8b70 | ||
|
|
8344e311fa | ||
|
|
63338cd98e | ||
|
|
bd193ceb5d | ||
|
|
5ed828736f | ||
|
|
ee620acf64 | ||
|
|
05a36075b8 | ||
|
|
1bd13d5c3c | ||
|
|
59d6533eff | ||
|
|
536d3a9844 | ||
|
|
a77353bce8 | ||
|
|
91621b2545 | ||
|
|
641d934337 | ||
|
|
a4fed136e3 | ||
|
|
3c77ef7b01 | ||
|
|
3b21f2f0a7 | ||
|
|
405433ab65 | ||
|
|
58c7f38a05 | ||
|
|
e0c88295c7 | ||
|
|
dc36e38cb9 | ||
|
|
d5de9b0154 | ||
|
|
995061514c | ||
|
|
406bb21b7e | ||
|
|
f06a514bd2 | ||
|
|
1e53ee63ae | ||
|
|
120dc441bb | ||
|
|
710b1219f4 | ||
|
|
177ecd3ebf | ||
|
|
0e6b9c4542 | ||
|
|
e9f0584af3 | ||
|
|
c48a53269e | ||
|
|
3bbd6ab19f | ||
|
|
b1b1ae1d48 | ||
|
|
8822bba9a5 | ||
|
|
c74c119a82 | ||
|
|
60fbf30e24 | ||
|
|
b801bc1f62 | ||
|
|
bf59753aa3 | ||
|
|
a86cbf724f | ||
|
|
76d447caa0 | ||
|
|
14e5bbe4fc | ||
|
|
bef722107f | ||
|
|
6800f8fdfe |
@@ -37,6 +37,35 @@ jobs:
|
|||||||
- name: Run Rust tests
|
- name: Run Rust tests
|
||||||
run: cargo test --workspace --locked
|
run: cargo test --workspace --locked
|
||||||
|
|
||||||
|
windows-daemon:
|
||||||
|
name: Windows daemon, installer and cancellation regression tests
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Run Windows named-pipe tests
|
||||||
|
run: cargo test -p bsk --lib --locked daemon::ipc::windows::tests
|
||||||
|
|
||||||
|
- name: Run Windows process liveness tests
|
||||||
|
run: cargo test -p bsk --lib --locked daemon::lockfile::tests
|
||||||
|
|
||||||
|
- name: Run Windows update tests
|
||||||
|
run: cargo test -p bsk --lib --locked cli::update::tests
|
||||||
|
|
||||||
|
- name: Run Windows update and cancellation process tests
|
||||||
|
run: cargo test -p bsk --test windows_update --test windows_parent_cancel --locked
|
||||||
|
|
||||||
|
- name: Run installer tests (Windows PowerShell 5.1)
|
||||||
|
shell: powershell
|
||||||
|
run: ./scripts/install-windows.test.ps1
|
||||||
|
|
||||||
|
- name: Run installer tests (PowerShell 7)
|
||||||
|
shell: pwsh
|
||||||
|
run: ./scripts/install-windows.test.ps1
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
name: Frontend lint, typecheck, tests, build
|
name: Frontend lint, typecheck, tests, build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -325,3 +325,180 @@ jobs:
|
|||||||
|
|
||||||
echo "Chrome Web Store submission created successfully:"
|
echo "Chrome Web Store submission created successfully:"
|
||||||
jq '{itemId, state, warningInfo}' "$PUBLISH_RESPONSE"
|
jq '{itemId, state, warningInfo}' "$PUBLISH_RESPONSE"
|
||||||
|
|
||||||
|
publish-edge-addons:
|
||||||
|
needs: [resolve, build-extension]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: edge-addons
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Download build artifact
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: extension-zip
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Validate Microsoft Edge Add-ons configuration
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
API_KEY: ${{ secrets.EDGE_ADDONS_API_KEY }}
|
||||||
|
CLIENT_ID: ${{ vars.EDGE_ADDONS_CLIENT_ID }}
|
||||||
|
PRODUCT_ID: ${{ vars.EDGE_ADDONS_PRODUCT_ID }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "$API_KEY" ]; then
|
||||||
|
echo "::error::Environment secret EDGE_ADDONS_API_KEY is not configured"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "$CLIENT_ID" ]; then
|
||||||
|
echo "::error::Environment variable EDGE_ADDONS_CLIENT_ID is not configured"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "$PRODUCT_ID" ]; then
|
||||||
|
echo "::error::Environment variable EDGE_ADDONS_PRODUCT_ID is not configured"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! [[ "$PRODUCT_ID" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then
|
||||||
|
echo "::error::EDGE_ADDONS_PRODUCT_ID must be the Partner Center product GUID, not the public Edge Add-ons extension ID"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ARCHIVE="$(find dist -maxdepth 1 -name '*.zip' -type f -print -quit)"
|
||||||
|
if [ -z "$ARCHIVE" ]; then
|
||||||
|
echo "::error::No extension zip found in the downloaded artifact"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "ARCHIVE=$ARCHIVE" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Upload and publish Microsoft Edge Add-ons extension
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
API_KEY: ${{ secrets.EDGE_ADDONS_API_KEY }}
|
||||||
|
CLIENT_ID: ${{ vars.EDGE_ADDONS_CLIENT_ID }}
|
||||||
|
PRODUCT_ID: ${{ vars.EDGE_ADDONS_PRODUCT_ID }}
|
||||||
|
RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
API_ROOT="https://api.addons.microsoftedge.microsoft.com/v1"
|
||||||
|
UPLOAD_HEADERS="$RUNNER_TEMP/edge-upload-headers.txt"
|
||||||
|
UPLOAD_RESPONSE="$RUNNER_TEMP/edge-upload-response.json"
|
||||||
|
STATUS_RESPONSE="$RUNNER_TEMP/edge-status-response.json"
|
||||||
|
PUBLISH_HEADERS="$RUNNER_TEMP/edge-publish-headers.txt"
|
||||||
|
PUBLISH_RESPONSE="$RUNNER_TEMP/edge-publish-response.json"
|
||||||
|
|
||||||
|
operation_id_from_headers() {
|
||||||
|
local headers="$1"
|
||||||
|
local location
|
||||||
|
location="$(awk '
|
||||||
|
tolower($1) == "location:" {
|
||||||
|
sub(/\r$/, "")
|
||||||
|
sub(/^[^:]*:[[:space:]]*/, "")
|
||||||
|
print
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
' "$headers")"
|
||||||
|
location="${location%%\?*}"
|
||||||
|
printf '%s' "${location##*/}"
|
||||||
|
}
|
||||||
|
|
||||||
|
poll_operation() {
|
||||||
|
local operation_name="$1"
|
||||||
|
local status_url="$2"
|
||||||
|
local status="InProgress"
|
||||||
|
local http_status
|
||||||
|
|
||||||
|
for attempt in $(seq 1 30); do
|
||||||
|
sleep 5
|
||||||
|
http_status="$(curl --silent --show-error \
|
||||||
|
--output "$STATUS_RESPONSE" \
|
||||||
|
--write-out '%{http_code}' \
|
||||||
|
--header "Authorization: ApiKey $API_KEY" \
|
||||||
|
--header "X-ClientID: $CLIENT_ID" \
|
||||||
|
"$status_url")"
|
||||||
|
if [ "$http_status" -lt 200 ] || [ "$http_status" -ge 300 ]; then
|
||||||
|
echo "::error::Microsoft Edge Add-ons $operation_name status check failed with HTTP $http_status"
|
||||||
|
jq . "$STATUS_RESPONSE" || cat "$STATUS_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
status="$(jq -r '.status // empty' "$STATUS_RESPONSE")"
|
||||||
|
echo "$operation_name status check $attempt/30: $status"
|
||||||
|
case "$status" in
|
||||||
|
Succeeded)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
InProgress)
|
||||||
|
;;
|
||||||
|
Failed)
|
||||||
|
echo "::error::Microsoft Edge Add-ons $operation_name failed"
|
||||||
|
jq . "$STATUS_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "::error::Microsoft Edge Add-ons returned an unknown $operation_name status: $status"
|
||||||
|
jq . "$STATUS_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "::error::Microsoft Edge Add-ons $operation_name did not complete after 30 checks"
|
||||||
|
jq . "$STATUS_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP_STATUS="$(curl --silent --show-error \
|
||||||
|
--output "$UPLOAD_RESPONSE" \
|
||||||
|
--dump-header "$UPLOAD_HEADERS" \
|
||||||
|
--write-out '%{http_code}' \
|
||||||
|
--request POST \
|
||||||
|
--header "Authorization: ApiKey $API_KEY" \
|
||||||
|
--header "X-ClientID: $CLIENT_ID" \
|
||||||
|
--header "Content-Type: application/zip" \
|
||||||
|
--data-binary "@$ARCHIVE" \
|
||||||
|
"$API_ROOT/products/$PRODUCT_ID/submissions/draft/package")"
|
||||||
|
if [ "$HTTP_STATUS" != "202" ]; then
|
||||||
|
echo "::error::Microsoft Edge Add-ons upload failed with HTTP $HTTP_STATUS"
|
||||||
|
jq . "$UPLOAD_RESPONSE" || cat "$UPLOAD_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
UPLOAD_OPERATION_ID="$(operation_id_from_headers "$UPLOAD_HEADERS")"
|
||||||
|
if [ -z "$UPLOAD_OPERATION_ID" ]; then
|
||||||
|
echo "::error::Microsoft Edge Add-ons upload response has no Location operation ID"
|
||||||
|
cat "$UPLOAD_HEADERS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
poll_operation \
|
||||||
|
"package upload" \
|
||||||
|
"$API_ROOT/products/$PRODUCT_ID/submissions/draft/package/operations/$UPLOAD_OPERATION_ID"
|
||||||
|
|
||||||
|
HTTP_STATUS="$(curl --silent --show-error \
|
||||||
|
--output "$PUBLISH_RESPONSE" \
|
||||||
|
--dump-header "$PUBLISH_HEADERS" \
|
||||||
|
--write-out '%{http_code}' \
|
||||||
|
--request POST \
|
||||||
|
--header "Authorization: ApiKey $API_KEY" \
|
||||||
|
--header "X-ClientID: $CLIENT_ID" \
|
||||||
|
--header "Content-Type: application/json" \
|
||||||
|
--data "{\"notes\":\"Automated BrowserSkill extension v${RELEASE_VERSION} release from GitHub Actions.\"}" \
|
||||||
|
"$API_ROOT/products/$PRODUCT_ID/submissions")"
|
||||||
|
if [ "$HTTP_STATUS" != "202" ]; then
|
||||||
|
echo "::error::Microsoft Edge Add-ons publish failed with HTTP $HTTP_STATUS"
|
||||||
|
jq . "$PUBLISH_RESPONSE" || cat "$PUBLISH_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PUBLISH_OPERATION_ID="$(operation_id_from_headers "$PUBLISH_HEADERS")"
|
||||||
|
if [ -z "$PUBLISH_OPERATION_ID" ]; then
|
||||||
|
echo "::error::Microsoft Edge Add-ons publish response has no Location operation ID"
|
||||||
|
cat "$PUBLISH_HEADERS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
poll_operation \
|
||||||
|
"submission" \
|
||||||
|
"$API_ROOT/products/$PRODUCT_ID/submissions/operations/$PUBLISH_OPERATION_ID"
|
||||||
|
|
||||||
|
echo "Microsoft Edge Add-ons submission created successfully:"
|
||||||
|
jq '{status, message, errorCode, errors}' "$STATUS_RESPONSE"
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ dist/
|
|||||||
.output/
|
.output/
|
||||||
.wxt/
|
.wxt/
|
||||||
.web-extension-id
|
.web-extension-id
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Git worktrees
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to BrowserSkill will be documented in this file.
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
||||||
|
|
||||||
|
Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number.
|
||||||
|
|
||||||
|
## [0.2.1] - 2026-09-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- DSH Plugin: clarified installation, configuration, profile restart, and upgrade instructions
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- CLI: reliable Windows self-updates, including executable replacement and daemon restart
|
||||||
|
- CLI: Windows named-pipe connection continuity and stale daemon process detection
|
||||||
|
- Windows installer: PowerShell 5.1 compatibility, path quoting, and staged executable replacement
|
||||||
|
- CLI / DSH Plugin: cancellation propagation and upload/download cleanup when the parent process exits
|
||||||
|
- Extension: tab ownership and session cleanup when agent tabs fail to open or borrowed tabs are returned or closed
|
||||||
|
- Extension: navigation recovery when other extensions restrict Chrome DevTools Protocol access
|
||||||
|
- Extension: background input filling, automatic append behavior, and verification of field values before reporting success
|
||||||
|
- Extension: match observed form state to the correct element after page changes
|
||||||
|
- Extension: UUID generation fallback for recording and overlays on insecure origins
|
||||||
|
- Browser locale matching for regional language variants
|
||||||
|
- DSH Plugin: observation state synchronization, thumbnail capture lifecycle, and stale preview cleanup
|
||||||
|
- DSH Plugin: removed the obsolete client runtime host dependency
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-09-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- File transfer: upload and download support across CLI, Extension, and DSH Plugin
|
||||||
|
- File transfer: drag-and-drop upload (`drop-to-upload`)
|
||||||
|
- VOM semantic graph, name enrichment, and hover perception modules
|
||||||
|
- VOM hover probing (opt-in via `observe` parameter)
|
||||||
|
- DSH Plugin: browser tool parity with CLI commands
|
||||||
|
- Edge Add-ons automated publishing in CI
|
||||||
|
- Protocol upgrade reminder when CLI / Extension protocol versions differ
|
||||||
|
- Browser evaluation harness (`evals/browser/`)
|
||||||
|
- Unified release script (`scripts/release.mjs`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Version scheme**: all three components now share the same semver
|
||||||
|
- VOM rendering algorithm optimizations
|
||||||
|
- Leaner SKILL.md agent instructions
|
||||||
|
- DSH Plugin: simplified browser commands
|
||||||
|
- Borrow confirmation UX — proactive focus and longer timeout
|
||||||
|
- PiP window now has a close button
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Screenshot media type detection (was hard-coded to `image/png`)
|
||||||
|
- DSH Plugin session lifecycle stability
|
||||||
|
- DSH Plugin Cordis package ID mismatch
|
||||||
|
- Observation thumbnail media type sniffing
|
||||||
|
- Upload/download race conditions and layout bypass issues
|
||||||
|
- VOM repeated name and safety policy issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Previous releases used independent version numbers per component.*
|
||||||
|
|
||||||
|
## CLI 0.1.11 / Extension 0.1.7 / DSH Plugin 0.1.2 — 2026-08-26 ~ 2026-08-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- DSH Plugin sidebar integration, session lifecycle, and archive cleanup
|
||||||
|
- Recorder iframe and OOPIF support
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- VOM functional refactor
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Recorder safety policy and bug fixes
|
||||||
|
|
||||||
|
## CLI 0.1.10 / Extension 0.1.6 — 2026-08-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- VOM observation recording and settled-state detection
|
||||||
|
- Record overlay timer
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Browser keepalive disconnect handling
|
||||||
|
|
||||||
|
## CLI 0.1.9 / Extension 0.1.5 — 2026-07-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- CLI auto-update mechanism
|
||||||
|
- Trace v3 protocol and recorder
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- MV3 keepalive disconnect
|
||||||
|
|
||||||
|
## CLI 0.1.8 / Extension 0.1.4 — 2026-07-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- More browser interaction actions
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Windows named-pipe hash-only path issue
|
||||||
|
|
||||||
|
## CLI 0.1.7 / Extension 0.1.3 — 2026-07-07
|
||||||
|
|
||||||
|
Initial public release pair.
|
||||||
|
|
||||||
|
## CLI 0.1.6 — 2026-06-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Minor CLI fixes
|
||||||
|
|
||||||
|
## CLI 0.1.5 / Extension 0.1.2 — 2026-06-22
|
||||||
|
|
||||||
|
First tagged releases.
|
||||||
Generated
+3
-2
@@ -134,9 +134,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bsk"
|
name = "bsk"
|
||||||
version = "0.1.11"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"base64",
|
||||||
"bsk-protocol",
|
"bsk-protocol",
|
||||||
"clap",
|
"clap",
|
||||||
"console",
|
"console",
|
||||||
@@ -168,7 +169,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bsk-protocol"
|
name = "bsk-protocol"
|
||||||
version = "0.1.11"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"schemars",
|
"schemars",
|
||||||
"semver",
|
"semver",
|
||||||
|
|||||||
+3
-2
@@ -7,7 +7,7 @@ members = [
|
|||||||
default-members = ["crates/bsk-cli"]
|
default-members = ["crates/bsk-cli"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.11"
|
version = "0.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -36,13 +36,14 @@ flate2 = "1.1.9"
|
|||||||
tar = "0.4.46"
|
tar = "0.4.46"
|
||||||
zip = { version = "7.2.0", default-features = false, features = ["deflate"] }
|
zip = { version = "7.2.0", default-features = false, features = ["deflate"] }
|
||||||
sha2 = "0.11.0"
|
sha2 = "0.11.0"
|
||||||
|
base64 = "0.22"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
nix = { version = "0.29", features = ["signal", "process"] }
|
nix = { version = "0.29", features = ["signal", "process"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
|
||||||
# Workspace crates (version required for `cargo publish` of dependents)
|
# Workspace crates (version required for `cargo publish` of dependents)
|
||||||
bsk-protocol = { version = "0.1.9", path = "crates/bsk-protocol" }
|
bsk-protocol = { version = "0.2.1", path = "crates/bsk-protocol" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = "symbols"
|
strip = "symbols"
|
||||||
|
|||||||
@@ -147,20 +147,26 @@ Start a new Agent session and write a prompt that needs the browser, for example
|
|||||||
Using [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`)?
|
Using [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`)?
|
||||||
BrowserSkill ships a first-class dsh plugin on npm as
|
BrowserSkill ships a first-class dsh plugin on npm as
|
||||||
[`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin).
|
[`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin).
|
||||||
It injects native `browser_*` tools (no shelling out to `bsk`) and a live Web UI
|
It gives the agent native `browser_*` tools and a live view of its browser sessions
|
||||||
overlay of each Agent Window.
|
in the Web UI. The plugin runs `bsk` on the agent's behalf.
|
||||||
|
|
||||||
Add it to a dsh profile, then start that profile:
|
Install the `bsk` CLI and connect the browser extension first. Then add the plugin
|
||||||
|
to a dsh profile and start it (replace `web` with your profile name):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin
|
dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin
|
||||||
dsh --profile web
|
dsh --profile web
|
||||||
```
|
```
|
||||||
|
|
||||||
The plugin carries its own copy of the skill, so `bsk install-skill` is not needed
|
The plugin includes the `browser-skill` skill, so `bsk install-skill` is not needed
|
||||||
for dsh — but the `bsk` CLI and the browser extension are still prerequisites. See
|
for dsh. Installed plugins do not update automatically. To upgrade this plugin:
|
||||||
the [plugin README](packages/dsh-plugin-browserskill/README.md) for the tool list,
|
|
||||||
configuration, and the observation overlay.
|
```sh
|
||||||
|
dsh plugin --profile web update @wxg-prc-cpg/browser-skill-dsh-plugin --latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the profile after upgrading. See the
|
||||||
|
[plugin README](packages/dsh-plugin-browserskill/README.md) for usage and configuration.
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
|
|||||||
+9
-3
@@ -125,16 +125,22 @@ bsk install-skill
|
|||||||
|
|
||||||
## DeepSeek Harness 插件
|
## DeepSeek Harness 插件
|
||||||
|
|
||||||
在用 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)?BrowserSkill 提供了官方 dsh 插件,已发布到 npm:[`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin)。它会注入原生 `browser_*` 工具(无需再通过 Shell 调用 `bsk`),并在 Web UI 中实时观察每个 Agent Window。
|
在用 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)?BrowserSkill 提供了官方 dsh 插件,已发布到 npm:[`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin)。它为 Agent 提供原生 `browser_*` 工具,由插件代为调用 `bsk`,并在 Web UI 中实时展示浏览器会话。
|
||||||
|
|
||||||
把它装进某个 dsh profile,然后启动该 profile:
|
先安装 `bsk` CLI 并连接浏览器扩展,再将插件装进 dsh profile 并启动(将 `web` 替换为你的 profile 名称):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin
|
dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin
|
||||||
dsh --profile web
|
dsh --profile web
|
||||||
```
|
```
|
||||||
|
|
||||||
插件自带 skill,所以在 dsh 下无需执行 `bsk install-skill`;但 `bsk` CLI 和浏览器扩展仍是前置条件。工具清单、配置项与观察浮层见[插件 README](packages/dsh-plugin-browserskill/README.md)。
|
插件自带 `browser-skill` skill,所以在 dsh 下无需执行 `bsk install-skill`。已安装的插件不会自动更新;升级此插件请运行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dsh plugin --profile web update @wxg-prc-cpg/browser-skill-dsh-plugin --latest
|
||||||
|
```
|
||||||
|
|
||||||
|
升级后重启该 profile。用法与配置见[插件 README](packages/dsh-plugin-browserskill/README.md)。
|
||||||
|
|
||||||
## 工作原理
|
## 工作原理
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# BrowserSkill — Privacy Policy
|
# BrowserSkill — Privacy Policy
|
||||||
|
|
||||||
**Last updated:** May 25, 2026
|
**Last updated:** August 20, 2026
|
||||||
|
|
||||||
This Privacy Policy describes how the **BrowserSkill** browser extension (the "Extension") handles information when you install and use it. BrowserSkill is published as part of the open-source [BrowserSkill](https://github.com/Tencent/BrowserSkill) project. The source code is publicly auditable.
|
This Privacy Policy describes how the **BrowserSkill** browser extension (the "Extension") handles information when you install and use it. BrowserSkill is published as part of the open-source [BrowserSkill](https://github.com/Tencent/BrowserSkill) project. The source code is publicly auditable.
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ BrowserSkill is a local automation bridge that lets AI coding agents (such as Cu
|
|||||||
|
|
||||||
## 2. Single Purpose
|
## 2. Single Purpose
|
||||||
|
|
||||||
The Extension's single purpose is to expose browser automation primitives (navigation, DOM observation, screenshots, clicks, form filling, tab management) to a locally running BrowserSkill daemon over a WebSocket connection on `127.0.0.1`, so that an AI agent invoked by the user can interact with web pages on the user's behalf.
|
The Extension's single purpose is to expose browser automation primitives (navigation, DOM observation, screenshots, clicks, form filling, task-scoped file transfer, and tab management) to a locally running BrowserSkill daemon over a WebSocket connection on `127.0.0.1`, so that an AI agent invoked by the user can interact with web pages on the user's behalf.
|
||||||
|
|
||||||
## 3. Data the Extension Accesses
|
## 3. Data the Extension Accesses
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ Depending on the commands the user (via their AI agent) sends to the local daemo
|
|||||||
| **User input simulated by the agent** | Mouse clicks, keystrokes, and form values that the AI agent dispatches through the Chrome DevTools Protocol (CDP). | Required to perform automation actions the user has asked the agent to do. |
|
| **User input simulated by the agent** | Mouse clicks, keystrokes, and form values that the AI agent dispatches through the Chrome DevTools Protocol (CDP). | Required to perform automation actions the user has asked the agent to do. |
|
||||||
| **Tab and window metadata** | Tab IDs, URLs, titles, window IDs of the Agent Window and any tabs the user explicitly authorizes. | Required to target automation commands at the correct tab/window. |
|
| **Tab and window metadata** | Tab IDs, URLs, titles, window IDs of the Agent Window and any tabs the user explicitly authorizes. | Required to target automation commands at the correct tab/window. |
|
||||||
| **Local extension storage** | A randomly generated 8-character instance ID and an optional user-supplied label. | Used so the local daemon can recognize this browser instance across reconnects. No personal data is stored. |
|
| **Local extension storage** | A randomly generated 8-character instance ID and an optional user-supplied label. | Used so the local daemon can recognize this browser instance across reconnects. No personal data is stored. |
|
||||||
|
| **File transfers requested by the agent** | Local files explicitly supplied to `bsk upload`, and the file created by a single `bsk download` action. | Required to attach a task file to a web page or return a browser-generated download to the invoking local agent. |
|
||||||
| **OS notifications** | Permission to display a system notification when the agent requests to "borrow" one of the user's existing tabs. | Required to obtain explicit, per-tab user consent before the agent touches any pre-existing tab. |
|
| **OS notifications** | Permission to display a system notification when the agent requests to "borrow" one of the user's existing tabs. | Required to obtain explicit, per-tab user consent before the agent touches any pre-existing tab. |
|
||||||
|
|
||||||
## 4. Data the Extension Does **Not** Collect
|
## 4. Data the Extension Does **Not** Collect
|
||||||
@@ -34,7 +35,7 @@ BrowserSkill does **not**:
|
|||||||
|
|
||||||
- Send any data to remote servers, the Extension's authors, or any third party.
|
- Send any data to remote servers, the Extension's authors, or any third party.
|
||||||
- Call any LLM, AI, or cloud API. The Extension contains no API keys, model identifiers, or remote endpoints.
|
- Call any LLM, AI, or cloud API. The Extension contains no API keys, model identifiers, or remote endpoints.
|
||||||
- Read or transmit cookies, browsing history, bookmarks, downloads, saved passwords, or autofill data.
|
- Read or transmit cookies, browsing history, bookmarks, saved passwords, or autofill data. It observes only the download initiated by an active `bsk download` call, not download history generally.
|
||||||
- Use webcam, microphone, geolocation, or any device sensor.
|
- Use webcam, microphone, geolocation, or any device sensor.
|
||||||
- Include analytics, telemetry, crash reporting, advertising SDKs, or fingerprinting code.
|
- Include analytics, telemetry, crash reporting, advertising SDKs, or fingerprinting code.
|
||||||
- Track users across websites or across sessions.
|
- Track users across websites or across sessions.
|
||||||
@@ -50,6 +51,7 @@ The Extension requests the following Chrome permissions. Each is used solely for
|
|||||||
- **`alarms`** — Periodically wake the service worker to keep the local WebSocket connection alive.
|
- **`alarms`** — Periodically wake the service worker to keep the local WebSocket connection alive.
|
||||||
- **`idle`** — Detect when the device returns from idle/locked so the Extension can promptly re-establish the local WebSocket connection after the machine wakes. No idle data is stored or transmitted.
|
- **`idle`** — Detect when the device returns from idle/locked so the Extension can promptly re-establish the local WebSocket connection after the machine wakes. No idle data is stored or transmitted.
|
||||||
- **`notifications`** — Show a system notification to obtain user approval before the agent borrows a user-owned tab.
|
- **`notifications`** — Show a system notification to obtain user approval before the agent borrows a user-owned tab.
|
||||||
|
- **`downloads`** — Correlate and route the one browser download initiated by an active `bsk download` command. If that claimed transaction fails, BrowserSkill cancels an in-progress file or removes its completed temporary browser file. It is not used to enumerate download history or alter unclaimed downloads.
|
||||||
- **`storage`** — Persist a random instance ID and optional label in `chrome.storage.local`.
|
- **`storage`** — Persist a random instance ID and optional label in `chrome.storage.local`.
|
||||||
- **Host permission `<all_urls>`** — Inject a small status overlay (showing "Agent Active") on pages controlled by the agent, and enable automation across whatever sites the user directs the agent to. The Extension does **not** read or transmit page content from sites the agent is not actively driving.
|
- **Host permission `<all_urls>`** — Inject a small status overlay (showing "Agent Active") on pages controlled by the agent, and enable automation across whatever sites the user directs the agent to. The Extension does **not** read or transmit page content from sites the agent is not actively driving.
|
||||||
|
|
||||||
@@ -61,6 +63,7 @@ All Extension activity stays on the user's local device. The only network traffi
|
|||||||
|
|
||||||
- The instance ID and optional label persist in `chrome.storage.local` until the user uninstalls the Extension or clears extension storage.
|
- The instance ID and optional label persist in `chrome.storage.local` until the user uninstalls the Extension or clears extension storage.
|
||||||
- Page content, screenshots, DOM snapshots, and other observed data are returned to the local daemon in response to commands and are **not retained by the Extension**. They live only as long as the agent's tool call.
|
- Page content, screenshots, DOM snapshots, and other observed data are returned to the local daemon in response to commands and are **not retained by the Extension**. They live only as long as the agent's tool call.
|
||||||
|
- Upload and download bytes are staged by the local daemon in a private, session-scoped directory. Download staging is removed after it is copied to the requested destination. Upload staging is retained until the session ends so a later form submission can still read the attached file. Remaining staging is removed when the session ends or disconnects, or when the daemon next starts after a crash.
|
||||||
|
|
||||||
## 8. User Control
|
## 8. User Control
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>BrowserSkill file-transfer fixture</title>
|
||||||
|
<style>
|
||||||
|
body { font: 16px system-ui; max-width: 760px; margin: 48px auto; }
|
||||||
|
.actions { display: flex; gap: 12px; align-items: center; margin: 24px 0; }
|
||||||
|
button, a { padding: 10px 16px; border: 1px solid #777; border-radius: 8px; }
|
||||||
|
a[aria-disabled="true"] { opacity: .45; pointer-events: none; }
|
||||||
|
#drop { padding: 36px; border: 3px dashed #777; border-radius: 12px; text-align: center; }
|
||||||
|
#drop.dragging { border-color: #2563eb; background: #eff6ff; }
|
||||||
|
img { display: block; max-width: 480px; max-height: 360px; margin-top: 20px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Upload then download the same image</h1>
|
||||||
|
<p id="status">No image selected</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="upload" type="button">Upload through file input</button>
|
||||||
|
<input id="file" type="file" accept="image/png" hidden />
|
||||||
|
<a id="download" aria-disabled="true">Download uploaded image</a>
|
||||||
|
</div>
|
||||||
|
<div id="drop" role="button" tabindex="0">Drop an image here</div>
|
||||||
|
<img id="preview" alt="Uploaded image preview" hidden />
|
||||||
|
<script>
|
||||||
|
const input = document.querySelector('#file');
|
||||||
|
const upload = document.querySelector('#upload');
|
||||||
|
const drop = document.querySelector('#drop');
|
||||||
|
const download = document.querySelector('#download');
|
||||||
|
const preview = document.querySelector('#preview');
|
||||||
|
const status = document.querySelector('#status');
|
||||||
|
let objectUrl;
|
||||||
|
|
||||||
|
window.__bskTransferFixture = { events: [], result: null };
|
||||||
|
|
||||||
|
function accept(file, mechanism) {
|
||||||
|
if (!file) return;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
objectUrl = URL.createObjectURL(file);
|
||||||
|
preview.src = objectUrl;
|
||||||
|
preview.hidden = false;
|
||||||
|
download.href = objectUrl;
|
||||||
|
download.download = file.name;
|
||||||
|
download.removeAttribute('aria-disabled');
|
||||||
|
status.textContent = `Ready via ${mechanism}: ${file.name} (${file.size} bytes)`;
|
||||||
|
window.__bskTransferFixture.result = {
|
||||||
|
mechanism,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
hasPathProperty: 'path' in file,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
upload.addEventListener('click', () => input.click());
|
||||||
|
input.addEventListener('change', () => accept(input.files[0], 'input'));
|
||||||
|
|
||||||
|
for (const type of ['dragenter', 'dragover']) {
|
||||||
|
drop.addEventListener(type, event => {
|
||||||
|
const types = [...event.dataTransfer.types];
|
||||||
|
window.__bskTransferFixture.events.push({ type, types });
|
||||||
|
if (!types.includes('Files')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
drop.classList.add('dragging');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
drop.addEventListener('dragleave', () => drop.classList.remove('dragging'));
|
||||||
|
drop.addEventListener('drop', event => {
|
||||||
|
window.__bskTransferFixture.events.push({
|
||||||
|
type: 'drop',
|
||||||
|
types: [...event.dataTransfer.types],
|
||||||
|
fileCount: event.dataTransfer.files.length,
|
||||||
|
});
|
||||||
|
event.preventDefault();
|
||||||
|
drop.classList.remove('dragging');
|
||||||
|
accept(event.dataTransfer.files[0], 'drop');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@browser-skill/extension",
|
"name": "@browser-skill/extension",
|
||||||
"version": "0.1.7",
|
"version": "0.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -29,6 +29,24 @@ function fakeApi() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ChromiumCdp", () => {
|
describe("ChromiumCdp", () => {
|
||||||
|
it("changes attachment identity only across real debugger attachments", async () => {
|
||||||
|
const { api, onEvent, onDetach } = fakeApi();
|
||||||
|
const cdp = new ChromiumCdp(api);
|
||||||
|
expect(cdp.getAttachmentId(4)).toBeUndefined();
|
||||||
|
await cdp.ensureAttached(4);
|
||||||
|
const first = cdp.getAttachmentId(4);
|
||||||
|
expect(first).toBeTruthy();
|
||||||
|
await cdp.ensureAttached(4);
|
||||||
|
onEvent.fire({ tabId: 4 }, "Target.attachedToTarget", { sessionId: "child" });
|
||||||
|
expect(cdp.getAttachmentId(4)).toBe(first);
|
||||||
|
onDetach.fire({ tabId: 4 }, "target_closed");
|
||||||
|
expect(cdp.getAttachmentId(4)).toBeUndefined();
|
||||||
|
await cdp.ensureAttached(4);
|
||||||
|
expect(cdp.getAttachmentId(4)).not.toBe(first);
|
||||||
|
await cdp.detach(4);
|
||||||
|
expect(cdp.getAttachmentId(4)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("discovers multiple iframe targets and recursively routes nested OOPIF commands", async () => {
|
it("discovers multiple iframe targets and recursively routes nested OOPIF commands", async () => {
|
||||||
const { api, onEvent } = fakeApi();
|
const { api, onEvent } = fakeApi();
|
||||||
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
|
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
|
||||||
@@ -254,6 +272,69 @@ describe("ChromiumCdp", () => {
|
|||||||
expect(cdp.isAttached(7)).toBe(false);
|
expect(cdp.isAttached(7)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
])("waits for detach before reconnecting a returned tab (detach rejects: %s)", async (rejectDetach) => {
|
||||||
|
const { api } = fakeApi();
|
||||||
|
const attached = new Set<number>();
|
||||||
|
let finishDetach!: () => void;
|
||||||
|
vi.mocked(api.attach).mockImplementation(async ({ tabId }) => {
|
||||||
|
if (attached.has(tabId!)) throw new Error("Another debugger is already attached");
|
||||||
|
attached.add(tabId!);
|
||||||
|
});
|
||||||
|
vi.mocked(api.detach).mockImplementation(async ({ tabId }) => {
|
||||||
|
attached.delete(tabId!);
|
||||||
|
});
|
||||||
|
vi.mocked(api.detach).mockImplementationOnce(
|
||||||
|
({ tabId }) =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
finishDetach = () => {
|
||||||
|
attached.delete(tabId!);
|
||||||
|
if (rejectDetach) reject(new Error("tab already detached"));
|
||||||
|
else resolve();
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const cdp = new ChromiumCdp(api);
|
||||||
|
cdp.trackSessionTab("aa11", 7);
|
||||||
|
await cdp.ensureAttached(7);
|
||||||
|
const returning = cdp.releaseSessionTab("aa11", 7);
|
||||||
|
await vi.waitFor(() => expect(api.detach).toHaveBeenCalledOnce());
|
||||||
|
let duplicateDetachFinished = false;
|
||||||
|
const duplicateDetach = cdp.detach(7).then(() => {
|
||||||
|
duplicateDetachFinished = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
cdp.trackSessionTab("bb22", 7);
|
||||||
|
// Register rejection handlers immediately; the pre-fix driver rejects
|
||||||
|
// both commands while the browser still has the previous attachment.
|
||||||
|
const pending = Promise.allSettled([
|
||||||
|
cdp.send(7, "Runtime.evaluate", { expression: "document.title" }),
|
||||||
|
cdp.send(7, "DOM.getDocument"),
|
||||||
|
]);
|
||||||
|
await cdp.send(8, "DOM.getDocument");
|
||||||
|
const finishedEarly = duplicateDetachFinished;
|
||||||
|
finishDetach();
|
||||||
|
await Promise.all([returning, duplicateDetach]);
|
||||||
|
|
||||||
|
expect(await pending).toEqual([
|
||||||
|
{ status: "fulfilled", value: { ok: true } },
|
||||||
|
{ status: "fulfilled", value: { ok: true } },
|
||||||
|
]);
|
||||||
|
expect(finishedEarly).toBe(false);
|
||||||
|
expect(vi.mocked(api.attach).mock.calls.filter(([target]) => target.tabId === 7)).toHaveLength(
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(cdp.isAttached(7)).toBe(true);
|
||||||
|
expect(cdp.isAttached(8)).toBe(true);
|
||||||
|
await cdp.detachSession("bb22");
|
||||||
|
expect(api.detach).toHaveBeenCalledTimes(2);
|
||||||
|
expect(cdp.isAttached(7)).toBe(false);
|
||||||
|
expect(attached.has(7)).toBe(false);
|
||||||
|
expect(cdp.isAttached(8)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("detachAll() iterates every cached tab", async () => {
|
it("detachAll() iterates every cached tab", async () => {
|
||||||
const { api } = fakeApi();
|
const { api } = fakeApi();
|
||||||
const cdp = new ChromiumCdp(api);
|
const cdp = new ChromiumCdp(api);
|
||||||
@@ -651,6 +732,155 @@ describe("ChromiumCdp", () => {
|
|||||||
expect(cdp.networkEntriesSince(33, 0, 50, 1000).entries[0].url).toBeUndefined();
|
expect(cdp.networkEntriesSince(33, 0, 50, 1000).entries[0].url).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("releases only the returning session's claim on a tab", async () => {
|
||||||
|
const { api } = fakeApi();
|
||||||
|
const cdp = new ChromiumCdp(api);
|
||||||
|
await cdp.ensureAttached(1);
|
||||||
|
await cdp.ensureAttached(2);
|
||||||
|
cdp.trackSessionTab("aa11", 1);
|
||||||
|
cdp.trackSessionTab("bb22", 1);
|
||||||
|
cdp.trackSessionTab("aa11", 2);
|
||||||
|
|
||||||
|
await cdp.releaseSessionTab("unknown", 1);
|
||||||
|
await cdp.releaseSessionTab("aa11", 1);
|
||||||
|
expect(api.detach).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await cdp.releaseSessionTab("bb22", 1);
|
||||||
|
await cdp.releaseSessionTab("bb22", 1);
|
||||||
|
expect(api.detach).toHaveBeenCalledExactlyOnceWith({ tabId: 1 });
|
||||||
|
expect(cdp.isAttached(2)).toBe(true);
|
||||||
|
|
||||||
|
await cdp.detachSession("aa11");
|
||||||
|
expect(api.detach).toHaveBeenCalledTimes(2);
|
||||||
|
expect(api.detach).toHaveBeenLastCalledWith({ tabId: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
])("releases a tab with an attachment in flight (new owner: %s)", async (newOwner) => {
|
||||||
|
const { api } = fakeApi();
|
||||||
|
let finishAttach!: () => void;
|
||||||
|
vi.mocked(api.attach).mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
finishAttach = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const cdp = new ChromiumCdp(api);
|
||||||
|
cdp.trackSessionTab("aa11", 1);
|
||||||
|
const attaching = cdp.ensureAttached(1);
|
||||||
|
const releasing = cdp.releaseSessionTab("aa11", 1);
|
||||||
|
if (newOwner) cdp.trackSessionTab("bb22", 1);
|
||||||
|
finishAttach();
|
||||||
|
await Promise.all([attaching, releasing]);
|
||||||
|
|
||||||
|
expect(cdp.isAttached(1)).toBe(newOwner);
|
||||||
|
expect(api.detach).toHaveBeenCalledTimes(newOwner ? 0 : 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops accepting dialogs on a returned tab even when another session keeps CDP attached", async () => {
|
||||||
|
const { api, onEvent } = fakeApi();
|
||||||
|
let controlled = true;
|
||||||
|
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog: () => controlled });
|
||||||
|
cdp.trackSessionTab("aa11", 1);
|
||||||
|
cdp.trackSessionTab("bb22", 1);
|
||||||
|
await cdp.ensureAttached(1);
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
|
||||||
|
type: "confirm",
|
||||||
|
message: "before return",
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(cdp.dialogsSince(1, 0)).toHaveLength(1));
|
||||||
|
vi.mocked(api.sendCommand).mockClear();
|
||||||
|
|
||||||
|
controlled = false;
|
||||||
|
await cdp.releaseSessionTab("aa11", 1);
|
||||||
|
expect(cdp.isAttached(1)).toBe(true);
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
|
||||||
|
type: "confirm",
|
||||||
|
message: "after return",
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(api.sendCommand).not.toHaveBeenCalled();
|
||||||
|
expect(cdp.dialogsSince(1, 0)).toHaveLength(1);
|
||||||
|
|
||||||
|
controlled = true;
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
|
||||||
|
type: "alert",
|
||||||
|
message: "borrowed again",
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(cdp.dialogsSince(1, 0)).toHaveLength(2));
|
||||||
|
expect(api.sendCommand).toHaveBeenCalledExactlyOnceWith(
|
||||||
|
{ tabId: 1 },
|
||||||
|
"Page.handleJavaScriptDialog",
|
||||||
|
{ accept: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores dialog events after detach, including a pending eligibility check", async () => {
|
||||||
|
const { api, onEvent } = fakeApi();
|
||||||
|
let resolveEligibility!: (value: boolean) => void;
|
||||||
|
const shouldAutoAcceptDialog = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
resolveEligibility = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog });
|
||||||
|
await cdp.ensureAttached(1);
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
|
||||||
|
await cdp.detach(1);
|
||||||
|
resolveEligibility(true);
|
||||||
|
await Promise.resolve();
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
|
||||||
|
|
||||||
|
expect(shouldAutoAcceptDialog).toHaveBeenCalledOnce();
|
||||||
|
expect(api.sendCommand).not.toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
"Page.handleJavaScriptDialog",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(cdp.dialogsSince(1, 0)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not accept dialogs when the tab's current scope cannot be determined", async () => {
|
||||||
|
const { api, onEvent } = fakeApi();
|
||||||
|
const shouldAutoAcceptDialog = vi.fn(async () => {
|
||||||
|
throw new Error("tab is gone");
|
||||||
|
});
|
||||||
|
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog });
|
||||||
|
await cdp.ensureAttached(1);
|
||||||
|
vi.mocked(api.sendCommand).mockClear();
|
||||||
|
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "confirm" });
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(shouldAutoAcceptDialog).toHaveBeenCalledWith(1);
|
||||||
|
expect(api.sendCommand).not.toHaveBeenCalled();
|
||||||
|
expect(cdp.dialogsSince(1, 0)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not restore dialog state when an acceptance completes after detach", async () => {
|
||||||
|
const { api, onEvent } = fakeApi();
|
||||||
|
const cdp = new ChromiumCdp(api);
|
||||||
|
await cdp.ensureAttached(1);
|
||||||
|
let finishAccept!: () => void;
|
||||||
|
vi.mocked(api.sendCommand).mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
finishAccept = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
|
||||||
|
await cdp.detach(1);
|
||||||
|
finishAccept();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(cdp.dialogsSince(1, 0)).toEqual([]);
|
||||||
|
expect(cdp.dialogCursor(1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("detachSession only detaches tabs no other session owns", async () => {
|
it("detachSession only detaches tabs no other session owns", async () => {
|
||||||
const { api } = fakeApi();
|
const { api } = fakeApi();
|
||||||
const cdp = new ChromiumCdp(api);
|
const cdp = new ChromiumCdp(api);
|
||||||
|
|||||||
@@ -164,7 +164,9 @@ async function settleBeforeDeadline(promises: Promise<void>[], deadline: number)
|
|||||||
export class ChromiumCdp {
|
export class ChromiumCdp {
|
||||||
private readonly api: CdpDebuggerApi;
|
private readonly api: CdpDebuggerApi;
|
||||||
private readonly attachedTabs = new Set<number>();
|
private readonly attachedTabs = new Set<number>();
|
||||||
|
private readonly attachmentIds = new Map<number, string>();
|
||||||
private readonly attachInFlight = new Map<number, Promise<void>>();
|
private readonly attachInFlight = new Map<number, Promise<void>>();
|
||||||
|
private readonly detachInFlight = new Map<number, Promise<void>>();
|
||||||
private readonly tabOwners = new Map<number, Set<string>>();
|
private readonly tabOwners = new Map<number, Set<string>>();
|
||||||
private readonly dialogBuffers = new Map<number, JavaScriptDialogInfo[]>();
|
private readonly dialogBuffers = new Map<number, JavaScriptDialogInfo[]>();
|
||||||
private readonly dialogSequences = new Map<number, number>();
|
private readonly dialogSequences = new Map<number, number>();
|
||||||
@@ -182,7 +184,13 @@ export class ChromiumCdp {
|
|||||||
private networkSubscription: { dispose(): void } | null = null;
|
private networkSubscription: { dispose(): void } | null = null;
|
||||||
private frameTargetSubscription: { dispose(): void } | null = null;
|
private frameTargetSubscription: { dispose(): void } | null = null;
|
||||||
|
|
||||||
constructor(api: CdpDebuggerApi = chromeDebuggerApi) {
|
constructor(
|
||||||
|
api: CdpDebuggerApi = chromeDebuggerApi,
|
||||||
|
private readonly options: {
|
||||||
|
/** CDP observation alone does not authorize dismissing user dialogs. */
|
||||||
|
shouldAutoAcceptDialog?: (tabId: number) => boolean | Promise<boolean>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
this.api = api;
|
this.api = api;
|
||||||
this.bindAutoDetach();
|
this.bindAutoDetach();
|
||||||
this.bindDialogHandler();
|
this.bindDialogHandler();
|
||||||
@@ -191,8 +199,17 @@ export class ChromiumCdp {
|
|||||||
this.bindFrameTargetHandler();
|
this.bindFrameTargetHandler();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Identity of this actual debugger attachment, independent of frame topology. */
|
||||||
|
getAttachmentId(tabId: number): string | undefined {
|
||||||
|
return this.attachmentIds.get(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Attach to `tabId` if we haven't already in this driver. */
|
/** Attach to `tabId` if we haven't already in this driver. */
|
||||||
async ensureAttached(tabId: number): Promise<void> {
|
async ensureAttached(tabId: number): Promise<void> {
|
||||||
|
// Returning a tab clears the cache before Chrome finishes detaching.
|
||||||
|
// New observers must wait before opening the next connection to that tab.
|
||||||
|
const detaching = this.detachInFlight.get(tabId);
|
||||||
|
if (detaching) await detaching;
|
||||||
if (this.attachedTabs.has(tabId)) return;
|
if (this.attachedTabs.has(tabId)) return;
|
||||||
const existing = this.attachInFlight.get(tabId);
|
const existing = this.attachInFlight.get(tabId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -206,6 +223,7 @@ export class ChromiumCdp {
|
|||||||
await this.enableConsoleDomains(tabId);
|
await this.enableConsoleDomains(tabId);
|
||||||
await this.enableNetworkDomainBestEffort(tabId);
|
await this.enableNetworkDomainBestEffort(tabId);
|
||||||
this.attachedTabs.add(tabId);
|
this.attachedTabs.add(tabId);
|
||||||
|
this.attachmentIds.set(tabId, crypto.randomUUID());
|
||||||
await this.enableFrameDiscovery({ tabId }).catch((err) => {
|
await this.enableFrameDiscovery({ tabId }).catch((err) => {
|
||||||
console.debug("[bsk cdp] frame discovery unavailable", { tabId, err });
|
console.debug("[bsk cdp] frame discovery unavailable", { tabId, err });
|
||||||
});
|
});
|
||||||
@@ -425,20 +443,32 @@ export class ChromiumCdp {
|
|||||||
|
|
||||||
/** Detach if attached; never throws. */
|
/** Detach if attached; never throws. */
|
||||||
async detach(tabId: number): Promise<void> {
|
async detach(tabId: number): Promise<void> {
|
||||||
|
const existing = this.detachInFlight.get(tabId);
|
||||||
|
if (existing) {
|
||||||
|
await existing;
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.attachInFlight.delete(tabId);
|
this.attachInFlight.delete(tabId);
|
||||||
if (!this.attachedTabs.has(tabId)) return;
|
if (!this.attachedTabs.has(tabId)) return;
|
||||||
this.attachedTabs.delete(tabId);
|
this.attachedTabs.delete(tabId);
|
||||||
|
this.attachmentIds.delete(tabId);
|
||||||
this.clearDialogState(tabId);
|
this.clearDialogState(tabId);
|
||||||
this.clearConsoleState(tabId);
|
this.clearConsoleState(tabId);
|
||||||
this.clearNetworkState(tabId);
|
this.clearNetworkState(tabId);
|
||||||
this.clearFrameState(tabId);
|
this.clearFrameState(tabId);
|
||||||
try {
|
const detach = (async () => {
|
||||||
await this.api.detach({ tabId });
|
try {
|
||||||
} catch (err) {
|
await this.api.detach({ tabId });
|
||||||
// Tab may already be gone — Chrome auto-detaches on close. Log
|
} catch (err) {
|
||||||
// at debug so production builds aren't noisy.
|
// Tab may already be gone — Chrome auto-detaches on close. Log
|
||||||
console.debug("[bsk cdp] detach failed (likely tab already closed)", err);
|
// at debug so production builds aren't noisy.
|
||||||
}
|
console.debug("[bsk cdp] detach failed (likely tab already closed)", err);
|
||||||
|
}
|
||||||
|
})().finally(() => {
|
||||||
|
this.detachInFlight.delete(tabId);
|
||||||
|
});
|
||||||
|
this.detachInFlight.set(tabId, detach);
|
||||||
|
await detach;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True iff `ensureAttached(tabId)` has succeeded since the last detach. */
|
/** True iff `ensureAttached(tabId)` has succeeded since the last detach. */
|
||||||
@@ -453,6 +483,17 @@ export class ChromiumCdp {
|
|||||||
this.tabOwners.set(tabId, owners);
|
this.tabOwners.set(tabId, owners);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Release one session's claim, preserving attachments still used by another. */
|
||||||
|
async releaseSessionTab(sessionId: string, tabId: number): Promise<void> {
|
||||||
|
const owners = this.tabOwners.get(tabId);
|
||||||
|
if (!owners?.delete(sessionId) || owners.size > 0) return;
|
||||||
|
this.tabOwners.delete(tabId);
|
||||||
|
// An observation may still be attaching when the tab is returned. Wait
|
||||||
|
// for it so detach cannot miss the attachment or remove a new owner's claim.
|
||||||
|
await this.attachInFlight.get(tabId)?.catch(() => {});
|
||||||
|
if (!this.tabOwners.has(tabId)) await this.detach(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Subscribe to all CDP events. Returned disposable removes the listener. */
|
/** Subscribe to all CDP events. Returned disposable removes the listener. */
|
||||||
onEvent(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
|
onEvent(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
@@ -469,6 +510,7 @@ export class ChromiumCdp {
|
|||||||
this.attachInFlight.clear();
|
this.attachInFlight.clear();
|
||||||
this.tabOwners.clear();
|
this.tabOwners.clear();
|
||||||
this.attachedTabs.clear();
|
this.attachedTabs.clear();
|
||||||
|
this.attachmentIds.clear();
|
||||||
this.dialogBuffers.clear();
|
this.dialogBuffers.clear();
|
||||||
this.dialogSequences.clear();
|
this.dialogSequences.clear();
|
||||||
this.consoleBuffers.clear();
|
this.consoleBuffers.clear();
|
||||||
@@ -639,13 +681,23 @@ export class ChromiumCdp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async onJavaScriptDialogOpening(tabId: number, params: unknown): Promise<void> {
|
private async onJavaScriptDialogOpening(tabId: number, params: unknown): Promise<void> {
|
||||||
|
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
|
||||||
const parsed = parseDialogOpeningParams(params);
|
const parsed = parseDialogOpeningParams(params);
|
||||||
try {
|
try {
|
||||||
|
if (
|
||||||
|
this.options.shouldAutoAcceptDialog &&
|
||||||
|
!(await this.options.shouldAutoAcceptDialog(tabId))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The tab may have been returned while its current scope was checked.
|
||||||
|
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
|
||||||
const handleParams: { accept: boolean; promptText?: string } = { accept: true };
|
const handleParams: { accept: boolean; promptText?: string } = { accept: true };
|
||||||
if (parsed.type === "prompt") {
|
if (parsed.type === "prompt") {
|
||||||
handleParams.promptText = parsed.defaultPrompt ?? "";
|
handleParams.promptText = parsed.defaultPrompt ?? "";
|
||||||
}
|
}
|
||||||
await this.api.sendCommand({ tabId }, "Page.handleJavaScriptDialog", handleParams);
|
await this.api.sendCommand({ tabId }, "Page.handleJavaScriptDialog", handleParams);
|
||||||
|
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
|
||||||
const sequence = (this.dialogSequences.get(tabId) ?? 0) + 1;
|
const sequence = (this.dialogSequences.get(tabId) ?? 0) + 1;
|
||||||
this.dialogSequences.set(tabId, sequence);
|
this.dialogSequences.set(tabId, sequence);
|
||||||
this.appendDialog(tabId, {
|
this.appendDialog(tabId, {
|
||||||
@@ -805,6 +857,7 @@ export class ChromiumCdp {
|
|||||||
const listener = (source: chrome.debugger.Debuggee, _reason: string) => {
|
const listener = (source: chrome.debugger.Debuggee, _reason: string) => {
|
||||||
if (typeof source.tabId === "number") {
|
if (typeof source.tabId === "number") {
|
||||||
this.attachedTabs.delete(source.tabId);
|
this.attachedTabs.delete(source.tabId);
|
||||||
|
this.attachmentIds.delete(source.tabId);
|
||||||
this.attachInFlight.delete(source.tabId);
|
this.attachInFlight.delete(source.tabId);
|
||||||
this.tabOwners.delete(source.tabId);
|
this.tabOwners.delete(source.tabId);
|
||||||
this.clearDialogState(source.tabId);
|
this.clearDialogState(source.tabId);
|
||||||
@@ -835,15 +888,9 @@ export class ChromiumCdp {
|
|||||||
|
|
||||||
/** Detach tabs only when no other live session has claimed them. */
|
/** Detach tabs only when no other live session has claimed them. */
|
||||||
async detachSession(sessionId: string): Promise<void> {
|
async detachSession(sessionId: string): Promise<void> {
|
||||||
const tabsToDetach: number[] = [];
|
await Promise.all(
|
||||||
for (const [tabId, owners] of this.tabOwners) {
|
Array.from(this.tabOwners.keys(), (tabId) => this.releaseSessionTab(sessionId, tabId)),
|
||||||
owners.delete(sessionId);
|
);
|
||||||
if (owners.size === 0) {
|
|
||||||
this.tabOwners.delete(tabId);
|
|
||||||
tabsToDetach.push(tabId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await Promise.all(tabsToDetach.map((tabId) => this.detach(tabId)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ describe("BorrowConfirmationOverlay", () => {
|
|||||||
id: "req-1",
|
id: "req-1",
|
||||||
isActiveTab: true,
|
isActiveTab: true,
|
||||||
tabTitle: "Example",
|
tabTitle: "Example",
|
||||||
timeoutMs: 5000,
|
timeoutMs: 60_000,
|
||||||
onAllow,
|
onAllow,
|
||||||
onDeny,
|
onDeny,
|
||||||
},
|
},
|
||||||
@@ -32,7 +32,7 @@ describe("BorrowConfirmationOverlay", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("允许借用标签页?")).toBeTruthy();
|
expect(screen.getByText("允许借用标签页?")).toBeTruthy();
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 60; i++) {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
});
|
});
|
||||||
@@ -66,7 +66,7 @@ describe("BorrowConfirmationOverlay", () => {
|
|||||||
id: "req-fallback",
|
id: "req-fallback",
|
||||||
isActiveTab: true,
|
isActiveTab: true,
|
||||||
tabTitle: "Example",
|
tabTitle: "Example",
|
||||||
timeoutMs: 5000,
|
timeoutMs: 60_000,
|
||||||
onAllow,
|
onAllow,
|
||||||
onDeny,
|
onDeny,
|
||||||
},
|
},
|
||||||
@@ -74,7 +74,7 @@ describe("BorrowConfirmationOverlay", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 60; i++) {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,10 +45,32 @@ function portHarness() {
|
|||||||
|
|
||||||
describe("RecordFrameAgent", () => {
|
describe("RecordFrameAgent", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
document.documentElement.removeAttribute(RECORD_DOCUMENT_ATTRIBUTE);
|
document.documentElement.removeAttribute(RECORD_DOCUMENT_ATTRIBUTE);
|
||||||
document.body.replaceChildren();
|
document.body.replaceChildren();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("starts when crypto.randomUUID is unavailable", async () => {
|
||||||
|
const harness = portHarness();
|
||||||
|
const getRandomValues = vi.fn((bytes: Uint8Array) => {
|
||||||
|
bytes.fill(0x11);
|
||||||
|
return bytes;
|
||||||
|
});
|
||||||
|
vi.stubGlobal("crypto", { getRandomValues });
|
||||||
|
vi.stubGlobal("chrome", {
|
||||||
|
runtime: {
|
||||||
|
connect: vi.fn(() => harness.port),
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const agent = new RecordFrameAgent();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
agent.start({ type: RECORD_FRAME_START, requestId: "rec-http", startedAtMs: 10 }),
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
expect(getRandomValues).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a failed stop retryable and flushes the final dirty fill", async () => {
|
it("keeps a failed stop retryable and flushes the final dirty fill", async () => {
|
||||||
const harness = portHarness();
|
const harness = portHarness();
|
||||||
const sendMessage = vi
|
const sendMessage = vi
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createRandomUuid } from "@/lib/random-uuid";
|
||||||
import {
|
import {
|
||||||
isAcceptedRecordStepAck,
|
isAcceptedRecordStepAck,
|
||||||
RECORD_STEP,
|
RECORD_STEP,
|
||||||
@@ -18,7 +19,7 @@ export class RecordStepDelivery {
|
|||||||
constructor(
|
constructor(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
send: SendRecordStepMessage = (message) => chrome.runtime.sendMessage(message),
|
send: SendRecordStepMessage = (message) => chrome.runtime.sendMessage(message),
|
||||||
producerId: string = crypto.randomUUID(),
|
producerId: string = createRandomUuid(),
|
||||||
) {
|
) {
|
||||||
this.#requestId = requestId;
|
this.#requestId = requestId;
|
||||||
this.#producerId = producerId;
|
this.#producerId = producerId;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createRandomUuid } from "@/lib/random-uuid";
|
||||||
import {
|
import {
|
||||||
markRecordingDocument,
|
markRecordingDocument,
|
||||||
type RecordingDocumentMarker,
|
type RecordingDocumentMarker,
|
||||||
@@ -67,7 +68,7 @@ export class RecordFrameAgent {
|
|||||||
async #start(
|
async #start(
|
||||||
message: RecordFrameStartMessage,
|
message: RecordFrameStartMessage,
|
||||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||||
const producerId = crypto.randomUUID();
|
const producerId = createRandomUuid();
|
||||||
const root = await waitForDocumentElement();
|
const root = await waitForDocumentElement();
|
||||||
if (this.#disposed) return { ok: false, error: "recording document is disposed" };
|
if (this.#disposed) return { ok: false, error: "recording document is disposed" };
|
||||||
const marker = markRecordingDocument(producerId, root);
|
const marker = markRecordingDocument(producerId, root);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { recordFrameCoordinator } from "@/lib/recording/frame-coordinator";
|
|||||||
import { attachSessionsLiveFlag } from "@/lib/sessions-live-flag";
|
import { attachSessionsLiveFlag } from "@/lib/sessions-live-flag";
|
||||||
import { createDisconnectCleanup } from "@/session-manager/disconnect-cleanup";
|
import { createDisconnectCleanup } from "@/session-manager/disconnect-cleanup";
|
||||||
import { attachSessionEventHandler } from "@/session-manager/event-handler";
|
import { attachSessionEventHandler } from "@/session-manager/event-handler";
|
||||||
import { SessionManager } from "@/session-manager/manager";
|
import { isAgentControlledTab, SessionManager } from "@/session-manager/manager";
|
||||||
import {
|
import {
|
||||||
attachBorrowNotificationButtonHandler,
|
attachBorrowNotificationButtonHandler,
|
||||||
attachBorrowNotificationClickHandler,
|
attachBorrowNotificationClickHandler,
|
||||||
@@ -41,6 +41,8 @@ import {
|
|||||||
attachRecordStepListener,
|
attachRecordStepListener,
|
||||||
type RecordRuntimeDeps,
|
type RecordRuntimeDeps,
|
||||||
} from "@/tools/record";
|
} from "@/tools/record";
|
||||||
|
import { chromeTabsApi } from "@/tools/shared";
|
||||||
|
import { chromeTabMutationApi } from "@/tools/tabs";
|
||||||
import { detectBrowserMeta } from "@/transport/handshake";
|
import { detectBrowserMeta } from "@/transport/handshake";
|
||||||
import type { Transport } from "@/transport/transport";
|
import type { Transport } from "@/transport/transport";
|
||||||
import { WSTransport } from "@/transport/ws-transport";
|
import { WSTransport } from "@/transport/ws-transport";
|
||||||
@@ -49,7 +51,12 @@ export default defineBackground(() => {
|
|||||||
const controller = new ConnectionController();
|
const controller = new ConnectionController();
|
||||||
const transport = new WSTransport({ url: __BSK_DAEMON_WS_URL__ });
|
const transport = new WSTransport({ url: __BSK_DAEMON_WS_URL__ });
|
||||||
const sessions = new SessionManager();
|
const sessions = new SessionManager();
|
||||||
const cdp = new ChromiumCdp();
|
const cdp = new ChromiumCdp(undefined, {
|
||||||
|
shouldAutoAcceptDialog: async (tabId) => {
|
||||||
|
const tab = await chrome.tabs.get(tabId);
|
||||||
|
return sessions.findByWindowId(tab.windowId) !== null;
|
||||||
|
},
|
||||||
|
});
|
||||||
const sessionsLive = attachSessionsLiveFlag({ manager: sessions });
|
const sessionsLive = attachSessionsLiveFlag({ manager: sessions });
|
||||||
let overlayGeneration = 0;
|
let overlayGeneration = 0;
|
||||||
const controlModes = new Map<string, OverlayMode>();
|
const controlModes = new Map<string, OverlayMode>();
|
||||||
@@ -80,6 +87,24 @@ export default defineBackground(() => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authoritative overlay state for a specific tab. Agent Window tabs are
|
||||||
|
* free by default; only tabs explicitly claimed through session startup,
|
||||||
|
* `tab_create`, or `tab_borrow` receive the control overlay.
|
||||||
|
*/
|
||||||
|
function overlayStateForTab(tabId?: number, windowId?: number): OverlayAgentStateMessage {
|
||||||
|
if (typeof tabId === "number" && typeof windowId === "number") {
|
||||||
|
const ctx = sessions.findByWindowId(windowId);
|
||||||
|
if (ctx && isAgentControlledTab(ctx, tabId)) return overlayStateForWindow(windowId);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: OVERLAY_AGENT_STATE,
|
||||||
|
sessionId: null,
|
||||||
|
mode: "hidden",
|
||||||
|
generation: overlayGeneration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function pushOverlayStateToTab(
|
async function pushOverlayStateToTab(
|
||||||
tabId: number,
|
tabId: number,
|
||||||
state: OverlayAgentStateMessage,
|
state: OverlayAgentStateMessage,
|
||||||
@@ -91,13 +116,18 @@ export default defineBackground(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pushOverlayStateForTab(tabId: number, windowId?: number): Promise<void> {
|
||||||
|
const state = overlayStateForTab(tabId, windowId);
|
||||||
|
await pushOverlayStateToTab(tabId, state);
|
||||||
|
}
|
||||||
|
|
||||||
async function pushOverlayStateForWindow(windowId: number): Promise<void> {
|
async function pushOverlayStateForWindow(windowId: number): Promise<void> {
|
||||||
const state = overlayStateForWindow(windowId);
|
|
||||||
const tabs = await chrome.tabs.query({ windowId });
|
const tabs = await chrome.tabs.query({ windowId });
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
tabs.map((tab) =>
|
tabs.map((tab) => {
|
||||||
typeof tab.id === "number" ? pushOverlayStateToTab(tab.id, state) : Promise.resolve(),
|
if (typeof tab.id !== "number") return Promise.resolve();
|
||||||
),
|
return pushOverlayStateForTab(tab.id, windowId);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,13 +166,30 @@ export default defineBackground(() => {
|
|||||||
if (typeof tab.windowId !== "number") return;
|
if (typeof tab.windowId !== "number") return;
|
||||||
pushOverlayStateForAgentWindow(tab.windowId);
|
pushOverlayStateForAgentWindow(tab.windowId);
|
||||||
});
|
});
|
||||||
|
// Newly observed tabs are free unless the agent's own creation path has
|
||||||
|
// already claimed their concrete id. This listener only renders that
|
||||||
|
// state; it never infers or mutates ownership from event ordering.
|
||||||
|
chrome.tabs.onCreated.addListener((tab) => {
|
||||||
|
if (typeof tab.windowId !== "number" || typeof tab.id !== "number") return;
|
||||||
|
if (!sessions.findByWindowId(tab.windowId)) return;
|
||||||
|
void pushOverlayStateForTab(tab.id, tab.windowId);
|
||||||
|
});
|
||||||
|
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
|
||||||
|
sessions.forgetClosedTab(tabId, { isWindowClosing: removeInfo.isWindowClosing });
|
||||||
|
});
|
||||||
// Re-sync the storage.session flag on SW startup so a previous SW's
|
// Re-sync the storage.session flag on SW startup so a previous SW's
|
||||||
// stale `true` does not keep waking us on every page load until the
|
// stale `true` does not keep waking us on every page load until the
|
||||||
// first mutation (review M4/M5 round 3 m-R3-1).
|
// first mutation (review M4/M5 round 3 m-R3-1).
|
||||||
void sessionsLive.refresh();
|
void sessionsLive.refresh();
|
||||||
const cleanupAfterDisconnect = createDisconnectCleanup({
|
const cleanupAfterDisconnect = createDisconnectCleanup({
|
||||||
manager: sessions,
|
manager: sessions,
|
||||||
sessionStopDeps: { cdp },
|
// Same contract as the dispatcher's `tool.session_stop`: without these
|
||||||
|
// deps the agent-tab cleanup and the window-release path are dead code.
|
||||||
|
sessionStopDeps: {
|
||||||
|
cdp,
|
||||||
|
tabManagement: { tabs: chromeTabMutationApi },
|
||||||
|
tabsQuery: chromeTabsApi,
|
||||||
|
},
|
||||||
onSessionsChanged: () => {
|
onSessionsChanged: () => {
|
||||||
void sessionsLive.syncFromManager();
|
void sessionsLive.syncFromManager();
|
||||||
},
|
},
|
||||||
@@ -176,6 +223,9 @@ export default defineBackground(() => {
|
|||||||
recording: recordDeps,
|
recording: recordDeps,
|
||||||
onSessionsChanged: onOverlaySessionStateChanged,
|
onSessionsChanged: onOverlaySessionStateChanged,
|
||||||
onBrowserControlResumed,
|
onBrowserControlResumed,
|
||||||
|
onAgentTabClaimed: (tabId, windowId) => {
|
||||||
|
void pushOverlayStateForTab(tabId, windowId);
|
||||||
|
},
|
||||||
approveBorrow: (ctx) =>
|
approveBorrow: (ctx) =>
|
||||||
requestBorrowConfirmation(ctx.tabId, {
|
requestBorrowConfirmation(ctx.tabId, {
|
||||||
...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
|
...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
|
||||||
@@ -309,7 +359,11 @@ export default defineBackground(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.kind === OVERLAY_MSG_READY) {
|
if (msg.kind === OVERLAY_MSG_READY) {
|
||||||
sendResponse(overlayStateForWindow(sender.tab?.windowId));
|
// Resolve from explicit tab ownership before sending so free tabs never
|
||||||
|
// flash the control mask.
|
||||||
|
if (typeof sender.tab?.id === "number") {
|
||||||
|
void pushOverlayStateForTab(sender.tab.id, sender.tab.windowId);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ describe("App", () => {
|
|||||||
|
|
||||||
expect(screen.queryByText(/^扩展 v/)).toBeNull();
|
expect(screen.queryByText(/^扩展 v/)).toBeNull();
|
||||||
expect(screen.queryByText(/^daemon v/)).toBeNull();
|
expect(screen.queryByText(/^daemon v/)).toBeNull();
|
||||||
expect(screen.getByTitle("扩展版本").textContent).toBe(EXTENSION_VERSION);
|
expect(screen.getByTitle("扩展版本").textContent).toBe(`Ext ${EXTENSION_VERSION}`);
|
||||||
expect(screen.getByTitle("bsk 版本").textContent).toBe(mockDaemonVersion);
|
expect(screen.getByTitle("bsk 版本").textContent).toBe(`CLI ${mockDaemonVersion}`);
|
||||||
expect(screen.getByText("03c3e47f")).toBeTruthy();
|
expect(screen.getByText("03c3e47f")).toBeTruthy();
|
||||||
|
|
||||||
const copyButton = screen.getByRole("button", { name: "复制实例 ID" });
|
const copyButton = screen.getByRole("button", { name: "复制实例 ID" });
|
||||||
@@ -247,6 +247,41 @@ describe("App", () => {
|
|||||||
expect(copyButton.getAttribute("disabled")).not.toBeNull();
|
expect(copyButton.getAttribute("disabled")).not.toBeNull();
|
||||||
expect(screen.getByText("连接后可用")).toBeTruthy();
|
expect(screen.getByText("连接后可用")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats protocol drift as still connected and prompts an upgrade", async () => {
|
||||||
|
mockUseConnectionState.mockReturnValue({
|
||||||
|
snapshot: {
|
||||||
|
...baseSnapshot,
|
||||||
|
state: "version_skew",
|
||||||
|
instanceId: "03c3e47f",
|
||||||
|
handshake: {
|
||||||
|
server: "bh",
|
||||||
|
version: mockDaemonVersion,
|
||||||
|
protocol_version: "1.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statusState: "version_skew",
|
||||||
|
setLabel,
|
||||||
|
setConnectionEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
expect(screen.getByText("已连接")).toBeTruthy();
|
||||||
|
expect(screen.getByText("可升级")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("协议不一致")).toBeNull();
|
||||||
|
expect(screen.queryByText("Action needed")).toBeNull();
|
||||||
|
expect(screen.queryByText("兼容")).toBeNull();
|
||||||
|
const warning = screen.getByText(/协议版本不同,请及时升级/);
|
||||||
|
expect(warning.textContent).toContain("CLI 协议");
|
||||||
|
expect(warning.textContent).toContain("扩展协议");
|
||||||
|
|
||||||
|
openRecordView();
|
||||||
|
const copyButton = screen.getByRole("button", { name: "复制录制指令" });
|
||||||
|
expect(copyButton.getAttribute("disabled")).toBeNull();
|
||||||
|
fireEvent.click(copyButton);
|
||||||
|
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("control hints toggle", () => {
|
describe("control hints toggle", () => {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export function App() {
|
|||||||
}, [copiedTick]);
|
}, [copiedTick]);
|
||||||
|
|
||||||
const isSkewed = statusState === "version_skew";
|
const isSkewed = statusState === "version_skew";
|
||||||
|
const connectionLive = statusState === "connected" || isSkewed;
|
||||||
const daemonVersion = snapshot.handshake?.version ?? "—";
|
const daemonVersion = snapshot.handshake?.version ?? "—";
|
||||||
const daemonProtocol = snapshot.handshake?.protocol_version ?? "—";
|
const daemonProtocol = snapshot.handshake?.protocol_version ?? "—";
|
||||||
const extensionVersion = snapshot.extensionVersion || "—";
|
const extensionVersion = snapshot.extensionVersion || "—";
|
||||||
@@ -90,7 +91,7 @@ export function App() {
|
|||||||
setCopiedInstanceId(true);
|
setCopiedInstanceId(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const recordReady = statusState === "connected" && Boolean(snapshot.instanceId);
|
const recordReady = connectionLive && Boolean(snapshot.instanceId);
|
||||||
const recordPurpose = purposeDraft.trim();
|
const recordPurpose = purposeDraft.trim();
|
||||||
const recordStartUrl = startUrlDraft.trim();
|
const recordStartUrl = startUrlDraft.trim();
|
||||||
const recordCommand = snapshot.instanceId
|
const recordCommand = snapshot.instanceId
|
||||||
@@ -206,12 +207,12 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
{isSkewed && (
|
{isSkewed && (
|
||||||
<p
|
<p
|
||||||
className="mt-2 text-xs leading-snug text-amber-600 dark:text-amber-400"
|
className="mt-2 text-xs leading-snug text-muted-foreground"
|
||||||
data-slot="popup-version-skew-warning"
|
data-slot="popup-version-skew-warning"
|
||||||
>
|
>
|
||||||
{t("popup.versionSkewWarning", {
|
{t("popup.versionSkewWarning", {
|
||||||
extensionProtocol: PROTOCOL_VERSION,
|
extensionProtocol: PROTOCOL_VERSION,
|
||||||
daemonProtocol,
|
cliProtocol: daemonProtocol,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -266,9 +267,9 @@ export function App() {
|
|||||||
data-slot="popup-meta"
|
data-slot="popup-meta"
|
||||||
>
|
>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<span title={t("popup.extensionVersionHint")}>{extensionVersion}</span>
|
<span title={t("popup.daemonVersionHint")}>CLI {daemonVersion}</span>
|
||||||
<span aria-hidden>/</span>
|
<span aria-hidden>/</span>
|
||||||
<span title={t("popup.daemonVersionHint")}>{daemonVersion}</span>
|
<span title={t("popup.extensionVersionHint")}>Ext {extensionVersion}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 items-center justify-end gap-1">
|
<div className="flex min-w-0 items-center justify-end gap-1">
|
||||||
<span className="shrink-0">{t("popup.instanceTitle")}</span>
|
<span className="shrink-0">{t("popup.instanceTitle")}</span>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
CAPTURE_SUPPRESS,
|
CAPTURE_SUPPRESS,
|
||||||
type CaptureSuppressMessage,
|
type CaptureSuppressMessage,
|
||||||
withOverlaysHiddenForCapture,
|
withExtensionOverlayHidden,
|
||||||
} from "../capture-suppress-bridge";
|
} from "../capture-suppress-bridge";
|
||||||
|
|
||||||
function recordingSendToTab(events: string[]) {
|
function recordingSendToTab(events: string[]) {
|
||||||
@@ -12,12 +12,12 @@ function recordingSendToTab(events: string[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("withOverlaysHiddenForCapture", () => {
|
describe("withExtensionOverlayHidden", () => {
|
||||||
it("sends begin → fn → end and returns fn's result", async () => {
|
it("sends begin → fn → end and returns fn's result", async () => {
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
const sendToTab = recordingSendToTab(events);
|
const sendToTab = recordingSendToTab(events);
|
||||||
|
|
||||||
const result = await withOverlaysHiddenForCapture(
|
const result = await withExtensionOverlayHidden(
|
||||||
7,
|
7,
|
||||||
async () => {
|
async () => {
|
||||||
events.push("capture");
|
events.push("capture");
|
||||||
@@ -37,7 +37,7 @@ describe("withOverlaysHiddenForCapture", () => {
|
|||||||
const sendToTab = recordingSendToTab(events);
|
const sendToTab = recordingSendToTab(events);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
withOverlaysHiddenForCapture(
|
withExtensionOverlayHidden(
|
||||||
7,
|
7,
|
||||||
async () => {
|
async () => {
|
||||||
events.push("capture");
|
events.push("capture");
|
||||||
@@ -55,7 +55,7 @@ describe("withOverlaysHiddenForCapture", () => {
|
|||||||
});
|
});
|
||||||
const fn = vi.fn(async () => "shot");
|
const fn = vi.fn(async () => "shot");
|
||||||
|
|
||||||
const result = await withOverlaysHiddenForCapture(7, fn, sendToTab);
|
const result = await withExtensionOverlayHidden(7, fn, sendToTab);
|
||||||
|
|
||||||
expect(result).toBe("shot");
|
expect(result).toBe("shot");
|
||||||
expect(fn).toHaveBeenCalledTimes(1);
|
expect(fn).toHaveBeenCalledTimes(1);
|
||||||
@@ -69,7 +69,7 @@ describe("withOverlaysHiddenForCapture", () => {
|
|||||||
return { type: CAPTURE_SUPPRESS, ok: true };
|
return { type: CAPTURE_SUPPRESS, ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await withOverlaysHiddenForCapture(7, async () => "shot", sendToTab);
|
const result = await withExtensionOverlayHidden(7, async () => "shot", sendToTab);
|
||||||
|
|
||||||
expect(result).toBe("shot");
|
expect(result).toBe("shot");
|
||||||
expect(sendToTab).toHaveBeenCalledTimes(2);
|
expect(sendToTab).toHaveBeenCalledTimes(2);
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createRandomUuid } from "../random-uuid";
|
||||||
|
|
||||||
|
describe("createRandomUuid", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses crypto.randomUUID when it is available", () => {
|
||||||
|
const randomUUID = vi.fn(() => "00000000-0000-4000-8000-000000000000");
|
||||||
|
vi.stubGlobal("crypto", { randomUUID, getRandomValues: vi.fn() });
|
||||||
|
|
||||||
|
expect(createRandomUuid()).toBe("00000000-0000-4000-8000-000000000000");
|
||||||
|
expect(randomUUID).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to getRandomValues and sets UUID v4 bits", () => {
|
||||||
|
const bytes = new Uint8Array(16).fill(0xff);
|
||||||
|
const getRandomValues = vi.fn((target: Uint8Array) => {
|
||||||
|
target.set(bytes);
|
||||||
|
return target;
|
||||||
|
});
|
||||||
|
vi.stubGlobal("crypto", { getRandomValues });
|
||||||
|
|
||||||
|
const uuid = createRandomUuid();
|
||||||
|
|
||||||
|
expect(uuid).toBe("ffffffff-ffff-4fff-bfff-ffffffffffff");
|
||||||
|
expect(getRandomValues).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Wire protocol and background-side helper for hiding the extension's
|
* Wire protocol and background-side helper for temporarily excluding the
|
||||||
* in-page overlay while a screenshot is captured, so captured frames only
|
* extension's in-page overlay. Screenshots use it to avoid captured chrome;
|
||||||
* contain the page itself.
|
* coordinate-driven native input uses it so Chrome hit-tests the page.
|
||||||
*
|
*
|
||||||
* The background wraps every capture in `withOverlaysHiddenForCapture`,
|
* The background wraps each bounded operation in `withExtensionOverlayHidden`,
|
||||||
* which sends `begin` (the content script hides the overlay host and only
|
* which sends `begin` (the content script hides the overlay host and only
|
||||||
* acks once the compositor has produced an overlay-free frame) and always
|
* acks once the compositor has produced an overlay-free frame) and always
|
||||||
* follows up with `end`, even when the capture throws. Tabs without the
|
* follows up with `end`, even when the capture throws. Tabs without the
|
||||||
@@ -46,7 +46,7 @@ const defaultSendToTab: CaptureSuppressSendToTab = (tabId, message) =>
|
|||||||
* (or any other `begin` failure) simply skips suppression — there is no
|
* (or any other `begin` failure) simply skips suppression — there is no
|
||||||
* overlay to hide in that tab.
|
* overlay to hide in that tab.
|
||||||
*/
|
*/
|
||||||
export async function withOverlaysHiddenForCapture<T>(
|
export async function withExtensionOverlayHidden<T>(
|
||||||
tabId: number,
|
tabId: number,
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
sendToTab: CaptureSuppressSendToTab = defaultSendToTab,
|
sendToTab: CaptureSuppressSendToTab = defaultSendToTab,
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
* between the content-script control overlay and the background SW.
|
* between the content-script control overlay and the background SW.
|
||||||
*
|
*
|
||||||
* Content script → background:
|
* Content script → background:
|
||||||
* - `{ kind: "overlay.ready" }` → background replies with the authoritative
|
* - `{ kind: "overlay.ready" }` → background decides the per-tab state first
|
||||||
* overlay state for the sender's window.
|
* (see overlayStateForTab) and pushes the authoritative overlay state for
|
||||||
|
* that tab. It also proactively pushes on tab create and control-mode
|
||||||
|
* change, so a tab usually receives state before it ever sends ready.
|
||||||
* - `{ kind: "overlay.interrupt", sessionId }` → background asks the
|
* - `{ kind: "overlay.interrupt", sessionId }` → background asks the
|
||||||
* daemon (via a `session.user_interrupt` WS event) to cancel
|
* daemon (via a `session.user_interrupt` WS event) to cancel
|
||||||
* every inflight + queued tool call for that session with
|
* every inflight + queued tool call for that session with
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Generate a UUID v4 in browser contexts where randomUUID() is unavailable.
|
||||||
|
*
|
||||||
|
* Crypto.randomUUID() is restricted to secure contexts, while
|
||||||
|
* crypto.getRandomValues() is available to content scripts on ordinary HTTP
|
||||||
|
* origins. BrowserSkill records many intranet pages served over HTTP, so the
|
||||||
|
* fallback is required for recording to work on those pages.
|
||||||
|
*/
|
||||||
|
export function createRandomUuid(): string {
|
||||||
|
const cryptoApi = globalThis.crypto;
|
||||||
|
if (typeof cryptoApi.randomUUID === "function") return cryptoApi.randomUUID();
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
cryptoApi.getRandomValues(bytes);
|
||||||
|
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||||
|
|
||||||
|
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
return [
|
||||||
|
hex.slice(0, 8),
|
||||||
|
hex.slice(8, 12),
|
||||||
|
hex.slice(12, 16),
|
||||||
|
hex.slice(16, 20),
|
||||||
|
hex.slice(20),
|
||||||
|
].join("-");
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createRandomUuid } from "../random-uuid";
|
||||||
import {
|
import {
|
||||||
isRecordFrameQueryMessage,
|
isRecordFrameQueryMessage,
|
||||||
RECORD_FRAME_PORT,
|
RECORD_FRAME_PORT,
|
||||||
@@ -188,7 +189,7 @@ export class RecordFrameCoordinator {
|
|||||||
const recording = this.#recordings.get(requestId);
|
const recording = this.#recordings.get(requestId);
|
||||||
if (!recording) return true;
|
if (!recording) return true;
|
||||||
recording.finishing = true;
|
recording.finishing = true;
|
||||||
const commandId = crypto.randomUUID();
|
const commandId = createRandomUuid();
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
[...recording.agents.values()].map((agent) => this.#stopAgent(agent, requestId, commandId)),
|
[...recording.agents.values()].map((agent) => this.#stopAgent(agent, requestId, commandId)),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ describe("disconnect session cleanup", () => {
|
|||||||
const manager = new SessionManager({
|
const manager = new SessionManager({
|
||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => nextWindowId++),
|
create: vi.fn(async () => nextWindowId++),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
remove,
|
remove,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -41,7 +41,7 @@ describe("disconnect session cleanup", () => {
|
|||||||
const manager = new SessionManager({
|
const manager = new SessionManager({
|
||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 100),
|
create: vi.fn(async () => 100),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
remove: vi.fn(() => removeGate),
|
remove: vi.fn(() => removeGate),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe("attachSessionEventHandler", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await manager.start("aa11");
|
await manager.start("aa11");
|
||||||
@@ -73,7 +73,7 @@ describe("attachSessionEventHandler", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const ctx = await manager.start("aa11");
|
const ctx = await manager.start("aa11");
|
||||||
@@ -87,6 +87,7 @@ describe("attachSessionEventHandler", () => {
|
|||||||
windowEvents: events.api,
|
windowEvents: events.api,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
manager.forgetClosedTab(7, { isWindowClosing: true });
|
||||||
events.emit(4242);
|
events.emit(4242);
|
||||||
for (let i = 0; i < 4; i += 1) await Promise.resolve();
|
for (let i = 0; i < 4; i += 1) await Promise.resolve();
|
||||||
|
|
||||||
@@ -113,7 +114,7 @@ describe("attachSessionEventHandler", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const transport = fakeTransport();
|
const transport = fakeTransport();
|
||||||
@@ -129,7 +130,7 @@ describe("attachSessionEventHandler", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const transport = fakeTransport();
|
const transport = fakeTransport();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { AgentWindowApi, AgentWindowCreateOptions } from "../agent-window";
|
import type { AgentWindowApi, AgentWindowCreateOptions } from "../agent-window";
|
||||||
import { SessionManager } from "../manager";
|
import { isAgentControlledTab, SessionManager } from "../manager";
|
||||||
|
|
||||||
function fakeAgentWindow(): AgentWindowApi & {
|
function fakeAgentWindow(): AgentWindowApi & {
|
||||||
createMock: ReturnType<typeof vi.fn>;
|
createMock: ReturnType<typeof vi.fn>;
|
||||||
@@ -13,7 +13,7 @@ function fakeAgentWindow(): AgentWindowApi & {
|
|||||||
return id;
|
return id;
|
||||||
});
|
});
|
||||||
const removeMock = vi.fn(async (_id: number) => {});
|
const removeMock = vi.fn(async (_id: number) => {});
|
||||||
const ensureActiveTabMock = vi.fn(async (_windowId: number, _url: string) => {});
|
const ensureActiveTabMock = vi.fn(async (_windowId: number, _url: string) => 0);
|
||||||
return {
|
return {
|
||||||
create: createMock,
|
create: createMock,
|
||||||
remove: removeMock,
|
remove: removeMock,
|
||||||
@@ -151,6 +151,76 @@ describe("SessionManager", () => {
|
|||||||
expect(sm.list()).toEqual([]);
|
expect(sm.list()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("explicit tab claims", () => {
|
||||||
|
it("claims the exact home tab returned by AgentWindowApi", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
expect(ctx.agentCreatedTabs).toEqual(new Set([0]));
|
||||||
|
expect(isAgentControlledTab(ctx, 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every other tab free without classification state", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
expect(isAgentControlledTab(ctx, 99)).toBe(false);
|
||||||
|
expect(ctx).not.toHaveProperty("userTabs");
|
||||||
|
expect(ctx).not.toHaveProperty("pendingAgentTabCount");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a committed borrow as an explicit claim", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
const reservation = sm.tryReserveBorrow(42, ctx.sessionId);
|
||||||
|
if ("borrowedBy" in reservation) throw new Error("unexpected borrow conflict");
|
||||||
|
reservation.commit({ tabId: 42, originalWindowId: 7, originalIndex: 3 });
|
||||||
|
expect(isAgentControlledTab(ctx, 42)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forgets an agent-created tab after Chrome removes it", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.agentCreatedTabs.add(42);
|
||||||
|
sm.forgetClosedTab(42);
|
||||||
|
expect(ctx.agentCreatedTabs.has(42)).toBe(false);
|
||||||
|
expect(isAgentControlledTab(ctx, 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forgets closed borrows idempotently without affecting other tabs or sessions", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||||
|
const a = await sm.start("aa11");
|
||||||
|
const b = await sm.start("bb22");
|
||||||
|
a.borrowedTabs.set(42, { tabId: 42, originalWindowId: 7, originalIndex: 0 });
|
||||||
|
a.borrowedTabs.set(43, { tabId: 43, originalWindowId: 7, originalIndex: 1 });
|
||||||
|
b.borrowedTabs.set(44, { tabId: 44, originalWindowId: 8, originalIndex: 0 });
|
||||||
|
|
||||||
|
sm.forgetClosedTab(42);
|
||||||
|
sm.forgetClosedTab(42);
|
||||||
|
sm.forgetClosedTab(999);
|
||||||
|
|
||||||
|
expect(isAgentControlledTab(a, 42)).toBe(false);
|
||||||
|
expect(sm.findBorrowingSession(42, null)).toBeNull();
|
||||||
|
expect([...a.borrowedTabs.keys()]).toEqual([43]);
|
||||||
|
expect([...b.borrowedTabs.keys()]).toEqual([44]);
|
||||||
|
expect(sm.list()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevents an in-flight borrow from reclaiming a closed tab", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
const reservation = sm.tryReserveBorrow(42, ctx.sessionId);
|
||||||
|
if ("borrowedBy" in reservation) throw new Error("unexpected borrow conflict");
|
||||||
|
|
||||||
|
sm.forgetClosedTab(42);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
reservation.commit({ tabId: 42, originalWindowId: 7, originalIndex: 0 }),
|
||||||
|
).toThrow(/reservation disappeared/);
|
||||||
|
reservation.release();
|
||||||
|
expect(sm.findBorrowingSession(42, null)).toBeNull();
|
||||||
|
expect(ctx.borrowedTabs.has(42)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("findBorrowingSession", () => {
|
describe("findBorrowingSession", () => {
|
||||||
it("returns null when no session has borrowed the tab", async () => {
|
it("returns null when no session has borrowed the tab", async () => {
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ export interface AgentWindowApi {
|
|||||||
* Guarantee the Agent Window has an active, CDP-navigable tab.
|
* Guarantee the Agent Window has an active, CDP-navigable tab.
|
||||||
* `chrome://` pages (including the New Tab page) reject `Page.navigate`,
|
* `chrome://` pages (including the New Tab page) reject `Page.navigate`,
|
||||||
* so sessions bootstrap with `about:blank` instead.
|
* so sessions bootstrap with `about:blank` instead.
|
||||||
|
*
|
||||||
|
* Resolves with the id of the activated (or newly created) tab, so callers
|
||||||
|
* can track the session's home tab without re-querying Chrome.
|
||||||
*/
|
*/
|
||||||
ensureActiveTab(windowId: number, url: string): Promise<void>;
|
ensureActiveTab(windowId: number, url: string): Promise<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creation hints for a new Agent Window. */
|
/** Creation hints for a new Agent Window. */
|
||||||
@@ -49,15 +52,19 @@ export const chromeAgentWindowApi: AgentWindowApi = {
|
|||||||
// cancellation success while the Agent Window remains open.
|
// cancellation success while the Agent Window remains open.
|
||||||
await chrome.windows.remove(windowId);
|
await chrome.windows.remove(windowId);
|
||||||
},
|
},
|
||||||
async ensureActiveTab(windowId: number, url: string): Promise<void> {
|
async ensureActiveTab(windowId: number, url: string): Promise<number> {
|
||||||
const tabs = await chrome.tabs.query({ windowId });
|
const tabs = await chrome.tabs.query({ windowId });
|
||||||
const first = tabs.find((t) => typeof t.id === "number");
|
const first = tabs.find((t) => typeof t.id === "number");
|
||||||
if (first?.id) {
|
if (first?.id !== undefined) {
|
||||||
if (!first.active) {
|
if (!first.active) {
|
||||||
await chrome.tabs.update(first.id, { active: true });
|
await chrome.tabs.update(first.id, { active: true });
|
||||||
}
|
}
|
||||||
return;
|
return first.id;
|
||||||
}
|
}
|
||||||
await chrome.tabs.create({ windowId, url, active: true });
|
const created = await chrome.tabs.create({ windowId, url, active: true });
|
||||||
|
if (typeof created?.id !== "number") {
|
||||||
|
throw new Error("[bh] chrome.tabs.create returned no tab id");
|
||||||
|
}
|
||||||
|
return created.id;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,9 +6,20 @@ export interface SessionContext {
|
|||||||
agentWindowId: number;
|
agentWindowId: number;
|
||||||
refStore: RefStore;
|
refStore: RefStore;
|
||||||
borrowedTabs: Map<number, BorrowedTab>;
|
borrowedTabs: Map<number, BorrowedTab>;
|
||||||
|
/**
|
||||||
|
* Tabs explicitly claimed by the agent because it created them. This
|
||||||
|
* includes the Agent Window's home tab and tabs created by `tool.tab_create`.
|
||||||
|
* Tabs opened through Chrome UI never enter this set.
|
||||||
|
*/
|
||||||
|
agentCreatedTabs: Set<number>;
|
||||||
createdAtMs: number;
|
createdAtMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether this session has explicitly claimed control of `tabId`. */
|
||||||
|
export function isAgentControlledTab(ctx: SessionContext, tabId: number): boolean {
|
||||||
|
return ctx.agentCreatedTabs.has(tabId) || ctx.borrowedTabs.has(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
export interface BorrowedTab {
|
export interface BorrowedTab {
|
||||||
tabId: number;
|
tabId: number;
|
||||||
originalWindowId: number;
|
originalWindowId: number;
|
||||||
@@ -106,6 +117,19 @@ export class SessionManager {
|
|||||||
return Array.from(this.sessions.values());
|
return Array.from(this.sessions.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget a tab Chrome has removed, including any uncommitted borrow.
|
||||||
|
* Whole-window closures keep committed borrows until the window-removed
|
||||||
|
* handler reports which user tabs could not be returned.
|
||||||
|
*/
|
||||||
|
forgetClosedTab(tabId: number, { isWindowClosing = false } = {}): void {
|
||||||
|
this.borrowReservations.delete(tabId);
|
||||||
|
for (const ctx of this.sessions.values()) {
|
||||||
|
ctx.agentCreatedTabs.delete(tabId);
|
||||||
|
if (!isWindowClosing) ctx.borrowedTabs.delete(tabId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Look up whether `tabId` is currently borrowed by some *other*
|
* Look up whether `tabId` is currently borrowed by some *other*
|
||||||
* session than the one calling. Used by M8 `tab_borrow` to refuse
|
* session than the one calling. Used by M8 `tab_borrow` to refuse
|
||||||
@@ -179,7 +203,7 @@ export class SessionManager {
|
|||||||
const { signal: _signal, ...createOptions } = opts;
|
const { signal: _signal, ...createOptions } = opts;
|
||||||
windowId = await this.agentWindow.create(AGENT_WINDOW_HOME, createOptions);
|
windowId = await this.agentWindow.create(AGENT_WINDOW_HOME, createOptions);
|
||||||
throwIfSessionStartAborted(opts.signal);
|
throwIfSessionStartAborted(opts.signal);
|
||||||
await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
|
const homeTabId = await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
|
||||||
throwIfSessionStartAborted(opts.signal);
|
throwIfSessionStartAborted(opts.signal);
|
||||||
|
|
||||||
const ctx: SessionContext = {
|
const ctx: SessionContext = {
|
||||||
@@ -187,6 +211,10 @@ export class SessionManager {
|
|||||||
agentWindowId: windowId,
|
agentWindowId: windowId,
|
||||||
refStore: new RefStore(),
|
refStore: new RefStore(),
|
||||||
borrowedTabs: new Map(),
|
borrowedTabs: new Map(),
|
||||||
|
// The home tab is the session's first explicit claim. Every other
|
||||||
|
// tab remains free until `tab_create` or `tab_borrow` identifies it
|
||||||
|
// by its concrete Chrome tab id.
|
||||||
|
agentCreatedTabs: new Set([homeTabId]),
|
||||||
createdAtMs: this.now(),
|
createdAtMs: this.now(),
|
||||||
};
|
};
|
||||||
this.sessions.set(sessionId, ctx);
|
this.sessions.set(sessionId, ctx);
|
||||||
|
|||||||
@@ -127,6 +127,38 @@ describe("requestBorrowConfirmation", () => {
|
|||||||
expect(message.requestId.length).toBeGreaterThan(0);
|
expect(message.requestId.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("proactively focuses the user window before sending the overlay message", async () => {
|
||||||
|
tabs.get.mockResolvedValueOnce({ id: 42, title: "Tab" });
|
||||||
|
windows.getLastFocused.mockResolvedValueOnce(
|
||||||
|
userWindowWithActiveTab({ windowId: 77, tabId: 7, url: "https://app.example/" }),
|
||||||
|
);
|
||||||
|
tabs.sendMessage.mockResolvedValueOnce({ type: "borrow-response", allowed: true });
|
||||||
|
|
||||||
|
const pending = requestBorrowConfirmation(42, {
|
||||||
|
deps: { tabs, windows, notifications },
|
||||||
|
});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
expect(windows.update).toHaveBeenCalledWith(77, { focused: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still proceeds when proactive window focus fails", async () => {
|
||||||
|
tabs.get.mockResolvedValueOnce({ id: 42, title: "Tab" });
|
||||||
|
windows.getLastFocused.mockResolvedValueOnce(
|
||||||
|
userWindowWithActiveTab({ windowId: 77, tabId: 7, url: "https://app.example/" }),
|
||||||
|
);
|
||||||
|
windows.update.mockRejectedValueOnce(new Error("window gone"));
|
||||||
|
tabs.sendMessage.mockResolvedValueOnce({ type: "borrow-response", allowed: true });
|
||||||
|
|
||||||
|
const pending = requestBorrowConfirmation(42, {
|
||||||
|
deps: { tabs, windows, notifications },
|
||||||
|
});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(await pending).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("sends confirmation to the borrow target when it is the active tab", async () => {
|
it("sends confirmation to the borrow target when it is the active tab", async () => {
|
||||||
tabs.get.mockResolvedValueOnce({ id: 42, title: "Active Tab" });
|
tabs.get.mockResolvedValueOnce({ id: 42, title: "Active Tab" });
|
||||||
windows.getLastFocused.mockResolvedValueOnce(
|
windows.getLastFocused.mockResolvedValueOnce(
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
}),
|
}),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,13 +49,54 @@ describe("ToolDispatcher", () => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"tool.snapshot",
|
||||||
|
"tool.evaluate",
|
||||||
|
"tool.get_html",
|
||||||
|
"tool.console",
|
||||||
|
])("annotates Chrome extension-access failures from %s", async (method) => {
|
||||||
|
const tab = { id: 7, windowId: 4242, active: true, url: "https://example.test" };
|
||||||
|
vi.stubGlobal("chrome", {
|
||||||
|
tabs: {
|
||||||
|
get: vi.fn(async () => tab),
|
||||||
|
query: vi.fn(async () => [tab]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { transport, sent, deliver } = fakeTransport();
|
||||||
|
const sessions = new SessionManager({
|
||||||
|
agentWindow: {
|
||||||
|
create: vi.fn(async () => 4242),
|
||||||
|
remove: vi.fn(),
|
||||||
|
ensureActiveTab: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await sessions.start("test");
|
||||||
|
const error = new Error("Cannot access a chrome-extension:// URL of different extension");
|
||||||
|
const cdp = {
|
||||||
|
send: vi.fn().mockRejectedValue(error),
|
||||||
|
ensureConsoleCapture: vi.fn().mockRejectedValue(error),
|
||||||
|
consoleEntriesSince: vi.fn(),
|
||||||
|
} as unknown as TestDispatcherCdp;
|
||||||
|
const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
|
||||||
|
dispatcher.start();
|
||||||
|
deliver(makeRequest(method, { session_id: "test", expression: "document.title" }));
|
||||||
|
await vi.waitFor(() => expect(sent).toHaveLength(1));
|
||||||
|
expect(sent[0]).toMatchObject({
|
||||||
|
error: {
|
||||||
|
code: "cdp_failed",
|
||||||
|
data: { reason: "cdp_extension_access_denied" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dispatcher.stop();
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the configured recording runtime for start and stop", async () => {
|
it("uses the configured recording runtime for start and stop", async () => {
|
||||||
const { transport, sent, deliver } = fakeTransport();
|
const { transport, sent, deliver } = fakeTransport();
|
||||||
const sessions = new SessionManager({
|
const sessions = new SessionManager({
|
||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await sessions.start("aa11");
|
await sessions.start("aa11");
|
||||||
@@ -117,7 +158,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
@@ -140,7 +181,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create,
|
create,
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
@@ -158,7 +199,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await sessions.start("aa11");
|
await sessions.start("aa11");
|
||||||
@@ -172,6 +213,47 @@ describe("ToolDispatcher", () => {
|
|||||||
expect(sent[0]).toEqual({ id: "r-1", result: {} });
|
expect(sent[0]).toEqual({ id: "r-1", result: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("wires the production tab APIs into tool.session_stop so a surviving user tab releases the window", async () => {
|
||||||
|
// Regression guard for the deps that session_stop reads directly: when
|
||||||
|
// `tabManagement.tabs` / `tabsQuery` are not injected by the dispatcher,
|
||||||
|
// the agent-tab cleanup and the release decision silently no-op and the
|
||||||
|
// Agent Window is closed even though a user tab is still open (issue #57).
|
||||||
|
const removeTab = vi.fn(async () => {});
|
||||||
|
const closeWindow = vi.fn(async () => {});
|
||||||
|
// After cleanup the window still holds one tab (id 99) that is neither
|
||||||
|
// the home tab nor an agent-created tab — i.e. a genuine user tab.
|
||||||
|
vi.stubGlobal("chrome", {
|
||||||
|
tabs: {
|
||||||
|
remove: removeTab,
|
||||||
|
query: vi.fn(async () => [{ id: 99, windowId: 4242, active: true }]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { transport, sent, deliver } = fakeTransport();
|
||||||
|
const sessions = new SessionManager({
|
||||||
|
agentWindow: {
|
||||||
|
create: vi.fn(async () => 4242),
|
||||||
|
remove: closeWindow,
|
||||||
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await sessions.start("aa11");
|
||||||
|
// Register the exact tab id returned by the agent's creation path.
|
||||||
|
sessions.get("aa11")?.agentCreatedTabs.add(7);
|
||||||
|
|
||||||
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
|
dispatcher.start();
|
||||||
|
|
||||||
|
deliver(makeRequest("tool.session_stop", { session_id: "aa11" }));
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
// The agent tab is closed through the real chrome.tabs surface...
|
||||||
|
expect(removeTab).toHaveBeenCalledWith(7);
|
||||||
|
// ...and the window is released rather than closed, so the user's tab survives.
|
||||||
|
expect(closeWindow).not.toHaveBeenCalled();
|
||||||
|
expect(sessions.has("aa11")).toBe(false);
|
||||||
|
expect(sent[0]).toEqual({ id: "r-1", result: { window_released: true } });
|
||||||
|
});
|
||||||
|
|
||||||
it("routes tool.console through the CDP console buffer", async () => {
|
it("routes tool.console through the CDP console buffer", async () => {
|
||||||
vi.stubGlobal("chrome", {
|
vi.stubGlobal("chrome", {
|
||||||
tabs: {
|
tabs: {
|
||||||
@@ -183,7 +265,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await sessions.start("aa11");
|
await sessions.start("aa11");
|
||||||
@@ -232,13 +314,114 @@ describe("ToolDispatcher", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("bypasses and restores the control overlay for an upload trigger click", async () => {
|
||||||
|
const sendMessage = vi.fn(async () => undefined);
|
||||||
|
vi.stubGlobal("chrome", {
|
||||||
|
tabs: {
|
||||||
|
get: vi.fn(async () => ({ id: 7, windowId: 4242, active: true })),
|
||||||
|
query: vi.fn(async () => [{ id: 7, windowId: 4242, active: true }]),
|
||||||
|
sendMessage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { transport, sent, deliver } = fakeTransport();
|
||||||
|
const sessions = new SessionManager({
|
||||||
|
agentWindow: {
|
||||||
|
create: vi.fn(async () => 4242),
|
||||||
|
remove: vi.fn(async () => {}),
|
||||||
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ctx = await sessions.start("aa11");
|
||||||
|
ctx.refStore.set("e1", 123, { tabId: 7 });
|
||||||
|
const send = vi.fn(async <T>(_tabId: number, method: string, params?: object) => {
|
||||||
|
if (method === "Page.getLayoutMetrics") {
|
||||||
|
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } } as T;
|
||||||
|
}
|
||||||
|
if (method === "DOM.getContentQuads") {
|
||||||
|
return { quads: [[0, 0, 20, 0, 20, 20, 0, 20]] } as T;
|
||||||
|
}
|
||||||
|
if (method === "DOM.resolveNode") {
|
||||||
|
return { object: { objectId: "trigger-object" } } as T;
|
||||||
|
}
|
||||||
|
if (method === "DOM.describeNode") return { node: { backendNodeId: 456 } } as T;
|
||||||
|
if (method === "Runtime.callFunctionOn") {
|
||||||
|
const declaration = (params as { functionDeclaration?: string }).functionDeclaration ?? "";
|
||||||
|
if (declaration.includes("count: state.inputs.length")) {
|
||||||
|
return { result: { value: { count: 1, multiple: false } } } as T;
|
||||||
|
}
|
||||||
|
if (declaration.includes("inputs[0]")) {
|
||||||
|
return { result: { objectId: "input-object" } } as T;
|
||||||
|
}
|
||||||
|
return { result: { value: true } } as T;
|
||||||
|
}
|
||||||
|
if (method === "Runtime.evaluate") {
|
||||||
|
const expression = (params as { expression?: string }).expression ?? "";
|
||||||
|
if (expression.includes("overlayDetails")) {
|
||||||
|
return { result: { value: { hitIndex: 0 } } } as T;
|
||||||
|
}
|
||||||
|
if (expression.includes("overlayHostPresent")) {
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
value: { overlayHostPresent: true, overlayHostConnected: true },
|
||||||
|
},
|
||||||
|
} as T;
|
||||||
|
}
|
||||||
|
if (expression.includes("count:")) {
|
||||||
|
return { result: { value: { count: 1, multiple: false } } } as T;
|
||||||
|
}
|
||||||
|
if (expression.includes("?.inputs[0]")) {
|
||||||
|
return { result: { objectId: "input-object" } } as T;
|
||||||
|
}
|
||||||
|
return { result: { value: true } } as T;
|
||||||
|
}
|
||||||
|
return {} as T;
|
||||||
|
});
|
||||||
|
const cdp = {
|
||||||
|
send,
|
||||||
|
detachSession: vi.fn(async () => {}),
|
||||||
|
ensureNetworkCapture: vi.fn(async () => {}),
|
||||||
|
networkEntriesSince: vi.fn(() => ({
|
||||||
|
tab_id: 7,
|
||||||
|
entries: [],
|
||||||
|
next_since: 0,
|
||||||
|
truncated: false,
|
||||||
|
})),
|
||||||
|
setDeviceMetricsOverride: vi.fn(async () => {}),
|
||||||
|
clearDeviceMetricsOverride: vi.fn(async () => {}),
|
||||||
|
setUserAgentOverride: vi.fn(async () => {}),
|
||||||
|
setTouchEmulationEnabled: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const dispatcher = new ToolDispatcher({ transport, sessions, cdp: cdp as TestDispatcherCdp });
|
||||||
|
dispatcher.start();
|
||||||
|
|
||||||
|
deliver(
|
||||||
|
makeRequest("tool.upload", {
|
||||||
|
session_id: "aa11",
|
||||||
|
ref: "@e1",
|
||||||
|
files: [{ transfer_id: "tr_1", name: "test.png", staged_path: "/stage/test.png" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await flushMicrotasks();
|
||||||
|
await vi.waitFor(() => expect(sent).toHaveLength(1));
|
||||||
|
|
||||||
|
expect(sent[0]).toMatchObject({ result: { tab_id: 7, file_names: ["test.png"] } });
|
||||||
|
expect(sendMessage).toHaveBeenNthCalledWith(1, 7, {
|
||||||
|
type: "bh-automation-bypass",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
expect(sendMessage).toHaveBeenNthCalledWith(2, 7, {
|
||||||
|
type: "bh-automation-bypass",
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("detaches CDP state before stopping a session", async () => {
|
it("detaches CDP state before stopping a session", async () => {
|
||||||
const { transport, sent, deliver } = fakeTransport();
|
const { transport, sent, deliver } = fakeTransport();
|
||||||
const sessions = new SessionManager({
|
const sessions = new SessionManager({
|
||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await sessions.start("aa11");
|
await sessions.start("aa11");
|
||||||
@@ -274,7 +457,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove,
|
remove,
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await sessions.start("aa11");
|
await sessions.start("aa11");
|
||||||
@@ -322,7 +505,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
@@ -342,7 +525,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
@@ -366,7 +549,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
@@ -383,7 +566,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
@@ -400,7 +583,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const onSessionsChanged = vi.fn();
|
const onSessionsChanged = vi.fn();
|
||||||
@@ -417,16 +600,32 @@ describe("ToolDispatcher", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("invokes onBrowserControlResumed for browser-control tools but not passive reads", async () => {
|
it("invokes onBrowserControlResumed for browser-control tools but not passive reads", async () => {
|
||||||
|
vi.stubGlobal("chrome", {
|
||||||
|
tabs: {
|
||||||
|
create: vi.fn(async (props: chrome.tabs.CreateProperties) => ({
|
||||||
|
id: 7,
|
||||||
|
windowId: props.windowId,
|
||||||
|
url: props.url,
|
||||||
|
active: props.active,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
const { transport, sent, deliver } = fakeTransport();
|
const { transport, sent, deliver } = fakeTransport();
|
||||||
const sessions = new SessionManager({
|
const sessions = new SessionManager({
|
||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const onBrowserControlResumed = vi.fn();
|
const onBrowserControlResumed = vi.fn();
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions, onBrowserControlResumed });
|
const onAgentTabClaimed = vi.fn();
|
||||||
|
const dispatcher = new ToolDispatcher({
|
||||||
|
transport,
|
||||||
|
sessions,
|
||||||
|
onBrowserControlResumed,
|
||||||
|
onAgentTabClaimed,
|
||||||
|
});
|
||||||
dispatcher.start();
|
dispatcher.start();
|
||||||
|
|
||||||
deliver(makeRequest("tool.session_start", { session_id: "aa11" }));
|
deliver(makeRequest("tool.session_start", { session_id: "aa11" }));
|
||||||
@@ -443,6 +642,7 @@ describe("ToolDispatcher", () => {
|
|||||||
});
|
});
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
expect(onBrowserControlResumed).toHaveBeenCalledWith("aa11");
|
expect(onBrowserControlResumed).toHaveBeenCalledWith("aa11");
|
||||||
|
expect(onAgentTabClaimed).toHaveBeenCalledWith(7, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reasserts remembered hover before follow-up work and releases only after actions", async () => {
|
it("reasserts remembered hover before follow-up work and releases only after actions", async () => {
|
||||||
@@ -456,7 +656,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
@@ -538,7 +738,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const cdp = {
|
const cdp = {
|
||||||
@@ -588,6 +788,135 @@ describe("ToolDispatcher", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ tab_id: 7, code: "not_found" },
|
||||||
|
{ tab_id: undefined, code: "invalid_params" },
|
||||||
|
])("keeps hover when tab_return is rejected with $code", async ({ tab_id, code }) => {
|
||||||
|
const sendMessage = vi.fn(async () => undefined);
|
||||||
|
vi.stubGlobal("chrome", {
|
||||||
|
tabs: {
|
||||||
|
sendMessage,
|
||||||
|
get: vi.fn(async () => ({ id: 7, windowId: 100, active: true })),
|
||||||
|
query: vi.fn(async () => [{ id: 7, windowId: 100, active: true }]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { transport, sent, deliver } = fakeTransport();
|
||||||
|
const sessions = new SessionManager({
|
||||||
|
agentWindow: {
|
||||||
|
create: vi.fn(async () => 100),
|
||||||
|
remove: vi.fn(async () => {}),
|
||||||
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await sessions.start("aa11");
|
||||||
|
const cdp = {
|
||||||
|
send: vi.fn(async <T>() => ({}) as T),
|
||||||
|
detachSession: vi.fn(async () => {}),
|
||||||
|
releaseSessionTab: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const dispatcher = new ToolDispatcher({
|
||||||
|
transport,
|
||||||
|
sessions,
|
||||||
|
cdp: cdp as unknown as TestDispatcherCdp,
|
||||||
|
});
|
||||||
|
const helpers = dispatcher as unknown as {
|
||||||
|
rememberHover: (sessionId: string, result: unknown) => unknown;
|
||||||
|
setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise<void>;
|
||||||
|
withHoverReassert: <T>(
|
||||||
|
params: { session_id: string; tab_id?: number },
|
||||||
|
work: () => Promise<T>,
|
||||||
|
) => Promise<T>;
|
||||||
|
};
|
||||||
|
helpers.rememberHover("aa11", { tab_id: 7, x: 10, y: 20 });
|
||||||
|
await helpers.setHoverBypass("aa11", 7, true);
|
||||||
|
dispatcher.start();
|
||||||
|
deliver(makeRequest("tool.tab_return", { session_id: "aa11", tab_id }));
|
||||||
|
await vi.waitFor(() => expect(sent).toHaveLength(1));
|
||||||
|
|
||||||
|
expect(sent[0]).toMatchObject({ error: { code } });
|
||||||
|
expect(cdp.releaseSessionTab).not.toHaveBeenCalled();
|
||||||
|
expect(sendMessage).not.toHaveBeenCalledWith(7, expect.objectContaining({ enabled: false }));
|
||||||
|
await helpers.withHoverReassert({ session_id: "aa11", tab_id: 7 }, async () => ({}));
|
||||||
|
expect(cdp.send).toHaveBeenCalledExactlyOnceWith(7, "Input.dispatchMouseEvent", {
|
||||||
|
type: "mouseMoved",
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
});
|
||||||
|
dispatcher.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases the returned tab's hover before moving it and leaves other tabs alone", async () => {
|
||||||
|
const sendMessage = vi.fn(async () => undefined);
|
||||||
|
const move = vi.fn(async () => {
|
||||||
|
expect(sendMessage).toHaveBeenCalledWith(7, expect.objectContaining({ enabled: false }));
|
||||||
|
return { id: 7, windowId: 200, index: 4 };
|
||||||
|
});
|
||||||
|
vi.stubGlobal("chrome", {
|
||||||
|
tabs: { sendMessage, move },
|
||||||
|
windows: { get: vi.fn(async () => ({ id: 200 })) },
|
||||||
|
});
|
||||||
|
const { transport, sent, deliver } = fakeTransport();
|
||||||
|
const sessions = new SessionManager({
|
||||||
|
agentWindow: {
|
||||||
|
create: vi.fn(async () => 100),
|
||||||
|
remove: vi.fn(async () => {}),
|
||||||
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ctx = await sessions.start("aa11");
|
||||||
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
||||||
|
const cdp = {
|
||||||
|
send: vi.fn(async <T>() => ({}) as T),
|
||||||
|
detachSession: vi.fn(async () => {}),
|
||||||
|
releaseSessionTab: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const dispatcher = new ToolDispatcher({
|
||||||
|
transport,
|
||||||
|
sessions,
|
||||||
|
cdp: cdp as unknown as TestDispatcherCdp,
|
||||||
|
});
|
||||||
|
const helpers = dispatcher as unknown as {
|
||||||
|
rememberHover: (sessionId: string, result: unknown) => unknown;
|
||||||
|
setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise<void>;
|
||||||
|
withHoverReassert: <T>(
|
||||||
|
params: { session_id: string; tab_id?: number },
|
||||||
|
work: () => Promise<T>,
|
||||||
|
) => Promise<T>;
|
||||||
|
};
|
||||||
|
for (const [sessionId, tabId] of [
|
||||||
|
["aa11", 7],
|
||||||
|
["aa11", 8],
|
||||||
|
["bb22", 9],
|
||||||
|
] as const) {
|
||||||
|
helpers.rememberHover(sessionId, { tab_id: tabId, x: 10, y: 20 });
|
||||||
|
await helpers.setHoverBypass(sessionId, tabId, true);
|
||||||
|
}
|
||||||
|
dispatcher.start();
|
||||||
|
deliver(makeRequest("tool.tab_return", { session_id: "aa11", tab_id: 7 }));
|
||||||
|
await vi.waitFor(() => expect(sent).toHaveLength(1));
|
||||||
|
|
||||||
|
expect(sent[0]).toMatchObject({ result: { tab_id: 7, returned_to_window_id: 200 } });
|
||||||
|
expect(cdp.releaseSessionTab).toHaveBeenCalledExactlyOnceWith("aa11", 7);
|
||||||
|
await helpers.withHoverReassert({ session_id: "aa11", tab_id: 7 }, async () => ({}));
|
||||||
|
expect(cdp.send).not.toHaveBeenCalled();
|
||||||
|
for (const [sessionId, tabId] of [
|
||||||
|
["aa11", 8],
|
||||||
|
["bb22", 9],
|
||||||
|
] as const) {
|
||||||
|
expect(sendMessage).not.toHaveBeenCalledWith(
|
||||||
|
tabId,
|
||||||
|
expect.objectContaining({ enabled: false }),
|
||||||
|
);
|
||||||
|
await helpers.withHoverReassert({ session_id: sessionId, tab_id: tabId }, async () => ({}));
|
||||||
|
expect(cdp.send).toHaveBeenLastCalledWith(tabId, "Input.dispatchMouseEvent", {
|
||||||
|
type: "mouseMoved",
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dispatcher.stop();
|
||||||
|
});
|
||||||
|
|
||||||
it("transfers hover overlay bypass ownership between sessions", async () => {
|
it("transfers hover overlay bypass ownership between sessions", async () => {
|
||||||
vi.stubGlobal("chrome", {
|
vi.stubGlobal("chrome", {
|
||||||
tabs: {
|
tabs: {
|
||||||
@@ -599,7 +928,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
@@ -636,7 +965,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await sessions.start("aa11");
|
await sessions.start("aa11");
|
||||||
@@ -688,7 +1017,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
@@ -722,7 +1051,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 4242),
|
create: vi.fn(async () => 4242),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
transport.send = () => {
|
transport.send = () => {
|
||||||
@@ -744,7 +1073,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 5555),
|
create: vi.fn(async () => 5555),
|
||||||
remove,
|
remove,
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
transport.send = () => {
|
transport.send = () => {
|
||||||
@@ -772,7 +1101,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(() => createPromise),
|
create: vi.fn(() => createPromise),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
@@ -823,7 +1152,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(async () => 1),
|
create: vi.fn(async () => 1),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
@@ -844,7 +1173,7 @@ describe("ToolDispatcher", () => {
|
|||||||
agentWindow: {
|
agentWindow: {
|
||||||
create: vi.fn(() => createPromise),
|
create: vi.fn(() => createPromise),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
}),
|
}),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { RpcError } from "@/transport/types";
|
||||||
|
import { cdpError, classifyCdpError } from "../errors";
|
||||||
|
|
||||||
|
const denied = "Cannot access a chrome-extension:// URL of different extension";
|
||||||
|
|
||||||
|
describe("CDP extension access errors", () => {
|
||||||
|
it("classifies both Chrome Error instances and string failures", () => {
|
||||||
|
for (const error of [new Error(denied), denied]) {
|
||||||
|
expect(cdpError(error)).toEqual({
|
||||||
|
code: "cdp_failed",
|
||||||
|
message: denied,
|
||||||
|
data: { reason: "cdp_extension_access_denied" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves transfer outcome and cleanup instructions when CDP was denied", () => {
|
||||||
|
const error: RpcError = {
|
||||||
|
code: "cdp_failed",
|
||||||
|
message: denied,
|
||||||
|
data: {
|
||||||
|
reason: "transfer_outcome_unknown",
|
||||||
|
effect_state: "unknown",
|
||||||
|
cleanup_state: "failed",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(classifyCdpError(error)).toBe(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves other error codes and unrelated debugger failures unchanged", () => {
|
||||||
|
for (const error of [
|
||||||
|
{ code: "permission_denied", message: denied },
|
||||||
|
{ code: "cdp_failed", message: "Another debugger is already attached" },
|
||||||
|
] satisfies RpcError[]) {
|
||||||
|
expect(classifyCdpError(error)).toBe(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,7 +12,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
}),
|
}),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,988 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { SessionManager } from "@/session-manager/manager";
|
||||||
|
import { type DownloadsApi, handleDownload } from "../download";
|
||||||
|
import { captureBrowserDownload } from "../download-capture";
|
||||||
|
import { uploadThroughFileDrop } from "../file-drop-transaction";
|
||||||
|
import { uploadThroughActivatedFileInput } from "../file-input-transaction";
|
||||||
|
import type { ResolvedActionTarget } from "../interaction";
|
||||||
|
import type { CdpRunner } from "../shared";
|
||||||
|
import { handleUpload } from "../upload";
|
||||||
|
|
||||||
|
function sessions() {
|
||||||
|
return new SessionManager({
|
||||||
|
agentWindow: {
|
||||||
|
create: vi.fn(async () => 100),
|
||||||
|
remove: vi.fn(async () => {}),
|
||||||
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabsApi() {
|
||||||
|
return {
|
||||||
|
get: vi.fn(
|
||||||
|
async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab,
|
||||||
|
),
|
||||||
|
query: vi.fn(async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeEvent<T extends (...args: never[]) => unknown>() {
|
||||||
|
const listeners = new Set<T>();
|
||||||
|
return {
|
||||||
|
addListener: (listener: T) => listeners.add(listener),
|
||||||
|
removeListener: (listener: T) => listeners.delete(listener),
|
||||||
|
emit: (...args: Parameters<T>) => {
|
||||||
|
for (const listener of listeners) listener(...args);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionTarget(frameId?: string, sessionId?: string): ResolvedActionTarget {
|
||||||
|
return {
|
||||||
|
tab: { tabId: 4, windowId: 100, active: true },
|
||||||
|
backendNodeId: 123,
|
||||||
|
cdpTarget: { tabId: 4, ...(sessionId ? { sessionId } : {}) },
|
||||||
|
...(frameId ? { frameId } : {}),
|
||||||
|
usedRef: "e3",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutOverlay<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
return operation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadCdp(
|
||||||
|
options: {
|
||||||
|
inputCount?: number;
|
||||||
|
multiple?: boolean;
|
||||||
|
chooser?: { frameId?: string; backendNodeId?: number; mode?: string };
|
||||||
|
pendingResolve?: boolean;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const calls: Array<{ method: string; params?: object }> = [];
|
||||||
|
let cdpEvent: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
|
||||||
|
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
|
||||||
|
calls.push({ method, params });
|
||||||
|
if (method === "DOM.scrollIntoViewIfNeeded") return {};
|
||||||
|
if (method === "Page.setInterceptFileChooserDialog") return {};
|
||||||
|
if (method === "Page.getLayoutMetrics")
|
||||||
|
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
|
||||||
|
if (method === "DOM.getContentQuads") return { quads: [[0, 0, 20, 0, 20, 20, 0, 20]] };
|
||||||
|
if (method === "DOM.resolveNode") {
|
||||||
|
if (options.pendingResolve) return new Promise<never>(() => {});
|
||||||
|
return { object: { objectId: "trigger-object" } };
|
||||||
|
}
|
||||||
|
if (method === "Runtime.callFunctionOn") {
|
||||||
|
const declaration = (params as { functionDeclaration?: string }).functionDeclaration ?? "";
|
||||||
|
if (declaration.includes("Object.defineProperty")) return { result: { value: true } };
|
||||||
|
if (declaration.includes("count: state.inputs.length")) {
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
value: {
|
||||||
|
count: options.inputCount ?? 1,
|
||||||
|
multiple: options.multiple ?? true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (declaration.includes("inputs[0]")) {
|
||||||
|
return { result: { objectId: "input-object" } };
|
||||||
|
}
|
||||||
|
return { result: { value: true } };
|
||||||
|
}
|
||||||
|
if (method === "DOM.describeNode") return { node: { backendNodeId: 456 } };
|
||||||
|
if (
|
||||||
|
method === "Input.dispatchMouseEvent" &&
|
||||||
|
(params as { type?: string }).type === "mousePressed" &&
|
||||||
|
options.chooser
|
||||||
|
) {
|
||||||
|
cdpEvent?.({ tabId: 4 }, "Page.fileChooserOpened", options.chooser);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
const cdp: CdpRunner = {
|
||||||
|
send: send as unknown as CdpRunner["send"],
|
||||||
|
onEvent: (handler) => {
|
||||||
|
cdpEvent = handler;
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
emitChooser: (event: { frameId?: string; backendNodeId?: number; mode?: string }) =>
|
||||||
|
cdpEvent?.({ tabId: 4 }, "Page.fileChooserOpened", event),
|
||||||
|
cdp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropCdp(
|
||||||
|
options: {
|
||||||
|
targetContainsPoint?: boolean;
|
||||||
|
dispatchError?: (type: string) => Error | Promise<never> | undefined;
|
||||||
|
onCall?: (method: string) => void;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const calls: Array<{ method: string; params?: object }> = [];
|
||||||
|
const detach = vi.fn(async () => {});
|
||||||
|
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
|
||||||
|
calls.push({ method, params });
|
||||||
|
options.onCall?.(method);
|
||||||
|
if (method === "DOM.scrollIntoViewIfNeeded") return {};
|
||||||
|
if (method === "Page.getLayoutMetrics") {
|
||||||
|
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
|
||||||
|
}
|
||||||
|
if (method === "DOM.getContentQuads") {
|
||||||
|
return { quads: [[10, 20, 210, 20, 210, 120, 10, 120]] };
|
||||||
|
}
|
||||||
|
if (method === "DOM.getNodeForLocation") return { backendNodeId: 124 };
|
||||||
|
if (method === "DOM.resolveNode") {
|
||||||
|
const id = (params as { backendNodeId?: number }).backendNodeId;
|
||||||
|
return { object: { objectId: id === 123 ? "drop-target" : "hit-target" } };
|
||||||
|
}
|
||||||
|
if (method === "Runtime.callFunctionOn") {
|
||||||
|
return { result: { value: options.targetContainsPoint ?? true } };
|
||||||
|
}
|
||||||
|
if (method === "Input.dispatchDragEvent") {
|
||||||
|
const type = (params as { type?: string }).type ?? "";
|
||||||
|
const error = options.dispatchError?.(type);
|
||||||
|
if (error instanceof Promise) return error;
|
||||||
|
if (error) throw error;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (method === "Runtime.releaseObjectGroup") return {};
|
||||||
|
throw new Error(`unexpected drop CDP command ${method}`);
|
||||||
|
});
|
||||||
|
const cdp: CdpRunner = {
|
||||||
|
send: send as unknown as CdpRunner["send"],
|
||||||
|
detach,
|
||||||
|
};
|
||||||
|
return { calls, cdp, detach };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("file transfer tools", () => {
|
||||||
|
it("captures the file input activated by the requested click and injects only staged paths", async () => {
|
||||||
|
const manager = sessions();
|
||||||
|
const ctx = await manager.start("s1");
|
||||||
|
ctx.refStore.set("e3", 123, { tabId: 4 });
|
||||||
|
const { cdp, calls } = uploadCdp();
|
||||||
|
|
||||||
|
const result = await handleUpload(
|
||||||
|
manager,
|
||||||
|
{
|
||||||
|
session_id: "s1",
|
||||||
|
ref: "@e3",
|
||||||
|
files: [
|
||||||
|
{ transfer_id: "tr_1", name: "one.png", staged_path: "/private/stage/one" },
|
||||||
|
{ transfer_id: "tr_2", name: "two.png", staged_path: "/private/stage/two" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ cdp, tabsApi: tabsApi() },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ tab_id: 4, file_names: ["one.png", "two.png"] });
|
||||||
|
expect(calls[0]).toEqual({
|
||||||
|
method: "Page.setInterceptFileChooserDialog",
|
||||||
|
params: { enabled: true },
|
||||||
|
});
|
||||||
|
expect(calls).toContainEqual({
|
||||||
|
method: "Page.setInterceptFileChooserDialog",
|
||||||
|
params: { enabled: false, cancel: true },
|
||||||
|
});
|
||||||
|
expect(calls).toContainEqual({
|
||||||
|
method: "DOM.setFileInputFiles",
|
||||||
|
params: { files: ["/private/stage/one", "/private/stage/two"], backendNodeId: 456 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails immediately when the trigger does not activate a file input", async () => {
|
||||||
|
const { cdp } = uploadCdp({ inputCount: 0 });
|
||||||
|
const result = await uploadThroughActivatedFileInput({
|
||||||
|
cdp,
|
||||||
|
actionTarget: actionTarget(),
|
||||||
|
files: ["/private/stage/one"],
|
||||||
|
timeoutMs: 100,
|
||||||
|
trigger: async () => ({ tab_id: 4, x: 10, y: 10 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
code: "unsupported",
|
||||||
|
data: { reason: "file_input_not_activated", phase: "resolve_input" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails before clicking when chooser interception is unavailable", async () => {
|
||||||
|
const trigger = vi.fn(async () => ({ tab_id: 4, x: 10, y: 10 }));
|
||||||
|
const cdp: CdpRunner = {
|
||||||
|
send: vi.fn(async (_tabId: number, method: string) => {
|
||||||
|
if (method === "Page.setInterceptFileChooserDialog") {
|
||||||
|
throw new Error("method unavailable");
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}) as CdpRunner["send"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await uploadThroughActivatedFileInput({
|
||||||
|
cdp,
|
||||||
|
actionTarget: actionTarget(),
|
||||||
|
files: ["/private/stage/one"],
|
||||||
|
timeoutMs: 100,
|
||||||
|
trigger,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
code: "cdp_failed",
|
||||||
|
data: { effect_state: "none", phase: "arm_interception" },
|
||||||
|
});
|
||||||
|
expect(trigger).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses an exact chooser event as an independent input-location signal", async () => {
|
||||||
|
const { cdp, calls, emitChooser } = uploadCdp({ inputCount: 0 });
|
||||||
|
const result = await uploadThroughActivatedFileInput({
|
||||||
|
cdp,
|
||||||
|
actionTarget: actionTarget("f1"),
|
||||||
|
files: ["/private/stage/one"],
|
||||||
|
timeoutMs: 100,
|
||||||
|
trigger: async () => {
|
||||||
|
emitChooser({ frameId: "f1", backendNodeId: 789, mode: "selectSingle" });
|
||||||
|
return { tab_id: 4, x: 10, y: 10 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ multiple: false });
|
||||||
|
expect(calls).toContainEqual({
|
||||||
|
method: "DOM.setFileInputFiles",
|
||||||
|
params: { files: ["/private/stage/one"], backendNodeId: 789 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a File System Access picker without waiting for a timeout", async () => {
|
||||||
|
const { cdp, emitChooser } = uploadCdp({
|
||||||
|
inputCount: 0,
|
||||||
|
});
|
||||||
|
const result = await uploadThroughActivatedFileInput({
|
||||||
|
cdp,
|
||||||
|
actionTarget: actionTarget("f1"),
|
||||||
|
files: ["/private/stage/one"],
|
||||||
|
timeoutMs: 100,
|
||||||
|
trigger: async () => {
|
||||||
|
emitChooser({ frameId: "f1", mode: "selectSingle" });
|
||||||
|
return { tab_id: 4, x: 10, y: 10 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
code: "unsupported",
|
||||||
|
message: "upload trigger invoked a non-input file picker",
|
||||||
|
data: { reason: "file_input_not_activated", phase: "resolve_input" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds a stuck file-input probe", async () => {
|
||||||
|
const { cdp } = uploadCdp({ pendingResolve: true });
|
||||||
|
|
||||||
|
const result = await uploadThroughActivatedFileInput({
|
||||||
|
cdp,
|
||||||
|
actionTarget: actionTarget(),
|
||||||
|
files: ["/private/stage/one"],
|
||||||
|
timeoutMs: 5,
|
||||||
|
trigger: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
code: "timeout",
|
||||||
|
data: { reason: "file_input_probe_failed", phase: "arm_input_probe" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a timed-out file assignment unknown and detaches browser state", async () => {
|
||||||
|
const fixture = uploadCdp();
|
||||||
|
const originalSend = fixture.cdp.send;
|
||||||
|
const detach = vi.fn(async () => {});
|
||||||
|
fixture.cdp.detach = detach;
|
||||||
|
fixture.cdp.send = vi.fn((tabId: number, method: string, params?: object) => {
|
||||||
|
if (method === "DOM.setFileInputFiles") return new Promise<never>(() => {});
|
||||||
|
return originalSend(tabId, method, params);
|
||||||
|
}) as CdpRunner["send"];
|
||||||
|
|
||||||
|
const result = await uploadThroughActivatedFileInput({
|
||||||
|
cdp: fixture.cdp,
|
||||||
|
actionTarget: actionTarget(),
|
||||||
|
files: ["/private/stage/one"],
|
||||||
|
timeoutMs: 10,
|
||||||
|
trigger: async () => ({ tab_id: 4, x: 10, y: 10 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
code: "timeout",
|
||||||
|
data: { effect_state: "unknown", phase: "set_files" },
|
||||||
|
});
|
||||||
|
expect(detach).toHaveBeenCalledWith(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delivers staged files through one explicit native drop without clicking", async () => {
|
||||||
|
const manager = sessions();
|
||||||
|
const ctx = await manager.start("s1");
|
||||||
|
ctx.refStore.set("e3", 123, { tabId: 4 });
|
||||||
|
const lifecycle: string[] = [];
|
||||||
|
const { cdp, calls } = dropCdp({ onCall: (method) => lifecycle.push(method) });
|
||||||
|
const overlayPhases: string[] = [];
|
||||||
|
|
||||||
|
const result = await handleUpload(
|
||||||
|
manager,
|
||||||
|
{
|
||||||
|
session_id: "s1",
|
||||||
|
ref: "@e3",
|
||||||
|
mode: "drop",
|
||||||
|
files: [{ transfer_id: "tr_1", name: "one.png", staged_path: "/stage/one.png" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cdp,
|
||||||
|
tabsApi: tabsApi(),
|
||||||
|
sendToTab: vi.fn(async (_tabId, message) => {
|
||||||
|
overlayPhases.push(message.phase);
|
||||||
|
lifecycle.push(`overlay:${message.phase}`);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ tab_id: 4, used_ref: "e3", file_names: ["one.png"] });
|
||||||
|
expect(calls.filter((call) => call.method === "Input.dispatchDragEvent")).toEqual([
|
||||||
|
{
|
||||||
|
method: "Input.dispatchDragEvent",
|
||||||
|
params: {
|
||||||
|
type: "dragEnter",
|
||||||
|
x: 110,
|
||||||
|
y: 70,
|
||||||
|
data: { items: [], files: ["/stage/one.png"], dragOperationsMask: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "Input.dispatchDragEvent",
|
||||||
|
params: {
|
||||||
|
type: "dragOver",
|
||||||
|
x: 110,
|
||||||
|
y: 70,
|
||||||
|
data: { items: [], files: ["/stage/one.png"], dragOperationsMask: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "Input.dispatchDragEvent",
|
||||||
|
params: {
|
||||||
|
type: "drop",
|
||||||
|
x: 110,
|
||||||
|
y: 70,
|
||||||
|
data: { items: [], files: ["/stage/one.png"], dragOperationsMask: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(calls.some((call) => call.method === "Input.dispatchMouseEvent")).toBe(false);
|
||||||
|
expect(overlayPhases).toEqual(["begin", "end"]);
|
||||||
|
expect(lifecycle.indexOf("DOM.getContentQuads")).toBeLessThan(
|
||||||
|
lifecycle.indexOf("overlay:begin"),
|
||||||
|
);
|
||||||
|
expect(lifecycle.indexOf("overlay:begin")).toBeLessThan(
|
||||||
|
lifecycle.indexOf("DOM.getNodeForLocation"),
|
||||||
|
);
|
||||||
|
expect(lifecycle.lastIndexOf("Input.dispatchDragEvent")).toBeLessThan(
|
||||||
|
lifecycle.indexOf("overlay:end"),
|
||||||
|
);
|
||||||
|
expect(lifecycle.indexOf("overlay:end")).toBeLessThan(
|
||||||
|
lifecycle.indexOf("Runtime.releaseObjectGroup"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed before dragging when another element owns the action point", async () => {
|
||||||
|
const { cdp, calls } = dropCdp({ targetContainsPoint: false });
|
||||||
|
const result = await uploadThroughFileDrop({
|
||||||
|
cdp,
|
||||||
|
actionTarget: actionTarget(),
|
||||||
|
files: ["/stage/one.png"],
|
||||||
|
timeoutMs: 100,
|
||||||
|
withOverlayHidden: withoutOverlay,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
code: "permission_denied",
|
||||||
|
data: {
|
||||||
|
reason: "file_drop_target_unavailable",
|
||||||
|
mechanism: "drop",
|
||||||
|
effect_state: "none",
|
||||||
|
phase: "resolve_target",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(calls.some((call) => call.method === "Input.dispatchDragEvent")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches an OOPIF file drop in the child target's local coordinates", async () => {
|
||||||
|
const rootCalls: Array<{ method: string; params?: object }> = [];
|
||||||
|
const childCalls: Array<{ method: string; params?: object }> = [];
|
||||||
|
const cdp: CdpRunner = {
|
||||||
|
send: vi.fn(async (_tabId, method, params) => {
|
||||||
|
rootCalls.push({ method, params });
|
||||||
|
if (method === "DOM.scrollIntoViewIfNeeded") return {};
|
||||||
|
if (method === "DOM.getBoxModel") {
|
||||||
|
return { model: { content: [100, 100, 300, 100, 300, 200, 100, 200] } };
|
||||||
|
}
|
||||||
|
if (method === "Page.getLayoutMetrics") {
|
||||||
|
return { cssLayoutViewport: { clientWidth: 800, clientHeight: 600 } };
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected root command ${method}`);
|
||||||
|
}) as CdpRunner["send"],
|
||||||
|
sendToTarget: vi.fn(async (_target, method, params) => {
|
||||||
|
childCalls.push({ method, params });
|
||||||
|
if (method === "DOM.scrollIntoViewIfNeeded") return {};
|
||||||
|
if (method === "DOM.getContentQuads") {
|
||||||
|
return { quads: [[10, 20, 210, 20, 210, 120, 10, 120]] };
|
||||||
|
}
|
||||||
|
if (method === "Page.getLayoutMetrics") {
|
||||||
|
return { cssLayoutViewport: { clientWidth: 400, clientHeight: 300 } };
|
||||||
|
}
|
||||||
|
if (method === "DOM.getNodeForLocation") {
|
||||||
|
return { backendNodeId: 124, frameId: "child" };
|
||||||
|
}
|
||||||
|
if (method === "DOM.resolveNode") {
|
||||||
|
const id = (params as { backendNodeId?: number }).backendNodeId;
|
||||||
|
return { object: { objectId: id === 123 ? "drop-target" : "hit-target" } };
|
||||||
|
}
|
||||||
|
if (method === "Runtime.callFunctionOn") return { result: { value: true } };
|
||||||
|
if (method === "Input.dispatchDragEvent" || method === "Runtime.releaseObjectGroup") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected child command ${method}`);
|
||||||
|
}) as CdpRunner["sendToTarget"],
|
||||||
|
getFrameGraph: vi.fn(async () => ({
|
||||||
|
rootFrameId: "main",
|
||||||
|
frames: [
|
||||||
|
{ frameId: "main", target: { tabId: 4 } },
|
||||||
|
{
|
||||||
|
frameId: "child",
|
||||||
|
parentFrameId: "main",
|
||||||
|
ownerBackendNodeId: 10,
|
||||||
|
target: { tabId: 4, sessionId: "child-session" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await uploadThroughFileDrop({
|
||||||
|
cdp,
|
||||||
|
actionTarget: actionTarget("child", "child-session"),
|
||||||
|
files: ["/stage/one.png"],
|
||||||
|
timeoutMs: 100,
|
||||||
|
withOverlayHidden: withoutOverlay,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ x: 110, y: 70 });
|
||||||
|
expect(
|
||||||
|
childCalls
|
||||||
|
.filter((call) => call.method === "Input.dispatchDragEvent")
|
||||||
|
.map((call) => call.params),
|
||||||
|
).toEqual([
|
||||||
|
expect.objectContaining({ type: "dragEnter", x: 110, y: 70 }),
|
||||||
|
expect.objectContaining({ type: "dragOver", x: 110, y: 70 }),
|
||||||
|
expect.objectContaining({ type: "drop", x: 110, y: 70 }),
|
||||||
|
]);
|
||||||
|
expect(rootCalls.some((call) => call.method === "Input.dispatchDragEvent")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing native drag command as unsupported before commit", async () => {
|
||||||
|
const { cdp } = dropCdp({
|
||||||
|
dispatchError: (type) =>
|
||||||
|
type === "dragEnter" ? new Error("'Input.dispatchDragEvent' wasn't found") : undefined,
|
||||||
|
});
|
||||||
|
const result = await uploadThroughFileDrop({
|
||||||
|
cdp,
|
||||||
|
actionTarget: actionTarget(),
|
||||||
|
files: ["/stage/one.png"],
|
||||||
|
timeoutMs: 100,
|
||||||
|
withOverlayHidden: withoutOverlay,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
code: "unsupported",
|
||||||
|
data: {
|
||||||
|
reason: "upload_mechanism_unsupported",
|
||||||
|
effect_state: "none",
|
||||||
|
phase: "drag_enter",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels an unresolved drag before returning a pre-commit timeout", async () => {
|
||||||
|
const { cdp, calls } = dropCdp({
|
||||||
|
dispatchError: (type) => (type === "dragEnter" ? new Promise<never>(() => {}) : undefined),
|
||||||
|
});
|
||||||
|
const result = await uploadThroughFileDrop({
|
||||||
|
cdp,
|
||||||
|
actionTarget: actionTarget(),
|
||||||
|
files: ["/stage/one.png"],
|
||||||
|
timeoutMs: 10,
|
||||||
|
withOverlayHidden: withoutOverlay,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
code: "timeout",
|
||||||
|
data: { reason: "file_drop_failed", effect_state: "none", phase: "drag_enter" },
|
||||||
|
});
|
||||||
|
expect(calls).toContainEqual({
|
||||||
|
method: "Input.dispatchDragEvent",
|
||||||
|
params: expect.objectContaining({ type: "dragCancel" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks an unresolved drop unknown and detaches browser state", async () => {
|
||||||
|
const { cdp, detach } = dropCdp({
|
||||||
|
dispatchError: (type) => (type === "drop" ? new Promise<never>(() => {}) : undefined),
|
||||||
|
});
|
||||||
|
const result = await uploadThroughFileDrop({
|
||||||
|
cdp,
|
||||||
|
actionTarget: actionTarget(),
|
||||||
|
files: ["/stage/one.png"],
|
||||||
|
timeoutMs: 10,
|
||||||
|
withOverlayHidden: withoutOverlay,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
code: "timeout",
|
||||||
|
data: { reason: "file_drop_failed", effect_state: "unknown", phase: "drop" },
|
||||||
|
});
|
||||||
|
expect(detach).toHaveBeenCalledWith(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes one exact-target download through a browser-relative capability", async () => {
|
||||||
|
const manager = sessions();
|
||||||
|
const ctx = await manager.start("s1");
|
||||||
|
ctx.refStore.set("e3", 123, { tabId: 4 });
|
||||||
|
const onCreated = fakeEvent<(item: chrome.downloads.DownloadItem) => void>();
|
||||||
|
const onChanged = fakeEvent<(delta: chrome.downloads.DownloadDelta) => void>();
|
||||||
|
const onDeterminingFilename =
|
||||||
|
fakeEvent<
|
||||||
|
(
|
||||||
|
item: chrome.downloads.DownloadItem,
|
||||||
|
suggest: (suggestion?: chrome.downloads.DownloadFilenameSuggestion) => void,
|
||||||
|
) => void | true
|
||||||
|
>();
|
||||||
|
const initial = {
|
||||||
|
id: 9,
|
||||||
|
url: "https://example.test/result.zip",
|
||||||
|
finalUrl: "https://example.test/result.zip",
|
||||||
|
filename: "result.zip",
|
||||||
|
state: "in_progress",
|
||||||
|
fileSize: -1,
|
||||||
|
totalBytes: 12,
|
||||||
|
mime: "application/zip",
|
||||||
|
danger: "safe",
|
||||||
|
} as chrome.downloads.DownloadItem;
|
||||||
|
const completed = {
|
||||||
|
...initial,
|
||||||
|
filename: "/profile/Downloads/BrowserSkill/tr_1/result.zip",
|
||||||
|
state: "complete",
|
||||||
|
fileSize: 12,
|
||||||
|
} as chrome.downloads.DownloadItem;
|
||||||
|
const downloads: DownloadsApi = {
|
||||||
|
onCreated,
|
||||||
|
onChanged,
|
||||||
|
onDeterminingFilename,
|
||||||
|
search: vi.fn(async () => [completed]),
|
||||||
|
cancel: vi.fn(async () => {}),
|
||||||
|
removeFile: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
let cdpEvent: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
|
||||||
|
let suggested: chrome.downloads.DownloadFilenameSuggestion | undefined;
|
||||||
|
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
|
||||||
|
if (method === "Page.getLayoutMetrics")
|
||||||
|
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
|
||||||
|
if (method === "DOM.getContentQuads") return { quads: [[0, 0, 20, 0, 20, 20, 0, 20]] };
|
||||||
|
if (
|
||||||
|
method === "Input.dispatchMouseEvent" &&
|
||||||
|
(params as { type?: string }).type === "mousePressed"
|
||||||
|
) {
|
||||||
|
cdpEvent?.({ tabId: 4 }, "Page.downloadWillBegin", {
|
||||||
|
url: initial.url,
|
||||||
|
suggestedFilename: "result.zip",
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
onDeterminingFilename.emit(initial, (value) => {
|
||||||
|
suggested = value;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
onCreated.emit(completed);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
const cdp: CdpRunner = {
|
||||||
|
send: send as unknown as CdpRunner["send"],
|
||||||
|
onEvent: (handler) => {
|
||||||
|
cdpEvent = handler;
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await handleDownload(
|
||||||
|
manager,
|
||||||
|
{ session_id: "s1", ref: "@e3", browser_relative_dir: "BrowserSkill/tr_1" },
|
||||||
|
{ cdp, tabsApi: tabsApi(), downloads },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(suggested).toEqual({
|
||||||
|
filename: "BrowserSkill/tr_1/result.zip",
|
||||||
|
conflictAction: "overwrite",
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
tab_id: 4,
|
||||||
|
suggested_filename: "result.zip",
|
||||||
|
byte_size: 12,
|
||||||
|
browser_path: completed.filename,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not claim a download without an intent from the exact target", async () => {
|
||||||
|
const onCreated = fakeEvent<(item: chrome.downloads.DownloadItem) => void>();
|
||||||
|
const onChanged = fakeEvent<(delta: chrome.downloads.DownloadDelta) => void>();
|
||||||
|
const onDeterminingFilename =
|
||||||
|
fakeEvent<
|
||||||
|
(
|
||||||
|
item: chrome.downloads.DownloadItem,
|
||||||
|
suggest: (suggestion?: chrome.downloads.DownloadFilenameSuggestion) => void,
|
||||||
|
) => void | true
|
||||||
|
>();
|
||||||
|
const downloads: DownloadsApi = {
|
||||||
|
onCreated,
|
||||||
|
onChanged,
|
||||||
|
onDeterminingFilename,
|
||||||
|
search: vi.fn(async () => []),
|
||||||
|
cancel: vi.fn(async () => {}),
|
||||||
|
removeFile: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
let cdpEvent: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
|
||||||
|
const cdp: CdpRunner = {
|
||||||
|
send: vi.fn(async () => ({})) as unknown as CdpRunner["send"],
|
||||||
|
onEvent: (handler) => {
|
||||||
|
cdpEvent = handler;
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const unrelated = {
|
||||||
|
id: 17,
|
||||||
|
url: "https://example.test/unrelated.zip",
|
||||||
|
finalUrl: "https://example.test/unrelated.zip",
|
||||||
|
filename: "unrelated.zip",
|
||||||
|
state: "in_progress",
|
||||||
|
} as chrome.downloads.DownloadItem;
|
||||||
|
let defaultSuggestionCalled = false;
|
||||||
|
|
||||||
|
const result = await captureBrowserDownload({
|
||||||
|
cdp,
|
||||||
|
target: { tabId: 4, sessionId: "expected-child" },
|
||||||
|
downloads,
|
||||||
|
browserRelativeDir: "BrowserSkill/tr_1",
|
||||||
|
timeoutMs: 5,
|
||||||
|
trigger: async () => {
|
||||||
|
cdpEvent?.({ tabId: 4, sessionId: "other-child" }, "Page.downloadWillBegin", {
|
||||||
|
url: unrelated.url,
|
||||||
|
suggestedFilename: unrelated.filename,
|
||||||
|
});
|
||||||
|
onDeterminingFilename.emit(unrelated, (suggestion) => {
|
||||||
|
defaultSuggestionCalled = suggestion === undefined;
|
||||||
|
});
|
||||||
|
return { tab_id: 4, x: 10, y: 10 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(defaultSuggestionCalled).toBe(true);
|
||||||
|
expect(downloads.cancel).not.toHaveBeenCalled();
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
code: "cdp_failed",
|
||||||
|
data: { reason: "download_capture_failed" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("correlates a filename candidate that arrives before the CDP intent", async () => {
|
||||||
|
const onCreated = fakeEvent<(item: chrome.downloads.DownloadItem) => void>();
|
||||||
|
const onChanged = fakeEvent<(delta: chrome.downloads.DownloadDelta) => void>();
|
||||||
|
const onDeterminingFilename =
|
||||||
|
fakeEvent<
|
||||||
|
(
|
||||||
|
item: chrome.downloads.DownloadItem,
|
||||||
|
suggest: (suggestion?: chrome.downloads.DownloadFilenameSuggestion) => void,
|
||||||
|
) => void | true
|
||||||
|
>();
|
||||||
|
const initial = {
|
||||||
|
id: 21,
|
||||||
|
url: "https://example.test/candidate-first.bin",
|
||||||
|
finalUrl: "https://example.test/candidate-first.bin",
|
||||||
|
filename: "candidate-first.bin",
|
||||||
|
state: "in_progress",
|
||||||
|
fileSize: -1,
|
||||||
|
totalBytes: 4,
|
||||||
|
bytesReceived: 0,
|
||||||
|
} as chrome.downloads.DownloadItem;
|
||||||
|
const complete = {
|
||||||
|
...initial,
|
||||||
|
state: "complete",
|
||||||
|
fileSize: 4,
|
||||||
|
} as chrome.downloads.DownloadItem;
|
||||||
|
const downloads: DownloadsApi = {
|
||||||
|
onCreated,
|
||||||
|
onChanged,
|
||||||
|
onDeterminingFilename,
|
||||||
|
search: vi.fn(async () => [complete]),
|
||||||
|
cancel: vi.fn(async () => {}),
|
||||||
|
removeFile: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
let cdpEvent: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
|
||||||
|
const cdp: CdpRunner = {
|
||||||
|
send: vi.fn(async () => ({})) as CdpRunner["send"],
|
||||||
|
onEvent: (handler) => {
|
||||||
|
cdpEvent = handler;
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let suggestion: chrome.downloads.DownloadFilenameSuggestion | undefined;
|
||||||
|
|
||||||
|
const result = await captureBrowserDownload({
|
||||||
|
cdp,
|
||||||
|
target: { tabId: 4 },
|
||||||
|
downloads,
|
||||||
|
browserRelativeDir: "BrowserSkill/tr_21",
|
||||||
|
timeoutMs: 1_000,
|
||||||
|
trigger: async () => {
|
||||||
|
const suggested = new Promise<void>((resolve) => {
|
||||||
|
onDeterminingFilename.emit(initial, (value) => {
|
||||||
|
suggestion = value;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cdpEvent?.({ tabId: 4 }, "Page.downloadWillBegin", {
|
||||||
|
url: initial.url,
|
||||||
|
suggestedFilename: initial.filename,
|
||||||
|
});
|
||||||
|
await suggested;
|
||||||
|
onCreated.emit(complete);
|
||||||
|
return { tab_id: 4, x: 10, y: 10 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(suggestion).toEqual({
|
||||||
|
filename: "BrowserSkill/tr_21/candidate-first.bin",
|
||||||
|
conflictAction: "overwrite",
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({ item: { id: 21, state: "complete" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a completed download when the final size exceeds the limit", async () => {
|
||||||
|
const onCreated = fakeEvent<(item: chrome.downloads.DownloadItem) => void>();
|
||||||
|
const onChanged = fakeEvent<(delta: chrome.downloads.DownloadDelta) => void>();
|
||||||
|
const onDeterminingFilename =
|
||||||
|
fakeEvent<
|
||||||
|
(
|
||||||
|
item: chrome.downloads.DownloadItem,
|
||||||
|
suggest: (suggestion?: chrome.downloads.DownloadFilenameSuggestion) => void,
|
||||||
|
) => void | true
|
||||||
|
>();
|
||||||
|
const initial = {
|
||||||
|
id: 22,
|
||||||
|
url: "https://example.test/oversized.bin",
|
||||||
|
finalUrl: "https://example.test/oversized.bin",
|
||||||
|
filename: "oversized.bin",
|
||||||
|
state: "in_progress",
|
||||||
|
fileSize: -1,
|
||||||
|
totalBytes: -1,
|
||||||
|
bytesReceived: 0,
|
||||||
|
} as chrome.downloads.DownloadItem;
|
||||||
|
const complete = {
|
||||||
|
...initial,
|
||||||
|
filename: "/profile/Downloads/BrowserSkill/tr_22/oversized.bin",
|
||||||
|
state: "complete",
|
||||||
|
fileSize: 8,
|
||||||
|
totalBytes: 8,
|
||||||
|
bytesReceived: 8,
|
||||||
|
} as chrome.downloads.DownloadItem;
|
||||||
|
const downloads: DownloadsApi = {
|
||||||
|
onCreated,
|
||||||
|
onChanged,
|
||||||
|
onDeterminingFilename,
|
||||||
|
search: vi.fn(async () => [complete]),
|
||||||
|
cancel: vi.fn(async () => {}),
|
||||||
|
removeFile: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
let cdpEvent: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
|
||||||
|
const cdp: CdpRunner = {
|
||||||
|
send: vi.fn(async () => ({})) as CdpRunner["send"],
|
||||||
|
onEvent: (handler) => {
|
||||||
|
cdpEvent = handler;
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await captureBrowserDownload({
|
||||||
|
cdp,
|
||||||
|
target: { tabId: 4 },
|
||||||
|
downloads,
|
||||||
|
browserRelativeDir: "BrowserSkill/tr_22",
|
||||||
|
maxByteSize: 4,
|
||||||
|
timeoutMs: 1_000,
|
||||||
|
trigger: async () => {
|
||||||
|
cdpEvent?.({ tabId: 4 }, "Page.downloadWillBegin", {
|
||||||
|
url: initial.url,
|
||||||
|
suggestedFilename: initial.filename,
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
onDeterminingFilename.emit(initial, () => resolve());
|
||||||
|
});
|
||||||
|
onCreated.emit(initial);
|
||||||
|
onChanged.emit({ id: initial.id, state: { current: "complete" } });
|
||||||
|
return { tab_id: 4, x: 10, y: 10 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
code: "cdp_failed",
|
||||||
|
data: { reason: "download_capture_failed", effect_state: "committed" },
|
||||||
|
});
|
||||||
|
expect(downloads.removeFile).toHaveBeenCalledWith(initial.id);
|
||||||
|
expect(downloads.cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconciles a download that completes while cancellation is being requested", async () => {
|
||||||
|
const onCreated = fakeEvent<(item: chrome.downloads.DownloadItem) => void>();
|
||||||
|
const onChanged = fakeEvent<(delta: chrome.downloads.DownloadDelta) => void>();
|
||||||
|
const onDeterminingFilename =
|
||||||
|
fakeEvent<
|
||||||
|
(
|
||||||
|
item: chrome.downloads.DownloadItem,
|
||||||
|
suggest: (suggestion?: chrome.downloads.DownloadFilenameSuggestion) => void,
|
||||||
|
) => void | true
|
||||||
|
>();
|
||||||
|
const initial = {
|
||||||
|
id: 23,
|
||||||
|
url: "https://example.test/racing.bin",
|
||||||
|
finalUrl: "https://example.test/racing.bin",
|
||||||
|
filename: "racing.bin",
|
||||||
|
state: "in_progress",
|
||||||
|
fileSize: -1,
|
||||||
|
totalBytes: -1,
|
||||||
|
bytesReceived: 0,
|
||||||
|
} as chrome.downloads.DownloadItem;
|
||||||
|
const complete = {
|
||||||
|
...initial,
|
||||||
|
filename: "/profile/Downloads/BrowserSkill/tr_23/racing.bin",
|
||||||
|
state: "complete",
|
||||||
|
fileSize: 4,
|
||||||
|
totalBytes: 4,
|
||||||
|
bytesReceived: 4,
|
||||||
|
} as chrome.downloads.DownloadItem;
|
||||||
|
const downloads: DownloadsApi = {
|
||||||
|
onCreated,
|
||||||
|
onChanged,
|
||||||
|
onDeterminingFilename,
|
||||||
|
search: vi.fn().mockResolvedValueOnce([initial]).mockResolvedValueOnce([complete]),
|
||||||
|
cancel: vi.fn(async () => {
|
||||||
|
throw new Error("download already complete");
|
||||||
|
}),
|
||||||
|
removeFile: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
let cdpEvent: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
|
||||||
|
const cdp: CdpRunner = {
|
||||||
|
send: vi.fn(async () => ({})) as CdpRunner["send"],
|
||||||
|
onEvent: (handler) => {
|
||||||
|
cdpEvent = handler;
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await captureBrowserDownload({
|
||||||
|
cdp,
|
||||||
|
target: { tabId: 4 },
|
||||||
|
downloads,
|
||||||
|
browserRelativeDir: "BrowserSkill/tr_23",
|
||||||
|
timeoutMs: 80,
|
||||||
|
trigger: async () => {
|
||||||
|
cdpEvent?.({ tabId: 4 }, "Page.downloadWillBegin", {
|
||||||
|
url: initial.url,
|
||||||
|
suggestedFilename: initial.filename,
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
onDeterminingFilename.emit(initial, () => resolve());
|
||||||
|
});
|
||||||
|
onCreated.emit(initial);
|
||||||
|
return { tab_id: 4, x: 10, y: 10 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
code: "cdp_failed",
|
||||||
|
data: { reason: "download_capture_failed", effect_state: "committed" },
|
||||||
|
});
|
||||||
|
expect(downloads.cancel).toHaveBeenCalledWith(initial.id);
|
||||||
|
expect(downloads.removeFile).toHaveBeenCalledWith(initial.id);
|
||||||
|
expect(result).not.toMatchObject({ data: { cleanup_state: "failed" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects ambiguous attribution without cancelling either unclaimed download", async () => {
|
||||||
|
const onCreated = fakeEvent<(item: chrome.downloads.DownloadItem) => void>();
|
||||||
|
const onChanged = fakeEvent<(delta: chrome.downloads.DownloadDelta) => void>();
|
||||||
|
const onDeterminingFilename =
|
||||||
|
fakeEvent<
|
||||||
|
(
|
||||||
|
item: chrome.downloads.DownloadItem,
|
||||||
|
suggest: (suggestion?: chrome.downloads.DownloadFilenameSuggestion) => void,
|
||||||
|
) => void | true
|
||||||
|
>();
|
||||||
|
const downloads: DownloadsApi = {
|
||||||
|
onCreated,
|
||||||
|
onChanged,
|
||||||
|
onDeterminingFilename,
|
||||||
|
search: vi.fn(async () => []),
|
||||||
|
cancel: vi.fn(async () => {}),
|
||||||
|
removeFile: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
let cdpEvent: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
|
||||||
|
const cdp: CdpRunner = {
|
||||||
|
send: vi.fn(async () => ({})) as CdpRunner["send"],
|
||||||
|
onEvent: (handler) => {
|
||||||
|
cdpEvent = handler;
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const defaults: number[] = [];
|
||||||
|
const candidate = (id: number) =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
url: "https://example.test/same.bin",
|
||||||
|
finalUrl: "https://example.test/same.bin",
|
||||||
|
filename: "same.bin",
|
||||||
|
state: "in_progress",
|
||||||
|
}) as chrome.downloads.DownloadItem;
|
||||||
|
|
||||||
|
const result = await captureBrowserDownload({
|
||||||
|
cdp,
|
||||||
|
target: { tabId: 4 },
|
||||||
|
downloads,
|
||||||
|
browserRelativeDir: "BrowserSkill/tr_ambiguous",
|
||||||
|
timeoutMs: 100,
|
||||||
|
trigger: async () => {
|
||||||
|
cdpEvent?.({ tabId: 4 }, "Page.downloadWillBegin", {
|
||||||
|
url: "https://example.test/same.bin",
|
||||||
|
suggestedFilename: "same.bin",
|
||||||
|
});
|
||||||
|
for (const id of [31, 32]) {
|
||||||
|
onDeterminingFilename.emit(candidate(id), (value) => {
|
||||||
|
if (value === undefined) defaults.push(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { tab_id: 4, x: 10, y: 10 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(defaults.sort()).toEqual([31, 32]);
|
||||||
|
expect(downloads.cancel).not.toHaveBeenCalled();
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
code: "cdp_failed",
|
||||||
|
data: { effect_state: "unknown", phase: "attribution" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { SessionManager } from "@/session-manager/manager";
|
||||||
|
import type { CdpRunner } from "@/tools/shared";
|
||||||
|
import { handleFill } from "../interaction";
|
||||||
|
|
||||||
|
interface ScriptParams {
|
||||||
|
functionDeclaration: string;
|
||||||
|
arguments?: Array<{ value: unknown }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(markup = '<input value="old">') {
|
||||||
|
document.body.innerHTML = markup;
|
||||||
|
const element = document.body.firstElementChild as HTMLInputElement;
|
||||||
|
const manager = new SessionManager({
|
||||||
|
agentWindow: {
|
||||||
|
create: async () => 100,
|
||||||
|
remove: async () => {},
|
||||||
|
ensureActiveTab: async () => 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ctx = await manager.start("aa11");
|
||||||
|
ctx.refStore.set("e1", 12, { tabId: 4 });
|
||||||
|
const script = vi.fn(async (params: ScriptParams): Promise<unknown> => {
|
||||||
|
try {
|
||||||
|
const fn = new Function(`return (${params.functionDeclaration})`)();
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
value: await fn.apply(
|
||||||
|
element,
|
||||||
|
(params.arguments ?? []).map((arg) => arg.value),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { exceptionDetails: { text: "test page script failed" } };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Number/email controls have a browser caret even though their selection
|
||||||
|
// getters return null. Model that caret instead of assuming it is at the end.
|
||||||
|
const hiddenCaret = { start: 0, end: 0 };
|
||||||
|
const moveToEnd = vi.fn(async () => {
|
||||||
|
if (typeof element.selectionStart === "number") {
|
||||||
|
element.setSelectionRange(element.value.length, element.value.length);
|
||||||
|
} else {
|
||||||
|
hiddenCaret.start = hiddenCaret.end = element.value.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const insert = vi.fn(async (text: string) => {
|
||||||
|
const focused = document.activeElement as HTMLInputElement;
|
||||||
|
if (focused.readOnly || focused.disabled) return;
|
||||||
|
const start = focused.selectionStart ?? hiddenCaret.start;
|
||||||
|
const end = focused.selectionEnd ?? hiddenCaret.end;
|
||||||
|
const value = focused.value.slice(0, start) + text + focused.value.slice(end);
|
||||||
|
focused.value = focused.maxLength >= 0 ? value.slice(0, focused.maxLength) : value;
|
||||||
|
hiddenCaret.start = hiddenCaret.end = Math.min(start + text.length, focused.value.length);
|
||||||
|
focused.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
const release = vi.fn(async () => ({}));
|
||||||
|
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
|
||||||
|
switch (method) {
|
||||||
|
case "DOM.describeNode":
|
||||||
|
return {
|
||||||
|
node: {
|
||||||
|
nodeName: element.tagName,
|
||||||
|
attributes: Array.from(element.attributes).flatMap((attr) => [attr.name, attr.value]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "DOM.scrollIntoViewIfNeeded":
|
||||||
|
return {};
|
||||||
|
case "DOM.focus":
|
||||||
|
element.focus();
|
||||||
|
return {};
|
||||||
|
case "DOM.resolveNode":
|
||||||
|
return { object: { objectId: "fill-target" } };
|
||||||
|
case "Runtime.callFunctionOn":
|
||||||
|
return script(params as ScriptParams);
|
||||||
|
case "Input.insertText":
|
||||||
|
await insert((params as { text: string }).text);
|
||||||
|
return {};
|
||||||
|
case "Input.dispatchKeyEvent":
|
||||||
|
if ((params as { commands?: string[] }).commands?.includes("moveToEndOfDocument")) {
|
||||||
|
await moveToEnd();
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
case "Runtime.releaseObject":
|
||||||
|
return release();
|
||||||
|
default:
|
||||||
|
throw new Error(`unexpected ${method}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
element,
|
||||||
|
hiddenCaret,
|
||||||
|
moveToEnd,
|
||||||
|
script,
|
||||||
|
insert,
|
||||||
|
release,
|
||||||
|
send,
|
||||||
|
fill: (value = "hello", clearBefore = true, signal?: AbortSignal) =>
|
||||||
|
handleFill(
|
||||||
|
manager,
|
||||||
|
{ session_id: "aa11", ref: "e1", value, clear_before: clearBefore },
|
||||||
|
{
|
||||||
|
cdp: { send: send as CdpRunner["send"] },
|
||||||
|
tabsApi: {
|
||||||
|
get: async (id) => ({ id, windowId: 100, active: true }) as chrome.tabs.Tab,
|
||||||
|
query: async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab],
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fill result verification", () => {
|
||||||
|
it.each([
|
||||||
|
"readonly",
|
||||||
|
"disabled",
|
||||||
|
])("preserves a %s field before rejecting it", async (attribute) => {
|
||||||
|
const h = await setup(`<input ${attribute} value="old">`);
|
||||||
|
expect(await h.fill()).toMatchObject({
|
||||||
|
code: "invalid_params",
|
||||||
|
data: { reason: "target_not_fillable" },
|
||||||
|
});
|
||||||
|
expect(h.element.value).toBe("old");
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
expect(h.release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"checkbox",
|
||||||
|
"radio",
|
||||||
|
"range",
|
||||||
|
"date",
|
||||||
|
"file",
|
||||||
|
])("rejects an unsupported %s input before clearing", async (type) => {
|
||||||
|
const h = await setup(`<input type="${type}">`);
|
||||||
|
const before = h.element.value;
|
||||||
|
expect(await h.fill()).toMatchObject({
|
||||||
|
code: "invalid_params",
|
||||||
|
data: { reason: "target_not_fillable" },
|
||||||
|
});
|
||||||
|
expect(h.element.value).toBe(before);
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an overlong value before clearing", async () => {
|
||||||
|
const h = await setup('<input maxlength="3" value="old">');
|
||||||
|
expect(await h.fill()).toMatchObject({
|
||||||
|
code: "invalid_params",
|
||||||
|
message: /maxlength/,
|
||||||
|
data: { reason: "fill_value_invalid" },
|
||||||
|
});
|
||||||
|
expect(h.element.value).toBe("old");
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
])("classifies a DOM.focus rejection on a disabled=%s target", async (disabled) => {
|
||||||
|
const h = await setup(`<input ${disabled ? "disabled" : ""} value="old">`);
|
||||||
|
const send = h.send.getMockImplementation()!;
|
||||||
|
h.send.mockImplementation(async (tabId, method, params) => {
|
||||||
|
if (method === "DOM.focus") throw new Error("Element is not focusable");
|
||||||
|
return send(tabId, method, params);
|
||||||
|
});
|
||||||
|
expect(await h.fill()).toMatchObject({
|
||||||
|
code: disabled ? "invalid_params" : "cdp_failed",
|
||||||
|
data: { reason: disabled ? "target_not_fillable" : "fill_failed" },
|
||||||
|
});
|
||||||
|
expect(h.element.value).toBe("old");
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
expect(h.release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills a background document without requiring foreground browser focus", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
vi.spyOn(document, "hasFocus").mockReturnValue(false);
|
||||||
|
expect(await h.fill()).toMatchObject({ value_length: 5 });
|
||||||
|
expect(h.element.value).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores focus once after clearing without typing into another field", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
const other = document.createElement("input");
|
||||||
|
document.body.append(other);
|
||||||
|
h.element.addEventListener("input", () => other.focus());
|
||||||
|
expect(await h.fill()).toMatchObject({ value_length: 5 });
|
||||||
|
expect(h.element.value).toBe("hello");
|
||||||
|
expect(other.value).toBe("");
|
||||||
|
expect(h.send.mock.calls.filter(([, method]) => method === "DOM.focus")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"focus",
|
||||||
|
"value",
|
||||||
|
])("rechecks %s after restoring focus, without retrying again", async (action) => {
|
||||||
|
const h = await setup();
|
||||||
|
const other = document.createElement("input");
|
||||||
|
document.body.append(other);
|
||||||
|
h.element.addEventListener(
|
||||||
|
"input",
|
||||||
|
() => {
|
||||||
|
other.focus();
|
||||||
|
h.element.addEventListener("focus", () => {
|
||||||
|
if (action === "focus") queueMicrotask(() => other.focus());
|
||||||
|
else h.element.value = "changed";
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
expect(await h.fill()).toMatchObject({
|
||||||
|
code: "cdp_failed",
|
||||||
|
data: { reason: action === "focus" ? "fill_focus_lost" : "fill_target_changed" },
|
||||||
|
});
|
||||||
|
expect(other.value).toBe("");
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
expect(h.send.mock.calls.filter(([, method]) => method === "DOM.focus")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"remove",
|
||||||
|
"readonly",
|
||||||
|
"restore",
|
||||||
|
"microtask-restore",
|
||||||
|
])("stops when clearing causes the page to %s the target", async (action) => {
|
||||||
|
const h = await setup();
|
||||||
|
h.element.addEventListener("input", () => {
|
||||||
|
if (action === "remove") h.element.remove();
|
||||||
|
if (action === "readonly") h.element.readOnly = true;
|
||||||
|
if (action === "restore") h.element.value = "restored";
|
||||||
|
if (action === "microtask-restore")
|
||||||
|
queueMicrotask(() => {
|
||||||
|
h.element.value = "restored";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(await h.fill()).toMatchObject({
|
||||||
|
code: "cdp_failed",
|
||||||
|
data: { reason: "fill_target_changed" },
|
||||||
|
});
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when insertText is acknowledged without changing the value", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
h.insert.mockImplementation(async () => {});
|
||||||
|
expect(await h.fill()).toMatchObject({
|
||||||
|
code: "cdp_failed",
|
||||||
|
data: { reason: "fill_value_mismatch" },
|
||||||
|
});
|
||||||
|
expect(h.release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects truncation when maxlength changes after preparation", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
h.element.addEventListener(
|
||||||
|
"input",
|
||||||
|
() => {
|
||||||
|
h.element.maxLength = 3;
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
expect(await h.fill()).toMatchObject({
|
||||||
|
code: "cdp_failed",
|
||||||
|
data: { reason: "fill_value_mismatch" },
|
||||||
|
});
|
||||||
|
expect(h.element.value).toBe("hel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"input",
|
||||||
|
"change",
|
||||||
|
"microtask-change",
|
||||||
|
])("detects a value reverted by the %s handler", async (event) => {
|
||||||
|
const h = await setup();
|
||||||
|
h.element.addEventListener(event === "microtask-change" ? "change" : event, () => {
|
||||||
|
if (event === "microtask-change")
|
||||||
|
queueMicrotask(() => {
|
||||||
|
h.element.value = "";
|
||||||
|
});
|
||||||
|
else h.element.value = "";
|
||||||
|
});
|
||||||
|
expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /expected value/ });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compares content rather than length without including field values in the error", async () => {
|
||||||
|
const h = await setup('<input type="password">');
|
||||||
|
h.element.addEventListener("change", () => {
|
||||||
|
h.element.value = "other-secret";
|
||||||
|
});
|
||||||
|
const result = await h.fill("given-secret");
|
||||||
|
expect(result).toMatchObject({ code: "cdp_failed", message: /expected value/ });
|
||||||
|
expect(JSON.stringify(result)).not.toMatch(/given-secret|other-secret/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not report success for a target replaced by a change handler", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
h.element.addEventListener("change", () => h.element.replaceWith(h.element.cloneNode()));
|
||||||
|
expect(await h.fill()).toMatchObject({
|
||||||
|
code: "cdp_failed",
|
||||||
|
data: { reason: "fill_target_changed" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
1, 2, 3, 4, 5,
|
||||||
|
])("rejects script exceptions at phase %s and releases the target", async (phase) => {
|
||||||
|
const h = await setup();
|
||||||
|
vi.spyOn(document, "hasFocus").mockReturnValue(false);
|
||||||
|
const run = h.script.getMockImplementation()!;
|
||||||
|
let count = 0;
|
||||||
|
h.script.mockImplementation(async (params) =>
|
||||||
|
++count === phase ? { exceptionDetails: { text: "page secret" } } : run(params),
|
||||||
|
);
|
||||||
|
const result = await h.fill();
|
||||||
|
expect(result).toMatchObject({ code: "cdp_failed", data: { reason: "fill_failed" } });
|
||||||
|
expect(JSON.stringify(result)).not.toContain("page secret");
|
||||||
|
if (phase <= 3) expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
expect(h.release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([1, 2, 3, 5])("rejects missing results at phase %s", async (phase) => {
|
||||||
|
const h = await setup();
|
||||||
|
vi.spyOn(document, "hasFocus").mockReturnValue(false);
|
||||||
|
const run = h.script.getMockImplementation()!;
|
||||||
|
let count = 0;
|
||||||
|
h.script.mockImplementation(async (params) =>
|
||||||
|
++count === phase ? { result: { type: "undefined" } } : run(params),
|
||||||
|
);
|
||||||
|
expect(await h.fill()).toMatchObject({ code: "cdp_failed" });
|
||||||
|
if (phase <= 3) expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["input", "change"])("checks state after nested microtasks from %s", async (event) => {
|
||||||
|
const h = await setup();
|
||||||
|
h.element.addEventListener(event, () => {
|
||||||
|
queueMicrotask(() =>
|
||||||
|
queueMicrotask(() => {
|
||||||
|
h.element.value = "reverted";
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(await h.fill()).toMatchObject({ code: "cdp_failed" });
|
||||||
|
if (event === "input") expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"<input>",
|
||||||
|
'<input type="password">',
|
||||||
|
"<textarea></textarea>",
|
||||||
|
])("verifies successful text in %s", async (markup) => {
|
||||||
|
const h = await setup(markup);
|
||||||
|
expect(await h.fill("你好🙂")).toMatchObject({ value_length: 4 });
|
||||||
|
expect(h.element.value).toBe("你好🙂");
|
||||||
|
expect(h.release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies append against the old value plus the requested text", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
h.element.focus();
|
||||||
|
h.element.setSelectionRange(3, 3);
|
||||||
|
expect(await h.fill("!", false)).toMatchObject({ value_length: 4 });
|
||||||
|
expect(h.element.value).toBe("old!");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["<textarea></textarea>", "a\r\nb", "a\nb"],
|
||||||
|
["<input>", "a\nb", "a b"],
|
||||||
|
["<input>", "a\r\nb", "a b"],
|
||||||
|
[
|
||||||
|
'<input type="email" multiple>',
|
||||||
|
" a@example.com , b@example.com ",
|
||||||
|
"a@example.com,b@example.com",
|
||||||
|
],
|
||||||
|
])("accepts native normalization for %s", async (markup, requested, normalized) => {
|
||||||
|
if (markup.startsWith("<textarea")) {
|
||||||
|
// Happy DOM does not implement Chrome's textarea line-ending normalization.
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!;
|
||||||
|
vi.spyOn(HTMLTextAreaElement.prototype, "value", "set").mockImplementation(function (
|
||||||
|
this: HTMLTextAreaElement,
|
||||||
|
value,
|
||||||
|
) {
|
||||||
|
setter.call(this, value.replace(/\r\n?/g, "\n"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const h = await setup(markup);
|
||||||
|
h.insert.mockImplementation(async () => {
|
||||||
|
h.element.value = normalized;
|
||||||
|
});
|
||||||
|
expect(await h.fill(requested)).toMatchObject({ value_length: normalized.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not count an editable padding break as an extra typed character", async () => {
|
||||||
|
const h = await setup('<div contenteditable="true" tabindex="0"></div>');
|
||||||
|
// These two layout/editability properties are missing in Happy DOM.
|
||||||
|
let innerText = "";
|
||||||
|
Object.defineProperty(h.element, "isContentEditable", { get: () => true });
|
||||||
|
Object.defineProperty(h.element, "innerText", { get: () => innerText });
|
||||||
|
h.insert.mockImplementation(async () => {
|
||||||
|
h.element.innerHTML = "one<div><br></div>";
|
||||||
|
innerText = "one\n\n";
|
||||||
|
});
|
||||||
|
expect(await h.fill("one\n")).toMatchObject({ value_length: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[0, 0],
|
||||||
|
[1, 1],
|
||||||
|
[0, 3],
|
||||||
|
])("appends after moving selection %s:%s to the end", async (start, end) => {
|
||||||
|
const h = await setup();
|
||||||
|
h.element.focus();
|
||||||
|
h.element.setSelectionRange(start, end);
|
||||||
|
expect(await h.fill("🙂", false)).toMatchObject({ value_length: 5 });
|
||||||
|
expect(h.element.value).toBe("old🙂");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(
|
||||||
|
["number", "email"].flatMap((type) =>
|
||||||
|
[
|
||||||
|
[0, 0],
|
||||||
|
[1, 1],
|
||||||
|
[0, 2],
|
||||||
|
].map(([start, end]) => ({ type, start, end })),
|
||||||
|
),
|
||||||
|
)("appends to $type from selection $start:$end without using its unsupported API", async ({
|
||||||
|
type,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
}) => {
|
||||||
|
const h = await setup(`<input type="${type}" value="12">`);
|
||||||
|
Object.assign(h.hiddenCaret, { start, end });
|
||||||
|
vi.spyOn(h.element, "selectionStart", "get").mockReturnValue(null);
|
||||||
|
vi.spyOn(h.element, "selectionEnd", "get").mockReturnValue(null);
|
||||||
|
const selection = vi.spyOn(h.element, "setSelectionRange").mockImplementation(() => {
|
||||||
|
throw new DOMException("unsupported", "InvalidStateError");
|
||||||
|
});
|
||||||
|
expect(await h.fill("3", false)).toMatchObject({ value_length: 3 });
|
||||||
|
expect(h.element.value).toBe("123");
|
||||||
|
expect(selection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"number",
|
||||||
|
"email",
|
||||||
|
])("does not report successful %s append when the caret command is ignored", async (type) => {
|
||||||
|
const h = await setup(`<input type="${type}" value="12">`);
|
||||||
|
vi.spyOn(h.element, "selectionStart", "get").mockReturnValue(null);
|
||||||
|
vi.spyOn(h.element, "selectionEnd", "get").mockReturnValue(null);
|
||||||
|
h.moveToEnd.mockResolvedValueOnce(undefined);
|
||||||
|
expect(await h.fill("3", false)).toMatchObject({
|
||||||
|
code: "cdp_failed",
|
||||||
|
data: { reason: "fill_value_mismatch" },
|
||||||
|
});
|
||||||
|
expect(h.element.value).toBe("312");
|
||||||
|
expect(h.insert).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid number text before clearing", async () => {
|
||||||
|
const h = await setup('<input type="number" value="12">');
|
||||||
|
expect(await h.fill("abc")).toMatchObject({
|
||||||
|
code: "invalid_params",
|
||||||
|
data: { reason: "fill_value_invalid" },
|
||||||
|
});
|
||||||
|
expect(h.element.value).toBe("12");
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports page formatting as unconfirmed without retrying the write", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
h.element.addEventListener("change", () => {
|
||||||
|
h.element.value = h.element.value.toUpperCase();
|
||||||
|
});
|
||||||
|
expect(await h.fill()).toMatchObject({
|
||||||
|
code: "cdp_failed",
|
||||||
|
data: { reason: "fill_value_mismatch" },
|
||||||
|
});
|
||||||
|
expect(h.element.value).toBe("HELLO");
|
||||||
|
expect(h.insert).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["focus", false],
|
||||||
|
["focus", true],
|
||||||
|
["value", false],
|
||||||
|
["value", true],
|
||||||
|
["cancel", false],
|
||||||
|
["cancel", true],
|
||||||
|
["throw", false],
|
||||||
|
["throw", true],
|
||||||
|
] as const)("stops when positioning triggers %s (clear=%s) and releases the key", async (action, clearBefore) => {
|
||||||
|
const h = await setup();
|
||||||
|
vi.spyOn(document, "hasFocus").mockReturnValue(false);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const other = document.createElement("input");
|
||||||
|
document.body.append(other);
|
||||||
|
const send = h.send.getMockImplementation()!;
|
||||||
|
h.send.mockImplementation(async (tabId, method, params) => {
|
||||||
|
const reply = await send(tabId, method, params);
|
||||||
|
if (
|
||||||
|
method === "Input.dispatchKeyEvent" &&
|
||||||
|
(params as { type: string }).type === "rawKeyDown"
|
||||||
|
) {
|
||||||
|
if (action === "focus") other.focus();
|
||||||
|
if (action === "value") h.element.value = "changed";
|
||||||
|
if (action === "cancel") controller.abort();
|
||||||
|
if (action === "throw") throw new Error("dispatch failed");
|
||||||
|
}
|
||||||
|
return reply;
|
||||||
|
});
|
||||||
|
const result = await h.fill("!", clearBefore, controller.signal);
|
||||||
|
expect(result).toMatchObject({ code: action === "cancel" ? "cancelled" : "cdp_failed" });
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
expect(other.value).toBe("");
|
||||||
|
expect(h.send).toHaveBeenCalledWith(
|
||||||
|
4,
|
||||||
|
"Input.dispatchKeyEvent",
|
||||||
|
expect.objectContaining({ type: "keyUp" }),
|
||||||
|
);
|
||||||
|
expect(h.release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])("verifies an empty request with clear_before=%s", async (clearBefore) => {
|
||||||
|
const h = await setup();
|
||||||
|
expect(await h.fill("", clearBefore)).toMatchObject({ value_length: clearBefore ? 0 : 3 });
|
||||||
|
expect(h.element.value).toBe(clearBefore ? "" : "old");
|
||||||
|
expect(h.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels after insertion without sending change events", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
const controller = new AbortController();
|
||||||
|
h.insert.mockImplementation(async () => controller.abort());
|
||||||
|
expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" });
|
||||||
|
expect(h.script).toHaveBeenCalledTimes(2);
|
||||||
|
expect(h.release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels after resolving the remote object without clearing the field", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const send = h.send.getMockImplementation()!;
|
||||||
|
h.send.mockImplementation(async (tabId, method, params) => {
|
||||||
|
const result = await send(tabId, method, params);
|
||||||
|
if (method === "DOM.resolveNode") controller.abort();
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" });
|
||||||
|
expect(h.element.value).toBe("old");
|
||||||
|
expect(h.script).not.toHaveBeenCalled();
|
||||||
|
expect(h.release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors cancellation during verification", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
const controller = new AbortController();
|
||||||
|
h.element.addEventListener("change", () => controller.abort());
|
||||||
|
expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" });
|
||||||
|
expect(h.release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the verified outcome when releasing the remote object fails", async () => {
|
||||||
|
const h = await setup();
|
||||||
|
h.release.mockRejectedValueOnce(new Error("target closed"));
|
||||||
|
expect(await h.fill()).toMatchObject({ value_length: 5 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { resolveFrameProjection, resolveNodeGeometry } from "../frame-geometry";
|
import { resolveNodeGeometry } from "../frame-geometry";
|
||||||
import {
|
import {
|
||||||
clipPolygon,
|
clipPolygon,
|
||||||
polygonArea,
|
polygonArea,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
rectPolygon,
|
rectPolygon,
|
||||||
regionBounds,
|
regionBounds,
|
||||||
} from "../geometry";
|
} from "../geometry";
|
||||||
|
import { GeometryContext } from "../geometry/frame-context";
|
||||||
import type { CdpRunner } from "../shared";
|
import type { CdpRunner } from "../shared";
|
||||||
|
|
||||||
describe("frame geometry projection", () => {
|
describe("frame geometry projection", () => {
|
||||||
@@ -82,28 +83,24 @@ describe("frame geometry projection", () => {
|
|||||||
throw new Error(`unexpected child command ${method}`);
|
throw new Error(`unexpected child command ${method}`);
|
||||||
}) as CdpRunner["sendToTarget"],
|
}) as CdpRunner["sendToTarget"],
|
||||||
};
|
};
|
||||||
const projection = await resolveFrameProjection(
|
const projection = await new GeometryContext(cdp, 4, {
|
||||||
cdp,
|
rootFrameId: "main",
|
||||||
{
|
frames: [
|
||||||
rootFrameId: "main",
|
{ frameId: "main", target: { tabId: 4 } },
|
||||||
frames: [
|
{
|
||||||
{ frameId: "main", target: { tabId: 4 } },
|
frameId: "same-process-parent",
|
||||||
{
|
parentFrameId: "main",
|
||||||
frameId: "same-process-parent",
|
ownerBackendNodeId: 10,
|
||||||
parentFrameId: "main",
|
target: { tabId: 4 },
|
||||||
ownerBackendNodeId: 10,
|
},
|
||||||
target: { tabId: 4 },
|
{
|
||||||
},
|
frameId: "oopif",
|
||||||
{
|
parentFrameId: "same-process-parent",
|
||||||
frameId: "oopif",
|
ownerBackendNodeId: 20,
|
||||||
parentFrameId: "same-process-parent",
|
target: { tabId: 4, sessionId: "oopif-session" },
|
||||||
ownerBackendNodeId: 20,
|
},
|
||||||
target: { tabId: 4, sessionId: "oopif-session" },
|
],
|
||||||
},
|
}).targetProjection("oopif");
|
||||||
],
|
|
||||||
},
|
|
||||||
"oopif",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(projection?.edges).toEqual([
|
expect(projection?.edges).toEqual([
|
||||||
{
|
{
|
||||||
@@ -169,6 +166,7 @@ describe("frame geometry projection", () => {
|
|||||||
expect(geometry).toMatchObject({
|
expect(geometry).toMatchObject({
|
||||||
topBounds: { x: 100, y: 100, width: 200, height: 100 },
|
topBounds: { x: 100, y: 100, width: 200, height: 100 },
|
||||||
actionPoint: { x: 200, y: 150 },
|
actionPoint: { x: 200, y: 150 },
|
||||||
|
targetActionPoint: { x: 100, y: 50 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -261,6 +259,7 @@ describe("frame geometry projection", () => {
|
|||||||
expect(geometry).toMatchObject({
|
expect(geometry).toMatchObject({
|
||||||
topBounds: { x: 100, y: 100, width: 200, height: 100 },
|
topBounds: { x: 100, y: 100, width: 200, height: 100 },
|
||||||
actionPoint: { x: 200, y: 150 },
|
actionPoint: { x: 200, y: 150 },
|
||||||
|
targetActionPoint: { x: 200, y: 150 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { CdpFrameGraph } from "@/browser-driver/frame-graph";
|
||||||
|
import { resolveNodeGeometry } from "../frame-geometry";
|
||||||
|
import { rectPolygon } from "../geometry";
|
||||||
|
import {
|
||||||
|
projectSnapshotRect,
|
||||||
|
screenshotPageRect,
|
||||||
|
snapshotCoordinates,
|
||||||
|
snapshotViewportRect,
|
||||||
|
} from "../geometry/coordinate-types";
|
||||||
|
import { GeometryContext, snapshotLayoutScale } from "../geometry/frame-context";
|
||||||
|
import type { CdpRunner } from "../shared";
|
||||||
|
|
||||||
|
const target = { tabId: 4 };
|
||||||
|
const graph: CdpFrameGraph = {
|
||||||
|
rootFrameId: "main",
|
||||||
|
frames: [
|
||||||
|
{ frameId: "main", target },
|
||||||
|
{ frameId: "child", parentFrameId: "main", ownerBackendNodeId: 10, target },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function driver() {
|
||||||
|
const send = vi.fn(async (_tab: number, method: string): Promise<object> => {
|
||||||
|
if (method === "Page.getLayoutMetrics")
|
||||||
|
return {
|
||||||
|
visualViewport: { clientWidth: 1000 },
|
||||||
|
cssVisualViewport: { clientWidth: 1000 },
|
||||||
|
cssLayoutViewport: { clientWidth: 1000, clientHeight: 1000 },
|
||||||
|
};
|
||||||
|
if (method === "DOM.getBoxModel")
|
||||||
|
return { model: { content: [52.5, 612.5, 427.5, 612.5, 427.5, 862.5, 52.5, 862.5] } };
|
||||||
|
if (method === "DOM.resolveNode") return { object: { objectId: "owner" } };
|
||||||
|
if (method === "Runtime.callFunctionOn")
|
||||||
|
return { result: { value: { width: 300, height: 200 } } };
|
||||||
|
if (method === "Runtime.releaseObject") return {};
|
||||||
|
throw new Error(method);
|
||||||
|
});
|
||||||
|
return { send: send as CdpRunner["send"], getFrameGraph: vi.fn(async () => graph), calls: send };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("measurement geometry context", () => {
|
||||||
|
it.each([0.8, 1, 1.25, 2])("normalizes snapshot layout units at scale %s", (scale) => {
|
||||||
|
const measured = snapshotLayoutScale({
|
||||||
|
visualViewport: { clientWidth: 997.25, zoom: 1.1 },
|
||||||
|
cssVisualViewport: { clientWidth: 997.25 / scale, zoom: 1.1 },
|
||||||
|
// Rounded layout dimensions must not supply the scale.
|
||||||
|
layoutViewport: { clientWidth: 997 },
|
||||||
|
cssLayoutViewport: { clientWidth: Math.floor(997.25 / scale) },
|
||||||
|
});
|
||||||
|
expect(measured).toBeCloseTo(scale, 12);
|
||||||
|
const coordinates = snapshotCoordinates(
|
||||||
|
{ scrollOffsetX: 80 * scale, scrollOffsetY: 200 * scale },
|
||||||
|
measured,
|
||||||
|
{ x: 999, y: 999 },
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
snapshotViewportRect(
|
||||||
|
[160 * scale, 320 * scale, 120 * scale, 40 * scale],
|
||||||
|
{ target },
|
||||||
|
coordinates,
|
||||||
|
)?.rect,
|
||||||
|
).toEqual({ x: 80, y: 120, width: 120, height: 40 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses CSS root scroll only for missing snapshot offsets, without scaling it twice", () => {
|
||||||
|
const coordinates = snapshotCoordinates({ scrollOffsetX: 80 }, 2, { x: 999, y: 100 });
|
||||||
|
expect(snapshotViewportRect([200, 800, 240, 80], { target }, coordinates)?.rect).toEqual({
|
||||||
|
x: 60,
|
||||||
|
y: 300,
|
||||||
|
width: 120,
|
||||||
|
height: 40,
|
||||||
|
});
|
||||||
|
expect(snapshotCoordinates({ scrollOffsetX: 0 }, 2)).toBeNull();
|
||||||
|
expect(
|
||||||
|
snapshotCoordinates({ scrollOffsetX: NaN, scrollOffsetY: 0 }, 2, { x: 0, y: 0 }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not infer snapshot units from missing, invalid, or CSS-only metrics", () => {
|
||||||
|
expect(snapshotLayoutScale({ cssLayoutViewport: { clientWidth: 800 } })).toBeNull();
|
||||||
|
for (const raw of [0, -1, NaN, Infinity]) {
|
||||||
|
expect(
|
||||||
|
snapshotLayoutScale({
|
||||||
|
visualViewport: { clientWidth: raw },
|
||||||
|
cssVisualViewport: { clientWidth: 800 },
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
snapshotLayoutScale({
|
||||||
|
visualViewport: { clientWidth: 0, clientHeight: 600 },
|
||||||
|
cssVisualViewport: { clientWidth: 0, clientHeight: 300 },
|
||||||
|
}),
|
||||||
|
).toBe(2);
|
||||||
|
expect(snapshotCoordinates({ scrollOffsetX: 0, scrollOffsetY: 0 }, null)).toBeNull();
|
||||||
|
expect(snapshotViewportRect([10, 20, 120, 40], { target }, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares in-flight frame measurements, but never shares them with the next context", async () => {
|
||||||
|
const cdp = driver();
|
||||||
|
const context = new GeometryContext(cdp, 4);
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from({ length: 20 }, () => context.targetProjection("child")),
|
||||||
|
);
|
||||||
|
expect(results.every((value) => value === results[0])).toBe(true);
|
||||||
|
expect(cdp.getFrameGraph).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cdp.calls.mock.calls.map(([, method]) => method)).toEqual([
|
||||||
|
"Page.getLayoutMetrics",
|
||||||
|
"DOM.getBoxModel",
|
||||||
|
]);
|
||||||
|
await new GeometryContext(cdp, 4).targetProjection("child");
|
||||||
|
expect(cdp.getFrameGraph).toHaveBeenCalledTimes(2);
|
||||||
|
expect(cdp.calls.mock.calls).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("projects frame-local snapshot CSS bounds through the scaled owner content quad", async () => {
|
||||||
|
const cdp = driver();
|
||||||
|
const context = new GeometryContext(cdp, 4);
|
||||||
|
const source = { target, frameId: "child" };
|
||||||
|
const projection = await context.snapshotProjection(source, 10, [], {
|
||||||
|
width: 1000,
|
||||||
|
height: 1000,
|
||||||
|
});
|
||||||
|
if (projection.status !== "available") throw new Error("expected available projection");
|
||||||
|
const input = snapshotViewportRect([17, 23, 120, 40], source, {
|
||||||
|
layoutUnitsPerCssPixel: 1,
|
||||||
|
scrollCss: { x: 0, y: 0 },
|
||||||
|
});
|
||||||
|
expect(projectSnapshotRect(input!, projection.projection)).toEqual({
|
||||||
|
x: 73.75,
|
||||||
|
y: 641.25,
|
||||||
|
width: 150,
|
||||||
|
height: 50,
|
||||||
|
});
|
||||||
|
const scrolled = snapshotViewportRect([17, 23, 120, 40], source, {
|
||||||
|
layoutUnitsPerCssPixel: 1,
|
||||||
|
scrollCss: { x: 0, y: 10 },
|
||||||
|
});
|
||||||
|
expect(projectSnapshotRect(scrolled!, projection.projection)?.y).toBe(628.75);
|
||||||
|
await context.snapshotProjection(source, 10, [], { width: 1000, height: 1000 });
|
||||||
|
expect(cdp.calls.mock.calls.filter(([, method]) => method === "DOM.resolveNode")).toHaveLength(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
cdp.calls.mock.calls.filter(([, method]) => method === "Runtime.releaseObject"),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back only for missing or invalid batch entries and does not reuse sizes across contexts", async () => {
|
||||||
|
const cdp = driver();
|
||||||
|
const original = cdp.send;
|
||||||
|
const calls: string[] = [];
|
||||||
|
cdp.send = async (tabId, method, params) => {
|
||||||
|
calls.push(method);
|
||||||
|
if (method === "Runtime.evaluate")
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
deepSerializedValue: {
|
||||||
|
type: "array",
|
||||||
|
value: [
|
||||||
|
{
|
||||||
|
type: "array",
|
||||||
|
value: [
|
||||||
|
{ type: "node", value: { backendNodeId: 10 } },
|
||||||
|
{ type: "string", value: '{"width":300,"height":200}' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "array",
|
||||||
|
value: [
|
||||||
|
{ type: "node", value: { backendNodeId: 11 } },
|
||||||
|
{ type: "string", value: '{"width":0,"height":200}' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never;
|
||||||
|
if (method === "Runtime.releaseObjectGroup") return {} as never;
|
||||||
|
return original(tabId, method, params);
|
||||||
|
};
|
||||||
|
const context = new GeometryContext(cdp, 4);
|
||||||
|
context.registerSnapshotOwners(target, new Set([10, 11]));
|
||||||
|
const values = await Promise.all(
|
||||||
|
[10, 11].map((id) =>
|
||||||
|
context.snapshotProjection({ target, frameId: String(id) }, id, [], {
|
||||||
|
width: 1000,
|
||||||
|
height: 1000,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(values.every((value) => value.status === "available")).toBe(true);
|
||||||
|
expect(calls.filter((method) => method === "DOM.resolveNode")).toHaveLength(1);
|
||||||
|
expect(calls.filter((method) => method === "Runtime.evaluate")).toHaveLength(1);
|
||||||
|
await new GeometryContext(cdp, 4).snapshotProjection({ target, frameId: "child" }, 10, [], {
|
||||||
|
width: 1000,
|
||||||
|
height: 1000,
|
||||||
|
});
|
||||||
|
expect(calls.filter((method) => method === "DOM.resolveNode")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for batch object cleanup and propagates cancellation", async () => {
|
||||||
|
const cdp = driver();
|
||||||
|
const original = cdp.send;
|
||||||
|
const controller = new AbortController();
|
||||||
|
let release!: () => void;
|
||||||
|
let cleanup!: () => void;
|
||||||
|
const started = new Promise<void>((resolve) => {
|
||||||
|
cleanup = resolve;
|
||||||
|
});
|
||||||
|
cdp.send = async (tabId, method, params) => {
|
||||||
|
if (method === "Runtime.evaluate") return {} as never;
|
||||||
|
if (method === "Runtime.releaseObjectGroup") {
|
||||||
|
cleanup();
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
return {} as never;
|
||||||
|
}
|
||||||
|
return original(tabId, method, params);
|
||||||
|
};
|
||||||
|
const context = new GeometryContext(cdp, 4, undefined, controller.signal);
|
||||||
|
context.registerSnapshotOwners(target, new Set([10, 11]));
|
||||||
|
let settled = false;
|
||||||
|
const operation = context
|
||||||
|
.snapshotProjection({ target, frameId: "child" }, 10, [], { width: 1000, height: 1000 })
|
||||||
|
.finally(() => {
|
||||||
|
settled = true;
|
||||||
|
});
|
||||||
|
const rejected = expect(operation).rejects.toMatchObject({ name: "AbortError" });
|
||||||
|
await started;
|
||||||
|
controller.abort();
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
release();
|
||||||
|
await rejected;
|
||||||
|
expect(cdp.calls.mock.calls.some(([, method]) => method === "DOM.resolveNode")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects owner mismatches and invalid numeric bounds", () => {
|
||||||
|
const input = snapshotViewportRect(
|
||||||
|
[30, 50, 120, 40],
|
||||||
|
{ target, frameId: "main" },
|
||||||
|
{ layoutUnitsPerCssPixel: 1, scrollCss: { x: 10, y: 20 } },
|
||||||
|
)!;
|
||||||
|
const projection = {
|
||||||
|
source: { target, frameId: "main" },
|
||||||
|
geometry: { sourceClips: [], edges: [], topViewport: { width: 800, height: 600 } },
|
||||||
|
};
|
||||||
|
expect(projectSnapshotRect(input, projection)).toEqual({
|
||||||
|
x: 20,
|
||||||
|
y: 30,
|
||||||
|
width: 120,
|
||||||
|
height: 40,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
projectSnapshotRect(input, { ...projection, source: { target, frameId: "other" } }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
projectSnapshotRect(input, {
|
||||||
|
...projection,
|
||||||
|
source: { target: { tabId: 5 }, frameId: "main" },
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
snapshotViewportRect(
|
||||||
|
[0, 0, NaN, 40],
|
||||||
|
{ target },
|
||||||
|
{ layoutUnitsPerCssPixel: 1, scrollCss: { x: 0, y: 0 } },
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
snapshotViewportRect(
|
||||||
|
[0, 0, 10, 40],
|
||||||
|
{ target },
|
||||||
|
{ layoutUnitsPerCssPixel: 1, scrollCss: { x: Infinity, y: 0 } },
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not turn a border quad or a failed owner read into a content projection", async () => {
|
||||||
|
const cdp: CdpRunner = {
|
||||||
|
send: vi.fn(async () => ({
|
||||||
|
model: { border: [0, 0, 100, 0, 100, 100, 0, 100] },
|
||||||
|
})) as CdpRunner["send"],
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
await new GeometryContext(cdp, 4).snapshotProjection({ target, frameId: "child" }, 10, [], {
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
status: "unavailable",
|
||||||
|
source: { target, frameId: "child" },
|
||||||
|
ownerBackendNodeId: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps ancestor clips in target coordinates instead of applying nested offsets twice", async () => {
|
||||||
|
const cdp = driver();
|
||||||
|
const projection = await new GeometryContext(cdp, 4).snapshotProjection(
|
||||||
|
{ target, frameId: "nested" },
|
||||||
|
10,
|
||||||
|
[rectPolygon({ x: 60, y: 620, w: 100, h: 100 })],
|
||||||
|
{ width: 1000, height: 1000 },
|
||||||
|
);
|
||||||
|
if (projection.status !== "available") throw new Error("expected available projection");
|
||||||
|
const input = snapshotViewportRect(
|
||||||
|
[0, 0, 300, 200],
|
||||||
|
{ target, frameId: "nested" },
|
||||||
|
{ layoutUnitsPerCssPixel: 1, scrollCss: { x: 0, y: 0 } },
|
||||||
|
);
|
||||||
|
expect(projectSnapshotRect(input!, projection.projection)).toEqual({
|
||||||
|
x: 60,
|
||||||
|
y: 620,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed frame ancestry without looping", async () => {
|
||||||
|
const cdp = driver();
|
||||||
|
const cycle: CdpFrameGraph = {
|
||||||
|
rootFrameId: "a",
|
||||||
|
frames: [
|
||||||
|
{ frameId: "a", parentFrameId: "b", target },
|
||||||
|
{ frameId: "b", parentFrameId: "a", target },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(await new GeometryContext(cdp, 4, cycle).targetProjection("a")).toBeNull();
|
||||||
|
expect(cdp.calls).not.toHaveBeenCalled();
|
||||||
|
const orphan: CdpFrameGraph = {
|
||||||
|
rootFrameId: "a",
|
||||||
|
frames: [{ frameId: "a", parentFrameId: "missing", target }],
|
||||||
|
};
|
||||||
|
expect(await new GeometryContext(cdp, 4, orphan).targetProjection("a")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a live node whose frame belongs to another target before any DOM input", async () => {
|
||||||
|
const cdp = driver();
|
||||||
|
expect(
|
||||||
|
await resolveNodeGeometry(
|
||||||
|
cdp,
|
||||||
|
4,
|
||||||
|
{ target: { tabId: 4, sessionId: "wrong" }, frameId: "child", backendNodeId: 20 },
|
||||||
|
{ scrollIntoView: true },
|
||||||
|
),
|
||||||
|
).toMatchObject({ code: "cdp_failed" });
|
||||||
|
expect(cdp.calls).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds concurrent measurements and does not dispatch queued reads after cancellation", async () => {
|
||||||
|
let active = 0;
|
||||||
|
let peak = 0;
|
||||||
|
const release: Array<() => void> = [];
|
||||||
|
const send = vi.fn(async () => {
|
||||||
|
active++;
|
||||||
|
peak = Math.max(peak, active);
|
||||||
|
await new Promise<void>((resolve) => release.push(resolve));
|
||||||
|
active--;
|
||||||
|
return {
|
||||||
|
visualViewport: { clientWidth: 1000 },
|
||||||
|
cssVisualViewport: { clientWidth: 1000 },
|
||||||
|
cssLayoutViewport: { clientWidth: 100, clientHeight: 100 },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const controller = new AbortController();
|
||||||
|
const context = new GeometryContext(
|
||||||
|
{ send: send as CdpRunner["send"] },
|
||||||
|
4,
|
||||||
|
undefined,
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
const tasks = Array.from({ length: 8 }, (_, index) =>
|
||||||
|
context.viewport({ tabId: 4, sessionId: String(index) }),
|
||||||
|
);
|
||||||
|
const settled = Promise.allSettled(tasks);
|
||||||
|
expect(send).toHaveBeenCalledTimes(4);
|
||||||
|
controller.abort();
|
||||||
|
for (const done of release) done();
|
||||||
|
const results = await settled;
|
||||||
|
expect(peak).toBe(4);
|
||||||
|
expect(send).toHaveBeenCalledTimes(4);
|
||||||
|
expect(results.filter((item) => item.status === "rejected")).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases an owner object even when cancellation arrives before its viewport read", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const cdp = driver();
|
||||||
|
const original = cdp.calls.getMockImplementation()!;
|
||||||
|
cdp.calls.mockImplementation(async (tab, method) => {
|
||||||
|
const result = await original(tab, method);
|
||||||
|
if (method === "DOM.resolveNode") controller.abort();
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
const context = new GeometryContext(cdp, 4, undefined, controller.signal);
|
||||||
|
await expect(
|
||||||
|
context.snapshotProjection({ target, frameId: "child" }, 10, [], {
|
||||||
|
width: 1000,
|
||||||
|
height: 1000,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ name: "AbortError" });
|
||||||
|
expect(cdp.calls.mock.calls.map(([, method]) => method)).toEqual([
|
||||||
|
"DOM.getBoxModel",
|
||||||
|
"DOM.resolveNode",
|
||||||
|
"Runtime.releaseObject",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads live owner geometry only after scrolling", async () => {
|
||||||
|
const cdp = driver();
|
||||||
|
const original = cdp.calls.getMockImplementation()!;
|
||||||
|
let scrolled = false;
|
||||||
|
cdp.calls.mockImplementation(async (tab, method) => {
|
||||||
|
if (method === "DOM.scrollIntoViewIfNeeded") {
|
||||||
|
scrolled = true;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (method === "DOM.getContentQuads") {
|
||||||
|
expect(scrolled).toBe(true);
|
||||||
|
return { quads: [[60, 620, 100, 620, 100, 640, 60, 640]] };
|
||||||
|
}
|
||||||
|
if (method === "DOM.getBoxModel") expect(scrolled).toBe(true);
|
||||||
|
return original(tab, method);
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await resolveNodeGeometry(
|
||||||
|
cdp,
|
||||||
|
4,
|
||||||
|
{ target, frameId: "child", backendNodeId: 20 },
|
||||||
|
{ scrollIntoView: true },
|
||||||
|
),
|
||||||
|
).toMatchObject({ topBounds: { x: 60, y: 620, width: 40, height: 20 } });
|
||||||
|
expect(cdp.getFrameGraph).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cdp.calls.mock.calls.filter(([, method]) => method === "DOM.getBoxModel")).toHaveLength(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adapts viewport CSS bounds to page DIP with scroll and browser zoom, never raster DPR", () => {
|
||||||
|
const viewport = { width: 1600, height: 1000, scrollX: 10, scrollY: 100, cssToDip: 0.9 };
|
||||||
|
const clip = screenshotPageRect({ x: 20, y: 30, width: 120, height: 40 }, viewport);
|
||||||
|
expect(clip).toEqual({ space: "page-dip", rect: { x: 27, y: 117, width: 108, height: 36 } });
|
||||||
|
expect(
|
||||||
|
screenshotPageRect({ x: 20, y: 30, width: 120, height: 40 }, { ...viewport, cssToDip: NaN }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
screenshotPageRect({ x: 20, y: 30, width: 120, height: 40 }, { ...viewport, cssToDip: 0 }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries a rejected measurement only in a new operation", async () => {
|
||||||
|
const cdp = driver();
|
||||||
|
cdp.calls.mockRejectedValueOnce(new Error("unavailable"));
|
||||||
|
const context = new GeometryContext(cdp, 4);
|
||||||
|
await expect(context.viewport(target)).rejects.toThrow("unavailable");
|
||||||
|
await expect(context.viewport(target)).rejects.toThrow("unavailable");
|
||||||
|
expect(cdp.calls).toHaveBeenCalledTimes(1);
|
||||||
|
expect(await new GeometryContext(cdp, 4).viewport(target)).toEqual({
|
||||||
|
width: 1000,
|
||||||
|
height: 1000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,7 +21,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
}),
|
}),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,6 +540,21 @@ describe("handleHover", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function successfulFillScript(params: unknown) {
|
||||||
|
const script = params as { arguments?: Array<{ value: unknown }>; functionDeclaration: string };
|
||||||
|
const args = script.arguments ?? [];
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
value:
|
||||||
|
args.length === 2
|
||||||
|
? { before: "", expected: args[0].value }
|
||||||
|
: script.functionDeclaration.startsWith("function(expected)")
|
||||||
|
? { connected: true, matches: true, valueLength: String(args[0].value).length }
|
||||||
|
: "ready",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("handleFill", () => {
|
describe("handleFill", () => {
|
||||||
it("returns not_found for unknown ref", async () => {
|
it("returns not_found for unknown ref", async () => {
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
@@ -586,7 +601,8 @@ describe("handleFill", () => {
|
|||||||
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
||||||
"DOM.focus": () => ({}),
|
"DOM.focus": () => ({}),
|
||||||
"DOM.resolveNode": () => ({ object: { objectId: "obj-1" } }),
|
"DOM.resolveNode": () => ({ object: { objectId: "obj-1" } }),
|
||||||
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }),
|
"Runtime.callFunctionOn": successfulFillScript,
|
||||||
|
"Runtime.releaseObject": () => ({}),
|
||||||
"Input.insertText": () => ({}),
|
"Input.insertText": () => ({}),
|
||||||
});
|
});
|
||||||
const res = await handleFill(
|
const res = await handleFill(
|
||||||
@@ -600,13 +616,12 @@ describe("handleFill", () => {
|
|||||||
expect(res.used_ref).toBe("e1");
|
expect(res.used_ref).toBe("e1");
|
||||||
const insert = fake.sent.find((c) => c.method === "Input.insertText");
|
const insert = fake.sent.find((c) => c.method === "Input.insertText");
|
||||||
expect(insert?.params).toEqual({ text: "hello" });
|
expect(insert?.params).toEqual({ text: "hello" });
|
||||||
// clear_before defaults to true → callFunctionOn invoked once to
|
// Foreground replacement needs no extra caret-positioning round trip.
|
||||||
// clear, once to fire input/change after typing.
|
|
||||||
const callFns = fake.sent.filter((c) => c.method === "Runtime.callFunctionOn");
|
const callFns = fake.sent.filter((c) => c.method === "Runtime.callFunctionOn");
|
||||||
expect(callFns.length).toBeGreaterThanOrEqual(2);
|
expect(callFns).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clear_before=false skips the wipe call", async () => {
|
it("passes clear_before=false to preparation and verifies the result", async () => {
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
const ctx = await sm.start("aa11");
|
const ctx = await sm.start("aa11");
|
||||||
ctx.refStore.set("e1", 1, { tabId: 4 });
|
ctx.refStore.set("e1", 1, { tabId: 4 });
|
||||||
@@ -618,12 +633,12 @@ describe("handleFill", () => {
|
|||||||
"DOM.focus": () => ({}),
|
"DOM.focus": () => ({}),
|
||||||
"DOM.resolveNode": () => ({ object: { objectId: "obj-2" } }),
|
"DOM.resolveNode": () => ({ object: { objectId: "obj-2" } }),
|
||||||
"Runtime.callFunctionOn": (p) => {
|
"Runtime.callFunctionOn": (p) => {
|
||||||
const fn = (p as { functionDeclaration?: string }).functionDeclaration ?? "";
|
const args = (p as { arguments?: Array<{ value: unknown }> }).arguments ?? [];
|
||||||
// No "this.value = ''" clearing on a no-clear path; only the
|
if (args.length === 2) expect(args[1].value).toBe(false);
|
||||||
// post-input dispatchEvent.
|
return successfulFillScript(p);
|
||||||
expect(fn).not.toMatch(/this\.value\s*=\s*''/);
|
|
||||||
return { result: { type: "undefined" } };
|
|
||||||
},
|
},
|
||||||
|
"Input.dispatchKeyEvent": () => ({}),
|
||||||
|
"Runtime.releaseObject": () => ({}),
|
||||||
"Input.insertText": () => ({}),
|
"Input.insertText": () => ({}),
|
||||||
});
|
});
|
||||||
const res = await handleFill(
|
const res = await handleFill(
|
||||||
@@ -650,7 +665,8 @@ describe("handleFill", () => {
|
|||||||
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
||||||
"DOM.focus": () => ({}),
|
"DOM.focus": () => ({}),
|
||||||
"DOM.resolveNode": () => ({ object: { objectId: "obj-3" } }),
|
"DOM.resolveNode": () => ({ object: { objectId: "obj-3" } }),
|
||||||
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }),
|
"Runtime.callFunctionOn": successfulFillScript,
|
||||||
|
"Runtime.releaseObject": () => ({}),
|
||||||
"Input.insertText": () => ({}),
|
"Input.insertText": () => ({}),
|
||||||
});
|
});
|
||||||
const res = await handleFill(
|
const res = await handleFill(
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { SessionManager } from "@/session-manager/manager";
|
||||||
|
import type { BrowserNavigationApi } from "../browser-navigation";
|
||||||
|
import { handleNavigate, handleReload } from "../navigation";
|
||||||
|
import type { CdpRunner } from "../shared";
|
||||||
|
|
||||||
|
const denied = "Cannot access a chrome-extension:// URL of different extension";
|
||||||
|
const url = "https://example.test/recovered";
|
||||||
|
|
||||||
|
function event<T extends (...args: never[]) => void>() {
|
||||||
|
const listeners = new Set<T>();
|
||||||
|
return {
|
||||||
|
listeners,
|
||||||
|
addListener: (listener: T) => listeners.add(listener),
|
||||||
|
removeListener: (listener: T) => listeners.delete(listener),
|
||||||
|
fire: (...args: Parameters<T>) => {
|
||||||
|
for (const listener of [...listeners]) listener(...args);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixture() {
|
||||||
|
const manager = new SessionManager({
|
||||||
|
agentWindow: {
|
||||||
|
create: vi.fn(async () => 100),
|
||||||
|
remove: vi.fn(async () => {}),
|
||||||
|
ensureActiveTab: vi.fn(async () => 4),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await manager.start("test");
|
||||||
|
type Listener = Parameters<BrowserNavigationApi["onCommitted"]["addListener"]>[0];
|
||||||
|
const events = {
|
||||||
|
onCommitted: event<Listener>(),
|
||||||
|
onDOMContentLoaded: event<Listener>(),
|
||||||
|
onCompleted: event<Listener>(),
|
||||||
|
onErrorOccurred: event<Listener>(),
|
||||||
|
};
|
||||||
|
const cdpEvents = event<Parameters<NonNullable<CdpRunner["onEvent"]>>[0]>();
|
||||||
|
const state = { blocked: true, destinationBlocked: false, emitIdle: true };
|
||||||
|
const send = vi.fn(async (_tabId: number, method: string) => {
|
||||||
|
if (state.blocked) throw new Error(denied);
|
||||||
|
if (method === "Page.getFrameTree") {
|
||||||
|
return { frameTree: { frame: { id: "main", loaderId: "new-loader" } } };
|
||||||
|
}
|
||||||
|
if (method === "Runtime.evaluate") return { result: { value: "complete" } };
|
||||||
|
if (method === "Page.setLifecycleEventsEnabled" && state.emitIdle) {
|
||||||
|
cdpEvents.fire({ tabId: 4 }, "Page.lifecycleEvent", {
|
||||||
|
frameId: "main",
|
||||||
|
loaderId: "new-loader",
|
||||||
|
name: "networkIdle",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (method === "Page.navigate") return { frameId: "main", loaderId: "new-loader" };
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
const cdp: CdpRunner = {
|
||||||
|
send: send as CdpRunner["send"],
|
||||||
|
onEvent: (listener) => {
|
||||||
|
cdpEvents.addListener(listener);
|
||||||
|
return { dispose: () => cdpEvents.removeListener(listener) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const main = { tabId: 4, frameId: 0, documentId: "new-document" };
|
||||||
|
const navigate = async () => {
|
||||||
|
state.blocked = state.destinationBlocked;
|
||||||
|
events.onCommitted.fire(main);
|
||||||
|
events.onDOMContentLoaded.fire(main);
|
||||||
|
events.onCompleted.fire(main);
|
||||||
|
};
|
||||||
|
const browserNavigation = { ...events, update: vi.fn(navigate), reload: vi.fn(navigate) };
|
||||||
|
const tab = { id: 4, windowId: 100, active: true, url } as chrome.tabs.Tab;
|
||||||
|
const tabsApi = { get: vi.fn(async () => tab), query: vi.fn(async () => [tab]) };
|
||||||
|
const deps = { cdp, tabsApi, browserNavigation, defaultTimeoutMs: 1000 };
|
||||||
|
const expectCleanedUp = () => {
|
||||||
|
for (const entry of Object.values(events)) expect(entry.listeners.size).toBe(0);
|
||||||
|
expect(cdpEvents.listeners.size).toBe(0);
|
||||||
|
};
|
||||||
|
return { manager, deps, state, send, events, main, cdpEvents, expectCleanedUp };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("navigation after Chrome denies extension-frame access", () => {
|
||||||
|
it.each([
|
||||||
|
"load",
|
||||||
|
"commit",
|
||||||
|
"domcontentloaded",
|
||||||
|
"networkidle",
|
||||||
|
] as const)("recovers the same tab with the requested %s checkpoint", async (waitUntil) => {
|
||||||
|
const f = await fixture();
|
||||||
|
const result = await handleNavigate(
|
||||||
|
f.manager,
|
||||||
|
{
|
||||||
|
session_id: "test",
|
||||||
|
url,
|
||||||
|
wait_until: waitUntil,
|
||||||
|
},
|
||||||
|
f.deps,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ tab_id: 4, final_url: url, reached: waitUntil });
|
||||||
|
expect(f.deps.browserNavigation.update).toHaveBeenCalledExactlyOnceWith(4, { url });
|
||||||
|
expect(f.send.mock.calls.some(([, method]) => method === "Page.navigate")).toBe(false);
|
||||||
|
expect(f.send).toHaveBeenCalledWith(4, "Page.enable", {});
|
||||||
|
f.expectCleanedUp();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers reload through tabs.reload and preserves hard reload", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
const result = await handleReload(f.manager, { session_id: "test", hard: true }, f.deps);
|
||||||
|
expect(result).toMatchObject({ tab_id: 4, reached: "load" });
|
||||||
|
expect(f.deps.browserNavigation.reload).toHaveBeenCalledExactlyOnceWith(4, {
|
||||||
|
bypassCache: true,
|
||||||
|
});
|
||||||
|
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
|
||||||
|
f.expectCleanedUp();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the restriction when reload leaves the conflicting frame present", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
f.state.destinationBlocked = true;
|
||||||
|
const result = await handleReload(f.manager, { session_id: "test" }, f.deps);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
code: "cdp_failed",
|
||||||
|
data: { reason: "cdp_extension_access_denied" },
|
||||||
|
});
|
||||||
|
expect(f.deps.browserNavigation.reload).toHaveBeenCalledOnce();
|
||||||
|
f.expectCleanedUp();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fall back for unrelated debugger failures", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
f.send.mockRejectedValue(new Error("Another debugger is already attached"));
|
||||||
|
const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
code: "cdp_failed",
|
||||||
|
message: "Another debugger is already attached",
|
||||||
|
});
|
||||||
|
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
|
||||||
|
f.expectCleanedUp();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not replay navigation when a dispatched CDP command fails", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
f.state.blocked = false;
|
||||||
|
const send = f.send.getMockImplementation()!;
|
||||||
|
f.send.mockImplementation(async (tabId, method) => {
|
||||||
|
if (method === "Page.navigate") throw new Error(denied);
|
||||||
|
return send(tabId, method);
|
||||||
|
});
|
||||||
|
const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
code: "cdp_failed",
|
||||||
|
data: { reason: "cdp_extension_access_denied" },
|
||||||
|
});
|
||||||
|
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
|
||||||
|
f.expectCleanedUp();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the Agent Window guard in front of the recovery", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
f.deps.tabsApi.get.mockResolvedValue({
|
||||||
|
id: 4,
|
||||||
|
windowId: 200,
|
||||||
|
active: true,
|
||||||
|
url,
|
||||||
|
} as chrome.tabs.Tab);
|
||||||
|
const result = await handleNavigate(f.manager, { session_id: "test", tab_id: 4, url }, f.deps);
|
||||||
|
expect(result).toMatchObject({ code: "permission_denied" });
|
||||||
|
expect(f.send).not.toHaveBeenCalled();
|
||||||
|
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores old-document, subframe, and other-tab completion events", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
f.deps.browserNavigation.update.mockImplementation(async () => {});
|
||||||
|
let settled = false;
|
||||||
|
const work = handleNavigate(f.manager, { session_id: "test", url }, f.deps).then((result) => {
|
||||||
|
settled = true;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(f.deps.browserNavigation.update).toHaveBeenCalledOnce());
|
||||||
|
f.events.onCompleted.fire(f.main);
|
||||||
|
f.events.onCommitted.fire({ ...f.main, tabId: 9 });
|
||||||
|
f.events.onCommitted.fire({ ...f.main, frameId: 1 });
|
||||||
|
f.events.onCompleted.fire(f.main);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
f.events.onCommitted.fire(f.main);
|
||||||
|
f.events.onCompleted.fire({ ...f.main, documentId: "old-document" });
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
f.state.blocked = false;
|
||||||
|
f.events.onCompleted.fire(f.main);
|
||||||
|
expect(await work).toMatchObject({ reached: "load" });
|
||||||
|
f.expectCleanedUp();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not infer network idle from a browser load event", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
f.state.emitIdle = false;
|
||||||
|
const result = await handleNavigate(
|
||||||
|
f.manager,
|
||||||
|
{
|
||||||
|
session_id: "test",
|
||||||
|
url,
|
||||||
|
wait_until: "networkidle",
|
||||||
|
timeout_ms: 10,
|
||||||
|
},
|
||||||
|
f.deps,
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({ reached: "timeout" });
|
||||||
|
f.expectCleanedUp();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans up browser listeners on timeout", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
f.deps.browserNavigation.update.mockImplementation(async () => {});
|
||||||
|
const result = await handleNavigate(
|
||||||
|
f.manager,
|
||||||
|
{ session_id: "test", url, timeout_ms: 10 },
|
||||||
|
f.deps,
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({ reached: "timeout" });
|
||||||
|
f.expectCleanedUp();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not navigate an already-cancelled request", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
const result = await handleNavigate(
|
||||||
|
f.manager,
|
||||||
|
{ session_id: "test", url },
|
||||||
|
{
|
||||||
|
...f.deps,
|
||||||
|
signal: AbortSignal.abort(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({ code: "cancelled" });
|
||||||
|
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
|
||||||
|
f.expectCleanedUp();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans up browser listeners when cancelled after dispatch", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
const ac = new AbortController();
|
||||||
|
f.deps.browserNavigation.update.mockImplementation(async () => {
|
||||||
|
ac.abort();
|
||||||
|
});
|
||||||
|
const result = await handleNavigate(
|
||||||
|
f.manager,
|
||||||
|
{ session_id: "test", url },
|
||||||
|
{ ...f.deps, signal: ac.signal },
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({ code: "cancelled" });
|
||||||
|
expect(f.deps.browserNavigation.update).toHaveBeenCalledOnce();
|
||||||
|
f.expectCleanedUp();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports browser navigation errors and releases listeners", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
f.deps.browserNavigation.update.mockImplementation(async () => {
|
||||||
|
f.events.onErrorOccurred.fire({ ...f.main, error: "net::ERR_NAME_NOT_RESOLVED" });
|
||||||
|
});
|
||||||
|
const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps);
|
||||||
|
expect(result).toMatchObject({ code: "cdp_failed", message: "net::ERR_NAME_NOT_RESOLVED" });
|
||||||
|
f.expectCleanedUp();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,7 +21,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
}),
|
}),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
}),
|
}),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,26 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { SessionManager } from "@/session-manager/manager";
|
import { SessionManager } from "@/session-manager/manager";
|
||||||
import { handleSessionStop } from "../session";
|
import { handleSessionStop } from "../session";
|
||||||
|
import type { ChromeTabsApi } from "../shared";
|
||||||
import { type AgentOverlayResetApi, type ChromeWindowsApi, type TabMutationApi } from "../tabs";
|
import { type AgentOverlayResetApi, type ChromeWindowsApi, type TabMutationApi } from "../tabs";
|
||||||
|
|
||||||
|
/** Build a read-only query api over a FakeState. */
|
||||||
|
function makeQuery(state: FakeState): ChromeTabsApi {
|
||||||
|
return {
|
||||||
|
get: vi.fn(async (id) => {
|
||||||
|
const t = state.tabs.get(id);
|
||||||
|
if (!t) throw new Error(`tab ${id} not found`);
|
||||||
|
return t;
|
||||||
|
}),
|
||||||
|
query: vi.fn(async (q: chrome.tabs.QueryInfo) => {
|
||||||
|
const w = q.windowId;
|
||||||
|
return Array.from(state.tabs.values()).filter(
|
||||||
|
(t) => typeof w !== "number" || t.windowId === w,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function fakeAgentWindow(ids: number[]) {
|
function fakeAgentWindow(ids: number[]) {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
const create = vi.fn(async () => {
|
const create = vi.fn(async () => {
|
||||||
@@ -11,7 +29,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
});
|
});
|
||||||
const remove = vi.fn(async () => {});
|
const remove = vi.fn(async () => {});
|
||||||
const ensureActiveTab = vi.fn(async () => {});
|
const ensureActiveTab = vi.fn(async () => 0);
|
||||||
return { create, remove, ensureActiveTab };
|
return { create, remove, ensureActiveTab };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +48,9 @@ function makeApis(
|
|||||||
} {
|
} {
|
||||||
const tabs: TabMutationApi = {
|
const tabs: TabMutationApi = {
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async (id: number) => {
|
||||||
|
state.tabs.delete(id);
|
||||||
|
}),
|
||||||
update: vi.fn(async (_id, _p) => undefined),
|
update: vi.fn(async (_id, _p) => undefined),
|
||||||
get: vi.fn(async (id) => {
|
get: vi.fn(async (id) => {
|
||||||
const t = state.tabs.get(id);
|
const t = state.tabs.get(id);
|
||||||
@@ -237,11 +257,15 @@ describe("handleSessionStop with auto-return", () => {
|
|||||||
moves: [],
|
moves: [],
|
||||||
};
|
};
|
||||||
const { tabs, windows } = makeApis(state, { moveThrowsFor: new Set([1]) });
|
const { tabs, windows } = makeApis(state, { moveThrowsFor: new Set([1]) });
|
||||||
|
const cdp = {
|
||||||
|
detachSession: vi.fn(async () => {}),
|
||||||
|
releaseSessionTab: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
|
||||||
const res = await handleSessionStop(
|
const res = await handleSessionStop(
|
||||||
sm,
|
sm,
|
||||||
{ session_id: "aa11" },
|
{ session_id: "aa11" },
|
||||||
{ tabManagement: { tabs, windows } },
|
{ cdp, tabManagement: { tabs, windows } },
|
||||||
);
|
);
|
||||||
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||||
expect(res.return_failures?.map((f) => f.tab_id)).toEqual([1]);
|
expect(res.return_failures?.map((f) => f.tab_id)).toEqual([1]);
|
||||||
@@ -249,6 +273,8 @@ describe("handleSessionStop with auto-return", () => {
|
|||||||
expect(sm.has("aa11")).toBe(true);
|
expect(sm.has("aa11")).toBe(true);
|
||||||
expect(ctx.borrowedTabs.has(1)).toBe(true);
|
expect(ctx.borrowedTabs.has(1)).toBe(true);
|
||||||
expect(ctx.borrowedTabs.has(2)).toBe(false);
|
expect(ctx.borrowedTabs.has(2)).toBe(false);
|
||||||
|
expect(cdp.releaseSessionTab).toHaveBeenCalledExactlyOnceWith("aa11", 2);
|
||||||
|
expect(cdp.detachSession).not.toHaveBeenCalled();
|
||||||
expect(aw.remove).not.toHaveBeenCalled();
|
expect(aw.remove).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -267,4 +293,275 @@ describe("handleSessionStop with auto-return", () => {
|
|||||||
await handleSessionStop(sm, { session_id: "aa11" }, { tabManagement: { tabs, windows } });
|
await handleSessionStop(sm, { session_id: "aa11" }, { tabManagement: { tabs, windows } });
|
||||||
expect(ctx.refStore.isEmpty()).toBe(true);
|
expect(ctx.refStore.isEmpty()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"before teardown",
|
||||||
|
"during its return",
|
||||||
|
"after its return failed",
|
||||||
|
"before its turn in the return loop",
|
||||||
|
])("finishes stopping when a borrowed tab closes %s", async (timing) => {
|
||||||
|
const aw = fakeAgentWindow([100]);
|
||||||
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.borrowedTabs.set(1, { tabId: 1, originalWindowId: 200, originalIndex: 0 });
|
||||||
|
ctx.borrowedTabs.set(2, { tabId: 2, originalWindowId: 200, originalIndex: 1 });
|
||||||
|
const state: FakeState = {
|
||||||
|
tabs: new Map([
|
||||||
|
[1, { id: 1, windowId: 100 } as chrome.tabs.Tab],
|
||||||
|
[2, { id: 2, windowId: 100 } as chrome.tabs.Tab],
|
||||||
|
]),
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
moves: [],
|
||||||
|
};
|
||||||
|
const { tabs, windows } = makeApis(state);
|
||||||
|
const cdp = { detachSession: vi.fn(async () => {}) };
|
||||||
|
const closeTab = (tabId: number) => {
|
||||||
|
state.tabs.delete(tabId);
|
||||||
|
sm.forgetClosedTab(tabId);
|
||||||
|
};
|
||||||
|
const baseMove = tabs.move;
|
||||||
|
tabs.move = vi.fn(async (tabId, props) => {
|
||||||
|
if (tabId === 1 && timing === "during its return") closeTab(1);
|
||||||
|
if (tabId === 1 && timing === "after its return failed") {
|
||||||
|
throw new Error("simulated move failure");
|
||||||
|
}
|
||||||
|
if (tabId === 2 && timing === "after its return failed") closeTab(1);
|
||||||
|
if (tabId === 1 && timing === "before its turn in the return loop") closeTab(2);
|
||||||
|
if (!state.tabs.has(tabId)) throw new Error(`tab ${tabId} not found`);
|
||||||
|
return baseMove(tabId, props);
|
||||||
|
});
|
||||||
|
if (timing === "before teardown") closeTab(1);
|
||||||
|
|
||||||
|
const res = await handleSessionStop(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11" },
|
||||||
|
{
|
||||||
|
cdp,
|
||||||
|
tabManagement: {
|
||||||
|
tabs,
|
||||||
|
windows,
|
||||||
|
agentOverlayReset: { resetAgentOverlays: vi.fn(async () => {}) },
|
||||||
|
},
|
||||||
|
tabsQuery: makeQuery(state),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||||
|
const closedTabId = timing === "before its turn in the return loop" ? 2 : 1;
|
||||||
|
expect(res.returned_tab_ids).toEqual([closedTabId === 1 ? 2 : 1]);
|
||||||
|
expect(res.return_failures).toBeUndefined();
|
||||||
|
expect(ctx.borrowedTabs.size).toBe(0);
|
||||||
|
expect(sm.has("aa11")).toBe(false);
|
||||||
|
expect(cdp.detachSession).toHaveBeenCalledWith("aa11");
|
||||||
|
expect(aw.remove).toHaveBeenCalledWith(100);
|
||||||
|
if (timing === "before teardown" || timing === "before its turn in the return loop") {
|
||||||
|
expect(tabs.move).not.toHaveBeenCalledWith(closedTabId, expect.anything());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleSessionStop window release (issue #57)", () => {
|
||||||
|
const agentWindowId = 100;
|
||||||
|
|
||||||
|
it("releases the window when user-created tabs remain", async () => {
|
||||||
|
const aw = fakeAgentWindow([agentWindowId]);
|
||||||
|
// ensureActiveTab returns the home tab id (10). We override the fake to
|
||||||
|
// return a known id.
|
||||||
|
aw.ensureActiveTab = vi.fn(async () => 10);
|
||||||
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.agentCreatedTabs.add(11);
|
||||||
|
ctx.agentCreatedTabs.add(12);
|
||||||
|
// user tab 99 (created via Chrome UI, NOT in agentCreatedTabs)
|
||||||
|
const state: FakeState = {
|
||||||
|
tabs: new Map([
|
||||||
|
[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab],
|
||||||
|
[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab],
|
||||||
|
[12, { id: 12, windowId: agentWindowId } as chrome.tabs.Tab],
|
||||||
|
[99, { id: 99, windowId: agentWindowId } as chrome.tabs.Tab],
|
||||||
|
]),
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
moves: [],
|
||||||
|
};
|
||||||
|
const { tabs, windows } = makeApis(state);
|
||||||
|
const query = makeQuery(state);
|
||||||
|
|
||||||
|
const res = await handleSessionStop(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11" },
|
||||||
|
{ tabManagement: { tabs, windows }, tabsQuery: query },
|
||||||
|
);
|
||||||
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||||
|
expect(res.window_released).toBe(true);
|
||||||
|
// agent tabs + home removed, user tab 99 kept
|
||||||
|
expect((tabs.remove as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0]).sort()).toEqual([
|
||||||
|
10, 11, 12,
|
||||||
|
]);
|
||||||
|
expect(state.tabs.has(99)).toBe(true);
|
||||||
|
// window released (dropOnly), not closed
|
||||||
|
expect(aw.remove).not.toHaveBeenCalled();
|
||||||
|
expect(sm.has("aa11")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the window (not release) when no user tabs remain", async () => {
|
||||||
|
const aw = fakeAgentWindow([agentWindowId]);
|
||||||
|
aw.ensureActiveTab = vi.fn(async () => 10);
|
||||||
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.agentCreatedTabs.add(11);
|
||||||
|
const state: FakeState = {
|
||||||
|
tabs: new Map([
|
||||||
|
[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab],
|
||||||
|
[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab],
|
||||||
|
]),
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
moves: [],
|
||||||
|
};
|
||||||
|
const { tabs, windows } = makeApis(state);
|
||||||
|
const query = makeQuery(state);
|
||||||
|
|
||||||
|
const res = await handleSessionStop(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11" },
|
||||||
|
{ tabManagement: { tabs, windows }, tabsQuery: query },
|
||||||
|
);
|
||||||
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||||
|
expect(res.window_released).toBeFalsy();
|
||||||
|
expect(aw.remove).toHaveBeenCalledWith(agentWindowId);
|
||||||
|
expect(sm.has("aa11")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is non-fatal when an agent tab is already gone", async () => {
|
||||||
|
const aw = fakeAgentWindow([agentWindowId]);
|
||||||
|
aw.ensureActiveTab = vi.fn(async () => 10);
|
||||||
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.agentCreatedTabs.add(11); // no longer in state → remove throws
|
||||||
|
const state: FakeState = {
|
||||||
|
tabs: new Map([[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab]]),
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
moves: [],
|
||||||
|
};
|
||||||
|
const { tabs, windows } = makeApis(state);
|
||||||
|
(tabs.remove as ReturnType<typeof vi.fn>).mockImplementation(async (id: number) => {
|
||||||
|
if (id === 11) throw new Error("tab 11 already closed");
|
||||||
|
state.tabs.delete(id); // home (10) removed normally
|
||||||
|
});
|
||||||
|
const query = makeQuery(state);
|
||||||
|
|
||||||
|
const res = await handleSessionStop(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11" },
|
||||||
|
{ tabManagement: { tabs, windows }, tabsQuery: query },
|
||||||
|
);
|
||||||
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||||
|
// user tab 10? no — 10 is home, removed. window would be empty → close.
|
||||||
|
expect(res.window_released).toBeFalsy();
|
||||||
|
expect(aw.remove).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degrades to close-window when tabsQuery is missing", async () => {
|
||||||
|
const aw = fakeAgentWindow([agentWindowId]);
|
||||||
|
aw.ensureActiveTab = vi.fn(async () => 10);
|
||||||
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.agentCreatedTabs.add(11);
|
||||||
|
const state: FakeState = {
|
||||||
|
tabs: new Map([
|
||||||
|
[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab],
|
||||||
|
[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab],
|
||||||
|
]),
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
moves: [],
|
||||||
|
};
|
||||||
|
const { tabs, windows } = makeApis(state);
|
||||||
|
|
||||||
|
const res = await handleSessionStop(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11" },
|
||||||
|
{ tabManagement: { tabs, windows } }, // no tabsQuery
|
||||||
|
);
|
||||||
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||||
|
// No query available → conservatively close the window.
|
||||||
|
expect(res.window_released).toBeFalsy();
|
||||||
|
expect(aw.remove).toHaveBeenCalledWith(agentWindowId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degrades to close-window when tabsQuery.query throws", async () => {
|
||||||
|
const aw = fakeAgentWindow([agentWindowId]);
|
||||||
|
aw.ensureActiveTab = vi.fn(async () => 10);
|
||||||
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.agentCreatedTabs.add(11);
|
||||||
|
const state: FakeState = {
|
||||||
|
tabs: new Map([[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab]]),
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
moves: [],
|
||||||
|
};
|
||||||
|
const { tabs, windows } = makeApis(state);
|
||||||
|
const query = makeQuery(state);
|
||||||
|
(query.query as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
||||||
|
throw new Error("query failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await handleSessionStop(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11" },
|
||||||
|
{ tabManagement: { tabs, windows }, tabsQuery: query },
|
||||||
|
);
|
||||||
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||||
|
expect(res.window_released).toBeFalsy();
|
||||||
|
expect(aw.remove).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks agentCreatedTabs across tab_create / tab_close", async () => {
|
||||||
|
const aw = fakeAgentWindow([agentWindowId]);
|
||||||
|
aw.ensureActiveTab = vi.fn(async () => 10);
|
||||||
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
// Simulate handleTabCreate adding, then handleTabClose removing.
|
||||||
|
ctx.agentCreatedTabs.add(11);
|
||||||
|
expect(ctx.agentCreatedTabs.has(11)).toBe(true);
|
||||||
|
ctx.agentCreatedTabs.delete(11); // as handleTabClose does on success
|
||||||
|
expect(ctx.agentCreatedTabs.has(11)).toBe(false);
|
||||||
|
// Unknown id delete is a safe no-op (user-closed tab).
|
||||||
|
expect(() => ctx.agentCreatedTabs.delete(999)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the window (not release) when an agent tab fails to close", async () => {
|
||||||
|
// Regression: a leaked agent tab (still in agentCreatedTabs, but
|
||||||
|
// still present because Step 4 remove() threw) must NOT be mistaken
|
||||||
|
// for a user tab. Otherwise the window would be released (dropOnly)
|
||||||
|
// and the agent tab would leak (issue #57 regression).
|
||||||
|
const aw = fakeAgentWindow([agentWindowId]);
|
||||||
|
aw.ensureActiveTab = vi.fn(async () => 10);
|
||||||
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.agentCreatedTabs.add(11); // Step 4 remove() will throw for this tab
|
||||||
|
const state: FakeState = {
|
||||||
|
tabs: new Map([
|
||||||
|
[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab],
|
||||||
|
[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab],
|
||||||
|
]),
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
moves: [],
|
||||||
|
};
|
||||||
|
const { tabs, windows } = makeApis(state);
|
||||||
|
(tabs.remove as ReturnType<typeof vi.fn>).mockImplementation(async (id: number) => {
|
||||||
|
if (id === 11) throw new Error("tab 11 failed to close");
|
||||||
|
state.tabs.delete(id); // home (10) removed normally; 11 lingers
|
||||||
|
});
|
||||||
|
const query = makeQuery(state);
|
||||||
|
|
||||||
|
const res = await handleSessionStop(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11" },
|
||||||
|
{ tabManagement: { tabs, windows }, tabsQuery: query },
|
||||||
|
);
|
||||||
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||||
|
// The leaked agent tab must NOT keep the window open.
|
||||||
|
expect(res.window_released).toBeFalsy();
|
||||||
|
expect(aw.remove).toHaveBeenCalledWith(agentWindowId);
|
||||||
|
expect(sm.has("aa11")).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function fakeAgentWindow() {
|
|||||||
return {
|
return {
|
||||||
create: vi.fn(async () => 100),
|
create: vi.fn(async () => 100),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { CdpFrame, CdpFrameGraph, CdpTarget } from "@/browser-driver/frame-graph";
|
||||||
|
import type { CdpRunner } from "../shared";
|
||||||
|
import { captureObservationFacts } from "../vom/capture-coordinator";
|
||||||
|
|
||||||
|
type Send = <T = Record<string, unknown>>(
|
||||||
|
method: string,
|
||||||
|
params?: object,
|
||||||
|
sessionId?: string,
|
||||||
|
) => Promise<T>;
|
||||||
|
type Tree = { frame: { id: string; parentId?: string; name?: string }; childFrames?: Tree[] };
|
||||||
|
type Rect = { x: number; y: number; w: number; h: number };
|
||||||
|
type Oracle = { probe: Rect; owners: Record<string, { x: number; y: number; scale: number }> };
|
||||||
|
|
||||||
|
// The independent oracle uses DOM border boxes and this fixture's axis-aligned
|
||||||
|
// iframe transforms. No production snapshot conversion/projection builds expectations.
|
||||||
|
const oracleExpression = `(() => {
|
||||||
|
const box = document.querySelector('#probe').getBoundingClientRect();
|
||||||
|
const owners = {};
|
||||||
|
for (const frame of document.querySelectorAll('iframe')) {
|
||||||
|
const rect = frame.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(frame);
|
||||||
|
const scale = rect.width / frame.offsetWidth;
|
||||||
|
owners[frame.name] = {
|
||||||
|
x: rect.x + (frame.clientLeft + parseFloat(style.paddingLeft)) * scale,
|
||||||
|
y: rect.y + (frame.clientTop + parseFloat(style.paddingTop)) * scale,
|
||||||
|
scale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { probe: { x: box.x, y: box.y, w: box.width, h: box.height }, owners };
|
||||||
|
})()`;
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.BSK_GEOMETRY_CHROME)("real DOMSnapshot coordinate contract", () => {
|
||||||
|
it.each([
|
||||||
|
{ deviceScale: 1, zoom: 1 },
|
||||||
|
{ deviceScale: 0.8, zoom: 1 },
|
||||||
|
{ deviceScale: 1, zoom: 1.25 },
|
||||||
|
{ deviceScale: 2, zoom: 1 },
|
||||||
|
{ deviceScale: 2, zoom: 0.8 },
|
||||||
|
])("matches DOM geometry at device scale $deviceScale and zoom $zoom", async (configuration) => {
|
||||||
|
const evalRoot = new URL("../../../../../evals/browser/", import.meta.url);
|
||||||
|
const { createEvalServer } = await import(new URL("lib/server.mjs", evalRoot).href);
|
||||||
|
const { withChrome } = await import(
|
||||||
|
new URL("cases/regression/snapshot-coordinates/chrome.mjs", evalRoot).href
|
||||||
|
);
|
||||||
|
const server = createEvalServer();
|
||||||
|
const { baseUrl } = await server.start();
|
||||||
|
try {
|
||||||
|
await withChrome(
|
||||||
|
{ executable: process.env.BSK_GEOMETRY_CHROME, ...configuration },
|
||||||
|
async (send: Send) => {
|
||||||
|
const { targetId } = await send<{ targetId: string }>("Target.createTarget", {
|
||||||
|
url: "about:blank",
|
||||||
|
});
|
||||||
|
const { sessionId: rootSession } = await send<{ sessionId: string }>(
|
||||||
|
"Target.attachToTarget",
|
||||||
|
{ targetId, flatten: true },
|
||||||
|
);
|
||||||
|
await send(
|
||||||
|
"Page.navigate",
|
||||||
|
{ url: `${baseUrl}/snapshot-coordinates?run=coordinates` },
|
||||||
|
rootSession,
|
||||||
|
);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() =>
|
||||||
|
server
|
||||||
|
.snapshot("coordinates")
|
||||||
|
.events.some(
|
||||||
|
(event: { type: string; data: { root?: boolean } }) =>
|
||||||
|
event.type === "geometry.ready" && event.data.root,
|
||||||
|
),
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
const targets = await send<{ targetInfos: { targetId: string; type: string }[] }>(
|
||||||
|
"Target.getTargets",
|
||||||
|
);
|
||||||
|
const sessions = [rootSession];
|
||||||
|
for (const target of targets.targetInfos.filter((target) => target.type === "iframe")) {
|
||||||
|
const { sessionId } = await send<{ sessionId: string }>("Target.attachToTarget", {
|
||||||
|
targetId: target.targetId,
|
||||||
|
flatten: true,
|
||||||
|
});
|
||||||
|
sessions.push(sessionId);
|
||||||
|
}
|
||||||
|
expect(sessions.length).toBe(2); // The cross-site fixture must actually be an OOPIF.
|
||||||
|
const frames: CdpFrame[] = [];
|
||||||
|
const names = new Map<string, string>();
|
||||||
|
const sessionFor = (target: CdpTarget) => target.sessionId ?? rootSession;
|
||||||
|
for (const sessionId of sessions) {
|
||||||
|
const { frameTree } = await send<{ frameTree: Tree }>(
|
||||||
|
"Page.getFrameTree",
|
||||||
|
{},
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
const target: CdpTarget = {
|
||||||
|
tabId: 1,
|
||||||
|
...(sessionId === rootSession ? {} : { sessionId }),
|
||||||
|
};
|
||||||
|
const visit = (tree: Tree, parentFrameId = tree.frame.parentId) => {
|
||||||
|
frames.push({ frameId: tree.frame.id, parentFrameId, target });
|
||||||
|
names.set(tree.frame.id, tree.frame.name ?? "");
|
||||||
|
for (const child of tree.childFrames ?? []) visit(child, tree.frame.id);
|
||||||
|
};
|
||||||
|
visit(frameTree);
|
||||||
|
}
|
||||||
|
expect(frames).toHaveLength(5);
|
||||||
|
for (const frame of frames) {
|
||||||
|
if (!frame.parentFrameId) continue;
|
||||||
|
const parent = frames.find((item) => item.frameId === frame.parentFrameId)!;
|
||||||
|
const owner = await send<{ backendNodeId: number }>(
|
||||||
|
"DOM.getFrameOwner",
|
||||||
|
{ frameId: frame.frameId },
|
||||||
|
sessionFor(parent.target),
|
||||||
|
);
|
||||||
|
frame.ownerBackendNodeId = owner.backendNodeId;
|
||||||
|
}
|
||||||
|
const graph: CdpFrameGraph = { rootFrameId: frames[0].frameId, frames };
|
||||||
|
const calls: string[] = [];
|
||||||
|
const cdp: CdpRunner = {
|
||||||
|
send: (tabId, method, params) => {
|
||||||
|
calls.push(`${tabId}:${method}`);
|
||||||
|
return send(method, params, rootSession);
|
||||||
|
},
|
||||||
|
sendToTarget: (target, method, params) => {
|
||||||
|
calls.push(`${target.sessionId ?? target.tabId}:${method}`);
|
||||||
|
return send(method, params, sessionFor(target));
|
||||||
|
},
|
||||||
|
getFrameGraph: async () => graph,
|
||||||
|
getAttachmentId: () => rootSession,
|
||||||
|
};
|
||||||
|
const oracles = new Map<string, Oracle>();
|
||||||
|
for (const frame of frames) {
|
||||||
|
const session = sessionFor(frame.target);
|
||||||
|
const { executionContextId } = await send<{ executionContextId: number }>(
|
||||||
|
"Page.createIsolatedWorld",
|
||||||
|
{ frameId: frame.frameId, worldName: "coordinate-oracle" },
|
||||||
|
session,
|
||||||
|
);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const ready = await send<{ result: { value: boolean } }>(
|
||||||
|
"Runtime.evaluate",
|
||||||
|
{
|
||||||
|
expression: 'document.documentElement.dataset.geometryReady === "true"',
|
||||||
|
contextId: executionContextId,
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
);
|
||||||
|
return ready.result.value;
|
||||||
|
},
|
||||||
|
{ timeout: 5_000 },
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
// Establish scroll after cross-process target creation/layout settles.
|
||||||
|
const scroll = frame.parentFrameId
|
||||||
|
? names.get(frame.frameId) === "nested"
|
||||||
|
? [10, 20]
|
||||||
|
: [40, 100]
|
||||||
|
: [80, 240];
|
||||||
|
await send(
|
||||||
|
"Runtime.evaluate",
|
||||||
|
{
|
||||||
|
expression: `(async () => { scrollTo(${scroll.join(",")}); await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); })()`,
|
||||||
|
contextId: executionContextId,
|
||||||
|
awaitPromise: true,
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
);
|
||||||
|
const reply = await send<{ result: { value: Oracle } }>(
|
||||||
|
"Runtime.evaluate",
|
||||||
|
{ expression: oracleExpression, contextId: executionContextId, returnByValue: true },
|
||||||
|
session,
|
||||||
|
);
|
||||||
|
oracles.set(frame.frameId, reply.result.value);
|
||||||
|
}
|
||||||
|
const facts = await captureObservationFacts(cdp, 1);
|
||||||
|
expect(facts.issues).toEqual([]);
|
||||||
|
for (const frame of frames) {
|
||||||
|
const node = facts.documents
|
||||||
|
.find((doc) => doc.frame.frameId === frame.frameId)
|
||||||
|
?.domNodes.find((node) => node.attrs.id === "probe");
|
||||||
|
expect(node, `missing probe in ${names.get(frame.frameId) || "root"}`).toBeDefined();
|
||||||
|
const local = oracles.get(frame.frameId)!.probe;
|
||||||
|
const top = { ...local };
|
||||||
|
let current = frame;
|
||||||
|
while (current.parentFrameId) {
|
||||||
|
const owner = oracles.get(current.parentFrameId)!.owners[names.get(current.frameId)!];
|
||||||
|
top.x = owner.x + top.x * owner.scale;
|
||||||
|
top.y = owner.y + top.y * owner.scale;
|
||||||
|
top.w *= owner.scale;
|
||||||
|
top.h *= owner.scale;
|
||||||
|
current = frames.find((item) => item.frameId === current.parentFrameId)!;
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
node!.rect,
|
||||||
|
JSON.stringify({
|
||||||
|
frame: names.get(frame.frameId),
|
||||||
|
local,
|
||||||
|
top,
|
||||||
|
viewport: facts.viewport,
|
||||||
|
}),
|
||||||
|
).not.toBeNull();
|
||||||
|
for (const key of ["x", "y", "w", "h"] as const) {
|
||||||
|
expect(
|
||||||
|
Math.abs(node!.localRect![key] - local[key]),
|
||||||
|
`${names.get(frame.frameId)} local ${key}`,
|
||||||
|
).toBeLessThan(2);
|
||||||
|
expect(
|
||||||
|
Math.abs(node!.rect![key] - top[key]),
|
||||||
|
`${names.get(frame.frameId)} top ${key}`,
|
||||||
|
).toBeLessThan(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(calls.filter((call) => call.endsWith(":Page.getLayoutMetrics"))).toHaveLength(
|
||||||
|
sessions.length,
|
||||||
|
);
|
||||||
|
const metrics = await send<{
|
||||||
|
cssVisualViewport: { zoom: number };
|
||||||
|
visualViewport: { clientWidth: number };
|
||||||
|
}>("Page.getLayoutMetrics", {}, rootSession);
|
||||||
|
expect(metrics.cssVisualViewport.zoom).toBeCloseTo(configuration.zoom, 4);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
@@ -11,7 +11,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
remove: async () => {},
|
remove: async () => {},
|
||||||
ensureActiveTab: async () => {},
|
ensureActiveTab: async () => 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { type CdpDebuggerApi, ChromiumCdp } from "@/browser-driver/chromium-cdp";
|
||||||
import { SessionManager } from "@/session-manager/manager";
|
import { SessionManager } from "@/session-manager/manager";
|
||||||
|
import { handleHover } from "../interaction";
|
||||||
import {
|
import {
|
||||||
type AgentOverlayResetApi,
|
type AgentOverlayResetApi,
|
||||||
type ChromeWindowsApi,
|
type ChromeWindowsApi,
|
||||||
@@ -23,7 +25,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
}),
|
}),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +223,7 @@ describe("handleTabCreate", () => {
|
|||||||
active: true,
|
active: true,
|
||||||
});
|
});
|
||||||
expect(res).toMatchObject({ tab_id: 10, window_id: 100 });
|
expect(res).toMatchObject({ tab_id: 10, window_id: 100 });
|
||||||
|
expect(sm.get("aa11")?.agentCreatedTabs.has(10)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards explicit url + index + active=false", async () => {
|
it("forwards explicit url + index + active=false", async () => {
|
||||||
@@ -256,6 +259,41 @@ describe("handleTabCreate", () => {
|
|||||||
const res = await handleTabCreate(sm, { session_id: "aa11", index: -1 }, { tabs: api });
|
const res = await handleTabCreate(sm, { session_id: "aa11", index: -1 }, { tabs: api });
|
||||||
expect(res).toMatchObject({ code: "invalid_params" });
|
expect(res).toMatchObject({ code: "invalid_params" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not claim any tab when chrome.tabs.create fails", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
|
await sm.start("aa11");
|
||||||
|
const state: FakeTabState = { tabs: new Map(), nextTabId: 10, windowsClosed: new Set() };
|
||||||
|
const { api, spies } = makeTabMutationApi(state);
|
||||||
|
spies.create.mockRejectedValueOnce(new Error("chrome.tabs.create failed"));
|
||||||
|
|
||||||
|
const res = await handleTabCreate(sm, { session_id: "aa11" }, { tabs: api });
|
||||||
|
expect(res).toMatchObject({ code: "protocol_error" });
|
||||||
|
|
||||||
|
expect(sm.get("aa11")?.agentCreatedTabs).toEqual(new Set([1]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("claims the returned id even when a user tab appears during tab creation", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
const state: FakeTabState = { tabs: new Map(), nextTabId: 10, windowsClosed: new Set() };
|
||||||
|
const { api, spies } = makeTabMutationApi(state);
|
||||||
|
let resolveCreate!: (tab: chrome.tabs.Tab) => void;
|
||||||
|
const createResult = new Promise<chrome.tabs.Tab>((resolve) => {
|
||||||
|
resolveCreate = resolve;
|
||||||
|
});
|
||||||
|
spies.create.mockImplementationOnce(() => createResult);
|
||||||
|
|
||||||
|
const pending = handleTabCreate(sm, { session_id: "aa11" }, { tabs: api });
|
||||||
|
// A user can create an unrelated same-window tab while Chrome is still
|
||||||
|
// resolving the agent request. There is no pending slot for it to steal.
|
||||||
|
expect(ctx.agentCreatedTabs.has(99)).toBe(false);
|
||||||
|
resolveCreate({ id: 10, windowId: 100, active: true } as chrome.tabs.Tab);
|
||||||
|
|
||||||
|
await expect(pending).resolves.toMatchObject({ tab_id: 10 });
|
||||||
|
expect(ctx.agentCreatedTabs.has(10)).toBe(true);
|
||||||
|
expect(ctx.agentCreatedTabs.has(99)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("handleTabClose", () => {
|
describe("handleTabClose", () => {
|
||||||
@@ -511,6 +549,148 @@ describe("handleTabBorrow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("handleTabReturn", () => {
|
describe("handleTabReturn", () => {
|
||||||
|
it.each([
|
||||||
|
"not_found",
|
||||||
|
"cdp_failed",
|
||||||
|
])("detaches after selector resolution fails with %s", async (code) => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
||||||
|
const state: FakeTabState = {
|
||||||
|
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
|
||||||
|
nextTabId: 50,
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
};
|
||||||
|
const { api } = makeTabMutationApi(state);
|
||||||
|
const { api: windows } = makeWindowsApi(state);
|
||||||
|
const debuggerApi = {
|
||||||
|
attach: vi.fn(async () => {}),
|
||||||
|
detach: vi.fn(async () => {}),
|
||||||
|
sendCommand: vi.fn(async (_target: unknown, method: string) => {
|
||||||
|
if (method === "DOM.getDocument") return { root: { nodeId: 1 } };
|
||||||
|
if (method === "DOM.querySelector") {
|
||||||
|
if (code === "cdp_failed") throw new Error("selector query failed");
|
||||||
|
return { nodeId: 0 };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
onEvent: { addListener: vi.fn(), removeListener: vi.fn() },
|
||||||
|
onDetach: { addListener: vi.fn(), removeListener: vi.fn() },
|
||||||
|
};
|
||||||
|
const cdp = new ChromiumCdp(debuggerApi as unknown as CdpDebuggerApi);
|
||||||
|
|
||||||
|
const hover = await handleHover(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11", tab_id: 7, selector: "#missing" },
|
||||||
|
{
|
||||||
|
cdp,
|
||||||
|
tabsApi: { get: api.get, query: vi.fn(async () => []) },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(hover).toMatchObject({ code });
|
||||||
|
expect(cdp.isAttached(7)).toBe(true);
|
||||||
|
|
||||||
|
const returned = await handleTabReturn(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11", tab_id: 7 },
|
||||||
|
{
|
||||||
|
tabs: api,
|
||||||
|
windows,
|
||||||
|
cdp,
|
||||||
|
agentOverlayReset: { resetAgentOverlays: vi.fn(async () => {}) },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(returned).toMatchObject({ returned_to_window_id: 200 });
|
||||||
|
expect(ctx.borrowedTabs.has(7)).toBe(false);
|
||||||
|
expect(cdp.isAttached(7)).toBe(false);
|
||||||
|
expect(debuggerApi.detach).toHaveBeenCalledExactlyOnceWith({ tabId: 7 });
|
||||||
|
cdp.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours cancellation while preparing a validated return", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
||||||
|
const state: FakeTabState = {
|
||||||
|
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
|
||||||
|
nextTabId: 50,
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
};
|
||||||
|
const { api, spies } = makeTabMutationApi(state);
|
||||||
|
const { api: windows } = makeWindowsApi(state);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const beforeReturn = vi.fn(async () => {
|
||||||
|
controller.abort();
|
||||||
|
});
|
||||||
|
const result = await handleTabReturn(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11", tab_id: 7 },
|
||||||
|
{
|
||||||
|
tabs: api,
|
||||||
|
windows,
|
||||||
|
signal: controller.signal,
|
||||||
|
beforeReturn,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ code: "cancelled" });
|
||||||
|
expect(beforeReturn).toHaveBeenCalledExactlyOnceWith("aa11", 7);
|
||||||
|
expect(spies.move).not.toHaveBeenCalled();
|
||||||
|
expect(ctx.borrowedTabs.has(7)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"original",
|
||||||
|
"fallback",
|
||||||
|
"cleanup_failed",
|
||||||
|
"failed",
|
||||||
|
"cancelled",
|
||||||
|
])("releases CDP only after a successful return (%s)", async (mode) => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
||||||
|
const state: FakeTabState = {
|
||||||
|
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
|
||||||
|
nextTabId: 50,
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
};
|
||||||
|
const { api, spies } = makeTabMutationApi(state);
|
||||||
|
const { api: windowsApi } = makeWindowsApi(state, { lastFocused: 500 });
|
||||||
|
const success = mode !== "failed" && mode !== "cancelled";
|
||||||
|
if (mode === "fallback") spies.move.mockRejectedValueOnce(new Error("original move failed"));
|
||||||
|
if (mode === "failed") spies.move.mockRejectedValue(new Error("move failed"));
|
||||||
|
let windowAtRelease: number | undefined;
|
||||||
|
const cdp = {
|
||||||
|
releaseSessionTab: vi.fn(async () => {
|
||||||
|
windowAtRelease = state.tabs.get(7)?.windowId;
|
||||||
|
if (mode === "cleanup_failed") throw new Error("debugger release failed");
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const controller = new AbortController();
|
||||||
|
if (mode === "cancelled") controller.abort();
|
||||||
|
|
||||||
|
const result = await handleTabReturn(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11", tab_id: 7 },
|
||||||
|
{
|
||||||
|
tabs: api,
|
||||||
|
windows: windowsApi,
|
||||||
|
cdp,
|
||||||
|
signal: controller.signal,
|
||||||
|
agentOverlayReset: { resetAgentOverlays: vi.fn(async () => {}) },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect("code" in result).toBe(!success);
|
||||||
|
expect(ctx.borrowedTabs.has(7)).toBe(!success);
|
||||||
|
expect(cdp.releaseSessionTab).toHaveBeenCalledTimes(success ? 1 : 0);
|
||||||
|
if (success) {
|
||||||
|
expect(cdp.releaseSessionTab).toHaveBeenCalledWith("aa11", 7);
|
||||||
|
expect(windowAtRelease).toBe(mode === "fallback" ? 500 : 200);
|
||||||
|
expect(spies.move).toHaveBeenCalledTimes(mode === "fallback" ? 2 : 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("returns the tab to its original window/index and clears borrowedTabs", async () => {
|
it("returns the tab to its original window/index and clears borrowedTabs", async () => {
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
const ctx = await sm.start("aa11");
|
const ctx = await sm.start("aa11");
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
}),
|
}),
|
||||||
remove: vi.fn(async () => {}),
|
remove: vi.fn(async () => {}),
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ function fakeAgentWindow(ids: number[]) {
|
|||||||
return id;
|
return id;
|
||||||
});
|
});
|
||||||
const remove = vi.fn(async () => {});
|
const remove = vi.fn(async () => {});
|
||||||
const ensureActiveTab = vi.fn(async () => {});
|
const ensureActiveTab = vi.fn(async () => 1);
|
||||||
return { create, remove, ensureActiveTab };
|
return { create, remove, ensureActiveTab };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface BorrowResponseMessage {
|
|||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CONFIRMATION_TIMEOUT_MS = 5000;
|
export const CONFIRMATION_TIMEOUT_MS = 60_000;
|
||||||
const EXIT_ANIMATION_MS = 150;
|
const EXIT_ANIMATION_MS = 150;
|
||||||
/** Matches BorrowConfirmationOverlay progress ring/bar transition (duration-1000). */
|
/** Matches BorrowConfirmationOverlay progress ring/bar transition (duration-1000). */
|
||||||
export const PROGRESS_TRANSITION_MS = 1000;
|
export const PROGRESS_TRANSITION_MS = 1000;
|
||||||
@@ -480,6 +480,13 @@ export async function requestBorrowConfirmation(
|
|||||||
settle(false);
|
settle(false);
|
||||||
}, BACKGROUND_TIMEOUT_MS);
|
}, BACKGROUND_TIMEOUT_MS);
|
||||||
|
|
||||||
|
// Bring the user window to the front so the overlay is visible even
|
||||||
|
// when the Agent Window has stolen focus. This is fire-and-forget: if
|
||||||
|
// it fails the overlay / OS notification still give the user a path.
|
||||||
|
void windowsApi.update(notificationAnchor.windowId, { focused: true }).catch((err) => {
|
||||||
|
console.debug("[bsk borrow] proactive focus of user window failed", err);
|
||||||
|
});
|
||||||
|
|
||||||
// Surface the OS notification *before* messaging any candidate so the
|
// Surface the OS notification *before* messaging any candidate so the
|
||||||
// user has a parallel signal even if every content script is missing.
|
// user has a parallel signal even if every content script is missing.
|
||||||
// The Allow / Deny buttons let the user authorize explicitly when the
|
// The Allow / Deny buttons let the user authorize explicitly when the
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { RpcError, WaitUntil } from "@/transport/types";
|
||||||
|
import { cdpError } from "./errors";
|
||||||
|
|
||||||
|
interface NavigationEvent {
|
||||||
|
tabId: number;
|
||||||
|
frameId: number;
|
||||||
|
documentId?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavigationEvents {
|
||||||
|
addListener(listener: (details: NavigationEvent) => void): void;
|
||||||
|
removeListener(listener: (details: NavigationEvent) => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normal browser navigation remains available when chrome.debugger is denied. */
|
||||||
|
export interface BrowserNavigationApi {
|
||||||
|
update(tabId: number, props: { url: string }): Promise<unknown>;
|
||||||
|
reload(tabId: number, props: { bypassCache: boolean }): Promise<void>;
|
||||||
|
onCommitted: NavigationEvents;
|
||||||
|
onDOMContentLoaded: NavigationEvents;
|
||||||
|
onCompleted: NavigationEvents;
|
||||||
|
onErrorOccurred: NavigationEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chromeBrowserNavigationApi: BrowserNavigationApi = {
|
||||||
|
update: (tabId, props) => chrome.tabs.update(tabId, props),
|
||||||
|
reload: (tabId, props) => chrome.tabs.reload(tabId, props),
|
||||||
|
get onCommitted() {
|
||||||
|
return chrome.webNavigation.onCommitted;
|
||||||
|
},
|
||||||
|
get onDOMContentLoaded() {
|
||||||
|
return chrome.webNavigation.onDOMContentLoaded;
|
||||||
|
},
|
||||||
|
get onCompleted() {
|
||||||
|
return chrome.webNavigation.onCompleted;
|
||||||
|
},
|
||||||
|
get onErrorOccurred() {
|
||||||
|
return chrome.webNavigation.onErrorOccurred;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NavigationOutcome =
|
||||||
|
| { reached: "match" | "timeout" | "cancelled"; lastLifecycle?: string }
|
||||||
|
| { reached: "failed"; error: RpcError };
|
||||||
|
|
||||||
|
/** Subscribe before dispatch and ignore the departing document's load events. */
|
||||||
|
export async function navigateWithBrowserApi(
|
||||||
|
api: BrowserNavigationApi,
|
||||||
|
tabId: number,
|
||||||
|
action: () => Promise<unknown>,
|
||||||
|
waitUntil: Exclude<WaitUntil, "networkidle">,
|
||||||
|
timeoutMs: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<NavigationOutcome> {
|
||||||
|
if (signal?.aborted) return { reached: "cancelled" };
|
||||||
|
if (timeoutMs <= 0) return { reached: "timeout" };
|
||||||
|
let committed = false;
|
||||||
|
let documentId: string | undefined;
|
||||||
|
let lastLifecycle: string | undefined;
|
||||||
|
let finish!: (outcome: NavigationOutcome) => void;
|
||||||
|
const outcome = new Promise<NavigationOutcome>((resolve) => {
|
||||||
|
finish = resolve;
|
||||||
|
});
|
||||||
|
const isMainFrame = (details: NavigationEvent) =>
|
||||||
|
details.tabId === tabId && details.frameId === 0;
|
||||||
|
const onCommitted = (details: NavigationEvent) => {
|
||||||
|
if (!isMainFrame(details)) return;
|
||||||
|
committed = true;
|
||||||
|
documentId = details.documentId;
|
||||||
|
lastLifecycle = "commit";
|
||||||
|
if (waitUntil === "commit") finish({ reached: "match", lastLifecycle });
|
||||||
|
};
|
||||||
|
const onDOMContentLoaded = (details: NavigationEvent) => {
|
||||||
|
if (!isMainFrame(details) || !committed || (documentId && details.documentId !== documentId))
|
||||||
|
return;
|
||||||
|
lastLifecycle = "DOMContentLoaded";
|
||||||
|
if (waitUntil === "domcontentloaded") finish({ reached: "match", lastLifecycle });
|
||||||
|
};
|
||||||
|
const onCompleted = (details: NavigationEvent) => {
|
||||||
|
if (!isMainFrame(details) || !committed || (documentId && details.documentId !== documentId))
|
||||||
|
return;
|
||||||
|
finish({ reached: "match", lastLifecycle: "load" });
|
||||||
|
};
|
||||||
|
const onError = (details: NavigationEvent) => {
|
||||||
|
if (!isMainFrame(details)) return;
|
||||||
|
finish({ reached: "failed", error: cdpError(details.error ?? "browser navigation failed") });
|
||||||
|
};
|
||||||
|
const onAbort = () => finish({ reached: "cancelled", lastLifecycle });
|
||||||
|
const subscriptions = [
|
||||||
|
[api.onCommitted, onCommitted],
|
||||||
|
[api.onDOMContentLoaded, onDOMContentLoaded],
|
||||||
|
[api.onCompleted, onCompleted],
|
||||||
|
[api.onErrorOccurred, onError],
|
||||||
|
] as const;
|
||||||
|
for (const [event, listener] of subscriptions) event.addListener(listener);
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
const timer = setTimeout(() => finish({ reached: "timeout", lastLifecycle }), timeoutMs);
|
||||||
|
try {
|
||||||
|
if (signal?.aborted) return { reached: "cancelled" };
|
||||||
|
await action();
|
||||||
|
return await outcome;
|
||||||
|
} catch (error) {
|
||||||
|
return { reached: "failed", error: cdpError(error) };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
for (const [event, listener] of subscriptions) event.removeListener(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { Transport } from "@/transport/transport";
|
|||||||
import type {
|
import type {
|
||||||
ClickParams,
|
ClickParams,
|
||||||
ConsoleParams,
|
ConsoleParams,
|
||||||
|
DownloadParams,
|
||||||
EmulateParams,
|
EmulateParams,
|
||||||
EvaluateParams,
|
EvaluateParams,
|
||||||
FillParams,
|
FillParams,
|
||||||
@@ -28,11 +29,14 @@ import type {
|
|||||||
ScreenshotParams,
|
ScreenshotParams,
|
||||||
SelectParams,
|
SelectParams,
|
||||||
SnapshotParams,
|
SnapshotParams,
|
||||||
|
UploadParams,
|
||||||
WaitForNavigationParams,
|
WaitForNavigationParams,
|
||||||
} from "@/transport/types";
|
} from "@/transport/types";
|
||||||
import { isRequestFrame } from "@/transport/types";
|
import { isRequestFrame } from "@/transport/types";
|
||||||
import { handleConsole } from "./console";
|
import { handleConsole } from "./console";
|
||||||
|
import { handleDownload } from "./download";
|
||||||
import { type EmulateCdpRunner, handleEmulate } from "./emulate";
|
import { type EmulateCdpRunner, handleEmulate } from "./emulate";
|
||||||
|
import { classifyCdpError } from "./errors";
|
||||||
import { handleEvaluate } from "./evaluate";
|
import { handleEvaluate } from "./evaluate";
|
||||||
import { handleRequestHelp } from "./human-loop";
|
import { handleRequestHelp } from "./human-loop";
|
||||||
import { handleClick, handleFill, handleHover, handlePress, handleSelect } from "./interaction";
|
import { handleClick, handleFill, handleHover, handlePress, handleSelect } from "./interaction";
|
||||||
@@ -66,6 +70,7 @@ import {
|
|||||||
import { chromeTabsApi, lookupSession, resolveTargetTab } from "./shared";
|
import { chromeTabsApi, lookupSession, resolveTargetTab } from "./shared";
|
||||||
import {
|
import {
|
||||||
type BorrowConfirmationApprover,
|
type BorrowConfirmationApprover,
|
||||||
|
chromeTabMutationApi,
|
||||||
handleTabBorrow,
|
handleTabBorrow,
|
||||||
handleTabClose,
|
handleTabClose,
|
||||||
handleTabCreate,
|
handleTabCreate,
|
||||||
@@ -79,6 +84,7 @@ import {
|
|||||||
type TabReturnParams,
|
type TabReturnParams,
|
||||||
type TabSelectParams,
|
type TabSelectParams,
|
||||||
} from "./tabs";
|
} from "./tabs";
|
||||||
|
import { handleUpload } from "./upload";
|
||||||
import { handleWaitForNavigation } from "./waits";
|
import { handleWaitForNavigation } from "./waits";
|
||||||
import { handleWindowResize, type WindowResizeParams } from "./window";
|
import { handleWindowResize, type WindowResizeParams } from "./window";
|
||||||
|
|
||||||
@@ -114,6 +120,8 @@ export interface DispatcherDeps {
|
|||||||
onSessionsChanged?: () => void;
|
onSessionsChanged?: () => void;
|
||||||
/** Invoked before a tool that dispatches page input or mutates browser state is forwarded. */
|
/** Invoked before a tool that dispatches page input or mutates browser state is forwarded. */
|
||||||
onBrowserControlResumed?: (sessionId: string) => void;
|
onBrowserControlResumed?: (sessionId: string) => void;
|
||||||
|
/** Invoked after a tab is explicitly claimed so its overlay can be refreshed immediately. */
|
||||||
|
onAgentTabClaimed?: (tabId: number, windowId: number) => void;
|
||||||
/** User approval for `tool.tab_borrow` (overlay in content script). */
|
/** User approval for `tool.tab_borrow` (overlay in content script). */
|
||||||
approveBorrow?: BorrowConfirmationApprover;
|
approveBorrow?: BorrowConfirmationApprover;
|
||||||
/** i18n notification copy for `tool.request_help` (resolved per-call). */
|
/** i18n notification copy for `tool.request_help` (resolved per-call). */
|
||||||
@@ -143,6 +151,7 @@ export class ToolDispatcher {
|
|||||||
private readonly recording?: RecordRuntimeDeps;
|
private readonly recording?: RecordRuntimeDeps;
|
||||||
private readonly onSessionsChanged?: () => void;
|
private readonly onSessionsChanged?: () => void;
|
||||||
private readonly onBrowserControlResumed?: (sessionId: string) => void;
|
private readonly onBrowserControlResumed?: (sessionId: string) => void;
|
||||||
|
private readonly onAgentTabClaimed?: (tabId: number, windowId: number) => void;
|
||||||
private readonly approveBorrow?: BorrowConfirmationApprover;
|
private readonly approveBorrow?: BorrowConfirmationApprover;
|
||||||
private readonly helpNotificationCopy?: () => { title: string; body: string };
|
private readonly helpNotificationCopy?: () => { title: string; body: string };
|
||||||
private subscription: { dispose(): void } | null = null;
|
private subscription: { dispose(): void } | null = null;
|
||||||
@@ -163,6 +172,7 @@ export class ToolDispatcher {
|
|||||||
this.recording = deps.recording;
|
this.recording = deps.recording;
|
||||||
this.onSessionsChanged = deps.onSessionsChanged;
|
this.onSessionsChanged = deps.onSessionsChanged;
|
||||||
this.onBrowserControlResumed = deps.onBrowserControlResumed;
|
this.onBrowserControlResumed = deps.onBrowserControlResumed;
|
||||||
|
this.onAgentTabClaimed = deps.onAgentTabClaimed;
|
||||||
this.approveBorrow = deps.approveBorrow;
|
this.approveBorrow = deps.approveBorrow;
|
||||||
this.helpNotificationCopy = deps.helpNotificationCopy;
|
this.helpNotificationCopy = deps.helpNotificationCopy;
|
||||||
}
|
}
|
||||||
@@ -230,7 +240,7 @@ export class ToolDispatcher {
|
|||||||
if (sessionId) this.onBrowserControlResumed?.(sessionId);
|
if (sessionId) this.onBrowserControlResumed?.(sessionId);
|
||||||
const result = await this.invoke(req, ac.signal);
|
const result = await this.invoke(req, ac.signal);
|
||||||
if (isRpcError(result)) {
|
if (isRpcError(result)) {
|
||||||
body = { id: req.id, error: result };
|
body = { id: req.id, error: classifyCdpError(result) };
|
||||||
} else {
|
} else {
|
||||||
body = { id: req.id, result };
|
body = { id: req.id, result };
|
||||||
if (req.method === "tool.session_start") {
|
if (req.method === "tool.session_start") {
|
||||||
@@ -298,13 +308,25 @@ export class ToolDispatcher {
|
|||||||
await this.releaseHoverLatch((req.params as SessionStopParams).session_id);
|
await this.releaseHoverLatch((req.params as SessionStopParams).session_id);
|
||||||
return handleSessionStop(this.sessions, req.params as SessionStopParams, {
|
return handleSessionStop(this.sessions, req.params as SessionStopParams, {
|
||||||
cdp: this.cdp,
|
cdp: this.cdp,
|
||||||
|
// Must be wired in production: the agent-tab cleanup and the
|
||||||
|
// window-release decision (issue #57) read these deps directly
|
||||||
|
// and silently no-op when they are absent.
|
||||||
|
tabManagement: { tabs: chromeTabMutationApi },
|
||||||
|
tabsQuery: chromeTabsApi,
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
case "tool.tab_list":
|
case "tool.tab_list":
|
||||||
return handleTabList(this.sessions, req.params as TabListParams, chromeTabsApi, signal);
|
return handleTabList(this.sessions, req.params as TabListParams, chromeTabsApi, signal);
|
||||||
case "tool.tab_create":
|
case "tool.tab_create": {
|
||||||
return handleTabCreate(this.sessions, req.params as TabCreateParams, { signal });
|
const result = await handleTabCreate(this.sessions, req.params as TabCreateParams, {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!isRpcError(result)) {
|
||||||
|
this.onAgentTabClaimed?.(result.tab_id, result.window_id);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
case "tool.tab_close":
|
case "tool.tab_close":
|
||||||
return this.withHoverReleaseForRequest(
|
return this.withHoverReleaseForRequest(
|
||||||
req.params as TabCloseParams,
|
req.params as TabCloseParams,
|
||||||
@@ -313,13 +335,22 @@ export class ToolDispatcher {
|
|||||||
);
|
);
|
||||||
case "tool.tab_select":
|
case "tool.tab_select":
|
||||||
return handleTabSelect(this.sessions, req.params as TabSelectParams, { signal });
|
return handleTabSelect(this.sessions, req.params as TabSelectParams, { signal });
|
||||||
case "tool.tab_borrow":
|
case "tool.tab_borrow": {
|
||||||
return handleTabBorrow(this.sessions, req.params as TabBorrowParams, {
|
const result = await handleTabBorrow(this.sessions, req.params as TabBorrowParams, {
|
||||||
signal,
|
signal,
|
||||||
approveBorrow: this.approveBorrow,
|
approveBorrow: this.approveBorrow,
|
||||||
});
|
});
|
||||||
|
if (!isRpcError(result)) {
|
||||||
|
this.onAgentTabClaimed?.(result.tab_id, result.agent_window_id);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
case "tool.tab_return":
|
case "tool.tab_return":
|
||||||
return handleTabReturn(this.sessions, req.params as TabReturnParams, { signal });
|
return handleTabReturn(this.sessions, req.params as TabReturnParams, {
|
||||||
|
signal,
|
||||||
|
cdp: this.cdp,
|
||||||
|
beforeReturn: (sessionId, tabId) => this.releaseHoverLatch(sessionId, tabId),
|
||||||
|
});
|
||||||
case "tool.window_resize":
|
case "tool.window_resize":
|
||||||
return handleWindowResize(
|
return handleWindowResize(
|
||||||
this.sessions,
|
this.sessions,
|
||||||
@@ -383,7 +414,11 @@ export class ToolDispatcher {
|
|||||||
? {
|
? {
|
||||||
cdp: this.cdp,
|
cdp: this.cdp,
|
||||||
tabsApi: chromeTabsCaptureApi,
|
tabsApi: chromeTabsCaptureApi,
|
||||||
conditionalSurfaceProbe: !this.hasHoverLatchForScope(hoverScope),
|
// Active hover probing is opt-in. A held hover latch still
|
||||||
|
// suppresses it, because probing would move the cursor off
|
||||||
|
// the element the caller is deliberately holding.
|
||||||
|
conditionalSurfaceProbe:
|
||||||
|
params.probe_hover === true && !this.hasHoverLatchForScope(hoverScope),
|
||||||
hoverProbeBypassOverlay: bypassOverlay,
|
hoverProbeBypassOverlay: bypassOverlay,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -513,6 +548,40 @@ export class ToolDispatcher {
|
|||||||
),
|
),
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
|
case "tool.upload":
|
||||||
|
return this.withHoverReleaseForRequest(
|
||||||
|
req.params as UploadParams,
|
||||||
|
() =>
|
||||||
|
this.cdp
|
||||||
|
? handleUpload(this.sessions, req.params as UploadParams, {
|
||||||
|
cdp: this.cdp,
|
||||||
|
tabsApi: chromeTabsApi,
|
||||||
|
signal,
|
||||||
|
bypassOverlay,
|
||||||
|
})
|
||||||
|
: Promise.resolve({
|
||||||
|
code: "unsupported",
|
||||||
|
message: "upload requires CDP",
|
||||||
|
} satisfies RpcError),
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
case "tool.download":
|
||||||
|
return this.withHoverReleaseForRequest(
|
||||||
|
req.params as DownloadParams,
|
||||||
|
() =>
|
||||||
|
this.cdp
|
||||||
|
? handleDownload(this.sessions, req.params as DownloadParams, {
|
||||||
|
cdp: this.cdp,
|
||||||
|
tabsApi: chromeTabsApi,
|
||||||
|
signal,
|
||||||
|
bypassOverlay,
|
||||||
|
})
|
||||||
|
: Promise.resolve({
|
||||||
|
code: "unsupported",
|
||||||
|
message: "download requires CDP",
|
||||||
|
} satisfies RpcError),
|
||||||
|
signal,
|
||||||
|
);
|
||||||
case "tool.evaluate":
|
case "tool.evaluate":
|
||||||
return handleEvaluate(
|
return handleEvaluate(
|
||||||
this.sessions,
|
this.sessions,
|
||||||
@@ -719,6 +788,8 @@ function sessionIdForBrowserControlMethod(req: RequestFrame): string | null {
|
|||||||
case "tool.fill":
|
case "tool.fill":
|
||||||
case "tool.press":
|
case "tool.press":
|
||||||
case "tool.select":
|
case "tool.select":
|
||||||
|
case "tool.upload":
|
||||||
|
case "tool.download":
|
||||||
case "tool.evaluate":
|
case "tool.evaluate":
|
||||||
case "tool.observe":
|
case "tool.observe":
|
||||||
case "tool.request_help":
|
case "tool.request_help":
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
// Order-independent coordinator for one browser download. CDP supplies the
|
||||||
|
// exact target/frame intent while chrome.downloads supplies the download id
|
||||||
|
// and filename routing hook; neither event is assumed to arrive first.
|
||||||
|
|
||||||
|
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
||||||
|
import type { ClickResult, RpcError, TransferEffectState } from "@/transport/types";
|
||||||
|
import { transferError } from "./errors";
|
||||||
|
import { type CdpRunner, isRpcError } from "./shared";
|
||||||
|
|
||||||
|
const CORRELATION_GRACE_MS = 750;
|
||||||
|
const UNIQUE_SETTLE_MS = 50;
|
||||||
|
const SIZE_POLL_MS = 250;
|
||||||
|
|
||||||
|
type DeterminingFilenameListener = (
|
||||||
|
item: chrome.downloads.DownloadItem,
|
||||||
|
suggest: (suggestion?: chrome.downloads.DownloadFilenameSuggestion) => void,
|
||||||
|
) => void | true;
|
||||||
|
|
||||||
|
interface ListenerEvent<T> {
|
||||||
|
addListener(listener: T): void;
|
||||||
|
removeListener(listener: T): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadsApi {
|
||||||
|
onCreated: ListenerEvent<(item: chrome.downloads.DownloadItem) => void>;
|
||||||
|
onChanged: ListenerEvent<(delta: chrome.downloads.DownloadDelta) => void>;
|
||||||
|
onDeterminingFilename: ListenerEvent<DeterminingFilenameListener>;
|
||||||
|
search(query: chrome.downloads.DownloadQuery): Promise<chrome.downloads.DownloadItem[]>;
|
||||||
|
cancel(downloadId: number): Promise<void>;
|
||||||
|
removeFile(downloadId: number): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chromeDownloadsApi: DownloadsApi = {
|
||||||
|
get onCreated() {
|
||||||
|
return chrome.downloads.onCreated;
|
||||||
|
},
|
||||||
|
get onChanged() {
|
||||||
|
return chrome.downloads.onChanged;
|
||||||
|
},
|
||||||
|
get onDeterminingFilename() {
|
||||||
|
return chrome.downloads.onDeterminingFilename;
|
||||||
|
},
|
||||||
|
search: (query) => chrome.downloads.search(query),
|
||||||
|
cancel: (id) => chrome.downloads.cancel(id),
|
||||||
|
removeFile: (id) => chrome.downloads.removeFile(id),
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DownloadCaptureOptions {
|
||||||
|
cdp: CdpRunner;
|
||||||
|
target: CdpTarget;
|
||||||
|
expectedFrameId?: string;
|
||||||
|
downloads: DownloadsApi;
|
||||||
|
browserRelativeDir: string;
|
||||||
|
maxByteSize?: number;
|
||||||
|
timeoutMs: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
trigger(): Promise<ClickResult | RpcError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadCaptureResult {
|
||||||
|
click: ClickResult;
|
||||||
|
item: chrome.downloads.DownloadItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadIntent {
|
||||||
|
url: string;
|
||||||
|
suggestedFilename: string;
|
||||||
|
frameId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadCandidate {
|
||||||
|
item: chrome.downloads.DownloadItem;
|
||||||
|
suggest: (suggestion?: chrome.downloads.DownloadFilenameSuggestion) => void;
|
||||||
|
suggested: boolean;
|
||||||
|
graceTimer: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeBasename(filename: string): string {
|
||||||
|
const basename = filename.split(/[\\/]/).pop()?.trim();
|
||||||
|
return basename && basename !== "." && basename !== ".." ? basename : "download";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameTarget(source: { tabId?: number; sessionId?: string }, target: CdpTarget): boolean {
|
||||||
|
return source.tabId === target.tabId && source.sessionId === target.sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesIntent(item: chrome.downloads.DownloadItem, intent: DownloadIntent): boolean {
|
||||||
|
const urlMatches = item.url === intent.url || item.finalUrl === intent.url;
|
||||||
|
return urlMatches && safeBasename(item.filename) === safeBasename(intent.suggestedFilename);
|
||||||
|
}
|
||||||
|
|
||||||
|
function knownSize(item: chrome.downloads.DownloadItem): number | undefined {
|
||||||
|
if (item.fileSize >= 0) return item.fileSize;
|
||||||
|
if (item.totalBytes >= 0) return item.totalBytes;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureError(
|
||||||
|
message: string,
|
||||||
|
effectState: TransferEffectState,
|
||||||
|
phase: string,
|
||||||
|
cleanupFailed = false,
|
||||||
|
): RpcError {
|
||||||
|
return transferError("cdp_failed", "download_capture_failed", message, {
|
||||||
|
effectState,
|
||||||
|
phase,
|
||||||
|
...(cleanupFailed ? { cleanupState: "failed" } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupClaimedDownload(downloads: DownloadsApi, downloadId: number): Promise<void> {
|
||||||
|
const lookup = async () => (await downloads.search({ id: downloadId }))[0];
|
||||||
|
const item = await lookup();
|
||||||
|
if (!item || item.state === "interrupted") return;
|
||||||
|
if (item.state === "complete") {
|
||||||
|
await downloads.removeFile(downloadId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloads.cancel(downloadId);
|
||||||
|
} catch (cancelError) {
|
||||||
|
// Completion can win the race after the lookup but before cancellation.
|
||||||
|
// Reconcile against Chrome's authoritative state before declaring cleanup
|
||||||
|
// failed so every terminal state has one explicit cleanup path.
|
||||||
|
const reconciled = await lookup();
|
||||||
|
if (!reconciled || reconciled.state === "interrupted") return;
|
||||||
|
if (reconciled.state === "complete") {
|
||||||
|
await downloads.removeFile(downloadId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw cancelError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureBrowserDownload(
|
||||||
|
options: DownloadCaptureOptions,
|
||||||
|
): Promise<DownloadCaptureResult | RpcError> {
|
||||||
|
let click: ClickResult | undefined;
|
||||||
|
let intent: DownloadIntent | undefined;
|
||||||
|
let capturedId: number | undefined;
|
||||||
|
let settled = false;
|
||||||
|
let succeeded = false;
|
||||||
|
let failureResult: RpcError | undefined;
|
||||||
|
let uniquenessTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let operationTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let sizePoll: ReturnType<typeof setInterval> | undefined;
|
||||||
|
const candidates = new Map<number, DownloadCandidate>();
|
||||||
|
const createdItems = new Map<number, chrome.downloads.DownloadItem>();
|
||||||
|
|
||||||
|
let resolveCompletion!: (item: chrome.downloads.DownloadItem) => void;
|
||||||
|
let rejectCompletion!: (error: Error) => void;
|
||||||
|
const completion = new Promise<chrome.downloads.DownloadItem>((resolve, reject) => {
|
||||||
|
resolveCompletion = resolve;
|
||||||
|
rejectCompletion = reject;
|
||||||
|
});
|
||||||
|
const fail = (error: Error) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
rejectCompletion(error);
|
||||||
|
};
|
||||||
|
const complete = (item: chrome.downloads.DownloadItem) => {
|
||||||
|
if (settled) return;
|
||||||
|
const size = knownSize(item);
|
||||||
|
if (size !== undefined && options.maxByteSize !== undefined && size > options.maxByteSize) {
|
||||||
|
fail(new Error(`download exceeds transfer limit ${options.maxByteSize}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
resolveCompletion(item);
|
||||||
|
};
|
||||||
|
const suggestDefault = (candidate: DownloadCandidate) => {
|
||||||
|
if (candidate.suggested) return;
|
||||||
|
candidate.suggested = true;
|
||||||
|
candidate.suggest();
|
||||||
|
};
|
||||||
|
const matchingCandidates = (): DownloadCandidate[] => {
|
||||||
|
const currentIntent = intent;
|
||||||
|
return currentIntent
|
||||||
|
? [...candidates.values()].filter(
|
||||||
|
(candidate) => !candidate.suggested && matchesIntent(candidate.item, currentIntent),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const claimUnique = () => {
|
||||||
|
uniquenessTimer = undefined;
|
||||||
|
if (settled || capturedId !== undefined || !intent) return;
|
||||||
|
const matches = matchingCandidates();
|
||||||
|
if (matches.length !== 1) {
|
||||||
|
if (matches.length > 1) {
|
||||||
|
for (const candidate of matches) suggestDefault(candidate);
|
||||||
|
fail(new Error("download attribution is ambiguous"));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const candidate = matches[0];
|
||||||
|
candidate.suggested = true;
|
||||||
|
clearTimeout(candidate.graceTimer);
|
||||||
|
capturedId = candidate.item.id;
|
||||||
|
candidate.suggest({
|
||||||
|
filename: `${options.browserRelativeDir}/${safeBasename(intent.suggestedFilename)}`,
|
||||||
|
conflictAction: "overwrite",
|
||||||
|
});
|
||||||
|
const size = knownSize(candidate.item);
|
||||||
|
if (size !== undefined && options.maxByteSize !== undefined && size > options.maxByteSize) {
|
||||||
|
fail(new Error(`download exceeds transfer limit ${options.maxByteSize}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const created = createdItems.get(candidate.item.id);
|
||||||
|
if (created?.state === "interrupted") {
|
||||||
|
fail(new Error(created.error ?? "download interrupted"));
|
||||||
|
} else if (created?.state === "complete") {
|
||||||
|
complete(created);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const reconcile = () => {
|
||||||
|
if (settled || capturedId !== undefined || !intent) return;
|
||||||
|
const matches = matchingCandidates();
|
||||||
|
if (matches.length > 1) {
|
||||||
|
for (const candidate of matches) suggestDefault(candidate);
|
||||||
|
fail(new Error("download attribution is ambiguous"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matches.length === 1 && !uniquenessTimer) {
|
||||||
|
uniquenessTimer = setTimeout(claimUnique, UNIQUE_SETTLE_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const determiningListener: DeterminingFilenameListener = (item, suggest) => {
|
||||||
|
const candidate: DownloadCandidate = {
|
||||||
|
item,
|
||||||
|
suggest,
|
||||||
|
suggested: false,
|
||||||
|
graceTimer: setTimeout(() => {
|
||||||
|
suggestDefault(candidate);
|
||||||
|
candidates.delete(item.id);
|
||||||
|
if (intent && matchesIntent(item, intent) && capturedId === undefined) {
|
||||||
|
fail(new Error("download correlation grace elapsed before unique attribution"));
|
||||||
|
}
|
||||||
|
}, CORRELATION_GRACE_MS),
|
||||||
|
};
|
||||||
|
candidates.set(item.id, candidate);
|
||||||
|
reconcile();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const createdListener = (item: chrome.downloads.DownloadItem) => {
|
||||||
|
createdItems.set(item.id, item);
|
||||||
|
if (capturedId !== item.id) return;
|
||||||
|
if (item.state === "interrupted") {
|
||||||
|
fail(new Error(item.error ?? "download interrupted"));
|
||||||
|
} else if (item.state === "complete") {
|
||||||
|
complete(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const changedListener = async (delta: chrome.downloads.DownloadDelta) => {
|
||||||
|
if (capturedId === undefined || delta.id !== capturedId || settled) return;
|
||||||
|
if (delta.state?.current === "interrupted" || delta.error?.current) {
|
||||||
|
fail(new Error(delta.error?.current ?? "download interrupted"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (delta.state?.current === "complete") {
|
||||||
|
try {
|
||||||
|
const [item] = await options.downloads.search({ id: delta.id });
|
||||||
|
if (item) complete(item);
|
||||||
|
else fail(new Error("completed download disappeared"));
|
||||||
|
} catch (err) {
|
||||||
|
fail(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onAbort = () => fail(new DOMException("aborted", "AbortError"));
|
||||||
|
const cdpSubscription = options.cdp.onEvent?.((source, method, raw) => {
|
||||||
|
if (method !== "Page.downloadWillBegin" || !sameTarget(source, options.target)) return;
|
||||||
|
const event = raw as { url?: unknown; suggestedFilename?: unknown; frameId?: unknown };
|
||||||
|
if (typeof event.url !== "string" || typeof event.suggestedFilename !== "string") return;
|
||||||
|
if (options.expectedFrameId && event.frameId !== options.expectedFrameId) {
|
||||||
|
fail(new Error("download originated from a different frame"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (intent) {
|
||||||
|
fail(new Error("download trigger produced more than one browser download intent"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
intent = {
|
||||||
|
url: event.url,
|
||||||
|
suggestedFilename: event.suggestedFilename,
|
||||||
|
...(typeof event.frameId === "string" ? { frameId: event.frameId } : {}),
|
||||||
|
};
|
||||||
|
reconcile();
|
||||||
|
});
|
||||||
|
if (!cdpSubscription) {
|
||||||
|
return captureError("CDP download intent subscription unavailable", "none", "arm");
|
||||||
|
}
|
||||||
|
|
||||||
|
options.downloads.onDeterminingFilename.addListener(determiningListener);
|
||||||
|
options.downloads.onCreated.addListener(createdListener);
|
||||||
|
options.downloads.onChanged.addListener(changedListener);
|
||||||
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
operationTimer = setTimeout(
|
||||||
|
() => fail(new Error("download did not complete before timeout")),
|
||||||
|
options.timeoutMs,
|
||||||
|
);
|
||||||
|
sizePoll = setInterval(() => {
|
||||||
|
if (capturedId === undefined || settled || options.maxByteSize === undefined) return;
|
||||||
|
void options.downloads
|
||||||
|
.search({ id: capturedId })
|
||||||
|
.then(([item]) => {
|
||||||
|
if (!item || settled) return;
|
||||||
|
if (item.bytesReceived > (options.maxByteSize as number)) {
|
||||||
|
fail(new Error(`download exceeds transfer limit ${options.maxByteSize}`));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => fail(err instanceof Error ? err : new Error(String(err))));
|
||||||
|
}, SIZE_POLL_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const triggered = await options.trigger();
|
||||||
|
if (isRpcError(triggered)) {
|
||||||
|
void completion.catch(() => undefined);
|
||||||
|
const effect: TransferEffectState =
|
||||||
|
capturedId !== undefined ? "committed" : intent ? "unknown" : "none";
|
||||||
|
failureResult = {
|
||||||
|
...triggered,
|
||||||
|
data: { ...triggered.data, effect_state: effect, phase: "trigger" },
|
||||||
|
};
|
||||||
|
return failureResult;
|
||||||
|
}
|
||||||
|
click = triggered;
|
||||||
|
const item = await completion;
|
||||||
|
succeeded = true;
|
||||||
|
return { click, item };
|
||||||
|
} catch (err) {
|
||||||
|
const effect: TransferEffectState =
|
||||||
|
capturedId !== undefined ? "committed" : click ? "unknown" : "none";
|
||||||
|
failureResult = captureError(
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
effect,
|
||||||
|
capturedId !== undefined ? "download" : "attribution",
|
||||||
|
);
|
||||||
|
return failureResult;
|
||||||
|
} finally {
|
||||||
|
settled = true;
|
||||||
|
if (operationTimer) clearTimeout(operationTimer);
|
||||||
|
if (uniquenessTimer) clearTimeout(uniquenessTimer);
|
||||||
|
if (sizePoll) clearInterval(sizePoll);
|
||||||
|
options.signal?.removeEventListener("abort", onAbort);
|
||||||
|
options.downloads.onDeterminingFilename.removeListener(determiningListener);
|
||||||
|
options.downloads.onCreated.removeListener(createdListener);
|
||||||
|
options.downloads.onChanged.removeListener(changedListener);
|
||||||
|
cdpSubscription.dispose();
|
||||||
|
for (const candidate of candidates.values()) {
|
||||||
|
clearTimeout(candidate.graceTimer);
|
||||||
|
if (candidate.item.id !== capturedId) suggestDefault(candidate);
|
||||||
|
}
|
||||||
|
if (!succeeded && capturedId !== undefined) {
|
||||||
|
try {
|
||||||
|
await cleanupClaimedDownload(options.downloads, capturedId);
|
||||||
|
} catch {
|
||||||
|
if (failureResult?.data) failureResult.data.cleanup_state = "failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Download orchestration: validate session/tab ownership, then delegate one
|
||||||
|
// browser-global chrome.downloads transaction to download-capture.ts.
|
||||||
|
|
||||||
|
import type { SessionManager } from "@/session-manager/manager";
|
||||||
|
import type { DownloadParams, DownloadResult, RpcError } from "@/transport/types";
|
||||||
|
import { captureBrowserDownload, chromeDownloadsApi, type DownloadsApi } from "./download-capture";
|
||||||
|
import { clickResolvedTarget, type InteractionDeps, resolveActionTarget } from "./interaction";
|
||||||
|
import { enforceAgentWindow, isRpcError, lookupSession, resolveTargetTab } from "./shared";
|
||||||
|
|
||||||
|
let downloadActive = false;
|
||||||
|
|
||||||
|
export type { DownloadsApi } from "./download-capture";
|
||||||
|
|
||||||
|
export interface DownloadDeps extends InteractionDeps {
|
||||||
|
downloads?: DownloadsApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDownload(
|
||||||
|
manager: SessionManager,
|
||||||
|
params: DownloadParams,
|
||||||
|
deps: DownloadDeps,
|
||||||
|
): Promise<DownloadResult | RpcError> {
|
||||||
|
if (downloadActive) return { code: "invalid_params", message: "another bsk download is active" };
|
||||||
|
downloadActive = true;
|
||||||
|
try {
|
||||||
|
const ctx = lookupSession(manager, params, "download");
|
||||||
|
if (isRpcError(ctx)) return ctx;
|
||||||
|
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
|
||||||
|
if (isRpcError(target)) return target;
|
||||||
|
const denied = enforceAgentWindow(ctx, target, "download");
|
||||||
|
if (denied) return denied;
|
||||||
|
if (!params.browser_relative_dir) {
|
||||||
|
return { code: "invalid_params", message: "download requires a daemon capability directory" };
|
||||||
|
}
|
||||||
|
const address = await resolveActionTarget(deps.cdp, ctx, target, params, "download");
|
||||||
|
if (isRpcError(address)) return address;
|
||||||
|
|
||||||
|
const capture = await captureBrowserDownload({
|
||||||
|
cdp: deps.cdp,
|
||||||
|
target: address.cdpTarget,
|
||||||
|
downloads: deps.downloads ?? chromeDownloadsApi,
|
||||||
|
browserRelativeDir: params.browser_relative_dir,
|
||||||
|
maxByteSize: params.max_byte_size,
|
||||||
|
timeoutMs: params.timeout_ms ?? 120_000,
|
||||||
|
signal: deps.signal,
|
||||||
|
expectedFrameId: address.frameId,
|
||||||
|
trigger: () => clickResolvedTarget(ctx, address, {}, deps),
|
||||||
|
});
|
||||||
|
if (isRpcError(capture)) return capture;
|
||||||
|
const { click, item } = capture;
|
||||||
|
return {
|
||||||
|
tab_id: target.tabId,
|
||||||
|
used_ref: click.used_ref,
|
||||||
|
used_selector: click.used_selector,
|
||||||
|
suggested_filename: item.filename.split(/[\\/]/).pop() ?? "download",
|
||||||
|
byte_size: item.fileSize >= 0 ? item.fileSize : item.totalBytes,
|
||||||
|
mime: item.mime || undefined,
|
||||||
|
danger: item.danger,
|
||||||
|
browser_path: item.filename,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
downloadActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,51 @@
|
|||||||
// rendering. Extension handlers attach reasons here; human-facing copy
|
// rendering. Extension handlers attach reasons here; human-facing copy
|
||||||
// lives in bsk-cli `render_error.rs`.
|
// lives in bsk-cli `render_error.rs`.
|
||||||
|
|
||||||
import type { ErrorCode, RpcError, RpcErrorData, RpcErrorReason } from "@/transport/types";
|
import type {
|
||||||
|
ErrorCode,
|
||||||
|
RpcError,
|
||||||
|
RpcErrorData,
|
||||||
|
RpcErrorReason,
|
||||||
|
TransferCleanupState,
|
||||||
|
TransferEffectState,
|
||||||
|
} from "@/transport/types";
|
||||||
|
|
||||||
export type { RpcErrorData, RpcErrorReason };
|
export type { RpcErrorData, RpcErrorReason };
|
||||||
|
|
||||||
|
/** Chrome checks the whole tab's frame tree, including other extensions' frames. */
|
||||||
|
export function isCdpExtensionAccessDenied(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return message.includes("Cannot access a chrome-extension:// URL of different extension");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preserve more specific reasons, especially file-transfer outcome/cleanup details. */
|
||||||
|
export function classifyCdpError(error: RpcError): RpcError {
|
||||||
|
if (
|
||||||
|
error.code !== "cdp_failed" ||
|
||||||
|
error.data?.reason ||
|
||||||
|
!isCdpExtensionAccessDenied(error.message)
|
||||||
|
) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...error,
|
||||||
|
data: { ...error.data, reason: "cdp_extension_access_denied" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cdpError(error: unknown): RpcError {
|
||||||
|
return classifyCdpError({
|
||||||
|
code: "cdp_failed",
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransferErrorOptions {
|
||||||
|
effectState: TransferEffectState;
|
||||||
|
phase: string;
|
||||||
|
cleanupState?: TransferCleanupState;
|
||||||
|
}
|
||||||
|
|
||||||
export function rpcError(
|
export function rpcError(
|
||||||
code: ErrorCode,
|
code: ErrorCode,
|
||||||
reason: RpcErrorReason,
|
reason: RpcErrorReason,
|
||||||
@@ -15,3 +56,16 @@ export function rpcError(
|
|||||||
const data: RpcErrorData = { reason, ...extra };
|
const data: RpcErrorData = { reason, ...extra };
|
||||||
return { code, message, data };
|
return { code, message, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function transferError(
|
||||||
|
code: ErrorCode,
|
||||||
|
reason: RpcErrorReason,
|
||||||
|
message: string,
|
||||||
|
options: TransferErrorOptions,
|
||||||
|
): RpcError {
|
||||||
|
return rpcError(code, reason, message, {
|
||||||
|
effect_state: options.effectState,
|
||||||
|
phase: options.phase,
|
||||||
|
...(options.cleanupState ? { cleanup_state: options.cleanupState } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
// One native file-drop transaction bound to an explicit page target. CDP drag
|
||||||
|
// events are sent to the target that owns the node: root targets use top-level
|
||||||
|
// coordinates, while OOPIF targets require coordinates local to their session.
|
||||||
|
|
||||||
|
import type { RpcError, TransferEffectState } from "@/transport/types";
|
||||||
|
import { transferError } from "./errors";
|
||||||
|
import { resolveNodeGeometry } from "./frame-geometry";
|
||||||
|
import type { ResolvedActionTarget } from "./interaction";
|
||||||
|
import { type CdpRunner, isRpcError, sendToCdpTarget } from "./shared";
|
||||||
|
import { BoundedWaitError, waitBounded } from "./transfer-transaction";
|
||||||
|
|
||||||
|
const CLEANUP_TIMEOUT_MS = 1_000;
|
||||||
|
|
||||||
|
type FileDropPhase = "resolve_target" | "drag_enter" | "drag_over" | "drop" | "cleanup";
|
||||||
|
|
||||||
|
interface RuntimeReply {
|
||||||
|
result?: { value?: unknown; objectId?: string };
|
||||||
|
exceptionDetails?: { text?: string; exception?: { description?: string } };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragData {
|
||||||
|
items: [];
|
||||||
|
files: string[];
|
||||||
|
dragOperationsMask: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileDropTransactionOptions {
|
||||||
|
cdp: CdpRunner;
|
||||||
|
actionTarget: ResolvedActionTarget;
|
||||||
|
files: string[];
|
||||||
|
timeoutMs: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
withOverlayHidden: <T>(operation: () => Promise<T>) => Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileDropTransactionResult {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function failure(
|
||||||
|
code: RpcError["code"],
|
||||||
|
reason: "upload_mechanism_unsupported" | "file_drop_target_unavailable" | "file_drop_failed",
|
||||||
|
message: string,
|
||||||
|
effectState: TransferEffectState,
|
||||||
|
phase: FileDropPhase,
|
||||||
|
): RpcError {
|
||||||
|
const error = transferError(code, reason, message, { effectState, phase });
|
||||||
|
error.data = { ...error.data, mechanism: "drop" };
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichFailure(
|
||||||
|
error: RpcError,
|
||||||
|
effectState: TransferEffectState,
|
||||||
|
phase: FileDropPhase,
|
||||||
|
): RpcError {
|
||||||
|
return {
|
||||||
|
...error,
|
||||||
|
data: {
|
||||||
|
...error.data,
|
||||||
|
mechanism: "drop",
|
||||||
|
effect_state: effectState,
|
||||||
|
phase,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageOf(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function methodUnavailable(error: unknown): boolean {
|
||||||
|
return /(?:method|command).*(?:not found|wasn't found|unsupported)|wasn't found|-32601/i.test(
|
||||||
|
messageOf(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeError(reply: RuntimeReply, fallback: string): Error | null {
|
||||||
|
if (!reply.exceptionDetails) return null;
|
||||||
|
return new Error(
|
||||||
|
reply.exceptionDetails.exception?.description ?? reply.exceptionDetails.text ?? fallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pointBelongsToTarget(
|
||||||
|
options: FileDropTransactionOptions,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
objectGroup: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const target = options.actionTarget.cdpTarget;
|
||||||
|
const hit = await sendToCdpTarget<{ backendNodeId?: number; frameId?: string }>(
|
||||||
|
options.cdp,
|
||||||
|
target,
|
||||||
|
"DOM.getNodeForLocation",
|
||||||
|
{ x: Math.round(x), y: Math.round(y), includeUserAgentShadowDOM: true },
|
||||||
|
);
|
||||||
|
if (typeof hit.backendNodeId !== "number") return false;
|
||||||
|
if (options.actionTarget.frameId && hit.frameId !== options.actionTarget.frameId) return false;
|
||||||
|
|
||||||
|
const [resolvedTarget, resolvedHit] = await Promise.all([
|
||||||
|
sendToCdpTarget<{ object?: { objectId?: string } }>(options.cdp, target, "DOM.resolveNode", {
|
||||||
|
backendNodeId: options.actionTarget.backendNodeId,
|
||||||
|
objectGroup,
|
||||||
|
}),
|
||||||
|
sendToCdpTarget<{ object?: { objectId?: string } }>(options.cdp, target, "DOM.resolveNode", {
|
||||||
|
backendNodeId: hit.backendNodeId,
|
||||||
|
objectGroup,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const targetObjectId = resolvedTarget.object?.objectId;
|
||||||
|
const hitObjectId = resolvedHit.object?.objectId;
|
||||||
|
if (!targetObjectId || !hitObjectId) return false;
|
||||||
|
|
||||||
|
const reply = await sendToCdpTarget<RuntimeReply>(options.cdp, target, "Runtime.callFunctionOn", {
|
||||||
|
objectId: targetObjectId,
|
||||||
|
functionDeclaration: `function(hit) {
|
||||||
|
for (let node = hit; node; ) {
|
||||||
|
if (node === this) return true;
|
||||||
|
if (this.contains?.(node)) return true;
|
||||||
|
const root = node.getRootNode?.();
|
||||||
|
node = node.parentNode || (root && root !== node ? root.host : null);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}`,
|
||||||
|
arguments: [{ objectId: hitObjectId }],
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
const error = runtimeError(reply, "failed to verify file-drop target");
|
||||||
|
if (error) throw error;
|
||||||
|
return reply.result?.value === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadThroughFileDrop(
|
||||||
|
options: FileDropTransactionOptions,
|
||||||
|
): Promise<FileDropTransactionResult | RpcError> {
|
||||||
|
const deadline = Date.now() + options.timeoutMs;
|
||||||
|
const target = options.actionTarget.cdpTarget;
|
||||||
|
const objectGroup = `bsk-file-drop-${crypto.randomUUID()}`;
|
||||||
|
const dragData: DragData = {
|
||||||
|
items: [],
|
||||||
|
files: options.files,
|
||||||
|
dragOperationsMask: 1,
|
||||||
|
};
|
||||||
|
let dragStarted = false;
|
||||||
|
let dropSent = false;
|
||||||
|
let dragPoint = { x: 0, y: 0 };
|
||||||
|
let cleanupFailed = false;
|
||||||
|
let outcome: FileDropTransactionResult | RpcError = failure(
|
||||||
|
"protocol_error",
|
||||||
|
"file_drop_failed",
|
||||||
|
"file-drop transaction ended without an outcome",
|
||||||
|
"none",
|
||||||
|
"cleanup",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const geometry = await waitBounded(
|
||||||
|
resolveNodeGeometry(
|
||||||
|
options.cdp,
|
||||||
|
options.actionTarget.tab.tabId,
|
||||||
|
{
|
||||||
|
target,
|
||||||
|
backendNodeId: options.actionTarget.backendNodeId,
|
||||||
|
...(options.actionTarget.frameId ? { frameId: options.actionTarget.frameId } : {}),
|
||||||
|
},
|
||||||
|
{ scrollIntoView: true },
|
||||||
|
),
|
||||||
|
deadline,
|
||||||
|
options.signal,
|
||||||
|
"resolving file-drop target timed out",
|
||||||
|
);
|
||||||
|
if (isRpcError(geometry)) {
|
||||||
|
outcome = enrichFailure(geometry, "none", "resolve_target");
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
const point = geometry.targetActionPoint;
|
||||||
|
dragPoint = point;
|
||||||
|
|
||||||
|
outcome = await options.withOverlayHidden(async () => {
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
const belongs = await waitBounded(
|
||||||
|
pointBelongsToTarget(options, point.x, point.y, objectGroup),
|
||||||
|
deadline,
|
||||||
|
options.signal,
|
||||||
|
"verifying file-drop target timed out",
|
||||||
|
);
|
||||||
|
if (!belongs) {
|
||||||
|
return failure(
|
||||||
|
"permission_denied",
|
||||||
|
"file_drop_target_unavailable",
|
||||||
|
"file-drop target is not the topmost element at its action point",
|
||||||
|
"none",
|
||||||
|
"resolve_target",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return failure(
|
||||||
|
error instanceof BoundedWaitError ? "timeout" : "cdp_failed",
|
||||||
|
"file_drop_target_unavailable",
|
||||||
|
messageOf(error),
|
||||||
|
"none",
|
||||||
|
"resolve_target",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [type, phase] of [
|
||||||
|
["dragEnter", "drag_enter"],
|
||||||
|
["dragOver", "drag_over"],
|
||||||
|
] as const) {
|
||||||
|
try {
|
||||||
|
if (type === "dragEnter") dragStarted = true;
|
||||||
|
await waitBounded(
|
||||||
|
sendToCdpTarget(options.cdp, target, "Input.dispatchDragEvent", {
|
||||||
|
type,
|
||||||
|
x: point.x,
|
||||||
|
y: point.y,
|
||||||
|
data: dragData,
|
||||||
|
}),
|
||||||
|
deadline,
|
||||||
|
options.signal,
|
||||||
|
`${type} timed out`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const unsupported = methodUnavailable(error);
|
||||||
|
if (type === "dragEnter" && unsupported) dragStarted = false;
|
||||||
|
return failure(
|
||||||
|
unsupported
|
||||||
|
? "unsupported"
|
||||||
|
: error instanceof BoundedWaitError
|
||||||
|
? "timeout"
|
||||||
|
: "cdp_failed",
|
||||||
|
unsupported ? "upload_mechanism_unsupported" : "file_drop_failed",
|
||||||
|
messageOf(error),
|
||||||
|
"none",
|
||||||
|
phase,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
dropSent = true;
|
||||||
|
await waitBounded(
|
||||||
|
sendToCdpTarget(options.cdp, target, "Input.dispatchDragEvent", {
|
||||||
|
type: "drop",
|
||||||
|
x: point.x,
|
||||||
|
y: point.y,
|
||||||
|
data: dragData,
|
||||||
|
}),
|
||||||
|
deadline,
|
||||||
|
options.signal,
|
||||||
|
"file drop timed out",
|
||||||
|
);
|
||||||
|
return { x: point.x, y: point.y };
|
||||||
|
} catch (error) {
|
||||||
|
outcome = failure(
|
||||||
|
error instanceof BoundedWaitError ? "timeout" : "cdp_failed",
|
||||||
|
"file_drop_failed",
|
||||||
|
messageOf(error),
|
||||||
|
"unknown",
|
||||||
|
"drop",
|
||||||
|
);
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (dragStarted && !dropSent) {
|
||||||
|
try {
|
||||||
|
await waitBounded(
|
||||||
|
sendToCdpTarget(options.cdp, target, "Input.dispatchDragEvent", {
|
||||||
|
type: "dragCancel",
|
||||||
|
x: dragPoint.x,
|
||||||
|
y: dragPoint.y,
|
||||||
|
data: dragData,
|
||||||
|
}),
|
||||||
|
Date.now() + CLEANUP_TIMEOUT_MS,
|
||||||
|
undefined,
|
||||||
|
"cancelling file drag timed out",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
cleanupFailed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return outcome;
|
||||||
|
} catch (error) {
|
||||||
|
outcome = failure(
|
||||||
|
error instanceof BoundedWaitError ? "timeout" : "cdp_failed",
|
||||||
|
"file_drop_target_unavailable",
|
||||||
|
messageOf(error),
|
||||||
|
"none",
|
||||||
|
"resolve_target",
|
||||||
|
);
|
||||||
|
return outcome;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await waitBounded(
|
||||||
|
sendToCdpTarget(options.cdp, target, "Runtime.releaseObjectGroup", { objectGroup }),
|
||||||
|
Date.now() + CLEANUP_TIMEOUT_MS,
|
||||||
|
undefined,
|
||||||
|
"releasing file-drop object group timed out",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
cleanupFailed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effect = isRpcError(outcome)
|
||||||
|
? (outcome.data?.effect_state as TransferEffectState | undefined)
|
||||||
|
: "committed";
|
||||||
|
if (effect === "unknown" || cleanupFailed) {
|
||||||
|
await options.cdp.detach?.(target.tabId);
|
||||||
|
}
|
||||||
|
if (cleanupFailed && isRpcError(outcome)) {
|
||||||
|
outcome.data = { ...outcome.data, cleanup_state: "failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
// One upload transaction with an explicit browser-side commit boundary.
|
||||||
|
// Chrome interception prevents a native chooser from escaping automation;
|
||||||
|
// chooser events and a frame-scoped DOM probe are independent input-location
|
||||||
|
// signals, so event delivery is not required for standard file inputs.
|
||||||
|
|
||||||
|
import type { ClickResult, RpcError, TransferEffectState } from "@/transport/types";
|
||||||
|
import { transferError } from "./errors";
|
||||||
|
import type { ResolvedActionTarget } from "./interaction";
|
||||||
|
import { type CdpRunner, isRpcError, sendToCdpTarget } from "./shared";
|
||||||
|
import { BoundedWaitError, remainingMs, waitBounded } from "./transfer-transaction";
|
||||||
|
|
||||||
|
const CLEANUP_TIMEOUT_MS = 1_000;
|
||||||
|
const ACTIVATION_GRACE_MS = 1_000;
|
||||||
|
const PROBE_INTERVAL_MS = 20;
|
||||||
|
|
||||||
|
type UploadPhase =
|
||||||
|
| "arm_interception"
|
||||||
|
| "arm_input_probe"
|
||||||
|
| "trigger"
|
||||||
|
| "resolve_input"
|
||||||
|
| "set_files"
|
||||||
|
| "cleanup";
|
||||||
|
|
||||||
|
interface RuntimeReply {
|
||||||
|
result?: { value?: unknown; objectId?: string };
|
||||||
|
exceptionDetails?: { text?: string; exception?: { description?: string } };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChooserEvent {
|
||||||
|
frameId?: string;
|
||||||
|
backendNodeId?: number;
|
||||||
|
mode?: "selectSingle" | "selectMultiple";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileInputTransactionOptions {
|
||||||
|
cdp: CdpRunner;
|
||||||
|
actionTarget: ResolvedActionTarget;
|
||||||
|
files: string[];
|
||||||
|
timeoutMs: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
trigger(): Promise<ClickResult | RpcError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileInputTransactionResult {
|
||||||
|
click: ClickResult;
|
||||||
|
multiple: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeError(reply: RuntimeReply, fallback: string): Error | null {
|
||||||
|
if (!reply.exceptionDetails) return null;
|
||||||
|
return new Error(
|
||||||
|
reply.exceptionDetails.exception?.description ?? reply.exceptionDetails.text ?? fallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function transferFailure(
|
||||||
|
code: RpcError["code"],
|
||||||
|
reason: "file_input_probe_failed" | "file_input_not_activated" | "set_file_input_failed",
|
||||||
|
message: string,
|
||||||
|
effectState: TransferEffectState,
|
||||||
|
phase: UploadPhase,
|
||||||
|
): RpcError {
|
||||||
|
return transferError(code, reason, message, { effectState, phase });
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichFailure(
|
||||||
|
error: RpcError,
|
||||||
|
effectState: TransferEffectState,
|
||||||
|
phase: UploadPhase,
|
||||||
|
): RpcError {
|
||||||
|
return {
|
||||||
|
...error,
|
||||||
|
data: { ...error.data, effect_state: effectState, phase },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameTarget(
|
||||||
|
source: { tabId?: number; sessionId?: string },
|
||||||
|
target: { tabId: number; sessionId?: string },
|
||||||
|
): boolean {
|
||||||
|
return source.tabId === target.tabId && source.sessionId === target.sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
await waitBounded(
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms)),
|
||||||
|
Date.now() + ms + 1,
|
||||||
|
signal,
|
||||||
|
"upload activation probe timed out",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callOnTrigger<T = RuntimeReply>(
|
||||||
|
options: FileInputTransactionOptions,
|
||||||
|
objectId: string,
|
||||||
|
functionDeclaration: string,
|
||||||
|
args: unknown[],
|
||||||
|
returnByValue: boolean,
|
||||||
|
): Promise<T> {
|
||||||
|
return sendToCdpTarget<T>(options.cdp, options.actionTarget.cdpTarget, "Runtime.callFunctionOn", {
|
||||||
|
objectId,
|
||||||
|
functionDeclaration,
|
||||||
|
arguments: args.map((value) => ({ value })),
|
||||||
|
returnByValue,
|
||||||
|
awaitPromise: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadThroughActivatedFileInput(
|
||||||
|
options: FileInputTransactionOptions,
|
||||||
|
): Promise<FileInputTransactionResult | RpcError> {
|
||||||
|
const deadline = Date.now() + options.timeoutMs;
|
||||||
|
const target = options.actionTarget.cdpTarget;
|
||||||
|
const objectGroup = `bsk-upload-${crypto.randomUUID()}`;
|
||||||
|
const stateKey = `__bskUpload_${crypto.randomUUID().replaceAll("-", "")}`;
|
||||||
|
const chooserEvents: ChooserEvent[] = [];
|
||||||
|
let interceptionArmed = false;
|
||||||
|
let probeArmed = false;
|
||||||
|
let triggerObjectId: string | undefined;
|
||||||
|
let outcome: FileInputTransactionResult | RpcError = transferFailure(
|
||||||
|
"protocol_error",
|
||||||
|
"file_input_probe_failed",
|
||||||
|
"upload transaction ended without an outcome",
|
||||||
|
"none",
|
||||||
|
"cleanup",
|
||||||
|
);
|
||||||
|
|
||||||
|
const chooserSubscription = options.cdp.onEvent?.((source, method, raw) => {
|
||||||
|
if (method !== "Page.fileChooserOpened" || !sameTarget(source, target)) return;
|
||||||
|
const event = raw as ChooserEvent;
|
||||||
|
chooserEvents.push(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await waitBounded(
|
||||||
|
sendToCdpTarget(options.cdp, target, "Page.setInterceptFileChooserDialog", {
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
deadline,
|
||||||
|
options.signal,
|
||||||
|
"arming native file chooser interception timed out",
|
||||||
|
);
|
||||||
|
interceptionArmed = true;
|
||||||
|
} catch (err) {
|
||||||
|
outcome = transferFailure(
|
||||||
|
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
|
||||||
|
"file_input_probe_failed",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
"none",
|
||||||
|
"arm_interception",
|
||||||
|
);
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = await waitBounded(
|
||||||
|
sendToCdpTarget<{ object?: { objectId?: string } }>(
|
||||||
|
options.cdp,
|
||||||
|
target,
|
||||||
|
"DOM.resolveNode",
|
||||||
|
{
|
||||||
|
backendNodeId: options.actionTarget.backendNodeId,
|
||||||
|
objectGroup,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
deadline,
|
||||||
|
options.signal,
|
||||||
|
"resolving upload trigger timed out",
|
||||||
|
);
|
||||||
|
triggerObjectId = resolved.object?.objectId;
|
||||||
|
if (!triggerObjectId) throw new Error("DOM.resolveNode returned no trigger objectId");
|
||||||
|
|
||||||
|
const armed = await waitBounded(
|
||||||
|
callOnTrigger<RuntimeReply>(
|
||||||
|
options,
|
||||||
|
triggerObjectId,
|
||||||
|
`function(key) {
|
||||||
|
const doc = this.ownerDocument;
|
||||||
|
const owner = doc.defaultView;
|
||||||
|
if (!owner) return false;
|
||||||
|
const state = { inputs: [], listener: null };
|
||||||
|
Object.defineProperty(owner, key, { value: state, configurable: true });
|
||||||
|
state.listener = event => {
|
||||||
|
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
|
||||||
|
const candidate = path[0] || event.target;
|
||||||
|
if (candidate && candidate.nodeType === 1 &&
|
||||||
|
candidate.localName === "input" && candidate.type === "file") {
|
||||||
|
if (!state.inputs.includes(candidate)) state.inputs.push(candidate);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
doc.addEventListener("click", state.listener, true);
|
||||||
|
return true;
|
||||||
|
}`,
|
||||||
|
[stateKey],
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
deadline,
|
||||||
|
options.signal,
|
||||||
|
"arming frame-scoped file input probe timed out",
|
||||||
|
);
|
||||||
|
const armError = runtimeError(armed, "failed to arm file input probe");
|
||||||
|
if (armError || armed.result?.value !== true) {
|
||||||
|
throw armError ?? new Error("file input probe did not arm");
|
||||||
|
}
|
||||||
|
probeArmed = true;
|
||||||
|
} catch (err) {
|
||||||
|
outcome = transferFailure(
|
||||||
|
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
|
||||||
|
"file_input_probe_failed",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
"none",
|
||||||
|
"arm_input_probe",
|
||||||
|
);
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
let click: ClickResult | RpcError;
|
||||||
|
try {
|
||||||
|
click = await waitBounded(
|
||||||
|
options.trigger(),
|
||||||
|
deadline,
|
||||||
|
options.signal,
|
||||||
|
"upload trigger timed out",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
outcome = transferFailure(
|
||||||
|
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
|
||||||
|
"file_input_probe_failed",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
"none",
|
||||||
|
"trigger",
|
||||||
|
);
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
if (isRpcError(click)) {
|
||||||
|
outcome = enrichFailure(click, "none", "trigger");
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activationDeadline = Math.min(deadline, Date.now() + ACTIVATION_GRACE_MS);
|
||||||
|
let summary: { count: number; multiple: boolean } = { count: 0, multiple: false };
|
||||||
|
while (Date.now() < activationDeadline) {
|
||||||
|
if (chooserEvents.length > 0) break;
|
||||||
|
const reply = await callOnTrigger<RuntimeReply>(
|
||||||
|
options,
|
||||||
|
triggerObjectId,
|
||||||
|
`function(key) {
|
||||||
|
const state = this.ownerDocument.defaultView?.[key];
|
||||||
|
return state
|
||||||
|
? { count: state.inputs.length, multiple: state.inputs[0]?.multiple === true }
|
||||||
|
: { count: 0, multiple: false };
|
||||||
|
}`,
|
||||||
|
[stateKey],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const summaryError = runtimeError(reply, "failed to inspect activated file input");
|
||||||
|
if (summaryError) throw summaryError;
|
||||||
|
const value = reply.result?.value as { count?: unknown; multiple?: unknown } | undefined;
|
||||||
|
summary = {
|
||||||
|
count: typeof value?.count === "number" ? value.count : 0,
|
||||||
|
multiple: value?.multiple === true,
|
||||||
|
};
|
||||||
|
if (summary.count > 0) break;
|
||||||
|
await delay(Math.min(PROBE_INTERVAL_MS, remainingMs(activationDeadline)), options.signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chooserEvents.length > 1) {
|
||||||
|
outcome = transferFailure(
|
||||||
|
"unsupported",
|
||||||
|
"file_input_not_activated",
|
||||||
|
"upload trigger activated more than one file chooser",
|
||||||
|
"none",
|
||||||
|
"resolve_input",
|
||||||
|
);
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chooser = chooserEvents[0];
|
||||||
|
let backendNodeId: number | undefined;
|
||||||
|
let multiple = summary.multiple;
|
||||||
|
if (chooser) {
|
||||||
|
if (options.actionTarget.frameId && chooser.frameId !== options.actionTarget.frameId) {
|
||||||
|
outcome = transferFailure(
|
||||||
|
"unsupported",
|
||||||
|
"file_input_not_activated",
|
||||||
|
"upload trigger activated a file chooser in a different frame",
|
||||||
|
"none",
|
||||||
|
"resolve_input",
|
||||||
|
);
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
if (typeof chooser.backendNodeId !== "number") {
|
||||||
|
outcome = transferFailure(
|
||||||
|
"unsupported",
|
||||||
|
"file_input_not_activated",
|
||||||
|
"upload trigger invoked a non-input file picker",
|
||||||
|
"none",
|
||||||
|
"resolve_input",
|
||||||
|
);
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
backendNodeId = chooser.backendNodeId;
|
||||||
|
multiple = chooser.mode === "selectMultiple";
|
||||||
|
} else {
|
||||||
|
if (summary.count !== 1) {
|
||||||
|
outcome = transferFailure(
|
||||||
|
"unsupported",
|
||||||
|
"file_input_not_activated",
|
||||||
|
summary.count === 0
|
||||||
|
? "upload trigger did not activate an input[type=file]"
|
||||||
|
: "upload trigger activated more than one input[type=file]",
|
||||||
|
"none",
|
||||||
|
"resolve_input",
|
||||||
|
);
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
const input = await callOnTrigger<RuntimeReply>(
|
||||||
|
options,
|
||||||
|
triggerObjectId,
|
||||||
|
`function(key) { return this.ownerDocument.defaultView?.[key]?.inputs[0]; }`,
|
||||||
|
[stateKey],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const inputError = runtimeError(input, "failed to resolve activated file input object");
|
||||||
|
if (inputError) throw inputError;
|
||||||
|
if (!input.result?.objectId) throw new Error("activated file input returned no objectId");
|
||||||
|
const described = await sendToCdpTarget<{ node?: { backendNodeId?: number } }>(
|
||||||
|
options.cdp,
|
||||||
|
target,
|
||||||
|
"DOM.describeNode",
|
||||||
|
{ objectId: input.result.objectId },
|
||||||
|
);
|
||||||
|
backendNodeId = described.node?.backendNodeId;
|
||||||
|
if (typeof backendNodeId !== "number") {
|
||||||
|
throw new Error("DOM.describeNode returned no file input backendNodeId");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!multiple && options.files.length !== 1) {
|
||||||
|
outcome = enrichFailure(
|
||||||
|
{ code: "invalid_params", message: "file input accepts exactly one file" },
|
||||||
|
"none",
|
||||||
|
"resolve_input",
|
||||||
|
);
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitBounded(
|
||||||
|
sendToCdpTarget(options.cdp, target, "DOM.setFileInputFiles", {
|
||||||
|
files: options.files,
|
||||||
|
backendNodeId,
|
||||||
|
}),
|
||||||
|
deadline,
|
||||||
|
options.signal,
|
||||||
|
"setting file input files timed out",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
outcome = transferFailure(
|
||||||
|
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
|
||||||
|
"set_file_input_failed",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
"unknown",
|
||||||
|
"set_files",
|
||||||
|
);
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
outcome = { click, multiple };
|
||||||
|
return outcome;
|
||||||
|
} catch (err) {
|
||||||
|
outcome = transferFailure(
|
||||||
|
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
|
||||||
|
"file_input_probe_failed",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
"none",
|
||||||
|
"resolve_input",
|
||||||
|
);
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
chooserSubscription?.dispose();
|
||||||
|
let cleanupFailed = false;
|
||||||
|
if (probeArmed && triggerObjectId) {
|
||||||
|
try {
|
||||||
|
await waitBounded(
|
||||||
|
callOnTrigger(
|
||||||
|
options,
|
||||||
|
triggerObjectId,
|
||||||
|
`function(key) {
|
||||||
|
const owner = this.ownerDocument.defaultView;
|
||||||
|
const state = owner?.[key];
|
||||||
|
if (state?.listener) this.ownerDocument.removeEventListener("click", state.listener, true);
|
||||||
|
if (owner) delete owner[key];
|
||||||
|
}`,
|
||||||
|
[stateKey],
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
Date.now() + CLEANUP_TIMEOUT_MS,
|
||||||
|
undefined,
|
||||||
|
"cleaning file input probe timed out",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
cleanupFailed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (interceptionArmed) {
|
||||||
|
try {
|
||||||
|
await waitBounded(
|
||||||
|
sendToCdpTarget(options.cdp, target, "Page.setInterceptFileChooserDialog", {
|
||||||
|
enabled: false,
|
||||||
|
cancel: true,
|
||||||
|
}),
|
||||||
|
Date.now() + CLEANUP_TIMEOUT_MS,
|
||||||
|
undefined,
|
||||||
|
"disabling file chooser interception timed out",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
cleanupFailed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await waitBounded(
|
||||||
|
sendToCdpTarget(options.cdp, target, "Runtime.releaseObjectGroup", { objectGroup }),
|
||||||
|
Date.now() + CLEANUP_TIMEOUT_MS,
|
||||||
|
undefined,
|
||||||
|
"releasing upload object group timed out",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
cleanupFailed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effect = isRpcError(outcome)
|
||||||
|
? (outcome.data?.effect_state as TransferEffectState | undefined)
|
||||||
|
: "committed";
|
||||||
|
if (effect === "unknown" || cleanupFailed) {
|
||||||
|
await options.cdp.detach?.(target.tabId);
|
||||||
|
}
|
||||||
|
if (cleanupFailed && isRpcError(outcome)) {
|
||||||
|
outcome.data = { ...outcome.data, cleanup_state: "failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,21 @@
|
|||||||
import { type CdpFrame, type CdpFrameGraph, type CdpTarget } from "@/browser-driver/frame-graph";
|
import { type CdpTarget, cdpTargetKey } from "@/browser-driver/frame-graph";
|
||||||
import type { RpcError } from "@/transport/types";
|
import type { RpcError } from "@/transport/types";
|
||||||
import { nodeContentRegion, scrollNodeIntoView } from "./element-geometry";
|
import { nodeContentRegion, scrollNodeIntoView } from "./element-geometry";
|
||||||
import {
|
import {
|
||||||
clipPolygon,
|
clipPolygon,
|
||||||
type GeometryProjection,
|
|
||||||
type Point,
|
type Point,
|
||||||
type Polygon,
|
type Polygon,
|
||||||
type ProjectiveEdge,
|
|
||||||
parseCdpQuad,
|
|
||||||
polygonArea,
|
polygonArea,
|
||||||
polygonCentroid,
|
polygonCentroid,
|
||||||
projectRegionToViewport,
|
projectRegionToViewport,
|
||||||
type Quad,
|
|
||||||
type Region,
|
type Region,
|
||||||
rectPolygon,
|
rectPolygon,
|
||||||
regionBounds,
|
regionBounds,
|
||||||
type Size,
|
|
||||||
type ViewportRect,
|
type ViewportRect,
|
||||||
} from "./geometry";
|
} from "./geometry";
|
||||||
import { type CdpRunner, cdpRunnerForTarget, isRpcError, sendToCdpTarget } from "./shared";
|
import type { CssViewport } from "./geometry/coordinate-types";
|
||||||
|
import { cssViewport, GeometryContext } from "./geometry/frame-context";
|
||||||
|
import { type CdpRunner, cdpRunnerForTarget, isRpcError } from "./shared";
|
||||||
|
|
||||||
export interface NodeAddress {
|
export interface NodeAddress {
|
||||||
target: CdpTarget;
|
target: CdpTarget;
|
||||||
@@ -27,154 +24,30 @@ export interface NodeAddress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedNodeGeometry {
|
export interface ResolvedNodeGeometry {
|
||||||
|
/** The same top-level measurement used by projection, for screenshot coordinate adaptation. */
|
||||||
|
topViewport: CssViewport;
|
||||||
topVisibleRegions: Region;
|
topVisibleRegions: Region;
|
||||||
topBounds: ViewportRect;
|
topBounds: ViewportRect;
|
||||||
|
/** Point in the top-level tab viewport, used by root-target input events. */
|
||||||
actionPoint: Point;
|
actionPoint: Point;
|
||||||
|
/** Point in the addressed CDP target's viewport, used by OOPIF-local input events. */
|
||||||
|
targetActionPoint: Point;
|
||||||
}
|
}
|
||||||
|
|
||||||
function geometryError(message: string): RpcError {
|
function geometryError(message: string): RpcError {
|
||||||
return { code: "cdp_failed", message };
|
return { code: "cdp_failed", message };
|
||||||
}
|
}
|
||||||
|
|
||||||
function frameMap(graph: CdpFrameGraph): Map<string, CdpFrame> {
|
|
||||||
return new Map(graph.frames.map((frame) => [frame.frameId, frame]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function targetRootFrame(byId: Map<string, CdpFrame>, frame: CdpFrame): CdpFrame | null {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
let current = frame;
|
|
||||||
while (current.parentFrameId) {
|
|
||||||
if (seen.has(current.frameId)) return null;
|
|
||||||
seen.add(current.frameId);
|
|
||||||
const parent = byId.get(current.parentFrameId);
|
|
||||||
if (!parent || parent.target.sessionId !== current.target.sessionId) break;
|
|
||||||
current = parent;
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
function frameAncestry(graph: CdpFrameGraph, frameId: string): CdpFrame[] | null {
|
|
||||||
const byId = frameMap(graph);
|
|
||||||
const path: CdpFrame[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
let current = byId.get(frameId);
|
|
||||||
if (!current) return null;
|
|
||||||
while (current.parentFrameId) {
|
|
||||||
if (seen.has(current.frameId)) return null;
|
|
||||||
seen.add(current.frameId);
|
|
||||||
path.push(current);
|
|
||||||
const parent = byId.get(current.parentFrameId);
|
|
||||||
if (!parent) return null;
|
|
||||||
current = parent;
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function targetViewport(cdp: CdpRunner, target: CdpTarget): Promise<Size | null> {
|
|
||||||
const metrics = await sendToCdpTarget<{
|
|
||||||
cssLayoutViewport?: { clientWidth?: number; clientHeight?: number };
|
|
||||||
layoutViewport?: { clientWidth?: number; clientHeight?: number };
|
|
||||||
}>(cdp, target, "Page.getLayoutMetrics", {});
|
|
||||||
const viewport = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
|
|
||||||
const width = viewport.clientWidth ?? 0;
|
|
||||||
const height = viewport.clientHeight ?? 0;
|
|
||||||
return width > 0 && height > 0 ? { width, height } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ownerContentQuad(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
parent: CdpFrame,
|
|
||||||
ownerBackendNodeId: number,
|
|
||||||
): Promise<Quad | null> {
|
|
||||||
const result = await sendToCdpTarget<{ model?: { content?: number[] } }>(
|
|
||||||
cdp,
|
|
||||||
parent.target,
|
|
||||||
"DOM.getBoxModel",
|
|
||||||
{ backendNodeId: ownerBackendNodeId },
|
|
||||||
);
|
|
||||||
return parseCdpQuad(result.model?.content);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sameTargetFrameClips(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
byId: Map<string, CdpFrame>,
|
|
||||||
frame: CdpFrame,
|
|
||||||
targetRoot: CdpFrame,
|
|
||||||
): Promise<Polygon[] | null> {
|
|
||||||
const clips: Polygon[] = [];
|
|
||||||
let current = frame;
|
|
||||||
const seen = new Set<string>();
|
|
||||||
while (current.frameId !== targetRoot.frameId) {
|
|
||||||
if (seen.has(current.frameId) || !current.parentFrameId) return null;
|
|
||||||
seen.add(current.frameId);
|
|
||||||
const parent = byId.get(current.parentFrameId);
|
|
||||||
if (!parent || current.ownerBackendNodeId === undefined) return null;
|
|
||||||
const clip = await ownerContentQuad(cdp, parent, current.ownerBackendNodeId);
|
|
||||||
if (!clip) return null;
|
|
||||||
clips.push(clip);
|
|
||||||
current = parent;
|
|
||||||
}
|
|
||||||
return clips;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveFrameProjection(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
graph: CdpFrameGraph,
|
|
||||||
frameId: string,
|
|
||||||
): Promise<GeometryProjection | null> {
|
|
||||||
const byId = frameMap(graph);
|
|
||||||
const frame = byId.get(frameId);
|
|
||||||
if (!frame) return null;
|
|
||||||
let targetRoot = targetRootFrame(byId, frame);
|
|
||||||
if (!targetRoot) return null;
|
|
||||||
const sourceViewport = await targetViewport(cdp, targetRoot.target);
|
|
||||||
if (!sourceViewport) return null;
|
|
||||||
const sourceClips = await sameTargetFrameClips(cdp, byId, frame, targetRoot);
|
|
||||||
if (!sourceClips) return null;
|
|
||||||
|
|
||||||
const edges: ProjectiveEdge[] = [];
|
|
||||||
while (targetRoot.parentFrameId) {
|
|
||||||
const parent = byId.get(targetRoot.parentFrameId);
|
|
||||||
if (!parent || targetRoot.ownerBackendNodeId === undefined) return null;
|
|
||||||
const destinationQuad = await ownerContentQuad(cdp, parent, targetRoot.ownerBackendNodeId);
|
|
||||||
if (!destinationQuad) return null;
|
|
||||||
const source =
|
|
||||||
edges.length === 0 ? sourceViewport : await targetViewport(cdp, targetRoot.target);
|
|
||||||
if (!source) return null;
|
|
||||||
const parentTargetRoot = targetRootFrame(byId, parent);
|
|
||||||
if (!parentTargetRoot) return null;
|
|
||||||
const destinationClips = await sameTargetFrameClips(cdp, byId, parent, parentTargetRoot);
|
|
||||||
if (!destinationClips) return null;
|
|
||||||
edges.push({ sourceViewport: source, destinationQuad, destinationClips });
|
|
||||||
targetRoot = parentTargetRoot;
|
|
||||||
}
|
|
||||||
|
|
||||||
const topViewport =
|
|
||||||
edges.length === 0 ? sourceViewport : await targetViewport(cdp, targetRoot.target);
|
|
||||||
return topViewport ? { sourceClips, edges, topViewport } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFrameGraph(cdp: CdpRunner, tabId: number): Promise<CdpFrameGraph | null> {
|
|
||||||
if (!cdp.getFrameGraph) return null;
|
|
||||||
try {
|
|
||||||
return await cdp.getFrameGraph(tabId);
|
|
||||||
} catch (error) {
|
|
||||||
console.debug("[bsk frame-geometry] frame graph resolution failed", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scrollFrameOwners(
|
async function scrollFrameOwners(
|
||||||
cdp: CdpRunner,
|
cdp: CdpRunner,
|
||||||
tabId: number,
|
tabId: number,
|
||||||
graph: CdpFrameGraph,
|
context: GeometryContext,
|
||||||
frameId: string,
|
frameId: string,
|
||||||
): Promise<RpcError | null> {
|
): Promise<RpcError | null> {
|
||||||
const byId = frameMap(graph);
|
const ancestry = await context.ancestry(frameId);
|
||||||
const ancestry = frameAncestry(graph, frameId);
|
|
||||||
if (!ancestry) return geometryError(`could not resolve frame ancestry for ${frameId}`);
|
if (!ancestry) return geometryError(`could not resolve frame ancestry for ${frameId}`);
|
||||||
for (const child of [...ancestry].reverse()) {
|
for (const child of [...ancestry].reverse()) {
|
||||||
const parent = child.parentFrameId ? byId.get(child.parentFrameId) : undefined;
|
const parent = child.parentFrameId ? await context.frame(child.parentFrameId) : undefined;
|
||||||
if (!parent || child.ownerBackendNodeId === undefined) {
|
if (!parent || child.ownerBackendNodeId === undefined) {
|
||||||
return geometryError(`could not resolve frame owner for ${child.frameId}`);
|
return geometryError(`could not resolve frame owner for ${child.frameId}`);
|
||||||
}
|
}
|
||||||
@@ -188,17 +61,20 @@ async function scrollFrameOwners(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scrollElementWithFrameGraph(
|
async function scrollElementWithContext(
|
||||||
cdp: CdpRunner,
|
cdp: CdpRunner,
|
||||||
tabId: number,
|
tabId: number,
|
||||||
target: CdpTarget,
|
target: CdpTarget,
|
||||||
backendNodeId: number,
|
backendNodeId: number,
|
||||||
frameId: string | undefined,
|
frameId: string | undefined,
|
||||||
graph: CdpFrameGraph | null,
|
context: GeometryContext,
|
||||||
): Promise<RpcError | null> {
|
): Promise<RpcError | null> {
|
||||||
if (frameId) {
|
if (frameId) {
|
||||||
if (!graph) return geometryError(`could not resolve frame graph for ${frameId}`);
|
const frame = await context.frame(frameId);
|
||||||
const error = await scrollFrameOwners(cdp, tabId, graph, frameId);
|
if (!frame || cdpTargetKey(frame.target) !== cdpTargetKey(target)) {
|
||||||
|
return geometryError("node frame does not belong to its target");
|
||||||
|
}
|
||||||
|
const error = await scrollFrameOwners(cdp, tabId, context, frameId);
|
||||||
if (error) return error;
|
if (error) return error;
|
||||||
} else if (target.sessionId) {
|
} else if (target.sessionId) {
|
||||||
return geometryError("an OOPIF node address requires frameId");
|
return geometryError("an OOPIF node address requires frameId");
|
||||||
@@ -213,8 +89,15 @@ export async function scrollElementAndFramesIntoView(
|
|||||||
backendNodeId: number,
|
backendNodeId: number,
|
||||||
frameId?: string,
|
frameId?: string,
|
||||||
): Promise<RpcError | null> {
|
): Promise<RpcError | null> {
|
||||||
const graph = frameId ? await loadFrameGraph(cdp, tabId) : null;
|
if (target.tabId !== tabId) return geometryError("node target belongs to another tab");
|
||||||
return scrollElementWithFrameGraph(cdp, tabId, target, backendNodeId, frameId, graph);
|
return scrollElementWithContext(
|
||||||
|
cdp,
|
||||||
|
tabId,
|
||||||
|
target,
|
||||||
|
backendNodeId,
|
||||||
|
frameId,
|
||||||
|
new GeometryContext(cdp, tabId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function largestRegion(regions: Region): Polygon | null {
|
function largestRegion(regions: Region): Polygon | null {
|
||||||
@@ -237,23 +120,32 @@ export async function resolveNodeGeometry(
|
|||||||
if (address.target.sessionId && !address.frameId) {
|
if (address.target.sessionId && !address.frameId) {
|
||||||
return geometryError("an OOPIF node address requires frameId");
|
return geometryError("an OOPIF node address requires frameId");
|
||||||
}
|
}
|
||||||
const graph = address.frameId ? await loadFrameGraph(cdp, tabId) : null;
|
if (address.target.tabId !== tabId) return geometryError("node target belongs to another tab");
|
||||||
|
const context = new GeometryContext(cdp, tabId);
|
||||||
|
const graph = address.frameId ? await context.graph() : null;
|
||||||
|
if (address.frameId && graph) {
|
||||||
|
const frame = await context.frame(address.frameId);
|
||||||
|
if (!frame || cdpTargetKey(frame.target) !== cdpTargetKey(address.target)) {
|
||||||
|
return geometryError("node frame does not belong to its target");
|
||||||
|
}
|
||||||
|
}
|
||||||
if (address.frameId && !graph) {
|
if (address.frameId && !graph) {
|
||||||
return geometryError(`could not resolve frame graph for ${address.frameId}`);
|
return geometryError(`could not resolve frame graph for ${address.frameId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.scrollIntoView) {
|
if (options.scrollIntoView) {
|
||||||
const scrollError = await scrollElementWithFrameGraph(
|
const scrollError = await scrollElementWithContext(
|
||||||
cdp,
|
cdp,
|
||||||
tabId,
|
tabId,
|
||||||
address.target,
|
address.target,
|
||||||
address.backendNodeId,
|
address.backendNodeId,
|
||||||
address.frameId,
|
address.frameId,
|
||||||
graph,
|
context,
|
||||||
);
|
);
|
||||||
if (scrollError) return scrollError;
|
if (scrollError) return scrollError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only topology was read above. Begin live measurements after scrolling.
|
||||||
const localRegion = await nodeContentRegion(
|
const localRegion = await nodeContentRegion(
|
||||||
cdpRunnerForTarget(cdp, address.target),
|
cdpRunnerForTarget(cdp, address.target),
|
||||||
tabId,
|
tabId,
|
||||||
@@ -262,13 +154,32 @@ export async function resolveNodeGeometry(
|
|||||||
if (isRpcError(localRegion)) return localRegion;
|
if (isRpcError(localRegion)) return localRegion;
|
||||||
|
|
||||||
let topVisibleRegions: Region;
|
let topVisibleRegions: Region;
|
||||||
|
let targetActionPoint: Point | null = null;
|
||||||
if (address.frameId && graph) {
|
if (address.frameId && graph) {
|
||||||
const projection = await resolveFrameProjection(cdp, graph, address.frameId);
|
const projection = await context.targetProjection(address.frameId);
|
||||||
if (!projection)
|
if (!projection)
|
||||||
return geometryError(`could not resolve frame geometry for ${address.frameId}`);
|
return geometryError(`could not resolve frame geometry for ${address.frameId}`);
|
||||||
topVisibleRegions = projectRegionToViewport(localRegion, projection);
|
topVisibleRegions = projectRegionToViewport(localRegion, projection);
|
||||||
|
if (address.target.sessionId) {
|
||||||
|
const localViewport = projection.edges[0]?.sourceViewport ?? projection.topViewport;
|
||||||
|
const localVisibleRegions = localRegion
|
||||||
|
.map((polygon) =>
|
||||||
|
clipPolygon(
|
||||||
|
polygon,
|
||||||
|
rectPolygon({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: localViewport.width,
|
||||||
|
h: localViewport.height,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter((polygon) => polygon.length >= 3);
|
||||||
|
const localActionRegion = largestRegion(localVisibleRegions);
|
||||||
|
targetActionPoint = localActionRegion ? polygonCentroid(localActionRegion) : null;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const viewport = await targetViewport(cdp, address.target);
|
const viewport = await context.viewport(address.target);
|
||||||
if (!viewport) return geometryError("could not resolve top viewport geometry");
|
if (!viewport) return geometryError("could not resolve top viewport geometry");
|
||||||
topVisibleRegions = localRegion
|
topVisibleRegions = localRegion
|
||||||
.map((polygon) =>
|
.map((polygon) =>
|
||||||
@@ -283,7 +194,17 @@ export async function resolveNodeGeometry(
|
|||||||
if (!topBounds || !actionPoint) {
|
if (!topBounds || !actionPoint) {
|
||||||
return { code: "permission_denied", message: "element not visible" };
|
return { code: "permission_denied", message: "element not visible" };
|
||||||
}
|
}
|
||||||
return { topVisibleRegions, topBounds, actionPoint };
|
if (address.target.sessionId && !targetActionPoint) {
|
||||||
|
return { code: "permission_denied", message: "element not visible in its target" };
|
||||||
|
}
|
||||||
|
targetActionPoint ??= actionPoint;
|
||||||
|
return {
|
||||||
|
topVisibleRegions,
|
||||||
|
topBounds,
|
||||||
|
actionPoint,
|
||||||
|
targetActionPoint,
|
||||||
|
topViewport: cssViewport(await context.layoutMetrics({ tabId })),
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return geometryError(error instanceof Error ? error.message : String(error));
|
return geometryError(error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,22 +245,3 @@ export function projectRectToViewport(
|
|||||||
if (!rect) return null;
|
if (!rect) return null;
|
||||||
return regionBounds(projectRegionToViewport([rectPolygon(rect)], projection));
|
return regionBounds(projectRegionToViewport([rectPolygon(rect)], projection));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function childFrameProjection(
|
|
||||||
parent: GeometryProjection,
|
|
||||||
ownerRectInParent: { x: number; y: number; w: number; h: number },
|
|
||||||
): GeometryProjection {
|
|
||||||
const destinationQuad = rectPolygon(ownerRectInParent);
|
|
||||||
return {
|
|
||||||
sourceClips: [],
|
|
||||||
edges: [
|
|
||||||
{
|
|
||||||
sourceViewport: { width: ownerRectInParent.w, height: ownerRectInParent.h },
|
|
||||||
destinationQuad,
|
|
||||||
destinationClips: parent.sourceClips,
|
|
||||||
},
|
|
||||||
...parent.edges,
|
|
||||||
],
|
|
||||||
topViewport: parent.topViewport,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { type CdpTarget, cdpTargetKey } from "@/browser-driver/frame-graph";
|
||||||
|
import type { GeometryProjection, ViewportRect } from "../geometry";
|
||||||
|
import { projectRectToViewport } from "../geometry";
|
||||||
|
|
||||||
|
export interface CoordinateOwner {
|
||||||
|
target: CdpTarget;
|
||||||
|
frameId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrameViewportRect {
|
||||||
|
space: "frame-viewport-css";
|
||||||
|
owner: CoordinateOwner;
|
||||||
|
rect: ViewportRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnapshotProjection {
|
||||||
|
source: CoordinateOwner;
|
||||||
|
geometry: GeometryProjection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A failed owner read is different from a successfully projected, clipped-out rect. */
|
||||||
|
export interface UnavailableFrameProjection {
|
||||||
|
status: "unavailable";
|
||||||
|
source: CoordinateOwner;
|
||||||
|
ownerBackendNodeId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnavailableSnapshotCoordinates {
|
||||||
|
status: "unavailable";
|
||||||
|
source: CoordinateOwner;
|
||||||
|
reason: "snapshot-coordinates-unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SnapshotProjectionResult =
|
||||||
|
| { status: "available"; projection: SnapshotProjection }
|
||||||
|
| UnavailableFrameProjection;
|
||||||
|
|
||||||
|
/** Descendants inherit the failed boundary without attempting another owner read. */
|
||||||
|
export type FrameProjectionIssue =
|
||||||
|
| UnavailableFrameProjection
|
||||||
|
| UnavailableSnapshotCoordinates
|
||||||
|
| {
|
||||||
|
status: "blocked";
|
||||||
|
source: CoordinateOwner;
|
||||||
|
cause: UnavailableFrameProjection | UnavailableSnapshotCoordinates;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FrameProjectionState = SnapshotProjectionResult | FrameProjectionIssue;
|
||||||
|
|
||||||
|
export interface SnapshotCoordinates {
|
||||||
|
layoutUnitsPerCssPixel: number;
|
||||||
|
scrollCss: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot scroll offsets share the raw layout units of its bounds. Only a
|
||||||
|
* target's root document may fall back to that target's CSS layout viewport. */
|
||||||
|
export function snapshotCoordinates(
|
||||||
|
document: { scrollOffsetX?: number; scrollOffsetY?: number },
|
||||||
|
layoutUnitsPerCssPixel: number | null,
|
||||||
|
rootScrollCss?: { x?: number; y?: number },
|
||||||
|
): SnapshotCoordinates | null {
|
||||||
|
if (
|
||||||
|
layoutUnitsPerCssPixel === null ||
|
||||||
|
!Number.isFinite(layoutUnitsPerCssPixel) ||
|
||||||
|
layoutUnitsPerCssPixel <= 0
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const x =
|
||||||
|
document.scrollOffsetX === undefined
|
||||||
|
? rootScrollCss?.x
|
||||||
|
: document.scrollOffsetX / layoutUnitsPerCssPixel;
|
||||||
|
const y =
|
||||||
|
document.scrollOffsetY === undefined
|
||||||
|
? rootScrollCss?.y
|
||||||
|
: document.scrollOffsetY / layoutUnitsPerCssPixel;
|
||||||
|
if (x === undefined || y === undefined || !Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||||||
|
return { layoutUnitsPerCssPixel, scrollCss: { x, y } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert raw document-relative Blink layout units exactly once, before any
|
||||||
|
* CSS viewport clipping or frame projection. This scale is not screenshot zoom. */
|
||||||
|
export function snapshotViewportRect(
|
||||||
|
bounds: number[],
|
||||||
|
owner: CoordinateOwner,
|
||||||
|
coordinates: SnapshotCoordinates | null,
|
||||||
|
): FrameViewportRect | null {
|
||||||
|
if (!coordinates || bounds.length < 4 || !bounds.slice(0, 4).every(Number.isFinite)) return null;
|
||||||
|
const { layoutUnitsPerCssPixel: scale, scrollCss: scroll } = coordinates;
|
||||||
|
const [x, y, width, height] = bounds;
|
||||||
|
if (
|
||||||
|
!Number.isFinite(scale) ||
|
||||||
|
scale <= 0 ||
|
||||||
|
width <= 0 ||
|
||||||
|
height <= 0 ||
|
||||||
|
!Number.isFinite(scroll.x) ||
|
||||||
|
!Number.isFinite(scroll.y)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
space: "frame-viewport-css",
|
||||||
|
owner,
|
||||||
|
rect: {
|
||||||
|
x: x / scale - scroll.x,
|
||||||
|
y: y / scale - scroll.y,
|
||||||
|
width: width / scale,
|
||||||
|
height: height / scale,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectSnapshotRect(
|
||||||
|
input: FrameViewportRect,
|
||||||
|
projection: SnapshotProjection,
|
||||||
|
): ViewportRect | null {
|
||||||
|
if (
|
||||||
|
input.owner.frameId !== projection.source.frameId ||
|
||||||
|
cdpTargetKey(input.owner.target) !== cdpTargetKey(projection.source.target)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { x, y, width: w, height: h } = input.rect;
|
||||||
|
return projectRectToViewport({ x, y, w, h }, projection.geometry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSS viewport metadata; browser zoom is distinct from raster devicePixelRatio. */
|
||||||
|
export interface CssViewport {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
scrollX: number;
|
||||||
|
scrollY: number;
|
||||||
|
cssToDip: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page.captureScreenshot takes page-relative DIP, not viewport-relative CSS pixels. */
|
||||||
|
export function screenshotPageRect(
|
||||||
|
rect: ViewportRect,
|
||||||
|
viewport: CssViewport,
|
||||||
|
): { space: "page-dip"; rect: ViewportRect } | null {
|
||||||
|
const { cssToDip, scrollX, scrollY } = viewport;
|
||||||
|
if (
|
||||||
|
![rect.x, rect.y, rect.width, rect.height, cssToDip, scrollX, scrollY].every(Number.isFinite) ||
|
||||||
|
cssToDip <= 0 ||
|
||||||
|
rect.width <= 0 ||
|
||||||
|
rect.height <= 0
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return {
|
||||||
|
space: "page-dip",
|
||||||
|
rect: {
|
||||||
|
x: (rect.x + scrollX) * cssToDip,
|
||||||
|
y: (rect.y + scrollY) * cssToDip,
|
||||||
|
width: rect.width * cssToDip,
|
||||||
|
height: rect.height * cssToDip,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import {
|
||||||
|
type CdpFrame,
|
||||||
|
type CdpFrameGraph,
|
||||||
|
type CdpTarget,
|
||||||
|
cdpTargetKey,
|
||||||
|
} from "@/browser-driver/frame-graph";
|
||||||
|
import {
|
||||||
|
type GeometryProjection,
|
||||||
|
type Polygon,
|
||||||
|
type ProjectiveEdge,
|
||||||
|
parseCdpQuad,
|
||||||
|
type Quad,
|
||||||
|
type Size,
|
||||||
|
} from "../geometry";
|
||||||
|
import { type CdpRunner, sendToCdpTarget } from "../shared";
|
||||||
|
import type { CoordinateOwner, CssViewport, SnapshotProjectionResult } from "./coordinate-types";
|
||||||
|
|
||||||
|
import { readSnapshotOwnerSizes } from "./snapshot-owner-sizes";
|
||||||
|
|
||||||
|
export interface LayoutMetrics {
|
||||||
|
cssVisualViewport?: { zoom?: number; clientWidth?: number; clientHeight?: number };
|
||||||
|
visualViewport?: { zoom?: number; clientWidth?: number; clientHeight?: number };
|
||||||
|
cssLayoutViewport?: {
|
||||||
|
clientWidth?: number;
|
||||||
|
clientHeight?: number;
|
||||||
|
pageX?: number;
|
||||||
|
pageY?: number;
|
||||||
|
};
|
||||||
|
layoutViewport?: { clientWidth?: number; clientHeight?: number; pageX?: number; pageY?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chromium emits these paired floating-point dimensions before/after dividing
|
||||||
|
* by LayoutZoomFactor. Integer layout viewport dimensions lose precision at
|
||||||
|
* fractional zoom. Neither visualViewport.scale nor .zoom is this conversion.
|
||||||
|
* See InspectorPageAgent::getLayoutMetrics in Chromium. */
|
||||||
|
export function snapshotLayoutScale(metrics: LayoutMetrics): number | null {
|
||||||
|
for (const dimension of ["clientWidth", "clientHeight"] as const) {
|
||||||
|
const raw = metrics.visualViewport?.[dimension];
|
||||||
|
const css = metrics.cssVisualViewport?.[dimension];
|
||||||
|
if (
|
||||||
|
raw !== undefined &&
|
||||||
|
css !== undefined &&
|
||||||
|
Number.isFinite(raw) &&
|
||||||
|
Number.isFinite(css) &&
|
||||||
|
raw > 0 &&
|
||||||
|
css > 0
|
||||||
|
) {
|
||||||
|
const scale = raw / css;
|
||||||
|
if (Number.isFinite(scale) && scale > 0) return scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The legacy field is a compatibility fallback, never a source of raster DPR. */
|
||||||
|
export function cssViewport(metrics: LayoutMetrics): CssViewport {
|
||||||
|
const value = metrics.cssLayoutViewport ?? metrics.layoutViewport;
|
||||||
|
return {
|
||||||
|
width: value?.clientWidth ?? 0,
|
||||||
|
height: value?.clientHeight ?? 0,
|
||||||
|
scrollX: value?.pageX ?? 0,
|
||||||
|
scrollY: value?.pageY ?? 0,
|
||||||
|
cssToDip: metrics.cssVisualViewport?.zoom ?? metrics.visualViewport?.zoom ?? 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One read-only measurement phase. Discard after scrolling or any later operation. */
|
||||||
|
export class GeometryContext {
|
||||||
|
private readonly metrics = new Map<string, Promise<LayoutMetrics>>();
|
||||||
|
private readonly owners = new Map<string, Promise<Quad | null>>();
|
||||||
|
private readonly projections = new Map<string, Promise<GeometryProjection | null>>();
|
||||||
|
private readonly snapshotOwners = new Map<string, Promise<{ quad: Quad; size: Size } | null>>();
|
||||||
|
private readonly sizeBatches = new Map<string, () => Promise<Map<number, Size>>>();
|
||||||
|
private active = 0;
|
||||||
|
private readonly waiting: Array<() => void> = [];
|
||||||
|
private graphPromise?: Promise<CdpFrameGraph | null>;
|
||||||
|
private framesPromise?: Promise<Map<string, CdpFrame>>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly cdp: CdpRunner,
|
||||||
|
private readonly tabId: number,
|
||||||
|
graph?: CdpFrameGraph,
|
||||||
|
private readonly signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
if (graph) this.graphPromise = Promise.resolve(graph);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
target: CdpTarget,
|
||||||
|
method: string,
|
||||||
|
params: object,
|
||||||
|
cleanup = false,
|
||||||
|
): Promise<T> {
|
||||||
|
if (!cleanup && this.signal?.aborted) throw new DOMException("geometry aborted", "AbortError");
|
||||||
|
if (this.active >= 4) await new Promise<void>((resolve) => this.waiting.push(resolve));
|
||||||
|
else this.active++;
|
||||||
|
try {
|
||||||
|
if (!cleanup && this.signal?.aborted)
|
||||||
|
throw new DOMException("geometry aborted", "AbortError");
|
||||||
|
return await sendToCdpTarget<T>(this.cdp, target, method, params);
|
||||||
|
} finally {
|
||||||
|
const next = this.waiting.shift();
|
||||||
|
if (next) next();
|
||||||
|
else this.active--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
graph(): Promise<CdpFrameGraph | null> {
|
||||||
|
this.graphPromise ??=
|
||||||
|
this.cdp.getFrameGraph?.(this.tabId).catch(() => null) ?? Promise.resolve(null);
|
||||||
|
return this.graphPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private frames(): Promise<Map<string, CdpFrame>> {
|
||||||
|
this.framesPromise ??= this.graph().then(
|
||||||
|
(graph) => new Map(graph?.frames.map((frame) => [frame.frameId, frame]) ?? []),
|
||||||
|
);
|
||||||
|
return this.framesPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async frame(frameId: string): Promise<CdpFrame | undefined> {
|
||||||
|
return (await this.frames()).get(frameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Topology only: safe to resolve before the caller scrolls frame owners. */
|
||||||
|
async ancestry(frameId: string): Promise<CdpFrame[] | null> {
|
||||||
|
const frames = await this.frames();
|
||||||
|
const path: CdpFrame[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let current = frames.get(frameId);
|
||||||
|
if (!current) return null;
|
||||||
|
while (current.parentFrameId) {
|
||||||
|
if (seen.has(current.frameId)) return null;
|
||||||
|
seen.add(current.frameId);
|
||||||
|
path.push(current);
|
||||||
|
const parent = frames.get(current.parentFrameId);
|
||||||
|
if (!parent) return null;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutMetrics(target: CdpTarget): Promise<LayoutMetrics> {
|
||||||
|
const key = cdpTargetKey(target);
|
||||||
|
let promise = this.metrics.get(key);
|
||||||
|
if (!promise) {
|
||||||
|
promise = this.request<LayoutMetrics>(target, "Page.getLayoutMetrics", {});
|
||||||
|
this.metrics.set(key, promise);
|
||||||
|
}
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async viewport(target: CdpTarget): Promise<Size | null> {
|
||||||
|
const { width, height } = cssViewport(await this.layoutMetrics(target));
|
||||||
|
return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0
|
||||||
|
? { width, height }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerContent(target: CdpTarget, backendNodeId: number): Promise<Quad | null> {
|
||||||
|
const key = `${cdpTargetKey(target)}:${backendNodeId}`;
|
||||||
|
let promise = this.owners.get(key);
|
||||||
|
if (!promise) {
|
||||||
|
promise = this.request<{ model?: { content?: number[] } }>(target, "DOM.getBoxModel", {
|
||||||
|
backendNodeId,
|
||||||
|
}).then((reply) => parseCdpQuad(reply.model?.content));
|
||||||
|
this.owners.set(key, promise);
|
||||||
|
}
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same-target owner quads are already target-relative, including nested owners. */
|
||||||
|
async snapshotProjection(
|
||||||
|
source: CoordinateOwner,
|
||||||
|
ownerBackendNodeId: number,
|
||||||
|
ancestorClips: Polygon[],
|
||||||
|
viewport: Size,
|
||||||
|
): Promise<SnapshotProjectionResult> {
|
||||||
|
const key = `${cdpTargetKey(source.target)}:${ownerBackendNodeId}`;
|
||||||
|
let promise = this.snapshotOwners.get(key);
|
||||||
|
if (!promise) {
|
||||||
|
promise = this.snapshotOwner(source.target, ownerBackendNodeId);
|
||||||
|
this.snapshotOwners.set(key, promise);
|
||||||
|
}
|
||||||
|
let owner: { quad: Quad; size: Size } | null;
|
||||||
|
try {
|
||||||
|
owner = await promise;
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === "object" && "name" in error && error.name === "AbortError")
|
||||||
|
throw error;
|
||||||
|
console.debug("[bsk geometry] frame owner unavailable", error);
|
||||||
|
owner = null;
|
||||||
|
}
|
||||||
|
return owner
|
||||||
|
? {
|
||||||
|
status: "available",
|
||||||
|
projection: {
|
||||||
|
source,
|
||||||
|
geometry: {
|
||||||
|
sourceClips: [],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
sourceViewport: owner.size,
|
||||||
|
destinationQuad: owner.quad,
|
||||||
|
destinationClips: ancestorClips,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
topViewport: viewport,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { status: "unavailable", source, ownerBackendNodeId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register one target's snapshot owners; the batch starts only when a
|
||||||
|
* projection actually needs a size. Single owners keep the cheaper old path. */
|
||||||
|
registerSnapshotOwners(target: CdpTarget, owners: ReadonlySet<number>): void {
|
||||||
|
const key = cdpTargetKey(target);
|
||||||
|
if (owners.size < 2 || this.sizeBatches.has(key)) return;
|
||||||
|
let pending: Promise<Map<number, Size>> | undefined;
|
||||||
|
this.sizeBatches.set(key, () => {
|
||||||
|
pending ??= readSnapshotOwnerSizes(
|
||||||
|
(method, params, cleanup) => this.request(target, method, params, cleanup),
|
||||||
|
owners,
|
||||||
|
).catch((error) => {
|
||||||
|
if (error && typeof error === "object" && "name" in error && error.name === "AbortError")
|
||||||
|
throw error;
|
||||||
|
return new Map<number, Size>();
|
||||||
|
});
|
||||||
|
return pending;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async snapshotOwner(
|
||||||
|
target: CdpTarget,
|
||||||
|
backendNodeId: number,
|
||||||
|
): Promise<{ quad: Quad; size: Size } | null> {
|
||||||
|
const quad = await this.ownerContent(target, backendNodeId);
|
||||||
|
if (!quad) return null;
|
||||||
|
const batchSize = (await this.sizeBatches.get(cdpTargetKey(target))?.())?.get(backendNodeId);
|
||||||
|
if (this.signal?.aborted) throw new DOMException("geometry aborted", "AbortError");
|
||||||
|
if (batchSize) return { quad, size: batchSize };
|
||||||
|
const resolved = await this.request<{ object?: { objectId?: string } }>(
|
||||||
|
target,
|
||||||
|
"DOM.resolveNode",
|
||||||
|
{ backendNodeId },
|
||||||
|
);
|
||||||
|
const objectId = resolved.object?.objectId;
|
||||||
|
if (!objectId) return null;
|
||||||
|
try {
|
||||||
|
const reply = await this.request<{ result?: { value?: Size }; exceptionDetails?: unknown }>(
|
||||||
|
target,
|
||||||
|
"Runtime.callFunctionOn",
|
||||||
|
{
|
||||||
|
objectId,
|
||||||
|
functionDeclaration: `function() {
|
||||||
|
if (!this.isConnected) return null;
|
||||||
|
const style = getComputedStyle(this);
|
||||||
|
return {
|
||||||
|
width: this.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight),
|
||||||
|
height: this.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
|
||||||
|
};
|
||||||
|
}`,
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const size = reply.result?.value;
|
||||||
|
return !reply.exceptionDetails &&
|
||||||
|
size &&
|
||||||
|
Number.isFinite(size.width) &&
|
||||||
|
Number.isFinite(size.height) &&
|
||||||
|
size.width > 0 &&
|
||||||
|
size.height > 0
|
||||||
|
? { quad, size }
|
||||||
|
: null;
|
||||||
|
} finally {
|
||||||
|
// Cleanup must still run if cancellation arrived after resolving the object.
|
||||||
|
await this.request(target, "Runtime.releaseObject", { objectId }, true).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private targetRoot(frames: Map<string, CdpFrame>, frame: CdpFrame): CdpFrame | null {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let current = frame;
|
||||||
|
while (current.parentFrameId) {
|
||||||
|
if (seen.has(current.frameId)) return null;
|
||||||
|
seen.add(current.frameId);
|
||||||
|
const parent = frames.get(current.parentFrameId);
|
||||||
|
if (!parent) return null;
|
||||||
|
if (cdpTargetKey(parent.target) !== cdpTargetKey(current.target)) break;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clips(
|
||||||
|
frames: Map<string, CdpFrame>,
|
||||||
|
frame: CdpFrame,
|
||||||
|
root: CdpFrame,
|
||||||
|
): Promise<Polygon[] | null> {
|
||||||
|
const clips: Polygon[] = [];
|
||||||
|
let current = frame;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
while (current.frameId !== root.frameId) {
|
||||||
|
if (seen.has(current.frameId) || !current.parentFrameId) return null;
|
||||||
|
seen.add(current.frameId);
|
||||||
|
const parent = frames.get(current.parentFrameId);
|
||||||
|
if (!parent || current.ownerBackendNodeId === undefined) return null;
|
||||||
|
const quad = await this.ownerContent(parent.target, current.ownerBackendNodeId);
|
||||||
|
if (!quad) return null;
|
||||||
|
clips.push(quad);
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
return clips;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Input is already in the frame's CDP target viewport, not its local viewport. */
|
||||||
|
targetProjection(frameId: string): Promise<GeometryProjection | null> {
|
||||||
|
let promise = this.projections.get(frameId);
|
||||||
|
if (!promise) {
|
||||||
|
promise = this.buildTargetProjection(frameId);
|
||||||
|
this.projections.set(frameId, promise);
|
||||||
|
}
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildTargetProjection(frameId: string): Promise<GeometryProjection | null> {
|
||||||
|
const frames = await this.frames();
|
||||||
|
const frame = frames.get(frameId);
|
||||||
|
if (!frame) return null;
|
||||||
|
let root = this.targetRoot(frames, frame);
|
||||||
|
if (!root) return null;
|
||||||
|
const sourceViewport = await this.viewport(root.target);
|
||||||
|
if (!sourceViewport) return null;
|
||||||
|
const sourceClips = await this.clips(frames, frame, root);
|
||||||
|
if (!sourceClips) return null;
|
||||||
|
const edges: ProjectiveEdge[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
while (root.parentFrameId) {
|
||||||
|
if (seen.has(root.frameId)) return null;
|
||||||
|
seen.add(root.frameId);
|
||||||
|
const parent = frames.get(root.parentFrameId);
|
||||||
|
if (!parent || root.ownerBackendNodeId === undefined) return null;
|
||||||
|
const destinationQuad = await this.ownerContent(parent.target, root.ownerBackendNodeId);
|
||||||
|
const source = await this.viewport(root.target);
|
||||||
|
const parentRoot = this.targetRoot(frames, parent);
|
||||||
|
if (!destinationQuad || !source || !parentRoot) return null;
|
||||||
|
const destinationClips = await this.clips(frames, parent, parentRoot);
|
||||||
|
if (!destinationClips) return null;
|
||||||
|
edges.push({ sourceViewport: source, destinationQuad, destinationClips });
|
||||||
|
root = parentRoot;
|
||||||
|
}
|
||||||
|
const topViewport = await this.viewport(root.target);
|
||||||
|
return topViewport ? { sourceClips, edges, topViewport } : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { Size } from "../geometry";
|
||||||
|
|
||||||
|
interface SerializedValue {
|
||||||
|
type: string;
|
||||||
|
value?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Send = <T>(method: string, params: object, cleanup?: boolean) => Promise<T>;
|
||||||
|
|
||||||
|
/** Batch only accessible frame owners. Missing/shadow/cross-origin owners keep
|
||||||
|
* the existing node-scoped read; no page-wide style columns are requested. */
|
||||||
|
export async function readSnapshotOwnerSizes(
|
||||||
|
send: Send,
|
||||||
|
owners: ReadonlySet<number>,
|
||||||
|
): Promise<Map<number, Size>> {
|
||||||
|
const sizes = new Map<number, Size>();
|
||||||
|
const objectGroup = `bsk-frame-sizes-${crypto.randomUUID()}`;
|
||||||
|
try {
|
||||||
|
const reply = await send<{ result?: { deepSerializedValue?: SerializedValue } }>(
|
||||||
|
"Runtime.evaluate",
|
||||||
|
{
|
||||||
|
expression: `(() => {
|
||||||
|
const result = [];
|
||||||
|
const documents = [document];
|
||||||
|
for (let i = 0; i < documents.length && result.length < ${owners.size}; i++) {
|
||||||
|
for (const owner of documents[i].querySelectorAll('iframe,frame')) {
|
||||||
|
if (result.length >= ${owners.size}) break;
|
||||||
|
if (!owner.isConnected) continue;
|
||||||
|
const style = getComputedStyle(owner);
|
||||||
|
result.push([owner, JSON.stringify({
|
||||||
|
width: owner.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight),
|
||||||
|
height: owner.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
|
||||||
|
})]);
|
||||||
|
try { if (owner.contentDocument) documents.push(owner.contentDocument); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
})()`,
|
||||||
|
objectGroup,
|
||||||
|
serializationOptions: {
|
||||||
|
serialization: "deep",
|
||||||
|
maxDepth: 3,
|
||||||
|
additionalParameters: { maxNodeDepth: 0, includeShadowTree: "none" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const result = reply.result?.deepSerializedValue;
|
||||||
|
if (result?.type !== "array" || !Array.isArray(result.value)) return sizes;
|
||||||
|
for (const entry of result.value as SerializedValue[]) {
|
||||||
|
if (entry.type !== "array" || !Array.isArray(entry.value)) continue;
|
||||||
|
const [node, data] = entry.value as SerializedValue[];
|
||||||
|
if (node?.type !== "node" || data?.type !== "string" || typeof data.value !== "string")
|
||||||
|
continue;
|
||||||
|
const id = (node.value as { backendNodeId?: number } | undefined)?.backendNodeId;
|
||||||
|
if (id === undefined || !owners.has(id)) continue;
|
||||||
|
try {
|
||||||
|
const size = JSON.parse(data.value) as Size;
|
||||||
|
if (
|
||||||
|
size &&
|
||||||
|
Number.isFinite(size.width) &&
|
||||||
|
Number.isFinite(size.height) &&
|
||||||
|
size.width > 0 &&
|
||||||
|
size.height > 0
|
||||||
|
)
|
||||||
|
sizes.set(id, size);
|
||||||
|
} catch {
|
||||||
|
/* An invalid entry uses the existing individual read. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await send("Runtime.releaseObjectGroup", { objectGroup }, true).catch(() => {});
|
||||||
|
}
|
||||||
|
return sizes;
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
enforceAgentWindow,
|
enforceAgentWindow,
|
||||||
isRpcError,
|
isRpcError,
|
||||||
lookupSession,
|
lookupSession,
|
||||||
|
type ResolvedTargetTab,
|
||||||
resolveTargetTab,
|
resolveTargetTab,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
import { resolveSnapshotRef } from "./snapshot-ref";
|
import { resolveSnapshotRef } from "./snapshot-ref";
|
||||||
@@ -56,6 +57,15 @@ export interface InteractionDeps {
|
|||||||
keepOverlayBypassAfterHover?: boolean;
|
keepOverlayBypassAfterHover?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResolvedActionTarget {
|
||||||
|
tab: ResolvedTargetTab;
|
||||||
|
backendNodeId: number;
|
||||||
|
cdpTarget: CdpTarget;
|
||||||
|
frameId?: string;
|
||||||
|
usedRef?: string;
|
||||||
|
usedSelector?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||||
const DEFAULT_HOVER_SETTLE_MS = 200;
|
const DEFAULT_HOVER_SETTLE_MS = 200;
|
||||||
|
|
||||||
@@ -128,7 +138,7 @@ async function wait(ms: number, signal?: AbortSignal): Promise<void> {
|
|||||||
* `RpcError` if the caller supplied neither (or both), or if neither
|
* `RpcError` if the caller supplied neither (or both), or if neither
|
||||||
* lookup matched.
|
* lookup matched.
|
||||||
*/
|
*/
|
||||||
async function resolveBackendNode(
|
export async function resolveBackendNode(
|
||||||
cdp: CdpRunner,
|
cdp: CdpRunner,
|
||||||
ctx: SessionContext,
|
ctx: SessionContext,
|
||||||
target: { tabId: number },
|
target: { tabId: number },
|
||||||
@@ -173,6 +183,8 @@ async function resolveBackendNode(
|
|||||||
}
|
}
|
||||||
// selector path
|
// selector path
|
||||||
try {
|
try {
|
||||||
|
// Selector lookup itself attaches CDP, even when no element is found.
|
||||||
|
cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||||
const doc = await cdp.send<{ root?: { nodeId?: number } }>(target.tabId, "DOM.getDocument", {
|
const doc = await cdp.send<{ root?: { nodeId?: number } }>(target.tabId, "DOM.getDocument", {
|
||||||
depth: 0,
|
depth: 0,
|
||||||
});
|
});
|
||||||
@@ -215,6 +227,17 @@ async function resolveBackendNode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveActionTarget(
|
||||||
|
cdp: CdpRunner,
|
||||||
|
ctx: SessionContext,
|
||||||
|
target: ResolvedTargetTab,
|
||||||
|
params: { ref?: string; selector?: string },
|
||||||
|
toolName: string,
|
||||||
|
): Promise<ResolvedActionTarget | RpcError> {
|
||||||
|
const node = await resolveBackendNode(cdp, ctx, target, params, toolName);
|
||||||
|
return isRpcError(node) ? node : { tab: target, ...node };
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// tool.click
|
// tool.click
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -233,10 +256,19 @@ export async function handleClick(
|
|||||||
if (isRpcError(target)) return target;
|
if (isRpcError(target)) return target;
|
||||||
const denied = enforceAgentWindow(ctx, target, "click");
|
const denied = enforceAgentWindow(ctx, target, "click");
|
||||||
if (denied) return denied;
|
if (denied) return denied;
|
||||||
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
|
const resolved = await resolveActionTarget(deps.cdp, ctx, target, params, "click");
|
||||||
|
if (isRpcError(resolved)) return resolved;
|
||||||
|
return clickResolvedTarget(ctx, resolved, params, deps);
|
||||||
|
}
|
||||||
|
|
||||||
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "click");
|
export async function clickResolvedTarget(
|
||||||
if (isRpcError(node)) return node;
|
ctx: SessionContext,
|
||||||
|
resolved: ResolvedActionTarget,
|
||||||
|
params: Pick<ClickParams, "button" | "click_count" | "modifiers">,
|
||||||
|
deps: InteractionDeps,
|
||||||
|
): Promise<ClickResult | RpcError> {
|
||||||
|
const { tab: target } = resolved;
|
||||||
|
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
|
||||||
|
|
||||||
if (throwIfAborted(deps.signal)) {
|
if (throwIfAborted(deps.signal)) {
|
||||||
return { code: "cancelled", message: "click aborted" };
|
return { code: "cancelled", message: "click aborted" };
|
||||||
@@ -247,9 +279,9 @@ export async function handleClick(
|
|||||||
deps.cdp,
|
deps.cdp,
|
||||||
target.tabId,
|
target.tabId,
|
||||||
{
|
{
|
||||||
target: node.cdpTarget,
|
target: resolved.cdpTarget,
|
||||||
backendNodeId: node.backendNodeId,
|
backendNodeId: resolved.backendNodeId,
|
||||||
...(node.frameId ? { frameId: node.frameId } : {}),
|
...(resolved.frameId ? { frameId: resolved.frameId } : {}),
|
||||||
},
|
},
|
||||||
{ scrollIntoView: true },
|
{ scrollIntoView: true },
|
||||||
);
|
);
|
||||||
@@ -337,8 +369,8 @@ export async function handleClick(
|
|||||||
|
|
||||||
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
||||||
tab_id: target.tabId,
|
tab_id: target.tabId,
|
||||||
used_ref: node.usedRef,
|
used_ref: resolved.usedRef,
|
||||||
used_selector: node.usedSelector,
|
used_selector: resolved.usedSelector,
|
||||||
x: centre.x,
|
x: centre.x,
|
||||||
y: centre.y,
|
y: centre.y,
|
||||||
});
|
});
|
||||||
@@ -577,6 +609,53 @@ type SelectMutationResult =
|
|||||||
// tool.fill
|
// tool.fill
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type FillFailureReason =
|
||||||
|
| "target_not_fillable"
|
||||||
|
| "fill_value_invalid"
|
||||||
|
| "fill_target_changed"
|
||||||
|
| "fill_focus_lost"
|
||||||
|
| "fill_value_mismatch"
|
||||||
|
| "fill_failed";
|
||||||
|
|
||||||
|
type FillPreparation =
|
||||||
|
| { before: string; expected: string }
|
||||||
|
| { reason: FillFailureReason; error: string };
|
||||||
|
|
||||||
|
type FillReadiness = "ready" | "background" | "fill_target_changed" | "fill_focus_lost";
|
||||||
|
|
||||||
|
function fillError(reason: FillFailureReason, message: string): RpcError {
|
||||||
|
return rpcError(
|
||||||
|
reason === "target_not_fillable" || reason === "fill_value_invalid"
|
||||||
|
? "invalid_params"
|
||||||
|
: "cdp_failed",
|
||||||
|
reason,
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FillScriptReply<T> {
|
||||||
|
result?: { value?: T };
|
||||||
|
exceptionDetails?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILL_EDITABLE_FUNCTION = `function() {
|
||||||
|
const tag = this.tagName.toLowerCase();
|
||||||
|
const supported = tag === 'input'
|
||||||
|
? ['text', 'search', 'tel', 'url', 'email', 'password', 'number'].includes(this.type)
|
||||||
|
: tag === 'textarea' || this.isContentEditable;
|
||||||
|
return this.isConnected && supported && !this.readOnly && !this.matches(':disabled');
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// Chrome renders a trailing editable newline with an empty <div><br></div>.
|
||||||
|
// innerText includes the padding break; it is not an extra typed character.
|
||||||
|
const FILL_VALUE_FUNCTION = `function() {
|
||||||
|
if (!this.isContentEditable) return this.value;
|
||||||
|
const value = this.innerText;
|
||||||
|
const tail = this.lastChild;
|
||||||
|
const padding = tail && tail.nodeName === 'DIV' && tail.childNodes.length === 1 && tail.firstChild.nodeName === 'BR';
|
||||||
|
return padding && value.endsWith('\\n\\n') ? value.slice(0, -1) : value;
|
||||||
|
}`;
|
||||||
|
|
||||||
export async function handleFill(
|
export async function handleFill(
|
||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
params: FillParams,
|
params: FillParams,
|
||||||
@@ -628,12 +707,8 @@ export async function handleFill(
|
|||||||
if (throwIfAborted(deps.signal)) {
|
if (throwIfAborted(deps.signal)) {
|
||||||
return { code: "cancelled", message: "fill aborted" };
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
}
|
}
|
||||||
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return fillError("fill_failed", err instanceof Error ? err.message : String(err));
|
||||||
code: "cdp_failed",
|
|
||||||
message: err instanceof Error ? err.message : String(err),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwIfAborted(deps.signal)) {
|
if (throwIfAborted(deps.signal)) {
|
||||||
@@ -646,57 +721,252 @@ export async function handleFill(
|
|||||||
const clearBefore = params.clear_before ?? true;
|
const clearBefore = params.clear_before ?? true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (clearBefore) {
|
if (throwIfAborted(deps.signal)) {
|
||||||
// Clear input/textarea value or wipe contenteditable innerText,
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
// then fire `input` so frameworks observe the empty state.
|
}
|
||||||
await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", {
|
try {
|
||||||
objectId,
|
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
|
||||||
functionDeclaration: `function() {
|
} catch (error) {
|
||||||
if (this.isContentEditable) { this.textContent = ''; }
|
// Chrome rejects DOM.focus for disabled controls, including inherited
|
||||||
else {
|
// fieldset state. Classify the live target before treating this as a
|
||||||
const proto = this instanceof HTMLTextAreaElement
|
// browser failure; the normal path needs no extra round trip.
|
||||||
? HTMLTextAreaElement.prototype
|
const editable = await nodeCdp.send<FillScriptReply<boolean>>(
|
||||||
: HTMLInputElement.prototype;
|
target.tabId,
|
||||||
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
|
"Runtime.callFunctionOn",
|
||||||
if (descriptor && descriptor.set) descriptor.set.call(this, '');
|
{ objectId, functionDeclaration: FILL_EDITABLE_FUNCTION, returnByValue: true },
|
||||||
else this.value = '';
|
);
|
||||||
}
|
if (throwIfAborted(deps.signal)) {
|
||||||
this.dispatchEvent(new Event('input', { bubbles: true }));
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
}`,
|
}
|
||||||
returnByValue: true,
|
if (!editable.exceptionDetails && editable.result?.value === false) {
|
||||||
});
|
return fillError(
|
||||||
|
"target_not_fillable",
|
||||||
|
"fill target is not editable or its input type is unsupported",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
if (throwIfAborted(deps.signal)) {
|
if (throwIfAborted(deps.signal)) {
|
||||||
return { code: "cancelled", message: "fill aborted" };
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
}
|
}
|
||||||
|
// Check editability before clearing. Keep the expected result tied to
|
||||||
|
// this object, including the existing value on the append path.
|
||||||
|
const prepared = await nodeCdp.send<FillScriptReply<FillPreparation>>(
|
||||||
|
target.tabId,
|
||||||
|
"Runtime.callFunctionOn",
|
||||||
|
{
|
||||||
|
objectId,
|
||||||
|
functionDeclaration: `function(value, clearBefore) {
|
||||||
|
const tag = this.tagName.toLowerCase();
|
||||||
|
const native = tag === 'input' || tag === 'textarea';
|
||||||
|
if (!(${FILL_EDITABLE_FUNCTION}).call(this)) return { reason: 'target_not_fillable', error: 'fill target is not editable or its input type is unsupported' };
|
||||||
|
if (this.getRootNode().activeElement !== this) return { reason: 'fill_focus_lost', error: 'fill target does not have focus' };
|
||||||
|
const before = clearBefore ? '' : (${FILL_VALUE_FUNCTION}).call(this);
|
||||||
|
let expected = before + value;
|
||||||
|
if (native) {
|
||||||
|
// Use the browser's own value sanitization (e.g. textarea
|
||||||
|
// line endings) without changing the live control.
|
||||||
|
const normalizer = this.ownerDocument.createElement(tag);
|
||||||
|
if (tag === 'input') {
|
||||||
|
normalizer.type = this.type;
|
||||||
|
normalizer.multiple = this.multiple;
|
||||||
|
}
|
||||||
|
// insertText treats line breaks in a single-line input as spaces.
|
||||||
|
if (tag === 'input') expected = expected.replace(/\\r\\n|\\r|\\n/g, ' ');
|
||||||
|
normalizer.value = expected;
|
||||||
|
if (expected !== '' && normalizer.value === '' && this.type === 'number') {
|
||||||
|
return { reason: 'fill_value_invalid', error: 'fill value is not valid for a number input' };
|
||||||
|
}
|
||||||
|
expected = normalizer.value;
|
||||||
|
if (this.type !== 'number' && this.maxLength >= 0 && expected.length > this.maxLength) {
|
||||||
|
return { reason: 'fill_value_invalid', error: 'fill value exceeds the target maxlength' };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expected = expected.replace(/\\r\\n?/g, '\\n');
|
||||||
|
}
|
||||||
|
if (clearBefore) {
|
||||||
|
if (native) {
|
||||||
|
const proto = tag === 'textarea' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||||
|
Object.getOwnPropertyDescriptor(proto, 'value').set.call(this, '');
|
||||||
|
} else {
|
||||||
|
this.textContent = '';
|
||||||
|
}
|
||||||
|
this.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
return { before, expected };
|
||||||
|
}`,
|
||||||
|
arguments: [{ value: params.value }, { value: clearBefore }],
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
if (prepared.exceptionDetails) {
|
||||||
|
return fillError("fill_failed", "fill preparation script failed");
|
||||||
|
}
|
||||||
|
const preparation = prepared.result?.value;
|
||||||
|
if (preparation && "error" in preparation) {
|
||||||
|
return fillError(preparation.reason, preparation.error);
|
||||||
|
}
|
||||||
|
if (typeof preparation?.before !== "string" || typeof preparation?.expected !== "string") {
|
||||||
|
return fillError("fill_failed", "fill preparation returned an unexpected result");
|
||||||
|
}
|
||||||
|
// A separate call runs after the clearing event's microtasks drain.
|
||||||
|
// DOM focus works in background tabs. Use document.hasFocus() only to
|
||||||
|
// detect deferred focus events, never to reject background input.
|
||||||
|
const checkReady = async (): Promise<FillReadiness | RpcError> => {
|
||||||
|
const reply = await nodeCdp.send<FillScriptReply<FillReadiness>>(
|
||||||
|
target.tabId,
|
||||||
|
"Runtime.callFunctionOn",
|
||||||
|
{
|
||||||
|
objectId,
|
||||||
|
functionDeclaration: `function(before) {
|
||||||
|
if (!(${FILL_EDITABLE_FUNCTION}).call(this) ||
|
||||||
|
(${FILL_VALUE_FUNCTION}).call(this) !== before) return 'fill_target_changed';
|
||||||
|
if (this.getRootNode().activeElement !== this) return 'fill_focus_lost';
|
||||||
|
return this.ownerDocument.hasFocus() ? 'ready' : 'background';
|
||||||
|
}`,
|
||||||
|
arguments: [{ value: preparation.before }],
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const state = reply.result?.value;
|
||||||
|
if (
|
||||||
|
reply.exceptionDetails ||
|
||||||
|
(state !== "ready" &&
|
||||||
|
state !== "background" &&
|
||||||
|
state !== "fill_target_changed" &&
|
||||||
|
state !== "fill_focus_lost")
|
||||||
|
) {
|
||||||
|
return fillError(
|
||||||
|
"fill_failed",
|
||||||
|
"fill readiness script failed or returned an unexpected result",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
let ready = await checkReady();
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
// Restore focus once only if clearing left this same target editable
|
||||||
|
// and unchanged. Recheck after focus handlers have run.
|
||||||
|
if (clearBefore && ready === "fill_focus_lost") {
|
||||||
|
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
ready = await checkReady();
|
||||||
|
}
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
if (typeof ready !== "string") return ready;
|
||||||
|
if (ready !== "ready" && ready !== "background") {
|
||||||
|
return fillError(ready, "fill target changed or lost focus before typing");
|
||||||
|
}
|
||||||
|
if (params.value !== "" && (!clearBefore || ready === "background")) {
|
||||||
|
// Native editing commands also support number/email inputs, whose
|
||||||
|
// selection APIs cannot set a caret, and multiline contenteditables.
|
||||||
|
// Also do this for background replacement: CDP input focuses the
|
||||||
|
// renderer, delivering deferred focus events before it inserts text.
|
||||||
|
// Use a command-only event so this does not invoke an End shortcut.
|
||||||
|
try {
|
||||||
|
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
|
||||||
|
type: "rawKeyDown",
|
||||||
|
commands: ["moveToEndOfDocument"],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", { type: "keyUp" });
|
||||||
|
}
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
// Focus and key handlers can change the target or move focus too.
|
||||||
|
ready = await checkReady();
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
if (typeof ready !== "string") return ready;
|
||||||
|
if (ready !== "ready" && ready !== "background") {
|
||||||
|
return fillError(ready, "fill target changed or lost focus while positioning the caret");
|
||||||
|
}
|
||||||
|
}
|
||||||
// CDP `Input.insertText` handles IME / multi-byte input out of the
|
// CDP `Input.insertText` handles IME / multi-byte input out of the
|
||||||
// box, much more reliably than per-key `dispatchKeyEvent`.
|
// box, much more reliably than per-key `dispatchKeyEvent`.
|
||||||
await deps.cdp.send(target.tabId, "Input.insertText", { text: params.value });
|
if (params.value !== "") {
|
||||||
|
await deps.cdp.send(target.tabId, "Input.insertText", { text: params.value });
|
||||||
|
}
|
||||||
if (throwIfAborted(deps.signal)) {
|
if (throwIfAborted(deps.signal)) {
|
||||||
return { code: "cancelled", message: "fill aborted" };
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
}
|
}
|
||||||
// Fire `input` + `change` so React / Vue controlled inputs commit.
|
// Keep the existing notifications, then read in a separate call so
|
||||||
await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", {
|
// nested microtasks queued by the page cannot race the verification.
|
||||||
|
const notified = await nodeCdp.send<FillScriptReply<unknown>>(
|
||||||
|
target.tabId,
|
||||||
|
"Runtime.callFunctionOn",
|
||||||
|
{
|
||||||
|
objectId,
|
||||||
|
functionDeclaration: `function() {
|
||||||
|
this.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}`,
|
||||||
|
returnByValue: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
if (notified.exceptionDetails) {
|
||||||
|
return fillError("fill_failed", "fill notification script failed");
|
||||||
|
}
|
||||||
|
const verified = await nodeCdp.send<
|
||||||
|
FillScriptReply<{ connected: boolean; matches: boolean; valueLength: number }>
|
||||||
|
>(target.tabId, "Runtime.callFunctionOn", {
|
||||||
objectId,
|
objectId,
|
||||||
functionDeclaration: `function() {
|
functionDeclaration: `function(expected) {
|
||||||
this.dispatchEvent(new Event('input', { bubbles: true }));
|
const value = (${FILL_VALUE_FUNCTION}).call(this);
|
||||||
this.dispatchEvent(new Event('change', { bubbles: true }));
|
return { connected: this.isConnected, matches: value === expected, valueLength: value.length };
|
||||||
}`,
|
}`,
|
||||||
|
arguments: [{ value: preparation.expected }],
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
});
|
});
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
const verification = verified.result?.value;
|
||||||
|
if (
|
||||||
|
verified.exceptionDetails ||
|
||||||
|
typeof verification?.connected !== "boolean" ||
|
||||||
|
typeof verification?.matches !== "boolean" ||
|
||||||
|
typeof verification?.valueLength !== "number"
|
||||||
|
) {
|
||||||
|
return fillError(
|
||||||
|
"fill_failed",
|
||||||
|
"fill verification script failed or returned an unexpected result",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!verification.connected) {
|
||||||
|
return fillError("fill_target_changed", "fill target was removed or replaced during input");
|
||||||
|
}
|
||||||
|
if (!verification.matches) {
|
||||||
|
return fillError(
|
||||||
|
"fill_value_mismatch",
|
||||||
|
"fill could not verify the expected value; observe the page before deciding whether to retry",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
||||||
|
tab_id: target.tabId,
|
||||||
|
used_ref: node.usedRef,
|
||||||
|
used_selector: node.usedSelector,
|
||||||
|
value_length: verification.valueLength,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return fillError("fill_failed", err instanceof Error ? err.message : String(err));
|
||||||
code: "cdp_failed",
|
} finally {
|
||||||
message: err instanceof Error ? err.message : String(err),
|
await nodeCdp.send(target.tabId, "Runtime.releaseObject", { objectId }).catch(() => undefined);
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
|
||||||
tab_id: target.tabId,
|
|
||||||
used_ref: node.usedRef,
|
|
||||||
used_selector: node.usedSelector,
|
|
||||||
value_length: params.value.length,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -28,7 +28,13 @@ import type {
|
|||||||
RpcError,
|
RpcError,
|
||||||
WaitUntil,
|
WaitUntil,
|
||||||
} from "@/transport/types";
|
} from "@/transport/types";
|
||||||
|
import {
|
||||||
|
type BrowserNavigationApi,
|
||||||
|
chromeBrowserNavigationApi,
|
||||||
|
navigateWithBrowserApi,
|
||||||
|
} from "./browser-navigation";
|
||||||
import { attachDialogs, markDialogCursor } from "./dialogs";
|
import { attachDialogs, markDialogCursor } from "./dialogs";
|
||||||
|
import { cdpError, isCdpExtensionAccessDenied } from "./errors";
|
||||||
import {
|
import {
|
||||||
type CdpRunner,
|
type CdpRunner,
|
||||||
type ChromeTabsApi,
|
type ChromeTabsApi,
|
||||||
@@ -42,6 +48,7 @@ import {
|
|||||||
export interface NavigationDeps {
|
export interface NavigationDeps {
|
||||||
cdp: CdpRunner;
|
cdp: CdpRunner;
|
||||||
tabsApi: ChromeTabsApi;
|
tabsApi: ChromeTabsApi;
|
||||||
|
browserNavigation?: BrowserNavigationApi;
|
||||||
/** Optional AbortSignal — M7 abort hook (M10.2 will wire the full chain). */
|
/** Optional AbortSignal — M7 abort hook (M10.2 will wire the full chain). */
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
/** Override default timeout when the caller omits `timeout_ms`. */
|
/** Override default timeout when the caller omits `timeout_ms`. */
|
||||||
@@ -479,6 +486,81 @@ async function readTabUrl(api: ChromeTabsApi, tabId: number): Promise<string | u
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Recover only a failed preflight; never replay an already-dispatched navigation. */
|
||||||
|
async function recoverBrowserNavigation(
|
||||||
|
deps: NavigationDeps,
|
||||||
|
tabId: number,
|
||||||
|
action: (api: BrowserNavigationApi) => Promise<unknown>,
|
||||||
|
waitUntil: WaitUntil,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<ReloadResult | RpcError> {
|
||||||
|
const abort = linkedAbortSignal(deps.signal);
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
const api = deps.browserNavigation ?? chromeBrowserNavigationApi;
|
||||||
|
try {
|
||||||
|
let outcome = await navigateWithBrowserApi(
|
||||||
|
api,
|
||||||
|
tabId,
|
||||||
|
() => action(api),
|
||||||
|
waitUntil === "networkidle" ? "commit" : waitUntil,
|
||||||
|
timeoutMs,
|
||||||
|
abort.signal,
|
||||||
|
);
|
||||||
|
if (outcome.reached === "failed") return outcome.error;
|
||||||
|
if (outcome.reached === "cancelled" || abort.signal.aborted) {
|
||||||
|
return { code: "cancelled", message: "navigation recovery aborted" };
|
||||||
|
}
|
||||||
|
if (outcome.reached === "match") {
|
||||||
|
// A completed reload is not a recovery if the restricted frame remains.
|
||||||
|
await deps.cdp.send(tabId, "Page.enable", {});
|
||||||
|
if (abort.signal.aborted)
|
||||||
|
return { code: "cancelled", message: "navigation recovery aborted" };
|
||||||
|
if (waitUntil === "networkidle") {
|
||||||
|
// webNavigation cannot prove network idle. Subscribe on the new
|
||||||
|
// document before enabling CDP lifecycle events after its commit.
|
||||||
|
const frame = await readMainFrameInfo(deps.cdp, tabId);
|
||||||
|
if (!frame.frameId) return cdpError("no main frame after navigation");
|
||||||
|
const remaining = deadline - Date.now();
|
||||||
|
if (remaining <= 0) {
|
||||||
|
outcome = { reached: "timeout", lastLifecycle: "commit" };
|
||||||
|
} else {
|
||||||
|
const wait = startLifecycleWait(
|
||||||
|
deps.cdp,
|
||||||
|
tabId,
|
||||||
|
frame.frameId,
|
||||||
|
"networkIdle",
|
||||||
|
remaining,
|
||||||
|
abort.signal,
|
||||||
|
{ loaderId: frame.loaderId ?? undefined },
|
||||||
|
);
|
||||||
|
await deps.cdp.send(tabId, "Page.setLifecycleEventsEnabled", { enabled: true });
|
||||||
|
outcome = await wait.promise;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (outcome.reached === "cancelled" || abort.signal.aborted) {
|
||||||
|
return { code: "cancelled", message: "navigation recovery aborted" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tab_id: tabId,
|
||||||
|
final_url: await readTabUrl(deps.tabsApi, tabId),
|
||||||
|
reached: outcome.reached === "match" ? waitUntil : "timeout",
|
||||||
|
...(outcome.reached === "timeout"
|
||||||
|
? {
|
||||||
|
error_text: `timed out waiting for ${waitUntil} during navigation recovery after ${timeoutMs}ms`,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return abort.signal.aborted
|
||||||
|
? { code: "cancelled", message: "navigation recovery aborted" }
|
||||||
|
: cdpError(error);
|
||||||
|
} finally {
|
||||||
|
abort.abort();
|
||||||
|
abort.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// tool.navigate
|
// tool.navigate
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -505,7 +587,20 @@ export async function handleNavigate(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||||
await ensureCdpReady(deps.cdp, target.tabId);
|
try {
|
||||||
|
await ensureCdpReady(deps.cdp, target.tabId);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCdpExtensionAccessDenied(error)) throw error;
|
||||||
|
const recovered = await recoverBrowserNavigation(
|
||||||
|
deps,
|
||||||
|
target.tabId,
|
||||||
|
(api) => api.update(target.tabId, { url: params.url }),
|
||||||
|
waitUntil,
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
if (isRpcError(recovered)) return recovered;
|
||||||
|
return attachDialogs(deps.cdp, target.tabId, dialogCursor, { ...recovered, url: params.url });
|
||||||
|
}
|
||||||
const expected = cdpLifecycleName(waitUntil);
|
const expected = cdpLifecycleName(waitUntil);
|
||||||
const beforeReadyState = await probeMainFrameReadyState(deps.cdp, target.tabId);
|
const beforeReadyState = await probeMainFrameReadyState(deps.cdp, target.tabId);
|
||||||
const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId);
|
const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId);
|
||||||
@@ -574,10 +669,7 @@ export async function handleNavigate(
|
|||||||
}`,
|
}`,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return cdpError(err);
|
||||||
code: "cdp_failed",
|
|
||||||
message: err instanceof Error ? err.message : String(err),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -727,7 +819,24 @@ export async function handleReload(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||||
await ensureCdpReady(deps.cdp, target.tabId);
|
try {
|
||||||
|
await ensureCdpReady(deps.cdp, target.tabId);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCdpExtensionAccessDenied(error)) throw error;
|
||||||
|
const previousUrl = await readTabUrl(deps.tabsApi, target.tabId);
|
||||||
|
const recovered = await recoverBrowserNavigation(
|
||||||
|
deps,
|
||||||
|
target.tabId,
|
||||||
|
(api) => api.reload(target.tabId, { bypassCache: ignoreCache }),
|
||||||
|
waitUntil,
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
if (isRpcError(recovered)) return recovered;
|
||||||
|
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
||||||
|
...recovered,
|
||||||
|
previous_url: previousUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
const previousUrl = await readTabUrl(deps.tabsApi, target.tabId);
|
const previousUrl = await readTabUrl(deps.tabsApi, target.tabId);
|
||||||
const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId);
|
const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId);
|
||||||
const expected = cdpLifecycleName(waitUntil);
|
const expected = cdpLifecycleName(waitUntil);
|
||||||
@@ -776,10 +885,7 @@ export async function handleReload(
|
|||||||
}`,
|
}`,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return cdpError(err);
|
||||||
code: "cdp_failed",
|
|
||||||
message: err instanceof Error ? err.message : String(err),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { CapturedSceneInput } from "./vom/facts";
|
||||||
// Observation handlers — `tool.snapshot`, `tool.get_html`, `tool.screenshot`,
|
// Observation handlers — `tool.snapshot`, `tool.get_html`, `tool.screenshot`,
|
||||||
// and semantic `tool.observe` (design §7). Each handler resolves the target
|
// and semantic `tool.observe` (design §7). Each handler resolves the target
|
||||||
// tab (defaulting to the Agent Window's active tab when omitted) and
|
// tab (defaulting to the Agent Window's active tab when omitted) and
|
||||||
@@ -17,7 +18,7 @@ import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
|
|||||||
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
||||||
import {
|
import {
|
||||||
type CaptureSuppressSendToTab,
|
type CaptureSuppressSendToTab,
|
||||||
withOverlaysHiddenForCapture,
|
withExtensionOverlayHidden,
|
||||||
} from "@/lib/capture-suppress-bridge";
|
} from "@/lib/capture-suppress-bridge";
|
||||||
import type { SessionContext, SessionManager } from "@/session-manager/manager";
|
import type { SessionContext, SessionManager } from "@/session-manager/manager";
|
||||||
import type {
|
import type {
|
||||||
@@ -34,6 +35,7 @@ import type {
|
|||||||
import { attachDialogs, markDialogCursor } from "./dialogs";
|
import { attachDialogs, markDialogCursor } from "./dialogs";
|
||||||
import { rpcError } from "./errors";
|
import { rpcError } from "./errors";
|
||||||
import { resolveNodeGeometry } from "./frame-geometry";
|
import { resolveNodeGeometry } from "./frame-geometry";
|
||||||
|
import { screenshotPageRect } from "./geometry/coordinate-types";
|
||||||
import {
|
import {
|
||||||
type ChromeTabsApi,
|
type ChromeTabsApi,
|
||||||
enforceToolTargetScope,
|
enforceToolTargetScope,
|
||||||
@@ -47,13 +49,10 @@ import {
|
|||||||
type ToolEffect,
|
type ToolEffect,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
import { resolveSnapshotRef } from "./snapshot-ref";
|
import { resolveSnapshotRef } from "./snapshot-ref";
|
||||||
import {
|
import { type CapturedNode, type CapturedSurfaceProbe, probeHoverSurfaces } from "./vom/capture";
|
||||||
type CapturedNode,
|
import { captureObservationFacts, semanticCapture } from "./vom/capture-coordinator";
|
||||||
type CapturedViewModel,
|
import type { FrameDocument as CapturedFrameDocument } from "./vom/frame-document";
|
||||||
captureViewModel,
|
import { withOverlayBypass } from "./vom/hover-perception";
|
||||||
collectOverlayExcludedBackendIds,
|
|
||||||
} from "./vom/capture";
|
|
||||||
import { type CapturedFrameDocument, captureFrameData } from "./vom/frame-capture";
|
|
||||||
import { probeTooltipNames } from "./vom/name-enrichment";
|
import { probeTooltipNames } from "./vom/name-enrichment";
|
||||||
import {
|
import {
|
||||||
type CaptureVomObservationResult,
|
type CaptureVomObservationResult,
|
||||||
@@ -194,15 +193,14 @@ async function captureElementScreenshot(
|
|||||||
if (isRpcError(geometry)) return geometry;
|
if (isRpcError(geometry)) return geometry;
|
||||||
if (signal?.aborted) return cancelled("screenshot");
|
if (signal?.aborted) return cancelled("screenshot");
|
||||||
const rect = geometry.topBounds;
|
const rect = geometry.topBounds;
|
||||||
|
const clip = screenshotPageRect(rect, geometry.topViewport);
|
||||||
|
if (!clip) return { code: "cdp_failed", message: "invalid screenshot coordinate space" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const shot = await cdp.send<{ data?: string }>(tabId, "Page.captureScreenshot", {
|
const shot = await cdp.send<{ data?: string }>(tabId, "Page.captureScreenshot", {
|
||||||
format: "png",
|
format: "png",
|
||||||
clip: {
|
clip: {
|
||||||
x: rect.x,
|
...clip.rect,
|
||||||
y: rect.y,
|
|
||||||
width: rect.width,
|
|
||||||
height: rect.height,
|
|
||||||
scale: 1,
|
scale: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -321,7 +319,7 @@ export async function handleScreenshot(
|
|||||||
tabId: target.tabId,
|
tabId: target.tabId,
|
||||||
...(node.cdpSessionId ? { sessionId: node.cdpSessionId } : {}),
|
...(node.cdpSessionId ? { sessionId: node.cdpSessionId } : {}),
|
||||||
};
|
};
|
||||||
const captured = await withOverlaysHiddenForCapture(
|
const captured = await withExtensionOverlayHidden(
|
||||||
target.tabId,
|
target.tabId,
|
||||||
() =>
|
() =>
|
||||||
captureElementScreenshot(
|
captureElementScreenshot(
|
||||||
@@ -353,7 +351,7 @@ export async function handleScreenshot(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const captured = await withOverlaysHiddenForCapture(
|
const captured = await withExtensionOverlayHidden(
|
||||||
target.tabId,
|
target.tabId,
|
||||||
() => captureFullTabPng(deps, ctx, target, signal),
|
() => captureFullTabPng(deps, ctx, target, signal),
|
||||||
deps.sendToTab,
|
deps.sendToTab,
|
||||||
@@ -525,8 +523,11 @@ function buildActiveScopeBlocks(nodes: VomNode[], signals: VomNodeDomSignals): A
|
|||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildConditionalSurfaces(nodes: VomNode[], captured: CapturedViewModel): CondSurface[] {
|
function buildConditionalSurfaces(
|
||||||
const probes = captured.surfaceProbes ?? [];
|
nodes: VomNode[],
|
||||||
|
captured: CapturedSceneInput,
|
||||||
|
probes: CapturedSurfaceProbe[],
|
||||||
|
): CondSurface[] {
|
||||||
if (probes.length === 0) return [];
|
if (probes.length === 0) return [];
|
||||||
|
|
||||||
const surfaces: CondSurface[] = [];
|
const surfaces: CondSurface[] = [];
|
||||||
@@ -649,165 +650,14 @@ function findSurfaceNodeByPoint(
|
|||||||
export interface BuildVomSceneOptions {
|
export interface BuildVomSceneOptions {
|
||||||
pageUrl?: string;
|
pageUrl?: string;
|
||||||
supplementalNames?: ReadonlyMap<string, string>;
|
supplementalNames?: ReadonlyMap<string, string>;
|
||||||
|
surfaceProbes?: CapturedSurfaceProbe[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VomFrameDocument = CapturedFrameDocument<CdpAxNode>;
|
export type VomFrameDocument = CapturedFrameDocument<CdpAxNode>;
|
||||||
|
|
||||||
function legacyFrameDocuments(
|
|
||||||
axNodes: CdpAxNode[],
|
|
||||||
captured: CapturedViewModel,
|
|
||||||
pageUrl?: string,
|
|
||||||
rootTarget: VomFrameDocument["target"] = { tabId: 0 },
|
|
||||||
): VomFrameDocument[] {
|
|
||||||
const rootFrameId = captured.rootFrameId ?? "root";
|
|
||||||
const documents: VomFrameDocument[] = [
|
|
||||||
{
|
|
||||||
frameId: rootFrameId,
|
|
||||||
contextScopeId: rootFrameId,
|
|
||||||
target: rootTarget,
|
|
||||||
...(pageUrl ? { url: pageUrl } : {}),
|
|
||||||
axNodes: [],
|
|
||||||
domNodes: captured.nodes.map((node) => ({ ...node, frameId: node.frameId ?? rootFrameId })),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const pending = [...captured.iframeNodes.entries()];
|
|
||||||
let progress = true;
|
|
||||||
let nextSyntheticFrame = 1;
|
|
||||||
while (pending.length > 0 && progress) {
|
|
||||||
progress = false;
|
|
||||||
for (let index = pending.length - 1; index >= 0; index -= 1) {
|
|
||||||
const [ownerBackendNodeId, domNodes] = pending[index];
|
|
||||||
const parent = documents.find((document) =>
|
|
||||||
document.domNodes.some((node) => node.backendNodeId === ownerBackendNodeId),
|
|
||||||
);
|
|
||||||
if (!parent) continue;
|
|
||||||
const frameId =
|
|
||||||
domNodes.find((node) => node.frameId)?.frameId ?? `legacy-frame-${nextSyntheticFrame++}`;
|
|
||||||
documents.push({
|
|
||||||
frameId,
|
|
||||||
parentFrameId: parent.frameId,
|
|
||||||
ownerBackendNodeId,
|
|
||||||
contextScopeId: frameId,
|
|
||||||
target: parent.target,
|
|
||||||
axNodes: [],
|
|
||||||
domNodes: domNodes.map((node) => ({ ...node, frameId: node.frameId ?? frameId })),
|
|
||||||
});
|
|
||||||
pending.splice(index, 1);
|
|
||||||
progress = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const documentByFrameId = new Map(documents.map((document) => [document.frameId, document]));
|
|
||||||
const backendFrame = new Map<number, string>();
|
|
||||||
const childFrameByOwner = new Map<number, string>();
|
|
||||||
for (const document of documents) {
|
|
||||||
if (document.ownerBackendNodeId !== undefined) {
|
|
||||||
childFrameByOwner.set(document.ownerBackendNodeId, document.frameId);
|
|
||||||
}
|
|
||||||
for (const node of document.domNodes) backendFrame.set(node.backendNodeId, document.frameId);
|
|
||||||
}
|
|
||||||
const axById = new Map(axNodes.map((node) => [node.nodeId, node]));
|
|
||||||
const ownership = new Map<string, string>();
|
|
||||||
const resolving = new Set<string>();
|
|
||||||
const frameForAx = (node: CdpAxNode): string => {
|
|
||||||
const cached = ownership.get(node.nodeId);
|
|
||||||
if (cached) return cached;
|
|
||||||
let frameId: string | undefined;
|
|
||||||
if (node.frameId && documentByFrameId.has(node.frameId)) frameId = node.frameId;
|
|
||||||
if (!frameId && typeof node.backendDOMNodeId === "number") {
|
|
||||||
frameId = backendFrame.get(node.backendDOMNodeId);
|
|
||||||
}
|
|
||||||
if (!frameId && node.parentId && !resolving.has(node.nodeId)) {
|
|
||||||
const parent = axById.get(node.parentId);
|
|
||||||
if (parent) {
|
|
||||||
frameId =
|
|
||||||
typeof parent.backendDOMNodeId === "number"
|
|
||||||
? childFrameByOwner.get(parent.backendDOMNodeId)
|
|
||||||
: undefined;
|
|
||||||
if (!frameId) {
|
|
||||||
resolving.add(node.nodeId);
|
|
||||||
frameId = frameForAx(parent);
|
|
||||||
resolving.delete(node.nodeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
frameId ??= rootFrameId;
|
|
||||||
ownership.set(node.nodeId, frameId);
|
|
||||||
return frameId;
|
|
||||||
};
|
|
||||||
for (const node of axNodes) frameForAx(node);
|
|
||||||
for (const node of axNodes) {
|
|
||||||
const frameId = ownership.get(node.nodeId) ?? rootFrameId;
|
|
||||||
const document = documentByFrameId.get(frameId) ?? documents[0];
|
|
||||||
document.axNodes.push({
|
|
||||||
...node,
|
|
||||||
frameId,
|
|
||||||
...(node.parentId && ownership.get(node.parentId) === frameId
|
|
||||||
? { parentId: node.parentId }
|
|
||||||
: { parentId: undefined }),
|
|
||||||
...(node.childIds
|
|
||||||
? { childIds: node.childIds.filter((childId) => ownership.get(childId) === frameId) }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return documents;
|
|
||||||
}
|
|
||||||
|
|
||||||
function withLegacyBackendIds(scene: VomScene): VomScene {
|
|
||||||
const used = new Set<number>();
|
|
||||||
const idMap = new Map<number, number>();
|
|
||||||
let nextVirtualId = -1;
|
|
||||||
for (const node of scene.nodes) {
|
|
||||||
const preferred = node.backendNodeId;
|
|
||||||
const id = preferred !== undefined && !used.has(preferred) ? preferred : nextVirtualId--;
|
|
||||||
used.add(id);
|
|
||||||
idMap.set(node.id, id);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...scene,
|
|
||||||
nodes: scene.nodes.map((node) => ({
|
|
||||||
...node,
|
|
||||||
id: idMap.get(node.id) as number,
|
|
||||||
parentId: node.parentId === null ? null : (idMap.get(node.parentId) ?? null),
|
|
||||||
...(node.domParentId !== undefined
|
|
||||||
? { domParentId: node.domParentId === null ? null : (idMap.get(node.domParentId) ?? null) }
|
|
||||||
: {}),
|
|
||||||
...(node.domAncestorIds
|
|
||||||
? { domAncestorIds: node.domAncestorIds.flatMap((id) => idMap.get(id) ?? []) }
|
|
||||||
: {}),
|
|
||||||
})),
|
|
||||||
...(scene.surfaces
|
|
||||||
? {
|
|
||||||
surfaces: scene.surfaces.map((surface) => ({
|
|
||||||
...surface,
|
|
||||||
triggerId: idMap.get(surface.triggerId) ?? surface.triggerId,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
...(scene.activeScopeBlocks
|
|
||||||
? {
|
|
||||||
activeScopeBlocks: scene.activeScopeBlocks.map((block) => ({
|
|
||||||
...block,
|
|
||||||
triggerId: idMap.get(block.triggerId) ?? block.triggerId,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildVomScene(
|
|
||||||
axNodes: CdpAxNode[],
|
|
||||||
captured: CapturedViewModel,
|
|
||||||
options: BuildVomSceneOptions = {},
|
|
||||||
): VomScene {
|
|
||||||
return withLegacyBackendIds(
|
|
||||||
buildFrameVomScene(legacyFrameDocuments(axNodes, captured, options.pageUrl), captured, options),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildFrameVomScene(
|
export function buildFrameVomScene(
|
||||||
documents: VomFrameDocument[],
|
documents: VomFrameDocument[],
|
||||||
captured: CapturedViewModel,
|
captured: CapturedSceneInput,
|
||||||
options: BuildVomSceneOptions = {},
|
options: BuildVomSceneOptions = {},
|
||||||
): VomScene {
|
): VomScene {
|
||||||
const scene = buildSemanticVomScene({
|
const scene = buildSemanticVomScene({
|
||||||
@@ -817,18 +667,19 @@ export function buildFrameVomScene(
|
|||||||
excludedBackendNodeIds: captured.excludedBackendNodeIds,
|
excludedBackendNodeIds: captured.excludedBackendNodeIds,
|
||||||
supplementalNames: options.supplementalNames,
|
supplementalNames: options.supplementalNames,
|
||||||
});
|
});
|
||||||
return attachCapturedSceneAnnotations(scene, documents, captured);
|
return attachCapturedSceneAnnotations(scene, documents, captured, options.surfaceProbes ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachCapturedSceneAnnotations(
|
function attachCapturedSceneAnnotations(
|
||||||
scene: VomScene,
|
scene: VomScene,
|
||||||
documents: VomFrameDocument[],
|
documents: VomFrameDocument[],
|
||||||
captured: CapturedViewModel,
|
captured: CapturedSceneInput,
|
||||||
|
surfaceProbes: CapturedSurfaceProbe[],
|
||||||
): VomScene {
|
): VomScene {
|
||||||
const rootDocument = documents.find((document) => document.frameId === scene.rootFrameId);
|
const rootDocument = documents.find((document) => document.frameId === scene.rootFrameId);
|
||||||
const signals = capturedOnlySignals(rootDocument?.domNodes ?? captured.nodes);
|
const signals = capturedOnlySignals(rootDocument?.domNodes ?? captured.nodes);
|
||||||
const activeScopeBlocks = buildActiveScopeBlocks(scene.nodes, signals);
|
const activeScopeBlocks = buildActiveScopeBlocks(scene.nodes, signals);
|
||||||
const surfaces = buildConditionalSurfaces(scene.nodes, captured);
|
const surfaces = buildConditionalSurfaces(scene.nodes, captured, surfaceProbes);
|
||||||
return {
|
return {
|
||||||
...scene,
|
...scene,
|
||||||
...(surfaces.length > 0 ? { surfaces } : {}),
|
...(surfaces.length > 0 ? { surfaces } : {}),
|
||||||
@@ -972,53 +823,60 @@ export async function handleGetHtml(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyCapturedViewModel(viewport = { width: 0, height: 0 }): CapturedViewModel {
|
export interface HoverProbeOutcome {
|
||||||
return { viewport, nodes: [], iframeNodes: new Map(), excludedBackendNodeIds: new Set() };
|
/** Whether any active hover was dispatched during this observation. */
|
||||||
|
performed: boolean;
|
||||||
|
/**
|
||||||
|
* Whether hovering surfaced content absent from the static snapshot. Cached
|
||||||
|
* tooltip names count, so this may over-report on repeat observations — it
|
||||||
|
* errs towards telling the caller the snapshot is less fresh than it looks.
|
||||||
|
*/
|
||||||
|
revealedContent: boolean;
|
||||||
|
surfaceProbes: CapturedSurfaceProbe[];
|
||||||
|
tooltipNames: Map<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LayoutMetricsViewportReply {
|
const NO_HOVER_PROBES: HoverProbeOutcome = {
|
||||||
cssLayoutViewport?: { clientWidth?: number; clientHeight?: number };
|
performed: false,
|
||||||
layoutViewport?: { clientWidth?: number; clientHeight?: number };
|
revealedContent: false,
|
||||||
}
|
surfaceProbes: [],
|
||||||
|
tooltipNames: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
async function fallbackCapturedViewModel(
|
/**
|
||||||
cdp: CdpRunner,
|
* Runs both active-hover chains back to back.
|
||||||
tabId: number,
|
*
|
||||||
signal?: AbortSignal,
|
* Ordering matters: this happens after DOM *and* accessibility capture so a
|
||||||
): Promise<CapturedViewModel> {
|
* hover that opens a menu cannot leave one half of the observation describing
|
||||||
throwIfAborted(signal, "observation");
|
* the page before the change and the other half after it. Sharing one overlay
|
||||||
let viewport = { width: 0, height: 0 };
|
* bypass span also means the agent overlay toggles once per observation
|
||||||
try {
|
* instead of once per chain.
|
||||||
const metrics = await cdp.send<LayoutMetricsViewportReply>(tabId, "Page.getLayoutMetrics", {});
|
*/
|
||||||
throwIfAborted(signal, "observation");
|
async function runHoverProbes(
|
||||||
const source = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
|
|
||||||
viewport = {
|
|
||||||
width: source.clientWidth ?? 0,
|
|
||||||
height: source.clientHeight ?? 0,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (isAbortError(error)) throw error;
|
|
||||||
}
|
|
||||||
const excludedBackendNodeIds = await collectOverlayExcludedBackendIds(cdp, tabId, signal);
|
|
||||||
throwIfAborted(signal, "observation");
|
|
||||||
return { ...emptyCapturedViewModel(viewport), excludedBackendNodeIds };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function captureForVom(
|
|
||||||
cdp: CdpRunner,
|
cdp: CdpRunner,
|
||||||
tabId: number,
|
tabId: number,
|
||||||
|
captured: CapturedSceneInput,
|
||||||
|
documents: VomFrameDocument[],
|
||||||
|
staticSemantics: ReturnType<typeof resolveSemanticGraph>,
|
||||||
options: CaptureVomObservationOptions,
|
options: CaptureVomObservationOptions,
|
||||||
): Promise<CapturedViewModel> {
|
): Promise<HoverProbeOutcome> {
|
||||||
try {
|
if (!options.conditionalSurfaceProbe) return NO_HOVER_PROBES;
|
||||||
return await captureViewModel(cdp, tabId, {
|
|
||||||
conditionalSurfaceProbe: options.conditionalSurfaceProbe ?? false,
|
return withOverlayBypass(options.hoverProbeBypassOverlay, tabId, async () => {
|
||||||
hoverProbeBypassOverlay: options.hoverProbeBypassOverlay,
|
const surfaceProbes = await probeHoverSurfaces(cdp, tabId, captured.nodes, {
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
throwIfAborted(options.signal, "observation");
|
||||||
if (isAbortError(error)) throw error;
|
const tooltipNames = await probeTooltipNames(cdp, tabId, documents, staticSemantics, {
|
||||||
return fallbackCapturedViewModel(cdp, tabId, options.signal);
|
signal: options.signal,
|
||||||
}
|
});
|
||||||
|
return {
|
||||||
|
performed: true,
|
||||||
|
revealedContent: surfaceProbes.length > 0 || tooltipNames.size > 0,
|
||||||
|
surfaceProbes,
|
||||||
|
tooltipNames,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CaptureVomObservationOptions extends VomOptions {
|
export interface CaptureVomObservationOptions extends VomOptions {
|
||||||
@@ -1036,14 +894,9 @@ export async function captureVomObservation(
|
|||||||
throwIfAborted(options.signal, "observation");
|
throwIfAborted(options.signal, "observation");
|
||||||
await cdp.ensureAttachedToUrl?.(tabId, url);
|
await cdp.ensureAttachedToUrl?.(tabId, url);
|
||||||
throwIfAborted(options.signal, "observation");
|
throwIfAborted(options.signal, "observation");
|
||||||
const captured = await captureForVom(cdp, tabId, options);
|
const facts = await captureObservationFacts<CdpAxNode>(cdp, tabId, options.signal, url);
|
||||||
|
const { captured, documents: normalizedDocuments } = semanticCapture(facts);
|
||||||
throwIfAborted(options.signal, "observation");
|
throwIfAborted(options.signal, "observation");
|
||||||
const documents = await captureFrameData<CdpAxNode>(cdp, tabId, captured, options.signal);
|
|
||||||
throwIfAborted(options.signal, "observation");
|
|
||||||
const normalizedDocuments =
|
|
||||||
documents.length === 1 && captured.iframeNodes.size > 0
|
|
||||||
? legacyFrameDocuments(documents[0].axNodes, captured, url, documents[0].target)
|
|
||||||
: documents;
|
|
||||||
const semanticGraph = buildSemanticGraph({
|
const semanticGraph = buildSemanticGraph({
|
||||||
documents: normalizedDocuments,
|
documents: normalizedDocuments,
|
||||||
viewport: captured.viewport,
|
viewport: captured.viewport,
|
||||||
@@ -1051,31 +904,73 @@ export async function captureVomObservation(
|
|||||||
excludedBackendNodeIds: captured.excludedBackendNodeIds,
|
excludedBackendNodeIds: captured.excludedBackendNodeIds,
|
||||||
});
|
});
|
||||||
const staticSemantics = resolveSemanticGraph(semanticGraph, { identifierFallback: false });
|
const staticSemantics = resolveSemanticGraph(semanticGraph, { identifierFallback: false });
|
||||||
const tooltipNames = options.conditionalSurfaceProbe
|
const hoverProbes = await runHoverProbes(
|
||||||
? await probeTooltipNames(cdp, tabId, normalizedDocuments, staticSemantics, {
|
cdp,
|
||||||
signal: options.signal,
|
tabId,
|
||||||
hoverProbeBypassOverlay: options.hoverProbeBypassOverlay,
|
captured,
|
||||||
})
|
normalizedDocuments,
|
||||||
: new Map<string, string>();
|
staticSemantics,
|
||||||
|
options,
|
||||||
|
);
|
||||||
throwIfAborted(options.signal, "observation");
|
throwIfAborted(options.signal, "observation");
|
||||||
const scene = projectSemanticGraph(
|
const scene = projectSemanticGraph(
|
||||||
normalizeSemanticStructure(
|
normalizeSemanticStructure(
|
||||||
resolveSemanticGraph(semanticGraph, { supplementalNames: tooltipNames }),
|
resolveSemanticGraph(semanticGraph, { supplementalNames: hoverProbes.tooltipNames }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const decoratedScene = attachCapturedSceneAnnotations(scene, normalizedDocuments, captured);
|
const decoratedScene = attachCapturedSceneAnnotations(
|
||||||
|
scene,
|
||||||
|
normalizedDocuments,
|
||||||
|
captured,
|
||||||
|
hoverProbes.surfaceProbes,
|
||||||
|
);
|
||||||
|
// Reserve space using the renderer's character-based token estimate. Like VOM
|
||||||
|
// headers, this integrity notice remains visible even under a tiny token budget.
|
||||||
|
const notices: string[] = [];
|
||||||
|
if (facts.issues.some((issue) => issue.stage === "geometry"))
|
||||||
|
notices.push(
|
||||||
|
"@warning geometry incomplete: some page or frame content has no top-level coordinates.",
|
||||||
|
);
|
||||||
|
const incompleteStages = ["dom", "ax", "forms", "ownership"].filter((stage) =>
|
||||||
|
facts.issues.some((issue) => issue.stage === stage),
|
||||||
|
);
|
||||||
|
if (incompleteStages.length)
|
||||||
|
notices.push(
|
||||||
|
`@warning observation incomplete: some ${incompleteStages.join(", ")} data is unavailable or omitted.`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
facts.issues.some(
|
||||||
|
(issue) => issue.stage === "identity" && issue.reason !== "identity-unverified",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
notices.push(
|
||||||
|
"@warning observation incomplete: some documents were omitted because their identity changed or could not be revalidated.",
|
||||||
|
);
|
||||||
|
if (facts.issues.some((issue) => issue.reason === "identity-unverified"))
|
||||||
|
notices.push(
|
||||||
|
"@warning document identity unverified: some retained documents could not be checked for changes during capture.",
|
||||||
|
);
|
||||||
|
const captureNotice = notices.join("\n");
|
||||||
const rendered = renderVom(decoratedScene, {
|
const rendered = renderVom(decoratedScene, {
|
||||||
maxDepth: options.maxDepth,
|
maxDepth: options.maxDepth,
|
||||||
maxTokens: options.maxTokens,
|
maxTokens:
|
||||||
|
!captureNotice || options.maxTokens === undefined
|
||||||
|
? options.maxTokens
|
||||||
|
: Math.max(0, options.maxTokens - Math.ceil((captureNotice.length + 1) / 4)),
|
||||||
redactValues: options.redactValues,
|
redactValues: options.redactValues,
|
||||||
activeRegionPolicy: options.activeRegionPolicy,
|
activeRegionPolicy: options.activeRegionPolicy,
|
||||||
});
|
});
|
||||||
|
if (captureNotice) rendered.text += `\n${captureNotice}`;
|
||||||
throwIfAborted(options.signal, "observation");
|
throwIfAborted(options.signal, "observation");
|
||||||
return projectRecordSafeObservation({
|
return projectRecordSafeObservation({
|
||||||
rootFrameId: captured.rootFrameId ?? normalizedDocuments[0]?.frameId ?? "root",
|
rootFrameId: captured.rootFrameId ?? normalizedDocuments[0]?.frameId ?? "root",
|
||||||
frameDocuments: normalizedDocuments,
|
frameDocuments: normalizedDocuments,
|
||||||
rendered,
|
rendered,
|
||||||
surfaceProbes: captured.surfaceProbes,
|
surfaceProbes: hoverProbes.surfaceProbes,
|
||||||
|
hoverProbe: {
|
||||||
|
performed: hoverProbes.performed,
|
||||||
|
revealedContent: hoverProbes.revealedContent,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1141,6 +1036,14 @@ async function handleVomObservation(
|
|||||||
ref_count: observation.refs.length,
|
ref_count: observation.refs.length,
|
||||||
tab_id: target.tabId,
|
tab_id: target.tabId,
|
||||||
truncated: observation.truncated,
|
truncated: observation.truncated,
|
||||||
|
...(toolName === "observe" && observation.hoverProbe?.performed
|
||||||
|
? {
|
||||||
|
hover_probe: {
|
||||||
|
performed: true,
|
||||||
|
revealed_content: observation.hoverProbe.revealedContent,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(toolName === "observe" && (params as ObserveParams).debug_surfaces
|
...(toolName === "observe" && (params as ObserveParams).debug_surfaces
|
||||||
? {
|
? {
|
||||||
debug: {
|
debug: {
|
||||||
@@ -1179,5 +1082,13 @@ export async function handleObserve(
|
|||||||
deps: SnapshotDeps = getDefaultDeps(),
|
deps: SnapshotDeps = getDefaultDeps(),
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<ObserveResult | RpcError> {
|
): Promise<ObserveResult | RpcError> {
|
||||||
return handleVomObservation(manager, params, "observe", "transient_input", true, deps, signal);
|
return handleVomObservation(
|
||||||
|
manager,
|
||||||
|
params,
|
||||||
|
"observe",
|
||||||
|
"transient_input",
|
||||||
|
params.probe_hover === true,
|
||||||
|
deps,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type SessionManager, SessionStartCleanupError } from "@/session-manager
|
|||||||
import type { RpcError } from "@/transport/types";
|
import type { RpcError } from "@/transport/types";
|
||||||
import { rpcError } from "./errors";
|
import { rpcError } from "./errors";
|
||||||
import { clearRecordingForSession } from "./record";
|
import { clearRecordingForSession } from "./record";
|
||||||
|
import type { CdpRunner, ChromeTabsApi } from "./shared";
|
||||||
import { isRpcError } from "./shared";
|
import { isRpcError } from "./shared";
|
||||||
import { returnBorrowedTab, type TabManagementDeps } from "./tabs";
|
import { returnBorrowedTab, type TabManagementDeps } from "./tabs";
|
||||||
|
|
||||||
@@ -73,11 +74,17 @@ export interface SessionStopResult {
|
|||||||
/** Tab ids whose return path failed; those entries remain borrowed so
|
/** Tab ids whose return path failed; those entries remain borrowed so
|
||||||
* shutdown can be retried without closing the Agent Window. */
|
* shutdown can be retried without closing the Agent Window. */
|
||||||
return_failures?: Array<{ tab_id: number; code: string; message: string }>;
|
return_failures?: Array<{ tab_id: number; code: string; message: string }>;
|
||||||
|
/**
|
||||||
|
* True when the Agent Window was *released* to the user (instead of being
|
||||||
|
* closed) because it still contained user-created tabs after the agent's own
|
||||||
|
* tabs were closed. Single-session scope.
|
||||||
|
*/
|
||||||
|
window_released?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionStopDeps {
|
export interface SessionStopDeps {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
cdp?: {
|
cdp?: Pick<CdpRunner, "releaseSessionTab"> & {
|
||||||
detachSession(sessionId: string): Promise<void>;
|
detachSession(sessionId: string): Promise<void>;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
@@ -87,6 +94,13 @@ export interface SessionStopDeps {
|
|||||||
* without a real browser.
|
* without a real browser.
|
||||||
*/
|
*/
|
||||||
tabManagement?: TabManagementDeps;
|
tabManagement?: TabManagementDeps;
|
||||||
|
/**
|
||||||
|
* Read-only Chrome tabs API used to *query* remaining tabs before deciding
|
||||||
|
* whether to release the window. Kept separate from `tabManagement.tabs`
|
||||||
|
* (a `TabMutationApi` that has no `query`) so the mutation interface stays
|
||||||
|
* pure. Tests can inject a fake `ChromeTabsApi` too.
|
||||||
|
*/
|
||||||
|
tabsQuery?: ChromeTabsApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,8 +166,10 @@ export async function handleSessionStart(
|
|||||||
* cleanly (review parity with M6).
|
* cleanly (review parity with M6).
|
||||||
* 3. Detach CDP sessions the extension still holds for this
|
* 3. Detach CDP sessions the extension still holds for this
|
||||||
* session (no-op if M6/M7 didn't attach to any tab).
|
* session (no-op if M6/M7 didn't attach to any tab).
|
||||||
* 4. Close the Agent Window. SessionManager.stop() removes the
|
* 4. Close the agent-created tabs and the home tab (design §3.2).
|
||||||
* Chrome window and forgets the context.
|
* 5. If any tab remains — those are user-created tabs — release the
|
||||||
|
* window to the user (dropOnly) instead of closing it, so user
|
||||||
|
* tabs survive. Otherwise close the window as before.
|
||||||
*
|
*
|
||||||
* Failures in step 1 keep the Agent Window open: a failed borrowed tab
|
* Failures in step 1 keep the Agent Window open: a failed borrowed tab
|
||||||
* may still be there, so closing the window would risk losing user
|
* may still be there, so closing the window would risk losing user
|
||||||
@@ -188,15 +204,19 @@ export async function handleSessionStop(
|
|||||||
const returnFailures: SessionStopResult["return_failures"] = [];
|
const returnFailures: SessionStopResult["return_failures"] = [];
|
||||||
const borrowedIds = Array.from(ctx.borrowedTabs.keys());
|
const borrowedIds = Array.from(ctx.borrowedTabs.keys());
|
||||||
for (const tabId of borrowedIds) {
|
for (const tabId of borrowedIds) {
|
||||||
|
// A close event may have removed this borrow while another tab returned.
|
||||||
|
if (!ctx.borrowedTabs.has(tabId)) continue;
|
||||||
try {
|
try {
|
||||||
const tabManagement = {
|
const tabManagement = {
|
||||||
...(deps.tabManagement ?? {}),
|
...(deps.tabManagement ?? {}),
|
||||||
|
cdp: deps.cdp ?? deps.tabManagement?.cdp,
|
||||||
signal: deps.signal,
|
signal: deps.signal,
|
||||||
isAgentWindowId:
|
isAgentWindowId:
|
||||||
deps.tabManagement?.isAgentWindowId ??
|
deps.tabManagement?.isAgentWindowId ??
|
||||||
((windowId: number) => manager.findByWindowId(windowId) !== null),
|
((windowId: number) => manager.findByWindowId(windowId) !== null),
|
||||||
};
|
};
|
||||||
const outcome = await returnBorrowedTab(ctx, tabId, tabManagement);
|
const outcome = await returnBorrowedTab(ctx, tabId, tabManagement);
|
||||||
|
if (!ctx.borrowedTabs.has(tabId)) continue;
|
||||||
if (typeof outcome === "object" && "code" in outcome) {
|
if (typeof outcome === "object" && "code" in outcome) {
|
||||||
console.warn(`[bsk session_stop] auto-return failed for tab ${tabId}`, outcome);
|
console.warn(`[bsk session_stop] auto-return failed for tab ${tabId}`, outcome);
|
||||||
returnFailures?.push({
|
returnFailures?.push({
|
||||||
@@ -209,6 +229,7 @@ export async function handleSessionStop(
|
|||||||
ctx.borrowedTabs.delete(tabId);
|
ctx.borrowedTabs.delete(tabId);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!ctx.borrowedTabs.has(tabId)) continue;
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
console.warn(`[bsk session_stop] auto-return threw for tab ${tabId}: ${message}`);
|
console.warn(`[bsk session_stop] auto-return threw for tab ${tabId}: ${message}`);
|
||||||
returnFailures?.push({
|
returnFailures?.push({
|
||||||
@@ -219,8 +240,15 @@ export async function handleSessionStop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A previously failed tab may have closed during a later return. Only
|
||||||
|
// confirmed close events remove borrows; ordinary move failures stay retryable.
|
||||||
|
const pendingReturnFailures = returnFailures.filter((failure) =>
|
||||||
|
ctx.borrowedTabs.has(failure.tab_id),
|
||||||
|
);
|
||||||
if (deps.signal?.aborted) {
|
if (deps.signal?.aborted) {
|
||||||
const nonCancelFailures = returnFailures?.filter((failure) => failure.code !== "cancelled");
|
const nonCancelFailures = pendingReturnFailures.filter(
|
||||||
|
(failure) => failure.code !== "cancelled",
|
||||||
|
);
|
||||||
if (nonCancelFailures && nonCancelFailures.length > 0) {
|
if (nonCancelFailures && nonCancelFailures.length > 0) {
|
||||||
return {
|
return {
|
||||||
...(returnedTabIds.length > 0 ? { returned_tab_ids: returnedTabIds } : {}),
|
...(returnedTabIds.length > 0 ? { returned_tab_ids: returnedTabIds } : {}),
|
||||||
@@ -232,8 +260,8 @@ export async function handleSessionStop(
|
|||||||
|
|
||||||
const result: SessionStopResult = {};
|
const result: SessionStopResult = {};
|
||||||
if (returnedTabIds.length > 0) result.returned_tab_ids = returnedTabIds;
|
if (returnedTabIds.length > 0) result.returned_tab_ids = returnedTabIds;
|
||||||
if (returnFailures && returnFailures.length > 0) {
|
if (pendingReturnFailures.length > 0) {
|
||||||
result.return_failures = returnFailures;
|
result.return_failures = pendingReturnFailures;
|
||||||
// A failed return means at least one borrowed user tab may still be
|
// A failed return means at least one borrowed user tab may still be
|
||||||
// inside the Agent Window. Keep the session/window alive so the user
|
// inside the Agent Window. Keep the session/window alive so the user
|
||||||
// can retry `bsk session stop` or explicitly `bsk tab return` after the
|
// can retry `bsk session stop` or explicitly `bsk tab return` after the
|
||||||
@@ -253,8 +281,66 @@ export async function handleSessionStop(
|
|||||||
return { code: "cancelled", message: "session_stop aborted before window close" };
|
return { code: "cancelled", message: "session_stop aborted before window close" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: close the Agent Window and drop the context.
|
// Step 4: close every tab explicitly created by the agent, including the
|
||||||
await manager.stop(params.session_id);
|
// home tab. `tabsApi` is a
|
||||||
|
// TabMutationApi (remove only); `queryApi` is a separate read-only
|
||||||
|
// ChromeTabsApi. If neither is injected, we conservatively fall back to
|
||||||
|
// closing the window (see Step 5).
|
||||||
|
const tabsApi = deps.tabManagement?.tabs;
|
||||||
|
const queryApi = deps.tabsQuery;
|
||||||
|
|
||||||
|
if (tabsApi) {
|
||||||
|
// Close each agent-created tab that still exists.
|
||||||
|
const agentCreatedTabIds = Array.from(ctx.agentCreatedTabs);
|
||||||
|
for (const tabId of agentCreatedTabIds) {
|
||||||
|
try {
|
||||||
|
await tabsApi.remove(tabId);
|
||||||
|
ctx.agentCreatedTabs.delete(tabId);
|
||||||
|
} catch (err) {
|
||||||
|
// Tab may already be gone (closed by the user). Non-fatal.
|
||||||
|
console.warn(`[bsk session_stop] failed to close agent tab ${tabId}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: decide whether to release (keep) the window or close it.
|
||||||
|
let shouldRelease = false;
|
||||||
|
if (queryApi) {
|
||||||
|
try {
|
||||||
|
const liveWindowTabs = await queryApi.query({ windowId: ctx.agentWindowId });
|
||||||
|
// Only genuine *user* tabs count toward keeping the window open.
|
||||||
|
// An agent tab that failed to close in Step 4 may still be present
|
||||||
|
// here; if we counted it as a reason to release (dropOnly), the
|
||||||
|
// window would be kept and that agent tab would leak (issue #57
|
||||||
|
// regression). Exclude any id still tracked in agentCreatedTabs.
|
||||||
|
const userTabs = liveWindowTabs.filter((t) => {
|
||||||
|
if (t.id === undefined) return false;
|
||||||
|
return !ctx.agentCreatedTabs.has(t.id);
|
||||||
|
});
|
||||||
|
const leakedAgentTabs = liveWindowTabs.filter(
|
||||||
|
(t) => t.id !== undefined && ctx.agentCreatedTabs.has(t.id),
|
||||||
|
);
|
||||||
|
if (leakedAgentTabs.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
`[bsk session_stop] ${leakedAgentTabs.length} agent tab(s) failed to close; forcing window close instead of release`,
|
||||||
|
leakedAgentTabs.map((t) => t.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
shouldRelease = userTabs.length > 0;
|
||||||
|
} catch {
|
||||||
|
// Query failed (e.g. window already gone) — conservatively close it.
|
||||||
|
shouldRelease = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRelease) {
|
||||||
|
// Keep the window + its user tabs; only drop the session binding.
|
||||||
|
await manager.stop(params.session_id, { dropOnly: true });
|
||||||
|
result.window_released = true;
|
||||||
|
} else {
|
||||||
|
// Window is empty (or we couldn't verify state) — close it.
|
||||||
|
await manager.stop(params.session_id);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,9 +53,12 @@ export type { DialogCursor };
|
|||||||
export interface CdpRunner {
|
export interface CdpRunner {
|
||||||
send<T = unknown>(tabId: number, method: string, params?: object): Promise<T>;
|
send<T = unknown>(tabId: number, method: string, params?: object): Promise<T>;
|
||||||
sendToTarget?<T = unknown>(target: CdpTarget, method: string, params?: object): Promise<T>;
|
sendToTarget?<T = unknown>(target: CdpTarget, method: string, params?: object): Promise<T>;
|
||||||
|
detach?(tabId: number): Promise<void>;
|
||||||
getFrameGraph?(tabId: number): Promise<CdpFrameGraph>;
|
getFrameGraph?(tabId: number): Promise<CdpFrameGraph>;
|
||||||
|
getAttachmentId?(tabId: number): string | undefined;
|
||||||
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
|
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
|
||||||
trackSessionTab?(sessionId: string, tabId: number): void;
|
trackSessionTab?(sessionId: string, tabId: number): void;
|
||||||
|
releaseSessionTab?(sessionId: string, tabId: number): Promise<void>;
|
||||||
onEvent?(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
|
onEvent?(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import type { SessionContext, SessionManager } from "@/session-manager/manager";
|
import type { SessionContext, SessionManager } from "@/session-manager/manager";
|
||||||
import type { RpcError } from "@/transport/types";
|
import type { RpcError } from "@/transport/types";
|
||||||
import { rpcError } from "./errors";
|
import { rpcError } from "./errors";
|
||||||
import { isRpcError, lookupSession } from "./shared";
|
import { type CdpRunner, isRpcError, lookupSession } from "./shared";
|
||||||
|
|
||||||
export type TabScope = "user" | "agent" | "all";
|
export type TabScope = "user" | "agent" | "all";
|
||||||
|
|
||||||
@@ -287,6 +287,10 @@ export interface TabManagementDeps {
|
|||||||
approveBorrow?: BorrowConfirmationApprover;
|
approveBorrow?: BorrowConfirmationApprover;
|
||||||
/** Clears Agent-scoped overlays after a borrowed tab is returned. */
|
/** Clears Agent-scoped overlays after a borrowed tab is returned. */
|
||||||
agentOverlayReset?: AgentOverlayResetApi;
|
agentOverlayReset?: AgentOverlayResetApi;
|
||||||
|
/** Releases this session's CDP claim after a borrowed tab is returned. */
|
||||||
|
cdp?: Pick<CdpRunner, "releaseSessionTab">;
|
||||||
|
/** Runs after tab_return validation, before moving the borrowed tab. */
|
||||||
|
beforeReturn?: (sessionId: string, tabId: number) => Promise<void>;
|
||||||
/**
|
/**
|
||||||
* Reports whether `windowId` is any live session's Agent Window.
|
* Reports whether `windowId` is any live session's Agent Window.
|
||||||
* `tab_return`'s fallback window picker uses this to avoid moving a
|
* `tab_return`'s fallback window picker uses this to avoid moving a
|
||||||
@@ -369,6 +373,7 @@ function buildCreateProps(
|
|||||||
* Returns the created tab on success, or an `RpcError` on failure.
|
* Returns the created tab on success, or an `RpcError` on failure.
|
||||||
*/
|
*/
|
||||||
async function createTabAndCleanup(
|
async function createTabAndCleanup(
|
||||||
|
ctx: SessionContext,
|
||||||
deps: TabManagementDeps,
|
deps: TabManagementDeps,
|
||||||
createProps: chrome.tabs.CreateProperties,
|
createProps: chrome.tabs.CreateProperties,
|
||||||
): Promise<CreatedChromeTab | RpcError> {
|
): Promise<CreatedChromeTab | RpcError> {
|
||||||
@@ -381,25 +386,28 @@ async function createTabAndCleanup(
|
|||||||
message: err instanceof Error ? err.message : String(err),
|
message: err instanceof Error ? err.message : String(err),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (aborted(deps.signal, "tab_create")) {
|
|
||||||
// We already opened the tab; close it on abort so we don't leak.
|
|
||||||
if (typeof tab.id === "number") {
|
|
||||||
try {
|
|
||||||
await getTabsApi(deps).remove(tab.id);
|
|
||||||
} catch (cleanupErr) {
|
|
||||||
return rpcError(
|
|
||||||
"protocol_error",
|
|
||||||
"cleanup_failed",
|
|
||||||
`tab_create aborted but cleanup of tab ${tab.id} failed: ${describeError(cleanupErr)}`,
|
|
||||||
{ resource_type: "tab", resource_id: tab.id },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { code: "cancelled", message: "tab_create aborted" };
|
|
||||||
}
|
|
||||||
if (typeof tab.id !== "number") {
|
if (typeof tab.id !== "number") {
|
||||||
return { code: "protocol_error", message: "chrome.tabs.create returned no tab id" };
|
return { code: "protocol_error", message: "chrome.tabs.create returned no tab id" };
|
||||||
}
|
}
|
||||||
|
// Claim the concrete id returned by Chrome. Ownership never depends on
|
||||||
|
// matching this request to an asynchronous onCreated event.
|
||||||
|
ctx.agentCreatedTabs.add(tab.id);
|
||||||
|
if (aborted(deps.signal, "tab_create")) {
|
||||||
|
try {
|
||||||
|
await getTabsApi(deps).remove(tab.id);
|
||||||
|
ctx.agentCreatedTabs.delete(tab.id);
|
||||||
|
} catch (cleanupErr) {
|
||||||
|
// Keep the claim when cleanup fails so session_stop can retry instead
|
||||||
|
// of releasing an agent-owned tab to the user.
|
||||||
|
return rpcError(
|
||||||
|
"protocol_error",
|
||||||
|
"cleanup_failed",
|
||||||
|
`tab_create aborted but cleanup of tab ${tab.id} failed: ${describeError(cleanupErr)}`,
|
||||||
|
{ resource_type: "tab", resource_id: tab.id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { code: "cancelled", message: "tab_create aborted" };
|
||||||
|
}
|
||||||
return { ...tab, id: tab.id };
|
return { ...tab, id: tab.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +434,7 @@ export async function handleTabCreate(
|
|||||||
const paramErr = validateTabCreateParams(params);
|
const paramErr = validateTabCreateParams(params);
|
||||||
if (paramErr) return paramErr;
|
if (paramErr) return paramErr;
|
||||||
|
|
||||||
const tab = await createTabAndCleanup(deps, buildCreateProps(ctx, params));
|
const tab = await createTabAndCleanup(ctx, deps, buildCreateProps(ctx, params));
|
||||||
if (isRpcError(tab)) return tab;
|
if (isRpcError(tab)) return tab;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -526,6 +534,9 @@ export async function handleTabClose(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await getTabsApi(deps).remove(params.tab_id);
|
await getTabsApi(deps).remove(params.tab_id);
|
||||||
|
// Keep the tracking set accurate so session_stop won't try to close a
|
||||||
|
// tab that's already gone (design §3.1).
|
||||||
|
ctx.agentCreatedTabs.delete(params.tab_id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
code: "protocol_error",
|
code: "protocol_error",
|
||||||
@@ -753,13 +764,6 @@ async function executeBorrowCore(
|
|||||||
);
|
);
|
||||||
if (moveErr) return moveErr;
|
if (moveErr) return moveErr;
|
||||||
|
|
||||||
// Best-effort activation — failure is non-fatal.
|
|
||||||
try {
|
|
||||||
await p.tabsApi.update(p.tabId, { active: true });
|
|
||||||
} catch (err) {
|
|
||||||
console.debug("[bsk tab_borrow] activate after move failed", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { originalWindowId, originalIndex };
|
return { originalWindowId, originalIndex };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -823,6 +827,12 @@ export async function handleTabBorrow(
|
|||||||
message: err instanceof Error ? err.message : String(err),
|
message: err instanceof Error ? err.message : String(err),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Activate after commit so overlay/event observers see the tab as claimed.
|
||||||
|
try {
|
||||||
|
await tabsApi.update(params.tab_id, { active: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.debug("[bsk tab_borrow] activate after move failed", err);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
tab_id: params.tab_id,
|
tab_id: params.tab_id,
|
||||||
original_window_id: coreResult.originalWindowId,
|
original_window_id: coreResult.originalWindowId,
|
||||||
@@ -939,16 +949,22 @@ async function cleanupUnusedFallbackWindow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetAgentOverlaysInReturnedTab(
|
async function releaseReturnedTabState(
|
||||||
ctx: SessionContext,
|
ctx: SessionContext,
|
||||||
tabId: number,
|
tabId: number,
|
||||||
deps: TabManagementDeps,
|
deps: TabManagementDeps,
|
||||||
): void {
|
): Promise<void> {
|
||||||
void getAgentOverlayResetApi(deps)
|
void getAgentOverlayResetApi(deps)
|
||||||
.resetAgentOverlays(tabId, ctx.sessionId)
|
.resetAgentOverlays(tabId, ctx.sessionId)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.debug("[bsk tab_return] agent overlay reset failed", err);
|
console.debug("[bsk tab_return] agent overlay reset failed", err);
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
|
await deps.cdp?.releaseSessionTab?.(ctx.sessionId, tabId);
|
||||||
|
} catch (err) {
|
||||||
|
// A successful move must not be retried because debugger cleanup failed.
|
||||||
|
console.debug("[bsk tab_return] CDP release failed", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1022,7 +1038,7 @@ export async function returnBorrowedTab(
|
|||||||
});
|
});
|
||||||
const movedTab = Array.isArray(moved) ? moved[0] : moved;
|
const movedTab = Array.isArray(moved) ? moved[0] : moved;
|
||||||
const finalIndex = typeof movedTab?.index === "number" ? movedTab.index : targetIndex;
|
const finalIndex = typeof movedTab?.index === "number" ? movedTab.index : targetIndex;
|
||||||
resetAgentOverlaysInReturnedTab(ctx, tabId, deps);
|
await releaseReturnedTabState(ctx, tabId, deps);
|
||||||
return {
|
return {
|
||||||
tabId,
|
tabId,
|
||||||
toWindowId: targetWindowId,
|
toWindowId: targetWindowId,
|
||||||
@@ -1064,7 +1080,7 @@ export async function returnBorrowedTab(
|
|||||||
});
|
});
|
||||||
const movedTab = Array.isArray(moved) ? moved[0] : moved;
|
const movedTab = Array.isArray(moved) ? moved[0] : moved;
|
||||||
const finalIndex = typeof movedTab?.index === "number" ? movedTab.index : target.index;
|
const finalIndex = typeof movedTab?.index === "number" ? movedTab.index : target.index;
|
||||||
resetAgentOverlaysInReturnedTab(ctx, tabId, deps);
|
await releaseReturnedTabState(ctx, tabId, deps);
|
||||||
return {
|
return {
|
||||||
tabId,
|
tabId,
|
||||||
toWindowId: target.windowId,
|
toWindowId: target.windowId,
|
||||||
@@ -1110,6 +1126,7 @@ export async function handleTabReturn(
|
|||||||
message: `tab_return: tab ${params.tab_id} is not borrowed by this session`,
|
message: `tab_return: tab ${params.tab_id} is not borrowed by this session`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
await deps.beforeReturn?.(ctx.sessionId, params.tab_id);
|
||||||
const outcome = await returnBorrowedTab(ctx, params.tab_id, {
|
const outcome = await returnBorrowedTab(ctx, params.tab_id, {
|
||||||
...deps,
|
...deps,
|
||||||
isAgentWindowId:
|
isAgentWindowId:
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Shared time boundary for browser-side file-transfer transactions. Concrete
|
||||||
|
// upload/download mechanisms own their state machines and effect semantics.
|
||||||
|
|
||||||
|
export class BoundedWaitError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly kind: "timeout" | "aborted",
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remainingMs(deadline: number): number {
|
||||||
|
return Math.max(0, deadline - Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitBounded<T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
deadline: number,
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
|
timeoutMessage: string,
|
||||||
|
): Promise<T> {
|
||||||
|
const remaining = remainingMs(deadline);
|
||||||
|
if (signal?.aborted) throw new BoundedWaitError("aborted", "file transfer aborted");
|
||||||
|
if (remaining === 0) throw new BoundedWaitError("timeout", timeoutMessage);
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let onAbort: (() => void) | undefined;
|
||||||
|
const boundary = new Promise<never>((_resolve, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new BoundedWaitError("timeout", timeoutMessage)), remaining);
|
||||||
|
if (signal) {
|
||||||
|
onAbort = () => reject(new BoundedWaitError("aborted", "file transfer aborted"));
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await Promise.race([promise, boundary]);
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Upload orchestration: validate the session-scoped request, resolve its click
|
||||||
|
// target, then delegate the browser protocol transaction to the file-input
|
||||||
|
// transaction module.
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CaptureSuppressSendToTab,
|
||||||
|
withExtensionOverlayHidden,
|
||||||
|
} from "@/lib/capture-suppress-bridge";
|
||||||
|
import type { SessionManager } from "@/session-manager/manager";
|
||||||
|
import type { RpcError, UploadParams, UploadResult } from "@/transport/types";
|
||||||
|
import { uploadThroughFileDrop } from "./file-drop-transaction";
|
||||||
|
import { uploadThroughActivatedFileInput } from "./file-input-transaction";
|
||||||
|
import { clickResolvedTarget, type InteractionDeps, resolveActionTarget } from "./interaction";
|
||||||
|
import {
|
||||||
|
type CdpRunner,
|
||||||
|
enforceAgentWindow,
|
||||||
|
isRpcError,
|
||||||
|
lookupSession,
|
||||||
|
resolveTargetTab,
|
||||||
|
} from "./shared";
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
export interface UploadDeps extends InteractionDeps {
|
||||||
|
cdp: CdpRunner;
|
||||||
|
sendToTab?: CaptureSuppressSendToTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUpload(
|
||||||
|
manager: SessionManager,
|
||||||
|
params: UploadParams,
|
||||||
|
deps: UploadDeps,
|
||||||
|
): Promise<UploadResult | RpcError> {
|
||||||
|
const ctx = lookupSession(manager, params, "upload");
|
||||||
|
if (isRpcError(ctx)) return ctx;
|
||||||
|
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
|
||||||
|
if (isRpcError(target)) return target;
|
||||||
|
const denied = enforceAgentWindow(ctx, target, "upload");
|
||||||
|
if (denied) return denied;
|
||||||
|
if (
|
||||||
|
params.files.length === 0 ||
|
||||||
|
params.files.length > 20 ||
|
||||||
|
params.files.some((file) => !file.staged_path)
|
||||||
|
) {
|
||||||
|
return { code: "invalid_params", message: "upload requires daemon-staged files" };
|
||||||
|
}
|
||||||
|
const address = await resolveActionTarget(deps.cdp, ctx, target, params, "upload");
|
||||||
|
if (isRpcError(address)) return address;
|
||||||
|
const timeoutMs = params.timeout_ms ?? DEFAULT_TIMEOUT_MS;
|
||||||
|
const files = params.files.map((file) => file.staged_path as string);
|
||||||
|
const mode = params.mode ?? "input";
|
||||||
|
let usedRef = address.usedRef;
|
||||||
|
let usedSelector = address.usedSelector;
|
||||||
|
if (mode === "drop") {
|
||||||
|
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||||
|
const transaction = await uploadThroughFileDrop({
|
||||||
|
cdp: deps.cdp,
|
||||||
|
actionTarget: address,
|
||||||
|
files,
|
||||||
|
timeoutMs,
|
||||||
|
signal: deps.signal,
|
||||||
|
withOverlayHidden: (operation) =>
|
||||||
|
withExtensionOverlayHidden(target.tabId, operation, deps.sendToTab),
|
||||||
|
});
|
||||||
|
if (isRpcError(transaction)) return transaction;
|
||||||
|
} else {
|
||||||
|
const transaction = await uploadThroughActivatedFileInput({
|
||||||
|
cdp: deps.cdp,
|
||||||
|
actionTarget: address,
|
||||||
|
files,
|
||||||
|
timeoutMs,
|
||||||
|
signal: deps.signal,
|
||||||
|
trigger: () => clickResolvedTarget(ctx, address, {}, deps),
|
||||||
|
});
|
||||||
|
if (isRpcError(transaction)) return transaction;
|
||||||
|
usedRef = transaction.click.used_ref;
|
||||||
|
usedSelector = transaction.click.used_selector;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tab_id: target.tabId,
|
||||||
|
used_ref: usedRef,
|
||||||
|
used_selector: usedSelector,
|
||||||
|
file_names: params.files.map((file) => file.name),
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { OVERLAY_HOST_MARKER_ATTR, OVERLAY_HOST_NAME } from "../../../lib/overlay-bridge";
|
import { OVERLAY_HOST_MARKER_ATTR, OVERLAY_HOST_NAME } from "../../../lib/overlay-bridge";
|
||||||
import { captureViewModel, collectOverlayExcludedBackendIds } from "../capture";
|
import { collectOverlayExcludedBackendIds, probeHoverSurfaces } from "../capture";
|
||||||
|
import { captureObservationFacts, semanticCapture } from "../capture-coordinator";
|
||||||
|
|
||||||
// Minimal but format-accurate captureSnapshot reply: a body with one
|
// Minimal but format-accurate captureSnapshot reply: a body with one
|
||||||
// fixed full-screen overlay div carrying a password input.
|
// fixed full-screen overlay div carrying a password input.
|
||||||
@@ -26,6 +27,7 @@ function fakeSnapshotReply() {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1, 2],
|
parentIndex: [-1, 0, 1, 2],
|
||||||
nodeType: [1, 1, 1, 1],
|
nodeType: [1, 1, 1, 1],
|
||||||
@@ -73,6 +75,7 @@ function hoverTriggerSnapshotReply() {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1],
|
parentIndex: [-1, 0, 1],
|
||||||
nodeType: [1, 1, 1],
|
nodeType: [1, 1, 1],
|
||||||
@@ -117,6 +120,7 @@ function twoHoverTriggerSnapshotReply() {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1, 1],
|
parentIndex: [-1, 0, 1, 1],
|
||||||
nodeType: [1, 1, 1, 1],
|
nodeType: [1, 1, 1, 1],
|
||||||
@@ -170,6 +174,7 @@ function nestedHoverTriggerSnapshotReply() {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1, 2, 3],
|
parentIndex: [-1, 0, 1, 2, 3],
|
||||||
nodeType: [1, 1, 1, 1, 1],
|
nodeType: [1, 1, 1, 1, 1],
|
||||||
@@ -210,38 +215,48 @@ function makeCdp(snapshot: unknown) {
|
|||||||
if (method === "DOMSnapshot.enable") return {};
|
if (method === "DOMSnapshot.enable") return {};
|
||||||
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
||||||
if (method === "Page.getLayoutMetrics") {
|
if (method === "Page.getLayoutMetrics") {
|
||||||
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 } };
|
return {
|
||||||
|
visualViewport: { clientWidth: 1000 },
|
||||||
|
cssVisualViewport: { clientWidth: 1000 },
|
||||||
|
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (method === "Runtime.evaluate") {
|
if (method === "Runtime.evaluate") {
|
||||||
return {
|
return {
|
||||||
result: {
|
result: {
|
||||||
value: {
|
deepSerializedValue: {
|
||||||
controls: [
|
type: "array",
|
||||||
|
value: [
|
||||||
{
|
{
|
||||||
state: "filled",
|
type: "array",
|
||||||
sensitive: true,
|
value: [
|
||||||
defaultValue: "hunter2",
|
{ type: "node", value: { backendNodeId: 13 } },
|
||||||
value: "hunter2",
|
{
|
||||||
placeholder: "secret",
|
type: "string",
|
||||||
|
value: JSON.stringify({
|
||||||
|
state: "filled",
|
||||||
|
sensitive: true,
|
||||||
|
placeholder: "secret",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
childFrames: [],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (method === "Runtime.releaseObjectGroup") return {};
|
||||||
throw new Error(`unexpected ${method}`);
|
throw new Error(`unexpected ${method}`);
|
||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("captureViewModel", () => {
|
describe("snapshot capture and hover", () => {
|
||||||
it("parses nodes, attrs, rects, paint order and styles", async () => {
|
it("parses nodes, attrs, rects, paint order and styles", async () => {
|
||||||
const { nodes, viewport, iframeNodes } = await captureViewModel(
|
const { nodes, viewport } = semanticCapture(
|
||||||
makeCdp(fakeSnapshotReply()),
|
await captureObservationFacts(makeCdp(fakeSnapshotReply()), 4),
|
||||||
4,
|
).captured;
|
||||||
);
|
|
||||||
expect(iframeNodes.size).toBe(0);
|
|
||||||
expect(viewport).toEqual({ width: 1000, height: 800 });
|
expect(viewport).toEqual({ width: 1000, height: 800 });
|
||||||
const div = nodes.find((n) => n.backendNodeId === 12);
|
const div = nodes.find((n) => n.backendNodeId === 12);
|
||||||
expect(div).toMatchObject({ tag: "div", position: "fixed", parentBackendNodeId: 11 });
|
expect(div).toMatchObject({ tag: "div", position: "fixed", parentBackendNodeId: 11 });
|
||||||
@@ -251,35 +266,13 @@ describe("captureViewModel", () => {
|
|||||||
expect(input?.attrs).toEqual({ type: "password" });
|
expect(input?.attrs).toEqual({ type: "password" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not run conditional surface probes by default", async () => {
|
it("never hovers the page while capturing", async () => {
|
||||||
const cdp = makeCdp(fakeSnapshotReply());
|
const cdp = makeCdp(fakeSnapshotReply());
|
||||||
const result = await captureViewModel(cdp, 4);
|
semanticCapture(await captureObservationFacts(cdp, 4)).captured;
|
||||||
|
|
||||||
expect(result.surfaceProbes).toEqual([]);
|
|
||||||
expect(cdp.send).not.toHaveBeenCalledWith(4, "Input.dispatchMouseEvent", expect.anything());
|
expect(cdp.send).not.toHaveBeenCalledWith(4, "Input.dispatchMouseEvent", expect.anything());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("batch-enriches form controls without per-node object resolution", async () => {
|
|
||||||
const cdp = makeCdp(fakeSnapshotReply());
|
|
||||||
const result = await captureViewModel(cdp, 4);
|
|
||||||
const input = result.nodes.find((node) => node.backendNodeId === 13);
|
|
||||||
|
|
||||||
expect(input).toMatchObject({
|
|
||||||
formState: "filled",
|
|
||||||
formPlaceholder: "secret",
|
|
||||||
attrs: { type: "password" },
|
|
||||||
});
|
|
||||||
expect(input?.formValue).toBeUndefined();
|
|
||||||
expect(input?.formDefaultValue).toBeUndefined();
|
|
||||||
expect(cdp.send).toHaveBeenCalledWith(
|
|
||||||
4,
|
|
||||||
"Runtime.evaluate",
|
|
||||||
expect.objectContaining({ returnByValue: true }),
|
|
||||||
);
|
|
||||||
expect(cdp.send).not.toHaveBeenCalledWith(4, "DOM.resolveNode", expect.anything());
|
|
||||||
expect(cdp.send).not.toHaveBeenCalledWith(4, "Runtime.callFunctionOn", expect.anything());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("runs hover surface probes when explicitly enabled", async () => {
|
it("runs hover surface probes when explicitly enabled", async () => {
|
||||||
let hoverStateCalls = 0;
|
let hoverStateCalls = 0;
|
||||||
const cdp = {
|
const cdp = {
|
||||||
@@ -288,6 +281,8 @@ describe("captureViewModel", () => {
|
|||||||
if (method === "DOMSnapshot.captureSnapshot") return hoverTriggerSnapshotReply();
|
if (method === "DOMSnapshot.captureSnapshot") return hoverTriggerSnapshotReply();
|
||||||
if (method === "Page.getLayoutMetrics") {
|
if (method === "Page.getLayoutMetrics") {
|
||||||
return {
|
return {
|
||||||
|
visualViewport: { clientWidth: 1000 },
|
||||||
|
cssVisualViewport: { clientWidth: 1000 },
|
||||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -319,9 +314,10 @@ describe("captureViewModel", () => {
|
|||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await captureViewModel(cdp, 4, { conditionalSurfaceProbe: true });
|
const captured = semanticCapture(await captureObservationFacts(cdp, 4)).captured;
|
||||||
|
const surfaceProbes = await probeHoverSurfaces(cdp, 4, captured.nodes);
|
||||||
|
|
||||||
expect(result.surfaceProbes).toEqual([
|
expect(surfaceProbes).toEqual([
|
||||||
{
|
{
|
||||||
triggerBackendNodeId: 12,
|
triggerBackendNodeId: 12,
|
||||||
triggerPoint: { x: 956, y: 32 },
|
triggerPoint: { x: 956, y: 32 },
|
||||||
@@ -350,6 +346,8 @@ describe("captureViewModel", () => {
|
|||||||
if (method === "DOMSnapshot.captureSnapshot") return twoHoverTriggerSnapshotReply();
|
if (method === "DOMSnapshot.captureSnapshot") return twoHoverTriggerSnapshotReply();
|
||||||
if (method === "Page.getLayoutMetrics") {
|
if (method === "Page.getLayoutMetrics") {
|
||||||
return {
|
return {
|
||||||
|
visualViewport: { clientWidth: 1000 },
|
||||||
|
cssVisualViewport: { clientWidth: 1000 },
|
||||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -378,9 +376,10 @@ describe("captureViewModel", () => {
|
|||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await captureViewModel(cdp, 4, { conditionalSurfaceProbe: true });
|
const captured = semanticCapture(await captureObservationFacts(cdp, 4)).captured;
|
||||||
|
const surfaceProbes = await probeHoverSurfaces(cdp, 4, captured.nodes);
|
||||||
|
|
||||||
expect(result.surfaceProbes).toEqual([
|
expect(surfaceProbes).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
triggerBackendNodeId: 12,
|
triggerBackendNodeId: 12,
|
||||||
subItems: ["My profile", "Sign out"],
|
subItems: ["My profile", "Sign out"],
|
||||||
@@ -397,6 +396,8 @@ describe("captureViewModel", () => {
|
|||||||
if (method === "DOMSnapshot.captureSnapshot") return nestedHoverTriggerSnapshotReply();
|
if (method === "DOMSnapshot.captureSnapshot") return nestedHoverTriggerSnapshotReply();
|
||||||
if (method === "Page.getLayoutMetrics") {
|
if (method === "Page.getLayoutMetrics") {
|
||||||
return {
|
return {
|
||||||
|
visualViewport: { clientWidth: 1000 },
|
||||||
|
cssVisualViewport: { clientWidth: 1000 },
|
||||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -422,7 +423,8 @@ describe("captureViewModel", () => {
|
|||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
|
|
||||||
await captureViewModel(cdp, 4, { conditionalSurfaceProbe: true });
|
const captured = semanticCapture(await captureObservationFacts(cdp, 4)).captured;
|
||||||
|
await probeHoverSurfaces(cdp, 4, captured.nodes);
|
||||||
|
|
||||||
expect(hoverMoves).toBe(1);
|
expect(hoverMoves).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -453,6 +455,7 @@ describe("captureViewModel", () => {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1, 0, 3, 4],
|
parentIndex: [-1, 0, 1, 0, 3, 4],
|
||||||
nodeName: [i("html"), i("body"), i("div"), i(OVERLAY_HOST_NAME), i("div"), i("button")],
|
nodeName: [i("html"), i("body"), i("div"), i(OVERLAY_HOST_NAME), i("div"), i("button")],
|
||||||
@@ -483,7 +486,7 @@ describe("captureViewModel", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { nodes } = await captureViewModel(makeCdp(snapshot), 4);
|
const { nodes } = semanticCapture(await captureObservationFacts(makeCdp(snapshot), 4)).captured;
|
||||||
const ids = nodes.map((n) => n.backendNodeId);
|
const ids = nodes.map((n) => n.backendNodeId);
|
||||||
// Real page nodes survive.
|
// Real page nodes survive.
|
||||||
expect(ids).toContain(12);
|
expect(ids).toContain(12);
|
||||||
@@ -514,6 +517,7 @@ describe("captureViewModel", () => {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1, 0, 3, 4],
|
parentIndex: [-1, 0, 1, 0, 3, 4],
|
||||||
nodeName: [i("html"), i("body"), i("div"), i(OVERLAY_HOST_NAME), i("div"), i("button")],
|
nodeName: [i("html"), i("body"), i("div"), i(OVERLAY_HOST_NAME), i("div"), i("button")],
|
||||||
@@ -544,7 +548,9 @@ describe("captureViewModel", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { excludedBackendNodeIds } = await captureViewModel(makeCdp(snapshot), 4);
|
const { excludedBackendNodeIds } = semanticCapture(
|
||||||
|
await captureObservationFacts(makeCdp(snapshot), 4),
|
||||||
|
).captured;
|
||||||
expect(excludedBackendNodeIds).toEqual(new Set([13, 14, 15]));
|
expect(excludedBackendNodeIds).toEqual(new Set([13, 14, 15]));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -565,6 +571,7 @@ describe("captureViewModel", () => {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1],
|
parentIndex: [-1, 0, 1],
|
||||||
nodeName: [i("html"), i("body"), i(OVERLAY_HOST_NAME)],
|
nodeName: [i("html"), i("body"), i(OVERLAY_HOST_NAME)],
|
||||||
@@ -587,7 +594,9 @@ describe("captureViewModel", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { nodes, excludedBackendNodeIds } = await captureViewModel(makeCdp(snapshot), 4);
|
const { nodes, excludedBackendNodeIds } = semanticCapture(
|
||||||
|
await captureObservationFacts(makeCdp(snapshot), 4),
|
||||||
|
).captured;
|
||||||
expect(nodes.map((n) => n.backendNodeId)).not.toContain(13);
|
expect(nodes.map((n) => n.backendNodeId)).not.toContain(13);
|
||||||
expect(excludedBackendNodeIds).toEqual(new Set([13]));
|
expect(excludedBackendNodeIds).toEqual(new Set([13]));
|
||||||
});
|
});
|
||||||
@@ -610,6 +619,7 @@ describe("captureViewModel", () => {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1],
|
parentIndex: [-1, 0, 1],
|
||||||
nodeName: [i("html"), i("body"), i("div")],
|
nodeName: [i("html"), i("body"), i("div")],
|
||||||
@@ -632,7 +642,9 @@ describe("captureViewModel", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { nodes, excludedBackendNodeIds } = await captureViewModel(makeCdp(snapshot), 4);
|
const { nodes, excludedBackendNodeIds } = semanticCapture(
|
||||||
|
await captureObservationFacts(makeCdp(snapshot), 4),
|
||||||
|
).captured;
|
||||||
expect(nodes.map((n) => n.backendNodeId)).not.toContain(13);
|
expect(nodes.map((n) => n.backendNodeId)).not.toContain(13);
|
||||||
expect(excludedBackendNodeIds).toEqual(new Set([13]));
|
expect(excludedBackendNodeIds).toEqual(new Set([13]));
|
||||||
});
|
});
|
||||||
@@ -655,6 +667,7 @@ describe("captureViewModel", () => {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1],
|
parentIndex: [-1, 0, 1],
|
||||||
nodeName: [i("html"), i("body"), i("div")],
|
nodeName: [i("html"), i("body"), i("div")],
|
||||||
@@ -677,7 +690,7 @@ describe("captureViewModel", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const { nodes } = await captureViewModel(makeCdp(snapshot), 4);
|
const { nodes } = semanticCapture(await captureObservationFacts(makeCdp(snapshot), 4)).captured;
|
||||||
expect(nodes.find((n) => n.backendNodeId === 12)?.cursor).toBe("pointer");
|
expect(nodes.find((n) => n.backendNodeId === 12)?.cursor).toBe("pointer");
|
||||||
expect(nodes.find((n) => n.backendNodeId === 11)?.cursor).toBe("auto");
|
expect(nodes.find((n) => n.backendNodeId === 11)?.cursor).toBe("auto");
|
||||||
});
|
});
|
||||||
@@ -689,6 +702,7 @@ describe("captureViewModel", () => {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1, 1, 1],
|
parentIndex: [-1, 0, 1, 1, 1],
|
||||||
nodeName: [i("html"), i("body"), i("button"), i("button"), i("button")],
|
nodeName: [i("html"), i("body"), i("button"), i("button"), i("button")],
|
||||||
@@ -715,7 +729,7 @@ describe("captureViewModel", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { nodes } = await captureViewModel(makeCdp(snapshot), 4);
|
const { nodes } = semanticCapture(await captureObservationFacts(makeCdp(snapshot), 4)).captured;
|
||||||
|
|
||||||
expect(nodes.find((node) => node.backendNodeId === 12)?.rendered).toBe(true);
|
expect(nodes.find((node) => node.backendNodeId === 12)?.rendered).toBe(true);
|
||||||
expect(nodes.find((node) => node.backendNodeId === 13)?.rendered).toBe(false);
|
expect(nodes.find((node) => node.backendNodeId === 13)?.rendered).toBe(false);
|
||||||
@@ -729,25 +743,28 @@ describe("captureViewModel", () => {
|
|||||||
if (method === "DOMSnapshot.captureSnapshot") return fakeSnapshotReply();
|
if (method === "DOMSnapshot.captureSnapshot") return fakeSnapshotReply();
|
||||||
if (method === "Page.getLayoutMetrics") {
|
if (method === "Page.getLayoutMetrics") {
|
||||||
return {
|
return {
|
||||||
|
visualViewport: { clientWidth: 1000 },
|
||||||
|
cssVisualViewport: { clientWidth: 1000 },
|
||||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 200 },
|
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 200 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw new Error(method);
|
throw new Error(method);
|
||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
const { nodes } = await captureViewModel(cdp, 4);
|
const { nodes } = semanticCapture(await captureObservationFacts(cdp, 4)).captured;
|
||||||
const div = nodes.find((n) => n.backendNodeId === 12);
|
const div = nodes.find((n) => n.backendNodeId === 12);
|
||||||
expect(div?.localRect?.y).toBe(-200);
|
expect(div?.localRect?.y).toBe(-200);
|
||||||
expect(div?.rect).toMatchObject({ y: 0, h: 600 });
|
expect(div?.rect).toMatchObject({ y: 0, h: 600 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes device-pixel bounds by devicePixelRatio", async () => {
|
it("normalizes raw snapshot bounds before viewport clipping", async () => {
|
||||||
const S = ["html", "body", "div", "position", "fixed", "static", "pointer-events", "auto"];
|
const S = ["html", "body", "div", "position", "fixed", "static", "pointer-events", "auto"];
|
||||||
const i = (s: string) => S.indexOf(s);
|
const i = (s: string) => S.indexOf(s);
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1],
|
parentIndex: [-1, 0, 1],
|
||||||
nodeName: [i("html"), i("body"), i("div")],
|
nodeName: [i("html"), i("body"), i("div")],
|
||||||
@@ -775,6 +792,8 @@ describe("captureViewModel", () => {
|
|||||||
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
||||||
if (method === "Page.getLayoutMetrics") {
|
if (method === "Page.getLayoutMetrics") {
|
||||||
return {
|
return {
|
||||||
|
visualViewport: { clientWidth: 2000 },
|
||||||
|
cssVisualViewport: { clientWidth: 1000 },
|
||||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||||
layoutViewport: { clientWidth: 2000, clientHeight: 1600 },
|
layoutViewport: { clientWidth: 2000, clientHeight: 1600 },
|
||||||
};
|
};
|
||||||
@@ -782,13 +801,19 @@ describe("captureViewModel", () => {
|
|||||||
throw new Error(method);
|
throw new Error(method);
|
||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
const { nodes } = await captureViewModel(cdp, 4);
|
const { nodes } = semanticCapture(await captureObservationFacts(cdp, 4)).captured;
|
||||||
expect(nodes.find((n) => n.backendNodeId === 12)?.rect).toEqual({
|
expect(nodes.find((n) => n.backendNodeId === 12)?.rect).toEqual({
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
w: 1000,
|
w: 1000,
|
||||||
h: 800,
|
h: 800,
|
||||||
});
|
});
|
||||||
|
expect(nodes.find((n) => n.backendNodeId === 12)?.localRect).toEqual({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: 1000,
|
||||||
|
h: 800,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts textContent from #text child nodes via nodeValue", async () => {
|
it("extracts textContent from #text child nodes via nodeValue", async () => {
|
||||||
@@ -812,6 +837,7 @@ describe("captureViewModel", () => {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
scrollOffsetX: 0,
|
scrollOffsetX: 0,
|
||||||
scrollOffsetY: 0,
|
scrollOffsetY: 0,
|
||||||
nodes: {
|
nodes: {
|
||||||
@@ -844,13 +870,15 @@ describe("captureViewModel", () => {
|
|||||||
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
||||||
if (method === "Page.getLayoutMetrics") {
|
if (method === "Page.getLayoutMetrics") {
|
||||||
return {
|
return {
|
||||||
|
visualViewport: { clientWidth: 1000 },
|
||||||
|
cssVisualViewport: { clientWidth: 1000 },
|
||||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw new Error(method);
|
throw new Error(method);
|
||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
const { nodes } = await captureViewModel(cdp, 4);
|
const { nodes } = semanticCapture(await captureObservationFacts(cdp, 4)).captured;
|
||||||
const btn = nodes.find((n) => n.backendNodeId === 12);
|
const btn = nodes.find((n) => n.backendNodeId === 12);
|
||||||
expect(btn?.textContent).toBe("登录");
|
expect(btn?.textContent).toBe("登录");
|
||||||
const link = nodes.find((n) => n.backendNodeId === 14);
|
const link = nodes.find((n) => n.backendNodeId === 14);
|
||||||
@@ -860,7 +888,7 @@ describe("captureViewModel", () => {
|
|||||||
expect(body?.textContent).toBeUndefined();
|
expect(body?.textContent).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses iframe sub-documents and returns iframeNodes keyed by iframe backendNodeId", async () => {
|
it("retains explicit iframe document ownership and projected nodes", async () => {
|
||||||
const S = [
|
const S = [
|
||||||
"html",
|
"html",
|
||||||
"body",
|
"body",
|
||||||
@@ -934,39 +962,38 @@ describe("captureViewModel", () => {
|
|||||||
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
||||||
if (method === "Page.getLayoutMetrics") {
|
if (method === "Page.getLayoutMetrics") {
|
||||||
return {
|
return {
|
||||||
|
visualViewport: { clientWidth: 1000 },
|
||||||
|
cssVisualViewport: { clientWidth: 1000 },
|
||||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (method === "DOM.getBoxModel")
|
||||||
|
return { model: { content: [100, 300, 900, 300, 900, 700, 100, 700] } };
|
||||||
|
if (method === "DOM.resolveNode") return { object: { objectId: "owner" } };
|
||||||
|
if (method === "Runtime.callFunctionOn")
|
||||||
|
return { result: { value: { width: 800, height: 400 } } };
|
||||||
|
if (method === "Runtime.releaseObject") return {};
|
||||||
throw new Error(method);
|
throw new Error(method);
|
||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
const {
|
const facts = await captureObservationFacts(cdp, 4);
|
||||||
nodes,
|
const root = facts.documents.find((doc) => doc.frame.frameId === "main-frame")!;
|
||||||
iframeNodes,
|
const child = facts.documents.find((doc) => doc.frame.frameId === "child-frame")!;
|
||||||
frameNodes,
|
expect(facts.documents).toHaveLength(2);
|
||||||
frameOwnerBackendNodeIds,
|
expect(root.domNodes.find((n) => n.backendNodeId === 13)?.tag).toBe("iframe");
|
||||||
frameParentIds,
|
const subNodes = child.domNodes;
|
||||||
rootFrameId,
|
|
||||||
} = await captureViewModel(cdp, 4);
|
|
||||||
// Main frame has 4 nodes; iframe is included
|
|
||||||
expect(nodes.find((n) => n.backendNodeId === 13)?.tag).toBe("iframe");
|
|
||||||
// iframeNodes keyed by the <iframe> element's backendNodeId=13
|
|
||||||
expect(iframeNodes.size).toBe(1);
|
|
||||||
expect(iframeNodes.has(13)).toBe(true);
|
|
||||||
const subNodes = iframeNodes.get(13)!;
|
|
||||||
const input = subNodes.find((n) => n.backendNodeId === 101);
|
const input = subNodes.find((n) => n.backendNodeId === 101);
|
||||||
expect(input?.tag).toBe("input");
|
expect(input?.tag).toBe("input");
|
||||||
expect(input?.attrs.type).toBe("text");
|
expect(input?.attrs.type).toBe("text");
|
||||||
expect(input?.ownerFrameBackendNodeId).toBe(13);
|
expect(input?.ownerFrameBackendNodeId).toBe(13);
|
||||||
expect(input?.localRect).toEqual({ x: 0, y: 0, w: 200, h: 40 });
|
expect(input?.localRect).toEqual({ x: 0, y: 0, w: 200, h: 40 });
|
||||||
expect(input?.rect).toEqual({ x: 100, y: 300, w: 200, h: 40 });
|
expect(input?.rect).toEqual({ x: 100, y: 300, w: 200, h: 40 });
|
||||||
expect(rootFrameId).toBe("main-frame");
|
expect(facts.rootFrameId).toBe("main-frame");
|
||||||
expect(frameNodes?.get("child-frame")).toBe(subNodes);
|
expect(child.frame.ownerBackendNodeId).toBe(13);
|
||||||
expect(frameOwnerBackendNodeIds?.get("child-frame")).toBe(13);
|
expect(child.frame.parentFrameId).toBe("main-frame");
|
||||||
expect(frameParentIds?.get("child-frame")).toBe("main-frame");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("captures iframe documents without requiring owner-frame geometry", async () => {
|
it("projects iframe documents independently of owner snapshot geometry", async () => {
|
||||||
const S = [
|
const S = [
|
||||||
"html",
|
"html",
|
||||||
"body",
|
"body",
|
||||||
@@ -984,6 +1011,7 @@ describe("captureViewModel", () => {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
scrollOffsetX: 0,
|
scrollOffsetX: 0,
|
||||||
scrollOffsetY: 0,
|
scrollOffsetY: 0,
|
||||||
nodes: {
|
nodes: {
|
||||||
@@ -1001,6 +1029,7 @@ describe("captureViewModel", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
frameId: "child",
|
||||||
scrollOffsetX: 0,
|
scrollOffsetX: 0,
|
||||||
scrollOffsetY: 0,
|
scrollOffsetY: 0,
|
||||||
nodes: {
|
nodes: {
|
||||||
@@ -1024,18 +1053,28 @@ describe("captureViewModel", () => {
|
|||||||
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
||||||
if (method === "Page.getLayoutMetrics") {
|
if (method === "Page.getLayoutMetrics") {
|
||||||
return {
|
return {
|
||||||
|
visualViewport: { clientWidth: 1000 },
|
||||||
|
cssVisualViewport: { clientWidth: 1000 },
|
||||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (method === "DOM.getBoxModel")
|
||||||
|
return { model: { content: [100, 300, 900, 300, 900, 700, 100, 700] } };
|
||||||
|
if (method === "DOM.resolveNode") return { object: { objectId: "owner" } };
|
||||||
|
if (method === "Runtime.callFunctionOn")
|
||||||
|
return { result: { value: { width: 800, height: 400 } } };
|
||||||
|
if (method === "Runtime.releaseObject") return {};
|
||||||
throw new Error(method);
|
throw new Error(method);
|
||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { iframeNodes } = await captureViewModel(cdp, 4);
|
const { documents } = await captureObservationFacts(cdp, 4);
|
||||||
const input = iframeNodes.get(13)?.find((n) => n.backendNodeId === 101);
|
const input = documents
|
||||||
|
.find((doc) => doc.frame.frameId === "child")
|
||||||
|
?.domNodes?.find((n) => n.backendNodeId === 101);
|
||||||
|
|
||||||
expect(input?.localRect).toEqual({ x: 20, y: 20, w: 120, h: 60 });
|
expect(input?.localRect).toEqual({ x: 20, y: 20, w: 120, h: 60 });
|
||||||
expect(input?.rect).toBeNull();
|
expect(input?.rect).toEqual({ x: 120, y: 320, w: 120, h: 60 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recursively normalizes nested iframe sub-documents", async () => {
|
it("recursively normalizes nested iframe sub-documents", async () => {
|
||||||
@@ -1056,6 +1095,7 @@ describe("captureViewModel", () => {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1],
|
parentIndex: [-1, 0, 1],
|
||||||
nodeName: [i("html"), i("body"), i("iframe")],
|
nodeName: [i("html"), i("body"), i("iframe")],
|
||||||
@@ -1077,6 +1117,9 @@ describe("captureViewModel", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
scrollOffsetX: 0,
|
||||||
|
scrollOffsetY: 0,
|
||||||
|
frameId: "child",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1],
|
parentIndex: [-1, 0, 1],
|
||||||
nodeName: [i("html"), i("body"), i("iframe")],
|
nodeName: [i("html"), i("body"), i("iframe")],
|
||||||
@@ -1098,6 +1141,9 @@ describe("captureViewModel", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
scrollOffsetX: 0,
|
||||||
|
scrollOffsetY: 0,
|
||||||
|
frameId: "nested",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0],
|
parentIndex: [-1, 0],
|
||||||
nodeName: [i("body"), i("input")],
|
nodeName: [i("body"), i("input")],
|
||||||
@@ -1114,21 +1160,54 @@ describe("captureViewModel", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
const cdp = {
|
const cdp = {
|
||||||
send: vi.fn(async (_t: number, method: string) => {
|
send: vi.fn(
|
||||||
if (method === "DOMSnapshot.enable") return {};
|
async (
|
||||||
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
_t: number,
|
||||||
if (method === "Page.getLayoutMetrics") {
|
method: string,
|
||||||
return {
|
params?: { backendNodeId?: number; objectId?: string },
|
||||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
) => {
|
||||||
};
|
if (method === "DOMSnapshot.enable") return {};
|
||||||
}
|
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
||||||
throw new Error(method);
|
if (method === "Page.getLayoutMetrics") {
|
||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
return {
|
||||||
|
visualViewport: { clientWidth: 1000 },
|
||||||
|
cssVisualViewport: { clientWidth: 1000 },
|
||||||
|
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (method === "DOM.getBoxModel")
|
||||||
|
return {
|
||||||
|
model: {
|
||||||
|
content:
|
||||||
|
params?.backendNodeId === 13
|
||||||
|
? [10, 20, 310, 20, 310, 220, 10, 220]
|
||||||
|
: [15, 26, 115, 26, 115, 106, 15, 106],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (method === "DOM.resolveNode")
|
||||||
|
return { object: { objectId: String(params?.backendNodeId) } };
|
||||||
|
if (method === "Runtime.callFunctionOn")
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
value:
|
||||||
|
params?.objectId === "13"
|
||||||
|
? { width: 300, height: 200 }
|
||||||
|
: { width: 100, height: 80 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (method === "Runtime.releaseObject") return {};
|
||||||
|
throw new Error(method);
|
||||||
|
},
|
||||||
|
) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { iframeNodes } = await captureViewModel(cdp, 4);
|
const { documents } = await captureObservationFacts(cdp, 4);
|
||||||
const nestedIframe = iframeNodes.get(13)?.find((n) => n.backendNodeId === 23);
|
const nestedIframe = documents
|
||||||
const input = iframeNodes.get(23)?.find((n) => n.backendNodeId === 31);
|
.find((doc) => doc.frame.frameId === "child")
|
||||||
|
?.domNodes?.find((n) => n.backendNodeId === 23);
|
||||||
|
const input = documents
|
||||||
|
.find((doc) => doc.frame.frameId === "nested")
|
||||||
|
?.domNodes?.find((n) => n.backendNodeId === 31);
|
||||||
|
|
||||||
expect(nestedIframe?.localRect).toEqual({ x: 5, y: 6, w: 100, h: 80 });
|
expect(nestedIframe?.localRect).toEqual({ x: 5, y: 6, w: 100, h: 80 });
|
||||||
expect(nestedIframe?.rect).toEqual({ x: 15, y: 26, w: 100, h: 80 });
|
expect(nestedIframe?.rect).toEqual({ x: 15, y: 26, w: 100, h: 80 });
|
||||||
@@ -1137,13 +1216,14 @@ describe("captureViewModel", () => {
|
|||||||
expect(input?.rect).toEqual({ x: 16, y: 28, w: 40, h: 20 });
|
expect(input?.rect).toEqual({ x: 16, y: 28, w: 40, h: 20 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes bounds then subtracts CSS scroll at dpr>1", async () => {
|
it("subtracts CSS scroll once without dividing snapshot bounds by a metrics ratio", async () => {
|
||||||
const S = ["html", "body", "div", "position", "fixed", "static", "pointer-events", "auto"];
|
const S = ["html", "body", "div", "position", "fixed", "static", "pointer-events", "auto"];
|
||||||
const i = (s: string) => S.indexOf(s);
|
const i = (s: string) => S.indexOf(s);
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1],
|
parentIndex: [-1, 0, 1],
|
||||||
nodeName: [i("html"), i("body"), i("div")],
|
nodeName: [i("html"), i("body"), i("div")],
|
||||||
@@ -1171,6 +1251,8 @@ describe("captureViewModel", () => {
|
|||||||
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
||||||
if (method === "Page.getLayoutMetrics") {
|
if (method === "Page.getLayoutMetrics") {
|
||||||
return {
|
return {
|
||||||
|
visualViewport: { clientWidth: 2000 },
|
||||||
|
cssVisualViewport: { clientWidth: 1000 },
|
||||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 100 },
|
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 100 },
|
||||||
layoutViewport: { clientWidth: 2000, clientHeight: 1600 },
|
layoutViewport: { clientWidth: 2000, clientHeight: 1600 },
|
||||||
};
|
};
|
||||||
@@ -1178,7 +1260,7 @@ describe("captureViewModel", () => {
|
|||||||
throw new Error(method);
|
throw new Error(method);
|
||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
const { nodes } = await captureViewModel(cdp, 4);
|
const { nodes } = semanticCapture(await captureObservationFacts(cdp, 4)).captured;
|
||||||
expect(nodes.find((n) => n.backendNodeId === 12)?.rect?.y).toBe(400 / 2 - 100);
|
expect(nodes.find((n) => n.backendNodeId === 12)?.rect?.y).toBe(400 / 2 - 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { OVERLAY_HOST_MARKER_ATTR } from "@/lib/overlay-bridge";
|
||||||
|
import { createCaptureCheckpoint } from "../capture-abort";
|
||||||
|
import { buildDocumentIndex, type DecodedNode } from "../facts";
|
||||||
|
import { decodeDocument, REQUESTED_STYLES } from "../snapshot";
|
||||||
|
|
||||||
|
function node(id: number, parent: number | null): DecodedNode {
|
||||||
|
return {
|
||||||
|
backendNodeId: id,
|
||||||
|
parentBackendNodeId: parent,
|
||||||
|
tag: "div",
|
||||||
|
attrs: {},
|
||||||
|
paintOrder: 0,
|
||||||
|
position: "static",
|
||||||
|
pointerEvents: "auto",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("document facts", () => {
|
||||||
|
it.each([
|
||||||
|
1_000, 10_000, 100_000,
|
||||||
|
])("indexes deep and wide inputs of %i without copying ancestors", async (count) => {
|
||||||
|
for (const wide of [false, true]) {
|
||||||
|
let reads = 0;
|
||||||
|
const input = Array.from({ length: count }, (_, i) => {
|
||||||
|
const item = node(i, i ? (wide ? 0 : i - 1) : null);
|
||||||
|
if (i === 0) item.attrs[OVERLAY_HOST_MARKER_ATTR] = "";
|
||||||
|
Object.defineProperty(item, "parentBackendNodeId", {
|
||||||
|
get: () => {
|
||||||
|
reads++;
|
||||||
|
return i ? (wide ? 0 : i - 1) : null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return item;
|
||||||
|
}).reverse(); // parent need not precede child
|
||||||
|
const index = await buildDocumentIndex(input);
|
||||||
|
expect(index.nodes.size).toBe(count);
|
||||||
|
expect(index.excludedBackendNodeIds.size).toBe(count);
|
||||||
|
expect(reads).toBeLessThanOrEqual(count * 8);
|
||||||
|
expect(index.nodes.get(count - 1)).toBe(input[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles cycles and orphans and propagates overlay through shadow roots", async () => {
|
||||||
|
const host = { ...node(1, null), attrs: { [OVERLAY_HOST_MARKER_ATTR]: "" } };
|
||||||
|
const input = [
|
||||||
|
node(3, 2),
|
||||||
|
{ ...node(2, 1), tag: "#document-fragment" },
|
||||||
|
host,
|
||||||
|
node(4, 99),
|
||||||
|
node(5, 6),
|
||||||
|
node(6, 5),
|
||||||
|
node(7, 6),
|
||||||
|
];
|
||||||
|
const index = await buildDocumentIndex(input);
|
||||||
|
expect([...index.excludedBackendNodeIds].sort()).toEqual([1, 2, 3]);
|
||||||
|
expect(index.nodes.size).toBe(input.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not propagate a descendant overlay backwards into a cycle", async () => {
|
||||||
|
const overlay = { ...node(3, 1), attrs: { [OVERLAY_HOST_MARKER_ATTR]: "" } };
|
||||||
|
const index = await buildDocumentIndex([overlay, node(1, 2), node(2, 1)]);
|
||||||
|
expect([...index.excludedBackendNodeIds]).toEqual([3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes current observation bounds, styles and attributes without projecting coordinates", async () => {
|
||||||
|
const strings = ["div", "aria-hidden", "true", "inert", "", "visible", "0", "static", "auto"];
|
||||||
|
const styles = REQUESTED_STYLES.map((style) =>
|
||||||
|
style === "visibility" ? 5 : style === "opacity" ? 6 : style === "position" ? 7 : 8,
|
||||||
|
);
|
||||||
|
const { nodes } = await decodeDocument(
|
||||||
|
{
|
||||||
|
nodes: { backendNodeId: [1], parentIndex: [-1], nodeName: [0], attributes: [[1, 2, 3, 4]] },
|
||||||
|
layout: { nodeIndex: [0], bounds: [[10, 20, 120, 40]], styles: [styles] },
|
||||||
|
},
|
||||||
|
strings,
|
||||||
|
);
|
||||||
|
expect(nodes[0].attrs).toEqual({ "aria-hidden": "true", inert: "" });
|
||||||
|
expect(nodes[0].layout).toEqual({
|
||||||
|
boundsSpace: "snapshot-document-layout",
|
||||||
|
bounds: [10, 20, 120, 40],
|
||||||
|
styles: {
|
||||||
|
position: "static",
|
||||||
|
"pointer-events": "auto",
|
||||||
|
cursor: "auto",
|
||||||
|
visibility: "visible",
|
||||||
|
opacity: "0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(nodes[0]).not.toHaveProperty("rect");
|
||||||
|
expect(nodes[0]).not.toHaveProperty("excluded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps short blocks synchronous and checks cancellation after yielding", async () => {
|
||||||
|
const clock = vi.spyOn(performance, "now").mockReturnValue(0);
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const checkpoint = createCaptureCheckpoint(controller.signal);
|
||||||
|
for (let i = 0; i < 20; i++) expect(checkpoint()).toBeUndefined();
|
||||||
|
clock.mockReturnValue(9);
|
||||||
|
const pending = checkpoint();
|
||||||
|
expect(pending).toBeInstanceOf(Promise);
|
||||||
|
controller.abort();
|
||||||
|
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||||
|
} finally {
|
||||||
|
clock.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks cancellation at bounded indexing blocks", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const input = Array.from({ length: 100_000 }, (_, i) => node(i, i ? i - 1 : null));
|
||||||
|
let reads = 0;
|
||||||
|
for (const item of input)
|
||||||
|
Object.defineProperty(item, "attrs", {
|
||||||
|
get: () => {
|
||||||
|
reads++;
|
||||||
|
if (reads === 100) controller.abort();
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(buildDocumentIndex(input, controller.signal)).rejects.toMatchObject({
|
||||||
|
name: "AbortError",
|
||||||
|
});
|
||||||
|
expect(reads).toBeLessThanOrEqual(356);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { type CdpRunner, cdpRunnerForTarget } from "../../shared";
|
||||||
|
import { enrichFormControlStates } from "../form-capture";
|
||||||
|
import { decodeDocument } from "../snapshot";
|
||||||
|
|
||||||
|
interface Control {
|
||||||
|
id: number;
|
||||||
|
value: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(groups: Control[][]) {
|
||||||
|
const strings: string[] = [];
|
||||||
|
const str = (value: string) => {
|
||||||
|
let index = strings.indexOf(value);
|
||||||
|
if (index < 0) index = strings.push(value) - 1;
|
||||||
|
return index;
|
||||||
|
};
|
||||||
|
const documents = groups.map((controls, groupIndex) => ({
|
||||||
|
frameId: `frame-${groupIndex}`,
|
||||||
|
nodes: {
|
||||||
|
parentIndex: [-1, 0, ...controls.map(() => 1)],
|
||||||
|
nodeName: [str("html"), str("body"), ...controls.map(() => str("input"))],
|
||||||
|
backendNodeId: [8000 + groupIndex * 2, 8001 + groupIndex * 2, ...controls.map((c) => c.id)],
|
||||||
|
attributes: [
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
...controls.map((c) => [
|
||||||
|
str("type"),
|
||||||
|
str(c.type ?? "text"),
|
||||||
|
str("placeholder"),
|
||||||
|
str("snapshot placeholder"),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
inputValue: {
|
||||||
|
index: controls.map((_, i) => i + 2),
|
||||||
|
value: controls.map((c) => str(c.value)),
|
||||||
|
},
|
||||||
|
contentDocumentIndex: { index: [] as number[], value: [] as number[] },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const root = documents[0].nodes;
|
||||||
|
for (let i = 1; i < documents.length; i++) {
|
||||||
|
root.contentDocumentIndex.index.push(root.backendNodeId.length);
|
||||||
|
root.contentDocumentIndex.value.push(i);
|
||||||
|
root.parentIndex.push(1);
|
||||||
|
root.nodeName.push(str("iframe"));
|
||||||
|
root.backendNodeId.push(9000 + i);
|
||||||
|
root.attributes.push([]);
|
||||||
|
}
|
||||||
|
return { strings, documents };
|
||||||
|
}
|
||||||
|
|
||||||
|
function state(value: string) {
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
defaultValue: "",
|
||||||
|
placeholder: "live placeholder",
|
||||||
|
state: value ? "filled" : "empty",
|
||||||
|
sensitive: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reply(entries: Array<[number, unknown]>) {
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
deepSerializedValue: {
|
||||||
|
type: "array",
|
||||||
|
value: entries.map(([backendNodeId, fields]) => ({
|
||||||
|
type: "array",
|
||||||
|
value: [
|
||||||
|
{ type: "node", value: { backendNodeId } },
|
||||||
|
{ type: "string", value: JSON.stringify(fields) },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fakeCdp(
|
||||||
|
groups: Control[][],
|
||||||
|
evaluate: (params: Record<string, unknown>) => unknown,
|
||||||
|
) {
|
||||||
|
const snap = snapshot(groups);
|
||||||
|
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
|
||||||
|
if (method === "Runtime.evaluate") return evaluate(params as Record<string, unknown>);
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
const nodes = await decodeControls(snap);
|
||||||
|
return { send, cdp: { send: send as CdpRunner["send"] }, nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decodeControls(snap: ReturnType<typeof snapshot>) {
|
||||||
|
return Promise.all(
|
||||||
|
snap.documents.map(async (doc) =>
|
||||||
|
(await decodeDocument(doc, snap.strings)).nodes.map((node) => ({ ...node, rect: null })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("form state identity", () => {
|
||||||
|
it("matches reordered controls by identity and preserves controls missing from the batch", async () => {
|
||||||
|
// The snapshot includes a shadow input. The runtime query skips it,
|
||||||
|
// encounters a new input, and sees the remaining inputs in a new order.
|
||||||
|
const { cdp, nodes } = await fakeCdp(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
{ id: 10, value: "" },
|
||||||
|
{ id: 11, value: "old email" },
|
||||||
|
{ id: 12, value: "old city" },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
() =>
|
||||||
|
reply([
|
||||||
|
[99, state("new control")],
|
||||||
|
[12, state("上海")],
|
||||||
|
[11, state("alice@example.com")],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await enrichFormControlStates(cdp, 7, nodes);
|
||||||
|
expect(nodes[0].find((n) => n.backendNodeId === 10)).toMatchObject({
|
||||||
|
formValue: "",
|
||||||
|
formState: "empty",
|
||||||
|
formPlaceholder: "snapshot placeholder",
|
||||||
|
});
|
||||||
|
expect(nodes[0].find((n) => n.backendNodeId === 11)).toMatchObject({
|
||||||
|
formValue: "alice@example.com",
|
||||||
|
});
|
||||||
|
expect(nodes[0].find((n) => n.backendNodeId === 12)).toMatchObject({ formValue: "上海" });
|
||||||
|
expect(nodes[0].some((n) => n.backendNodeId === 99)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not shift state between frames when one frame is absent from the runtime batch", async () => {
|
||||||
|
const { cdp, nodes } = await fakeCdp(
|
||||||
|
[
|
||||||
|
[],
|
||||||
|
[{ id: 11, value: "inaccessible frame" }],
|
||||||
|
[{ id: 21, value: "old second" }],
|
||||||
|
[{ id: 31, value: "old third" }],
|
||||||
|
],
|
||||||
|
() =>
|
||||||
|
reply([
|
||||||
|
[31, state("third frame")],
|
||||||
|
[21, state("second frame")],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await enrichFormControlStates(cdp, 7, nodes);
|
||||||
|
const controls = nodes.slice(1).flat();
|
||||||
|
expect(controls.find((n) => n.backendNodeId === 11)?.formValue).toBe("inaccessible frame");
|
||||||
|
expect(controls.find((n) => n.backendNodeId === 21)?.formValue).toBe("second frame");
|
||||||
|
expect(controls.find((n) => n.backendNodeId === 31)?.formValue).toBe("third frame");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps identical backend ids in different OOPIF targets separate", async () => {
|
||||||
|
const sendToTarget = vi.fn(async (target, method) => {
|
||||||
|
if (method === "Runtime.evaluate") return reply([[11, state(target.sessionId)]]);
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
const send = vi.fn(async () => {
|
||||||
|
throw new Error("unexpected top-level target");
|
||||||
|
});
|
||||||
|
const cdp = { send, sendToTarget } as unknown as CdpRunner;
|
||||||
|
for (const sessionId of ["left-frame", "right-frame"]) {
|
||||||
|
const nodes = await decodeControls(snapshot([[{ id: 11, value: "old" }]]));
|
||||||
|
await enrichFormControlStates(cdpRunnerForTarget(cdp, { tabId: 7, sessionId }), 7, nodes);
|
||||||
|
expect(nodes[0].find((n) => n.backendNodeId === 11)?.formValue).toBe(sessionId);
|
||||||
|
expect(sendToTarget).toHaveBeenCalledWith(
|
||||||
|
{ tabId: 7, sessionId },
|
||||||
|
"Runtime.releaseObjectGroup",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"unsupported",
|
||||||
|
"legacy",
|
||||||
|
"malformed",
|
||||||
|
"invalid-state",
|
||||||
|
])("preserves snapshot data when enrichment is %s", async (mode) => {
|
||||||
|
const { cdp, send, nodes } = await fakeCdp([[{ id: 11, value: "snapshot value" }]], () => {
|
||||||
|
if (mode === "unsupported") throw new Error("serialization unsupported");
|
||||||
|
if (mode === "legacy") return { result: { value: { controls: [state("wrong value")] } } };
|
||||||
|
const result = reply([
|
||||||
|
[11, mode === "invalid-state" ? { value: "wrong value" } : state("wrong value")],
|
||||||
|
]);
|
||||||
|
if (mode === "malformed")
|
||||||
|
result.result.deepSerializedValue.value[0].value[1].value = "not json";
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
expect(await enrichFormControlStates(cdp, 7, nodes)).toBe(mode !== "unsupported");
|
||||||
|
expect(nodes[0].find((n) => n.backendNodeId === 11)).toMatchObject({
|
||||||
|
formValue: "snapshot value",
|
||||||
|
formPlaceholder: "snapshot placeholder",
|
||||||
|
});
|
||||||
|
expect(send).toHaveBeenCalledWith(7, "Runtime.releaseObjectGroup", expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases the batch's remote objects when capture is cancelled", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const { cdp, send, nodes } = await fakeCdp([[{ id: 11, value: "old" }]], () => {
|
||||||
|
controller.abort();
|
||||||
|
return reply([[11, state("new")]]);
|
||||||
|
});
|
||||||
|
await expect(enrichFormControlStates(cdp, 7, nodes, controller.signal)).rejects.toMatchObject({
|
||||||
|
name: "AbortError",
|
||||||
|
});
|
||||||
|
expect(send).toHaveBeenCalledWith(7, "Runtime.releaseObjectGroup", expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not report success when cancelled during object cleanup", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const nodes = await decodeControls(snapshot([[{ id: 11, value: "old" }]]));
|
||||||
|
const cdp: CdpRunner = {
|
||||||
|
send: vi.fn(async (_tabId, method) => {
|
||||||
|
if (method === "Runtime.evaluate") return reply([[11, state("new")]]);
|
||||||
|
if (method === "Runtime.releaseObjectGroup") controller.abort();
|
||||||
|
return {};
|
||||||
|
}) as CdpRunner["send"],
|
||||||
|
};
|
||||||
|
await expect(enrichFormControlStates(cdp, 7, nodes, controller.signal)).rejects.toMatchObject({
|
||||||
|
name: "AbortError",
|
||||||
|
});
|
||||||
|
expect(cdp.send).toHaveBeenCalledWith(7, "Runtime.releaseObjectGroup", expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips entries without node identity while still applying valid entries", async () => {
|
||||||
|
const { cdp, nodes } = await fakeCdp(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
{ id: 11, value: "snapshot value" },
|
||||||
|
{ id: 12, value: "old value" },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
const result = reply([
|
||||||
|
[11, state("wrong value")],
|
||||||
|
[12, state("live value")],
|
||||||
|
]);
|
||||||
|
const element = result.result.deepSerializedValue.value[0].value[0];
|
||||||
|
delete (element.value as { backendNodeId?: number }).backendNodeId;
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await enrichFormControlStates(cdp, 7, nodes);
|
||||||
|
expect(nodes[0].find((n) => n.backendNodeId === 11)?.formValue).toBe("snapshot value");
|
||||||
|
expect(nodes[0].find((n) => n.backendNodeId === 12)?.formValue).toBe("live value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"password",
|
||||||
|
"text",
|
||||||
|
])("redacts a sensitive %s control after identity matching", async (type) => {
|
||||||
|
const { cdp, nodes } = await fakeCdp([[{ id: 11, value: "secret", type }]], () =>
|
||||||
|
reply([[11, { ...state("secret"), sensitive: true }]]),
|
||||||
|
);
|
||||||
|
await enrichFormControlStates(cdp, 7, nodes);
|
||||||
|
const control = nodes[0].find((n) => n.backendNodeId === 11);
|
||||||
|
expect(control?.formState).toBe("filled");
|
||||||
|
expect(control?.formValue).toBeUndefined();
|
||||||
|
expect(control?.formDefaultValue).toBeUndefined();
|
||||||
|
expect(control?.attrs.value).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps one bounded batch without per-control CDP queries", async () => {
|
||||||
|
const controls = Array.from({ length: 251 }, (_, i) => ({ id: i + 1, value: "old" }));
|
||||||
|
const elements = controls.map((c) => ({
|
||||||
|
backendNodeId: c.id,
|
||||||
|
tagName: "INPUT",
|
||||||
|
type: "text",
|
||||||
|
value: `live ${c.id}`,
|
||||||
|
defaultValue: "",
|
||||||
|
placeholder: "",
|
||||||
|
}));
|
||||||
|
const { cdp, send, nodes } = await fakeCdp([controls], (params) => {
|
||||||
|
const doc = {
|
||||||
|
querySelectorAll: (selector: string) =>
|
||||||
|
selector === "input,textarea,select" ? elements : [],
|
||||||
|
};
|
||||||
|
const entries = new Function("document", `return ${params.expression}`)(doc) as Array<
|
||||||
|
[{ backendNodeId: number }, string]
|
||||||
|
>;
|
||||||
|
expect(entries).toHaveLength(250);
|
||||||
|
return reply(entries.map(([element, json]) => [element.backendNodeId, JSON.parse(json)]));
|
||||||
|
});
|
||||||
|
await enrichFormControlStates(cdp, 7, nodes);
|
||||||
|
expect(nodes[0].find((n) => n.backendNodeId === 250)?.formValue).toBe("live 250");
|
||||||
|
expect(nodes[0].find((n) => n.backendNodeId === 251)?.formValue).toBe("old");
|
||||||
|
expect(send.mock.calls.filter(([, method]) => method === "Runtime.evaluate")).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
send.mock.calls.filter(([, method]) => method === "Runtime.releaseObjectGroup"),
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(send).not.toHaveBeenCalledWith(7, "DOM.resolveNode", expect.anything());
|
||||||
|
expect(send).not.toHaveBeenCalledWith(7, "Runtime.callFunctionOn", expect.anything());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import type { CdpRunner } from "../../shared";
|
|
||||||
import type { CapturedNode, CapturedViewModel } from "../capture";
|
|
||||||
import { captureFrameData } from "../frame-capture";
|
|
||||||
|
|
||||||
function ownerNode(backendNodeId: number, x: number): CapturedNode {
|
|
||||||
return {
|
|
||||||
backendNodeId,
|
|
||||||
parentBackendNodeId: 1,
|
|
||||||
frameId: "main",
|
|
||||||
ownerFrameBackendNodeId: null,
|
|
||||||
tag: "iframe",
|
|
||||||
attrs: {},
|
|
||||||
rect: { x, y: 100, w: 300, h: 200 },
|
|
||||||
localRect: { x, y: 100, w: 300, h: 200 },
|
|
||||||
paintOrder: 1,
|
|
||||||
position: "static",
|
|
||||||
pointerEvents: "auto",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function childSnapshot(frameId: string, backendNodeId: number) {
|
|
||||||
const strings = [frameId, "body", "button", "static", "auto", "pointer"];
|
|
||||||
return {
|
|
||||||
strings,
|
|
||||||
documents: [
|
|
||||||
{
|
|
||||||
frameId,
|
|
||||||
nodes: {
|
|
||||||
parentIndex: [-1, 0],
|
|
||||||
nodeName: [1, 2],
|
|
||||||
backendNodeId: [backendNodeId - 1, backendNodeId],
|
|
||||||
attributes: [[], []],
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
nodeIndex: [0, 1],
|
|
||||||
styles: [
|
|
||||||
[3, 4, 4],
|
|
||||||
[3, 4, 5],
|
|
||||||
],
|
|
||||||
bounds: [
|
|
||||||
[0, 0, 300, 200],
|
|
||||||
[10, 20, 100, 40],
|
|
||||||
],
|
|
||||||
paintOrders: [0, 1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("captureFrameData", () => {
|
|
||||||
it("captures and positions multiple OOPIF documents missing from the root snapshot", async () => {
|
|
||||||
const leftOwner = ownerNode(10, 50);
|
|
||||||
const rightOwner = ownerNode(20, 500);
|
|
||||||
const captured: CapturedViewModel = {
|
|
||||||
nodes: [leftOwner, rightOwner],
|
|
||||||
viewport: { width: 1000, height: 800 },
|
|
||||||
iframeNodes: new Map(),
|
|
||||||
frameNodes: new Map([["main", [leftOwner, rightOwner]]]),
|
|
||||||
frameOwnerBackendNodeIds: new Map(),
|
|
||||||
rootFrameId: "main",
|
|
||||||
excludedBackendNodeIds: new Set(),
|
|
||||||
};
|
|
||||||
const sendToTarget = vi.fn(async (target, method) => {
|
|
||||||
if (method === "Page.getLayoutMetrics") {
|
|
||||||
return { cssLayoutViewport: { clientWidth: 300, clientHeight: 200, pageX: 0, pageY: 0 } };
|
|
||||||
}
|
|
||||||
if (method === "DOMSnapshot.enable" || method === "Accessibility.enable") return {};
|
|
||||||
if (method === "DOMSnapshot.captureSnapshot") {
|
|
||||||
return target.sessionId === "left-session"
|
|
||||||
? childSnapshot("left", 101)
|
|
||||||
: childSnapshot("right", 201);
|
|
||||||
}
|
|
||||||
if (method === "Accessibility.getFullAXTree") return { nodes: [] };
|
|
||||||
throw new Error(`unexpected ${method}`);
|
|
||||||
});
|
|
||||||
const cdp: CdpRunner = {
|
|
||||||
send: vi.fn(async (_tabId, method, params) => {
|
|
||||||
if (method === "Accessibility.enable") return {};
|
|
||||||
if (method === "Accessibility.getFullAXTree") return { nodes: [] };
|
|
||||||
if (method === "DOM.getBoxModel") {
|
|
||||||
const backendNodeId = (params as { backendNodeId?: number })?.backendNodeId;
|
|
||||||
const x = backendNodeId === 10 ? 50 : 500;
|
|
||||||
return { model: { content: [x, 100, x + 300, 100, x + 300, 300, x, 300] } };
|
|
||||||
}
|
|
||||||
if (method === "Page.getLayoutMetrics") {
|
|
||||||
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } };
|
|
||||||
}
|
|
||||||
throw new Error(`unexpected root ${method}`);
|
|
||||||
}) as CdpRunner["send"],
|
|
||||||
sendToTarget: sendToTarget as unknown as NonNullable<CdpRunner["sendToTarget"]>,
|
|
||||||
getFrameGraph: vi.fn(async () => ({
|
|
||||||
rootFrameId: "main",
|
|
||||||
frames: [
|
|
||||||
{ frameId: "main", target: { tabId: 4 } },
|
|
||||||
{
|
|
||||||
frameId: "left",
|
|
||||||
parentFrameId: "main",
|
|
||||||
ownerBackendNodeId: 10,
|
|
||||||
target: { tabId: 4, sessionId: "left-session" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
frameId: "right",
|
|
||||||
parentFrameId: "main",
|
|
||||||
ownerBackendNodeId: 20,
|
|
||||||
target: { tabId: 4, sessionId: "right-session" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const trees = await captureFrameData(cdp, 4, captured);
|
|
||||||
|
|
||||||
expect(trees.map((tree) => tree.frameId)).toEqual(["main", "left", "right"]);
|
|
||||||
expect(
|
|
||||||
captured.frameNodes?.get("left")?.find((node) => node.backendNodeId === 101)?.rect,
|
|
||||||
).toEqual({ x: 60, y: 120, w: 100, h: 40 });
|
|
||||||
expect(
|
|
||||||
captured.frameNodes?.get("right")?.find((node) => node.backendNodeId === 201)?.rect,
|
|
||||||
).toEqual({ x: 510, y: 120, w: 100, h: 40 });
|
|
||||||
expect(captured.frameOwnerBackendNodeIds).toEqual(
|
|
||||||
new Map([
|
|
||||||
["left", 10],
|
|
||||||
["right", 20],
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(captured.frameParentIds).toEqual(
|
|
||||||
new Map([
|
|
||||||
["left", "main"],
|
|
||||||
["right", "main"],
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps OOPIF semantics when viewport projection is unavailable", async () => {
|
|
||||||
const owner = { ...ownerNode(10, 50), rect: null, localRect: null };
|
|
||||||
const captured: CapturedViewModel = {
|
|
||||||
nodes: [owner],
|
|
||||||
viewport: { width: 1000, height: 800 },
|
|
||||||
iframeNodes: new Map(),
|
|
||||||
frameNodes: new Map([["main", [owner]]]),
|
|
||||||
rootFrameId: "main",
|
|
||||||
excludedBackendNodeIds: new Set(),
|
|
||||||
};
|
|
||||||
const cdp: CdpRunner = {
|
|
||||||
send: vi.fn(async (_tabId, method) => {
|
|
||||||
if (method === "Accessibility.enable") return {};
|
|
||||||
if (method === "Accessibility.getFullAXTree") return { nodes: [] };
|
|
||||||
if (method === "DOM.getBoxModel") throw new Error("owner geometry unavailable");
|
|
||||||
if (method === "Page.getLayoutMetrics") {
|
|
||||||
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } };
|
|
||||||
}
|
|
||||||
throw new Error(`unexpected root ${method}`);
|
|
||||||
}) as CdpRunner["send"],
|
|
||||||
sendToTarget: vi.fn(async (_target, method) => {
|
|
||||||
if (method === "Page.getLayoutMetrics") throw new Error("viewport unavailable");
|
|
||||||
if (method === "DOMSnapshot.enable" || method === "Accessibility.enable") return {};
|
|
||||||
if (method === "DOMSnapshot.captureSnapshot") return childSnapshot("child", 101);
|
|
||||||
if (method === "Accessibility.getFullAXTree") {
|
|
||||||
return {
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
nodeId: "button",
|
|
||||||
backendDOMNodeId: 101,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
name: { type: "computedString", value: "Continue" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
throw new Error(`unexpected ${method}`);
|
|
||||||
}) as unknown as NonNullable<CdpRunner["sendToTarget"]>,
|
|
||||||
getFrameGraph: vi.fn(async () => ({
|
|
||||||
rootFrameId: "main",
|
|
||||||
frames: [
|
|
||||||
{ frameId: "main", target: { tabId: 4 } },
|
|
||||||
{
|
|
||||||
frameId: "child",
|
|
||||||
parentFrameId: "main",
|
|
||||||
ownerBackendNodeId: 10,
|
|
||||||
target: { tabId: 4, sessionId: "child-session" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const documents = await captureFrameData(cdp, 4, captured);
|
|
||||||
const childDocument = documents.find((document) => document.frameId === "child");
|
|
||||||
|
|
||||||
expect(childDocument?.axNodes).toEqual([
|
|
||||||
expect.objectContaining({ backendDOMNodeId: 101, frameId: "child" }),
|
|
||||||
]);
|
|
||||||
expect(childDocument?.domNodes.find((node) => node.backendNodeId === 101)).toEqual(
|
|
||||||
expect.objectContaining({ rect: null, localRect: { x: 10, y: 20, w: 100, h: 40 } }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { CdpFrameGraph } from "@/browser-driver/frame-graph";
|
import type { CdpFrameGraph } from "@/browser-driver/frame-graph";
|
||||||
import type { CapturedNode, CapturedViewModel } from "../capture";
|
import type { CapturedNode } from "../capture";
|
||||||
import { buildFrameDocuments, type FrameAxBatch, type FrameOwnedAxNode } from "../frame-document";
|
import {
|
||||||
|
buildFrameDocuments,
|
||||||
|
type FrameAxBatch,
|
||||||
|
type FrameDomInput,
|
||||||
|
type FrameOwnedAxNode,
|
||||||
|
} from "../frame-document";
|
||||||
|
|
||||||
function domNode(frameId: string, backendNodeId: number): CapturedNode {
|
function domNode(frameId: string, backendNodeId: number): CapturedNode {
|
||||||
return {
|
return {
|
||||||
@@ -17,19 +22,16 @@ function domNode(frameId: string, backendNodeId: number): CapturedNode {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function captured(frameNodes: Map<string, CapturedNode[]>): CapturedViewModel {
|
function captured(frameNodes: Map<string, CapturedNode[]>): FrameDomInput {
|
||||||
return {
|
return {
|
||||||
nodes: frameNodes.get("main") ?? [],
|
nodes: frameNodes.get("main") ?? [],
|
||||||
viewport: { width: 800, height: 600 },
|
|
||||||
iframeNodes: new Map(),
|
|
||||||
frameNodes,
|
frameNodes,
|
||||||
rootFrameId: "main",
|
rootFrameId: "main",
|
||||||
excludedBackendNodeIds: new Set(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("buildFrameDocuments", () => {
|
describe("buildFrameDocuments", () => {
|
||||||
it("partitions mixed AX results by node ownership and cuts cross-frame parent edges", () => {
|
it("partitions mixed AX results by node ownership and cuts cross-frame parent edges", async () => {
|
||||||
const graph: CdpFrameGraph = {
|
const graph: CdpFrameGraph = {
|
||||||
rootFrameId: "main",
|
rootFrameId: "main",
|
||||||
frames: [
|
frames: [
|
||||||
@@ -54,7 +56,7 @@ describe("buildFrameDocuments", () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const documents = buildFrameDocuments(
|
const documents = await buildFrameDocuments(
|
||||||
graph,
|
graph,
|
||||||
batches,
|
batches,
|
||||||
captured(
|
captured(
|
||||||
@@ -72,7 +74,7 @@ describe("buildFrameDocuments", () => {
|
|||||||
expect(childNodes.find((node) => node.nodeId === "virtual")?.frameId).toBe("child");
|
expect(childNodes.find((node) => node.nodeId === "virtual")?.frameId).toBe("child");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deduplicates overlapping same-target AX results using the strongest ownership", () => {
|
it("deduplicates overlapping same-target AX results using the strongest ownership", async () => {
|
||||||
const graph: CdpFrameGraph = {
|
const graph: CdpFrameGraph = {
|
||||||
rootFrameId: "main",
|
rootFrameId: "main",
|
||||||
frames: [
|
frames: [
|
||||||
@@ -92,7 +94,7 @@ describe("buildFrameDocuments", () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const documents = buildFrameDocuments(
|
const documents = await buildFrameDocuments(
|
||||||
graph,
|
graph,
|
||||||
batches,
|
batches,
|
||||||
captured(
|
captured(
|
||||||
@@ -107,7 +109,7 @@ describe("buildFrameDocuments", () => {
|
|||||||
expect(documents.find((document) => document.frameId === "child")?.axNodes).toHaveLength(1);
|
expect(documents.find((document) => document.frameId === "child")?.axNodes).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses target-scoped backend ownership when OOPIF backend ids collide", () => {
|
it("uses target-scoped backend ownership when OOPIF backend ids collide", async () => {
|
||||||
const graph: CdpFrameGraph = {
|
const graph: CdpFrameGraph = {
|
||||||
rootFrameId: "main",
|
rootFrameId: "main",
|
||||||
frames: [
|
frames: [
|
||||||
@@ -129,7 +131,7 @@ describe("buildFrameDocuments", () => {
|
|||||||
nodes: [{ nodeId: `${frame.frameId}-node`, backendDOMNodeId: 7 }],
|
nodes: [{ nodeId: `${frame.frameId}-node`, backendDOMNodeId: 7 }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const documents = buildFrameDocuments(
|
const documents = await buildFrameDocuments(
|
||||||
graph,
|
graph,
|
||||||
batches,
|
batches,
|
||||||
captured(
|
captured(
|
||||||
@@ -149,7 +151,7 @@ describe("buildFrameDocuments", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses captured frame parent identity instead of guessing from backend ids", () => {
|
it("uses captured frame parent identity instead of guessing from backend ids", async () => {
|
||||||
const frameNodes = new Map([
|
const frameNodes = new Map([
|
||||||
["main", [domNode("main", 1)]],
|
["main", [domNode("main", 1)]],
|
||||||
["left", [domNode("left", 7)]],
|
["left", [domNode("left", 7)]],
|
||||||
@@ -157,18 +159,51 @@ describe("buildFrameDocuments", () => {
|
|||||||
["child", [domNode("child", 9)]],
|
["child", [domNode("child", 9)]],
|
||||||
]);
|
]);
|
||||||
const capture = captured(frameNodes);
|
const capture = captured(frameNodes);
|
||||||
capture.frameOwnerBackendNodeIds = new Map([["child", 7]]);
|
|
||||||
capture.frameParentIds = new Map([["child", "left"]]);
|
|
||||||
const frames = ["main", "left", "right", "child"].map((frameId) => ({
|
const frames = ["main", "left", "right", "child"].map((frameId) => ({
|
||||||
frame: { frameId, target: { tabId: 4 } },
|
frame: {
|
||||||
|
frameId,
|
||||||
|
target: { tabId: 4 },
|
||||||
|
...(frameId === "child" ? { parentFrameId: "left", ownerBackendNodeId: 7 } : {}),
|
||||||
|
},
|
||||||
nodes: [],
|
nodes: [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const documents = buildFrameDocuments(null, frames, capture);
|
const documents = await buildFrameDocuments(null, frames, capture);
|
||||||
|
|
||||||
expect(documents.find((document) => document.frameId === "child")).toMatchObject({
|
expect(documents.find((document) => document.frameId === "child")).toMatchObject({
|
||||||
parentFrameId: "left",
|
parentFrameId: "left",
|
||||||
ownerBackendNodeId: 7,
|
ownerBackendNodeId: 7,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it("rejects explicit AX frame ownership in a different target", async () => {
|
||||||
|
const frames = [
|
||||||
|
{ frameId: "main", target: { tabId: 4 } },
|
||||||
|
{ frameId: "remote", target: { tabId: 4, sessionId: "remote" } },
|
||||||
|
];
|
||||||
|
const unresolved = vi.fn();
|
||||||
|
const documents = await buildFrameDocuments(
|
||||||
|
{ rootFrameId: "main", frames },
|
||||||
|
[{ frame: frames[0], nodes: [{ nodeId: "wrong", frameId: "remote" }] }],
|
||||||
|
captured(new Map()),
|
||||||
|
undefined,
|
||||||
|
unresolved,
|
||||||
|
);
|
||||||
|
expect(documents.every((doc) => doc.axNodes.length === 0)).toBe(true);
|
||||||
|
expect(unresolved).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a reverse-ordered deep AX parent chain without recursion", async () => {
|
||||||
|
const frame = { frameId: "main", target: { tabId: 4 } };
|
||||||
|
const nodes: FrameOwnedAxNode[] = Array.from({ length: 10000 }, (_, i) => ({
|
||||||
|
nodeId: String(i),
|
||||||
|
...(i ? { parentId: String(i - 1) } : { frameId: "main" }),
|
||||||
|
})).reverse();
|
||||||
|
const documents = await buildFrameDocuments(
|
||||||
|
{ rootFrameId: "main", frames: [frame] },
|
||||||
|
[{ frame, nodes }],
|
||||||
|
captured(new Map()),
|
||||||
|
);
|
||||||
|
expect(documents[0].axNodes).toHaveLength(10000);
|
||||||
|
expect(documents[0].axNodes[0].frameId).toBe("main");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ProbeBudget, resetOverlayBypassDepth, withOverlayBypass } from "../hover-perception";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetOverlayBypassDepth();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ProbeBudget", () => {
|
||||||
|
it("refuses a candidate that cannot finish inside the budget", () => {
|
||||||
|
const budget = new ProbeBudget(500);
|
||||||
|
|
||||||
|
expect(budget.canAfford(400)).toBe(true);
|
||||||
|
expect(budget.canAfford(600)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops admitting candidates once the remaining budget is smaller than one", async () => {
|
||||||
|
const budget = new ProbeBudget(60);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||||
|
|
||||||
|
// A top-of-loop `elapsed > total` check would still admit this candidate
|
||||||
|
// and then overshoot by its full cost.
|
||||||
|
expect(budget.canAfford(50)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withOverlayBypass", () => {
|
||||||
|
it("toggles the overlay once around nested probe phases", async () => {
|
||||||
|
const bypass = vi.fn(async () => {});
|
||||||
|
|
||||||
|
await withOverlayBypass(bypass, 4, async () => {
|
||||||
|
await withOverlayBypass(bypass, 4, async () => {
|
||||||
|
expect(bypass).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(bypass).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(bypass.mock.calls).toEqual([
|
||||||
|
[4, true],
|
||||||
|
[4, false],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the overlay when the probe throws", async () => {
|
||||||
|
const bypass = vi.fn(async () => {});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
withOverlayBypass(bypass, 4, async () => {
|
||||||
|
throw new Error("probe exploded");
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("probe exploded");
|
||||||
|
|
||||||
|
expect(bypass).toHaveBeenLastCalledWith(4, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a failed restore instead of swallowing it", async () => {
|
||||||
|
const onRestoreFailure = vi.fn();
|
||||||
|
const bypass = vi.fn(async (_tabId: number, enabled: boolean) => {
|
||||||
|
if (!enabled) throw new Error("tab is gone");
|
||||||
|
});
|
||||||
|
|
||||||
|
await withOverlayBypass(bypass, 4, async () => undefined, { onRestoreFailure });
|
||||||
|
|
||||||
|
expect(onRestoreFailure).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not pin the overlay off for the tab when a restore fails", async () => {
|
||||||
|
const failing = vi.fn(async (_tabId: number, enabled: boolean) => {
|
||||||
|
if (!enabled) throw new Error("tab is gone");
|
||||||
|
});
|
||||||
|
await withOverlayBypass(failing, 4, async () => undefined, { onRestoreFailure: () => {} });
|
||||||
|
|
||||||
|
const bypass = vi.fn(async () => {});
|
||||||
|
await withOverlayBypass(bypass, 4, async () => undefined);
|
||||||
|
|
||||||
|
expect(bypass.mock.calls).toEqual([
|
||||||
|
[4, true],
|
||||||
|
[4, false],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the probe untouched when no bypass is wired up", async () => {
|
||||||
|
await expect(withOverlayBypass(undefined, 4, async () => "done")).resolves.toBe("done");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -132,6 +132,37 @@ describe("tooltip name enrichment", () => {
|
|||||||
expect(cdp.sendToTarget).not.toHaveBeenCalled();
|
expect(cdp.sendToTarget).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("remembers controls that revealed no tooltip so the miss is paid once", async () => {
|
||||||
|
const cdp: CdpRunner = {
|
||||||
|
send: vi.fn(async () => ({})) as CdpRunner["send"],
|
||||||
|
sendToTarget: vi.fn(async (_target, method) => {
|
||||||
|
if (method === "Page.createIsolatedWorld") return { executionContextId: 31 };
|
||||||
|
if (method === "DOM.resolveNode") return { object: { objectId: "button-801" } };
|
||||||
|
if (method === "Runtime.callFunctionOn") return { result: { value: [] } };
|
||||||
|
return {};
|
||||||
|
}) as CdpRunner["sendToTarget"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const documents = [
|
||||||
|
document({ tabId: 11, sessionId: "miss-session" }, 801, undefined, "miss-frame"),
|
||||||
|
];
|
||||||
|
const graph = semantics(documents);
|
||||||
|
|
||||||
|
const first = await probeTooltipNames(cdp, 11, documents, graph);
|
||||||
|
expect(first.size).toBe(0);
|
||||||
|
const hoversAfterFirst = vi
|
||||||
|
.mocked(cdp.send)
|
||||||
|
.mock.calls.filter(([, method]) => method === "Input.dispatchMouseEvent").length;
|
||||||
|
expect(hoversAfterFirst).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const second = await probeTooltipNames(cdp, 11, documents, graph);
|
||||||
|
expect(second.size).toBe(0);
|
||||||
|
expect(
|
||||||
|
vi.mocked(cdp.send).mock.calls.filter(([, method]) => method === "Input.dispatchMouseEvent")
|
||||||
|
.length,
|
||||||
|
).toBe(hoversAfterFirst);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps tooltip evidence isolated across sibling frame sessions", async () => {
|
it("keeps tooltip evidence isolated across sibling frame sessions", async () => {
|
||||||
let hoveredX = -10;
|
let hoveredX = -10;
|
||||||
const cdp: CdpRunner = {
|
const cdp: CdpRunner = {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
function captureAbortError(): Error {
|
||||||
|
const error = new Error("observation aborted");
|
||||||
|
error.name = "AbortError";
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAbortError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
(error as { name?: string }).name === "AbortError"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function throwIfAborted(signal: AbortSignal | undefined): void {
|
||||||
|
if (signal?.aborted) throw captureAbortError();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check at bounded work blocks; yield only after a processing slice is spent.
|
||||||
|
* Each caller owns its deadline, so unrelated captures do not share state. */
|
||||||
|
export function createCaptureCheckpoint(signal?: AbortSignal): () => Promise<void> | undefined {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
let deadline = performance.now() + 8;
|
||||||
|
return () => {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
if (performance.now() < deadline) return;
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const channel = new MessageChannel();
|
||||||
|
channel.port1.onmessage = () => {
|
||||||
|
channel.port1.close();
|
||||||
|
channel.port2.close();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
channel.port2.postMessage(null);
|
||||||
|
}).then(() => {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
deadline = performance.now() + 8;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
import {
|
||||||
|
type CdpFrame,
|
||||||
|
type CdpFrameGraph,
|
||||||
|
type CdpTarget,
|
||||||
|
cdpTargetKey,
|
||||||
|
} from "@/browser-driver/frame-graph";
|
||||||
|
import { cssViewport, GeometryContext } from "../geometry/frame-context";
|
||||||
|
import { type CdpRunner, cdpRunnerForTarget } from "../shared";
|
||||||
|
import { collectOverlayExcludedBackendIds } from "./capture";
|
||||||
|
import {
|
||||||
|
isAbortError as isCaptureAbort,
|
||||||
|
throwIfAborted as throwCaptureAborted,
|
||||||
|
} from "./capture-abort";
|
||||||
|
import { verifyDocumentIdentity } from "./document-identity";
|
||||||
|
import type { CapturedSceneInput, DocumentIdentity } from "./facts";
|
||||||
|
import { buildDocumentIndex, type CaptureIssue, type ObservationFacts } from "./facts";
|
||||||
|
import { enrichFormControlStates } from "./form-capture";
|
||||||
|
import {
|
||||||
|
buildFrameDocuments,
|
||||||
|
type FrameAxBatch,
|
||||||
|
type FrameDocument,
|
||||||
|
type FrameOwnedAxNode,
|
||||||
|
} from "./frame-document";
|
||||||
|
import { type NormalizedFrameDocument, normalizeSnapshot } from "./normalize";
|
||||||
|
import { describeSnapshotFrames, REQUESTED_STYLES, type SnapshotReply } from "./snapshot";
|
||||||
|
|
||||||
|
interface TargetBatch<T extends FrameOwnedAxNode> {
|
||||||
|
target: CdpTarget;
|
||||||
|
frames: CdpFrame[];
|
||||||
|
snapshotAttachmentId?: string;
|
||||||
|
documents: NormalizedFrameDocument[];
|
||||||
|
ax: FrameAxBatch<T>[];
|
||||||
|
fallbackExcluded: Set<number>;
|
||||||
|
snapshotFrameIds?: Set<string>;
|
||||||
|
rootFrameId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fixed worker pool scoped to one capture; each worker issues at most one
|
||||||
|
* collection request at a time. Geometry has its own measurement-phase bound. */
|
||||||
|
async function collectTasks<T>(
|
||||||
|
items: readonly T[],
|
||||||
|
collect: (item: T) => Promise<void>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
let cursor = 0;
|
||||||
|
let failed = false;
|
||||||
|
let failure: unknown;
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(4, items.length) }, async () => {
|
||||||
|
try {
|
||||||
|
while (cursor < items.length && !failed && !signal?.aborted) {
|
||||||
|
const item = items[cursor++];
|
||||||
|
await collect(item);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!failed) failure = error;
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// A worker's rejection must not let the capture outlive its own task scope.
|
||||||
|
// In-flight reads and their finally blocks settle before cancellation/failure.
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
if (failed) throw failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
||||||
|
cdp: CdpRunner,
|
||||||
|
tabId: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
pageUrl?: string,
|
||||||
|
): Promise<ObservationFacts<T>> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
let graph: CdpFrameGraph | undefined;
|
||||||
|
try {
|
||||||
|
graph = await cdp.getFrameGraph?.(tabId);
|
||||||
|
} catch (error) {
|
||||||
|
if (isCaptureAbort(error)) throw error;
|
||||||
|
}
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
const geometry = new GeometryContext(cdp, tabId, graph, signal);
|
||||||
|
const issues: CaptureIssue[] = [];
|
||||||
|
const graphFrames = new Map(graph?.frames.map((frame) => [frame.frameId, frame]) ?? []);
|
||||||
|
const groups = new Map<string, TargetBatch<T>>();
|
||||||
|
for (const frame of graph?.frames ?? []) {
|
||||||
|
const key = cdpTargetKey(frame.target);
|
||||||
|
let group = groups.get(key);
|
||||||
|
if (!group) {
|
||||||
|
group = {
|
||||||
|
target: frame.target,
|
||||||
|
frames: [],
|
||||||
|
documents: [],
|
||||||
|
ax: [],
|
||||||
|
fallbackExcluded: new Set(),
|
||||||
|
};
|
||||||
|
groups.set(key, group);
|
||||||
|
}
|
||||||
|
group.frames.push(
|
||||||
|
!frame.target.sessionId && frame.frameId === graph?.rootFrameId && !frame.url && pageUrl
|
||||||
|
? { ...frame, url: pageUrl }
|
||||||
|
: frame,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!groups.has(cdpTargetKey({ tabId })))
|
||||||
|
groups.set(cdpTargetKey({ tabId }), {
|
||||||
|
target: { tabId },
|
||||||
|
frames: [],
|
||||||
|
documents: [],
|
||||||
|
ax: [],
|
||||||
|
fallbackExcluded: new Set(),
|
||||||
|
});
|
||||||
|
const batches = [...groups.values()];
|
||||||
|
const unavailable = (batch: TargetBatch<T>, stage: CaptureIssue["stage"], frameId?: string) =>
|
||||||
|
issues.push({ target: batch.target, frameId, stage, reason: "capture-unavailable" });
|
||||||
|
let firstFailure: unknown;
|
||||||
|
const axReady = new Set<TargetBatch<T>>();
|
||||||
|
await collectTasks(
|
||||||
|
batches,
|
||||||
|
async (batch) => {
|
||||||
|
const scoped = cdpRunnerForTarget(cdp, batch.target);
|
||||||
|
try {
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
await scoped.send(tabId, "DOMSnapshot.enable", {});
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
batch.snapshotAttachmentId = cdp.getAttachmentId?.(tabId);
|
||||||
|
const snapshot = await scoped.send<SnapshotReply>(tabId, "DOMSnapshot.captureSnapshot", {
|
||||||
|
computedStyles: REQUESTED_STYLES,
|
||||||
|
includePaintOrder: true,
|
||||||
|
includeDOMRects: true,
|
||||||
|
});
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
const observed = describeSnapshotFrames(snapshot, batch.target, graphFrames, pageUrl);
|
||||||
|
batch.snapshotFrameIds = observed.ids;
|
||||||
|
batch.rootFrameId = observed.rootFrameId;
|
||||||
|
// Keep graph-only frames for AX fallback, never in preference to observed membership.
|
||||||
|
batch.frames = [
|
||||||
|
...observed.frames,
|
||||||
|
...batch.frames.filter((frame) => !observed.ids.has(frame.frameId)),
|
||||||
|
];
|
||||||
|
batch.documents = await normalizeSnapshot(
|
||||||
|
snapshot,
|
||||||
|
batch.target,
|
||||||
|
batch.frames.filter((frame) => observed.ids.has(frame.frameId)),
|
||||||
|
geometry,
|
||||||
|
issues,
|
||||||
|
signal,
|
||||||
|
observed.rootFrameId,
|
||||||
|
);
|
||||||
|
const formsAvailable = await enrichFormControlStates(
|
||||||
|
scoped,
|
||||||
|
tabId,
|
||||||
|
batch.documents.map((doc) => doc.nodes),
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
if (!formsAvailable) unavailable(batch, "forms");
|
||||||
|
const present = new Set(batch.documents.map((doc) => doc.frame.frameId));
|
||||||
|
for (const frame of batch.frames)
|
||||||
|
if (!present.has(frame.frameId)) unavailable(batch, "dom", frame.frameId);
|
||||||
|
} catch (error) {
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
if (isCaptureAbort(error)) throw error;
|
||||||
|
firstFailure ??= error;
|
||||||
|
unavailable(batch, "dom");
|
||||||
|
batch.fallbackExcluded = await collectOverlayExcludedBackendIds(scoped, tabId, signal);
|
||||||
|
}
|
||||||
|
if (!batch.frames.length && !graph)
|
||||||
|
batch.frames = [
|
||||||
|
{
|
||||||
|
frameId: "root",
|
||||||
|
target: batch.target,
|
||||||
|
...(!batch.target.sessionId && pageUrl ? { url: pageUrl } : {}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
await scoped.send(tabId, "Accessibility.enable", {});
|
||||||
|
} catch (error) {
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
if (isCaptureAbort(error)) throw error;
|
||||||
|
firstFailure ??= error;
|
||||||
|
unavailable(batch, "ax");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
axReady.add(batch);
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schedule AX by frame across all ready targets, rather than nesting pools.
|
||||||
|
// Keep result slots in source order even when CDP replies arrive out of order.
|
||||||
|
const axTasks: { batch: TargetBatch<T>; frame: CdpFrame; result?: FrameAxBatch<T> }[] =
|
||||||
|
batches.flatMap((batch) =>
|
||||||
|
axReady.has(batch) ? batch.frames.map((frame) => ({ batch, frame })) : [],
|
||||||
|
);
|
||||||
|
await collectTasks(
|
||||||
|
axTasks,
|
||||||
|
async (task) => {
|
||||||
|
const { batch, frame } = task;
|
||||||
|
try {
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
const result = await cdpRunnerForTarget(cdp, batch.target).send<{ nodes?: T[] }>(
|
||||||
|
tabId,
|
||||||
|
"Accessibility.getFullAXTree",
|
||||||
|
frame.frameId === "root" ? {} : { frameId: frame.frameId },
|
||||||
|
);
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
task.result = { frame, nodes: result.nodes ?? [] };
|
||||||
|
} catch (error) {
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
if (isCaptureAbort(error)) throw error;
|
||||||
|
firstFailure ??= error;
|
||||||
|
unavailable(batch, "ax", frame.frameId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
for (const task of axTasks) if (task.result) task.batch.ax.push(task.result);
|
||||||
|
|
||||||
|
let viewport = { width: 0, height: 0 };
|
||||||
|
try {
|
||||||
|
const measured = cssViewport(await geometry.layoutMetrics({ tabId }));
|
||||||
|
viewport = { width: measured.width, height: measured.height };
|
||||||
|
} catch (error) {
|
||||||
|
if (isCaptureAbort(error)) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A snapshot claim outranks an old graph hint. Conflicting actual claims
|
||||||
|
// cannot be resolved by completion order or by overwriting a frameId map.
|
||||||
|
const claims = new Map<string, string>();
|
||||||
|
const invalid = new Set<string>();
|
||||||
|
for (const batch of batches) {
|
||||||
|
const targetKey = cdpTargetKey(batch.target);
|
||||||
|
for (const id of batch.snapshotFrameIds ?? []) {
|
||||||
|
const previous = claims.get(id);
|
||||||
|
if (previous !== undefined && previous !== targetKey) invalid.add(id);
|
||||||
|
claims.set(id, targetKey);
|
||||||
|
}
|
||||||
|
const accepted = new Set(batch.frames.map((frame) => frame.frameId));
|
||||||
|
for (const id of batch.snapshotFrameIds ?? []) if (!accepted.has(id)) invalid.add(id);
|
||||||
|
}
|
||||||
|
const currentFrames = batches.flatMap((batch) =>
|
||||||
|
batch.frames.filter(
|
||||||
|
(frame) =>
|
||||||
|
!claims.has(frame.frameId) || claims.get(frame.frameId) === cdpTargetKey(batch.target),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const rootBatch = batches.find((batch) => !batch.target.sessionId);
|
||||||
|
const rootFrameId =
|
||||||
|
rootBatch?.rootFrameId ?? graph?.rootFrameId ?? rootBatch?.frames[0]?.frameId ?? "root";
|
||||||
|
const ownershipInvalid = new Set(invalid);
|
||||||
|
const identities = new Map<string, DocumentIdentity>();
|
||||||
|
const selected = new Map(currentFrames.map((frame) => [frame.frameId, frame]));
|
||||||
|
const checks = batches.flatMap((batch) => {
|
||||||
|
const docs = new Map(batch.documents.map((doc) => [doc.frame.frameId, doc]));
|
||||||
|
return batch.frames
|
||||||
|
.filter(
|
||||||
|
(frame) =>
|
||||||
|
!invalid.has(frame.frameId) &&
|
||||||
|
cdpTargetKey(selected.get(frame.frameId)!.target) === cdpTargetKey(batch.target),
|
||||||
|
)
|
||||||
|
.map((frame) => ({ batch, frame, doc: docs.get(frame.frameId) }));
|
||||||
|
});
|
||||||
|
await collectTasks(
|
||||||
|
checks,
|
||||||
|
async ({ batch, frame, doc }) => {
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
if (!batch.snapshotAttachmentId || doc?.documentElementBackendNodeId === undefined) {
|
||||||
|
issues.push({
|
||||||
|
target: frame.target,
|
||||||
|
frameId: frame.frameId,
|
||||||
|
stage: "identity",
|
||||||
|
reason: "identity-unverified",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const identity: DocumentIdentity = {
|
||||||
|
attachmentId: batch.snapshotAttachmentId,
|
||||||
|
target: frame.target,
|
||||||
|
frameId: frame.frameId,
|
||||||
|
documentElementBackendNodeId: doc.documentElementBackendNodeId,
|
||||||
|
};
|
||||||
|
const verified = await verifyDocumentIdentity(cdp, identity, signal);
|
||||||
|
const status = cdp.getAttachmentId?.(tabId) === identity.attachmentId ? verified : "changed";
|
||||||
|
if (status === "current") identities.set(frame.frameId, identity);
|
||||||
|
else {
|
||||||
|
invalid.add(frame.frameId);
|
||||||
|
issues.push({
|
||||||
|
target: frame.target,
|
||||||
|
frameId: frame.frameId,
|
||||||
|
stage: "identity",
|
||||||
|
reason: status === "changed" ? "document-changed" : "identity-unavailable",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
const children = new Map<string, string[]>();
|
||||||
|
for (const frame of currentFrames) {
|
||||||
|
if (!frame.parentFrameId) continue;
|
||||||
|
const siblings = children.get(frame.parentFrameId);
|
||||||
|
if (siblings) siblings.push(frame.frameId);
|
||||||
|
else children.set(frame.parentFrameId, [frame.frameId]);
|
||||||
|
}
|
||||||
|
const blocked = [...invalid];
|
||||||
|
for (let i = 0; i < blocked.length; i++) {
|
||||||
|
for (const child of children.get(blocked[i]) ?? []) {
|
||||||
|
if (!invalid.has(child)) {
|
||||||
|
invalid.add(child);
|
||||||
|
blocked.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const frame of currentFrames) {
|
||||||
|
if (ownershipInvalid.has(frame.frameId))
|
||||||
|
issues.push({
|
||||||
|
target: frame.target,
|
||||||
|
frameId: frame.frameId,
|
||||||
|
stage: "ownership",
|
||||||
|
reason: "frame-ownership-unresolved",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (invalid.has(rootFrameId))
|
||||||
|
throw new Error(
|
||||||
|
ownershipInvalid.has(rootFrameId)
|
||||||
|
? "observation root document ownership is ambiguous"
|
||||||
|
: "observation document identity changed or could not be verified; observe again",
|
||||||
|
);
|
||||||
|
const frames = currentFrames.filter((frame) => !invalid.has(frame.frameId));
|
||||||
|
const frameById = new Map(frames.map((frame) => [frame.frameId, frame]));
|
||||||
|
const belongs = (frame: CdpFrame) => {
|
||||||
|
const current = frameById.get(frame.frameId);
|
||||||
|
return current && cdpTargetKey(current.target) === cdpTargetKey(frame.target);
|
||||||
|
};
|
||||||
|
const decoded = new Map(
|
||||||
|
batches
|
||||||
|
.flatMap((batch) => batch.documents)
|
||||||
|
.filter((doc) => belongs(doc.frame))
|
||||||
|
.map((doc) => [doc.frame.frameId, doc]),
|
||||||
|
);
|
||||||
|
const ax = batches.flatMap((batch) => batch.ax).filter((batch) => belongs(batch.frame));
|
||||||
|
const documents = await buildFrameDocuments(
|
||||||
|
{ rootFrameId, frames },
|
||||||
|
ax,
|
||||||
|
{
|
||||||
|
nodes: [],
|
||||||
|
rootFrameId,
|
||||||
|
frameNodes: new Map([...decoded].map(([id, doc]) => [id, doc.nodes])),
|
||||||
|
frameExcludedBackendNodeIds: new Map(
|
||||||
|
[...decoded].map(([id, doc]) => [id, doc.index.excludedBackendNodeIds]),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
(frame) =>
|
||||||
|
issues.push({
|
||||||
|
target: frame.target,
|
||||||
|
frameId: frame.frameId,
|
||||||
|
stage: "ownership",
|
||||||
|
reason: "frame-ownership-unresolved",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const facts: ObservationFacts<T>["documents"][number][] = [];
|
||||||
|
for (const document of documents) {
|
||||||
|
const doc = decoded.get(document.frameId);
|
||||||
|
const index = doc?.index ?? (await buildDocumentIndex([], signal));
|
||||||
|
const fallback = groups.get(cdpTargetKey(document.target))?.fallbackExcluded;
|
||||||
|
const {
|
||||||
|
domNodes,
|
||||||
|
axNodes,
|
||||||
|
contextScopeId: _scope,
|
||||||
|
excludedBackendNodeIds: _excluded,
|
||||||
|
...frame
|
||||||
|
} = document;
|
||||||
|
facts.push({
|
||||||
|
frame,
|
||||||
|
identity: identities.get(document.frameId),
|
||||||
|
index: fallback?.size ? { ...index, excludedBackendNodeIds: fallback } : index,
|
||||||
|
domNodes,
|
||||||
|
axNodes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
if (firstFailure && facts.every((doc) => !doc.domNodes.length && !doc.axNodes.length))
|
||||||
|
throw firstFailure;
|
||||||
|
return { rootFrameId, viewport, documents: facts, issues, startedAt, finishedAt: Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Existing semantic consumers get a narrow view; no raw snapshot
|
||||||
|
* data can leak through recording's allowlisted output DTO. */
|
||||||
|
export function semanticCapture<T extends FrameOwnedAxNode>(
|
||||||
|
facts: ObservationFacts<T>,
|
||||||
|
): { captured: CapturedSceneInput; documents: FrameDocument<T>[] } {
|
||||||
|
const documents = facts.documents.map((doc) => ({
|
||||||
|
...doc.frame,
|
||||||
|
contextScopeId: doc.frame.frameId,
|
||||||
|
domNodes: doc.domNodes.filter(
|
||||||
|
(node) => !doc.index.excludedBackendNodeIds.has(node.backendNodeId),
|
||||||
|
),
|
||||||
|
axNodes: doc.axNodes,
|
||||||
|
excludedBackendNodeIds: doc.index.excludedBackendNodeIds,
|
||||||
|
}));
|
||||||
|
const root = documents.find((doc) => doc.frameId === facts.rootFrameId);
|
||||||
|
return {
|
||||||
|
documents,
|
||||||
|
captured: {
|
||||||
|
nodes: root?.domNodes ?? [],
|
||||||
|
viewport: facts.viewport,
|
||||||
|
rootFrameId: facts.rootFrameId,
|
||||||
|
excludedBackendNodeIds: root?.excludedBackendNodeIds ?? new Set(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Rect } from "@browser-skill/vom";
|
||||||
|
|
||||||
|
export interface CapturedNode {
|
||||||
|
backendNodeId: number;
|
||||||
|
parentBackendNodeId: number | null;
|
||||||
|
frameId?: string;
|
||||||
|
/** Owning iframe backend node id; `null` for the top-level document. */
|
||||||
|
ownerFrameBackendNodeId?: number | null;
|
||||||
|
tag: string;
|
||||||
|
attrs: Record<string, string>;
|
||||||
|
/** Top-level viewport-relative CSS px, clipped to the owning frame viewport. */
|
||||||
|
rect: Rect | null;
|
||||||
|
/** Frame-local viewport-relative CSS px before top-level projection. */
|
||||||
|
localRect?: Rect | null;
|
||||||
|
paintOrder: number;
|
||||||
|
position: string;
|
||||||
|
pointerEvents: string;
|
||||||
|
/**
|
||||||
|
* computed `cursor`. `cursor: pointer` is the strongest CDP-free signal
|
||||||
|
* that a non-semantic element (a `<div>`/`<span>` with a click handler)
|
||||||
|
* is actually an interactive control — used by the adapter to surface
|
||||||
|
* custom buttons/checkboxes the AX tree drops as `generic`. Optional like
|
||||||
|
* `textContent`: the live parser always sets it, hand-built fixtures may not.
|
||||||
|
*/
|
||||||
|
cursor?: string;
|
||||||
|
/**
|
||||||
|
* Whether the live DOM snapshot provides a painted, non-hidden box for this
|
||||||
|
* node. Semantic resolution uses this only for DOM fallback nodes; AX-backed
|
||||||
|
* nodes remain authoritative even when they are outside the viewport.
|
||||||
|
*/
|
||||||
|
rendered?: boolean;
|
||||||
|
textContent?: string;
|
||||||
|
formState?: "empty" | "filled" | "default";
|
||||||
|
formValue?: string;
|
||||||
|
formDefaultValue?: string;
|
||||||
|
formPlaceholder?: string;
|
||||||
|
}
|
||||||
@@ -1,300 +1,18 @@
|
|||||||
// CDP capture adapter: DOMSnapshot.captureSnapshot + Page.getLayoutMetrics
|
// Browser-side hover probes and overlay exclusion fallback. Static collection
|
||||||
// → CapturedNode[] + Viewport. This is the ONLY VOM module that touches
|
// lives in capture-coordinator; snapshot decoding has no browser dependency.
|
||||||
// raw CDP. The captureSnapshot reply is columnar (parallel arrays + a
|
|
||||||
// shared string table); requested computed styles are decoded through
|
|
||||||
// STYLE_COL so capture and visibility policy share one explicit contract.
|
|
||||||
|
|
||||||
import type { Rect, Viewport } from "@browser-skill/vom";
|
|
||||||
import { evaluateHoverTrigger } from "@/lib/hover-trigger-policy";
|
import { evaluateHoverTrigger } from "@/lib/hover-trigger-policy";
|
||||||
import { isOverlayHostNode, OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge";
|
import { OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge";
|
||||||
import { childFrameProjection, type GeometryProjection, projectRectToViewport } from "../geometry";
|
|
||||||
import type { CdpRunner } from "../shared";
|
import type { CdpRunner } from "../shared";
|
||||||
import { clearHover, waitForHover } from "./hover-perception";
|
import type { CapturedNode } from "./capture-types";
|
||||||
|
import type { CapturedSurfaceProbe } from "./facts";
|
||||||
|
|
||||||
const REQUESTED_STYLES = ["position", "pointer-events", "cursor", "visibility", "opacity"] as const;
|
export type {
|
||||||
const STYLE_COL = Object.fromEntries(
|
CapturedNode,
|
||||||
REQUESTED_STYLES.map((name, index) => [name, index]),
|
CapturedSurfaceProbe,
|
||||||
) as Record<(typeof REQUESTED_STYLES)[number], number>;
|
} from "./facts";
|
||||||
|
|
||||||
export interface CapturedNode {
|
import { isAbortError, throwIfAborted } from "./capture-abort";
|
||||||
backendNodeId: number;
|
import { clearHover, ProbeBudget, waitForHover } from "./hover-perception";
|
||||||
parentBackendNodeId: number | null;
|
|
||||||
frameId?: string;
|
|
||||||
/** Owning iframe backend node id; `null` for the top-level document. */
|
|
||||||
ownerFrameBackendNodeId?: number | null;
|
|
||||||
tag: string;
|
|
||||||
attrs: Record<string, string>;
|
|
||||||
/** Top-level viewport-relative CSS px, clipped to the owning frame viewport. */
|
|
||||||
rect: Rect | null;
|
|
||||||
/** Frame-local viewport-relative CSS px before top-level projection. */
|
|
||||||
localRect?: Rect | null;
|
|
||||||
paintOrder: number;
|
|
||||||
position: string;
|
|
||||||
pointerEvents: string;
|
|
||||||
/**
|
|
||||||
* computed `cursor`. `cursor: pointer` is the strongest CDP-free signal
|
|
||||||
* that a non-semantic element (a `<div>`/`<span>` with a click handler)
|
|
||||||
* is actually an interactive control — used by the adapter to surface
|
|
||||||
* custom buttons/checkboxes the AX tree drops as `generic`. Optional like
|
|
||||||
* `textContent`: the live parser always sets it, hand-built fixtures may not.
|
|
||||||
*/
|
|
||||||
cursor?: string;
|
|
||||||
/**
|
|
||||||
* Whether the live DOM snapshot provides a painted, non-hidden box for this
|
|
||||||
* node. Semantic resolution uses this only for DOM fallback nodes; AX-backed
|
|
||||||
* nodes remain authoritative even when they are outside the viewport.
|
|
||||||
*/
|
|
||||||
rendered?: boolean;
|
|
||||||
textContent?: string;
|
|
||||||
formState?: "empty" | "filled" | "default";
|
|
||||||
formValue?: string;
|
|
||||||
formDefaultValue?: string;
|
|
||||||
formPlaceholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CapturedIframeNodes = Map<number, CapturedNode[]>;
|
|
||||||
|
|
||||||
export interface CapturedSurfaceProbe {
|
|
||||||
triggerBackendNodeId: number;
|
|
||||||
triggerPoint?: { x: number; y: number };
|
|
||||||
triggerAction: "hover" | "focus" | string;
|
|
||||||
subItems: string[];
|
|
||||||
confidence?: "high" | "medium" | "low";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CapturedViewModel {
|
|
||||||
nodes: CapturedNode[];
|
|
||||||
viewport: Viewport;
|
|
||||||
iframeNodes: CapturedIframeNodes;
|
|
||||||
frameNodes?: Map<string, CapturedNode[]>;
|
|
||||||
frameOwnerBackendNodeIds?: Map<string, number>;
|
|
||||||
/** Explicit DOMSnapshot frame ancestry; never inferred from backend node ids. */
|
|
||||||
frameParentIds?: Map<string, string>;
|
|
||||||
rootFrameId?: string;
|
|
||||||
surfaceProbes?: CapturedSurfaceProbe[];
|
|
||||||
/** Backend node ids belonging to the agent overlay host + its shadow subtree. */
|
|
||||||
excludedBackendNodeIds: Set<number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CaptureViewModelOptions {
|
|
||||||
conditionalSurfaceProbe?: boolean;
|
|
||||||
hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
function captureAbortError(): Error {
|
|
||||||
const error = new Error("observation aborted");
|
|
||||||
error.name = "AbortError";
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAbortError(error: unknown): boolean {
|
|
||||||
return (
|
|
||||||
typeof error === "object" &&
|
|
||||||
error !== null &&
|
|
||||||
(error as { name?: string }).name === "AbortError"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
||||||
if (signal?.aborted) throw captureAbortError();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sparse array format Chrome uses for infrequently-set per-node fields. */
|
|
||||||
interface SparseArray {
|
|
||||||
index: number[];
|
|
||||||
value: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RareBooleanData {
|
|
||||||
index: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SnapshotDocument {
|
|
||||||
frameId?: string | number;
|
|
||||||
scrollOffsetX?: number;
|
|
||||||
scrollOffsetY?: number;
|
|
||||||
nodes?: {
|
|
||||||
parentIndex?: number[];
|
|
||||||
nodeName?: number[];
|
|
||||||
backendNodeId?: number[];
|
|
||||||
attributes?: number[][];
|
|
||||||
/**
|
|
||||||
* Per-node text value (index into strings), set for `#text` / CDATA nodes.
|
|
||||||
* Element nodes carry -1. Same length as `backendNodeId`.
|
|
||||||
*/
|
|
||||||
nodeValue?: number[];
|
|
||||||
/** Maps node array index → index into `documents[]` for frame content. */
|
|
||||||
contentDocumentIndex?: SparseArray;
|
|
||||||
inputValue?: SparseArray;
|
|
||||||
textValue?: SparseArray;
|
|
||||||
inputChecked?: RareBooleanData;
|
|
||||||
optionSelected?: RareBooleanData;
|
|
||||||
};
|
|
||||||
layout?: {
|
|
||||||
nodeIndex?: number[];
|
|
||||||
styles?: number[][];
|
|
||||||
bounds?: number[][];
|
|
||||||
paintOrders?: number[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotFrameId(document: SnapshotDocument, strings: string[]): string | undefined {
|
|
||||||
if (typeof document.frameId === "string") return document.frameId || undefined;
|
|
||||||
if (typeof document.frameId === "number") return str(strings, document.frameId) || undefined;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SnapshotReply {
|
|
||||||
strings?: string[];
|
|
||||||
documents?: SnapshotDocument[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LayoutMetricsReply {
|
|
||||||
cssLayoutViewport?: {
|
|
||||||
clientWidth?: number;
|
|
||||||
clientHeight?: number;
|
|
||||||
pageX?: number;
|
|
||||||
pageY?: number;
|
|
||||||
};
|
|
||||||
layoutViewport?: { clientWidth?: number; clientHeight?: number; pageX?: number; pageY?: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFormControlTag(tag: string): boolean {
|
|
||||||
return tag === "input" || tag === "textarea" || tag === "select";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSensitiveFormControl(node: Pick<CapturedNode, "tag" | "attrs">): boolean {
|
|
||||||
return node.tag === "input" && (node.attrs.type ?? "").toLowerCase() === "password";
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotFormState(
|
|
||||||
value: string | undefined,
|
|
||||||
defaultValue: string,
|
|
||||||
hasDefaultValue: boolean,
|
|
||||||
sensitive: boolean,
|
|
||||||
): CapturedNode["formState"] {
|
|
||||||
if (sensitive) {
|
|
||||||
if (value === undefined && !hasDefaultValue) return undefined;
|
|
||||||
return (value ?? defaultValue) === "" ? "empty" : "filled";
|
|
||||||
}
|
|
||||||
if (value === undefined) return undefined;
|
|
||||||
if (value === "") return "empty";
|
|
||||||
return value === defaultValue ? "default" : "filled";
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_FORM_ENRICH_CONTROLS = 250;
|
|
||||||
|
|
||||||
interface CapturedFormState {
|
|
||||||
value?: string;
|
|
||||||
defaultValue?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
state?: "empty" | "filled" | "default";
|
|
||||||
sensitive?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CapturedFrameFormState {
|
|
||||||
controls: CapturedFormState[];
|
|
||||||
childFrames: CapturedFrameFormState[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formStateBatchExpression(maxControls: number): string {
|
|
||||||
return `(() => {
|
|
||||||
const maxControls = ${JSON.stringify(maxControls)};
|
|
||||||
let remaining = maxControls;
|
|
||||||
const controlSelector = "input,textarea,select";
|
|
||||||
const controlState = (el) => {
|
|
||||||
const tag = el.tagName.toLowerCase();
|
|
||||||
const type = tag === "input" ? String(el.type || "text").toLowerCase() : tag;
|
|
||||||
const sensitive = type === "password" || type === "credit-card";
|
|
||||||
const rawValue = typeof el.value === "string" ? el.value : "";
|
|
||||||
const defaultValue = typeof el.defaultValue === "string" ? el.defaultValue : "";
|
|
||||||
const placeholder = typeof el.placeholder === "string" ? el.placeholder : "";
|
|
||||||
const state = rawValue === "" ? "empty" : rawValue === defaultValue ? "default" : "filled";
|
|
||||||
return {
|
|
||||||
state,
|
|
||||||
sensitive,
|
|
||||||
placeholder,
|
|
||||||
...(sensitive ? {} : { value: rawValue, defaultValue }),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const collect = (doc) => {
|
|
||||||
const controls = [];
|
|
||||||
for (const el of Array.from(doc.querySelectorAll(controlSelector))) {
|
|
||||||
if (remaining <= 0) break;
|
|
||||||
controls.push(controlState(el));
|
|
||||||
remaining -= 1;
|
|
||||||
}
|
|
||||||
const childFrames = [];
|
|
||||||
for (const frame of Array.from(doc.querySelectorAll("iframe"))) {
|
|
||||||
let childDoc = null;
|
|
||||||
try { childDoc = frame.contentDocument; } catch { childDoc = null; }
|
|
||||||
childFrames.push(childDoc && remaining > 0 ? collect(childDoc) : { controls: [], childFrames: [] });
|
|
||||||
}
|
|
||||||
return { controls, childFrames };
|
|
||||||
};
|
|
||||||
return collect(document);
|
|
||||||
})()`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function flattenFrameFormStates(
|
|
||||||
frame: CapturedFrameFormState | undefined,
|
|
||||||
out: CapturedFormState[][] = [],
|
|
||||||
): CapturedFormState[][] {
|
|
||||||
if (!frame) return out;
|
|
||||||
out.push(Array.isArray(frame.controls) ? frame.controls : []);
|
|
||||||
for (const child of Array.isArray(frame.childFrames) ? frame.childFrames : []) {
|
|
||||||
flattenFrameFormStates(child, out);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFormStates(nodes: CapturedNode[], states: CapturedFormState[]): void {
|
|
||||||
let index = 0;
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (!isFormControlTag(node.tag)) continue;
|
|
||||||
const state = states[index];
|
|
||||||
index += 1;
|
|
||||||
if (!state) continue;
|
|
||||||
node.formState = state.state;
|
|
||||||
node.formPlaceholder = state.placeholder ?? "";
|
|
||||||
if (state.sensitive || isSensitiveFormControl(node)) {
|
|
||||||
delete node.formValue;
|
|
||||||
delete node.formDefaultValue;
|
|
||||||
delete node.attrs.value;
|
|
||||||
} else {
|
|
||||||
node.formDefaultValue = state.defaultValue ?? "";
|
|
||||||
if (state.value !== undefined) node.formValue = state.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enrichFormControlStates(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
frameNodeGroups: CapturedNode[][],
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<void> {
|
|
||||||
const hasControls = frameNodeGroups.some((nodes) =>
|
|
||||||
nodes.some((node) => isFormControlTag(node.tag)),
|
|
||||||
);
|
|
||||||
if (!hasControls) return;
|
|
||||||
try {
|
|
||||||
throwIfAborted(signal);
|
|
||||||
const result = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
|
||||||
expression: formStateBatchExpression(MAX_FORM_ENRICH_CONTROLS),
|
|
||||||
returnByValue: true,
|
|
||||||
});
|
|
||||||
throwIfAborted(signal);
|
|
||||||
const frameStates = flattenFrameFormStates(runtimeValue<CapturedFrameFormState>(result));
|
|
||||||
for (let i = 0; i < frameNodeGroups.length; i += 1) {
|
|
||||||
applyFormStates(frameNodeGroups[i], frameStates[i] ?? []);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (isAbortError(error)) throw error;
|
|
||||||
// Best-effort enrichment. DOMSnapshot/AX data still carries the nodes.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RuntimeEvaluateReply {
|
interface RuntimeEvaluateReply {
|
||||||
result?: {
|
result?: {
|
||||||
@@ -317,44 +35,6 @@ interface CdpDomNode {
|
|||||||
shadowRoots?: CdpDomNode[];
|
shadowRoots?: CdpDomNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParseDocumentResult {
|
|
||||||
nodes: CapturedNode[];
|
|
||||||
excludedBackendNodeIds: Set<number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FrameContext {
|
|
||||||
frameId?: string;
|
|
||||||
ownerFrameBackendNodeId: number | null;
|
|
||||||
projection: GeometryProjection | null;
|
|
||||||
scrollX: number;
|
|
||||||
scrollY: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function str(strings: string[], idx: number | undefined): string {
|
|
||||||
if (idx === undefined || idx < 0) return "";
|
|
||||||
return strings[idx] ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function devicePixelRatio(metrics: LayoutMetricsReply): number {
|
|
||||||
const layoutW = metrics.layoutViewport?.clientWidth ?? 0;
|
|
||||||
const cssW = metrics.cssLayoutViewport?.clientWidth ?? 0;
|
|
||||||
if (!layoutW || !cssW) return 1;
|
|
||||||
const dpr = layoutW / cssW;
|
|
||||||
if (!Number.isFinite(dpr) || dpr <= 0) return 1;
|
|
||||||
return dpr >= 1 ? dpr : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sparseIndexMap(sparse: SparseArray | undefined): Map<number, number> {
|
|
||||||
const out = new Map<number, number>();
|
|
||||||
if (!sparse?.index || !sparse.value) return out;
|
|
||||||
for (let i = 0; i < sparse.index.length; i++) {
|
|
||||||
const nodeIndex = sparse.index[i];
|
|
||||||
const docIndex = sparse.value[i];
|
|
||||||
if (nodeIndex !== undefined && docIndex !== undefined) out.set(nodeIndex, docIndex);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectBackendIdsFromDomNode(node: CdpDomNode | undefined, out: Set<number>): void {
|
function collectBackendIdsFromDomNode(node: CdpDomNode | undefined, out: Set<number>): void {
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
if (typeof node.backendNodeId === "number") {
|
if (typeof node.backendNodeId === "number") {
|
||||||
@@ -368,7 +48,16 @@ function collectBackendIdsFromDomNode(node: CdpDomNode | undefined, out: Set<num
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_HOVER_PROBE_MS = 2_000;
|
/**
|
||||||
|
* Ceiling for the whole hover-surface phase.
|
||||||
|
*
|
||||||
|
* The previous 2000 was only a floor: the loop checked elapsed time at the top,
|
||||||
|
* so a candidate could start with 1ms left and still run its two settle
|
||||||
|
* windows, pushing real cost to ~2.6s. This is that true ceiling, now actually
|
||||||
|
* enforced by an up-front affordability check, so the same number of candidates
|
||||||
|
* get probed under a bound that no longer lies.
|
||||||
|
*/
|
||||||
|
const MAX_HOVER_PROBE_MS = 2_600;
|
||||||
const MAX_HOVER_TRIGGERS = 6;
|
const MAX_HOVER_TRIGGERS = 6;
|
||||||
const MAX_HOVER_SURFACES = 3;
|
const MAX_HOVER_SURFACES = 3;
|
||||||
const HOVER_SETTLE_MS = 300;
|
const HOVER_SETTLE_MS = 300;
|
||||||
@@ -614,13 +303,35 @@ function confidenceForHover(
|
|||||||
return "low";
|
return "low";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function probeHoverSurfaces(
|
/**
|
||||||
|
* One candidate costs two settle windows (baseline + post-hover) plus a few
|
||||||
|
* CDP round trips. Used to decide whether the next candidate still fits the
|
||||||
|
* budget before paying for it.
|
||||||
|
*/
|
||||||
|
const HOVER_CANDIDATE_COST_MS = HOVER_SETTLE_MS * 2;
|
||||||
|
|
||||||
|
export interface HoverSurfaceProbeOptions {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hovers a bounded set of likely menu triggers and reports the sub-items each
|
||||||
|
* one reveals.
|
||||||
|
*
|
||||||
|
* Runs after DOM *and* accessibility capture. Hovering can open menus and
|
||||||
|
* change layout, so probing between the two captures would leave the DOM half
|
||||||
|
* of an observation describing the page before the change and the AX half
|
||||||
|
* describing it after.
|
||||||
|
*
|
||||||
|
* The caller owns the overlay bypass span (see `withOverlayBypass`).
|
||||||
|
*/
|
||||||
|
export async function probeHoverSurfaces(
|
||||||
cdp: CdpRunner,
|
cdp: CdpRunner,
|
||||||
tabId: number,
|
tabId: number,
|
||||||
nodes: CapturedNode[],
|
nodes: CapturedNode[],
|
||||||
options: CaptureViewModelOptions,
|
options: HoverSurfaceProbeOptions = {},
|
||||||
): Promise<CapturedSurfaceProbe[]> {
|
): Promise<CapturedSurfaceProbe[]> {
|
||||||
const started = Date.now();
|
const budget = new ProbeBudget(MAX_HOVER_PROBE_MS);
|
||||||
try {
|
try {
|
||||||
throwIfAborted(options.signal);
|
throwIfAborted(options.signal);
|
||||||
const cssScan = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
const cssScan = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
||||||
@@ -635,60 +346,54 @@ async function probeHoverSurfaces(
|
|||||||
const results: CapturedSurfaceProbe[] = [];
|
const results: CapturedSurfaceProbe[] = [];
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
throwIfAborted(options.signal);
|
throwIfAborted(options.signal);
|
||||||
await options.hoverProbeBypassOverlay?.(tabId, true).catch(() => undefined);
|
for (const candidate of candidates.slice(0, MAX_HOVER_TRIGGERS)) {
|
||||||
try {
|
|
||||||
throwIfAborted(options.signal);
|
throwIfAborted(options.signal);
|
||||||
for (const candidate of candidates.slice(0, MAX_HOVER_TRIGGERS)) {
|
if (!budget.canAfford(HOVER_CANDIDATE_COST_MS)) break;
|
||||||
|
if (results.length >= MAX_HOVER_SURFACES) break;
|
||||||
|
if (seen.has(candidate.backendNodeId)) continue;
|
||||||
|
try {
|
||||||
|
await clearHover(cdp, tabId);
|
||||||
throwIfAborted(options.signal);
|
throwIfAborted(options.signal);
|
||||||
if (Date.now() - started > MAX_HOVER_PROBE_MS) break;
|
await waitForHover(HOVER_SETTLE_MS, options.signal);
|
||||||
if (results.length >= MAX_HOVER_SURFACES) break;
|
const baselineReply = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
||||||
if (seen.has(candidate.backendNodeId)) continue;
|
expression: hoverStateExpression(),
|
||||||
try {
|
returnByValue: true,
|
||||||
await clearHover(cdp, tabId);
|
});
|
||||||
throwIfAborted(options.signal);
|
throwIfAborted(options.signal);
|
||||||
await waitForHover(HOVER_SETTLE_MS, options.signal);
|
const baselineItems = runtimeValue<HoverRuntimeItem[]>(baselineReply) ?? [];
|
||||||
const baselineReply = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
|
||||||
expression: hoverStateExpression(),
|
|
||||||
returnByValue: true,
|
|
||||||
});
|
|
||||||
throwIfAborted(options.signal);
|
|
||||||
const baselineItems = runtimeValue<HoverRuntimeItem[]>(baselineReply) ?? [];
|
|
||||||
|
|
||||||
throwIfAborted(options.signal);
|
throwIfAborted(options.signal);
|
||||||
await cdp.send(tabId, "Input.dispatchMouseEvent", {
|
await cdp.send(tabId, "Input.dispatchMouseEvent", {
|
||||||
type: "mouseMoved",
|
type: "mouseMoved",
|
||||||
x: candidate.x,
|
x: candidate.x,
|
||||||
y: candidate.y,
|
y: candidate.y,
|
||||||
});
|
});
|
||||||
throwIfAborted(options.signal);
|
throwIfAborted(options.signal);
|
||||||
await waitForHover(HOVER_SETTLE_MS, options.signal);
|
await waitForHover(HOVER_SETTLE_MS, options.signal);
|
||||||
const collected = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
const collected = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
||||||
expression: hoverStateExpression(),
|
expression: hoverStateExpression(),
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
});
|
});
|
||||||
throwIfAborted(options.signal);
|
throwIfAborted(options.signal);
|
||||||
const subItems = diffHoverItems(
|
const subItems = diffHoverItems(
|
||||||
baselineItems,
|
baselineItems,
|
||||||
runtimeValue<HoverRuntimeItem[]>(collected) ?? [],
|
runtimeValue<HoverRuntimeItem[]>(collected) ?? [],
|
||||||
);
|
);
|
||||||
if (subItems.length === 0) continue;
|
if (subItems.length === 0) continue;
|
||||||
seen.add(candidate.backendNodeId);
|
seen.add(candidate.backendNodeId);
|
||||||
results.push({
|
results.push({
|
||||||
triggerBackendNodeId: candidate.backendNodeId,
|
triggerBackendNodeId: candidate.backendNodeId,
|
||||||
triggerPoint: { x: candidate.x, y: candidate.y },
|
triggerPoint: { x: candidate.x, y: candidate.y },
|
||||||
triggerAction: "hover",
|
triggerAction: "hover",
|
||||||
subItems,
|
subItems,
|
||||||
confidence: confidenceForHover(candidate, subItems),
|
confidence: confidenceForHover(candidate, subItems),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAbortError(error)) throw error;
|
if (isAbortError(error)) throw error;
|
||||||
continue;
|
continue;
|
||||||
} finally {
|
} finally {
|
||||||
await clearHover(cdp, tabId);
|
await clearHover(cdp, tabId);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
await options.hoverProbeBypassOverlay?.(tabId, false).catch(() => undefined);
|
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -738,365 +443,3 @@ export async function collectOverlayExcludedBackendIds(
|
|||||||
}
|
}
|
||||||
return excluded;
|
return excluded;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse one DOMSnapshot document into CapturedNode[].
|
|
||||||
*
|
|
||||||
* @param doc - the raw DOMSnapshot document object
|
|
||||||
* @param strings - the shared string table for the whole snapshot
|
|
||||||
* @param dpr - device-pixel-ratio from Page.getLayoutMetrics
|
|
||||||
* @param context - frame coordinate context; output rects are top viewport-relative
|
|
||||||
*/
|
|
||||||
function parseDocumentNodes(
|
|
||||||
doc: SnapshotDocument,
|
|
||||||
strings: string[],
|
|
||||||
dpr: number,
|
|
||||||
context: FrameContext,
|
|
||||||
): ParseDocumentResult {
|
|
||||||
const dn = doc.nodes;
|
|
||||||
const dl = doc.layout;
|
|
||||||
if (!dn?.backendNodeId) {
|
|
||||||
return { nodes: [], excludedBackendNodeIds: new Set() };
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = dn.backendNodeId.length;
|
|
||||||
const layoutByNode = new Map<number, number>();
|
|
||||||
(dl?.nodeIndex ?? []).forEach((nodeIdx, layoutIdx) => layoutByNode.set(nodeIdx, layoutIdx));
|
|
||||||
|
|
||||||
// The agent's own overlay (WXT shadow host, marked with OVERLAY_HOST_MARKER_ATTR)
|
|
||||||
// is injected into the page and its open shadow root
|
|
||||||
// is inlined by DOMSnapshot as descendants of the host. Its fixed
|
|
||||||
// full-viewport click-blocker would otherwise dominate occlusion detection
|
|
||||||
// and hide the real page, so mark the host + its whole subtree excluded.
|
|
||||||
// parentIndex always references an earlier node, so one forward pass suffices.
|
|
||||||
const excluded = new Array<boolean>(count).fill(false);
|
|
||||||
const excludedBackendNodeIds = new Set<number>();
|
|
||||||
for (let n = 0; n < count; n++) {
|
|
||||||
const parentIdx = dn.parentIndex?.[n] ?? -1;
|
|
||||||
const inherited = parentIdx >= 0 && excluded[parentIdx];
|
|
||||||
let isOverlayHost = inherited;
|
|
||||||
if (!isOverlayHost) {
|
|
||||||
const pairs = dn.attributes?.[n] ?? [];
|
|
||||||
const attrNames: string[] = [];
|
|
||||||
for (let a = 0; a + 1 < pairs.length; a += 2) {
|
|
||||||
attrNames.push(str(strings, pairs[a]));
|
|
||||||
}
|
|
||||||
isOverlayHost = isOverlayHostNode(str(strings, dn.nodeName?.[n]), attrNames);
|
|
||||||
}
|
|
||||||
if (!isOverlayHost) continue;
|
|
||||||
excluded[n] = true;
|
|
||||||
const backendNodeId = dn.backendNodeId[n];
|
|
||||||
if (backendNodeId !== undefined) {
|
|
||||||
excludedBackendNodeIds.add(backendNodeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const posCol = STYLE_COL.position;
|
|
||||||
const peCol = STYLE_COL["pointer-events"];
|
|
||||||
const cursorCol = STYLE_COL.cursor;
|
|
||||||
const visibilityCol = STYLE_COL.visibility;
|
|
||||||
const opacityCol = STYLE_COL.opacity;
|
|
||||||
const inputValues = sparseIndexMap(dn.inputValue);
|
|
||||||
const textValues = sparseIndexMap(dn.textValue);
|
|
||||||
const checkedInputs = new Set(dn.inputChecked?.index ?? []);
|
|
||||||
const selectedOptions = new Set(dn.optionSelected?.index ?? []);
|
|
||||||
|
|
||||||
// Collect visible text from #text child nodes so element CapturedNodes
|
|
||||||
// carry a textContent value usable as a button/link label fallback.
|
|
||||||
// nodeValue is a parallel array: string index for text nodes, -1 otherwise.
|
|
||||||
const nodeTextContent = new Map<number, string>();
|
|
||||||
if (dn.nodeValue) {
|
|
||||||
for (let n = 0; n < count; n++) {
|
|
||||||
const nvIdx = dn.nodeValue[n] ?? -1;
|
|
||||||
if (nvIdx < 0) continue;
|
|
||||||
const text = str(strings, nvIdx).trim();
|
|
||||||
if (!text) continue;
|
|
||||||
const parentIdx = dn.parentIndex?.[n] ?? -1;
|
|
||||||
if (parentIdx >= 0) {
|
|
||||||
const existing = nodeTextContent.get(parentIdx);
|
|
||||||
nodeTextContent.set(parentIdx, existing ? `${existing} ${text}` : text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build CapturedNodes.
|
|
||||||
const nodes: CapturedNode[] = [];
|
|
||||||
for (let n = 0; n < count; n++) {
|
|
||||||
if (excluded[n]) continue;
|
|
||||||
const backendNodeId = dn.backendNodeId[n];
|
|
||||||
const parentIdx = dn.parentIndex?.[n] ?? -1;
|
|
||||||
const parentBackendNodeId = parentIdx >= 0 ? (dn.backendNodeId[parentIdx] ?? null) : null;
|
|
||||||
const tag = str(strings, dn.nodeName?.[n]).toLowerCase();
|
|
||||||
|
|
||||||
const attrs: Record<string, string> = {};
|
|
||||||
const pairs = dn.attributes?.[n] ?? [];
|
|
||||||
for (let a = 0; a + 1 < pairs.length; a += 2) {
|
|
||||||
attrs[str(strings, pairs[a]).toLowerCase()] = str(strings, pairs[a + 1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let rect: Rect | null = null;
|
|
||||||
let localRect: Rect | null = null;
|
|
||||||
let paintOrder = 0;
|
|
||||||
let position = "static";
|
|
||||||
let pointerEvents = "auto";
|
|
||||||
let cursor = "auto";
|
|
||||||
let rendered = false;
|
|
||||||
const li = layoutByNode.get(n);
|
|
||||||
if (li !== undefined) {
|
|
||||||
const b = dl?.bounds?.[li];
|
|
||||||
if (b && b.length >= 4 && b[2] > 0 && b[3] > 0) {
|
|
||||||
localRect = {
|
|
||||||
x: b[0] / dpr - context.scrollX,
|
|
||||||
y: b[1] / dpr - context.scrollY,
|
|
||||||
w: b[2] / dpr,
|
|
||||||
h: b[3] / dpr,
|
|
||||||
};
|
|
||||||
const bounds = context.projection
|
|
||||||
? projectRectToViewport(localRect, context.projection)
|
|
||||||
: null;
|
|
||||||
rect = bounds ? { x: bounds.x, y: bounds.y, w: bounds.width, h: bounds.height } : null;
|
|
||||||
}
|
|
||||||
paintOrder = dl?.paintOrders?.[li] ?? 0;
|
|
||||||
const styleRow = dl?.styles?.[li] ?? [];
|
|
||||||
position = str(strings, styleRow[posCol]) || "static";
|
|
||||||
pointerEvents = str(strings, styleRow[peCol]) || "auto";
|
|
||||||
cursor = str(strings, styleRow[cursorCol]) || "auto";
|
|
||||||
const visibility = str(strings, styleRow[visibilityCol]) || "visible";
|
|
||||||
const opacity = str(strings, styleRow[opacityCol]) || "1";
|
|
||||||
rendered =
|
|
||||||
localRect !== null &&
|
|
||||||
visibility !== "hidden" &&
|
|
||||||
visibility !== "collapse" &&
|
|
||||||
(Number.parseFloat(opacity) || 0) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip non-element nodes (#text, #cdata-section, etc.) — they carry no
|
|
||||||
// geometry and are never queried by callers.
|
|
||||||
if (tag.startsWith("#")) continue;
|
|
||||||
|
|
||||||
const textContent = nodeTextContent.get(n);
|
|
||||||
|
|
||||||
const rawFormValueIndex = tag === "textarea" ? textValues.get(n) : inputValues.get(n);
|
|
||||||
const rawFormValue =
|
|
||||||
rawFormValueIndex !== undefined ? str(strings, rawFormValueIndex) : undefined;
|
|
||||||
const sensitive = isSensitiveFormControl({ tag, attrs });
|
|
||||||
const formDefaultValue = attrs.value ?? "";
|
|
||||||
const formValue = sensitive ? undefined : rawFormValue;
|
|
||||||
const formState = snapshotFormState(
|
|
||||||
rawFormValue,
|
|
||||||
formDefaultValue,
|
|
||||||
Object.prototype.hasOwnProperty.call(attrs, "value"),
|
|
||||||
sensitive,
|
|
||||||
);
|
|
||||||
if (sensitive) delete attrs.value;
|
|
||||||
|
|
||||||
nodes.push({
|
|
||||||
backendNodeId,
|
|
||||||
parentBackendNodeId,
|
|
||||||
...(context.frameId ? { frameId: context.frameId } : {}),
|
|
||||||
ownerFrameBackendNodeId: context.ownerFrameBackendNodeId,
|
|
||||||
tag,
|
|
||||||
attrs,
|
|
||||||
rect,
|
|
||||||
localRect,
|
|
||||||
paintOrder,
|
|
||||||
position,
|
|
||||||
pointerEvents,
|
|
||||||
cursor,
|
|
||||||
rendered,
|
|
||||||
textContent,
|
|
||||||
...(formValue !== undefined ? { formValue } : {}),
|
|
||||||
...(tag === "input" || tag === "textarea"
|
|
||||||
? {
|
|
||||||
formPlaceholder: attrs.placeholder ?? "",
|
|
||||||
...(!sensitive ? { formDefaultValue } : {}),
|
|
||||||
...(formState ? { formState } : {}),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
...(checkedInputs.has(n) ? { formValue: "true", formState: "filled" } : {}),
|
|
||||||
...(selectedOptions.has(n) ? { formValue: attrs.value ?? textContent ?? "" } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { nodes, excludedBackendNodeIds };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ParseFrameDocumentsResult {
|
|
||||||
iframeNodes: CapturedIframeNodes;
|
|
||||||
frameOwnerBackendNodeIds: Map<string, number>;
|
|
||||||
frameParentIds: Map<string, string>;
|
|
||||||
excludedBackendNodeIds: Set<number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseChildFrameDocuments(
|
|
||||||
documents: SnapshotDocument[],
|
|
||||||
strings: string[],
|
|
||||||
dpr: number,
|
|
||||||
parentDocIndex: number,
|
|
||||||
parentNodes: CapturedNode[],
|
|
||||||
parentContext: FrameContext,
|
|
||||||
visited = new Set<number>(),
|
|
||||||
): ParseFrameDocumentsResult {
|
|
||||||
const iframeNodes: CapturedIframeNodes = new Map();
|
|
||||||
const frameOwnerBackendNodeIds = new Map<string, number>();
|
|
||||||
const frameParentIds = new Map<string, string>();
|
|
||||||
const excludedBackendNodeIds = new Set<number>();
|
|
||||||
const parentDoc = documents[parentDocIndex];
|
|
||||||
const cdi = sparseIndexMap(parentDoc?.nodes?.contentDocumentIndex);
|
|
||||||
if (cdi.size === 0) {
|
|
||||||
return { iframeNodes, frameOwnerBackendNodeIds, frameParentIds, excludedBackendNodeIds };
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentBackendIds = parentDoc?.nodes?.backendNodeId ?? [];
|
|
||||||
const parentNodeByBackendId = new Map(parentNodes.map((node) => [node.backendNodeId, node]));
|
|
||||||
|
|
||||||
for (const [nodeArrayIdx, childDocIndex] of cdi) {
|
|
||||||
if (visited.has(childDocIndex)) continue;
|
|
||||||
const childDoc = documents[childDocIndex];
|
|
||||||
if (!childDoc) continue;
|
|
||||||
const iframeBackendId = parentBackendIds[nodeArrayIdx];
|
|
||||||
if (iframeBackendId === undefined) continue;
|
|
||||||
const iframeNode = parentNodeByBackendId.get(iframeBackendId);
|
|
||||||
const projection =
|
|
||||||
parentContext.projection && iframeNode?.localRect
|
|
||||||
? childFrameProjection(parentContext.projection, iframeNode.localRect)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const childContext: FrameContext = {
|
|
||||||
frameId: snapshotFrameId(childDoc, strings),
|
|
||||||
ownerFrameBackendNodeId: iframeBackendId,
|
|
||||||
projection,
|
|
||||||
scrollX: childDoc.scrollOffsetX ?? 0,
|
|
||||||
scrollY: childDoc.scrollOffsetY ?? 0,
|
|
||||||
};
|
|
||||||
const nextVisited = new Set(visited);
|
|
||||||
nextVisited.add(childDocIndex);
|
|
||||||
const parsed = parseDocumentNodes(childDoc, strings, dpr, childContext);
|
|
||||||
iframeNodes.set(iframeBackendId, parsed.nodes);
|
|
||||||
if (childContext.frameId) {
|
|
||||||
frameOwnerBackendNodeIds.set(childContext.frameId, iframeBackendId);
|
|
||||||
if (parentContext.frameId) frameParentIds.set(childContext.frameId, parentContext.frameId);
|
|
||||||
}
|
|
||||||
for (const id of parsed.excludedBackendNodeIds) excludedBackendNodeIds.add(id);
|
|
||||||
|
|
||||||
const nested = parseChildFrameDocuments(
|
|
||||||
documents,
|
|
||||||
strings,
|
|
||||||
dpr,
|
|
||||||
childDocIndex,
|
|
||||||
parsed.nodes,
|
|
||||||
childContext,
|
|
||||||
nextVisited,
|
|
||||||
);
|
|
||||||
for (const [nestedFrameId, nestedNodes] of nested.iframeNodes) {
|
|
||||||
iframeNodes.set(nestedFrameId, nestedNodes);
|
|
||||||
}
|
|
||||||
for (const [frameId, ownerBackendNodeId] of nested.frameOwnerBackendNodeIds) {
|
|
||||||
frameOwnerBackendNodeIds.set(frameId, ownerBackendNodeId);
|
|
||||||
}
|
|
||||||
for (const [frameId, parentFrameId] of nested.frameParentIds) {
|
|
||||||
frameParentIds.set(frameId, parentFrameId);
|
|
||||||
}
|
|
||||||
for (const id of nested.excludedBackendNodeIds) excludedBackendNodeIds.add(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { iframeNodes, frameOwnerBackendNodeIds, frameParentIds, excludedBackendNodeIds };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function captureViewModel(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
options: CaptureViewModelOptions = {},
|
|
||||||
): Promise<CapturedViewModel> {
|
|
||||||
throwIfAborted(options.signal);
|
|
||||||
let metrics: LayoutMetricsReply = {};
|
|
||||||
try {
|
|
||||||
metrics = await cdp.send<LayoutMetricsReply>(tabId, "Page.getLayoutMetrics", {});
|
|
||||||
} catch (error) {
|
|
||||||
if (isAbortError(error)) throw error;
|
|
||||||
console.debug("[bsk capture] layout metrics unavailable", error);
|
|
||||||
}
|
|
||||||
throwIfAborted(options.signal);
|
|
||||||
const dpr = devicePixelRatio(metrics);
|
|
||||||
const vpSrc = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
|
|
||||||
const viewport: Viewport = {
|
|
||||||
width: vpSrc.clientWidth ?? 0,
|
|
||||||
height: vpSrc.clientHeight ?? 0,
|
|
||||||
};
|
|
||||||
const scrollX = vpSrc.pageX ?? 0;
|
|
||||||
const scrollY = vpSrc.pageY ?? 0;
|
|
||||||
|
|
||||||
await cdp.send(tabId, "DOMSnapshot.enable", {});
|
|
||||||
throwIfAborted(options.signal);
|
|
||||||
const snap = await cdp.send<SnapshotReply>(tabId, "DOMSnapshot.captureSnapshot", {
|
|
||||||
computedStyles: REQUESTED_STYLES,
|
|
||||||
includePaintOrder: true,
|
|
||||||
includeDOMRects: true,
|
|
||||||
});
|
|
||||||
throwIfAborted(options.signal);
|
|
||||||
|
|
||||||
const strings = snap.strings ?? [];
|
|
||||||
const documents = snap.documents ?? [];
|
|
||||||
const doc0 = documents[0];
|
|
||||||
if (!doc0?.nodes?.backendNodeId) {
|
|
||||||
return {
|
|
||||||
nodes: [],
|
|
||||||
viewport,
|
|
||||||
iframeNodes: new Map(),
|
|
||||||
surfaceProbes: [],
|
|
||||||
excludedBackendNodeIds: new Set(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const topContext: FrameContext = {
|
|
||||||
frameId: snapshotFrameId(doc0, strings),
|
|
||||||
ownerFrameBackendNodeId: null,
|
|
||||||
projection: {
|
|
||||||
sourceClips: [],
|
|
||||||
edges: [],
|
|
||||||
topViewport: viewport,
|
|
||||||
},
|
|
||||||
scrollX,
|
|
||||||
scrollY,
|
|
||||||
};
|
|
||||||
const mainParsed = parseDocumentNodes(doc0, strings, dpr, topContext);
|
|
||||||
const nodes = mainParsed.nodes;
|
|
||||||
const excludedBackendNodeIds = new Set(mainParsed.excludedBackendNodeIds);
|
|
||||||
|
|
||||||
const frameParsed = parseChildFrameDocuments(documents, strings, dpr, 0, nodes, topContext);
|
|
||||||
const iframeNodes = frameParsed.iframeNodes;
|
|
||||||
for (const id of frameParsed.excludedBackendNodeIds) {
|
|
||||||
excludedBackendNodeIds.add(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
await enrichFormControlStates(cdp, tabId, [nodes, ...iframeNodes.values()], options.signal);
|
|
||||||
throwIfAborted(options.signal);
|
|
||||||
|
|
||||||
const surfaceProbes = options.conditionalSurfaceProbe
|
|
||||||
? await probeHoverSurfaces(cdp, tabId, nodes, options)
|
|
||||||
: [];
|
|
||||||
throwIfAborted(options.signal);
|
|
||||||
|
|
||||||
const frameNodes = new Map<string, CapturedNode[]>();
|
|
||||||
if (topContext.frameId) frameNodes.set(topContext.frameId, nodes);
|
|
||||||
for (const iframeFrameNodes of iframeNodes.values()) {
|
|
||||||
const frameId = iframeFrameNodes.find((node) => node.frameId)?.frameId;
|
|
||||||
if (frameId) frameNodes.set(frameId, iframeFrameNodes);
|
|
||||||
}
|
|
||||||
for (const [frameId, ownerBackendNodeId] of frameParsed.frameOwnerBackendNodeIds) {
|
|
||||||
if (!frameNodes.has(frameId)) {
|
|
||||||
frameNodes.set(frameId, iframeNodes.get(ownerBackendNodeId) ?? []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
nodes,
|
|
||||||
viewport,
|
|
||||||
iframeNodes,
|
|
||||||
frameNodes,
|
|
||||||
frameOwnerBackendNodeIds: frameParsed.frameOwnerBackendNodeIds,
|
|
||||||
frameParentIds: frameParsed.frameParentIds,
|
|
||||||
...(topContext.frameId ? { rootFrameId: topContext.frameId } : {}),
|
|
||||||
surfaceProbes,
|
|
||||||
excludedBackendNodeIds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { CdpRunner } from "../shared";
|
||||||
|
import { sendToCdpTarget } from "../shared";
|
||||||
|
import { isAbortError, throwIfAborted } from "./capture-abort";
|
||||||
|
import type { DocumentIdentity } from "./facts";
|
||||||
|
|
||||||
|
/** Compare the snapshot root with the current root in that exact frame.
|
||||||
|
* Deep serialization supplies the backend ID without a separate describeNode. */
|
||||||
|
export async function verifyDocumentIdentity(
|
||||||
|
cdp: CdpRunner,
|
||||||
|
identity: DocumentIdentity,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<"current" | "changed" | "unavailable"> {
|
||||||
|
const attached = () => cdp.getAttachmentId?.(identity.target.tabId) === identity.attachmentId;
|
||||||
|
throwIfAborted(signal);
|
||||||
|
if (!attached()) return "changed";
|
||||||
|
const objectGroup = `bsk-document-identity-${crypto.randomUUID()}`;
|
||||||
|
const send = <T>(method: string, params: object) => {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
return sendToCdpTarget<T>(cdp, identity.target, method, params);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const world = await send<{ executionContextId: number }>("Page.createIsolatedWorld", {
|
||||||
|
frameId: identity.frameId,
|
||||||
|
worldName: "bsk-document-identity",
|
||||||
|
});
|
||||||
|
const reply = await send<{
|
||||||
|
result?: { deepSerializedValue?: { type: string; value?: { backendNodeId?: number } } };
|
||||||
|
}>("Runtime.evaluate", {
|
||||||
|
expression: "document.documentElement",
|
||||||
|
contextId: world.executionContextId,
|
||||||
|
objectGroup,
|
||||||
|
serializationOptions: {
|
||||||
|
serialization: "deep",
|
||||||
|
additionalParameters: { maxNodeDepth: 0, includeShadowTree: "none" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!attached()) return "changed";
|
||||||
|
const root = reply.result?.deepSerializedValue;
|
||||||
|
if (root?.type === "null") return "changed";
|
||||||
|
const id = root?.type === "node" ? root.value?.backendNodeId : undefined;
|
||||||
|
if (id === undefined) return "unavailable";
|
||||||
|
return id === identity.documentElementBackendNodeId ? "current" : "changed";
|
||||||
|
} catch (error) {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
if (isAbortError(error)) throw error;
|
||||||
|
return attached() ? "unavailable" : "changed";
|
||||||
|
} finally {
|
||||||
|
await sendToCdpTarget(cdp, identity.target, "Runtime.releaseObjectGroup", {
|
||||||
|
objectGroup,
|
||||||
|
}).catch(() => {});
|
||||||
|
throwIfAborted(signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import type { Viewport } from "@browser-skill/vom";
|
||||||
|
import type { CdpFrame, CdpTarget } from "@/browser-driver/frame-graph";
|
||||||
|
import { isOverlayHostNode } from "@/lib/overlay-bridge";
|
||||||
|
import type { FrameProjectionIssue } from "../geometry/coordinate-types";
|
||||||
|
import { createCaptureCheckpoint } from "./capture-abort";
|
||||||
|
import type { CapturedNode } from "./capture-types";
|
||||||
|
import type { FrameOwnedAxNode } from "./frame-document";
|
||||||
|
|
||||||
|
/** Bounds retain their raw snapshot document layout units until normalization. */
|
||||||
|
export interface SnapshotLayout {
|
||||||
|
readonly boundsSpace: "snapshot-document-layout";
|
||||||
|
bounds?: number[];
|
||||||
|
styles: Readonly<Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecodedNode extends Omit<CapturedNode, "rect" | "localRect" | "rendered"> {
|
||||||
|
nodeType?: number;
|
||||||
|
layout?: SnapshotLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeFacts extends CapturedNode {
|
||||||
|
nodeType?: number;
|
||||||
|
layout?: SnapshotLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentIndex<T extends DecodedNode = NodeFacts> {
|
||||||
|
readonly nodes: ReadonlyMap<number, T>;
|
||||||
|
readonly excludedBackendNodeIds: ReadonlySet<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecodedDocument {
|
||||||
|
nodes: DecodedNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All snapshot nodes participate, including document and shadow roots. This
|
||||||
|
* preserves overlay propagation when the semantic adapter omits those nodes. */
|
||||||
|
export async function buildDocumentIndex<T extends DecodedNode>(
|
||||||
|
input: readonly T[],
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<DocumentIndex<T>> {
|
||||||
|
const checkpoint = createCaptureCheckpoint(signal);
|
||||||
|
const nodes = new Map<number, T>();
|
||||||
|
const overlayByNode = new Map<number, boolean>();
|
||||||
|
const excludedBackendNodeIds = new Set<number>();
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
if (i % 256 === 0) {
|
||||||
|
const pending = checkpoint();
|
||||||
|
if (pending) await pending;
|
||||||
|
}
|
||||||
|
const node = input[i];
|
||||||
|
nodes.set(node.backendNodeId, node);
|
||||||
|
}
|
||||||
|
let work = 0;
|
||||||
|
for (const node of input) {
|
||||||
|
if (work++ % 256 === 0) {
|
||||||
|
const pending = checkpoint();
|
||||||
|
if (pending) await pending;
|
||||||
|
}
|
||||||
|
if (overlayByNode.has(node.backendNodeId)) continue;
|
||||||
|
const path: DecodedNode[] = [];
|
||||||
|
const visiting = new Set<number>();
|
||||||
|
let current: DecodedNode | undefined = node;
|
||||||
|
let overlay = false;
|
||||||
|
while (current && !overlayByNode.has(current.backendNodeId)) {
|
||||||
|
if (work++ % 256 === 0) {
|
||||||
|
const pending = checkpoint();
|
||||||
|
if (pending) await pending;
|
||||||
|
}
|
||||||
|
if (visiting.has(current.backendNodeId)) {
|
||||||
|
// Only an overlay inside the cycle may mark the cycle and its descendants.
|
||||||
|
overlay = path
|
||||||
|
.slice(path.findIndex((item) => item.backendNodeId === current!.backendNodeId))
|
||||||
|
.some((item) => isOverlayHostNode(item.tag, Object.keys(item.attrs)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
visiting.add(current.backendNodeId);
|
||||||
|
path.push(current);
|
||||||
|
if (current.parentBackendNodeId === null) {
|
||||||
|
current = undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = nodes.get(current.parentBackendNodeId);
|
||||||
|
}
|
||||||
|
if (current && overlayByNode.has(current.backendNodeId))
|
||||||
|
overlay = overlayByNode.get(current.backendNodeId)!;
|
||||||
|
for (let i = path.length - 1; i >= 0; i--) {
|
||||||
|
if (work++ % 256 === 0) {
|
||||||
|
const pending = checkpoint();
|
||||||
|
if (pending) await pending;
|
||||||
|
}
|
||||||
|
const item = path[i];
|
||||||
|
overlay = overlay || isOverlayHostNode(item.tag, Object.keys(item.attrs));
|
||||||
|
overlayByNode.set(item.backendNodeId, overlay);
|
||||||
|
if (overlay) excludedBackendNodeIds.add(item.backendNodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { nodes, excludedBackendNodeIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentIdentity {
|
||||||
|
attachmentId: string;
|
||||||
|
target: CdpTarget;
|
||||||
|
frameId: string;
|
||||||
|
documentElementBackendNodeId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrow input shared by the existing semantic scene/hover consumers. */
|
||||||
|
export interface CapturedSceneInput {
|
||||||
|
nodes: CapturedNode[];
|
||||||
|
viewport: Viewport;
|
||||||
|
rootFrameId?: string;
|
||||||
|
excludedBackendNodeIds: ReadonlySet<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaptureIssue {
|
||||||
|
/** Owner failure or inherited blockage; absent for other geometry failures. */
|
||||||
|
projectionIssue?: FrameProjectionIssue;
|
||||||
|
target: CdpTarget;
|
||||||
|
frameId?: string;
|
||||||
|
stage: "dom" | "ax" | "identity" | "ownership" | "geometry" | "forms";
|
||||||
|
reason:
|
||||||
|
| "document-changed"
|
||||||
|
| "identity-unavailable"
|
||||||
|
| "identity-unverified"
|
||||||
|
| "capture-unavailable"
|
||||||
|
| "frame-ownership-unresolved"
|
||||||
|
| "geometry-unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentFacts<T extends FrameOwnedAxNode> {
|
||||||
|
readonly frame: CdpFrame;
|
||||||
|
readonly identity?: DocumentIdentity;
|
||||||
|
readonly index: DocumentIndex;
|
||||||
|
readonly domNodes: CapturedNode[];
|
||||||
|
readonly axNodes: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservationFacts<T extends FrameOwnedAxNode> {
|
||||||
|
readonly rootFrameId: string;
|
||||||
|
readonly viewport: Viewport;
|
||||||
|
readonly documents: readonly DocumentFacts<T>[];
|
||||||
|
readonly issues: readonly CaptureIssue[];
|
||||||
|
readonly startedAt: number;
|
||||||
|
readonly finishedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapturedSurfaceProbe {
|
||||||
|
triggerBackendNodeId: number;
|
||||||
|
triggerPoint?: { x: number; y: number };
|
||||||
|
triggerAction: "hover" | "focus" | string;
|
||||||
|
subItems: string[];
|
||||||
|
confidence?: "high" | "medium" | "low";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { CapturedNode } from "./capture-types";
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import type { CdpRunner } from "../shared";
|
||||||
|
import { isAbortError, throwIfAborted } from "./capture-abort";
|
||||||
|
import type { CapturedNode } from "./capture-types";
|
||||||
|
|
||||||
|
interface RuntimeEvaluateReply {
|
||||||
|
result?: { deepSerializedValue?: DeepSerializedValue };
|
||||||
|
}
|
||||||
|
const MAX_FORM_ENRICH_CONTROLS = 250;
|
||||||
|
|
||||||
|
interface CapturedFormState {
|
||||||
|
value?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
state?: "empty" | "filled" | "default";
|
||||||
|
sensitive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeepSerializedValue {
|
||||||
|
type: string;
|
||||||
|
value?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formStateBatchExpression(maxControls: number): string {
|
||||||
|
return `(() => {
|
||||||
|
const maxControls = ${JSON.stringify(maxControls)};
|
||||||
|
let remaining = maxControls;
|
||||||
|
const controlSelector = "input,textarea,select";
|
||||||
|
const controlState = (el) => {
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
const type = tag === "input" ? String(el.type || "text").toLowerCase() : tag;
|
||||||
|
const sensitive = type === "password" || type === "credit-card";
|
||||||
|
const rawValue = typeof el.value === "string" ? el.value : "";
|
||||||
|
const defaultValue = typeof el.defaultValue === "string" ? el.defaultValue : "";
|
||||||
|
const placeholder = typeof el.placeholder === "string" ? el.placeholder : "";
|
||||||
|
const state = rawValue === "" ? "empty" : rawValue === defaultValue ? "default" : "filled";
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
sensitive,
|
||||||
|
placeholder,
|
||||||
|
...(sensitive ? {} : { value: rawValue, defaultValue }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const controls = [];
|
||||||
|
const collect = (doc) => {
|
||||||
|
for (const el of Array.from(doc.querySelectorAll(controlSelector))) {
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
// Deep serialization supplies the node's backend id. Keep the
|
||||||
|
// state as JSON so decoding needs no general-purpose V8 deserializer.
|
||||||
|
controls.push([el, JSON.stringify(controlState(el))]);
|
||||||
|
remaining -= 1;
|
||||||
|
}
|
||||||
|
for (const frame of Array.from(doc.querySelectorAll("iframe"))) {
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
let childDoc = null;
|
||||||
|
try { childDoc = frame.contentDocument; } catch { childDoc = null; }
|
||||||
|
if (childDoc) collect(childDoc);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
collect(document);
|
||||||
|
return controls;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formStatesByBackendId(result: RuntimeEvaluateReply): Map<number, CapturedFormState> {
|
||||||
|
const states = new Map<number, CapturedFormState>();
|
||||||
|
const serialized = result.result?.deepSerializedValue;
|
||||||
|
if (serialized?.type !== "array" || !Array.isArray(serialized.value)) return states;
|
||||||
|
for (const entry of serialized.value as DeepSerializedValue[]) {
|
||||||
|
if (entry?.type !== "array" || !Array.isArray(entry.value)) continue;
|
||||||
|
const [element, json] = entry.value as DeepSerializedValue[];
|
||||||
|
if (element?.type !== "node" || json?.type !== "string" || typeof json.value !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const backendNodeId = (element.value as { backendNodeId?: number } | undefined)?.backendNodeId;
|
||||||
|
if (typeof backendNodeId !== "number" || !Number.isSafeInteger(backendNodeId)) continue;
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(json.value) as CapturedFormState | null;
|
||||||
|
if (
|
||||||
|
!state ||
|
||||||
|
!["empty", "filled", "default"].includes(state.state ?? "") ||
|
||||||
|
typeof state.sensitive !== "boolean" ||
|
||||||
|
typeof state.placeholder !== "string" ||
|
||||||
|
(!state.sensitive &&
|
||||||
|
(typeof state.value !== "string" || typeof state.defaultValue !== "string"))
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
states.set(backendNodeId, state);
|
||||||
|
} catch {
|
||||||
|
// A malformed entry must not overwrite the snapshot's own state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return states;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFormStates(nodes: CapturedNode[], states: Map<number, CapturedFormState>): void {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (!["input", "textarea", "select"].includes(node.tag)) continue;
|
||||||
|
const state = states.get(node.backendNodeId);
|
||||||
|
if (!state) continue;
|
||||||
|
node.formState = state.state;
|
||||||
|
node.formPlaceholder = state.placeholder ?? "";
|
||||||
|
if (
|
||||||
|
state.sensitive ||
|
||||||
|
(node.tag === "input" && (node.attrs.type ?? "").toLowerCase() === "password")
|
||||||
|
) {
|
||||||
|
delete node.formValue;
|
||||||
|
delete node.formDefaultValue;
|
||||||
|
delete node.attrs.value;
|
||||||
|
} else {
|
||||||
|
node.formDefaultValue = state.defaultValue ?? "";
|
||||||
|
if (state.value !== undefined) node.formValue = state.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrichFormControlStates(
|
||||||
|
cdp: CdpRunner,
|
||||||
|
tabId: number,
|
||||||
|
frameNodeGroups: CapturedNode[][],
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const hasControls = frameNodeGroups.some((nodes) =>
|
||||||
|
nodes.some((node) => ["input", "textarea", "select"].includes(node.tag)),
|
||||||
|
);
|
||||||
|
if (!hasControls) return true;
|
||||||
|
const objectGroup = `bsk-vom-forms-${crypto.randomUUID()}`;
|
||||||
|
try {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
const result = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
||||||
|
expression: formStateBatchExpression(MAX_FORM_ENRICH_CONTROLS),
|
||||||
|
objectGroup,
|
||||||
|
serializationOptions: {
|
||||||
|
serialization: "deep",
|
||||||
|
maxDepth: 3,
|
||||||
|
additionalParameters: { maxNodeDepth: 0, includeShadowTree: "none" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
throwIfAborted(signal);
|
||||||
|
// Backend ids are scoped to this capture's CDP target. OOPIF targets
|
||||||
|
// have separate enrichment calls and never share this lookup.
|
||||||
|
const states = formStatesByBackendId(result);
|
||||||
|
for (const nodes of frameNodeGroups) {
|
||||||
|
applyFormStates(nodes, states);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) throw error;
|
||||||
|
// Best-effort enrichment. DOMSnapshot/AX data still carries the nodes.
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
await cdp.send(tabId, "Runtime.releaseObjectGroup", { objectGroup }).catch(() => undefined);
|
||||||
|
throwIfAborted(signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
import type { CdpFrame, CdpFrameGraph } from "@/browser-driver/frame-graph";
|
|
||||||
import { resolveFrameProjection } from "../frame-geometry";
|
|
||||||
import { type GeometryProjection, projectRectToViewport } from "../geometry";
|
|
||||||
import { type CdpRunner, cdpRunnerForTarget, sendToCdpTarget } from "../shared";
|
|
||||||
import { type CapturedNode, type CapturedViewModel, captureViewModel } from "./capture";
|
|
||||||
import {
|
|
||||||
buildFrameDocuments,
|
|
||||||
type FrameAxBatch,
|
|
||||||
type FrameDocument,
|
|
||||||
type FrameOwnedAxNode,
|
|
||||||
} from "./frame-document";
|
|
||||||
|
|
||||||
export type FrameAxNode = FrameOwnedAxNode;
|
|
||||||
export type CapturedFrameDocument<T extends FrameAxNode> = FrameDocument<T>;
|
|
||||||
|
|
||||||
function throwIfAborted(signal?: AbortSignal): void {
|
|
||||||
if (signal?.aborted) throw new DOMException("observation aborted", "AbortError");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAbortError(err: unknown): boolean {
|
|
||||||
return err instanceof Error && err.name === "AbortError";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function discoverFrameGraph(cdp: CdpRunner, tabId: number): Promise<CdpFrameGraph | null> {
|
|
||||||
if (!cdp.getFrameGraph) return null;
|
|
||||||
try {
|
|
||||||
return await cdp.getFrameGraph(tabId);
|
|
||||||
} catch (err) {
|
|
||||||
console.debug("[bsk observation] frame graph capture failed", err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformFrameNodes(
|
|
||||||
nodes: CapturedNode[],
|
|
||||||
projection: GeometryProjection | null,
|
|
||||||
): CapturedNode[] {
|
|
||||||
return nodes.map((node) => ({
|
|
||||||
...node,
|
|
||||||
rect: projection
|
|
||||||
? (() => {
|
|
||||||
const bounds = projectRectToViewport(node.rect, projection);
|
|
||||||
return bounds ? { x: bounds.x, y: bounds.y, w: bounds.width, h: bounds.height } : null;
|
|
||||||
})()
|
|
||||||
: null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function captureMissingFrameDocuments(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
graph: CdpFrameGraph,
|
|
||||||
captured: CapturedViewModel,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!captured.frameNodes) captured.frameNodes = new Map();
|
|
||||||
if (!captured.frameOwnerBackendNodeIds) captured.frameOwnerBackendNodeIds = new Map();
|
|
||||||
if (!captured.frameParentIds) captured.frameParentIds = new Map();
|
|
||||||
|
|
||||||
for (const frame of graph.frames) {
|
|
||||||
if (!frame.target.sessionId || captured.frameNodes.has(frame.frameId)) continue;
|
|
||||||
try {
|
|
||||||
const child = await captureViewModel(cdpRunnerForTarget(cdp, frame.target), tabId, {
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
let projection: GeometryProjection | null = null;
|
|
||||||
try {
|
|
||||||
projection = await resolveFrameProjection(cdp, graph, frame.frameId);
|
|
||||||
} catch (err) {
|
|
||||||
if (isAbortError(err)) throw err;
|
|
||||||
console.debug("[bsk observation] child frame geometry projection failed", {
|
|
||||||
frameId: frame.frameId,
|
|
||||||
err,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const rootFrameId = child.rootFrameId ?? frame.frameId;
|
|
||||||
const childFrames = child.frameNodes ?? new Map<string, CapturedNode[]>();
|
|
||||||
if (!childFrames.has(rootFrameId)) childFrames.set(rootFrameId, child.nodes);
|
|
||||||
if (rootFrameId !== frame.frameId && !childFrames.has(frame.frameId)) {
|
|
||||||
childFrames.set(
|
|
||||||
frame.frameId,
|
|
||||||
child.nodes.map((node) => ({ ...node, frameId: frame.frameId })),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const [childFrameId, nodes] of childFrames) {
|
|
||||||
captured.frameNodes.set(childFrameId, transformFrameNodes(nodes, projection));
|
|
||||||
}
|
|
||||||
if (frame.ownerBackendNodeId !== undefined) {
|
|
||||||
captured.iframeNodes.set(
|
|
||||||
frame.ownerBackendNodeId,
|
|
||||||
captured.frameNodes.get(frame.frameId) ?? [],
|
|
||||||
);
|
|
||||||
captured.frameOwnerBackendNodeIds.set(frame.frameId, frame.ownerBackendNodeId);
|
|
||||||
}
|
|
||||||
if (frame.parentFrameId) captured.frameParentIds.set(frame.frameId, frame.parentFrameId);
|
|
||||||
for (const [childFrameId, ownerBackendNodeId] of child.frameOwnerBackendNodeIds ?? []) {
|
|
||||||
captured.frameOwnerBackendNodeIds.set(childFrameId, ownerBackendNodeId);
|
|
||||||
}
|
|
||||||
for (const [childFrameId, parentFrameId] of child.frameParentIds ?? []) {
|
|
||||||
captured.frameParentIds.set(childFrameId, parentFrameId);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (isAbortError(err)) throw err;
|
|
||||||
console.debug("[bsk observation] child frame DOM capture failed", {
|
|
||||||
frameId: frame.frameId,
|
|
||||||
err,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function captureAxTrees<T extends FrameAxNode>(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
discoveredFrames: CdpFrame[],
|
|
||||||
captured: CapturedViewModel,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<FrameAxBatch<T>[]> {
|
|
||||||
let frames = [...discoveredFrames];
|
|
||||||
const capturedFrameIds = [...(captured.frameNodes?.keys() ?? [])];
|
|
||||||
if (frames.length === 0) {
|
|
||||||
const rootFrameId = captured.rootFrameId ?? capturedFrameIds[0] ?? "root";
|
|
||||||
frames = [{ frameId: rootFrameId, target: { tabId } }];
|
|
||||||
}
|
|
||||||
for (const frameId of capturedFrameIds) {
|
|
||||||
if (!frames.some((frame) => frame.frameId === frameId)) {
|
|
||||||
frames.push({ frameId, target: { tabId } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let firstFailure: unknown;
|
|
||||||
const trees = await Promise.all(
|
|
||||||
frames.map(async (frame): Promise<FrameAxBatch<T>> => {
|
|
||||||
try {
|
|
||||||
throwIfAborted(signal);
|
|
||||||
await sendToCdpTarget(cdp, frame.target, "Accessibility.enable", {});
|
|
||||||
const result = await sendToCdpTarget<{ nodes?: T[] }>(
|
|
||||||
cdp,
|
|
||||||
frame.target,
|
|
||||||
"Accessibility.getFullAXTree",
|
|
||||||
frame.frameId === "root" ? {} : { frameId: frame.frameId },
|
|
||||||
);
|
|
||||||
throwIfAborted(signal);
|
|
||||||
return {
|
|
||||||
frame,
|
|
||||||
nodes: result.nodes ?? [],
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
if (isAbortError(err)) throw err;
|
|
||||||
firstFailure ??= err;
|
|
||||||
console.debug("[bsk observation] frame AX capture failed", {
|
|
||||||
frameId: frame.frameId,
|
|
||||||
err,
|
|
||||||
});
|
|
||||||
return { frame, nodes: [] };
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const capturedNodeCount = [...captured.iframeNodes.values()].reduce(
|
|
||||||
(count, nodes) => count + nodes.length,
|
|
||||||
captured.nodes.length,
|
|
||||||
);
|
|
||||||
if (firstFailure && capturedNodeCount === 0 && trees.every((tree) => tree.nodes.length === 0)) {
|
|
||||||
throw firstFailure;
|
|
||||||
}
|
|
||||||
return trees;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function captureFrameData<T extends FrameAxNode>(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
captured: CapturedViewModel,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<CapturedFrameDocument<T>[]> {
|
|
||||||
const graph = await discoverFrameGraph(cdp, tabId);
|
|
||||||
const frames = graph?.frames ?? [];
|
|
||||||
if (graph) await captureMissingFrameDocuments(cdp, tabId, graph, captured, signal);
|
|
||||||
throwIfAborted(signal);
|
|
||||||
const batches = await captureAxTrees<T>(cdp, tabId, frames, captured, signal);
|
|
||||||
return buildFrameDocuments(graph, batches, captured);
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
type CdpTarget,
|
type CdpTarget,
|
||||||
cdpTargetKey,
|
cdpTargetKey,
|
||||||
} from "@/browser-driver/frame-graph";
|
} from "@/browser-driver/frame-graph";
|
||||||
import type { CapturedNode, CapturedViewModel } from "./capture";
|
import { createCaptureCheckpoint } from "./capture-abort";
|
||||||
|
import type { CapturedNode } from "./capture-types";
|
||||||
|
|
||||||
export interface FrameOwnedAxNode {
|
export interface FrameOwnedAxNode {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
@@ -23,6 +24,15 @@ export interface FrameDocument<T extends FrameOwnedAxNode> extends CdpFrame {
|
|||||||
contextScopeId: string;
|
contextScopeId: string;
|
||||||
axNodes: T[];
|
axNodes: T[];
|
||||||
domNodes: CapturedNode[];
|
domNodes: CapturedNode[];
|
||||||
|
excludedBackendNodeIds?: ReadonlySet<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Explicit snapshot document data used to join DOM and AX ownership. */
|
||||||
|
export interface FrameDomInput {
|
||||||
|
nodes: CapturedNode[];
|
||||||
|
rootFrameId?: string;
|
||||||
|
frameNodes?: ReadonlyMap<string, CapturedNode[]>;
|
||||||
|
frameExcludedBackendNodeIds?: ReadonlyMap<string, ReadonlySet<number>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Ownership {
|
interface Ownership {
|
||||||
@@ -47,35 +57,25 @@ function targetBackendKey(target: CdpTarget, backendNodeId: number): string {
|
|||||||
function frameList<T extends FrameOwnedAxNode>(
|
function frameList<T extends FrameOwnedAxNode>(
|
||||||
graph: CdpFrameGraph | null,
|
graph: CdpFrameGraph | null,
|
||||||
batches: FrameAxBatch<T>[],
|
batches: FrameAxBatch<T>[],
|
||||||
captured: CapturedViewModel,
|
|
||||||
): CdpFrame[] {
|
): CdpFrame[] {
|
||||||
const frames = new Map<string, CdpFrame>();
|
const frames = new Map<string, CdpFrame>();
|
||||||
for (const frame of graph?.frames ?? []) frames.set(frame.frameId, frame);
|
for (const frame of graph?.frames ?? []) frames.set(frame.frameId, frame);
|
||||||
for (const batch of batches) {
|
for (const batch of batches) {
|
||||||
if (!frames.has(batch.frame.frameId)) frames.set(batch.frame.frameId, batch.frame);
|
if (!frames.has(batch.frame.frameId)) frames.set(batch.frame.frameId, batch.frame);
|
||||||
}
|
}
|
||||||
for (const frameId of captured.frameNodes?.keys() ?? []) {
|
return [...frames.values()];
|
||||||
if (!frames.has(frameId))
|
|
||||||
frames.set(frameId, { frameId, target: { tabId: batches[0]?.frame.target.tabId ?? 0 } });
|
|
||||||
}
|
|
||||||
return [...frames.values()].map((frame) => {
|
|
||||||
const ownerBackendNodeId =
|
|
||||||
frame.ownerBackendNodeId ?? captured.frameOwnerBackendNodeIds?.get(frame.frameId);
|
|
||||||
const parentFrameId = frame.parentFrameId ?? captured.frameParentIds?.get(frame.frameId);
|
|
||||||
return {
|
|
||||||
...frame,
|
|
||||||
...(ownerBackendNodeId !== undefined ? { ownerBackendNodeId } : {}),
|
|
||||||
...(parentFrameId ? { parentFrameId } : {}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildFrameDocuments<T extends FrameOwnedAxNode>(
|
export async function buildFrameDocuments<T extends FrameOwnedAxNode>(
|
||||||
graph: CdpFrameGraph | null,
|
graph: CdpFrameGraph | null,
|
||||||
batches: FrameAxBatch<T>[],
|
batches: FrameAxBatch<T>[],
|
||||||
captured: CapturedViewModel,
|
captured: FrameDomInput,
|
||||||
): FrameDocument<T>[] {
|
signal?: AbortSignal,
|
||||||
const frames = frameList(graph, batches, captured);
|
unresolved?: (frame: CdpFrame) => void,
|
||||||
|
): Promise<FrameDocument<T>[]> {
|
||||||
|
const checkpoint = createCaptureCheckpoint(signal);
|
||||||
|
let work = 0;
|
||||||
|
const frames = frameList(graph, batches);
|
||||||
const frameById = new Map(frames.map((frame) => [frame.frameId, frame]));
|
const frameById = new Map(frames.map((frame) => [frame.frameId, frame]));
|
||||||
const rootFrameId = graph?.rootFrameId ?? captured.rootFrameId ?? frames[0]?.frameId;
|
const rootFrameId = graph?.rootFrameId ?? captured.rootFrameId ?? frames[0]?.frameId;
|
||||||
const domNodesForFrame = (frameId: string): CapturedNode[] =>
|
const domNodesForFrame = (frameId: string): CapturedNode[] =>
|
||||||
@@ -83,54 +83,80 @@ export function buildFrameDocuments<T extends FrameOwnedAxNode>(
|
|||||||
const backendOwner = new Map<string, string>();
|
const backendOwner = new Map<string, string>();
|
||||||
for (const frame of frames) {
|
for (const frame of frames) {
|
||||||
for (const node of domNodesForFrame(frame.frameId)) {
|
for (const node of domNodesForFrame(frame.frameId)) {
|
||||||
|
if (work++ % 256 === 0) {
|
||||||
|
const pending = checkpoint();
|
||||||
|
if (pending) await pending;
|
||||||
|
}
|
||||||
backendOwner.set(targetBackendKey(frame.target, node.backendNodeId), frame.frameId);
|
backendOwner.set(targetBackendKey(frame.target, node.backendNodeId), frame.frameId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates = new Map<string, OwnedCandidate<T>>();
|
const candidates = new Map<string, OwnedCandidate<T>>();
|
||||||
for (const batch of batches) {
|
for (const batch of batches) {
|
||||||
const nodeById = new Map(batch.nodes.map((node) => [node.nodeId, node]));
|
const nodeById = new Map<string, T>();
|
||||||
const ownershipByNodeId = new Map<string, Ownership>();
|
|
||||||
const resolving = new Set<string>();
|
|
||||||
const resolveOwnership = (node: T): Ownership => {
|
|
||||||
const cached = ownershipByNodeId.get(node.nodeId);
|
|
||||||
if (cached) return cached;
|
|
||||||
if (node.frameId && frameById.has(node.frameId)) {
|
|
||||||
const ownership = { frameId: node.frameId, strength: 4 };
|
|
||||||
ownershipByNodeId.set(node.nodeId, ownership);
|
|
||||||
return ownership;
|
|
||||||
}
|
|
||||||
if (typeof node.backendDOMNodeId === "number") {
|
|
||||||
const frameId = backendOwner.get(
|
|
||||||
targetBackendKey(batch.frame.target, node.backendDOMNodeId),
|
|
||||||
);
|
|
||||||
if (frameId) {
|
|
||||||
const ownership = { frameId, strength: 3 };
|
|
||||||
ownershipByNodeId.set(node.nodeId, ownership);
|
|
||||||
return ownership;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (node.parentId && !resolving.has(node.nodeId)) {
|
|
||||||
const parent = nodeById.get(node.parentId);
|
|
||||||
if (parent) {
|
|
||||||
resolving.add(node.nodeId);
|
|
||||||
const inherited = resolveOwnership(parent);
|
|
||||||
resolving.delete(node.nodeId);
|
|
||||||
const ownership = {
|
|
||||||
frameId: inherited.frameId,
|
|
||||||
strength: Math.min(2, inherited.strength),
|
|
||||||
};
|
|
||||||
ownershipByNodeId.set(node.nodeId, ownership);
|
|
||||||
return ownership;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const ownership = { frameId: batch.frame.frameId, strength: 1 };
|
|
||||||
ownershipByNodeId.set(node.nodeId, ownership);
|
|
||||||
return ownership;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const node of batch.nodes) {
|
for (const node of batch.nodes) {
|
||||||
const ownership = resolveOwnership(node);
|
if (work++ % 256 === 0) {
|
||||||
|
const pending = checkpoint();
|
||||||
|
if (pending) await pending;
|
||||||
|
}
|
||||||
|
nodeById.set(node.nodeId, node);
|
||||||
|
}
|
||||||
|
let reported = false;
|
||||||
|
const ownershipByNodeId = new Map<string, Ownership>();
|
||||||
|
for (const node of batch.nodes) {
|
||||||
|
const path: T[] = [];
|
||||||
|
const visiting = new Set<string>();
|
||||||
|
let current: T | undefined = node;
|
||||||
|
let ownership: Ownership = { frameId: batch.frame.frameId, strength: 1 };
|
||||||
|
while (current) {
|
||||||
|
if (work++ % 256 === 0) {
|
||||||
|
const pending = checkpoint();
|
||||||
|
if (pending) await pending;
|
||||||
|
}
|
||||||
|
const cached = ownershipByNodeId.get(current.nodeId);
|
||||||
|
if (cached) {
|
||||||
|
ownership = cached;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (visiting.has(current.nodeId)) break;
|
||||||
|
visiting.add(current.nodeId);
|
||||||
|
if (current.frameId) {
|
||||||
|
const frame = frameById.get(current.frameId);
|
||||||
|
// An explicit contradiction must not fall through to a guessed owner.
|
||||||
|
if (!frame || cdpTargetKey(frame.target) !== cdpTargetKey(batch.frame.target)) {
|
||||||
|
if (!reported) {
|
||||||
|
unresolved?.(batch.frame);
|
||||||
|
reported = true;
|
||||||
|
}
|
||||||
|
ownership = { frameId: "", strength: 0 };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ownership = { frameId: frame.frameId, strength: 4 };
|
||||||
|
ownershipByNodeId.set(current.nodeId, ownership);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const frameId =
|
||||||
|
typeof current.backendDOMNodeId === "number"
|
||||||
|
? backendOwner.get(targetBackendKey(batch.frame.target, current.backendDOMNodeId))
|
||||||
|
: undefined;
|
||||||
|
if (frameId) {
|
||||||
|
ownership = { frameId, strength: 3 };
|
||||||
|
ownershipByNodeId.set(current.nodeId, ownership);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
path.push(current);
|
||||||
|
current = current.parentId ? nodeById.get(current.parentId) : undefined;
|
||||||
|
}
|
||||||
|
for (let i = path.length - 1; i >= 0; i--) {
|
||||||
|
if (work++ % 256 === 0) {
|
||||||
|
const pending = checkpoint();
|
||||||
|
if (pending) await pending;
|
||||||
|
}
|
||||||
|
ownership = { frameId: ownership.frameId, strength: Math.min(2, ownership.strength) };
|
||||||
|
ownershipByNodeId.set(path[i].nodeId, ownership);
|
||||||
|
}
|
||||||
|
if (!ownershipByNodeId.has(node.nodeId)) ownershipByNodeId.set(node.nodeId, ownership);
|
||||||
|
if (!ownership.frameId) continue;
|
||||||
const key = targetNodeKey(batch.frame.target, node.nodeId);
|
const key = targetNodeKey(batch.frame.target, node.nodeId);
|
||||||
const existing = candidates.get(key);
|
const existing = candidates.get(key);
|
||||||
if (!existing || ownership.strength > existing.ownership.strength) {
|
if (!existing || ownership.strength > existing.ownership.strength) {
|
||||||
@@ -144,6 +170,10 @@ export function buildFrameDocuments<T extends FrameOwnedAxNode>(
|
|||||||
);
|
);
|
||||||
const axNodesByFrame = new Map<string, T[]>();
|
const axNodesByFrame = new Map<string, T[]>();
|
||||||
for (const candidate of candidates.values()) {
|
for (const candidate of candidates.values()) {
|
||||||
|
if (work++ % 256 === 0) {
|
||||||
|
const pending = checkpoint();
|
||||||
|
if (pending) await pending;
|
||||||
|
}
|
||||||
const { node, target, ownership } = candidate;
|
const { node, target, ownership } = candidate;
|
||||||
const parentFrameId = node.parentId
|
const parentFrameId = node.parentId
|
||||||
? ownershipByTargetNode.get(targetNodeKey(target, node.parentId))
|
? ownershipByTargetNode.get(targetNodeKey(target, node.parentId))
|
||||||
@@ -169,5 +199,6 @@ export function buildFrameDocuments<T extends FrameOwnedAxNode>(
|
|||||||
contextScopeId: frame.frameId,
|
contextScopeId: frame.frameId,
|
||||||
axNodes: axNodesByFrame.get(frame.frameId) ?? [],
|
axNodes: axNodesByFrame.get(frame.frameId) ?? [],
|
||||||
domNodes: domNodesForFrame(frame.frameId),
|
domNodes: domNodesForFrame(frame.frameId),
|
||||||
|
excludedBackendNodeIds: captured.frameExcludedBackendNodeIds?.get(frame.frameId),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,3 +35,91 @@ export async function clearHover(cdp: CdpRunner, tabId: number): Promise<void> {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wall-clock budget for a probe phase.
|
||||||
|
*
|
||||||
|
* Callers ask whether the *next* candidate still fits before starting it,
|
||||||
|
* rather than checking elapsed time at the top of the loop. A top-of-loop
|
||||||
|
* check lets a candidate start with 1ms of budget left and then run to
|
||||||
|
* completion, overshooting by a full settle window.
|
||||||
|
*/
|
||||||
|
export class ProbeBudget {
|
||||||
|
private readonly startedAt = Date.now();
|
||||||
|
|
||||||
|
constructor(private readonly totalMs: number) {}
|
||||||
|
|
||||||
|
get elapsedMs(): number {
|
||||||
|
return Date.now() - this.startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a candidate costing `estimatedMs` still fits in the budget. */
|
||||||
|
canAfford(estimatedMs: number): boolean {
|
||||||
|
return this.elapsedMs + estimatedMs <= this.totalMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OverlayBypass = (tabId: number, enabled: boolean) => Promise<void>;
|
||||||
|
|
||||||
|
export interface OverlayBypassScopeOptions {
|
||||||
|
/**
|
||||||
|
* Called when the overlay could not be restored. Defaults to `console.error`
|
||||||
|
* so a stuck-bypassed overlay is never silent; tests inject a spy.
|
||||||
|
*/
|
||||||
|
onRestoreFailure?: (error: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayBypassDepth = new Map<number, number>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `body` with the agent overlay bypassed, restoring it afterwards.
|
||||||
|
*
|
||||||
|
* Reference counted per tab so nested probe phases share a single bypass span
|
||||||
|
* instead of toggling the overlay once per phase. Restore failures are
|
||||||
|
* reported rather than swallowed, and the depth is released even when restore
|
||||||
|
* throws so a failure cannot pin the overlay off for the tab's lifetime.
|
||||||
|
*/
|
||||||
|
export async function withOverlayBypass<T>(
|
||||||
|
bypass: OverlayBypass | undefined,
|
||||||
|
tabId: number,
|
||||||
|
body: () => Promise<T>,
|
||||||
|
options: OverlayBypassScopeOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
if (!bypass) return body();
|
||||||
|
|
||||||
|
const depth = overlayBypassDepth.get(tabId) ?? 0;
|
||||||
|
overlayBypassDepth.set(tabId, depth + 1);
|
||||||
|
if (depth === 0) {
|
||||||
|
try {
|
||||||
|
await bypass(tabId, true);
|
||||||
|
} catch (error) {
|
||||||
|
// Failing to hide the overlay only risks probing our own UI, so continue.
|
||||||
|
console.debug("[bsk hover] overlay bypass could not be enabled", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await body();
|
||||||
|
} finally {
|
||||||
|
const current = overlayBypassDepth.get(tabId) ?? 1;
|
||||||
|
if (current <= 1) {
|
||||||
|
overlayBypassDepth.delete(tabId);
|
||||||
|
try {
|
||||||
|
await bypass(tabId, false);
|
||||||
|
} catch (error) {
|
||||||
|
(options.onRestoreFailure ?? defaultRestoreFailure)(error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
overlayBypassDepth.set(tabId, current - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultRestoreFailure(error: unknown): void {
|
||||||
|
console.error("[bsk hover] agent overlay could not be restored after probing", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: drops any bypass depth left behind by a crashed probe. */
|
||||||
|
export function resetOverlayBypassDepth(): void {
|
||||||
|
overlayBypassDepth.clear();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { isVomReferenceNode } from "@browser-skill/vom";
|
import { isVomReferenceNode } from "@browser-skill/vom";
|
||||||
import { type CdpRunner, sendToCdpTarget } from "../shared";
|
import { type CdpRunner, sendToCdpTarget } from "../shared";
|
||||||
import type { FrameDocument, FrameOwnedAxNode } from "./frame-document";
|
import type { FrameDocument, FrameOwnedAxNode } from "./frame-document";
|
||||||
import { clearHover, waitForHover } from "./hover-perception";
|
import { clearHover, ProbeBudget, waitForHover } from "./hover-perception";
|
||||||
import { stableIdentifierName } from "./semantic-graph/name-evidence";
|
import { stableIdentifierName } from "./semantic-graph/name-evidence";
|
||||||
import { frameBackendKey, type ResolvedSemanticGraph } from "./semantic-graph/types";
|
import { frameBackendKey, type ResolvedSemanticGraph } from "./semantic-graph/types";
|
||||||
|
|
||||||
@@ -9,11 +9,18 @@ const MAX_TOOLTIP_CANDIDATES = 8;
|
|||||||
const MAX_TOOLTIP_PROBE_MS = 2_400;
|
const MAX_TOOLTIP_PROBE_MS = 2_400;
|
||||||
const TOOLTIP_SETTLE_MS = 250;
|
const TOOLTIP_SETTLE_MS = 250;
|
||||||
const CACHE_TTL_MS = 5 * 60_000;
|
const CACHE_TTL_MS = 5 * 60_000;
|
||||||
|
/**
|
||||||
|
* Misses expire sooner than hits: a control that gains a tooltip once the app
|
||||||
|
* finishes booting should still be discoverable, but not at the cost of
|
||||||
|
* re-probing every unlabelled control on every observation.
|
||||||
|
*/
|
||||||
|
const MISS_CACHE_TTL_MS = 60_000;
|
||||||
const MAX_CACHE_ENTRIES = 500;
|
const MAX_CACHE_ENTRIES = 500;
|
||||||
const FORM_CONTROL_TAGS = new Set(["input", "select", "textarea"]);
|
const FORM_CONTROL_TAGS = new Set(["input", "select", "textarea"]);
|
||||||
|
|
||||||
interface TooltipCacheEntry {
|
interface TooltipCacheEntry {
|
||||||
name: string;
|
/** `null` records "probed, revealed no tooltip" so the miss is not re-paid. */
|
||||||
|
name: string | null;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +34,6 @@ interface TooltipCandidate<T extends FrameOwnedAxNode> {
|
|||||||
|
|
||||||
export interface TooltipNameProbeOptions {
|
export interface TooltipNameProbeOptions {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltipCache = new Map<string, TooltipCacheEntry>();
|
const tooltipCache = new Map<string, TooltipCacheEntry>();
|
||||||
@@ -39,10 +45,11 @@ function cacheKey<T extends FrameOwnedAxNode>(
|
|||||||
return `${document.target.tabId}\u0000${document.frameId}\u0000${document.url ?? ""}\u0000${backendNodeId}`;
|
return `${document.target.tabId}\u0000${document.frameId}\u0000${document.url ?? ""}\u0000${backendNodeId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cachedName<T extends FrameOwnedAxNode>(
|
/** Returns the cache entry, or `undefined` when this control was never probed. */
|
||||||
|
function cachedEntry<T extends FrameOwnedAxNode>(
|
||||||
document: FrameDocument<T>,
|
document: FrameDocument<T>,
|
||||||
backendNodeId: number,
|
backendNodeId: number,
|
||||||
): string | undefined {
|
): TooltipCacheEntry | undefined {
|
||||||
const key = cacheKey(document, backendNodeId);
|
const key = cacheKey(document, backendNodeId);
|
||||||
const entry = tooltipCache.get(key);
|
const entry = tooltipCache.get(key);
|
||||||
if (!entry) return undefined;
|
if (!entry) return undefined;
|
||||||
@@ -50,13 +57,13 @@ function cachedName<T extends FrameOwnedAxNode>(
|
|||||||
tooltipCache.delete(key);
|
tooltipCache.delete(key);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return entry.name;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
function storeName<T extends FrameOwnedAxNode>(
|
function storeName<T extends FrameOwnedAxNode>(
|
||||||
document: FrameDocument<T>,
|
document: FrameDocument<T>,
|
||||||
backendNodeId: number,
|
backendNodeId: number,
|
||||||
name: string,
|
name: string | null,
|
||||||
): void {
|
): void {
|
||||||
if (tooltipCache.size >= MAX_CACHE_ENTRIES) {
|
if (tooltipCache.size >= MAX_CACHE_ENTRIES) {
|
||||||
const oldest = tooltipCache.keys().next().value;
|
const oldest = tooltipCache.keys().next().value;
|
||||||
@@ -64,7 +71,7 @@ function storeName<T extends FrameOwnedAxNode>(
|
|||||||
}
|
}
|
||||||
tooltipCache.set(cacheKey(document, backendNodeId), {
|
tooltipCache.set(cacheKey(document, backendNodeId), {
|
||||||
name,
|
name,
|
||||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
expiresAt: Date.now() + (name === null ? MISS_CACHE_TTL_MS : CACHE_TTL_MS),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,22 +244,27 @@ export async function probeTooltipNames<T extends FrameOwnedAxNode>(
|
|||||||
const names = new Map<string, string>();
|
const names = new Map<string, string>();
|
||||||
const pending: TooltipCandidate<T>[] = [];
|
const pending: TooltipCandidate<T>[] = [];
|
||||||
for (const candidate of candidates(documents, graph)) {
|
for (const candidate of candidates(documents, graph)) {
|
||||||
const cached = cachedName(candidate.document, candidate.backendNodeId);
|
const cached = cachedEntry(candidate.document, candidate.backendNodeId);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
names.set(frameBackendKey(candidate.document.frameId, candidate.backendNodeId), cached);
|
// A cached miss is still a decision: skip the control instead of re-probing.
|
||||||
|
if (cached.name !== null) {
|
||||||
|
names.set(
|
||||||
|
frameBackendKey(candidate.document.frameId, candidate.backendNodeId),
|
||||||
|
cached.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if (pending.length < MAX_TOOLTIP_CANDIDATES) {
|
} else if (pending.length < MAX_TOOLTIP_CANDIDATES) {
|
||||||
pending.push(candidate);
|
pending.push(candidate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (pending.length === 0) return names;
|
if (pending.length === 0) return names;
|
||||||
|
|
||||||
const started = Date.now();
|
const budget = new ProbeBudget(MAX_TOOLTIP_PROBE_MS);
|
||||||
const contexts = new Map<string, number>();
|
const contexts = new Map<string, number>();
|
||||||
await options.hoverProbeBypassOverlay?.(tabId, true).catch(() => undefined);
|
|
||||||
try {
|
try {
|
||||||
for (const candidate of pending) {
|
for (const candidate of pending) {
|
||||||
if (options.signal?.aborted) throw new DOMException("observation aborted", "AbortError");
|
if (options.signal?.aborted) throw new DOMException("observation aborted", "AbortError");
|
||||||
if (Date.now() - started >= MAX_TOOLTIP_PROBE_MS) break;
|
if (!budget.canAfford(TOOLTIP_SETTLE_MS)) break;
|
||||||
let objectId: string | undefined;
|
let objectId: string | undefined;
|
||||||
try {
|
try {
|
||||||
let contextId = contexts.get(candidate.document.frameId);
|
let contextId = contexts.get(candidate.document.frameId);
|
||||||
@@ -278,9 +290,9 @@ export async function probeTooltipNames<T extends FrameOwnedAxNode>(
|
|||||||
await waitForHover(TOOLTIP_SETTLE_MS, options.signal);
|
await waitForHover(TOOLTIP_SETTLE_MS, options.signal);
|
||||||
const after = await tooltipTexts(cdp, candidate.document, objectId);
|
const after = await tooltipTexts(cdp, candidate.document, objectId);
|
||||||
const name = cleanTooltipDiff(before, after);
|
const name = cleanTooltipDiff(before, after);
|
||||||
|
storeName(candidate.document, candidate.backendNodeId, name ?? null);
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
names.set(frameBackendKey(candidate.document.frameId, candidate.backendNodeId), name);
|
names.set(frameBackendKey(candidate.document.frameId, candidate.backendNodeId), name);
|
||||||
storeName(candidate.document, candidate.backendNodeId, name);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.name === "AbortError") throw error;
|
if (error instanceof Error && error.name === "AbortError") throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -290,7 +302,6 @@ export async function probeTooltipNames<T extends FrameOwnedAxNode>(
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await clearHover(cdp, tabId);
|
await clearHover(cdp, tabId);
|
||||||
await options.hoverProbeBypassOverlay?.(tabId, false).catch(() => undefined);
|
|
||||||
}
|
}
|
||||||
return names;
|
return names;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import type { Viewport } from "@browser-skill/vom";
|
||||||
|
import { type CdpFrame, type CdpTarget, cdpTargetKey } from "@/browser-driver/frame-graph";
|
||||||
|
import { type GeometryProjection, projectRectToViewport } from "../geometry";
|
||||||
|
import {
|
||||||
|
type FrameProjectionState,
|
||||||
|
projectSnapshotRect,
|
||||||
|
type SnapshotCoordinates,
|
||||||
|
snapshotCoordinates,
|
||||||
|
snapshotViewportRect,
|
||||||
|
} from "../geometry/coordinate-types";
|
||||||
|
import {
|
||||||
|
GeometryContext,
|
||||||
|
type LayoutMetrics,
|
||||||
|
snapshotLayoutScale,
|
||||||
|
} from "../geometry/frame-context";
|
||||||
|
import {
|
||||||
|
createCaptureCheckpoint,
|
||||||
|
isAbortError as isCaptureAbort,
|
||||||
|
throwIfAborted as throwCaptureAborted,
|
||||||
|
} from "./capture-abort";
|
||||||
|
import { buildDocumentIndex, type CaptureIssue, type DocumentIndex, type NodeFacts } from "./facts";
|
||||||
|
import {
|
||||||
|
decodeDocument,
|
||||||
|
type SnapshotDocument,
|
||||||
|
type SnapshotReply,
|
||||||
|
snapshotFrameId,
|
||||||
|
} from "./snapshot";
|
||||||
|
|
||||||
|
export interface FrameContext {
|
||||||
|
frameId?: string;
|
||||||
|
ownerFrameBackendNodeId: number | null;
|
||||||
|
projection: FrameProjectionState | null;
|
||||||
|
targetProjection?: GeometryProjection | null;
|
||||||
|
target: CdpTarget;
|
||||||
|
coordinates: SnapshotCoordinates | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedDocument {
|
||||||
|
nodes: NodeFacts[];
|
||||||
|
index: DocumentIndex;
|
||||||
|
documentElementBackendNodeId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interpret one document using a supplied projection. No live reads or frame scheduling. */
|
||||||
|
export async function normalizeDocument(
|
||||||
|
doc: SnapshotDocument,
|
||||||
|
strings: string[],
|
||||||
|
context: FrameContext,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<NormalizedDocument> {
|
||||||
|
const decoded = await decodeDocument(doc, strings, signal);
|
||||||
|
const checkpoint = createCaptureCheckpoint(signal);
|
||||||
|
const nodes: NodeFacts[] = [];
|
||||||
|
for (let i = 0; i < decoded.nodes.length; i++) {
|
||||||
|
if (i % 256 === 0) {
|
||||||
|
const pending = checkpoint();
|
||||||
|
if (pending) await pending;
|
||||||
|
}
|
||||||
|
const node = decoded.nodes[i];
|
||||||
|
const bounds = node.layout?.bounds ?? [];
|
||||||
|
const input = snapshotViewportRect(
|
||||||
|
bounds,
|
||||||
|
{ target: context.target, frameId: context.frameId },
|
||||||
|
context.coordinates,
|
||||||
|
);
|
||||||
|
const local = input?.rect;
|
||||||
|
let rect =
|
||||||
|
input && context.projection?.status === "available"
|
||||||
|
? projectSnapshotRect(input, context.projection.projection)
|
||||||
|
: null;
|
||||||
|
if (rect && context.targetProjection !== undefined)
|
||||||
|
rect = context.targetProjection
|
||||||
|
? projectRectToViewport(
|
||||||
|
{ x: rect.x, y: rect.y, w: rect.width, h: rect.height },
|
||||||
|
context.targetProjection,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const visibility = node.layout?.styles.visibility || "visible";
|
||||||
|
const opacity = node.layout?.styles.opacity || "1";
|
||||||
|
nodes.push({
|
||||||
|
...node,
|
||||||
|
...(context.frameId ? { frameId: context.frameId } : {}),
|
||||||
|
ownerFrameBackendNodeId: context.ownerFrameBackendNodeId,
|
||||||
|
localRect: local ? { x: local.x, y: local.y, w: local.width, h: local.height } : null,
|
||||||
|
rect: rect ? { x: rect.x, y: rect.y, w: rect.width, h: rect.height } : null,
|
||||||
|
rendered:
|
||||||
|
bounds.length >= 4 &&
|
||||||
|
bounds.slice(0, 4).every(Number.isFinite) &&
|
||||||
|
bounds[2] > 0 &&
|
||||||
|
bounds[3] > 0 &&
|
||||||
|
visibility !== "hidden" &&
|
||||||
|
visibility !== "collapse" &&
|
||||||
|
(Number.parseFloat(opacity) || 0) > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const index = await buildDocumentIndex(nodes, signal);
|
||||||
|
return {
|
||||||
|
nodes: nodes.filter(
|
||||||
|
(node) => !node.tag.startsWith("#") && !index.excludedBackendNodeIds.has(node.backendNodeId),
|
||||||
|
),
|
||||||
|
index,
|
||||||
|
documentElementBackendNodeId: nodes.find(
|
||||||
|
(node) =>
|
||||||
|
node.nodeType === 1 &&
|
||||||
|
node.parentBackendNodeId !== null &&
|
||||||
|
index.nodes.get(node.parentBackendNodeId)?.nodeType === 9,
|
||||||
|
)?.backendNodeId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedFrameDocument extends NormalizedDocument {
|
||||||
|
frame: CdpFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One target's snapshot. Source ownership is checked before interpreting any
|
||||||
|
* coordinates. Snapshot and live quad coordinates are deliberately not conflated. */
|
||||||
|
export async function normalizeSnapshot(
|
||||||
|
snapshot: SnapshotReply,
|
||||||
|
target: CdpTarget,
|
||||||
|
frames: readonly CdpFrame[],
|
||||||
|
geometry: GeometryContext,
|
||||||
|
issues: CaptureIssue[],
|
||||||
|
signal?: AbortSignal,
|
||||||
|
rootFrameId?: string,
|
||||||
|
): Promise<NormalizedFrameDocument[]> {
|
||||||
|
const strings = snapshot.strings ?? [];
|
||||||
|
const raw = snapshot.documents ?? [];
|
||||||
|
const ownerIds = new Set<number>();
|
||||||
|
for (const document of raw) {
|
||||||
|
for (const index of document.nodes?.contentDocumentIndex?.index ?? []) {
|
||||||
|
const id = document.nodes?.backendNodeId?.[index];
|
||||||
|
if (id !== undefined) ownerIds.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
geometry.registerSnapshotOwners(target, ownerIds);
|
||||||
|
const frameById = new Map(frames.map((frame) => [frame.frameId, frame]));
|
||||||
|
const sources = new Map<string, SnapshotDocument>();
|
||||||
|
for (const doc of raw) {
|
||||||
|
const id = snapshotFrameId(doc, strings);
|
||||||
|
if (!id || !frameById.has(id) || sources.has(id)) {
|
||||||
|
issues.push({
|
||||||
|
target,
|
||||||
|
frameId: id,
|
||||||
|
stage: "ownership",
|
||||||
|
reason: "frame-ownership-unresolved",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sources.set(id, doc);
|
||||||
|
}
|
||||||
|
let metrics: LayoutMetrics = {};
|
||||||
|
try {
|
||||||
|
metrics = await geometry.layoutMetrics(target);
|
||||||
|
} catch (error) {
|
||||||
|
if (isCaptureAbort(error)) throw error;
|
||||||
|
}
|
||||||
|
const viewport: Viewport = {
|
||||||
|
width: metrics.cssLayoutViewport?.clientWidth ?? 0,
|
||||||
|
height: metrics.cssLayoutViewport?.clientHeight ?? 0,
|
||||||
|
};
|
||||||
|
const layoutUnitsPerCssPixel = snapshotLayoutScale(metrics);
|
||||||
|
const viewportAvailable = [viewport.width, viewport.height].every(
|
||||||
|
(size) => Number.isFinite(size) && size > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const children = new Map<string, CdpFrame[]>();
|
||||||
|
const pending: CdpFrame[] = [];
|
||||||
|
for (const frame of frames) {
|
||||||
|
if (!frame.parentFrameId || !frameById.has(frame.parentFrameId)) pending.push(frame);
|
||||||
|
else {
|
||||||
|
const siblings = children.get(frame.parentFrameId);
|
||||||
|
if (siblings) siblings.push(frame);
|
||||||
|
else children.set(frame.parentFrameId, [frame]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const projections = new Map<string, FrameProjectionState | null>();
|
||||||
|
const result: NormalizedFrameDocument[] = [];
|
||||||
|
let targetProjection: GeometryProjection | null = null;
|
||||||
|
const targetRoot = rootFrameId ? frameById.get(rootFrameId) : undefined;
|
||||||
|
if (target.sessionId && targetRoot) {
|
||||||
|
try {
|
||||||
|
const known = await geometry.frame(targetRoot.frameId);
|
||||||
|
if (known && cdpTargetKey(known.target) === cdpTargetKey(target))
|
||||||
|
targetProjection = await geometry.targetProjection(targetRoot.frameId);
|
||||||
|
} catch (error) {
|
||||||
|
if (isCaptureAbort(error)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const checkpoint = createCaptureCheckpoint(signal);
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const remaining = frames[Symbol.iterator]();
|
||||||
|
for (let cursor = 0; visited.size < frames.length; cursor++) {
|
||||||
|
if (cursor >= pending.length) {
|
||||||
|
let next = remaining.next();
|
||||||
|
while (!next.done && visited.has(next.value.frameId)) next = remaining.next();
|
||||||
|
if (next.done) break;
|
||||||
|
pending.push(next.value);
|
||||||
|
}
|
||||||
|
if (cursor % 256 === 0) {
|
||||||
|
const pending = checkpoint();
|
||||||
|
if (pending) await pending;
|
||||||
|
}
|
||||||
|
const frame = pending[cursor];
|
||||||
|
if (visited.has(frame.frameId)) continue;
|
||||||
|
visited.add(frame.frameId);
|
||||||
|
for (const child of children.get(frame.frameId) ?? []) pending.push(child);
|
||||||
|
const doc = sources.get(frame.frameId);
|
||||||
|
if (!doc) continue;
|
||||||
|
const source = { target, frameId: frame.frameId };
|
||||||
|
const coordinates = snapshotCoordinates(
|
||||||
|
doc,
|
||||||
|
layoutUnitsPerCssPixel,
|
||||||
|
frame.frameId === rootFrameId
|
||||||
|
? { x: metrics.cssLayoutViewport?.pageX, y: metrics.cssLayoutViewport?.pageY }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
let state: FrameProjectionState | null =
|
||||||
|
frame.frameId === rootFrameId
|
||||||
|
? viewportAvailable && coordinates
|
||||||
|
? {
|
||||||
|
status: "available",
|
||||||
|
projection: {
|
||||||
|
source,
|
||||||
|
geometry: { sourceClips: [], edges: [], topViewport: viewport },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { status: "unavailable", source, reason: "snapshot-coordinates-unavailable" }
|
||||||
|
: (projections.get(frame.frameId) ?? null);
|
||||||
|
if (!state && frame.frameId !== rootFrameId)
|
||||||
|
issues.push({
|
||||||
|
target,
|
||||||
|
frameId: frame.frameId,
|
||||||
|
stage: "ownership",
|
||||||
|
reason: "frame-ownership-unresolved",
|
||||||
|
});
|
||||||
|
if (!coordinates && (!state || state.status === "available"))
|
||||||
|
state = { status: "unavailable", source, reason: "snapshot-coordinates-unavailable" };
|
||||||
|
projections.set(frame.frameId, state);
|
||||||
|
const projection = state?.status === "available" ? state.projection : null;
|
||||||
|
if (!projection || (target.sessionId && !targetProjection))
|
||||||
|
issues.push({
|
||||||
|
target,
|
||||||
|
frameId: frame.frameId,
|
||||||
|
stage: "geometry",
|
||||||
|
reason: "geometry-unavailable",
|
||||||
|
...(state && state.status !== "available" ? { projectionIssue: state } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only direct siblings share this pool. Keep normalization in its existing
|
||||||
|
// breadth-first order, independent of owner read completion order.
|
||||||
|
const siblings = (children.get(frame.frameId) ?? []).filter((child) =>
|
||||||
|
sources.has(child.frameId),
|
||||||
|
);
|
||||||
|
const edge = projection?.geometry.edges[0];
|
||||||
|
const clips = edge
|
||||||
|
? [edge.destinationQuad, ...(edge.destinationClips ?? [])]
|
||||||
|
: (projection?.geometry.sourceClips ?? []);
|
||||||
|
let next = 0;
|
||||||
|
let failure: unknown;
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(4, siblings.length) }, async () => {
|
||||||
|
while (next < siblings.length && !signal?.aborted && !failure) {
|
||||||
|
const child = siblings[next++];
|
||||||
|
const childSource = { target, frameId: child.frameId };
|
||||||
|
if (!projection) {
|
||||||
|
projections.set(
|
||||||
|
child.frameId,
|
||||||
|
state && state.status !== "available"
|
||||||
|
? {
|
||||||
|
status: "blocked",
|
||||||
|
source: childSource,
|
||||||
|
cause: state.status === "blocked" ? state.cause : state,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (child.ownerBackendNodeId === undefined) {
|
||||||
|
projections.set(child.frameId, null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!snapshotCoordinates(sources.get(child.frameId)!, layoutUnitsPerCssPixel)) {
|
||||||
|
projections.set(child.frameId, {
|
||||||
|
status: "unavailable",
|
||||||
|
source: childSource,
|
||||||
|
reason: "snapshot-coordinates-unavailable",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
projections.set(
|
||||||
|
child.frameId,
|
||||||
|
await geometry.snapshotProjection(
|
||||||
|
childSource,
|
||||||
|
child.ownerBackendNodeId,
|
||||||
|
clips,
|
||||||
|
viewport,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
failure ??= error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Join active reads and their cleanup before returning cancellation.
|
||||||
|
if (failure) throw failure;
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
const normalized = await normalizeDocument(
|
||||||
|
doc,
|
||||||
|
strings,
|
||||||
|
{
|
||||||
|
frameId: frame.frameId,
|
||||||
|
ownerFrameBackendNodeId: frame.ownerBackendNodeId ?? null,
|
||||||
|
projection: state,
|
||||||
|
target,
|
||||||
|
...(target.sessionId ? { targetProjection } : {}),
|
||||||
|
coordinates,
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
result.push({ ...normalized, frame });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@ import type { Rect, RenderedRef, VomResult } from "@browser-skill/vom";
|
|||||||
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
||||||
import { readRecordingDocumentIdentity } from "@/shared/recording-document-identity";
|
import { readRecordingDocumentIdentity } from "@/shared/recording-document-identity";
|
||||||
import type { CapturedSurfaceProbe } from "./capture";
|
import type { CapturedSurfaceProbe } from "./capture";
|
||||||
import type { CapturedFrameDocument, FrameAxNode } from "./frame-capture";
|
import type {
|
||||||
|
FrameDocument as CapturedFrameDocument,
|
||||||
|
FrameOwnedAxNode as FrameAxNode,
|
||||||
|
} from "./frame-document";
|
||||||
|
|
||||||
/** Frame identity needed to resolve an `@eN` that lives in an iframe. */
|
/** Frame identity needed to resolve an `@eN` that lives in an iframe. */
|
||||||
export interface CaptureVomFrame {
|
export interface CaptureVomFrame {
|
||||||
@@ -37,6 +40,17 @@ export interface CaptureVomObservationResult {
|
|||||||
frames: CaptureVomFrame[];
|
frames: CaptureVomFrame[];
|
||||||
matchNodes: CaptureVomMatchNode[];
|
matchNodes: CaptureVomMatchNode[];
|
||||||
surfaceProbes?: CapturedSurfaceProbe[];
|
surfaceProbes?: CapturedSurfaceProbe[];
|
||||||
|
/**
|
||||||
|
* Whether this observation actively hovered the page, and whether that
|
||||||
|
* revealed content the static tree does not contain. Lets callers judge how
|
||||||
|
* far the page may have drifted from the returned snapshot.
|
||||||
|
*/
|
||||||
|
hoverProbe?: HoverProbeReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HoverProbeReport {
|
||||||
|
performed: boolean;
|
||||||
|
revealedContent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectFrames(documents: CapturedFrameDocument<FrameAxNode>[]): CaptureVomFrame[] {
|
function projectFrames(documents: CapturedFrameDocument<FrameAxNode>[]): CaptureVomFrame[] {
|
||||||
@@ -75,6 +89,7 @@ export function projectRecordSafeObservation(input: {
|
|||||||
frameDocuments: CapturedFrameDocument<FrameAxNode>[];
|
frameDocuments: CapturedFrameDocument<FrameAxNode>[];
|
||||||
rendered: VomResult;
|
rendered: VomResult;
|
||||||
surfaceProbes?: CapturedSurfaceProbe[];
|
surfaceProbes?: CapturedSurfaceProbe[];
|
||||||
|
hoverProbe?: HoverProbeReport;
|
||||||
}): CaptureVomObservationResult {
|
}): CaptureVomObservationResult {
|
||||||
return {
|
return {
|
||||||
text: input.rendered.text,
|
text: input.rendered.text,
|
||||||
@@ -84,5 +99,6 @@ export function projectRecordSafeObservation(input: {
|
|||||||
frames: projectFrames(input.frameDocuments),
|
frames: projectFrames(input.frameDocuments),
|
||||||
matchNodes: projectMatchNodes(input.frameDocuments),
|
matchNodes: projectMatchNodes(input.frameDocuments),
|
||||||
surfaceProbes: input.surfaceProbes,
|
surfaceProbes: input.surfaceProbes,
|
||||||
|
...(input.hoverProbe ? { hoverProbe: input.hoverProbe } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,8 +61,9 @@ export function buildSemanticGraph(input: BuildSemanticGraphInput): SemanticGrap
|
|||||||
dom,
|
dom,
|
||||||
sourceOrder: index,
|
sourceOrder: index,
|
||||||
excluded:
|
excluded:
|
||||||
document.frameId === rootFrameId &&
|
document.excludedBackendNodeIds?.has(dom.backendNodeId) === true ||
|
||||||
input.excludedBackendNodeIds?.has(dom.backendNodeId) === true,
|
(document.frameId === rootFrameId &&
|
||||||
|
input.excludedBackendNodeIds?.has(dom.backendNodeId) === true),
|
||||||
};
|
};
|
||||||
nodes.set(id, node);
|
nodes.set(id, node);
|
||||||
frameNodeIds.push(id);
|
frameNodeIds.push(id);
|
||||||
@@ -90,9 +91,10 @@ export function buildSemanticGraph(input: BuildSemanticGraphInput): SemanticGrap
|
|||||||
ax,
|
ax,
|
||||||
sourceOrder: document.domNodes.length + index,
|
sourceOrder: document.domNodes.length + index,
|
||||||
excluded:
|
excluded:
|
||||||
document.frameId === rootFrameId &&
|
|
||||||
typeof ax.backendDOMNodeId === "number" &&
|
typeof ax.backendDOMNodeId === "number" &&
|
||||||
input.excludedBackendNodeIds?.has(ax.backendDOMNodeId) === true,
|
(document.excludedBackendNodeIds?.has(ax.backendDOMNodeId) === true ||
|
||||||
|
(document.frameId === rootFrameId &&
|
||||||
|
input.excludedBackendNodeIds?.has(ax.backendDOMNodeId) === true)),
|
||||||
});
|
});
|
||||||
frameNodeIds.push(id);
|
frameNodeIds.push(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { type CdpFrame, type CdpTarget, cdpTargetKey } from "@/browser-driver/frame-graph";
|
||||||
|
import { createCaptureCheckpoint } from "./capture-abort";
|
||||||
|
import type { CapturedNode } from "./capture-types";
|
||||||
|
import type { DecodedDocument, DecodedNode } from "./facts";
|
||||||
|
export const REQUESTED_STYLES = [
|
||||||
|
"position",
|
||||||
|
"pointer-events",
|
||||||
|
"cursor",
|
||||||
|
"visibility",
|
||||||
|
"opacity",
|
||||||
|
] as const;
|
||||||
|
const STYLE_COL = Object.fromEntries(
|
||||||
|
REQUESTED_STYLES.map((name, index) => [name, index]),
|
||||||
|
) as Record<(typeof REQUESTED_STYLES)[number], number>;
|
||||||
|
/** Sparse array format Chrome uses for infrequently-set per-node fields. */
|
||||||
|
interface SparseArray {
|
||||||
|
index: number[];
|
||||||
|
value: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RareBooleanData {
|
||||||
|
index: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnapshotDocument {
|
||||||
|
frameId?: string | number;
|
||||||
|
documentURL?: number;
|
||||||
|
scrollOffsetX?: number;
|
||||||
|
scrollOffsetY?: number;
|
||||||
|
nodes?: {
|
||||||
|
parentIndex?: number[];
|
||||||
|
nodeName?: number[];
|
||||||
|
nodeType?: number[];
|
||||||
|
backendNodeId?: number[];
|
||||||
|
attributes?: number[][];
|
||||||
|
/**
|
||||||
|
* Per-node text value (index into strings), set for `#text` / CDATA nodes.
|
||||||
|
* Element nodes carry -1. Same length as `backendNodeId`.
|
||||||
|
*/
|
||||||
|
nodeValue?: number[];
|
||||||
|
/** Maps node array index → index into `documents[]` for frame content. */
|
||||||
|
contentDocumentIndex?: SparseArray;
|
||||||
|
inputValue?: SparseArray;
|
||||||
|
textValue?: SparseArray;
|
||||||
|
inputChecked?: RareBooleanData;
|
||||||
|
optionSelected?: RareBooleanData;
|
||||||
|
};
|
||||||
|
layout?: {
|
||||||
|
nodeIndex?: number[];
|
||||||
|
styles?: number[][];
|
||||||
|
bounds?: number[][];
|
||||||
|
paintOrders?: number[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapshotFrameId(document: SnapshotDocument, strings: string[]): string | undefined {
|
||||||
|
if (typeof document.frameId === "string") return document.frameId || undefined;
|
||||||
|
if (typeof document.frameId === "number") return str(strings, document.frameId) || undefined;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnapshotReply {
|
||||||
|
strings?: string[];
|
||||||
|
documents?: SnapshotDocument[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSensitiveFormControl(node: Pick<CapturedNode, "tag" | "attrs">): boolean {
|
||||||
|
return node.tag === "input" && (node.attrs.type ?? "").toLowerCase() === "password";
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotFormState(
|
||||||
|
value: string | undefined,
|
||||||
|
defaultValue: string,
|
||||||
|
hasDefaultValue: boolean,
|
||||||
|
sensitive: boolean,
|
||||||
|
): CapturedNode["formState"] {
|
||||||
|
if (sensitive) {
|
||||||
|
if (value === undefined && !hasDefaultValue) return undefined;
|
||||||
|
return (value ?? defaultValue) === "" ? "empty" : "filled";
|
||||||
|
}
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (value === "") return "empty";
|
||||||
|
return value === defaultValue ? "default" : "filled";
|
||||||
|
}
|
||||||
|
|
||||||
|
function str(strings: string[], idx: number | undefined): string {
|
||||||
|
if (idx === undefined || idx < 0) return "";
|
||||||
|
return strings[idx] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sparseIndexMap(sparse: SparseArray | undefined): Map<number, number> {
|
||||||
|
const out = new Map<number, number>();
|
||||||
|
if (!sparse?.index || !sparse.value) return out;
|
||||||
|
for (let i = 0; i < sparse.index.length; i++) {
|
||||||
|
const nodeIndex = sparse.index[i];
|
||||||
|
const docIndex = sparse.value[i];
|
||||||
|
if (nodeIndex !== undefined && docIndex !== undefined) out.set(nodeIndex, docIndex);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decodeDocument(
|
||||||
|
doc: SnapshotDocument,
|
||||||
|
strings: string[],
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<DecodedDocument> {
|
||||||
|
const checkpoint = createCaptureCheckpoint(signal);
|
||||||
|
const dn = doc.nodes;
|
||||||
|
const dl = doc.layout;
|
||||||
|
if (!dn?.backendNodeId) {
|
||||||
|
return { nodes: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = dn.backendNodeId.length;
|
||||||
|
const layoutByNode = new Map<number, number>();
|
||||||
|
for (let i = 0; i < (dl?.nodeIndex?.length ?? 0); i++) {
|
||||||
|
if (i % 256 === 0) {
|
||||||
|
const pending = checkpoint();
|
||||||
|
if (pending) await pending;
|
||||||
|
}
|
||||||
|
layoutByNode.set(dl!.nodeIndex![i], i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputValues = sparseIndexMap(dn.inputValue);
|
||||||
|
const textValues = sparseIndexMap(dn.textValue);
|
||||||
|
const checkedInputs = new Set(dn.inputChecked?.index ?? []);
|
||||||
|
const selectedOptions = new Set(dn.optionSelected?.index ?? []);
|
||||||
|
|
||||||
|
// Collect visible text from #text child nodes so element CapturedNodes
|
||||||
|
// carry a textContent value usable as a button/link label fallback.
|
||||||
|
// nodeValue is a parallel array: string index for text nodes, -1 otherwise.
|
||||||
|
const nodeTextContent = new Map<number, string[]>();
|
||||||
|
if (dn.nodeValue) {
|
||||||
|
for (let n = 0; n < count; n++) {
|
||||||
|
if (n % 256 === 0) {
|
||||||
|
const pending = checkpoint();
|
||||||
|
if (pending) await pending;
|
||||||
|
}
|
||||||
|
const nvIdx = dn.nodeValue[n] ?? -1;
|
||||||
|
if (nvIdx < 0) continue;
|
||||||
|
const text = str(strings, nvIdx).trim();
|
||||||
|
if (!text) continue;
|
||||||
|
const parentIdx = dn.parentIndex?.[n] ?? -1;
|
||||||
|
if (parentIdx >= 0) {
|
||||||
|
const existing = nodeTextContent.get(parentIdx);
|
||||||
|
if (existing) existing.push(text);
|
||||||
|
else nodeTextContent.set(parentIdx, [text]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode fields without assigning a coordinate projection or semantic policy.
|
||||||
|
const nodes: DecodedNode[] = [];
|
||||||
|
for (let n = 0; n < count; n++) {
|
||||||
|
if (n % 256 === 0) {
|
||||||
|
const pending = checkpoint();
|
||||||
|
if (pending) await pending;
|
||||||
|
}
|
||||||
|
const backendNodeId = dn.backendNodeId[n];
|
||||||
|
const parentIdx = dn.parentIndex?.[n] ?? -1;
|
||||||
|
const parentBackendNodeId = parentIdx >= 0 ? (dn.backendNodeId[parentIdx] ?? null) : null;
|
||||||
|
const tag = str(strings, dn.nodeName?.[n]).toLowerCase();
|
||||||
|
|
||||||
|
const attrs: Record<string, string> = {};
|
||||||
|
const pairs = dn.attributes?.[n] ?? [];
|
||||||
|
for (let a = 0; a + 1 < pairs.length; a += 2) {
|
||||||
|
attrs[str(strings, pairs[a]).toLowerCase()] = str(strings, pairs[a + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const li = layoutByNode.get(n);
|
||||||
|
const styleRow = li === undefined ? [] : (dl?.styles?.[li] ?? []);
|
||||||
|
const styles: Record<string, string> = {};
|
||||||
|
for (const name of REQUESTED_STYLES) styles[name] = str(strings, styleRow[STYLE_COL[name]]);
|
||||||
|
const layout =
|
||||||
|
li === undefined
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
boundsSpace: "snapshot-document-layout" as const,
|
||||||
|
bounds: dl?.bounds?.[li],
|
||||||
|
styles,
|
||||||
|
};
|
||||||
|
|
||||||
|
const textContent = nodeTextContent.get(n)?.join(" ");
|
||||||
|
|
||||||
|
const rawFormValueIndex = tag === "textarea" ? textValues.get(n) : inputValues.get(n);
|
||||||
|
const rawFormValue =
|
||||||
|
rawFormValueIndex !== undefined ? str(strings, rawFormValueIndex) : undefined;
|
||||||
|
const sensitive = isSensitiveFormControl({ tag, attrs });
|
||||||
|
const formDefaultValue = attrs.value ?? "";
|
||||||
|
const formValue = sensitive ? undefined : rawFormValue;
|
||||||
|
const formState = snapshotFormState(
|
||||||
|
rawFormValue,
|
||||||
|
formDefaultValue,
|
||||||
|
Object.prototype.hasOwnProperty.call(attrs, "value"),
|
||||||
|
sensitive,
|
||||||
|
);
|
||||||
|
if (sensitive) delete attrs.value;
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
backendNodeId,
|
||||||
|
nodeType: dn.nodeType?.[n],
|
||||||
|
parentBackendNodeId,
|
||||||
|
|
||||||
|
tag,
|
||||||
|
attrs,
|
||||||
|
layout,
|
||||||
|
paintOrder: li === undefined ? 0 : (dl?.paintOrders?.[li] ?? 0),
|
||||||
|
position: styles.position || "static",
|
||||||
|
pointerEvents: styles["pointer-events"] || "auto",
|
||||||
|
cursor: styles.cursor || "auto",
|
||||||
|
|
||||||
|
textContent,
|
||||||
|
...(formValue !== undefined ? { formValue } : {}),
|
||||||
|
...(tag === "input" || tag === "textarea"
|
||||||
|
? {
|
||||||
|
formPlaceholder: attrs.placeholder ?? "",
|
||||||
|
...(!sensitive ? { formDefaultValue } : {}),
|
||||||
|
...(formState ? { formState } : {}),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(checkedInputs.has(n) ? { formValue: "true", formState: "filled" } : {}),
|
||||||
|
...(selectedOptions.has(n) ? { formValue: attrs.value ?? textContent ?? "" } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot membership and same-target edges are current response evidence.
|
||||||
|
* The earlier graph only supplies compatible metadata and cross-target edges. */
|
||||||
|
export function describeSnapshotFrames(
|
||||||
|
snapshot: SnapshotReply,
|
||||||
|
target: CdpTarget,
|
||||||
|
hintById: ReadonlyMap<string, CdpFrame>,
|
||||||
|
pageUrl?: string,
|
||||||
|
): { frames: CdpFrame[]; ids: Set<string>; rootFrameId?: string } {
|
||||||
|
const raw = snapshot.documents ?? [];
|
||||||
|
const strings = snapshot.strings ?? [];
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const invalid = new Set<string>();
|
||||||
|
const frames = raw.map((doc) => {
|
||||||
|
const frameId = snapshotFrameId(doc, strings);
|
||||||
|
if (!frameId) return undefined;
|
||||||
|
if (ids.has(frameId)) invalid.add(frameId);
|
||||||
|
ids.add(frameId);
|
||||||
|
const hint = hintById.get(frameId);
|
||||||
|
const compatible =
|
||||||
|
hint && cdpTargetKey(hint.target) === cdpTargetKey(target) ? hint : undefined;
|
||||||
|
const url =
|
||||||
|
(doc.documentURL === undefined ? undefined : strings[doc.documentURL]) || compatible?.url;
|
||||||
|
return { frameId, target, ...(url ? { url } : {}) } as CdpFrame;
|
||||||
|
});
|
||||||
|
const root = frames[0];
|
||||||
|
if (root) {
|
||||||
|
const hint = hintById.get(root.frameId);
|
||||||
|
const parent = hint?.parentFrameId ? hintById.get(hint.parentFrameId) : undefined;
|
||||||
|
if (
|
||||||
|
target.sessionId &&
|
||||||
|
hint &&
|
||||||
|
cdpTargetKey(hint.target) === cdpTargetKey(target) &&
|
||||||
|
parent &&
|
||||||
|
cdpTargetKey(parent.target) !== cdpTargetKey(target)
|
||||||
|
) {
|
||||||
|
root.parentFrameId = parent.frameId;
|
||||||
|
root.ownerBackendNodeId = hint.ownerBackendNodeId;
|
||||||
|
}
|
||||||
|
if (!target.sessionId && !root.url && pageUrl) root.url = pageUrl;
|
||||||
|
}
|
||||||
|
const owned = new Set<string>();
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
const edges = raw[i].nodes?.contentDocumentIndex;
|
||||||
|
const parent = frames[i];
|
||||||
|
if (!parent || !edges) continue;
|
||||||
|
for (let e = 0; e < edges.index.length; e++) {
|
||||||
|
const child = frames[edges.value[e]];
|
||||||
|
const owner = raw[i].nodes?.backendNodeId?.[edges.index[e]];
|
||||||
|
if (!child || owner === undefined) continue;
|
||||||
|
if (child === root || child === parent || owned.has(child.frameId)) {
|
||||||
|
invalid.add(child.frameId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
owned.add(child.frameId);
|
||||||
|
child.parentFrameId = parent.frameId;
|
||||||
|
child.ownerBackendNodeId = owner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
frames: frames.filter((frame): frame is CdpFrame => !!frame && !invalid.has(frame.frameId)),
|
||||||
|
ids,
|
||||||
|
rootFrameId: root?.frameId,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,17 +26,38 @@ export type RpcErrorReason =
|
|||||||
| "ref_not_found"
|
| "ref_not_found"
|
||||||
| "selector_not_found"
|
| "selector_not_found"
|
||||||
| "target_not_fillable"
|
| "target_not_fillable"
|
||||||
|
| "fill_value_invalid"
|
||||||
|
| "fill_target_changed"
|
||||||
|
| "fill_focus_lost"
|
||||||
|
| "fill_value_mismatch"
|
||||||
|
| "fill_failed"
|
||||||
| "target_not_select"
|
| "target_not_select"
|
||||||
| "option_not_found"
|
| "option_not_found"
|
||||||
| "single_select_value_count"
|
| "single_select_value_count"
|
||||||
| "tab_not_active"
|
| "tab_not_active"
|
||||||
| "restricted_tab_url"
|
| "restricted_tab_url"
|
||||||
|
| "cdp_extension_access_denied"
|
||||||
| "borrow_conflict"
|
| "borrow_conflict"
|
||||||
| "screenshot_capture_failed"
|
| "screenshot_capture_failed"
|
||||||
|
| "file_input_probe_failed"
|
||||||
|
| "file_input_not_activated"
|
||||||
|
| "set_file_input_failed"
|
||||||
|
| "upload_mechanism_unsupported"
|
||||||
|
| "file_drop_target_unavailable"
|
||||||
|
| "file_drop_failed"
|
||||||
|
| "download_capture_failed"
|
||||||
|
| "transfer_outcome_unknown"
|
||||||
|
| "transfer_timeout"
|
||||||
| "cleanup_failed";
|
| "cleanup_failed";
|
||||||
|
|
||||||
|
export type TransferEffectState = "none" | "committed" | "unknown";
|
||||||
|
export type TransferCleanupState = "complete" | "failed";
|
||||||
|
|
||||||
export interface RpcErrorData {
|
export interface RpcErrorData {
|
||||||
reason?: RpcErrorReason;
|
reason?: RpcErrorReason;
|
||||||
|
effect_state?: TransferEffectState;
|
||||||
|
phase?: string;
|
||||||
|
cleanup_state?: TransferCleanupState;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,9 +339,14 @@ export interface SnapshotResult {
|
|||||||
|
|
||||||
export interface ObserveParams extends SnapshotParams {
|
export interface ObserveParams extends SnapshotParams {
|
||||||
debug_surfaces?: boolean;
|
debug_surfaces?: boolean;
|
||||||
|
probe_hover?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ObserveResult extends SnapshotResult {
|
export interface ObserveResult extends SnapshotResult {
|
||||||
|
hover_probe?: {
|
||||||
|
performed: boolean;
|
||||||
|
revealed_content: boolean;
|
||||||
|
};
|
||||||
debug?: {
|
debug?: {
|
||||||
surface_probes?: Array<{
|
surface_probes?: Array<{
|
||||||
trigger_backend_node_id: number;
|
trigger_backend_node_id: number;
|
||||||
@@ -500,6 +526,53 @@ export interface SelectResult {
|
|||||||
dialogs?: JavaScriptDialogInfo[];
|
dialogs?: JavaScriptDialogInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UploadFile {
|
||||||
|
transfer_id: string;
|
||||||
|
name: string;
|
||||||
|
staged_path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UploadMode = "input" | "drop";
|
||||||
|
|
||||||
|
export interface UploadParams {
|
||||||
|
session_id: string;
|
||||||
|
ref?: string;
|
||||||
|
selector?: string;
|
||||||
|
tab_id?: number;
|
||||||
|
files: UploadFile[];
|
||||||
|
mode?: UploadMode;
|
||||||
|
timeout_ms?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
tab_id: number;
|
||||||
|
used_ref?: string;
|
||||||
|
used_selector?: string;
|
||||||
|
file_names: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadParams {
|
||||||
|
session_id: string;
|
||||||
|
ref?: string;
|
||||||
|
selector?: string;
|
||||||
|
tab_id?: number;
|
||||||
|
timeout_ms?: number;
|
||||||
|
browser_relative_dir?: string;
|
||||||
|
max_byte_size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadResult {
|
||||||
|
tab_id: number;
|
||||||
|
used_ref?: string;
|
||||||
|
used_selector?: string;
|
||||||
|
suggested_filename: string;
|
||||||
|
byte_size: number;
|
||||||
|
mime?: string;
|
||||||
|
danger?: string;
|
||||||
|
browser_path?: string;
|
||||||
|
transfer_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
// M9 tool payloads — evaluate / wait_for_navigation / wait_ms
|
// M9 tool payloads — evaluate / wait_for_navigation / wait_ms
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default defineConfig({
|
|||||||
permissions: [
|
permissions: [
|
||||||
"alarms",
|
"alarms",
|
||||||
"debugger",
|
"debugger",
|
||||||
|
"downloads",
|
||||||
"idle",
|
"idle",
|
||||||
"notifications",
|
"notifications",
|
||||||
"tabs",
|
"tabs",
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ flate2 = { workspace = true }
|
|||||||
tar = { workspace = true }
|
tar = { workspace = true }
|
||||||
zip = { workspace = true }
|
zip = { workspace = true }
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
|
base64 = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
nix = { workspace = true }
|
nix = { workspace = true }
|
||||||
@@ -63,6 +64,7 @@ windows-sys = { version = "0.59", features = [
|
|||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
"Win32_System_Threading",
|
"Win32_System_Threading",
|
||||||
"Win32_Security",
|
"Win32_Security",
|
||||||
|
"Win32_Storage_FileSystem",
|
||||||
] }
|
] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user