mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec62b8f0f2 | ||
|
|
9393f0e58b | ||
|
|
f9d395ee41 | ||
|
|
8f81c15656 | ||
|
|
3cfa9eceef | ||
|
|
34f47d7387 | ||
|
|
0a184e33d1 |
@@ -325,180 +325,3 @@ 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"
|
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ dist/
|
|||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Local browser evaluation artifacts
|
|
||||||
evals/browser/results/
|
|
||||||
evals/browser/agents.local.json
|
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
|||||||
Generated
+3
-2
@@ -134,9 +134,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bsk"
|
name = "bsk"
|
||||||
version = "0.1.11"
|
version = "0.1.10"
|
||||||
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.1.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"schemars",
|
"schemars",
|
||||||
"semver",
|
"semver",
|
||||||
|
|||||||
+2
-1
@@ -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.1.10"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -36,6 +36,7 @@ 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"
|
||||||
|
|||||||
@@ -208,7 +208,6 @@ The repository is a Cargo + pnpm workspace:
|
|||||||
- `apps/extension` — browser extension
|
- `apps/extension` — browser extension
|
||||||
- `packages/ui` and `packages/i18n` — shared extension UI support
|
- `packages/ui` and `packages/i18n` — shared extension UI support
|
||||||
- `packages/dsh-plugin-browserskill` — DeepSeek Harness plugin (`@wxg-prc-cpg/browser-skill-dsh-plugin`)
|
- `packages/dsh-plugin-browserskill` — DeepSeek Harness plugin (`@wxg-prc-cpg/browser-skill-dsh-plugin`)
|
||||||
- [`evals/browser`](evals/browser/README.md) — deterministic local pages and agent-neutral browser capability evaluation
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -178,7 +178,6 @@ Agent 不直接与浏览器通信。它通过 `bsk` CLI 下发浏览器任务;
|
|||||||
- `apps/extension` — 浏览器扩展
|
- `apps/extension` — 浏览器扩展
|
||||||
- `packages/ui` 和 `packages/i18n` — 扩展 UI 共享支持
|
- `packages/ui` 和 `packages/i18n` — 扩展 UI 共享支持
|
||||||
- `packages/dsh-plugin-browserskill` — DeepSeek Harness 插件(`@wxg-prc-cpg/browser-skill-dsh-plugin`)
|
- `packages/dsh-plugin-browserskill` — DeepSeek Harness 插件(`@wxg-prc-cpg/browser-skill-dsh-plugin`)
|
||||||
- [`evals/browser`](evals/browser/README.zh-CN.md) — 确定性本地页面与 Agent 无关的浏览器能力测试台
|
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@browser-skill/extension",
|
"name": "@browser-skill/extension",
|
||||||
"version": "0.1.7",
|
"version": "0.1.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { RecordStepPayload } from "@/lib/record-bridge";
|
import { RECORD_STOP, type RecordStepPayload } from "@/lib/record-bridge";
|
||||||
import { startRecordCapture } from "../record-capture";
|
import { handleRecordContentMessage, startRecordCapture } from "../record-capture";
|
||||||
|
|
||||||
vi.stubGlobal("chrome", {
|
vi.stubGlobal("chrome", {
|
||||||
runtime: {
|
runtime: {
|
||||||
sendMessage: vi.fn((message: { sequence?: number }) =>
|
sendMessage: vi.fn(() => Promise.resolve()),
|
||||||
Promise.resolve({ ok: true, sequence: message.sequence }),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,6 +46,58 @@ function click(el: Element): void {
|
|||||||
el.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
|
el.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("handleRecordContentMessage stop/cancel", () => {
|
||||||
|
it("ignores STOP when no recording is active", () => {
|
||||||
|
const dispose = vi.fn();
|
||||||
|
const onStop = vi.fn();
|
||||||
|
const setActiveRequestId = vi.fn();
|
||||||
|
const setCapture = vi.fn();
|
||||||
|
const sendResponse = vi.fn();
|
||||||
|
|
||||||
|
const needsAsync = handleRecordContentMessage(
|
||||||
|
{ type: RECORD_STOP, requestId: "rec-stale" },
|
||||||
|
{
|
||||||
|
activeRequestId: null,
|
||||||
|
capture: { dispose },
|
||||||
|
setActiveRequestId,
|
||||||
|
setCapture,
|
||||||
|
onStart: vi.fn(),
|
||||||
|
onStop,
|
||||||
|
},
|
||||||
|
sendResponse,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(needsAsync).toBe(false);
|
||||||
|
expect(dispose).not.toHaveBeenCalled();
|
||||||
|
expect(onStop).not.toHaveBeenCalled();
|
||||||
|
expect(setActiveRequestId).not.toHaveBeenCalled();
|
||||||
|
expect(setCapture).not.toHaveBeenCalled();
|
||||||
|
expect(sendResponse).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores STOP for a mismatched requestId", () => {
|
||||||
|
const dispose = vi.fn();
|
||||||
|
const onStop = vi.fn();
|
||||||
|
|
||||||
|
const needsAsync = handleRecordContentMessage(
|
||||||
|
{ type: RECORD_STOP, requestId: "rec-other" },
|
||||||
|
{
|
||||||
|
activeRequestId: "rec-1",
|
||||||
|
capture: { dispose },
|
||||||
|
setActiveRequestId: vi.fn(),
|
||||||
|
setCapture: vi.fn(),
|
||||||
|
onStart: vi.fn(),
|
||||||
|
onStop,
|
||||||
|
},
|
||||||
|
vi.fn(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(needsAsync).toBe(false);
|
||||||
|
expect(dispose).not.toHaveBeenCalled();
|
||||||
|
expect(onStop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("record-capture semantic", () => {
|
describe("record-capture semantic", () => {
|
||||||
let steps: RecordStepPayload[];
|
let steps: RecordStepPayload[];
|
||||||
|
|
||||||
@@ -124,19 +174,6 @@ describe("record-capture semantic", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not emit page navigation from a child Document capture", () => {
|
|
||||||
const originalUrl = location.href;
|
|
||||||
const capture = startRecordCapture("rec-child", (step) => steps.push(step), {
|
|
||||||
captureNavigation: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
history.pushState({}, "", "#inside-frame");
|
|
||||||
expect(steps).toEqual([]);
|
|
||||||
|
|
||||||
capture.dispose();
|
|
||||||
history.replaceState({}, "", originalUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not record clicks on anonymous layout divs", () => {
|
it("does not record clicks on anonymous layout divs", () => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<div id="chrome">page chrome</div>
|
<div id="chrome">page chrome</div>
|
||||||
@@ -191,7 +228,6 @@ describe("record-capture semantic", () => {
|
|||||||
expect(steps[0]).toMatchObject({
|
expect(steps[0]).toMatchObject({
|
||||||
op: "hover",
|
op: "hover",
|
||||||
target: { role: "button", name: "Open user navigation menu" },
|
target: { role: "button", name: "Open user navigation menu" },
|
||||||
geometry: { tag: "button", rect: { x: 900, y: 8, w: 32, h: 32 } },
|
|
||||||
});
|
});
|
||||||
expect(steps[1]).toMatchObject({
|
expect(steps[1]).toMatchObject({
|
||||||
op: "click",
|
op: "click",
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { RecordFramePortMessage } from "@/lib/recording/frame-bridge";
|
|
||||||
import { RECORD_FRAME_START } from "@/lib/recording/frame-bridge";
|
|
||||||
import { RECORD_DOCUMENT_ATTRIBUTE } from "@/shared/recording-document-identity";
|
|
||||||
import { RecordFrameAgent } from "../recording/frame-agent";
|
|
||||||
|
|
||||||
class PortListeners<T extends (...args: never[]) => unknown> {
|
|
||||||
readonly values = new Set<T>();
|
|
||||||
addListener = (listener: T) => this.values.add(listener);
|
|
||||||
removeListener = (listener: T) => this.values.delete(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
function portHarness() {
|
|
||||||
const onMessage = new PortListeners<(message: unknown) => void>();
|
|
||||||
const onDisconnect = new PortListeners<() => void>();
|
|
||||||
const outbound: RecordFramePortMessage[] = [];
|
|
||||||
const port = {
|
|
||||||
name: "bsk-record-frame",
|
|
||||||
onMessage,
|
|
||||||
onDisconnect,
|
|
||||||
postMessage(message: RecordFramePortMessage) {
|
|
||||||
outbound.push(message);
|
|
||||||
if (message.type === "ready") {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
for (const listener of onMessage.values) {
|
|
||||||
listener({
|
|
||||||
type: "ready_ack",
|
|
||||||
requestId: message.requestId,
|
|
||||||
producerId: message.producerId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
disconnect: vi.fn(),
|
|
||||||
} as unknown as chrome.runtime.Port;
|
|
||||||
return {
|
|
||||||
port,
|
|
||||||
outbound,
|
|
||||||
receive(message: RecordFramePortMessage) {
|
|
||||||
for (const listener of onMessage.values) listener(message);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("RecordFrameAgent", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
document.documentElement.removeAttribute(RECORD_DOCUMENT_ATTRIBUTE);
|
|
||||||
document.body.replaceChildren();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a failed stop retryable and flushes the final dirty fill", async () => {
|
|
||||||
const harness = portHarness();
|
|
||||||
const sendMessage = vi
|
|
||||||
.fn<(message: { sequence: number }) => Promise<unknown>>()
|
|
||||||
.mockRejectedValueOnce(new Error("offline"))
|
|
||||||
.mockRejectedValueOnce(new Error("still offline"))
|
|
||||||
.mockImplementation(async (message) => ({ ok: true, sequence: message.sequence }));
|
|
||||||
vi.stubGlobal("chrome", {
|
|
||||||
runtime: {
|
|
||||||
connect: vi.fn(() => harness.port),
|
|
||||||
sendMessage,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
document.body.innerHTML = `<label for="draft">Draft</label><input id="draft" />`;
|
|
||||||
const agent = new RecordFrameAgent();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
agent.start({ type: RECORD_FRAME_START, requestId: "rec-1", startedAtMs: 10 }),
|
|
||||||
).resolves.toEqual({ ok: true });
|
|
||||||
expect(document.documentElement.hasAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe(true);
|
|
||||||
|
|
||||||
const input = document.querySelector("input")!;
|
|
||||||
input.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
|
|
||||||
input.value = "final value";
|
|
||||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
||||||
|
|
||||||
harness.receive({ type: "stop", requestId: "rec-1", commandId: "stop-1" });
|
|
||||||
await vi.waitFor(() =>
|
|
||||||
expect(harness.outbound).toContainEqual({
|
|
||||||
type: "stopped",
|
|
||||||
requestId: "rec-1",
|
|
||||||
commandId: "stop-1",
|
|
||||||
ok: false,
|
|
||||||
error: "failed to deliver one or more recorded steps",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(document.documentElement.hasAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe(true);
|
|
||||||
|
|
||||||
harness.receive({ type: "stop", requestId: "rec-1", commandId: "stop-2" });
|
|
||||||
await vi.waitFor(() =>
|
|
||||||
expect(harness.outbound).toContainEqual({
|
|
||||||
type: "stopped",
|
|
||||||
requestId: "rec-1",
|
|
||||||
commandId: "stop-2",
|
|
||||||
ok: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(sendMessage).toHaveBeenCalledTimes(3);
|
|
||||||
expect(document.documentElement.hasAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import type { RecordStepMessage } from "@/lib/record-bridge";
|
|
||||||
import { RecordStepDelivery } from "../record-step-delivery";
|
|
||||||
|
|
||||||
describe("RecordStepDelivery", () => {
|
|
||||||
it("retries from the first unacknowledged sequence before sending later steps", async () => {
|
|
||||||
const sent: RecordStepMessage[] = [];
|
|
||||||
const send = vi.fn(async (message: RecordStepMessage) => {
|
|
||||||
sent.push(message);
|
|
||||||
if (sent.length === 1) throw new Error("service worker unavailable");
|
|
||||||
return { ok: true, sequence: message.sequence };
|
|
||||||
});
|
|
||||||
const delivery = new RecordStepDelivery("rec-ordered", send, "document-1");
|
|
||||||
|
|
||||||
delivery.enqueue({ op: "click", target: { tag: "button", name: "First" } });
|
|
||||||
delivery.enqueue({ op: "click", target: { tag: "button", name: "Second" } });
|
|
||||||
|
|
||||||
await expect(delivery.flush()).resolves.toBe(true);
|
|
||||||
expect(sent.map((message) => message.sequence)).toEqual([1, 1, 2]);
|
|
||||||
expect(sent.map((message) => message.step.target?.name)).toEqual(["First", "First", "Second"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps unacknowledged steps pending for a later flush", async () => {
|
|
||||||
const send = vi
|
|
||||||
.fn<(message: RecordStepMessage) => Promise<unknown>>()
|
|
||||||
.mockRejectedValueOnce(new Error("offline"))
|
|
||||||
.mockRejectedValueOnce(new Error("still offline"))
|
|
||||||
.mockImplementation(async (message) => ({ ok: true, sequence: message.sequence }));
|
|
||||||
const delivery = new RecordStepDelivery("rec-retry", send, "document-1");
|
|
||||||
delivery.enqueue({ op: "click", target: { tag: "button", name: "Save" } });
|
|
||||||
|
|
||||||
await expect(delivery.flush()).resolves.toBe(false);
|
|
||||||
await expect(delivery.flush()).resolves.toBe(true);
|
|
||||||
expect(send).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
type CaptureTargetDescriptor,
|
|
||||||
describeEventTarget,
|
describeEventTarget,
|
||||||
describeTarget,
|
describeTarget,
|
||||||
resolveClickableElement,
|
resolveClickableElement,
|
||||||
resolveHoverElement,
|
resolveHoverElement,
|
||||||
|
type TargetDescriptor,
|
||||||
} from "@/lib/describe-target";
|
} from "@/lib/describe-target";
|
||||||
import {
|
import {
|
||||||
evaluateHoverTrigger,
|
evaluateHoverTrigger,
|
||||||
@@ -12,8 +12,22 @@ import {
|
|||||||
hasDirectHoverInteractiveSignal,
|
hasDirectHoverInteractiveSignal,
|
||||||
hasStrongHoverExpansionSignal,
|
hasStrongHoverExpansionSignal,
|
||||||
} from "@/lib/hover-trigger-policy";
|
} from "@/lib/hover-trigger-policy";
|
||||||
import type { RecordStepPayload } from "@/lib/record-bridge";
|
import {
|
||||||
import { shouldRecordPress } from "@/lib/recording/draft-policy";
|
isRecordCancelMessage,
|
||||||
|
isRecordStartMessage,
|
||||||
|
isRecordStopMessage,
|
||||||
|
RECORD_CANCEL,
|
||||||
|
RECORD_START,
|
||||||
|
RECORD_STEP,
|
||||||
|
RECORD_STOP,
|
||||||
|
type RecordCancelMessage,
|
||||||
|
type RecordStartAck,
|
||||||
|
type RecordStartMessage,
|
||||||
|
type RecordStepPayload,
|
||||||
|
type RecordStopAck,
|
||||||
|
type RecordStopMessage,
|
||||||
|
} from "@/lib/record-bridge";
|
||||||
|
import { shouldRecordPress } from "@/lib/trace-reducer";
|
||||||
import {
|
import {
|
||||||
closestHoverSurfaceCandidate,
|
closestHoverSurfaceCandidate,
|
||||||
collectHoverSurfaceStates,
|
collectHoverSurfaceStates,
|
||||||
@@ -23,21 +37,42 @@ import {
|
|||||||
isLikelyHoverSurfaceOwner,
|
isLikelyHoverSurfaceOwner,
|
||||||
} from "./record-hover-surface";
|
} from "./record-hover-surface";
|
||||||
|
|
||||||
|
const pendingStepSends = new Map<string, Set<Promise<boolean>>>();
|
||||||
|
const failedStepDeliveries = new Set<string>();
|
||||||
|
const knownRecordRequests = new Set<string>();
|
||||||
|
|
||||||
|
function sendRecordStep(requestId: string, step: RecordStepPayload): void {
|
||||||
|
const payload = { type: RECORD_STEP, requestId, step };
|
||||||
|
const pending = Promise.resolve(chrome.runtime.sendMessage(payload)).then(
|
||||||
|
() => true,
|
||||||
|
() => {
|
||||||
|
failedStepDeliveries.add(requestId);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const sends = pendingStepSends.get(requestId) ?? new Set<Promise<boolean>>();
|
||||||
|
sends.add(pending);
|
||||||
|
pendingStepSends.set(requestId, sends);
|
||||||
|
void pending.then(() => {
|
||||||
|
sends.delete(pending);
|
||||||
|
if (sends.size === 0) pendingStepSends.delete(requestId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface RecordCaptureController {
|
export interface RecordCaptureController {
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FillSession {
|
interface FillSession {
|
||||||
element: FillableElement;
|
element: FillableElement;
|
||||||
target: CaptureTargetDescriptor;
|
target: TargetDescriptor;
|
||||||
baselineValue: string;
|
baselineValue: string;
|
||||||
lastValue: string;
|
lastValue: string;
|
||||||
pendingCommit?: "enter" | "suggestion" | "blur";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HoverCandidate {
|
interface HoverCandidate {
|
||||||
element: Element;
|
element: Element;
|
||||||
target: CaptureTargetDescriptor;
|
target: TargetDescriptor;
|
||||||
recordedAt: number;
|
recordedAt: number;
|
||||||
score: number;
|
score: number;
|
||||||
eligible: boolean;
|
eligible: boolean;
|
||||||
@@ -122,24 +157,6 @@ function nearbyFillableFromSearchChrome(target: Element): FillableElement | null
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureGeometry(el: Element): RecordStepPayload["geometry"] {
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
return {
|
|
||||||
rect: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
|
|
||||||
tag: el.tagName.toLowerCase(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function geometryForEventTarget(
|
|
||||||
target: EventTarget | null,
|
|
||||||
): RecordStepPayload["geometry"] | undefined {
|
|
||||||
if (!(target instanceof Element)) return undefined;
|
|
||||||
const clickable = target.closest(
|
|
||||||
'a,button,input,select,textarea,[role="button"],[role="link"],[role="menuitem"],[contenteditable="true"]',
|
|
||||||
);
|
|
||||||
return captureGeometry(clickable ?? target);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillableValue(el: FillableElement): string {
|
function fillableValue(el: FillableElement): string {
|
||||||
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
||||||
return el.value;
|
return el.value;
|
||||||
@@ -215,10 +232,7 @@ function collectHoverTriggerLabelText(root: Element): string {
|
|||||||
return normalizeLabelText(text);
|
return normalizeLabelText(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function compactHoverTargetName(
|
function compactHoverTargetName(el: Element, desc: TargetDescriptor): TargetDescriptor {
|
||||||
el: Element,
|
|
||||||
desc: CaptureTargetDescriptor,
|
|
||||||
): CaptureTargetDescriptor {
|
|
||||||
if (!desc.name) return desc;
|
if (!desc.name) return desc;
|
||||||
const fullText = normalizeLabelText(el.textContent ?? "");
|
const fullText = normalizeLabelText(el.textContent ?? "");
|
||||||
const compactName = collectHoverTriggerLabelText(el);
|
const compactName = collectHoverTriggerLabelText(el);
|
||||||
@@ -236,7 +250,7 @@ function compactHoverTargetName(
|
|||||||
return desc;
|
return desc;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWeakHoverTarget(target: CaptureTargetDescriptor): boolean {
|
function isWeakHoverTarget(target: TargetDescriptor): boolean {
|
||||||
return !target.role && !target.name && target.tag === "div";
|
return !target.role && !target.name && target.tag === "div";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,9 +261,9 @@ function looksLikeAvatarElement(el: Element): boolean {
|
|||||||
|
|
||||||
function normalizeHoverTarget(
|
function normalizeHoverTarget(
|
||||||
el: Element,
|
el: Element,
|
||||||
desc: CaptureTargetDescriptor,
|
desc: TargetDescriptor,
|
||||||
decision: HoverTriggerDecision,
|
decision: HoverTriggerDecision,
|
||||||
): CaptureTargetDescriptor {
|
): TargetDescriptor {
|
||||||
if (desc.role === "img" && !desc.name && looksLikeAvatarElement(el)) {
|
if (desc.role === "img" && !desc.name && looksLikeAvatarElement(el)) {
|
||||||
return { ...desc, name: "image" };
|
return { ...desc, name: "image" };
|
||||||
}
|
}
|
||||||
@@ -263,7 +277,7 @@ function normalizeHoverTarget(
|
|||||||
return desc;
|
return desc;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hoverTriggerSignals(el: Element, desc: CaptureTargetDescriptor) {
|
function hoverTriggerSignals(el: Element, desc: TargetDescriptor) {
|
||||||
if (!(el instanceof HTMLElement)) return null;
|
if (!(el instanceof HTMLElement)) return null;
|
||||||
const style = hoverTriggerStyle(el);
|
const style = hoverTriggerStyle(el);
|
||||||
return {
|
return {
|
||||||
@@ -359,7 +373,6 @@ function scheduleInputCompletionCommit(
|
|||||||
export function startRecordCapture(
|
export function startRecordCapture(
|
||||||
_requestId: string,
|
_requestId: string,
|
||||||
sendStep: (step: RecordStepPayload) => void,
|
sendStep: (step: RecordStepPayload) => void,
|
||||||
options: { captureNavigation?: boolean } = {},
|
|
||||||
): RecordCaptureController {
|
): RecordCaptureController {
|
||||||
const emitStep = (step: RecordStepPayload) => {
|
const emitStep = (step: RecordStepPayload) => {
|
||||||
sendStep({ page_url: location.href, ...step });
|
sendStep({ page_url: location.href, ...step });
|
||||||
@@ -398,17 +411,14 @@ export function startRecordCapture(
|
|||||||
emitStep({
|
emitStep({
|
||||||
op: "fill",
|
op: "fill",
|
||||||
target: session.target,
|
target: session.target,
|
||||||
geometry: captureGeometry(session.element),
|
|
||||||
value,
|
value,
|
||||||
commit: session.pendingCommit ?? "blur",
|
|
||||||
...(isPassword ? { redacted: true } : {}),
|
...(isPassword ? { redacted: true } : {}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const commitFillSession = (commit: "enter" | "suggestion" | "blur" = "blur") => {
|
const commitFillSession = () => {
|
||||||
if (!fillSession || composing) return;
|
if (!fillSession || composing) return;
|
||||||
const session = fillSession;
|
const session = fillSession;
|
||||||
session.pendingCommit = commit;
|
|
||||||
fillSession = null;
|
fillSession = null;
|
||||||
emitFill(session);
|
emitFill(session);
|
||||||
committedValues.set(session.element, session.lastValue);
|
committedValues.set(session.element, session.lastValue);
|
||||||
@@ -614,7 +624,6 @@ export function startRecordCapture(
|
|||||||
emitStep({
|
emitStep({
|
||||||
op: "hover",
|
op: "hover",
|
||||||
target: hover.target,
|
target: hover.target,
|
||||||
geometry: captureGeometry(hover.element),
|
|
||||||
});
|
});
|
||||||
emittedHoverElements.add(hover.element);
|
emittedHoverElements.add(hover.element);
|
||||||
};
|
};
|
||||||
@@ -669,7 +678,6 @@ export function startRecordCapture(
|
|||||||
emitStep({
|
emitStep({
|
||||||
op: "click",
|
op: "click",
|
||||||
target,
|
target,
|
||||||
geometry: geometryForEventTarget(eventTarget(event)),
|
|
||||||
expects_navigation: true,
|
expects_navigation: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -771,7 +779,7 @@ export function startRecordCapture(
|
|||||||
scheduleInputCompletionCommit(
|
scheduleInputCompletionCommit(
|
||||||
sessionElement,
|
sessionElement,
|
||||||
syncFillSessionValue,
|
syncFillSessionValue,
|
||||||
() => commitFillSession("suggestion"),
|
commitFillSession,
|
||||||
(el) => fillSession?.element === el,
|
(el) => fillSession?.element === el,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -831,7 +839,6 @@ export function startRecordCapture(
|
|||||||
emitStep({
|
emitStep({
|
||||||
op: "select",
|
op: "select",
|
||||||
target: desc,
|
target: desc,
|
||||||
geometry: captureGeometry(target),
|
|
||||||
values,
|
values,
|
||||||
labels,
|
labels,
|
||||||
expects_navigation: true,
|
expects_navigation: true,
|
||||||
@@ -872,7 +879,7 @@ export function startRecordCapture(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (fillable) {
|
if (fillable) {
|
||||||
commitFillSession(event.key === "Enter" ? "enter" : "blur");
|
commitFillSession();
|
||||||
}
|
}
|
||||||
const desc = describeEventTarget(target);
|
const desc = describeEventTarget(target);
|
||||||
if (!desc && !event.key) return;
|
if (!desc && !event.key) return;
|
||||||
@@ -880,7 +887,7 @@ export function startRecordCapture(
|
|||||||
emitStep({
|
emitStep({
|
||||||
op: "press",
|
op: "press",
|
||||||
key: event.key,
|
key: event.key,
|
||||||
...(desc ? { target: desc, geometry: geometryForEventTarget(target) } : {}),
|
...(desc ? { target: desc } : {}),
|
||||||
...(modifiers.length ? { modifiers } : {}),
|
...(modifiers.length ? { modifiers } : {}),
|
||||||
expects_navigation: event.key === "Enter",
|
expects_navigation: event.key === "Enter",
|
||||||
});
|
});
|
||||||
@@ -896,24 +903,15 @@ export function startRecordCapture(
|
|||||||
document.addEventListener("change", onChange, true);
|
document.addEventListener("change", onChange, true);
|
||||||
document.addEventListener("keydown", onKeyDown, true);
|
document.addEventListener("keydown", onKeyDown, true);
|
||||||
|
|
||||||
const captureNavigation = options.captureNavigation ?? true;
|
const urlObserver = new MutationObserver(() => emitNavigateIfChanged());
|
||||||
const urlObserver = captureNavigation
|
urlObserver.observe(document, { subtree: true, childList: true });
|
||||||
? new MutationObserver(() => emitNavigateIfChanged())
|
|
||||||
: null;
|
|
||||||
urlObserver?.observe(document, { subtree: true, childList: true });
|
|
||||||
const onUrlEvent = () => emitNavigateIfChanged();
|
const onUrlEvent = () => emitNavigateIfChanged();
|
||||||
// Wrap: passing commitFillSession directly would forward the DOM event as
|
|
||||||
// the `commit` argument and stamp it onto the recorded fill step.
|
|
||||||
const onPageHide = () => commitFillSession();
|
|
||||||
if (captureNavigation) {
|
|
||||||
window.addEventListener("hashchange", onUrlEvent);
|
window.addEventListener("hashchange", onUrlEvent);
|
||||||
window.addEventListener("popstate", onUrlEvent);
|
window.addEventListener("popstate", onUrlEvent);
|
||||||
}
|
window.addEventListener("pagehide", commitFillSession);
|
||||||
window.addEventListener("pagehide", onPageHide);
|
|
||||||
|
|
||||||
const originalPushState = history.pushState;
|
const originalPushState = history.pushState;
|
||||||
const originalReplaceState = history.replaceState;
|
const originalReplaceState = history.replaceState;
|
||||||
if (captureNavigation) {
|
|
||||||
history.pushState = function (...args: Parameters<History["pushState"]>) {
|
history.pushState = function (...args: Parameters<History["pushState"]>) {
|
||||||
originalPushState.apply(this, args);
|
originalPushState.apply(this, args);
|
||||||
emitNavigateIfChanged(navigationActionPending ? true : undefined);
|
emitNavigateIfChanged(navigationActionPending ? true : undefined);
|
||||||
@@ -922,7 +920,6 @@ export function startRecordCapture(
|
|||||||
originalReplaceState.apply(this, args);
|
originalReplaceState.apply(this, args);
|
||||||
emitNavigateIfChanged(navigationActionPending ? true : undefined);
|
emitNavigateIfChanged(navigationActionPending ? true : undefined);
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dispose() {
|
dispose() {
|
||||||
@@ -936,16 +933,93 @@ export function startRecordCapture(
|
|||||||
document.removeEventListener("compositionend", onCompositionEnd, true);
|
document.removeEventListener("compositionend", onCompositionEnd, true);
|
||||||
document.removeEventListener("change", onChange, true);
|
document.removeEventListener("change", onChange, true);
|
||||||
document.removeEventListener("keydown", onKeyDown, true);
|
document.removeEventListener("keydown", onKeyDown, true);
|
||||||
urlObserver?.disconnect();
|
urlObserver.disconnect();
|
||||||
if (captureNavigation) {
|
|
||||||
window.removeEventListener("hashchange", onUrlEvent);
|
window.removeEventListener("hashchange", onUrlEvent);
|
||||||
window.removeEventListener("popstate", onUrlEvent);
|
window.removeEventListener("popstate", onUrlEvent);
|
||||||
}
|
window.removeEventListener("pagehide", commitFillSession);
|
||||||
window.removeEventListener("pagehide", onPageHide);
|
|
||||||
if (captureNavigation) {
|
|
||||||
history.pushState = originalPushState;
|
history.pushState = originalPushState;
|
||||||
history.replaceState = originalReplaceState;
|
history.replaceState = originalReplaceState;
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RecordContentMessage = RecordStartMessage | RecordStopMessage | RecordCancelMessage;
|
||||||
|
|
||||||
|
export function isRecordContentMessage(msg: unknown): msg is RecordContentMessage {
|
||||||
|
return isRecordStartMessage(msg) || isRecordStopMessage(msg) || isRecordCancelMessage(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleRecordContentMessage(
|
||||||
|
message: RecordContentMessage,
|
||||||
|
state: {
|
||||||
|
activeRequestId: string | null;
|
||||||
|
capture: RecordCaptureController | null;
|
||||||
|
setActiveRequestId(id: string | null): void;
|
||||||
|
setCapture(capture: RecordCaptureController | null): void;
|
||||||
|
onStart(requestId: string, startedAtMs?: number): void;
|
||||||
|
onStop(): void;
|
||||||
|
},
|
||||||
|
sendResponse?: (response: RecordStartAck | RecordStopAck) => void,
|
||||||
|
): boolean {
|
||||||
|
if (isRecordStartMessage(message)) {
|
||||||
|
if (!knownRecordRequests.has(message.requestId)) {
|
||||||
|
knownRecordRequests.add(message.requestId);
|
||||||
|
failedStepDeliveries.delete(message.requestId);
|
||||||
|
}
|
||||||
|
state.capture?.dispose();
|
||||||
|
state.setCapture(
|
||||||
|
startRecordCapture(message.requestId, (step) => {
|
||||||
|
sendRecordStep(message.requestId, step);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
state.setActiveRequestId(message.requestId);
|
||||||
|
state.onStart(message.requestId, message.startedAtMs);
|
||||||
|
sendResponse?.({ ok: true });
|
||||||
|
return sendResponse !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecordStopMessage(message) || isRecordCancelMessage(message)) {
|
||||||
|
// Require an active recording that matches this requestId — otherwise a
|
||||||
|
// stray STOP/CANCEL (e.g. after teardown) would still run finishStop and
|
||||||
|
// clear overlay state even though nothing was capturing.
|
||||||
|
if (!state.activeRequestId || state.activeRequestId !== message.requestId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state.capture?.dispose();
|
||||||
|
state.setCapture(null);
|
||||||
|
const finishStop = () => {
|
||||||
|
state.onStop();
|
||||||
|
state.setActiveRequestId(null);
|
||||||
|
};
|
||||||
|
if (isRecordStopMessage(message) && sendResponse) {
|
||||||
|
const pending = [...(pendingStepSends.get(message.requestId) ?? [])];
|
||||||
|
void Promise.all(pending).then((delivered) => {
|
||||||
|
finishStop();
|
||||||
|
const succeeded = delivered.every(Boolean) && !failedStepDeliveries.has(message.requestId);
|
||||||
|
if (succeeded) {
|
||||||
|
failedStepDeliveries.delete(message.requestId);
|
||||||
|
knownRecordRequests.delete(message.requestId);
|
||||||
|
}
|
||||||
|
sendResponse(
|
||||||
|
succeeded
|
||||||
|
? { ok: true }
|
||||||
|
: {
|
||||||
|
ok: false,
|
||||||
|
error: "failed to deliver one or more recorded steps",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isRecordCancelMessage(message)) {
|
||||||
|
failedStepDeliveries.delete(message.requestId);
|
||||||
|
knownRecordRequests.delete(message.requestId);
|
||||||
|
}
|
||||||
|
finishStop();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RECORD_CANCEL, RECORD_START, RECORD_STEP, RECORD_STOP };
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import {
|
|
||||||
isAcceptedRecordStepAck,
|
|
||||||
RECORD_STEP,
|
|
||||||
type RecordStepMessage,
|
|
||||||
type RecordStepPayload,
|
|
||||||
} from "@/lib/record-bridge";
|
|
||||||
|
|
||||||
export type SendRecordStepMessage = (message: RecordStepMessage) => Promise<unknown>;
|
|
||||||
|
|
||||||
export class RecordStepDelivery {
|
|
||||||
readonly #requestId: string;
|
|
||||||
readonly #producerId: string;
|
|
||||||
readonly #send: SendRecordStepMessage;
|
|
||||||
readonly #pending = new Map<number, RecordStepPayload>();
|
|
||||||
#nextSequence = 1;
|
|
||||||
#tail = Promise.resolve();
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
requestId: string,
|
|
||||||
send: SendRecordStepMessage = (message) => chrome.runtime.sendMessage(message),
|
|
||||||
producerId: string = crypto.randomUUID(),
|
|
||||||
) {
|
|
||||||
this.#requestId = requestId;
|
|
||||||
this.#producerId = producerId;
|
|
||||||
this.#send = send;
|
|
||||||
}
|
|
||||||
|
|
||||||
enqueue(step: RecordStepPayload): void {
|
|
||||||
const sequence = this.#nextSequence;
|
|
||||||
this.#nextSequence += 1;
|
|
||||||
this.#pending.set(sequence, step);
|
|
||||||
void this.#schedule();
|
|
||||||
}
|
|
||||||
|
|
||||||
async flush(): Promise<boolean> {
|
|
||||||
await this.#tail;
|
|
||||||
await this.#schedule();
|
|
||||||
return this.#pending.size === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#schedule(): Promise<void> {
|
|
||||||
const drain = () => this.#drain();
|
|
||||||
this.#tail = this.#tail.then(drain, drain);
|
|
||||||
return this.#tail;
|
|
||||||
}
|
|
||||||
|
|
||||||
async #drain(): Promise<void> {
|
|
||||||
for (const [sequence, step] of this.#pending) {
|
|
||||||
const message: RecordStepMessage = {
|
|
||||||
type: RECORD_STEP,
|
|
||||||
requestId: this.#requestId,
|
|
||||||
producerId: this.#producerId,
|
|
||||||
sequence,
|
|
||||||
step,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const response = await this.#send(message);
|
|
||||||
if (!isAcceptedRecordStepAck(response, sequence)) return;
|
|
||||||
this.#pending.delete(sequence);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import {
|
|
||||||
markRecordingDocument,
|
|
||||||
type RecordingDocumentMarker,
|
|
||||||
waitForDocumentElement,
|
|
||||||
} from "@/lib/recording/document-marker";
|
|
||||||
import {
|
|
||||||
isRecordFrameStartMessage,
|
|
||||||
RECORD_FRAME_PORT,
|
|
||||||
RECORD_FRAME_QUERY,
|
|
||||||
RECORD_FRAME_START,
|
|
||||||
type RecordFramePortMessage,
|
|
||||||
type RecordFrameQueryResponse,
|
|
||||||
type RecordFrameStartMessage,
|
|
||||||
} from "@/lib/recording/frame-bridge";
|
|
||||||
import { type RecordCaptureController, startRecordCapture } from "../record-capture";
|
|
||||||
import { RecordStepDelivery } from "../record-step-delivery";
|
|
||||||
|
|
||||||
interface ActiveFrameRecording {
|
|
||||||
requestId: string;
|
|
||||||
producerId: string;
|
|
||||||
port: chrome.runtime.Port;
|
|
||||||
delivery: RecordStepDelivery;
|
|
||||||
capture: RecordCaptureController;
|
|
||||||
marker: RecordingDocumentMarker;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RECORD_FRAME_REGISTER_TIMEOUT_MS = 5_000;
|
|
||||||
|
|
||||||
export class RecordFrameAgent {
|
|
||||||
#active: ActiveFrameRecording | null = null;
|
|
||||||
#startPromise: Promise<{ ok: true } | { ok: false; error: string }> | null = null;
|
|
||||||
#disposed = false;
|
|
||||||
|
|
||||||
async start(
|
|
||||||
message: RecordFrameStartMessage,
|
|
||||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
||||||
if (this.#active?.requestId === message.requestId) return { ok: true };
|
|
||||||
if (this.#disposed) return { ok: false, error: "recording document is disposed" };
|
|
||||||
if (this.#startPromise) {
|
|
||||||
await this.#startPromise;
|
|
||||||
return this.start(message);
|
|
||||||
}
|
|
||||||
if (this.#active) this.cancel(this.#active.requestId);
|
|
||||||
const starting = this.#start(message);
|
|
||||||
this.#startPromise = starting;
|
|
||||||
try {
|
|
||||||
return await starting;
|
|
||||||
} finally {
|
|
||||||
if (this.#startPromise === starting) this.#startPromise = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel(requestId: string): void {
|
|
||||||
const active = this.#active;
|
|
||||||
if (!active || active.requestId !== requestId) return;
|
|
||||||
active.capture.dispose();
|
|
||||||
active.marker.restore();
|
|
||||||
active.port.disconnect();
|
|
||||||
this.#active = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose(): void {
|
|
||||||
this.#disposed = true;
|
|
||||||
if (this.#active) this.cancel(this.#active.requestId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async #start(
|
|
||||||
message: RecordFrameStartMessage,
|
|
||||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
||||||
const producerId = crypto.randomUUID();
|
|
||||||
const root = await waitForDocumentElement();
|
|
||||||
if (this.#disposed) return { ok: false, error: "recording document is disposed" };
|
|
||||||
const marker = markRecordingDocument(producerId, root);
|
|
||||||
const port = chrome.runtime.connect({ name: RECORD_FRAME_PORT });
|
|
||||||
try {
|
|
||||||
await registerPort(port, message.requestId, producerId);
|
|
||||||
} catch {
|
|
||||||
marker.restore();
|
|
||||||
port.disconnect();
|
|
||||||
return { ok: false, error: "failed to register recording document" };
|
|
||||||
}
|
|
||||||
if (this.#disposed) {
|
|
||||||
marker.restore();
|
|
||||||
port.disconnect();
|
|
||||||
return { ok: false, error: "recording document is disposed" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const delivery = new RecordStepDelivery(message.requestId, undefined, producerId);
|
|
||||||
const capture = startRecordCapture(
|
|
||||||
message.requestId,
|
|
||||||
(step) => {
|
|
||||||
marker.ensure();
|
|
||||||
delivery.enqueue(step);
|
|
||||||
},
|
|
||||||
{ captureNavigation: window.top === window },
|
|
||||||
);
|
|
||||||
const active: ActiveFrameRecording = {
|
|
||||||
requestId: message.requestId,
|
|
||||||
producerId,
|
|
||||||
port,
|
|
||||||
delivery,
|
|
||||||
capture,
|
|
||||||
marker,
|
|
||||||
};
|
|
||||||
this.#active = active;
|
|
||||||
port.onMessage.addListener((raw: unknown) => {
|
|
||||||
const command = raw as Partial<RecordFramePortMessage>;
|
|
||||||
if (command.type === "stop" && command.requestId === active.requestId) {
|
|
||||||
void this.#stop(active, command.commandId);
|
|
||||||
} else if (command.type === "cancel" && command.requestId === active.requestId) {
|
|
||||||
this.cancel(active.requestId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
port.onDisconnect.addListener(() => {
|
|
||||||
if (this.#active !== active) return;
|
|
||||||
active.capture.dispose();
|
|
||||||
active.marker.restore();
|
|
||||||
this.#active = null;
|
|
||||||
});
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
async #stop(active: ActiveFrameRecording, commandId: string | undefined): Promise<void> {
|
|
||||||
if (!commandId || this.#active !== active) return;
|
|
||||||
active.capture.dispose();
|
|
||||||
const succeeded = await active.delivery.flush();
|
|
||||||
try {
|
|
||||||
active.port.postMessage({
|
|
||||||
type: "stopped",
|
|
||||||
requestId: active.requestId,
|
|
||||||
commandId,
|
|
||||||
ok: succeeded,
|
|
||||||
...(succeeded ? {} : { error: "failed to deliver one or more recorded steps" }),
|
|
||||||
} satisfies RecordFramePortMessage);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!succeeded || this.#active !== active) return;
|
|
||||||
active.marker.restore();
|
|
||||||
this.#active = null;
|
|
||||||
active.port.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerPort(
|
|
||||||
port: chrome.runtime.Port,
|
|
||||||
requestId: string,
|
|
||||||
producerId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
cleanup();
|
|
||||||
reject(new Error("recording document registration timed out"));
|
|
||||||
}, RECORD_FRAME_REGISTER_TIMEOUT_MS);
|
|
||||||
const cleanup = () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
port.onMessage.removeListener(onMessage);
|
|
||||||
port.onDisconnect.removeListener(onDisconnect);
|
|
||||||
};
|
|
||||||
const onMessage = (raw: unknown) => {
|
|
||||||
const message = raw as Partial<RecordFramePortMessage>;
|
|
||||||
if (
|
|
||||||
message.type !== "ready_ack" ||
|
|
||||||
message.requestId !== requestId ||
|
|
||||||
message.producerId !== producerId
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cleanup();
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
const onDisconnect = () => {
|
|
||||||
cleanup();
|
|
||||||
reject(new Error("recording document port disconnected"));
|
|
||||||
};
|
|
||||||
port.onMessage.addListener(onMessage);
|
|
||||||
port.onDisconnect.addListener(onDisconnect);
|
|
||||||
port.postMessage({ type: "ready", requestId, producerId } satisfies RecordFramePortMessage);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function attachRecordFrameAgent(): () => void {
|
|
||||||
const agent = new RecordFrameAgent();
|
|
||||||
const onMessage = (
|
|
||||||
message: unknown,
|
|
||||||
_sender: chrome.runtime.MessageSender,
|
|
||||||
sendResponse: (response: { ok: true } | { ok: false; error: string }) => void,
|
|
||||||
) => {
|
|
||||||
if (!isRecordFrameStartMessage(message)) return false;
|
|
||||||
void agent.start(message).then(sendResponse);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
chrome.runtime.onMessage.addListener(onMessage);
|
|
||||||
void chrome.runtime
|
|
||||||
.sendMessage({ type: RECORD_FRAME_QUERY })
|
|
||||||
.then((response: RecordFrameQueryResponse | undefined) => {
|
|
||||||
if (!response?.active || !response.requestId || response.startedAtMs === undefined) return;
|
|
||||||
return agent.start({
|
|
||||||
type: RECORD_FRAME_START,
|
|
||||||
requestId: response.requestId,
|
|
||||||
startedAtMs: response.startedAtMs,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
return () => {
|
|
||||||
chrome.runtime.onMessage.removeListener(onMessage);
|
|
||||||
agent.dispose();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
type OverlayMode,
|
type OverlayMode,
|
||||||
} from "@/lib/overlay-bridge";
|
} from "@/lib/overlay-bridge";
|
||||||
import { POPUP_PORT_NAME, type PopupInbound, type PopupOutbound } from "@/lib/popup-bridge";
|
import { POPUP_PORT_NAME, type PopupInbound, type PopupOutbound } from "@/lib/popup-bridge";
|
||||||
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";
|
||||||
@@ -39,7 +38,6 @@ import {
|
|||||||
attachRecordFinishListener,
|
attachRecordFinishListener,
|
||||||
attachRecordQueryListener,
|
attachRecordQueryListener,
|
||||||
attachRecordStepListener,
|
attachRecordStepListener,
|
||||||
type RecordRuntimeDeps,
|
|
||||||
} from "@/tools/record";
|
} from "@/tools/record";
|
||||||
import { detectBrowserMeta } from "@/transport/handshake";
|
import { detectBrowserMeta } from "@/transport/handshake";
|
||||||
import type { Transport } from "@/transport/transport";
|
import type { Transport } from "@/transport/transport";
|
||||||
@@ -147,33 +145,10 @@ export default defineBackground(() => {
|
|||||||
void sessionsLive.syncFromManager();
|
void sessionsLive.syncFromManager();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const recordDeps = {
|
|
||||||
tabsApi: chrome.tabs,
|
|
||||||
cdp,
|
|
||||||
frameCoordinator: recordFrameCoordinator,
|
|
||||||
sendToTab: (tabId: number, msg: Parameters<typeof chrome.tabs.sendMessage>[1]) =>
|
|
||||||
chrome.tabs.sendMessage(tabId, msg),
|
|
||||||
bypassOverlay: async (tabId: number, enabled: boolean) => {
|
|
||||||
try {
|
|
||||||
await chrome.tabs.sendMessage(tabId, {
|
|
||||||
type: OVERLAY_AUTOMATION_BYPASS,
|
|
||||||
enabled,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Content script may be unavailable on restricted pages.
|
|
||||||
}
|
|
||||||
},
|
|
||||||
} satisfies RecordRuntimeDeps;
|
|
||||||
recordFrameCoordinator.attach();
|
|
||||||
attachRecordStepListener(recordDeps);
|
|
||||||
attachRecordFinishListener(recordDeps);
|
|
||||||
attachRecordQueryListener(recordDeps);
|
|
||||||
|
|
||||||
const dispatcher = new ToolDispatcher({
|
const dispatcher = new ToolDispatcher({
|
||||||
transport,
|
transport,
|
||||||
sessions,
|
sessions,
|
||||||
cdp,
|
cdp,
|
||||||
recording: recordDeps,
|
|
||||||
onSessionsChanged: onOverlaySessionStateChanged,
|
onSessionsChanged: onOverlaySessionStateChanged,
|
||||||
onBrowserControlResumed,
|
onBrowserControlResumed,
|
||||||
approveBorrow: (ctx) =>
|
approveBorrow: (ctx) =>
|
||||||
@@ -195,6 +170,27 @@ export default defineBackground(() => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
dispatcher.start();
|
dispatcher.start();
|
||||||
|
const recordDeps = {
|
||||||
|
tabsApi: chrome.tabs,
|
||||||
|
sendToTab: (tabId: number, msg: Parameters<typeof chrome.tabs.sendMessage>[1]) =>
|
||||||
|
chrome.tabs.sendMessage(tabId, msg),
|
||||||
|
bypassOverlay: async (tabId: number, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
await chrome.tabs.sendMessage(tabId, {
|
||||||
|
type: OVERLAY_AUTOMATION_BYPASS,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Content script may be unavailable on restricted pages.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Message listeners stay up (cheap; fire only for record message types).
|
||||||
|
// Tab / webNavigation observation attaches lazily while a recording is
|
||||||
|
// active — see ensureBrowserObservationListeners in tools/record.ts.
|
||||||
|
attachRecordStepListener();
|
||||||
|
attachRecordFinishListener(recordDeps);
|
||||||
|
attachRecordQueryListener(recordDeps);
|
||||||
if (typeof chrome.notifications?.onClicked?.addListener === "function") {
|
if (typeof chrome.notifications?.onClicked?.addListener === "function") {
|
||||||
attachBorrowNotificationClickHandler({
|
attachBorrowNotificationClickHandler({
|
||||||
onClicked: chrome.notifications.onClicked,
|
onClicked: chrome.notifications.onClicked,
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import { createHelpRequestData } from "@/content/help-request";
|
|||||||
import overlayCss from "@/content/overlay.css?inline";
|
import overlayCss from "@/content/overlay.css?inline";
|
||||||
import { OverlayController, shouldShowAgentControlOverlay } from "@/content/overlay-controller";
|
import { OverlayController, shouldShowAgentControlOverlay } from "@/content/overlay-controller";
|
||||||
import { RecordOverlay } from "@/content/RecordOverlay";
|
import { RecordOverlay } from "@/content/RecordOverlay";
|
||||||
|
import {
|
||||||
|
handleRecordContentMessage,
|
||||||
|
isRecordContentMessage,
|
||||||
|
type RecordCaptureController,
|
||||||
|
} from "@/content/record-capture";
|
||||||
import {
|
import {
|
||||||
type CaptureSuppressAck,
|
type CaptureSuppressAck,
|
||||||
type CaptureSuppressMessage,
|
type CaptureSuppressMessage,
|
||||||
@@ -40,17 +45,11 @@ import {
|
|||||||
} from "@/lib/overlay-bridge";
|
} from "@/lib/overlay-bridge";
|
||||||
import { sendInterrupt } from "@/lib/overlay-interrupt-client";
|
import { sendInterrupt } from "@/lib/overlay-interrupt-client";
|
||||||
import {
|
import {
|
||||||
isRecordCancelMessage,
|
|
||||||
isRecordStartMessage,
|
|
||||||
isRecordStopMessage,
|
|
||||||
RECORD_FINISH,
|
RECORD_FINISH,
|
||||||
RECORD_QUERY,
|
RECORD_QUERY,
|
||||||
type RecordCancelMessage,
|
|
||||||
type RecordQueryResponse,
|
type RecordQueryResponse,
|
||||||
type RecordStartAck,
|
type RecordStartAck,
|
||||||
type RecordStartMessage,
|
|
||||||
type RecordStopAck,
|
type RecordStopAck,
|
||||||
type RecordStopMessage,
|
|
||||||
} from "@/lib/record-bridge";
|
} from "@/lib/record-bridge";
|
||||||
import type {
|
import type {
|
||||||
BorrowCancelMessage,
|
BorrowCancelMessage,
|
||||||
@@ -70,6 +69,7 @@ export default defineContentScript({
|
|||||||
if (window.top !== window) return;
|
if (window.top !== window) return;
|
||||||
|
|
||||||
const overlays = new OverlayController();
|
const overlays = new OverlayController();
|
||||||
|
let recordCapture: RecordCaptureController | null = null;
|
||||||
let activeRecordRequestId: string | null = null;
|
let activeRecordRequestId: string | null = null;
|
||||||
let reactRoot: ReactDOM.Root | null = null;
|
let reactRoot: ReactDOM.Root | null = null;
|
||||||
let overlayHost: HTMLElement | null = null;
|
let overlayHost: HTMLElement | null = null;
|
||||||
@@ -228,6 +228,8 @@ export default defineContentScript({
|
|||||||
if (previousHelp) {
|
if (previousHelp) {
|
||||||
void sendHelpFinish(previousHelp.id, "cancelled");
|
void sendHelpFinish(previousHelp.id, "cancelled");
|
||||||
}
|
}
|
||||||
|
recordCapture?.dispose();
|
||||||
|
recordCapture = null;
|
||||||
activeRecordRequestId = null;
|
activeRecordRequestId = null;
|
||||||
renderAll();
|
renderAll();
|
||||||
}
|
}
|
||||||
@@ -257,9 +259,6 @@ export default defineContentScript({
|
|||||||
| HelpRequestMessage
|
| HelpRequestMessage
|
||||||
| HelpCancelMessage
|
| HelpCancelMessage
|
||||||
| CaptureSuppressMessage
|
| CaptureSuppressMessage
|
||||||
| RecordStartMessage
|
|
||||||
| RecordStopMessage
|
|
||||||
| RecordCancelMessage
|
|
||||||
| OverlayAgentOverlayResetMessage
|
| OverlayAgentOverlayResetMessage
|
||||||
| OverlayAgentStateMessage
|
| OverlayAgentStateMessage
|
||||||
| OverlayAutomationBypassMessage,
|
| OverlayAutomationBypassMessage,
|
||||||
@@ -270,32 +269,41 @@ export default defineContentScript({
|
|||||||
return captureSuppress.handleMessage(message, sendResponse);
|
return captureSuppress.handleMessage(message, sendResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRecordStartMessage(message)) {
|
if (isRecordContentMessage(message)) {
|
||||||
activeRecordRequestId = message.requestId;
|
const needsAsync = handleRecordContentMessage(
|
||||||
|
message,
|
||||||
|
{
|
||||||
|
activeRequestId: activeRecordRequestId,
|
||||||
|
capture: recordCapture,
|
||||||
|
setActiveRequestId: (id) => {
|
||||||
|
activeRecordRequestId = id;
|
||||||
|
},
|
||||||
|
setCapture: (capture) => {
|
||||||
|
recordCapture = capture;
|
||||||
|
},
|
||||||
|
onStart: (requestId, startedAtMs) => {
|
||||||
overlays.setAgentRecordRequest({
|
overlays.setAgentRecordRequest({
|
||||||
id: message.requestId,
|
id: requestId,
|
||||||
...(typeof message.startedAtMs === "number" ? { startedAtMs: message.startedAtMs } : {}),
|
...(typeof startedAtMs === "number" ? { startedAtMs } : {}),
|
||||||
onFinish: () => {
|
onFinish: () => {
|
||||||
void chrome.runtime.sendMessage({
|
void chrome.runtime.sendMessage({
|
||||||
type: RECORD_FINISH,
|
type: RECORD_FINISH,
|
||||||
requestId: message.requestId,
|
requestId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
renderAll();
|
renderAll();
|
||||||
(sendResponse as unknown as (response: RecordStartAck) => void)({ ok: true });
|
},
|
||||||
return false;
|
onStop: () => {
|
||||||
}
|
overlays.clearAgentRecordRequest(activeRecordRequestId ?? undefined);
|
||||||
|
|
||||||
if (isRecordStopMessage(message) || isRecordCancelMessage(message)) {
|
|
||||||
if (activeRecordRequestId !== message.requestId) return false;
|
|
||||||
overlays.clearAgentRecordRequest(message.requestId);
|
|
||||||
activeRecordRequestId = null;
|
|
||||||
renderAll();
|
renderAll();
|
||||||
if (isRecordStopMessage(message)) {
|
},
|
||||||
(sendResponse as unknown as (response: RecordStopAck) => void)({ ok: true });
|
},
|
||||||
}
|
sendResponse as unknown as
|
||||||
return false;
|
| ((response: RecordStartAck | RecordStopAck) => void)
|
||||||
|
| undefined,
|
||||||
|
);
|
||||||
|
return needsAsync;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -518,6 +526,9 @@ export default defineContentScript({
|
|||||||
chrome.runtime.onMessage.removeListener(onMessage);
|
chrome.runtime.onMessage.removeListener(onMessage);
|
||||||
chrome.storage.onChanged.removeListener(onStorageChange);
|
chrome.storage.onChanged.removeListener(onStorageChange);
|
||||||
window.removeEventListener("pageshow", onPageShow);
|
window.removeEventListener("pageshow", onPageShow);
|
||||||
|
// Restore history hooks / remove capture listeners before the CS unloads.
|
||||||
|
recordCapture?.dispose();
|
||||||
|
recordCapture = null;
|
||||||
activeRecordRequestId = null;
|
activeRecordRequestId = null;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -200,9 +200,6 @@ describe("App", () => {
|
|||||||
expect(copied).toContain("--browser 03c3e47f");
|
expect(copied).toContain("--browser 03c3e47f");
|
||||||
expect(copied).toContain('--purpose "发布 wiki 文档"');
|
expect(copied).toContain('--purpose "发布 wiki 文档"');
|
||||||
expect(copied).not.toMatch(/bsk record start[^\n]*--url/);
|
expect(copied).not.toMatch(/bsk record start[^\n]*--url/);
|
||||||
expect(copied).toContain("./trace");
|
|
||||||
expect(copied).toContain("trace.json");
|
|
||||||
expect(copied).toContain("states/");
|
|
||||||
// Button label stays static; a transient toast confirms the copy.
|
// Button label stays static; a transient toast confirms the copy.
|
||||||
expect(copyButton.textContent).toContain("复制录制指令");
|
expect(copyButton.textContent).toContain("复制录制指令");
|
||||||
await waitFor(() => expect(screen.getByRole("status").textContent).toContain("已复制"));
|
await waitFor(() => expect(screen.getByRole("status").textContent).toContain("已复制"));
|
||||||
@@ -247,41 +244,6 @@ 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,7 +79,6 @@ 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 || "—";
|
||||||
@@ -91,7 +90,7 @@ export function App() {
|
|||||||
setCopiedInstanceId(true);
|
setCopiedInstanceId(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const recordReady = connectionLive && Boolean(snapshot.instanceId);
|
const recordReady = statusState === "connected" && 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
|
||||||
@@ -207,12 +206,12 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
{isSkewed && (
|
{isSkewed && (
|
||||||
<p
|
<p
|
||||||
className="mt-2 text-xs leading-snug text-muted-foreground"
|
className="mt-2 text-xs leading-snug text-amber-600 dark:text-amber-400"
|
||||||
data-slot="popup-version-skew-warning"
|
data-slot="popup-version-skew-warning"
|
||||||
>
|
>
|
||||||
{t("popup.versionSkewWarning", {
|
{t("popup.versionSkewWarning", {
|
||||||
extensionProtocol: PROTOCOL_VERSION,
|
extensionProtocol: PROTOCOL_VERSION,
|
||||||
cliProtocol: daemonProtocol,
|
daemonProtocol,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { attachRecordFrameAgent } from "@/content/recording/frame-agent";
|
|
||||||
|
|
||||||
export default defineContentScript({
|
|
||||||
matches: ["<all_urls>"],
|
|
||||||
runAt: "document_start",
|
|
||||||
allFrames: true,
|
|
||||||
matchAboutBlank: true,
|
|
||||||
matchOriginAsFallback: true,
|
|
||||||
|
|
||||||
main(ctx) {
|
|
||||||
const dispose = attachRecordFrameAgent();
|
|
||||||
ctx.onInvalidated(dispose);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -26,20 +26,20 @@ function handshake(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("computeConnectedState (protocol-based compat)", () => {
|
describe("computeConnectedState (protocol-based compat)", () => {
|
||||||
it("returns connected when daemon protocol equals extension protocol", () => {
|
it("returns connected when protocol strings match", () => {
|
||||||
expect(computeConnectedState(handshake("1.1", "1.0"), MIN_COMPATIBLE_PROTOCOL)).toEqual({
|
expect(computeConnectedState(handshake("1.0", "1.0"), MIN_COMPATIBLE_PROTOCOL)).toEqual({
|
||||||
kind: "connected",
|
kind: "connected",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns version_skew when daemon protocol minor is newer", () => {
|
it("returns version_skew when daemon protocol minor is newer", () => {
|
||||||
expect(computeConnectedState(handshake("1.2", "1.0"))).toEqual({
|
expect(computeConnectedState(handshake("1.1", "1.0"))).toEqual({
|
||||||
kind: "version_skew",
|
kind: "version_skew",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns version_skew when daemon protocol string differs but floor is satisfied", () => {
|
it("returns version_skew when daemon protocol string differs but floor is satisfied", () => {
|
||||||
expect(computeConnectedState(handshake("1.1.0", "1.0"))).toEqual({
|
expect(computeConnectedState(handshake("1", "1.0"))).toEqual({
|
||||||
kind: "version_skew",
|
kind: "version_skew",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -53,7 +53,7 @@ describe("computeConnectedState (protocol-based compat)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects when extension is below daemon min_compatible_protocol", () => {
|
it("rejects when extension is below daemon min_compatible_protocol", () => {
|
||||||
const result = computeConnectedState(handshake("1.1", "1.5"));
|
const result = computeConnectedState(handshake("1.0", "1.5"));
|
||||||
expect(result.kind).toBe("rejected");
|
expect(result.kind).toBe("rejected");
|
||||||
if (result.kind === "rejected") {
|
if (result.kind === "rejected") {
|
||||||
expect(result.reason).toContain("min_compatible_protocol");
|
expect(result.reason).toContain("min_compatible_protocol");
|
||||||
@@ -65,7 +65,7 @@ describe("computeConnectedState (protocol-based compat)", () => {
|
|||||||
const result = computeConnectedState({
|
const result = computeConnectedState({
|
||||||
server: "browser-skill-daemon",
|
server: "browser-skill-daemon",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
protocol_version: "1.1",
|
protocol_version: "1.0",
|
||||||
min_compatible_peer: "0.1.0",
|
min_compatible_peer: "0.1.0",
|
||||||
});
|
});
|
||||||
expect(result).toEqual({ kind: "connected" });
|
expect(result).toEqual({ kind: "connected" });
|
||||||
@@ -80,7 +80,7 @@ describe("computeConnectedState (protocol-based compat)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects malformed daemon min_compatible_protocol with a daemon-floor reason", () => {
|
it("rejects malformed daemon min_compatible_protocol with a daemon-floor reason", () => {
|
||||||
const result = computeConnectedState(handshake("1.1", "not-a-protocol"));
|
const result = computeConnectedState(handshake("1.0", "not-a-protocol"));
|
||||||
expect(result.kind).toBe("rejected");
|
expect(result.kind).toBe("rejected");
|
||||||
if (result.kind === "rejected") {
|
if (result.kind === "rejected") {
|
||||||
expect(result.reason).toContain("daemon min_compatible_protocol");
|
expect(result.reason).toContain("daemon min_compatible_protocol");
|
||||||
@@ -242,11 +242,11 @@ describe("ConnectionController connectionEnabled", () => {
|
|||||||
const second = transport.send.mock.calls[1]?.[0] as { id: string };
|
const second = transport.send.mock.calls[1]?.[0] as { id: string };
|
||||||
expect(second.id).not.toBe(first.id);
|
expect(second.id).not.toBe(first.id);
|
||||||
|
|
||||||
transport.emitMessage({ id: first.id, result: handshake("1.1", "1.0") });
|
transport.emitMessage({ id: first.id, result: handshake("1.0", "1.0") });
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
expect(controller.snapshot().state).not.toBe("connected");
|
expect(controller.snapshot().state).not.toBe("connected");
|
||||||
|
|
||||||
transport.emitMessage({ id: second.id, result: handshake("1.1", "1.0") });
|
transport.emitMessage({ id: second.id, result: handshake("1.0", "1.0") });
|
||||||
await vi.waitFor(() => expect(controller.snapshot().state).toBe("connected"));
|
await vi.waitFor(() => expect(controller.snapshot().state).toBe("connected"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { CdpRunner } from "@/tools/shared";
|
|
||||||
import { waitForDocumentSettled } from "../recording/document-settle";
|
|
||||||
|
|
||||||
function quietCdp(): { cdp: CdpRunner; send: ReturnType<typeof vi.fn> } {
|
|
||||||
const send = vi.fn(async () => ({
|
|
||||||
result: { value: { idleMs: 1_000, readyState: "complete" } },
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
cdp: { send: send as unknown as CdpRunner["send"] },
|
|
||||||
send,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("waitForDocumentSettled", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("waits through the minimum observation floor before accepting a quiet page", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const { cdp, send } = quietCdp();
|
|
||||||
|
|
||||||
const settled = waitForDocumentSettled(cdp, { target: { tabId: 7 } });
|
|
||||||
await vi.advanceTimersByTimeAsync(180);
|
|
||||||
|
|
||||||
await expect(settled).resolves.toBe("quiet");
|
|
||||||
expect(send).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cancels before probing a superseded observation", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const { cdp, send } = quietCdp();
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const settled = waitForDocumentSettled(
|
|
||||||
cdp,
|
|
||||||
{ target: { tabId: 7 } },
|
|
||||||
{
|
|
||||||
signal: controller.signal,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
controller.abort();
|
|
||||||
await vi.advanceTimersByTimeAsync(60);
|
|
||||||
|
|
||||||
await expect(settled).resolves.toBe("cancelled");
|
|
||||||
expect(send).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("probes a same-process iframe through its isolated execution context", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const send = vi.fn(async (_tabId: number, method: string) => {
|
|
||||||
if (method === "Page.createIsolatedWorld") return { executionContextId: 91 };
|
|
||||||
return { result: { value: { idleMs: 1_000, readyState: "complete" } } };
|
|
||||||
});
|
|
||||||
const cdp: CdpRunner = { send: send as unknown as CdpRunner["send"] };
|
|
||||||
|
|
||||||
const settled = waitForDocumentSettled(cdp, {
|
|
||||||
target: { tabId: 7 },
|
|
||||||
frameId: "child-frame",
|
|
||||||
});
|
|
||||||
await vi.advanceTimersByTimeAsync(180);
|
|
||||||
|
|
||||||
await expect(settled).resolves.toBe("quiet");
|
|
||||||
expect(send).toHaveBeenCalledWith(
|
|
||||||
7,
|
|
||||||
"Page.createIsolatedWorld",
|
|
||||||
expect.objectContaining({ frameId: "child-frame" }),
|
|
||||||
);
|
|
||||||
expect(send).toHaveBeenCalledWith(
|
|
||||||
7,
|
|
||||||
"Runtime.evaluate",
|
|
||||||
expect.objectContaining({ contextId: 91 }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes an OOPIF probe through its CDP target session", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const send = vi.fn();
|
|
||||||
const sendToTarget = vi.fn(async (_target, method: string) => {
|
|
||||||
if (method === "Page.createIsolatedWorld") return { executionContextId: 27 };
|
|
||||||
return { result: { value: { idleMs: 1_000, readyState: "complete" } } };
|
|
||||||
});
|
|
||||||
const cdp: CdpRunner = {
|
|
||||||
send: send as unknown as CdpRunner["send"],
|
|
||||||
sendToTarget: sendToTarget as unknown as NonNullable<CdpRunner["sendToTarget"]>,
|
|
||||||
};
|
|
||||||
|
|
||||||
const target = { tabId: 7, sessionId: "oopif-session" };
|
|
||||||
const settled = waitForDocumentSettled(cdp, { target, frameId: "oopif-frame" });
|
|
||||||
await vi.advanceTimersByTimeAsync(180);
|
|
||||||
|
|
||||||
await expect(settled).resolves.toBe("quiet");
|
|
||||||
expect(send).not.toHaveBeenCalled();
|
|
||||||
expect(sendToTarget).toHaveBeenCalledWith(
|
|
||||||
target,
|
|
||||||
"Runtime.evaluate",
|
|
||||||
expect.objectContaining({ contextId: 27 }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
isAcceptedRecordStepAck,
|
|
||||||
isRecordStepMessage,
|
|
||||||
RECORD_STEP,
|
|
||||||
type RecordStepMessage,
|
|
||||||
} from "../record-bridge";
|
|
||||||
|
|
||||||
const message: RecordStepMessage = {
|
|
||||||
type: RECORD_STEP,
|
|
||||||
requestId: "rec-1",
|
|
||||||
producerId: "document-1",
|
|
||||||
sequence: 1,
|
|
||||||
step: { op: "click", target: { tag: "button", name: "Submit" } },
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("record step delivery protocol", () => {
|
|
||||||
it("requires a producer and a positive integer sequence", () => {
|
|
||||||
expect(isRecordStepMessage(message)).toBe(true);
|
|
||||||
expect(isRecordStepMessage({ ...message, producerId: "" })).toBe(false);
|
|
||||||
expect(isRecordStepMessage({ ...message, sequence: 0 })).toBe(false);
|
|
||||||
expect(isRecordStepMessage({ ...message, sequence: 1.5 })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts only the acknowledgement for the sent sequence", () => {
|
|
||||||
expect(isAcceptedRecordStepAck({ ok: true, sequence: 2 }, 2)).toBe(true);
|
|
||||||
expect(isAcceptedRecordStepAck({ ok: true, sequence: 1 }, 2)).toBe(false);
|
|
||||||
expect(isAcceptedRecordStepAck({ ok: false, expectedSequence: 2, error: "gap" }, 2)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { RECORD_FRAME_PORT, RECORD_FRAME_QUERY } from "../recording/frame-bridge";
|
|
||||||
import { RecordFrameCoordinator } from "../recording/frame-coordinator";
|
|
||||||
|
|
||||||
class ListenerSet<T extends (...args: never[]) => unknown> {
|
|
||||||
readonly listeners = new Set<T>();
|
|
||||||
addListener = (listener: T) => this.listeners.add(listener);
|
|
||||||
removeListener = (listener: T) => this.listeners.delete(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fakePort(sender: chrome.runtime.MessageSender) {
|
|
||||||
const messages: unknown[] = [];
|
|
||||||
const onMessage = new ListenerSet<(message: unknown) => void>();
|
|
||||||
const onDisconnect = new ListenerSet<() => void>();
|
|
||||||
return {
|
|
||||||
port: {
|
|
||||||
name: RECORD_FRAME_PORT,
|
|
||||||
sender,
|
|
||||||
onMessage,
|
|
||||||
onDisconnect,
|
|
||||||
postMessage: vi.fn((message: unknown) => messages.push(message)),
|
|
||||||
disconnect: vi.fn(),
|
|
||||||
} as unknown as chrome.runtime.Port,
|
|
||||||
messages,
|
|
||||||
receive(message: unknown) {
|
|
||||||
for (const listener of onMessage.listeners) listener(message);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("RecordFrameCoordinator", () => {
|
|
||||||
const onConnect = new ListenerSet<(port: chrome.runtime.Port) => void>();
|
|
||||||
const onMessage = new ListenerSet<
|
|
||||||
(
|
|
||||||
message: unknown,
|
|
||||||
sender: chrome.runtime.MessageSender,
|
|
||||||
sendResponse: (response: unknown) => void,
|
|
||||||
) => boolean
|
|
||||||
>();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
onConnect.listeners.clear();
|
|
||||||
onMessage.listeners.clear();
|
|
||||||
vi.stubGlobal("chrome", {
|
|
||||||
runtime: { onConnect, onMessage },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("answers child-document queries for the initial tab before it is armed", () => {
|
|
||||||
const coordinator = new RecordFrameCoordinator({
|
|
||||||
getAllFrames: async () => [],
|
|
||||||
sendToDocument: async () => ({ ok: true }),
|
|
||||||
});
|
|
||||||
coordinator.attach();
|
|
||||||
coordinator.begin("rec-1", 10, 3);
|
|
||||||
|
|
||||||
const query = (tabId: number) => {
|
|
||||||
let response: unknown;
|
|
||||||
for (const listener of onMessage.listeners) {
|
|
||||||
listener(
|
|
||||||
{ type: RECORD_FRAME_QUERY },
|
|
||||||
{
|
|
||||||
tab: { id: tabId },
|
|
||||||
frameId: 7,
|
|
||||||
documentId: "child-document",
|
|
||||||
} as chrome.runtime.MessageSender,
|
|
||||||
(value) => {
|
|
||||||
response = value;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(query(3)).toEqual({ active: true, requestId: "rec-1", startedAtMs: 10 });
|
|
||||||
expect(query(4)).toEqual({ active: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts a child Document that appears after the initial frame snapshot", async () => {
|
|
||||||
let notifyFrameNavigation:
|
|
||||||
| ((frame: {
|
|
||||||
tabId: number;
|
|
||||||
frameId: number;
|
|
||||||
documentId?: string;
|
|
||||||
lifecycle: "committed" | "completed";
|
|
||||||
}) => void)
|
|
||||||
| undefined;
|
|
||||||
const sendToDocument = vi.fn(async () => ({ ok: true }));
|
|
||||||
const coordinator = new RecordFrameCoordinator({
|
|
||||||
getAllFrames: async () => [{ frameId: 0, documentId: "top-document" }],
|
|
||||||
sendToDocument,
|
|
||||||
subscribeFrameNavigation(listener) {
|
|
||||||
notifyFrameNavigation = listener;
|
|
||||||
return () => {};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
coordinator.attach();
|
|
||||||
const onDocumentReady = vi.fn();
|
|
||||||
coordinator.begin("rec-1", 10, 3, onDocumentReady);
|
|
||||||
await coordinator.armTab("rec-1", 3);
|
|
||||||
sendToDocument.mockClear();
|
|
||||||
|
|
||||||
notifyFrameNavigation?.({
|
|
||||||
tabId: 3,
|
|
||||||
frameId: 7,
|
|
||||||
documentId: "late-child",
|
|
||||||
lifecycle: "completed",
|
|
||||||
});
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(sendToDocument).toHaveBeenCalledWith(
|
|
||||||
3,
|
|
||||||
{ type: "bsk-record-frame-start", requestId: "rec-1", startedAtMs: 10 },
|
|
||||||
{ documentId: "late-child" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
expect(onDocumentReady).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
const child = fakePort({
|
|
||||||
tab: { id: 3 },
|
|
||||||
frameId: 7,
|
|
||||||
documentId: "late-child",
|
|
||||||
} as chrome.runtime.MessageSender);
|
|
||||||
for (const listener of onConnect.listeners) listener(child.port);
|
|
||||||
child.receive({ type: "ready", requestId: "rec-1", producerId: "producer-1" });
|
|
||||||
notifyFrameNavigation?.({
|
|
||||||
tabId: 3,
|
|
||||||
frameId: 7,
|
|
||||||
documentId: "late-child",
|
|
||||||
lifecycle: "completed",
|
|
||||||
});
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(onDocumentReady).toHaveBeenCalledWith({
|
|
||||||
tabId: 3,
|
|
||||||
documentId: "late-child",
|
|
||||||
browserFrameId: 7,
|
|
||||||
producerId: "producer-1",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("binds a producer to its sender Document and keeps final steps valid while stopping", async () => {
|
|
||||||
const sendToDocument = vi.fn(async () => ({ ok: true }));
|
|
||||||
const coordinator = new RecordFrameCoordinator({
|
|
||||||
getAllFrames: async () => [
|
|
||||||
{ frameId: 0, documentId: "top-document" },
|
|
||||||
{ frameId: 7, documentId: "child-document" },
|
|
||||||
],
|
|
||||||
sendToDocument,
|
|
||||||
});
|
|
||||||
coordinator.attach();
|
|
||||||
coordinator.begin("rec-1", 10, 3);
|
|
||||||
await expect(coordinator.armTab("rec-1", 3)).resolves.toBe(true);
|
|
||||||
|
|
||||||
const sender = {
|
|
||||||
tab: { id: 3 },
|
|
||||||
frameId: 7,
|
|
||||||
documentId: "child-document",
|
|
||||||
} as chrome.runtime.MessageSender;
|
|
||||||
const frame = fakePort(sender);
|
|
||||||
for (const listener of onConnect.listeners) listener(frame.port);
|
|
||||||
frame.receive({ type: "ready", requestId: "rec-1", producerId: "producer-1" });
|
|
||||||
|
|
||||||
expect(coordinator.sourceFor("rec-1", "producer-1", sender)).toEqual({
|
|
||||||
tabId: 3,
|
|
||||||
documentId: "child-document",
|
|
||||||
browserFrameId: 7,
|
|
||||||
producerId: "producer-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
const stopping = coordinator.stop("rec-1");
|
|
||||||
expect(coordinator.sourceFor("rec-1", "producer-1", sender)).not.toBeNull();
|
|
||||||
const stop = frame.messages.find(
|
|
||||||
(message): message is { type: "stop"; commandId: string } =>
|
|
||||||
typeof message === "object" &&
|
|
||||||
message !== null &&
|
|
||||||
(message as { type?: string }).type === "stop",
|
|
||||||
);
|
|
||||||
expect(stop).toBeDefined();
|
|
||||||
frame.receive({
|
|
||||||
type: "stopped",
|
|
||||||
requestId: "rec-1",
|
|
||||||
commandId: stop!.commandId,
|
|
||||||
ok: true,
|
|
||||||
});
|
|
||||||
await expect(stopping).resolves.toBe(true);
|
|
||||||
expect(coordinator.sourceFor("rec-1", "producer-1", sender)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a producer used from another Document", async () => {
|
|
||||||
const coordinator = new RecordFrameCoordinator({
|
|
||||||
getAllFrames: async () => [{ frameId: 0, documentId: "top-document" }],
|
|
||||||
sendToDocument: async () => ({ ok: true }),
|
|
||||||
});
|
|
||||||
coordinator.attach();
|
|
||||||
coordinator.begin("rec-1", 10, 3);
|
|
||||||
await coordinator.armTab("rec-1", 3);
|
|
||||||
const sender = {
|
|
||||||
tab: { id: 3 },
|
|
||||||
frameId: 0,
|
|
||||||
documentId: "top-document",
|
|
||||||
} as chrome.runtime.MessageSender;
|
|
||||||
const frame = fakePort(sender);
|
|
||||||
for (const listener of onConnect.listeners) listener(frame.port);
|
|
||||||
frame.receive({ type: "ready", requestId: "rec-1", producerId: "producer-1" });
|
|
||||||
|
|
||||||
expect(
|
|
||||||
coordinator.sourceFor("rec-1", "producer-1", {
|
|
||||||
...sender,
|
|
||||||
documentId: "other-document",
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
RECORD_DOCUMENT_ATTRIBUTE,
|
|
||||||
recordingDocumentMarkerValue,
|
|
||||||
} from "@/shared/recording-document-identity";
|
|
||||||
import { markRecordingDocument } from "../recording/document-marker";
|
|
||||||
import { ObservationNodeIndex } from "../recording/observation-capture";
|
|
||||||
|
|
||||||
describe("recording document marker", () => {
|
|
||||||
it("rejects a non-random Document identity", () => {
|
|
||||||
expect(() => markRecordingDocument("user@example.com")).toThrow(
|
|
||||||
"recording Document identity must be a random UUID",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("restores the Document attribute exactly", () => {
|
|
||||||
document.documentElement.setAttribute(RECORD_DOCUMENT_ATTRIBUTE, "page-value");
|
|
||||||
const producerId = "123e4567-e89b-42d3-a456-426614174000";
|
|
||||||
const marker = markRecordingDocument(producerId);
|
|
||||||
|
|
||||||
expect(document.documentElement.getAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe(
|
|
||||||
recordingDocumentMarkerValue(producerId),
|
|
||||||
);
|
|
||||||
marker.restore();
|
|
||||||
expect(document.documentElement.getAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe("page-value");
|
|
||||||
document.documentElement.removeAttribute(RECORD_DOCUMENT_ATTRIBUTE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("indexes the CDP scope from the safe frame identity", () => {
|
|
||||||
const index = new ObservationNodeIndex({
|
|
||||||
rootFrameId: "root",
|
|
||||||
frames: [
|
|
||||||
{
|
|
||||||
frameId: "child",
|
|
||||||
target: { tabId: 3, sessionId: "oopif-session" },
|
|
||||||
recordingDocumentId: "producer-1",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
matchNodes: [],
|
|
||||||
refs: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(index.documentScope("producer-1")).toEqual({
|
|
||||||
frameId: "child",
|
|
||||||
target: { tabId: 3, sessionId: "oopif-session" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fails closed when a recording identity maps to multiple Documents", () => {
|
|
||||||
const index = new ObservationNodeIndex({
|
|
||||||
rootFrameId: "root",
|
|
||||||
frames: [
|
|
||||||
{
|
|
||||||
frameId: "left",
|
|
||||||
target: { tabId: 3, sessionId: "left-session" },
|
|
||||||
recordingDocumentId: "duplicate",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
frameId: "right",
|
|
||||||
target: { tabId: 3, sessionId: "right-session" },
|
|
||||||
recordingDocumentId: "duplicate",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
matchNodes: [],
|
|
||||||
refs: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(index.documentScope("duplicate")).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { ObservationNodeIndex } from "../recording/observation-capture";
|
|
||||||
import { RecordingObservationSession } from "../recording/observation-session";
|
|
||||||
import { RecordingStateRegistry } from "../recording/state-registry";
|
|
||||||
import { buildTraceV3 } from "../recording/trace-builder-v3";
|
|
||||||
import type { RecordingDraftStep } from "../recording/types";
|
|
||||||
|
|
||||||
const URL = "https://example.com/login";
|
|
||||||
|
|
||||||
function sessionWithInput(redactValues = false): RecordingObservationSession {
|
|
||||||
const session = new RecordingObservationSession({ redactValues });
|
|
||||||
const state = session.registry.register({
|
|
||||||
url: URL,
|
|
||||||
vomText: '@vom 1\ntextbox "Password" value="••••••" [ref=e1]',
|
|
||||||
});
|
|
||||||
session.cursor.lastSettled = {
|
|
||||||
stateId: state.id,
|
|
||||||
rootFrameId: "root",
|
|
||||||
index: new ObservationNodeIndex({
|
|
||||||
rootFrameId: "root",
|
|
||||||
matchNodes: [
|
|
||||||
{
|
|
||||||
frameId: "root",
|
|
||||||
backendNodeId: 42,
|
|
||||||
tag: "input",
|
|
||||||
rect: { x: 20, y: 40, w: 200, h: 30 },
|
|
||||||
localRect: { x: 20, y: 40, w: 200, h: 30 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
refs: [{ ref: "e1", backendNodeId: 42, role: "textbox", name: "Password", line: 1 }],
|
|
||||||
}),
|
|
||||||
url: URL,
|
|
||||||
};
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
function finalizedFillTrace(value: string, redactValues: boolean) {
|
|
||||||
const session = sessionWithInput(redactValues);
|
|
||||||
const draft: RecordingDraftStep = {
|
|
||||||
op: "fill",
|
|
||||||
captureTarget: { tag: "input", role: "textbox", name: "Password" },
|
|
||||||
value,
|
|
||||||
targetHint: {
|
|
||||||
geometry: { rect: { x: 20, y: 40, w: 200, h: 30 }, tag: "input" },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
session.bindDraft(draft, 1);
|
|
||||||
draft.postStateId = draft.preStateId;
|
|
||||||
return buildTraceV3({
|
|
||||||
registry: session.registry,
|
|
||||||
drafts: [draft],
|
|
||||||
annotations: session.annotations,
|
|
||||||
startedAt: "2026-08-12T00:00:00.000Z",
|
|
||||||
stoppedBy: "user_finish",
|
|
||||||
bskVersion: "test",
|
|
||||||
redactValues,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function finalizedFillBody(value: string, redactValues: boolean): string {
|
|
||||||
return finalizedFillTrace(value, redactValues).states[0]!.body;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("record observation annotations", () => {
|
|
||||||
it("omits a fill literal when values are redacted", () => {
|
|
||||||
const secret = "hunter2-private";
|
|
||||||
const dumped = JSON.stringify(finalizedFillTrace(secret, true));
|
|
||||||
expect(dumped).toContain("step 1: fill");
|
|
||||||
expect(dumped).not.toContain(secret);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps ordinary fill details", () => {
|
|
||||||
expect(finalizedFillBody("ordinary text", false)).toContain('step 1: fill: "ordinary text"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("encodes title and URL so line breaks cannot corrupt state metadata", () => {
|
|
||||||
const registry = new RecordingStateRegistry();
|
|
||||||
const state = registry.register({
|
|
||||||
url: "https://example.com/a\nb",
|
|
||||||
title: "hello\nworld",
|
|
||||||
vomText: "@vom 1",
|
|
||||||
});
|
|
||||||
const trace = buildTraceV3({
|
|
||||||
registry,
|
|
||||||
drafts: [{ op: "scroll", preStateId: state.id, postStateId: state.id }],
|
|
||||||
startedAt: "2026-08-12T00:00:00.000Z",
|
|
||||||
stoppedBy: "user_finish",
|
|
||||||
bskVersion: "test",
|
|
||||||
});
|
|
||||||
expect(trace.states[0]?.body).toContain('url: "https://example.com/a\\nb"');
|
|
||||||
expect(trace.states[0]?.body).toContain('title: "hello\\nworld"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("recording state ownership", () => {
|
|
||||||
it("deduplicates within one recording and isolates ids between recordings", () => {
|
|
||||||
const first = new RecordingStateRegistry();
|
|
||||||
const second = new RecordingStateRegistry();
|
|
||||||
expect(first.register({ url: URL, vomText: "same" }).id).toBe("s1");
|
|
||||||
expect(first.register({ url: URL, vomText: "same" }).id).toBe("s1");
|
|
||||||
expect(second.register({ url: URL, vomText: "other" }).id).toBe("s1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("enriches metadata when a deduplicated observation becomes more complete", () => {
|
|
||||||
const registry = new RecordingStateRegistry();
|
|
||||||
registry.register({ url: URL, vomText: "same" });
|
|
||||||
const state = registry.register({
|
|
||||||
url: URL,
|
|
||||||
title: "Login",
|
|
||||||
vomText: "same",
|
|
||||||
truncated: true,
|
|
||||||
});
|
|
||||||
expect(state).toMatchObject({ id: "s1", title: "Login", truncated: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("draft binding", () => {
|
|
||||||
it("keeps capture semantics when no observation exists", () => {
|
|
||||||
const session = new RecordingObservationSession();
|
|
||||||
const draft: RecordingDraftStep = {
|
|
||||||
op: "hover",
|
|
||||||
captureTarget: { tag: "button", role: "button", name: "新建" },
|
|
||||||
};
|
|
||||||
session.bindDraft(draft, 1);
|
|
||||||
expect(draft.matchedTarget).toEqual({ role: "button", name: "新建", unmatched: true });
|
|
||||||
expect(draft.preStateId).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not bind an unmatched new action to a stale observation", () => {
|
|
||||||
const session = sessionWithInput();
|
|
||||||
const draft: RecordingDraftStep = {
|
|
||||||
op: "click",
|
|
||||||
captureTarget: { tag: "button", role: "button", name: "Confirm" },
|
|
||||||
targetHint: {
|
|
||||||
geometry: { rect: { x: 400, y: 300, w: 80, h: 30 }, tag: "button" },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
session.bindDraft(draft, 2, true);
|
|
||||||
expect(draft.preStateId).toBeUndefined();
|
|
||||||
expect("matchedTarget" in draft ? draft.matchedTarget : undefined).toEqual({
|
|
||||||
role: "button",
|
|
||||||
name: "Confirm",
|
|
||||||
unmatched: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { CdpRunner, ChromeTabsApi } from "@/tools/shared";
|
|
||||||
|
|
||||||
const captureRecordingObservation = vi.hoisted(() => vi.fn());
|
|
||||||
const waitForDocumentSettled = vi.hoisted(() => vi.fn(async () => "quiet" as const));
|
|
||||||
|
|
||||||
vi.mock("../recording/observation-capture", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof import("../recording/observation-capture")>();
|
|
||||||
return { ...actual, captureRecordingObservation };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("../recording/document-settle", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof import("../recording/document-settle")>();
|
|
||||||
return { ...actual, waitForDocumentSettled };
|
|
||||||
});
|
|
||||||
|
|
||||||
import { ObservationNodeIndex } from "../recording/observation-capture";
|
|
||||||
import { RecordingObservationRuntime } from "../recording/recording-runtime";
|
|
||||||
import type { RecordingDraftStep } from "../recording/types";
|
|
||||||
|
|
||||||
function observation(url = "https://example.com/") {
|
|
||||||
return {
|
|
||||||
rootFrameId: "root",
|
|
||||||
index: new ObservationNodeIndex({ rootFrameId: "root", matchNodes: [], refs: [] }),
|
|
||||||
url,
|
|
||||||
title: "Example",
|
|
||||||
vomText: '@vom 1\nRootWebArea "Example"',
|
|
||||||
truncated: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function runtime(): RecordingObservationRuntime {
|
|
||||||
return new RecordingObservationRuntime({
|
|
||||||
cdp: { send: vi.fn() as unknown as CdpRunner["send"] },
|
|
||||||
tabsApi: { get: vi.fn(), query: vi.fn() } as unknown as ChromeTabsApi,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("RecordingObservationRuntime", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
captureRecordingObservation.mockReset();
|
|
||||||
waitForDocumentSettled.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shares one initial capture and includes it in flush", async () => {
|
|
||||||
let release!: (value: ReturnType<typeof observation>) => void;
|
|
||||||
captureRecordingObservation.mockReturnValueOnce(
|
|
||||||
new Promise((resolve) => {
|
|
||||||
release = resolve;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const recording = runtime();
|
|
||||||
|
|
||||||
const first = recording.captureInitial(7);
|
|
||||||
const second = recording.captureInitial(7);
|
|
||||||
const flushed = recording.flush();
|
|
||||||
let flushCompleted = false;
|
|
||||||
void flushed.then(() => {
|
|
||||||
flushCompleted = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.resolve();
|
|
||||||
expect(captureRecordingObservation).toHaveBeenCalledTimes(1);
|
|
||||||
expect(flushCompleted).toBe(false);
|
|
||||||
|
|
||||||
release(observation());
|
|
||||||
await Promise.all([first, second, flushed]);
|
|
||||||
expect(flushCompleted).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cancels an in-flight initial capture", async () => {
|
|
||||||
captureRecordingObservation.mockImplementationOnce(
|
|
||||||
(input: { signal?: AbortSignal }) =>
|
|
||||||
new Promise((_, reject) => {
|
|
||||||
input.signal?.addEventListener(
|
|
||||||
"abort",
|
|
||||||
() => reject(new DOMException("observation aborted", "AbortError")),
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const recording = runtime();
|
|
||||||
|
|
||||||
const pending = recording.captureInitial(7);
|
|
||||||
recording.cancel();
|
|
||||||
|
|
||||||
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("retries a failed initial capture when the recording settles at stop", async () => {
|
|
||||||
captureRecordingObservation
|
|
||||||
.mockRejectedValueOnce(new Error("document swapped"))
|
|
||||||
.mockResolvedValueOnce(observation());
|
|
||||||
const recording = runtime();
|
|
||||||
|
|
||||||
await expect(recording.captureInitial(7)).rejects.toThrow("document swapped");
|
|
||||||
await recording.settleTrailing(7, []);
|
|
||||||
const trace = recording.buildTrace({
|
|
||||||
drafts: [],
|
|
||||||
startedAt: "2026-08-19T00:00:00.000Z",
|
|
||||||
stoppedBy: "user_finish",
|
|
||||||
bskVersion: "0.1.6",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(captureRecordingObservation).toHaveBeenCalledTimes(2);
|
|
||||||
expect(trace.states).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps runtime matching data out of the serialized trace", async () => {
|
|
||||||
const captured = observation();
|
|
||||||
captured.index = new ObservationNodeIndex({
|
|
||||||
rootFrameId: "root",
|
|
||||||
matchNodes: [
|
|
||||||
{
|
|
||||||
frameId: "root",
|
|
||||||
backendNodeId: 42,
|
|
||||||
tag: "button",
|
|
||||||
rect: { x: 10, y: 20, w: 100, h: 30 },
|
|
||||||
localRect: { x: 10, y: 20, w: 100, h: 30 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
refs: [{ ref: "e1", backendNodeId: 42, role: "button", name: "Submit", line: 1 }],
|
|
||||||
});
|
|
||||||
captureRecordingObservation.mockResolvedValueOnce(captured);
|
|
||||||
const recording = runtime();
|
|
||||||
|
|
||||||
await recording.captureInitial(7);
|
|
||||||
const dumped = JSON.stringify(
|
|
||||||
recording.buildTrace({
|
|
||||||
drafts: [],
|
|
||||||
startedAt: "2026-08-19T00:00:00.000Z",
|
|
||||||
stoppedBy: "user_finish",
|
|
||||||
bskVersion: "0.1.6",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(dumped).toContain("RootWebArea");
|
|
||||||
expect(dumped).not.toContain("matchNodes");
|
|
||||||
expect(dumped).not.toContain("localRect");
|
|
||||||
expect(dumped).not.toContain("backendNodeId");
|
|
||||||
expect(dumped).not.toContain("ObservationNodeIndex");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("captures a tab transition without sharing observation cursors", async () => {
|
|
||||||
captureRecordingObservation
|
|
||||||
.mockResolvedValueOnce(observation("https://example.com/first"))
|
|
||||||
.mockResolvedValueOnce(observation("https://example.com/second"));
|
|
||||||
const recording = runtime();
|
|
||||||
|
|
||||||
await recording.captureInitial(4);
|
|
||||||
const transition = await recording.captureTabTransition(4, 5);
|
|
||||||
await recording.captureInitial(4);
|
|
||||||
await recording.captureInitial(5);
|
|
||||||
|
|
||||||
expect(transition).toEqual({
|
|
||||||
preStateId: "s1",
|
|
||||||
postStateId: "s2",
|
|
||||||
targetUrl: "https://example.com/second",
|
|
||||||
});
|
|
||||||
expect(captureRecordingObservation.mock.calls.map(([input]) => input.tabId)).toEqual([4, 5]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("binds an iframe draft to its marked CDP Document scope", async () => {
|
|
||||||
captureRecordingObservation.mockResolvedValueOnce({
|
|
||||||
...observation(),
|
|
||||||
index: new ObservationNodeIndex({
|
|
||||||
rootFrameId: "root",
|
|
||||||
frames: [
|
|
||||||
{
|
|
||||||
frameId: "child",
|
|
||||||
target: { tabId: 7, sessionId: "oopif-session" },
|
|
||||||
recordingDocumentId: "producer-1",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
matchNodes: [
|
|
||||||
{
|
|
||||||
backendNodeId: 42,
|
|
||||||
frameId: "child",
|
|
||||||
tag: "button",
|
|
||||||
rect: { x: 410, y: 20, w: 100, h: 30 },
|
|
||||||
localRect: { x: 10, y: 20, w: 100, h: 30 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
refs: [{ ref: "e1", backendNodeId: 42, frameId: "child", line: 1 }],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const recording = runtime();
|
|
||||||
const drafts: RecordingDraftStep[] = [
|
|
||||||
{
|
|
||||||
op: "click" as const,
|
|
||||||
captureTarget: { tag: "button", role: "button", name: "Save" },
|
|
||||||
targetHint: {
|
|
||||||
geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
await recording.captureInitial(7);
|
|
||||||
await recording.processDraft(7, drafts, 0, "producer-1");
|
|
||||||
recording.cancel();
|
|
||||||
|
|
||||||
const draft = drafts[0];
|
|
||||||
expect(draft?.op).toBe("click");
|
|
||||||
if (!draft || draft.op !== "click") throw new Error("expected click draft");
|
|
||||||
expect(draft.targetHint).toMatchObject({
|
|
||||||
frameId: "child",
|
|
||||||
geometrySpace: "local",
|
|
||||||
});
|
|
||||||
expect(draft.matchedTarget?.ref).toBe("e1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refreshes a late iframe before matching its first action", async () => {
|
|
||||||
const iframeObservation = {
|
|
||||||
...observation(),
|
|
||||||
index: new ObservationNodeIndex({
|
|
||||||
rootFrameId: "root",
|
|
||||||
frames: [
|
|
||||||
{
|
|
||||||
frameId: "child",
|
|
||||||
target: { tabId: 7, sessionId: "oopif-session" },
|
|
||||||
recordingDocumentId: "producer-1",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
matchNodes: [
|
|
||||||
{
|
|
||||||
backendNodeId: 42,
|
|
||||||
frameId: "child",
|
|
||||||
tag: "button",
|
|
||||||
rect: { x: 410, y: 20, w: 100, h: 30 },
|
|
||||||
localRect: { x: 10, y: 20, w: 100, h: 30 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
refs: [
|
|
||||||
{
|
|
||||||
ref: "e1",
|
|
||||||
backendNodeId: 42,
|
|
||||||
frameId: "child",
|
|
||||||
role: "button",
|
|
||||||
name: "表格视图",
|
|
||||||
line: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
captureRecordingObservation
|
|
||||||
.mockResolvedValueOnce(observation())
|
|
||||||
.mockResolvedValueOnce(iframeObservation)
|
|
||||||
.mockResolvedValueOnce(iframeObservation);
|
|
||||||
const recording = runtime();
|
|
||||||
|
|
||||||
await recording.captureInitial(7);
|
|
||||||
await recording.refreshDocument(7, "producer-1");
|
|
||||||
const drafts: RecordingDraftStep[] = [
|
|
||||||
{
|
|
||||||
op: "click",
|
|
||||||
captureTarget: { tag: "button", role: "button", name: "表格视图" },
|
|
||||||
targetHint: {
|
|
||||||
geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
await recording.processDraft(7, drafts, 0, "producer-1");
|
|
||||||
recording.cancel();
|
|
||||||
|
|
||||||
expect(captureRecordingObservation).toHaveBeenCalledTimes(3);
|
|
||||||
expect(waitForDocumentSettled).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
{ frameId: "child", target: { tabId: 7, sessionId: "oopif-session" } },
|
|
||||||
{ signal: expect.any(AbortSignal) },
|
|
||||||
);
|
|
||||||
const draft = drafts[0];
|
|
||||||
expect(draft?.op).toBe("click");
|
|
||||||
if (!draft || draft.op !== "click") throw new Error("expected click draft");
|
|
||||||
expect(draft.matchedTarget).toMatchObject({ ref: "e1", name: "表格视图" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not poison final flush when a frame readiness refresh fails", async () => {
|
|
||||||
captureRecordingObservation
|
|
||||||
.mockResolvedValueOnce(observation())
|
|
||||||
.mockRejectedValueOnce(new Error("child Document replaced"));
|
|
||||||
const recording = runtime();
|
|
||||||
|
|
||||||
await recording.captureInitial(7);
|
|
||||||
await expect(recording.refreshDocument(7, "producer-1")).rejects.toThrow(
|
|
||||||
"child Document replaced",
|
|
||||||
);
|
|
||||||
await expect(recording.flush()).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { appendRecordedPayload, observeRecordedNavigation } from "../recording-step-buffer";
|
||||||
|
|
||||||
|
describe("recording-step-buffer", () => {
|
||||||
|
it("stores semantic click without summary", () => {
|
||||||
|
const buffer = { steps: [], pendingNavigation: false };
|
||||||
|
appendRecordedPayload(buffer, {
|
||||||
|
op: "click",
|
||||||
|
target: { tag: "button", role: "button", name: "发布" },
|
||||||
|
expects_navigation: true,
|
||||||
|
});
|
||||||
|
expect(buffer.steps).toEqual([
|
||||||
|
{
|
||||||
|
op: "click",
|
||||||
|
target: { tag: "button", role: "button", name: "发布" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(buffer.pendingNavigation).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("annotates navigated_to on action-caused navigation instead of wait_for_navigation", () => {
|
||||||
|
const buffer = {
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
op: "click" as const,
|
||||||
|
target: { tag: "button", role: "button", name: "发布" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
currentUrl: "https://example.com/a",
|
||||||
|
pendingNavigation: true,
|
||||||
|
pendingNavigationDeadline: Date.now() + 5_000,
|
||||||
|
};
|
||||||
|
observeRecordedNavigation(buffer, "https://example.com/b", true);
|
||||||
|
expect(buffer.steps).toEqual([
|
||||||
|
{
|
||||||
|
op: "click",
|
||||||
|
target: { tag: "button", role: "button", name: "发布" },
|
||||||
|
navigated_to: "https://example.com/b",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(buffer.steps)).not.toContain("wait_for_navigation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("annotates navigated_to onto a select that auto-submits a navigation", () => {
|
||||||
|
const buffer = {
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
op: "select" as const,
|
||||||
|
target: { tag: "select", role: "combobox", name: "分类" },
|
||||||
|
values: ["tech"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
currentUrl: "https://example.com/list",
|
||||||
|
pendingNavigation: true,
|
||||||
|
pendingNavigationDeadline: Date.now() + 5_000,
|
||||||
|
};
|
||||||
|
observeRecordedNavigation(buffer, "https://example.com/list?cat=tech", true);
|
||||||
|
expect(buffer.steps).toEqual([
|
||||||
|
{
|
||||||
|
op: "select",
|
||||||
|
target: { tag: "select", role: "combobox", name: "分类" },
|
||||||
|
values: ["tech"],
|
||||||
|
navigated_to: "https://example.com/list?cat=tech",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits navigate for uncaused URL changes", () => {
|
||||||
|
const buffer = {
|
||||||
|
steps: [],
|
||||||
|
currentUrl: "https://example.com/a",
|
||||||
|
pendingNavigation: false,
|
||||||
|
};
|
||||||
|
observeRecordedNavigation(buffer, "https://example.com/b", false);
|
||||||
|
expect(buffer.steps[0]).toMatchObject({
|
||||||
|
op: "navigate",
|
||||||
|
url: "https://example.com/b",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { CdpRunner, ChromeTabsApi } from "@/tools/shared";
|
|
||||||
import { ObservationNodeIndex, type RegisteredObservation } from "../recording/observation-capture";
|
|
||||||
import { RecordingObservationSession } from "../recording/observation-session";
|
|
||||||
import { SettleController } from "../recording/settle-controller";
|
|
||||||
import type { RecordingDraftStep } from "../recording/types";
|
|
||||||
|
|
||||||
const OBSERVATION: RegisteredObservation = {
|
|
||||||
stateId: "s-next",
|
|
||||||
rootFrameId: "root",
|
|
||||||
index: new ObservationNodeIndex({ rootFrameId: "root", matchNodes: [], refs: [] }),
|
|
||||||
url: "https://example.com/next",
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("SettleController", () => {
|
|
||||||
afterEach(() => vi.useRealTimers());
|
|
||||||
|
|
||||||
it("aborts superseded capture work and lands the prior action on the next origin", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const cdp: CdpRunner = {
|
|
||||||
send: vi.fn(async () => ({
|
|
||||||
result: { value: { idleMs: 1_000, readyState: "complete" } },
|
|
||||||
})) as unknown as CdpRunner["send"],
|
|
||||||
};
|
|
||||||
const tabsApi: ChromeTabsApi = {
|
|
||||||
get: vi.fn(async () => ({ id: 7, url: OBSERVATION.url }) as chrome.tabs.Tab),
|
|
||||||
query: vi.fn(async () => []),
|
|
||||||
};
|
|
||||||
const session = new RecordingObservationSession();
|
|
||||||
let firstSignal: AbortSignal | undefined;
|
|
||||||
vi.spyOn(session, "capture")
|
|
||||||
.mockImplementationOnce(async (_cdp, _tabs, _tabId, signal) => {
|
|
||||||
firstSignal = signal;
|
|
||||||
return new Promise<RegisteredObservation>((_resolve, reject) => {
|
|
||||||
signal?.addEventListener(
|
|
||||||
"abort",
|
|
||||||
() => reject(new DOMException("observation aborted", "AbortError")),
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce(OBSERVATION);
|
|
||||||
|
|
||||||
const drafts: RecordingDraftStep[] = [
|
|
||||||
{ op: "click", captureTarget: { tag: "button", name: "First" }, preStateId: "s1" },
|
|
||||||
];
|
|
||||||
const controller = new SettleController({ session, cdp, tabsApi, tabId: 7 });
|
|
||||||
controller.schedule(drafts, 0);
|
|
||||||
await vi.advanceTimersByTimeAsync(180);
|
|
||||||
|
|
||||||
drafts.push({
|
|
||||||
op: "click",
|
|
||||||
captureTarget: { tag: "button", name: "Second" },
|
|
||||||
preStateId: "s-next",
|
|
||||||
});
|
|
||||||
controller.schedule(drafts, 1);
|
|
||||||
expect(firstSignal?.aborted).toBe(true);
|
|
||||||
expect(drafts[0]?.postStateId).toBe("s-next");
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
|
||||||
await controller.flush();
|
|
||||||
expect(drafts[1]?.postStateId).toBe("s-next");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not consume a newer redirect while reading the prior landing URL", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const cdp: CdpRunner = {
|
|
||||||
send: vi.fn(async () => ({
|
|
||||||
result: { value: { idleMs: 1_000, readyState: "complete" } },
|
|
||||||
})) as unknown as CdpRunner["send"],
|
|
||||||
};
|
|
||||||
let resolveFirstTab!: (tab: chrome.tabs.Tab) => void;
|
|
||||||
const firstTab = new Promise<chrome.tabs.Tab>((resolve) => {
|
|
||||||
resolveFirstTab = resolve;
|
|
||||||
});
|
|
||||||
const tabsApi: ChromeTabsApi = {
|
|
||||||
get: vi
|
|
||||||
.fn()
|
|
||||||
.mockImplementationOnce(() => firstTab)
|
|
||||||
.mockResolvedValue({ id: 7, url: OBSERVATION.url }),
|
|
||||||
query: vi.fn(async () => []),
|
|
||||||
};
|
|
||||||
const session = new RecordingObservationSession();
|
|
||||||
session.cursor.lastSettled = OBSERVATION;
|
|
||||||
const drafts: RecordingDraftStep[] = [];
|
|
||||||
const controller = new SettleController({ session, cdp, tabsApi, tabId: 7 });
|
|
||||||
|
|
||||||
controller.scheduleRedirect(drafts, "https://example.com/intermediate");
|
|
||||||
await vi.advanceTimersByTimeAsync(180);
|
|
||||||
expect(tabsApi.get).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
controller.scheduleRedirect(drafts, OBSERVATION.url);
|
|
||||||
resolveFirstTab({ id: 7, url: "https://example.com/intermediate" } as chrome.tabs.Tab);
|
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
|
||||||
await controller.flushRedirects();
|
|
||||||
|
|
||||||
expect(drafts).toEqual([]);
|
|
||||||
expect(controller.hasPending).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("settles an action in its own OOPIF document scope", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const send = vi.fn();
|
|
||||||
const sendToTarget = vi.fn(async (_target, method: string) => {
|
|
||||||
if (method === "Page.createIsolatedWorld") return { executionContextId: 39 };
|
|
||||||
return { result: { value: { idleMs: 1_000, readyState: "complete" } } };
|
|
||||||
});
|
|
||||||
const cdp: CdpRunner = {
|
|
||||||
send: send as unknown as CdpRunner["send"],
|
|
||||||
sendToTarget: sendToTarget as unknown as NonNullable<CdpRunner["sendToTarget"]>,
|
|
||||||
};
|
|
||||||
const tabsApi: ChromeTabsApi = {
|
|
||||||
get: vi.fn(async () => ({ id: 7, url: OBSERVATION.url }) as chrome.tabs.Tab),
|
|
||||||
query: vi.fn(async () => []),
|
|
||||||
};
|
|
||||||
const session = new RecordingObservationSession();
|
|
||||||
vi.spyOn(session, "capture").mockResolvedValue(OBSERVATION);
|
|
||||||
const drafts: RecordingDraftStep[] = [
|
|
||||||
{ op: "click", captureTarget: { tag: "button", name: "Inside frame" } },
|
|
||||||
];
|
|
||||||
const controller = new SettleController({ session, cdp, tabsApi, tabId: 7 });
|
|
||||||
const target = { tabId: 7, sessionId: "oopif-session" };
|
|
||||||
|
|
||||||
controller.schedule(drafts, 0, { target, frameId: "oopif-frame" });
|
|
||||||
await vi.advanceTimersByTimeAsync(180);
|
|
||||||
await controller.flush();
|
|
||||||
|
|
||||||
expect(send).not.toHaveBeenCalled();
|
|
||||||
expect(sendToTarget).toHaveBeenCalledWith(
|
|
||||||
target,
|
|
||||||
"Runtime.evaluate",
|
|
||||||
expect.objectContaining({ contextId: 39 }),
|
|
||||||
);
|
|
||||||
expect(drafts[0]?.postStateId).toBe("s-next");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { appendRecordedPayload, observeRecordedNavigation } from "../recording/step-buffer";
|
|
||||||
|
|
||||||
describe("recording-step-buffer", () => {
|
|
||||||
it("stores semantic click without summary", () => {
|
|
||||||
const buffer = { steps: [], navigation: { pendingNavigation: false } };
|
|
||||||
appendRecordedPayload(buffer, {
|
|
||||||
op: "click",
|
|
||||||
target: { tag: "button", role: "button", name: "发布" },
|
|
||||||
expects_navigation: true,
|
|
||||||
});
|
|
||||||
expect(buffer.steps).toEqual([
|
|
||||||
{
|
|
||||||
op: "click",
|
|
||||||
captureTarget: { tag: "button", role: "button", name: "发布" },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(buffer.navigation.pendingNavigation).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the hovered element description as a capture fallback", () => {
|
|
||||||
const buffer = { steps: [], navigation: { pendingNavigation: false } };
|
|
||||||
appendRecordedPayload(
|
|
||||||
buffer,
|
|
||||||
{
|
|
||||||
op: "hover",
|
|
||||||
target: { tag: "button", role: "button", name: "新建" },
|
|
||||||
geometry: {
|
|
||||||
rect: { x: 900, y: 8, w: 60, h: 32 },
|
|
||||||
tag: "button",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
geometry: {
|
|
||||||
rect: { x: 900, y: 8, w: 60, h: 32 },
|
|
||||||
tag: "button",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(buffer.steps).toEqual([
|
|
||||||
{
|
|
||||||
op: "hover",
|
|
||||||
captureTarget: { tag: "button", role: "button", name: "新建" },
|
|
||||||
targetHint: {
|
|
||||||
geometry: {
|
|
||||||
rect: { x: 900, y: 8, w: 60, h: 32 },
|
|
||||||
tag: "button",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("annotates navigated_to on action-caused navigation instead of wait_for_navigation", () => {
|
|
||||||
const buffer = {
|
|
||||||
steps: [
|
|
||||||
{
|
|
||||||
op: "click" as const,
|
|
||||||
captureTarget: { tag: "button", role: "button", name: "发布" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
navigation: {
|
|
||||||
currentUrl: "https://example.com/a",
|
|
||||||
pendingNavigation: true,
|
|
||||||
pendingNavigationDeadline: Date.now() + 5_000,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
observeRecordedNavigation(buffer, "https://example.com/b", true);
|
|
||||||
expect(buffer.steps).toEqual([
|
|
||||||
{
|
|
||||||
op: "click",
|
|
||||||
captureTarget: { tag: "button", role: "button", name: "发布" },
|
|
||||||
navigatedTo: "https://example.com/b",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(JSON.stringify(buffer.steps)).not.toContain("wait_for_navigation");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("annotates navigated_to onto a select that auto-submits a navigation", () => {
|
|
||||||
const buffer = {
|
|
||||||
steps: [
|
|
||||||
{
|
|
||||||
op: "select" as const,
|
|
||||||
captureTarget: { tag: "select", role: "combobox", name: "分类" },
|
|
||||||
values: ["tech"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
navigation: {
|
|
||||||
currentUrl: "https://example.com/list",
|
|
||||||
pendingNavigation: true,
|
|
||||||
pendingNavigationDeadline: Date.now() + 5_000,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
observeRecordedNavigation(buffer, "https://example.com/list?cat=tech", true);
|
|
||||||
expect(buffer.steps).toEqual([
|
|
||||||
{
|
|
||||||
op: "select",
|
|
||||||
captureTarget: { tag: "select", role: "combobox", name: "分类" },
|
|
||||||
values: ["tech"],
|
|
||||||
navigatedTo: "https://example.com/list?cat=tech",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits navigate for uncaused URL changes", () => {
|
|
||||||
const buffer = {
|
|
||||||
steps: [],
|
|
||||||
navigation: { currentUrl: "https://example.com/a", pendingNavigation: false },
|
|
||||||
};
|
|
||||||
const result = observeRecordedNavigation(buffer, "https://example.com/b", false);
|
|
||||||
expect(result).toEqual({ kind: "appended", index: 0 });
|
|
||||||
expect(buffer.steps[0]).toMatchObject({
|
|
||||||
op: "navigate",
|
|
||||||
url: "https://example.com/b",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("asks the recorder to coalesce redirect hops instead of emitting each one", () => {
|
|
||||||
const buffer = {
|
|
||||||
steps: [],
|
|
||||||
navigation: {
|
|
||||||
currentUrl: "https://passport.example/login",
|
|
||||||
pendingNavigation: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const hop1 = observeRecordedNavigation(
|
|
||||||
buffer,
|
|
||||||
"https://passport.example/callback",
|
|
||||||
false,
|
|
||||||
"link",
|
|
||||||
["server_redirect"],
|
|
||||||
);
|
|
||||||
expect(hop1).toEqual({
|
|
||||||
kind: "coalesce_redirect",
|
|
||||||
url: "https://passport.example/callback",
|
|
||||||
});
|
|
||||||
expect(buffer.steps).toEqual([]);
|
|
||||||
|
|
||||||
const hop2 = observeRecordedNavigation(buffer, "https://app.example/dashboard", false, "link", [
|
|
||||||
"client_redirect",
|
|
||||||
]);
|
|
||||||
expect(hop2).toEqual({
|
|
||||||
kind: "coalesce_redirect",
|
|
||||||
url: "https://app.example/dashboard",
|
|
||||||
});
|
|
||||||
expect(buffer.steps).toEqual([]);
|
|
||||||
expect(buffer.navigation.currentUrl).toBe("https://app.example/dashboard");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not reuse redirect metadata for a later content-observed URL change", () => {
|
|
||||||
const buffer = {
|
|
||||||
steps: [],
|
|
||||||
navigation: {
|
|
||||||
currentUrl: "https://example.com/redirect",
|
|
||||||
pendingNavigation: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = observeRecordedNavigation(buffer, "https://example.com/spa");
|
|
||||||
|
|
||||||
expect(result).toEqual({ kind: "appended", index: 0 });
|
|
||||||
expect(buffer.steps[0]).toMatchObject({ op: "navigate", url: "https://example.com/spa" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { RecordingTabCoordinator } from "../recording/tab-coordinator";
|
|
||||||
|
|
||||||
describe("RecordingTabCoordinator", () => {
|
|
||||||
it("keeps navigation state isolated per tab", () => {
|
|
||||||
const tabs = new RecordingTabCoordinator(4, "https://example.com/first");
|
|
||||||
const first = tabs.navigation(4);
|
|
||||||
first.pendingNavigation = true;
|
|
||||||
const second = tabs.navigation(5, "https://example.com/second");
|
|
||||||
|
|
||||||
expect(second).toEqual({
|
|
||||||
currentUrl: "https://example.com/second",
|
|
||||||
pendingNavigation: false,
|
|
||||||
});
|
|
||||||
expect(tabs.navigation(4)).toBe(first);
|
|
||||||
expect(tabs.navigation(4).pendingNavigation).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("invalidates an earlier activation when a newer tab becomes active", () => {
|
|
||||||
const tabs = new RecordingTabCoordinator(4);
|
|
||||||
const second = tabs.noteActivation(5);
|
|
||||||
const third = tabs.noteActivation(6);
|
|
||||||
|
|
||||||
expect(tabs.isLatest(second)).toBe(false);
|
|
||||||
expect(tabs.isLatest(third)).toBe(true);
|
|
||||||
tabs.commit(6);
|
|
||||||
expect(tabs.currentTabId).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("commits the freshly observed URL when returning to an existing tab", () => {
|
|
||||||
const tabs = new RecordingTabCoordinator(4);
|
|
||||||
tabs.navigation(5, "https://example.com/old");
|
|
||||||
|
|
||||||
tabs.commit(5, "https://example.com/new");
|
|
||||||
|
|
||||||
expect(tabs.currentTabId).toBe(5);
|
|
||||||
expect(tabs.navigation(5).currentUrl).toBe("https://example.com/new");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import type { RenderedRef } from "@browser-skill/vom";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type { CaptureVomMatchNode } from "@/tools/capture-vom-observation";
|
|
||||||
import { ObservationNodeIndex, type RegisteredObservation } from "../recording/observation-capture";
|
|
||||||
import { matchObservationTarget } from "../recording/target-matcher";
|
|
||||||
|
|
||||||
function node(backendNodeId: number, frameId: string, x = 10): CaptureVomMatchNode {
|
|
||||||
return {
|
|
||||||
backendNodeId,
|
|
||||||
frameId,
|
|
||||||
tag: "button",
|
|
||||||
rect: { x, y: 20, w: 100, h: 30 },
|
|
||||||
localRect: { x, y: 20, w: 100, h: 30 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function observation(
|
|
||||||
matchNodes: CaptureVomMatchNode[],
|
|
||||||
refs: RenderedRef[],
|
|
||||||
): RegisteredObservation {
|
|
||||||
return {
|
|
||||||
stateId: "s1",
|
|
||||||
rootFrameId: "root",
|
|
||||||
index: new ObservationNodeIndex({ rootFrameId: "root", matchNodes, refs }),
|
|
||||||
url: "https://example.com",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("matchObservationTarget", () => {
|
|
||||||
it("indexes the safe geometry DTO without discarding frame-local geometry", () => {
|
|
||||||
const geometry = node(42, "child");
|
|
||||||
geometry.localRect = { x: 5, y: 6, w: 100, h: 30 };
|
|
||||||
const ref: RenderedRef = {
|
|
||||||
ref: "e1",
|
|
||||||
backendNodeId: 42,
|
|
||||||
frameId: "child",
|
|
||||||
line: 1,
|
|
||||||
};
|
|
||||||
const candidate = observation([geometry], [ref]).index.candidates("child", "button")[0];
|
|
||||||
|
|
||||||
expect(candidate).toEqual({ frameId: "child", geometry, ref });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches a unique node using canonical top-level viewport geometry", () => {
|
|
||||||
const target = matchObservationTarget({
|
|
||||||
observation: observation(
|
|
||||||
[node(42, "root")],
|
|
||||||
[{ ref: "e1", backendNodeId: 42, role: "button", name: "发布", line: 1 }],
|
|
||||||
),
|
|
||||||
hint: { geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" } },
|
|
||||||
});
|
|
||||||
expect(target).toEqual({ ref: "e1", role: "button", name: "发布" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses frame id with backend node id so sibling frames cannot collide", () => {
|
|
||||||
const target = matchObservationTarget({
|
|
||||||
observation: observation(
|
|
||||||
[node(42, "left"), node(42, "right")],
|
|
||||||
[
|
|
||||||
{ ref: "e1", backendNodeId: 42, frameId: "left", line: 1 },
|
|
||||||
{ ref: "e2", backendNodeId: 42, frameId: "right", line: 2 },
|
|
||||||
],
|
|
||||||
),
|
|
||||||
hint: {
|
|
||||||
frameId: "right",
|
|
||||||
geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(target.ref).toBe("e2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches iframe capture geometry in the child viewport coordinate space", () => {
|
|
||||||
const child = node(42, "child", 410);
|
|
||||||
child.localRect = { x: 10, y: 20, w: 100, h: 30 };
|
|
||||||
const target = matchObservationTarget({
|
|
||||||
observation: observation(
|
|
||||||
[child],
|
|
||||||
[{ ref: "e1", backendNodeId: 42, frameId: "child", line: 1 }],
|
|
||||||
),
|
|
||||||
hint: {
|
|
||||||
frameId: "child",
|
|
||||||
geometrySpace: "local",
|
|
||||||
geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(target.ref).toBe("e1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("restricts a missing frame hint to the root frame", () => {
|
|
||||||
const target = matchObservationTarget({
|
|
||||||
observation: observation(
|
|
||||||
[node(42, "child")],
|
|
||||||
[{ ref: "e1", backendNodeId: 42, frameId: "child", line: 1 }],
|
|
||||||
),
|
|
||||||
hint: { geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" } },
|
|
||||||
fallback: { tag: "button", name: "发布" },
|
|
||||||
});
|
|
||||||
expect(target).toEqual({ name: "发布", unmatched: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fails closed when the source Document has no VOM frame mapping", () => {
|
|
||||||
const target = matchObservationTarget({
|
|
||||||
observation: observation(
|
|
||||||
[node(42, "root")],
|
|
||||||
[{ ref: "e1", backendNodeId: 42, role: "button", name: "发布", line: 1 }],
|
|
||||||
),
|
|
||||||
hint: {
|
|
||||||
frameId: null,
|
|
||||||
geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" },
|
|
||||||
},
|
|
||||||
fallback: { tag: "button", role: "button", name: "发布" },
|
|
||||||
});
|
|
||||||
expect(target).toEqual({ role: "button", name: "发布", unmatched: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns unmatched for ambiguous geometry", () => {
|
|
||||||
const target = matchObservationTarget({
|
|
||||||
observation: observation(
|
|
||||||
[node(42, "root"), node(43, "root")],
|
|
||||||
[
|
|
||||||
{ ref: "e1", backendNodeId: 42, line: 1 },
|
|
||||||
{ ref: "e2", backendNodeId: 43, line: 2 },
|
|
||||||
],
|
|
||||||
),
|
|
||||||
hint: { geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" } },
|
|
||||||
});
|
|
||||||
expect(target.unmatched).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses semantics when geometry is unavailable without crossing frame boundaries", () => {
|
|
||||||
const target = matchObservationTarget({
|
|
||||||
observation: observation(
|
|
||||||
[node(41, "root"), node(42, "child")],
|
|
||||||
[
|
|
||||||
{ ref: "e1", backendNodeId: 41, role: "button", name: "保存", line: 1 },
|
|
||||||
{ ref: "e2", backendNodeId: 42, frameId: "child", role: "button", name: "保存", line: 2 },
|
|
||||||
],
|
|
||||||
),
|
|
||||||
hint: { frameId: "child" },
|
|
||||||
fallback: { tag: "button", role: "button", name: "保存" },
|
|
||||||
});
|
|
||||||
expect(target.ref).toBe("e2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses semantics to disambiguate equal geometry in one frame", () => {
|
|
||||||
const target = matchObservationTarget({
|
|
||||||
observation: observation(
|
|
||||||
[node(42, "root"), node(43, "root")],
|
|
||||||
[
|
|
||||||
{ ref: "e1", backendNodeId: 42, role: "button", name: "保存", line: 1 },
|
|
||||||
{ ref: "e2", backendNodeId: 43, role: "button", name: "取消", line: 2 },
|
|
||||||
],
|
|
||||||
),
|
|
||||||
hint: { geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" } },
|
|
||||||
fallback: { tag: "button", role: "button", name: "取消" },
|
|
||||||
});
|
|
||||||
expect(target.ref).toBe("e2");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { TRACE_VERSION_V3, VOM_FORMAT_VERSION } from "@/transport/types";
|
|
||||||
import { RecordingStateRegistry } from "../recording/state-registry";
|
|
||||||
import { buildTraceV3 } from "../recording/trace-builder-v3";
|
|
||||||
import { reduceTraceStepsV3 } from "../recording/trace-reducer-v3";
|
|
||||||
import type { RecordingDraftStep } from "../recording/types";
|
|
||||||
|
|
||||||
describe("trace reducer v3", () => {
|
|
||||||
it("removes form literals when value redaction is requested", () => {
|
|
||||||
const reduced = reduceTraceStepsV3(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
op: "fill",
|
|
||||||
value: "private@example.com",
|
|
||||||
preStateId: "s1",
|
|
||||||
postStateId: "s1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "select",
|
|
||||||
values: ["private-account-id"],
|
|
||||||
labels: ["Private account"],
|
|
||||||
preStateId: "s1",
|
|
||||||
postStateId: "s1",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
{ redactValues: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(reduced.steps[0]).toMatchObject({ op: "fill", value: "***", redacted: true });
|
|
||||||
expect(reduced.steps[1]).toMatchObject({ op: "select" });
|
|
||||||
expect(reduced.steps[1]).not.toHaveProperty("selection");
|
|
||||||
expect(JSON.stringify(reduced.steps)).not.toContain("private@example.com");
|
|
||||||
expect(JSON.stringify(reduced.steps)).not.toContain("private-account-id");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits tab transitions only for callers that advertised support", () => {
|
|
||||||
const drafts: RecordingDraftStep[] = [
|
|
||||||
{
|
|
||||||
op: "switch_tab",
|
|
||||||
preStateId: "s1",
|
|
||||||
postStateId: "s2",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(reduceTraceStepsV3(drafts).steps).toEqual([]);
|
|
||||||
expect(reduceTraceStepsV3(drafts, { includeTabSwitches: true }).steps).toEqual([
|
|
||||||
{ op: "switch_tab", id: 1, state: "s1", result: { state: "s2" } },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps hover steps before menu clicks", () => {
|
|
||||||
const drafts: RecordingDraftStep[] = [
|
|
||||||
{
|
|
||||||
op: "hover",
|
|
||||||
captureTarget: { tag: "span", role: "button", name: "Account" },
|
|
||||||
preStateId: "s1",
|
|
||||||
postStateId: "s1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "click",
|
|
||||||
captureTarget: { tag: "a", role: "link", name: "Profile" },
|
|
||||||
preStateId: "s1",
|
|
||||||
postStateId: "s1",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(reduceTraceStepsV3(drafts).steps.map((s) => s.op)).toEqual(["hover", "click"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not export a tab transition without both observation endpoints", () => {
|
|
||||||
const sourceOnly: RecordingDraftStep = { op: "switch_tab", preStateId: "s1" };
|
|
||||||
const targetOnly: RecordingDraftStep = { op: "switch_tab", postStateId: "s2" };
|
|
||||||
|
|
||||||
expect(
|
|
||||||
reduceTraceStepsV3([sourceOnly, targetOnly], { includeTabSwitches: true }).steps,
|
|
||||||
).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("collapses redirect hops while retaining draft-to-step identity", () => {
|
|
||||||
const drafts: RecordingDraftStep[] = [
|
|
||||||
{ op: "navigate", url: "https://example.com/start", preStateId: "s1", postStateId: "s2" },
|
|
||||||
{
|
|
||||||
op: "navigate",
|
|
||||||
url: "https://example.com/final",
|
|
||||||
transitionQualifiers: ["server_redirect"],
|
|
||||||
preStateId: "s2",
|
|
||||||
postStateId: "s3",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const before = structuredClone(drafts);
|
|
||||||
const reduced = reduceTraceStepsV3(drafts);
|
|
||||||
|
|
||||||
expect(drafts).toEqual(before);
|
|
||||||
expect(reduced.steps).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
id: 1,
|
|
||||||
op: "navigate",
|
|
||||||
state: "s1",
|
|
||||||
to: "https://example.com/final",
|
|
||||||
result: { state: "s3" },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(reduced.stepIdByDraftId.get(1)).toBe(1);
|
|
||||||
expect(reduced.stepIdByDraftId.get(2)).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds the wire model from protocol constants", () => {
|
|
||||||
const registry = new RecordingStateRegistry();
|
|
||||||
const state = registry.register({ url: "https://example.com", vomText: "@vom 1" });
|
|
||||||
const trace = buildTraceV3({
|
|
||||||
registry,
|
|
||||||
drafts: [
|
|
||||||
{
|
|
||||||
op: "click",
|
|
||||||
captureTarget: { tag: "button", role: "button", name: "Save" },
|
|
||||||
preStateId: state.id,
|
|
||||||
postStateId: state.id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
startedAt: "2026-08-12T00:00:00.000Z",
|
|
||||||
stoppedBy: "user_finish",
|
|
||||||
bskVersion: "test",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(trace.version).toBe(TRACE_VERSION_V3);
|
|
||||||
expect(trace.recorder.vom).toBe(VOM_FORMAT_VERSION);
|
|
||||||
expect(trace.steps[0]).toMatchObject({
|
|
||||||
op: "click",
|
|
||||||
target: { role: "button", name: "Save", unmatched: true },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+34
-50
@@ -1,15 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { RecordingDraftStep } from "@/lib/recording/types";
|
import type { DraftTraceStep } from "@/transport/types";
|
||||||
import { shouldRecordPress } from "../recording/draft-policy";
|
import { reduceTraceSteps, resolveTraceStartUrl, shouldRecordPress } from "../trace-reducer";
|
||||||
import { buildTraceV2 } from "../recording/trace-reducer-v2";
|
|
||||||
|
|
||||||
function reduceTraceSteps(steps: RecordingDraftStep[], startUrl?: string) {
|
|
||||||
return buildTraceV2({
|
|
||||||
steps,
|
|
||||||
startedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
...(startUrl ? { startUrl } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("shouldRecordPress", () => {
|
describe("shouldRecordPress", () => {
|
||||||
it("keeps Enter and Escape", () => {
|
it("keeps Enter and Escape", () => {
|
||||||
@@ -27,47 +18,36 @@ describe("shouldRecordPress", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("reduceTraceSteps", () => {
|
describe("reduceTraceSteps", () => {
|
||||||
it("does not expose internal tab transitions in trace v2", () => {
|
|
||||||
const trace = reduceTraceSteps([
|
|
||||||
{
|
|
||||||
op: "switch_tab",
|
|
||||||
preStateId: "s1",
|
|
||||||
postStateId: "s2",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(trace.steps).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds steps with pages dictionary and page id references", () => {
|
it("builds steps with pages dictionary and page id references", () => {
|
||||||
const drafts: RecordingDraftStep[] = [
|
const drafts: DraftTraceStep[] = [
|
||||||
{
|
{
|
||||||
op: "navigate",
|
op: "navigate",
|
||||||
url: "https://example.com/search?q=hello&utm_source=x",
|
url: "https://example.com/search?q=hello&utm_source=x",
|
||||||
pageUrl: "https://example.com/search?q=hello&utm_source=x",
|
page_url: "https://example.com/search?q=hello&utm_source=x",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
op: "fill",
|
op: "fill",
|
||||||
captureTarget: { tag: "input", role: "textbox", name: "搜索", name_attr: "q" },
|
target: { tag: "input", role: "textbox", name: "搜索", name_attr: "q" },
|
||||||
value: "browser skill",
|
value: "browser skill",
|
||||||
pageUrl: "https://example.com/search?q=hello&utm_source=x",
|
page_url: "https://example.com/search?q=hello&utm_source=x",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
op: "press",
|
op: "press",
|
||||||
key: "Enter",
|
key: "Enter",
|
||||||
captureTarget: { tag: "input", role: "textbox", name: "搜索", name_attr: "q" },
|
target: { tag: "input", role: "textbox", name: "搜索", name_attr: "q" },
|
||||||
navigatedTo: "https://example.com/results/42",
|
navigated_to: "https://example.com/results/42",
|
||||||
pageUrl: "https://example.com/search?q=hello&utm_source=x",
|
page_url: "https://example.com/search?q=hello&utm_source=x",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
op: "click",
|
op: "click",
|
||||||
captureTarget: { tag: "button", role: "button", name: "发布" },
|
target: { tag: "button", role: "button", name: "发布" },
|
||||||
navigatedTo: "https://example.com/p/99",
|
navigated_to: "https://example.com/p/99",
|
||||||
pageUrl: "https://example.com/results/42",
|
page_url: "https://example.com/results/42",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
op: "press",
|
op: "press",
|
||||||
key: "a",
|
key: "a",
|
||||||
pageUrl: "https://example.com/p/99",
|
page_url: "https://example.com/p/99",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -129,9 +109,9 @@ describe("reduceTraceSteps", () => {
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
op: "fill",
|
op: "fill",
|
||||||
captureTarget: { tag: "input", role: "textbox", name: "Search query" },
|
target: { tag: "input", role: "textbox", name: "Search query" },
|
||||||
value: "",
|
value: "",
|
||||||
pageUrl: "https://example.com/search",
|
page_url: "https://example.com/search",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"https://example.com/search",
|
"https://example.com/search",
|
||||||
@@ -145,16 +125,16 @@ describe("reduceTraceSteps", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps select navigatedTo onto effect.navigatedTo (page id)", () => {
|
it("maps select navigated_to onto effect.navigated_to (page id)", () => {
|
||||||
const { pages, steps } = reduceTraceSteps(
|
const { pages, steps } = reduceTraceSteps(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
op: "select",
|
op: "select",
|
||||||
captureTarget: { tag: "select", role: "combobox", name: "分类" },
|
target: { tag: "select", role: "combobox", name: "分类" },
|
||||||
values: ["tech"],
|
values: ["tech"],
|
||||||
labels: ["技术"],
|
labels: ["技术"],
|
||||||
navigatedTo: "https://example.com/list?cat=tech",
|
navigated_to: "https://example.com/list?cat=tech",
|
||||||
pageUrl: "https://example.com/list",
|
page_url: "https://example.com/list",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"https://example.com/list",
|
"https://example.com/list",
|
||||||
@@ -171,32 +151,36 @@ describe("reduceTraceSteps", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops hover steps, which only exist in trace v3", () => {
|
it("keeps hover steps before menu clicks", () => {
|
||||||
const { steps } = reduceTraceSteps(
|
const { steps } = reduceTraceSteps(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
op: "hover",
|
op: "hover",
|
||||||
captureTarget: { tag: "span", role: "button", name: "Account" },
|
target: { tag: "span", role: "button", name: "Account" },
|
||||||
pageUrl: "https://example.com/app",
|
page_url: "https://example.com/app",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
op: "click",
|
op: "click",
|
||||||
captureTarget: { tag: "a", role: "link", name: "Profile" },
|
target: { tag: "a", role: "link", name: "Profile" },
|
||||||
pageUrl: "https://example.com/app",
|
page_url: "https://example.com/app",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"https://example.com/app",
|
"https://example.com/app",
|
||||||
);
|
);
|
||||||
expect(steps.map((s) => s.op)).toEqual(["click"]);
|
expect(steps.map((s) => s.op)).toEqual(["hover", "click"]);
|
||||||
|
expect(steps[0]).toMatchObject({
|
||||||
|
op: "hover",
|
||||||
|
page: "p1",
|
||||||
|
target: { name: "Account" },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolveTraceStartUrl prefers explicit start URL", () => {
|
it("resolveTraceStartUrl prefers explicit start URL", () => {
|
||||||
expect(
|
expect(
|
||||||
buildTraceV2({
|
resolveTraceStartUrl(
|
||||||
steps: [{ op: "navigate", url: "https://example.com/other" }],
|
[{ op: "navigate", url: "https://example.com/other" }],
|
||||||
startedAt: "2026-01-01T00:00:00.000Z",
|
"https://example.com/start",
|
||||||
startUrl: "https://example.com/start",
|
),
|
||||||
}).entry.start_url,
|
|
||||||
).toBe("https://example.com/start");
|
).toBe("https://example.com/start");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Build a semantic capture descriptor for an interacted element.
|
* Build a semantic TargetDescriptor for an interacted element.
|
||||||
*
|
*
|
||||||
* Trace steps are an LLM *textbook*: each click must say what to look for
|
* Trace steps are an LLM *textbook*: each click must say what to look for
|
||||||
* on screen (usually a short visible name). Tag-only noise like
|
* on screen (usually a short visible name). Tag-only noise like
|
||||||
* `{ "tag": "div" }` / “点击div” is useless and must not be recorded.
|
* `{ "tag": "div" }` / “点击div” is useless and must not be recorded.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Content-script capture descriptor before VOM geometric matching. */
|
export interface TargetDescriptor {
|
||||||
export interface CaptureTargetDescriptor {
|
|
||||||
role?: string;
|
role?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
tag: string;
|
tag: string;
|
||||||
@@ -248,7 +247,7 @@ export function resolveHoverElement(target: Element): Element | null {
|
|||||||
* (visible name), or at least a form `name_attr` for checkbox/radio.
|
* (visible name), or at least a form `name_attr` for checkbox/radio.
|
||||||
* Recording “点击div” with no name fails this bar.
|
* Recording “点击div” with no name fails this bar.
|
||||||
*/
|
*/
|
||||||
export function isMeaningfulClickTarget(target: CaptureTargetDescriptor): boolean {
|
export function isMeaningfulClickTarget(target: TargetDescriptor): boolean {
|
||||||
const name = target.name?.trim();
|
const name = target.name?.trim();
|
||||||
if (name && isActionableLabel(name)) return true;
|
if (name && isActionableLabel(name)) return true;
|
||||||
if (
|
if (
|
||||||
@@ -324,7 +323,7 @@ function nearbyLabelText(el: Element): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function describeTarget(el: Element): CaptureTargetDescriptor {
|
export function describeTarget(el: Element): TargetDescriptor {
|
||||||
const tag = el.tagName.toLowerCase();
|
const tag = el.tagName.toLowerCase();
|
||||||
const role = inferRole(el);
|
const role = inferRole(el);
|
||||||
const name = accessibleName(el);
|
const name = accessibleName(el);
|
||||||
@@ -351,7 +350,7 @@ export function describeTarget(el: Element): CaptureTargetDescriptor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function describeEventTarget(target: EventTarget | null): CaptureTargetDescriptor | null {
|
export function describeEventTarget(target: EventTarget | null): TargetDescriptor | null {
|
||||||
if (!(target instanceof Element)) return null;
|
if (!(target instanceof Element)) return null;
|
||||||
const clickable = resolveClickableElement(target);
|
const clickable = resolveClickableElement(target);
|
||||||
if (!clickable) return null;
|
if (!clickable) return null;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* service worker and a tab's content script.
|
* service worker and a tab's content script.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CaptureTargetDescriptor } from "./describe-target";
|
import type { TargetDescriptor } from "./describe-target";
|
||||||
|
|
||||||
export const RECORD_START = "bsk-record-start";
|
export const RECORD_START = "bsk-record-start";
|
||||||
export const RECORD_STEP = "bsk-record-step";
|
export const RECORD_STEP = "bsk-record-step";
|
||||||
@@ -16,10 +16,6 @@ export interface RecordStartAck {
|
|||||||
ok: true;
|
ok: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RecordStepAck =
|
|
||||||
| { ok: true; sequence: number }
|
|
||||||
| { ok: false; expectedSequence: number; error: string };
|
|
||||||
|
|
||||||
export type RecordStopAck = { ok: true } | { ok: false; error: string };
|
export type RecordStopAck = { ok: true } | { ok: false; error: string };
|
||||||
|
|
||||||
export interface RecordQueryMessage {
|
export interface RecordQueryMessage {
|
||||||
@@ -46,7 +42,7 @@ export interface RecordStartMessage {
|
|||||||
|
|
||||||
export interface RecordStepPayload {
|
export interface RecordStepPayload {
|
||||||
op: "click" | "hover" | "fill" | "press" | "select" | "navigate";
|
op: "click" | "hover" | "fill" | "press" | "select" | "navigate";
|
||||||
target?: CaptureTargetDescriptor;
|
target?: TargetDescriptor;
|
||||||
value?: string;
|
value?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">;
|
modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">;
|
||||||
@@ -54,30 +50,17 @@ export interface RecordStepPayload {
|
|||||||
labels?: string[];
|
labels?: string[];
|
||||||
url?: string;
|
url?: string;
|
||||||
redacted?: boolean;
|
redacted?: boolean;
|
||||||
commit?: "enter" | "suggestion" | "blur";
|
|
||||||
/** Page URL when the step was captured. */
|
/** Page URL when the step was captured. */
|
||||||
page_url?: string;
|
page_url?: string;
|
||||||
/** Event-target bounds in the source frame's viewport coordinate space. */
|
|
||||||
geometry?: {
|
|
||||||
rect: { x: number; y: number; w: number; h: number };
|
|
||||||
tag: string;
|
|
||||||
};
|
|
||||||
/** Capture-only hint; never persisted unless converted to navigated_to. */
|
/** Capture-only hint; never persisted unless converted to navigated_to. */
|
||||||
expects_navigation?: boolean;
|
expects_navigation?: boolean;
|
||||||
/** Whether an observed URL change was synchronously caused by the action. */
|
/** Whether an observed URL change was synchronously caused by the action. */
|
||||||
navigation_caused_by_action?: boolean;
|
navigation_caused_by_action?: boolean;
|
||||||
/** Raw webNavigation transition metadata for cause mapping. */
|
|
||||||
transitionType?: string;
|
|
||||||
transitionQualifiers?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecordStepMessage {
|
export interface RecordStepMessage {
|
||||||
type: typeof RECORD_STEP;
|
type: typeof RECORD_STEP;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
/** Stable for the lifetime of one content-script document. */
|
|
||||||
producerId: string;
|
|
||||||
/** Monotonic within one content-script producer and recording request. */
|
|
||||||
sequence: number;
|
|
||||||
step: RecordStepPayload;
|
step: RecordStepPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,27 +112,8 @@ export function isRecordQueryMessage(msg: unknown): msg is RecordQueryMessage {
|
|||||||
export function isRecordStepMessage(msg: unknown): msg is RecordStepMessage {
|
export function isRecordStepMessage(msg: unknown): msg is RecordStepMessage {
|
||||||
if (typeof msg !== "object" || msg === null) return false;
|
if (typeof msg !== "object" || msg === null) return false;
|
||||||
const m = msg as Record<string, unknown>;
|
const m = msg as Record<string, unknown>;
|
||||||
if (
|
if (m.type !== RECORD_STEP || typeof m.requestId !== "string") return false;
|
||||||
m.type !== RECORD_STEP ||
|
|
||||||
typeof m.requestId !== "string" ||
|
|
||||||
typeof m.producerId !== "string" ||
|
|
||||||
m.producerId.length === 0 ||
|
|
||||||
typeof m.sequence !== "number" ||
|
|
||||||
!Number.isSafeInteger(m.sequence) ||
|
|
||||||
m.sequence < 1
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const step = m.step;
|
const step = m.step;
|
||||||
if (typeof step !== "object" || step === null) return false;
|
if (typeof step !== "object" || step === null) return false;
|
||||||
return typeof (step as RecordStepPayload).op === "string";
|
return typeof (step as RecordStepPayload).op === "string";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAcceptedRecordStepAck(
|
|
||||||
value: unknown,
|
|
||||||
sequence: number,
|
|
||||||
): value is Extract<RecordStepAck, { ok: true }> {
|
|
||||||
if (typeof value !== "object" || value === null) return false;
|
|
||||||
const ack = value as Partial<RecordStepAck>;
|
|
||||||
return ack.ok === true && ack.sequence === sequence;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import type { DraftTraceStep } from "@/transport/types";
|
||||||
|
import type { RecordStepPayload } from "./record-bridge";
|
||||||
|
|
||||||
|
export interface RecordingStepBuffer {
|
||||||
|
steps: DraftTraceStep[];
|
||||||
|
currentUrl?: string;
|
||||||
|
pendingNavigation: boolean;
|
||||||
|
pendingNavigationDeadline?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAVIGATION_TRIGGER_WINDOW_MS = 3_000;
|
||||||
|
|
||||||
|
function toDraftStep(payload: RecordStepPayload): DraftTraceStep | null {
|
||||||
|
const pageUrl = payload.page_url;
|
||||||
|
switch (payload.op) {
|
||||||
|
case "click":
|
||||||
|
return payload.target
|
||||||
|
? {
|
||||||
|
op: "click",
|
||||||
|
target: payload.target,
|
||||||
|
...(pageUrl ? { page_url: pageUrl } : {}),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
case "hover":
|
||||||
|
return payload.target
|
||||||
|
? {
|
||||||
|
op: "hover",
|
||||||
|
target: payload.target,
|
||||||
|
...(pageUrl ? { page_url: pageUrl } : {}),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
case "fill":
|
||||||
|
return payload.target
|
||||||
|
? {
|
||||||
|
op: "fill",
|
||||||
|
target: payload.target,
|
||||||
|
value: payload.value ?? "",
|
||||||
|
...(payload.redacted ? { redacted: true } : {}),
|
||||||
|
...(pageUrl ? { page_url: pageUrl } : {}),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
case "press":
|
||||||
|
return payload.key
|
||||||
|
? {
|
||||||
|
op: "press",
|
||||||
|
key: payload.key,
|
||||||
|
...(payload.target ? { target: payload.target } : {}),
|
||||||
|
...(payload.modifiers?.length ? { modifiers: payload.modifiers } : {}),
|
||||||
|
...(pageUrl ? { page_url: pageUrl } : {}),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
case "select":
|
||||||
|
return payload.target && payload.values
|
||||||
|
? {
|
||||||
|
op: "select",
|
||||||
|
target: payload.target,
|
||||||
|
values: payload.values,
|
||||||
|
...(payload.labels?.length ? { labels: payload.labels } : {}),
|
||||||
|
...(pageUrl ? { page_url: pageUrl } : {}),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
case "navigate":
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function annotateLastStepNavigation(buffer: RecordingStepBuffer, url: string): boolean {
|
||||||
|
for (let i = buffer.steps.length - 1; i >= 0; i -= 1) {
|
||||||
|
const step = buffer.steps[i];
|
||||||
|
if (!step) continue;
|
||||||
|
if (step.op === "click" || step.op === "press" || step.op === "select") {
|
||||||
|
buffer.steps[i] = { ...step, navigated_to: url };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function observeRecordedNavigation(
|
||||||
|
buffer: RecordingStepBuffer,
|
||||||
|
url: string,
|
||||||
|
causedByAction?: boolean,
|
||||||
|
): void {
|
||||||
|
if (!url || url === buffer.currentUrl) return;
|
||||||
|
buffer.currentUrl = url;
|
||||||
|
const pendingIsCurrent =
|
||||||
|
buffer.pendingNavigation &&
|
||||||
|
(buffer.pendingNavigationDeadline === undefined ||
|
||||||
|
buffer.pendingNavigationDeadline >= Date.now());
|
||||||
|
|
||||||
|
if (causedByAction === true || (causedByAction === undefined && pendingIsCurrent)) {
|
||||||
|
buffer.pendingNavigation = false;
|
||||||
|
buffer.pendingNavigationDeadline = undefined;
|
||||||
|
if (!annotateLastStepNavigation(buffer, url)) {
|
||||||
|
buffer.steps.push({
|
||||||
|
op: "navigate",
|
||||||
|
url,
|
||||||
|
page_url: url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.pendingNavigation = false;
|
||||||
|
buffer.pendingNavigationDeadline = undefined;
|
||||||
|
buffer.steps.push({
|
||||||
|
op: "navigate",
|
||||||
|
url,
|
||||||
|
page_url: url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendRecordedPayload(
|
||||||
|
buffer: RecordingStepBuffer,
|
||||||
|
payload: RecordStepPayload,
|
||||||
|
): void {
|
||||||
|
if (payload.op === "navigate") {
|
||||||
|
if (payload.url) {
|
||||||
|
observeRecordedNavigation(buffer, payload.url, payload.navigation_caused_by_action);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const step = toDraftStep({
|
||||||
|
...payload,
|
||||||
|
page_url: payload.page_url ?? buffer.currentUrl,
|
||||||
|
});
|
||||||
|
if (!step) return;
|
||||||
|
buffer.steps.push(step);
|
||||||
|
if (step.op === "click" || step.op === "press" || step.op === "select" || step.op === "fill") {
|
||||||
|
buffer.pendingNavigation = payload.expects_navigation === true;
|
||||||
|
buffer.pendingNavigationDeadline = buffer.pendingNavigation
|
||||||
|
? Date.now() + NAVIGATION_TRIGGER_WINDOW_MS
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import {
|
|
||||||
RECORD_DOCUMENT_ATTRIBUTE,
|
|
||||||
recordingDocumentMarkerValue,
|
|
||||||
} from "@/shared/recording-document-identity";
|
|
||||||
|
|
||||||
export interface RecordingDocumentMarker {
|
|
||||||
restore(): void;
|
|
||||||
ensure(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function waitForDocumentElement(): Promise<HTMLElement> {
|
|
||||||
if (document.documentElement) return Promise.resolve(document.documentElement);
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
if (!document.documentElement) return;
|
|
||||||
observer.disconnect();
|
|
||||||
resolve(document.documentElement);
|
|
||||||
});
|
|
||||||
observer.observe(document, { childList: true });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function markRecordingDocument(
|
|
||||||
producerId: string,
|
|
||||||
root: HTMLElement = document.documentElement,
|
|
||||||
): RecordingDocumentMarker {
|
|
||||||
const previous = root.getAttribute(RECORD_DOCUMENT_ATTRIBUTE);
|
|
||||||
const markerValue = recordingDocumentMarkerValue(producerId);
|
|
||||||
const ensure = () => root.setAttribute(RECORD_DOCUMENT_ATTRIBUTE, markerValue);
|
|
||||||
ensure();
|
|
||||||
return {
|
|
||||||
ensure,
|
|
||||||
restore() {
|
|
||||||
if (root.getAttribute(RECORD_DOCUMENT_ATTRIBUTE) !== markerValue) return;
|
|
||||||
if (previous === null) root.removeAttribute(RECORD_DOCUMENT_ATTRIBUTE);
|
|
||||||
else root.setAttribute(RECORD_DOCUMENT_ATTRIBUTE, previous);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
|
||||||
import { type CdpRunner, sendToCdpTarget } from "@/tools/shared";
|
|
||||||
|
|
||||||
const SETTLE_MIN_MS = 150;
|
|
||||||
const SETTLE_QUIET_MS = 250;
|
|
||||||
const SETTLE_MAX_MS = 2_000;
|
|
||||||
const SETTLE_POLL_MS = 60;
|
|
||||||
const SETTLE_WORLD_NAME = "__bsk_record_settle__";
|
|
||||||
|
|
||||||
const QUIET_PROBE = `(() => {
|
|
||||||
const scope = window;
|
|
||||||
let probe = scope.__bskRecordQuiet;
|
|
||||||
if (!probe) {
|
|
||||||
probe = { changedAt: Date.now() };
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
probe.changedAt = Date.now();
|
|
||||||
});
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
subtree: true,
|
|
||||||
childList: true,
|
|
||||||
attributes: true,
|
|
||||||
characterData: true,
|
|
||||||
});
|
|
||||||
scope.__bskRecordQuiet = probe;
|
|
||||||
}
|
|
||||||
return { idleMs: Date.now() - probe.changedAt, readyState: document.readyState };
|
|
||||||
})()`;
|
|
||||||
|
|
||||||
export interface DocumentSettleScope {
|
|
||||||
target: CdpTarget;
|
|
||||||
/** Omit only for the target's root document. */
|
|
||||||
frameId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SettleOutcome = "quiet" | "timeout" | "cancelled";
|
|
||||||
|
|
||||||
interface QuietProbe {
|
|
||||||
idleMs: number;
|
|
||||||
readyState: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProbeContext {
|
|
||||||
executionContextId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (signal?.aborted) return resolve();
|
|
||||||
const timer = setTimeout(resolve, ms);
|
|
||||||
signal?.addEventListener(
|
|
||||||
"abort",
|
|
||||||
() => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve();
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executionContextForScope(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
scope: DocumentSettleScope,
|
|
||||||
): Promise<number | undefined> {
|
|
||||||
if (!scope.frameId) return undefined;
|
|
||||||
const result = await sendToCdpTarget<{ executionContextId?: number }>(
|
|
||||||
cdp,
|
|
||||||
scope.target,
|
|
||||||
"Page.createIsolatedWorld",
|
|
||||||
{
|
|
||||||
frameId: scope.frameId,
|
|
||||||
worldName: SETTLE_WORLD_NAME,
|
|
||||||
grantUniveralAccess: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return result.executionContextId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readQuietProbe(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
scope: DocumentSettleScope,
|
|
||||||
context: ProbeContext,
|
|
||||||
): Promise<QuietProbe | null> {
|
|
||||||
try {
|
|
||||||
if (scope.frameId && context.executionContextId === undefined) {
|
|
||||||
context.executionContextId = await executionContextForScope(cdp, scope);
|
|
||||||
if (context.executionContextId === undefined) return null;
|
|
||||||
}
|
|
||||||
const reply = await sendToCdpTarget<{ result?: { value?: unknown } }>(
|
|
||||||
cdp,
|
|
||||||
scope.target,
|
|
||||||
"Runtime.evaluate",
|
|
||||||
{
|
|
||||||
expression: QUIET_PROBE,
|
|
||||||
returnByValue: true,
|
|
||||||
...(context.executionContextId !== undefined
|
|
||||||
? { contextId: context.executionContextId }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const value = reply.result?.value;
|
|
||||||
if (!value || typeof value !== "object") return null;
|
|
||||||
const { idleMs, readyState } = value as { idleMs?: unknown; readyState?: unknown };
|
|
||||||
if (typeof idleMs !== "number" || typeof readyState !== "string") return null;
|
|
||||||
return { idleMs, readyState };
|
|
||||||
} catch {
|
|
||||||
context.executionContextId = undefined;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitForDocumentSettled(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
scope: DocumentSettleScope,
|
|
||||||
options: { signal?: AbortSignal } = {},
|
|
||||||
): Promise<SettleOutcome> {
|
|
||||||
const startedAt = Date.now();
|
|
||||||
const floor = startedAt + SETTLE_MIN_MS;
|
|
||||||
const deadline = startedAt + SETTLE_MAX_MS;
|
|
||||||
const context: ProbeContext = {};
|
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
if (options.signal?.aborted) return "cancelled";
|
|
||||||
await sleep(SETTLE_POLL_MS, options.signal);
|
|
||||||
if (options.signal?.aborted) return "cancelled";
|
|
||||||
|
|
||||||
const probe = await readQuietProbe(cdp, scope, context);
|
|
||||||
const now = Date.now();
|
|
||||||
if (now < floor) continue;
|
|
||||||
if (now >= deadline) return "timeout";
|
|
||||||
if (!probe || probe.readyState === "loading") continue;
|
|
||||||
if (probe.idleMs >= SETTLE_QUIET_MS) return "quiet";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import type { RecordingDraftStep } from "./types";
|
|
||||||
|
|
||||||
const CLIPBOARD_KEYS = new Set(["a", "c", "v", "x", "A", "C", "V", "X"]);
|
|
||||||
const MODIFIER_ONLY_KEYS = new Set(["Meta", "Control", "Alt", "Shift", "OS", "Hyper", "Super"]);
|
|
||||||
|
|
||||||
export function shouldRecordPress(
|
|
||||||
key: string,
|
|
||||||
modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">,
|
|
||||||
): boolean {
|
|
||||||
if (MODIFIER_ONLY_KEYS.has(key)) return false;
|
|
||||||
const mods = modifiers ?? [];
|
|
||||||
const hasCtrlOrMeta = mods.includes("ctrl") || mods.includes("meta");
|
|
||||||
if (hasCtrlOrMeta && CLIPBOARD_KEYS.has(key)) return false;
|
|
||||||
if (key === "Enter" || key === "Escape") return true;
|
|
||||||
if (key.length === 1 && !hasCtrlOrMeta && !mods.includes("alt")) return false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldIncludeDraft(step: RecordingDraftStep): boolean {
|
|
||||||
return step.op !== "press" || shouldRecordPress(step.key, step.modifiers);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveDraftStartUrl(
|
|
||||||
drafts: RecordingDraftStep[],
|
|
||||||
explicitStartUrl?: string,
|
|
||||||
fallbackUrl?: string,
|
|
||||||
): string {
|
|
||||||
if (explicitStartUrl) return explicitStartUrl;
|
|
||||||
const navigation = drafts.find(
|
|
||||||
(step): step is Extract<RecordingDraftStep, { op: "navigate" }> => step.op === "navigate",
|
|
||||||
);
|
|
||||||
if (navigation) return navigation.url;
|
|
||||||
return drafts.find((step) => step.pageUrl)?.pageUrl ?? fallbackUrl ?? "about:blank";
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
export const RECORD_FRAME_PORT = "bsk-record-frame";
|
|
||||||
export const RECORD_FRAME_QUERY = "bsk-record-frame-query";
|
|
||||||
export const RECORD_FRAME_START = "bsk-record-frame-start";
|
|
||||||
|
|
||||||
export interface RecordFrameQueryMessage {
|
|
||||||
type: typeof RECORD_FRAME_QUERY;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecordFrameQueryResponse {
|
|
||||||
active: boolean;
|
|
||||||
requestId?: string;
|
|
||||||
startedAtMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecordFrameStartMessage {
|
|
||||||
type: typeof RECORD_FRAME_START;
|
|
||||||
requestId: string;
|
|
||||||
startedAtMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RecordFramePortMessage =
|
|
||||||
| {
|
|
||||||
type: "ready";
|
|
||||||
requestId: string;
|
|
||||||
producerId: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "ready_ack";
|
|
||||||
requestId: string;
|
|
||||||
producerId: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "stop";
|
|
||||||
requestId: string;
|
|
||||||
commandId: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "cancel";
|
|
||||||
requestId: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "stopped";
|
|
||||||
requestId: string;
|
|
||||||
commandId: string;
|
|
||||||
ok: boolean;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function isRecordFrameQueryMessage(value: unknown): value is RecordFrameQueryMessage {
|
|
||||||
return (
|
|
||||||
typeof value === "object" &&
|
|
||||||
value !== null &&
|
|
||||||
(value as { type?: unknown }).type === RECORD_FRAME_QUERY
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRecordFrameStartMessage(value: unknown): value is RecordFrameStartMessage {
|
|
||||||
if (typeof value !== "object" || value === null) return false;
|
|
||||||
const message = value as Partial<RecordFrameStartMessage>;
|
|
||||||
return (
|
|
||||||
message.type === RECORD_FRAME_START &&
|
|
||||||
typeof message.requestId === "string" &&
|
|
||||||
typeof message.startedAtMs === "number"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
import {
|
|
||||||
isRecordFrameQueryMessage,
|
|
||||||
RECORD_FRAME_PORT,
|
|
||||||
RECORD_FRAME_START,
|
|
||||||
type RecordFramePortMessage,
|
|
||||||
type RecordFrameQueryResponse,
|
|
||||||
type RecordFrameStartMessage,
|
|
||||||
} from "./frame-bridge";
|
|
||||||
|
|
||||||
export interface RecordingCaptureScope {
|
|
||||||
tabId: number;
|
|
||||||
documentId: string;
|
|
||||||
browserFrameId: number;
|
|
||||||
producerId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ArmedRecording {
|
|
||||||
requestId: string;
|
|
||||||
startedAtMs: number;
|
|
||||||
tabIds: Set<number>;
|
|
||||||
agents: Map<string, FrameAgent>;
|
|
||||||
onDocumentReady?: (scope: RecordingCaptureScope) => void | Promise<void>;
|
|
||||||
finishing: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FrameAgent extends RecordingCaptureScope {
|
|
||||||
requestId: string;
|
|
||||||
port: chrome.runtime.Port;
|
|
||||||
stopWaiters: Map<string, (ok: boolean) => void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BrowserFrame {
|
|
||||||
frameId: number;
|
|
||||||
documentId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BrowserFrameNavigation extends BrowserFrame {
|
|
||||||
tabId: number;
|
|
||||||
lifecycle: "committed" | "completed";
|
|
||||||
}
|
|
||||||
|
|
||||||
const RECORD_FRAME_STOP_TIMEOUT_MS = 5_000;
|
|
||||||
|
|
||||||
export interface RecordFrameCoordinatorDeps {
|
|
||||||
getAllFrames(tabId: number): Promise<BrowserFrame[]>;
|
|
||||||
sendToDocument(
|
|
||||||
tabId: number,
|
|
||||||
message: RecordFrameStartMessage,
|
|
||||||
target: { documentId?: string; frameId?: number },
|
|
||||||
): Promise<unknown>;
|
|
||||||
subscribeFrameNavigation?(listener: (frame: BrowserFrameNavigation) => void): () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function documentKey(tabId: number, documentId: string): string {
|
|
||||||
return `${tabId}:${documentId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function senderAddress(
|
|
||||||
sender: chrome.runtime.MessageSender,
|
|
||||||
): { tabId: number; documentId: string; browserFrameId: number } | null {
|
|
||||||
const tabId = sender.tab?.id;
|
|
||||||
const documentId = sender.documentId;
|
|
||||||
const browserFrameId = sender.frameId;
|
|
||||||
if (
|
|
||||||
typeof tabId !== "number" ||
|
|
||||||
typeof documentId !== "string" ||
|
|
||||||
typeof browserFrameId !== "number"
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { tabId, documentId, browserFrameId };
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultDeps(): RecordFrameCoordinatorDeps {
|
|
||||||
return {
|
|
||||||
async getAllFrames(tabId) {
|
|
||||||
return (await chrome.webNavigation.getAllFrames({ tabId })) ?? [];
|
|
||||||
},
|
|
||||||
sendToDocument(tabId, message, target) {
|
|
||||||
return chrome.tabs.sendMessage(tabId, message, target);
|
|
||||||
},
|
|
||||||
subscribeFrameNavigation(listener) {
|
|
||||||
const onCommitted = (details: chrome.webNavigation.WebNavigationTransitionCallbackDetails) =>
|
|
||||||
listener({ ...details, lifecycle: "committed" });
|
|
||||||
const onCompleted = (details: chrome.webNavigation.WebNavigationFramedCallbackDetails) =>
|
|
||||||
listener({ ...details, lifecycle: "completed" });
|
|
||||||
chrome.webNavigation.onCommitted.addListener(onCommitted);
|
|
||||||
chrome.webNavigation.onCompleted.addListener(onCompleted);
|
|
||||||
return () => {
|
|
||||||
chrome.webNavigation.onCommitted.removeListener(onCommitted);
|
|
||||||
chrome.webNavigation.onCompleted.removeListener(onCompleted);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RecordFrameCoordinator {
|
|
||||||
readonly #deps: RecordFrameCoordinatorDeps;
|
|
||||||
readonly #recordings = new Map<string, ArmedRecording>();
|
|
||||||
#attached = false;
|
|
||||||
#detachFrameNavigation: (() => void) | null = null;
|
|
||||||
|
|
||||||
constructor(deps: RecordFrameCoordinatorDeps = defaultDeps()) {
|
|
||||||
this.#deps = deps;
|
|
||||||
}
|
|
||||||
|
|
||||||
attach(): () => void {
|
|
||||||
if (this.#attached) return () => {};
|
|
||||||
this.#attached = true;
|
|
||||||
chrome.runtime.onConnect.addListener(this.#onConnect);
|
|
||||||
chrome.runtime.onMessage.addListener(this.#onMessage);
|
|
||||||
this.#detachFrameNavigation =
|
|
||||||
this.#deps.subscribeFrameNavigation?.(this.#onFrameNavigation) ?? null;
|
|
||||||
return () => {
|
|
||||||
if (!this.#attached) return;
|
|
||||||
this.#attached = false;
|
|
||||||
chrome.runtime.onConnect.removeListener(this.#onConnect);
|
|
||||||
chrome.runtime.onMessage.removeListener(this.#onMessage);
|
|
||||||
this.#detachFrameNavigation?.();
|
|
||||||
this.#detachFrameNavigation = null;
|
|
||||||
for (const recording of this.#recordings.values()) this.#cancelAgents(recording);
|
|
||||||
this.#recordings.clear();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
begin(
|
|
||||||
requestId: string,
|
|
||||||
startedAtMs: number,
|
|
||||||
initialTabId: number,
|
|
||||||
onDocumentReady?: (scope: RecordingCaptureScope) => void | Promise<void>,
|
|
||||||
): void {
|
|
||||||
const previous = this.#recordings.get(requestId);
|
|
||||||
if (previous) this.#cancelAgents(previous);
|
|
||||||
this.#recordings.set(requestId, {
|
|
||||||
requestId,
|
|
||||||
startedAtMs,
|
|
||||||
// Register the initial tab before navigation begins. A newly-created
|
|
||||||
// child document can query at document_start before armTab's frame
|
|
||||||
// snapshot sees it, especially when Chromium promotes it to an OOPIF.
|
|
||||||
tabIds: new Set([initialTabId]),
|
|
||||||
agents: new Map(),
|
|
||||||
...(onDocumentReady ? { onDocumentReady } : {}),
|
|
||||||
finishing: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async armTab(requestId: string, tabId: number): Promise<boolean> {
|
|
||||||
const recording = this.#recordings.get(requestId);
|
|
||||||
if (!recording || recording.finishing) return false;
|
|
||||||
recording.tabIds.add(tabId);
|
|
||||||
|
|
||||||
let frames: BrowserFrame[];
|
|
||||||
try {
|
|
||||||
frames = await this.#deps.getAllFrames(tabId);
|
|
||||||
} catch {
|
|
||||||
frames = [{ frameId: 0 }];
|
|
||||||
}
|
|
||||||
if (!frames.some((frame) => frame.frameId === 0)) frames.unshift({ frameId: 0 });
|
|
||||||
|
|
||||||
const results = await Promise.all(
|
|
||||||
frames.map(async (frame) => {
|
|
||||||
const started = await this.#startFrame(recording, tabId, frame);
|
|
||||||
return { frameId: frame.frameId, started };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return results.some((result) => result.frameId === 0 && result.started);
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceFor(
|
|
||||||
requestId: string,
|
|
||||||
producerId: string,
|
|
||||||
sender: chrome.runtime.MessageSender,
|
|
||||||
): RecordingCaptureScope | null {
|
|
||||||
const address = senderAddress(sender);
|
|
||||||
const recording = this.#recordings.get(requestId);
|
|
||||||
if (!address || !recording) return null;
|
|
||||||
const agent = recording.agents.get(documentKey(address.tabId, address.documentId));
|
|
||||||
if (!agent || agent.producerId !== producerId) return null;
|
|
||||||
return {
|
|
||||||
tabId: agent.tabId,
|
|
||||||
documentId: agent.documentId,
|
|
||||||
browserFrameId: agent.browserFrameId,
|
|
||||||
producerId: agent.producerId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async stop(requestId: string): Promise<boolean> {
|
|
||||||
const recording = this.#recordings.get(requestId);
|
|
||||||
if (!recording) return true;
|
|
||||||
recording.finishing = true;
|
|
||||||
const commandId = crypto.randomUUID();
|
|
||||||
const results = await Promise.all(
|
|
||||||
[...recording.agents.values()].map((agent) => this.#stopAgent(agent, requestId, commandId)),
|
|
||||||
);
|
|
||||||
const succeeded = results.every(Boolean);
|
|
||||||
if (succeeded) this.#recordings.delete(requestId);
|
|
||||||
else recording.finishing = false;
|
|
||||||
return succeeded;
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel(requestId: string): void {
|
|
||||||
const recording = this.#recordings.get(requestId);
|
|
||||||
if (!recording) return;
|
|
||||||
this.#cancelAgents(recording);
|
|
||||||
this.#recordings.delete(requestId);
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly #onMessage = (
|
|
||||||
message: unknown,
|
|
||||||
sender: chrome.runtime.MessageSender,
|
|
||||||
sendResponse: (response: RecordFrameQueryResponse) => void,
|
|
||||||
): boolean => {
|
|
||||||
if (!isRecordFrameQueryMessage(message)) return false;
|
|
||||||
const tabId = sender.tab?.id;
|
|
||||||
const recording =
|
|
||||||
typeof tabId === "number"
|
|
||||||
? [...this.#recordings.values()].find(
|
|
||||||
(candidate) => !candidate.finishing && candidate.tabIds.has(tabId),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
sendResponse(
|
|
||||||
recording
|
|
||||||
? {
|
|
||||||
active: true,
|
|
||||||
requestId: recording.requestId,
|
|
||||||
startedAtMs: recording.startedAtMs,
|
|
||||||
}
|
|
||||||
: { active: false },
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
readonly #onFrameNavigation = (frame: BrowserFrameNavigation): void => {
|
|
||||||
if (frame.frameId === 0) return;
|
|
||||||
for (const recording of this.#recordings.values()) {
|
|
||||||
if (recording.finishing || !recording.tabIds.has(frame.tabId)) continue;
|
|
||||||
// onCommitted can race document_start, while onCompleted runs after the
|
|
||||||
// content script is available. Starting twice is safe and closes both
|
|
||||||
// the early-query and late-SPA-frame gaps.
|
|
||||||
void (async () => {
|
|
||||||
const started = await this.#startFrame(recording, frame.tabId, frame);
|
|
||||||
if (!started || frame.lifecycle !== "completed" || recording.finishing) return;
|
|
||||||
const agent = frame.documentId
|
|
||||||
? recording.agents.get(documentKey(frame.tabId, frame.documentId))
|
|
||||||
: [...recording.agents.values()].find(
|
|
||||||
(candidate) =>
|
|
||||||
candidate.tabId === frame.tabId && candidate.browserFrameId === frame.frameId,
|
|
||||||
);
|
|
||||||
if (!agent) return;
|
|
||||||
await recording.onDocumentReady?.({
|
|
||||||
tabId: agent.tabId,
|
|
||||||
documentId: agent.documentId,
|
|
||||||
browserFrameId: agent.browserFrameId,
|
|
||||||
producerId: agent.producerId,
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
readonly #onConnect = (port: chrome.runtime.Port): void => {
|
|
||||||
if (port.name !== RECORD_FRAME_PORT) return;
|
|
||||||
const address = senderAddress(port.sender ?? {});
|
|
||||||
if (!address) {
|
|
||||||
port.disconnect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let registered: FrameAgent | null = null;
|
|
||||||
port.onMessage.addListener((raw: unknown) => {
|
|
||||||
const message = raw as Partial<RecordFramePortMessage>;
|
|
||||||
if (message.type === "ready") {
|
|
||||||
if (typeof message.requestId !== "string" || typeof message.producerId !== "string") {
|
|
||||||
port.disconnect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const requestId = message.requestId;
|
|
||||||
const producerId = message.producerId;
|
|
||||||
if (registered) {
|
|
||||||
if (registered.requestId === requestId && registered.producerId === producerId) {
|
|
||||||
port.postMessage({
|
|
||||||
type: "ready_ack",
|
|
||||||
requestId,
|
|
||||||
producerId,
|
|
||||||
} satisfies RecordFramePortMessage);
|
|
||||||
} else {
|
|
||||||
port.disconnect();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const recording = this.#recordings.get(requestId);
|
|
||||||
if (
|
|
||||||
!recording ||
|
|
||||||
recording.finishing ||
|
|
||||||
!recording.tabIds.has(address.tabId) ||
|
|
||||||
producerId.length === 0
|
|
||||||
) {
|
|
||||||
port.disconnect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const agent: FrameAgent = {
|
|
||||||
...address,
|
|
||||||
requestId,
|
|
||||||
producerId,
|
|
||||||
port,
|
|
||||||
stopWaiters: new Map(),
|
|
||||||
};
|
|
||||||
registered = agent;
|
|
||||||
const key = documentKey(address.tabId, address.documentId);
|
|
||||||
const previous = recording.agents.get(key);
|
|
||||||
if (previous && previous !== agent) previous.port.disconnect();
|
|
||||||
recording.agents.set(key, agent);
|
|
||||||
port.postMessage({
|
|
||||||
type: "ready_ack",
|
|
||||||
requestId,
|
|
||||||
producerId,
|
|
||||||
} satisfies RecordFramePortMessage);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
message.type === "stopped" &&
|
|
||||||
registered &&
|
|
||||||
message.requestId === registered.requestId &&
|
|
||||||
typeof message.commandId === "string"
|
|
||||||
) {
|
|
||||||
registered.stopWaiters.get(message.commandId)?.(message.ok === true);
|
|
||||||
registered.stopWaiters.delete(message.commandId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
port.onDisconnect.addListener(() => {
|
|
||||||
if (!registered) return;
|
|
||||||
for (const resolve of registered.stopWaiters.values()) resolve(true);
|
|
||||||
const recording = this.#recordings.get(registered.requestId);
|
|
||||||
const key = documentKey(registered.tabId, registered.documentId);
|
|
||||||
if (recording?.agents.get(key) === registered) recording.agents.delete(key);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
#stopAgent(agent: FrameAgent, requestId: string, commandId: string): Promise<boolean> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
agent.stopWaiters.delete(commandId);
|
|
||||||
resolve(false);
|
|
||||||
}, RECORD_FRAME_STOP_TIMEOUT_MS);
|
|
||||||
agent.stopWaiters.set(commandId, (ok) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve(ok);
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
agent.port.postMessage({
|
|
||||||
type: "stop",
|
|
||||||
requestId,
|
|
||||||
commandId,
|
|
||||||
} satisfies RecordFramePortMessage);
|
|
||||||
} catch {
|
|
||||||
clearTimeout(timer);
|
|
||||||
agent.stopWaiters.delete(commandId);
|
|
||||||
resolve(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async #startFrame(
|
|
||||||
recording: ArmedRecording,
|
|
||||||
tabId: number,
|
|
||||||
frame: BrowserFrame,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const message: RecordFrameStartMessage = {
|
|
||||||
type: RECORD_FRAME_START,
|
|
||||||
requestId: recording.requestId,
|
|
||||||
startedAtMs: recording.startedAtMs,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const response = await this.#deps.sendToDocument(tabId, message, {
|
|
||||||
...(frame.documentId ? { documentId: frame.documentId } : { frameId: frame.frameId }),
|
|
||||||
});
|
|
||||||
return isStarted(response);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#cancelAgents(recording: ArmedRecording): void {
|
|
||||||
for (const agent of recording.agents.values()) {
|
|
||||||
try {
|
|
||||||
agent.port.postMessage({
|
|
||||||
type: "cancel",
|
|
||||||
requestId: recording.requestId,
|
|
||||||
} satisfies RecordFramePortMessage);
|
|
||||||
} catch {
|
|
||||||
// A destroyed Document has no remaining capture state to cancel.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isStarted(value: unknown): boolean {
|
|
||||||
return typeof value === "object" && value !== null && (value as { ok?: unknown }).ok === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const recordFrameCoordinator = new RecordFrameCoordinator();
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
const REDIRECT_QUALIFIERS = new Set(["client_redirect", "server_redirect"]);
|
|
||||||
|
|
||||||
export function hasRedirectQualifier(qualifiers?: readonly string[]): boolean {
|
|
||||||
return (qualifiers ?? []).some((qualifier) => REDIRECT_QUALIFIERS.has(qualifier));
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import type { RenderedRef } from "@browser-skill/vom";
|
|
||||||
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
|
||||||
import {
|
|
||||||
type CaptureVomMatchNode,
|
|
||||||
type CaptureVomObservationResult,
|
|
||||||
captureVomObservation,
|
|
||||||
} from "@/tools/capture-vom-observation";
|
|
||||||
import type { CdpRunner, ChromeTabsApi } from "@/tools/shared";
|
|
||||||
|
|
||||||
export interface IndexedObservationNode {
|
|
||||||
frameId: string;
|
|
||||||
geometry: CaptureVomMatchNode;
|
|
||||||
ref?: RenderedRef;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CapturedRecordingObservation {
|
|
||||||
rootFrameId: string;
|
|
||||||
index: ObservationNodeIndex;
|
|
||||||
url: string;
|
|
||||||
title?: string;
|
|
||||||
vomText: string;
|
|
||||||
truncated: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RegisteredObservation {
|
|
||||||
stateId: string;
|
|
||||||
rootFrameId: string;
|
|
||||||
index: ObservationNodeIndex;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecordingDocumentScope {
|
|
||||||
frameId: string;
|
|
||||||
target: CdpTarget;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeKey(frameId: string, backendNodeId: number): string {
|
|
||||||
return `${frameId}:${backendNodeId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function frameTagKey(frameId: string, tag: string): string {
|
|
||||||
return `${frameId}:${tag.toLowerCase()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ObservationNodeIndex {
|
|
||||||
readonly #nodesByFrameTag = new Map<string, IndexedObservationNode[]>();
|
|
||||||
readonly #refById = new Map<string, RenderedRef>();
|
|
||||||
readonly #refsByFrame = new Map<string, RenderedRef[]>();
|
|
||||||
readonly #scopeByProducer = new Map<string, RecordingDocumentScope | null>();
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
input: Pick<CaptureVomObservationResult, "rootFrameId" | "matchNodes" | "refs"> &
|
|
||||||
Partial<Pick<CaptureVomObservationResult, "frames">>,
|
|
||||||
) {
|
|
||||||
const refByNode = new Map<string, RenderedRef>();
|
|
||||||
for (const ref of input.refs) {
|
|
||||||
const frameId = ref.frameId ?? input.rootFrameId;
|
|
||||||
refByNode.set(nodeKey(frameId, ref.backendNodeId), ref);
|
|
||||||
this.#refById.set(ref.ref, ref);
|
|
||||||
const frameRefs = this.#refsByFrame.get(frameId) ?? [];
|
|
||||||
frameRefs.push(ref);
|
|
||||||
this.#refsByFrame.set(frameId, frameRefs);
|
|
||||||
}
|
|
||||||
for (const frame of input.frames ?? []) {
|
|
||||||
const documentId = frame.recordingDocumentId;
|
|
||||||
if (!documentId) continue;
|
|
||||||
this.#scopeByProducer.set(
|
|
||||||
documentId,
|
|
||||||
this.#scopeByProducer.has(documentId)
|
|
||||||
? null
|
|
||||||
: { frameId: frame.frameId, target: frame.target },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const geometry of input.matchNodes) {
|
|
||||||
const { frameId } = geometry;
|
|
||||||
const entry = {
|
|
||||||
frameId,
|
|
||||||
geometry,
|
|
||||||
ref: refByNode.get(nodeKey(frameId, geometry.backendNodeId)),
|
|
||||||
};
|
|
||||||
const key = frameTagKey(frameId, geometry.tag);
|
|
||||||
const bucket = this.#nodesByFrameTag.get(key) ?? [];
|
|
||||||
bucket.push(entry);
|
|
||||||
this.#nodesByFrameTag.set(key, bucket);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
candidates(frameId: string, tag: string): readonly IndexedObservationNode[] {
|
|
||||||
return this.#nodesByFrameTag.get(frameTagKey(frameId, tag)) ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
ref(refId: string): RenderedRef | undefined {
|
|
||||||
return this.#refById.get(refId);
|
|
||||||
}
|
|
||||||
|
|
||||||
refs(frameId: string): readonly RenderedRef[] {
|
|
||||||
return this.#refsByFrame.get(frameId) ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
documentScope(producerId: string): RecordingDocumentScope | undefined {
|
|
||||||
return this.#scopeByProducer.get(producerId) ?? undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readTabMeta(
|
|
||||||
tabsApi: ChromeTabsApi,
|
|
||||||
tabId: number,
|
|
||||||
): Promise<{ url: string; title?: string }> {
|
|
||||||
try {
|
|
||||||
const tab = await tabsApi.get(tabId);
|
|
||||||
return { url: tab.url ?? "about:blank", title: tab.title };
|
|
||||||
} catch {
|
|
||||||
return { url: "about:blank" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function captureRecordingObservation(input: {
|
|
||||||
cdp: CdpRunner;
|
|
||||||
tabsApi: ChromeTabsApi;
|
|
||||||
tabId: number;
|
|
||||||
maxTokens: number;
|
|
||||||
redactValues: boolean;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
}): Promise<CapturedRecordingObservation> {
|
|
||||||
const { url, title } = await readTabMeta(input.tabsApi, input.tabId);
|
|
||||||
const captured = await captureVomObservation(input.cdp, input.tabId, url, {
|
|
||||||
maxTokens: input.maxTokens,
|
|
||||||
redactValues: input.redactValues,
|
|
||||||
conditionalSurfaceProbe: false,
|
|
||||||
signal: input.signal,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
rootFrameId: captured.rootFrameId,
|
|
||||||
index: new ObservationNodeIndex(captured),
|
|
||||||
url,
|
|
||||||
title,
|
|
||||||
vomText: captured.text,
|
|
||||||
truncated: captured.truncated,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import type { CdpRunner, ChromeTabsApi } from "@/tools/shared";
|
|
||||||
import { captureRecordingObservation, type RegisteredObservation } from "./observation-capture";
|
|
||||||
import { RecordingStateRegistry } from "./state-registry";
|
|
||||||
import { matchObservationTarget, unmatchedTarget } from "./target-matcher";
|
|
||||||
import type { RecordingDraftStep, StepAnnotation, TargetedRecordingDraft } from "./types";
|
|
||||||
|
|
||||||
const DEFAULT_MAX_PAGE_TOKENS = 3_000;
|
|
||||||
const MIN_CAPTURE_INTERVAL_MS = 200;
|
|
||||||
|
|
||||||
export interface TabObservationCursor {
|
|
||||||
lastSettled: RegisteredObservation | null;
|
|
||||||
lastCaptureAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (signal?.aborted) return resolve();
|
|
||||||
const timer = setTimeout(resolve, ms);
|
|
||||||
signal?.addEventListener(
|
|
||||||
"abort",
|
|
||||||
() => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve();
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTargeted(draft: RecordingDraftStep): draft is TargetedRecordingDraft {
|
|
||||||
return (
|
|
||||||
draft.op === "click" ||
|
|
||||||
draft.op === "hover" ||
|
|
||||||
draft.op === "fill" ||
|
|
||||||
draft.op === "press" ||
|
|
||||||
draft.op === "select"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RecordingObservationSession {
|
|
||||||
readonly registry: RecordingStateRegistry;
|
|
||||||
readonly cursor: TabObservationCursor;
|
|
||||||
readonly annotations: StepAnnotation[];
|
|
||||||
readonly #maxTokens: number;
|
|
||||||
readonly #redactValues: boolean;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
options: {
|
|
||||||
registry?: RecordingStateRegistry;
|
|
||||||
cursor?: TabObservationCursor;
|
|
||||||
annotations?: StepAnnotation[];
|
|
||||||
maxTokens?: number;
|
|
||||||
redactValues?: boolean;
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
this.registry = options.registry ?? new RecordingStateRegistry();
|
|
||||||
this.cursor = options.cursor ?? { lastSettled: null, lastCaptureAt: 0 };
|
|
||||||
this.annotations = options.annotations ?? [];
|
|
||||||
this.#maxTokens = options.maxTokens ?? DEFAULT_MAX_PAGE_TOKENS;
|
|
||||||
this.#redactValues = options.redactValues ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async capture(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabsApi: ChromeTabsApi,
|
|
||||||
tabId: number,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<RegisteredObservation> {
|
|
||||||
const waitMs = Math.max(0, MIN_CAPTURE_INTERVAL_MS - (Date.now() - this.cursor.lastCaptureAt));
|
|
||||||
if (waitMs > 0) await abortableDelay(waitMs, signal);
|
|
||||||
if (signal?.aborted) throw new DOMException("observation aborted", "AbortError");
|
|
||||||
const captured = await captureRecordingObservation({
|
|
||||||
cdp,
|
|
||||||
tabsApi,
|
|
||||||
tabId,
|
|
||||||
maxTokens: this.#maxTokens,
|
|
||||||
redactValues: this.#redactValues,
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
const state = this.registry.register({
|
|
||||||
url: captured.url,
|
|
||||||
title: captured.title,
|
|
||||||
vomText: captured.vomText,
|
|
||||||
truncated: captured.truncated,
|
|
||||||
});
|
|
||||||
const observation: RegisteredObservation = {
|
|
||||||
stateId: state.id,
|
|
||||||
rootFrameId: captured.rootFrameId,
|
|
||||||
index: captured.index,
|
|
||||||
url: captured.url,
|
|
||||||
};
|
|
||||||
this.cursor.lastSettled = observation;
|
|
||||||
this.cursor.lastCaptureAt = Date.now();
|
|
||||||
return observation;
|
|
||||||
}
|
|
||||||
|
|
||||||
bindDraft(draft: RecordingDraftStep, draftId: number, previousActionPending = false): void {
|
|
||||||
const observation = this.cursor.lastSettled;
|
|
||||||
if (isTargeted(draft)) {
|
|
||||||
draft.matchedTarget = observation
|
|
||||||
? matchObservationTarget({
|
|
||||||
observation,
|
|
||||||
hint: draft.targetHint,
|
|
||||||
fallback: draft.captureTarget,
|
|
||||||
})
|
|
||||||
: unmatchedTarget(draft.captureTarget);
|
|
||||||
}
|
|
||||||
if (!observation) return;
|
|
||||||
|
|
||||||
const unmatched = isTargeted(draft) && draft.matchedTarget?.unmatched === true;
|
|
||||||
if (previousActionPending && unmatched) return;
|
|
||||||
draft.preStateId = observation.stateId;
|
|
||||||
this.registry.markStep(observation.stateId, draftId);
|
|
||||||
|
|
||||||
if (!isTargeted(draft) || !draft.matchedTarget?.ref) return;
|
|
||||||
const ref = observation.index.ref(draft.matchedTarget.ref);
|
|
||||||
if (!ref) return;
|
|
||||||
this.annotations.push({
|
|
||||||
draftId,
|
|
||||||
op: draft.op,
|
|
||||||
line: ref.line,
|
|
||||||
stateId: observation.stateId,
|
|
||||||
...(draft.op === "fill" && !this.#redactValues
|
|
||||||
? { detail: JSON.stringify(draft.value) }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
import type { CdpRunner, ChromeTabsApi } from "@/tools/shared";
|
|
||||||
import type { StopReason, TraceV3 } from "@/transport/types";
|
|
||||||
import { type DocumentSettleScope, waitForDocumentSettled } from "./document-settle";
|
|
||||||
import type { RegisteredObservation } from "./observation-capture";
|
|
||||||
import { RecordingObservationSession } from "./observation-session";
|
|
||||||
import { inferMissingPostStates, SettleController } from "./settle-controller";
|
|
||||||
import { RecordingStateRegistry } from "./state-registry";
|
|
||||||
import { buildTraceV3 } from "./trace-builder-v3";
|
|
||||||
import type { RecordingDraftStep, StepAnnotation } from "./types";
|
|
||||||
|
|
||||||
interface TabRecordingContext {
|
|
||||||
session: RecordingObservationSession;
|
|
||||||
settle: SettleController;
|
|
||||||
pendingCapture: PendingCapture | null;
|
|
||||||
frameRefresh: Promise<void>;
|
|
||||||
frameRefreshAbort: AbortController | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PendingCapture {
|
|
||||||
promise: Promise<RegisteredObservation>;
|
|
||||||
abort: AbortController;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RecordingObservationRuntime {
|
|
||||||
readonly #cdp: CdpRunner;
|
|
||||||
readonly #tabsApi: ChromeTabsApi;
|
|
||||||
readonly #registry = new RecordingStateRegistry();
|
|
||||||
readonly #annotations: StepAnnotation[] = [];
|
|
||||||
readonly #contexts = new Map<number, TabRecordingContext>();
|
|
||||||
readonly #maxTokens?: number;
|
|
||||||
readonly #redactValues: boolean;
|
|
||||||
|
|
||||||
constructor(input: {
|
|
||||||
cdp: CdpRunner;
|
|
||||||
tabsApi: ChromeTabsApi;
|
|
||||||
maxTokens?: number;
|
|
||||||
redactValues?: boolean;
|
|
||||||
}) {
|
|
||||||
this.#cdp = input.cdp;
|
|
||||||
this.#tabsApi = input.tabsApi;
|
|
||||||
this.#maxTokens = input.maxTokens;
|
|
||||||
this.#redactValues = input.redactValues ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
#context(tabId: number): TabRecordingContext {
|
|
||||||
const existing = this.#contexts.get(tabId);
|
|
||||||
if (existing) return existing;
|
|
||||||
const session = new RecordingObservationSession({
|
|
||||||
registry: this.#registry,
|
|
||||||
annotations: this.#annotations,
|
|
||||||
maxTokens: this.#maxTokens,
|
|
||||||
redactValues: this.#redactValues,
|
|
||||||
});
|
|
||||||
const context: TabRecordingContext = {
|
|
||||||
session,
|
|
||||||
settle: new SettleController({
|
|
||||||
session,
|
|
||||||
cdp: this.#cdp,
|
|
||||||
tabsApi: this.#tabsApi,
|
|
||||||
tabId,
|
|
||||||
}),
|
|
||||||
pendingCapture: null,
|
|
||||||
frameRefresh: Promise.resolve(),
|
|
||||||
frameRefreshAbort: null,
|
|
||||||
};
|
|
||||||
this.#contexts.set(tabId, context);
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
async #capture(tabId: number): Promise<RegisteredObservation> {
|
|
||||||
const context = this.#context(tabId);
|
|
||||||
if (context.pendingCapture) return context.pendingCapture.promise;
|
|
||||||
|
|
||||||
const abort = new AbortController();
|
|
||||||
let pending!: PendingCapture;
|
|
||||||
const promise = context.session.capture(this.#cdp, this.#tabsApi, tabId, abort.signal);
|
|
||||||
pending = {
|
|
||||||
abort,
|
|
||||||
promise: promise.finally(() => {
|
|
||||||
if (context.pendingCapture === pending) context.pendingCapture = null;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
context.pendingCapture = pending;
|
|
||||||
return pending.promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async captureInitial(tabId: number): Promise<void> {
|
|
||||||
const context = this.#context(tabId);
|
|
||||||
if (context.session.cursor.lastSettled) return;
|
|
||||||
await this.#capture(tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Refresh the safe observation after a late child Document becomes usable. */
|
|
||||||
async refreshDocument(tabId: number, producerId: string): Promise<void> {
|
|
||||||
const context = this.#context(tabId);
|
|
||||||
context.frameRefreshAbort?.abort();
|
|
||||||
const abort = new AbortController();
|
|
||||||
context.frameRefreshAbort = abort;
|
|
||||||
const previous = context.frameRefresh;
|
|
||||||
const refresh = (async () => {
|
|
||||||
await previous;
|
|
||||||
if (abort.signal.aborted) return;
|
|
||||||
if (context.pendingCapture) await Promise.allSettled([context.pendingCapture.promise]);
|
|
||||||
if (abort.signal.aborted) return;
|
|
||||||
|
|
||||||
// The first capture discovers the safe producer -> CDP Document scope.
|
|
||||||
await this.#capture(tabId);
|
|
||||||
if (abort.signal.aborted) return;
|
|
||||||
const scope = context.session.cursor.lastSettled?.index.documentScope(producerId);
|
|
||||||
if (scope) {
|
|
||||||
await waitForDocumentSettled(this.#cdp, scope, { signal: abort.signal });
|
|
||||||
}
|
|
||||||
if (abort.signal.aborted) return;
|
|
||||||
|
|
||||||
// Capture again after the child SPA is quiet so the next action can be
|
|
||||||
// matched against refs that actually exist in its pre-action state.
|
|
||||||
await this.#capture(tabId);
|
|
||||||
})();
|
|
||||||
const tracked = refresh.catch(() => {});
|
|
||||||
context.frameRefresh = tracked;
|
|
||||||
try {
|
|
||||||
await refresh;
|
|
||||||
} finally {
|
|
||||||
if (context.frameRefresh === tracked) context.frameRefreshAbort = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async captureTabTransition(
|
|
||||||
fromTabId: number,
|
|
||||||
toTabId: number,
|
|
||||||
): Promise<{ preStateId?: string; postStateId?: string; targetUrl?: string }> {
|
|
||||||
const from = this.#context(fromTabId);
|
|
||||||
await this.#flushContext(from);
|
|
||||||
if (!from.session.cursor.lastSettled) {
|
|
||||||
try {
|
|
||||||
await this.captureInitial(fromTabId);
|
|
||||||
} catch {
|
|
||||||
// A target-tab observation can still preserve the forward transition.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const to = this.#context(toTabId);
|
|
||||||
let target = to.session.cursor.lastSettled;
|
|
||||||
try {
|
|
||||||
target = await this.#capture(toTabId);
|
|
||||||
} catch {
|
|
||||||
// Reuse the last settled target state when a refresh races navigation.
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = from.session.cursor.lastSettled;
|
|
||||||
return {
|
|
||||||
...(source ? { preStateId: source.stateId } : {}),
|
|
||||||
...(target ? { postStateId: target.stateId, targetUrl: target.url } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
bindTabTransition(
|
|
||||||
draft: Extract<RecordingDraftStep, { op: "switch_tab" }>,
|
|
||||||
draftId: number,
|
|
||||||
): void {
|
|
||||||
if (draft.preStateId) this.#registry.markStep(draft.preStateId, draftId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async processDraft(
|
|
||||||
tabId: number,
|
|
||||||
drafts: RecordingDraftStep[],
|
|
||||||
draftIndex: number,
|
|
||||||
producerId?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const draft = drafts[draftIndex];
|
|
||||||
if (!draft) return;
|
|
||||||
const context = this.#context(tabId);
|
|
||||||
if (!context.session.cursor.lastSettled) {
|
|
||||||
try {
|
|
||||||
await this.captureInitial(tabId);
|
|
||||||
} catch {
|
|
||||||
// Post-action settle can still provide a usable state.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const scope = producerId
|
|
||||||
? context.session.cursor.lastSettled?.index.documentScope(producerId)
|
|
||||||
: undefined;
|
|
||||||
if (
|
|
||||||
producerId &&
|
|
||||||
(draft.op === "click" ||
|
|
||||||
draft.op === "hover" ||
|
|
||||||
draft.op === "fill" ||
|
|
||||||
draft.op === "press" ||
|
|
||||||
draft.op === "select")
|
|
||||||
) {
|
|
||||||
draft.targetHint = {
|
|
||||||
...(draft.targetHint ?? {}),
|
|
||||||
frameId: scope?.frameId ?? null,
|
|
||||||
geometrySpace: "local",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
context.session.bindDraft(draft, draftIndex + 1, context.settle.hasPending);
|
|
||||||
context.settle.schedule(drafts, draftIndex, scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleSettle(
|
|
||||||
tabId: number,
|
|
||||||
drafts: RecordingDraftStep[],
|
|
||||||
draftIndex: number,
|
|
||||||
scope?: DocumentSettleScope,
|
|
||||||
): void {
|
|
||||||
this.#context(tabId).settle.schedule(drafts, draftIndex, scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearRedirect(tabId: number): void {
|
|
||||||
this.#contexts.get(tabId)?.settle.clearRedirect();
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleRedirect(tabId: number, drafts: RecordingDraftStep[], url: string): void {
|
|
||||||
this.#context(tabId).settle.scheduleRedirect(drafts, url);
|
|
||||||
}
|
|
||||||
|
|
||||||
async #flushContext(context: TabRecordingContext): Promise<void> {
|
|
||||||
await context.frameRefresh;
|
|
||||||
if (context.pendingCapture) await Promise.allSettled([context.pendingCapture.promise]);
|
|
||||||
await context.settle.flushRedirects();
|
|
||||||
await context.settle.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
async flushRedirects(tabId: number): Promise<void> {
|
|
||||||
await this.#contexts.get(tabId)?.settle.flushRedirects();
|
|
||||||
}
|
|
||||||
|
|
||||||
async flush(): Promise<void> {
|
|
||||||
for (const context of this.#contexts.values()) await this.#flushContext(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
async settleTrailing(tabId: number, drafts: RecordingDraftStep[]): Promise<void> {
|
|
||||||
const context = this.#context(tabId);
|
|
||||||
if (context.pendingCapture) await Promise.allSettled([context.pendingCapture.promise]);
|
|
||||||
if (!context.session.cursor.lastSettled) {
|
|
||||||
try {
|
|
||||||
await this.captureInitial(tabId);
|
|
||||||
} catch {
|
|
||||||
// The trace may remain observation-free when CDP is unavailable.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await context.settle.settleTrailing(drafts);
|
|
||||||
inferMissingPostStates(drafts);
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel(): void {
|
|
||||||
for (const context of this.#contexts.values()) {
|
|
||||||
context.frameRefreshAbort?.abort();
|
|
||||||
context.pendingCapture?.abort.abort();
|
|
||||||
context.settle.cancel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTrace(input: {
|
|
||||||
drafts: RecordingDraftStep[];
|
|
||||||
startedAt: string;
|
|
||||||
purpose?: string;
|
|
||||||
startUrl?: string;
|
|
||||||
stoppedBy: StopReason;
|
|
||||||
bskVersion: string;
|
|
||||||
includeTabSwitches?: boolean;
|
|
||||||
}): TraceV3 {
|
|
||||||
return buildTraceV3({
|
|
||||||
registry: this.#registry,
|
|
||||||
drafts: input.drafts,
|
|
||||||
annotations: this.#annotations,
|
|
||||||
startedAt: input.startedAt,
|
|
||||||
purpose: input.purpose,
|
|
||||||
startUrl: input.startUrl,
|
|
||||||
stoppedBy: input.stoppedBy,
|
|
||||||
bskVersion: input.bskVersion,
|
|
||||||
redactValues: this.#redactValues,
|
|
||||||
includeTabSwitches: input.includeTabSwitches,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
import type { CdpRunner, ChromeTabsApi } from "@/tools/shared";
|
|
||||||
import { type DocumentSettleScope, waitForDocumentSettled } from "./document-settle";
|
|
||||||
import { RecordingObservationSession } from "./observation-session";
|
|
||||||
import type { RecordingDraftStep } from "./types";
|
|
||||||
|
|
||||||
interface PendingSettle {
|
|
||||||
abort: AbortController;
|
|
||||||
scope: DocumentSettleScope;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PendingRedirect {
|
|
||||||
url: string;
|
|
||||||
abort: AbortController;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CAPTURE_RETRY_DELAY_MS = 250;
|
|
||||||
|
|
||||||
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (signal?.aborted) return resolve();
|
|
||||||
const timer = setTimeout(resolve, ms);
|
|
||||||
signal?.addEventListener(
|
|
||||||
"abort",
|
|
||||||
() => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve();
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAbortError(error: unknown): boolean {
|
|
||||||
return (
|
|
||||||
typeof error === "object" &&
|
|
||||||
error !== null &&
|
|
||||||
(error as { name?: string }).name === "AbortError"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function inferMissingPostStates(drafts: RecordingDraftStep[]): void {
|
|
||||||
for (let index = 0; index < drafts.length - 1; index += 1) {
|
|
||||||
const draft = drafts[index];
|
|
||||||
const next = drafts[index + 1];
|
|
||||||
if (draft && next && !draft.postStateId && next.preStateId) {
|
|
||||||
draft.postStateId = next.preStateId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SettleController {
|
|
||||||
readonly #session: RecordingObservationSession;
|
|
||||||
readonly #cdp: CdpRunner;
|
|
||||||
readonly #tabsApi: ChromeTabsApi;
|
|
||||||
readonly #tabId: number;
|
|
||||||
readonly #rootScope: DocumentSettleScope;
|
|
||||||
readonly #pending = new Map<number, PendingSettle>();
|
|
||||||
#queue = Promise.resolve();
|
|
||||||
#pendingRedirect: PendingRedirect | null = null;
|
|
||||||
#redirectQueue = Promise.resolve();
|
|
||||||
|
|
||||||
constructor(input: {
|
|
||||||
session: RecordingObservationSession;
|
|
||||||
cdp: CdpRunner;
|
|
||||||
tabsApi: ChromeTabsApi;
|
|
||||||
tabId: number;
|
|
||||||
}) {
|
|
||||||
this.#session = input.session;
|
|
||||||
this.#cdp = input.cdp;
|
|
||||||
this.#tabsApi = input.tabsApi;
|
|
||||||
this.#tabId = input.tabId;
|
|
||||||
this.#rootScope = { target: { tabId: input.tabId } };
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasPending(): boolean {
|
|
||||||
return this.#pending.size > 0 || this.#pendingRedirect !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async #captureWithRetry(signal?: AbortSignal) {
|
|
||||||
try {
|
|
||||||
return await this.#session.capture(this.#cdp, this.#tabsApi, this.#tabId, signal);
|
|
||||||
} catch (error) {
|
|
||||||
if (signal?.aborted) throw error;
|
|
||||||
await delay(CAPTURE_RETRY_DELAY_MS, signal);
|
|
||||||
return this.#session.capture(this.#cdp, this.#tabsApi, this.#tabId, signal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule(
|
|
||||||
drafts: RecordingDraftStep[],
|
|
||||||
draftIndex: number,
|
|
||||||
scope: DocumentSettleScope = this.#rootScope,
|
|
||||||
): void {
|
|
||||||
if (scope.target.tabId !== this.#tabId) {
|
|
||||||
throw new Error(
|
|
||||||
`settle scope tab ${scope.target.tabId} does not belong to tab ${this.#tabId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const landing = drafts[draftIndex]?.preStateId;
|
|
||||||
for (const [index, pending] of this.#pending) {
|
|
||||||
if (index >= draftIndex) continue;
|
|
||||||
pending.abort.abort();
|
|
||||||
this.#pending.delete(index);
|
|
||||||
if (landing && drafts[index] && !drafts[index].postStateId) {
|
|
||||||
drafts[index].postStateId = landing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#pending.get(draftIndex)?.abort.abort();
|
|
||||||
const pending = { abort: new AbortController(), scope };
|
|
||||||
this.#pending.set(draftIndex, pending);
|
|
||||||
const task = async () => {
|
|
||||||
if (pending.abort.signal.aborted) return;
|
|
||||||
try {
|
|
||||||
const outcome = await waitForDocumentSettled(this.#cdp, pending.scope, {
|
|
||||||
signal: pending.abort.signal,
|
|
||||||
});
|
|
||||||
if (outcome === "cancelled") return;
|
|
||||||
const observation = await this.#captureWithRetry(pending.abort.signal);
|
|
||||||
if (!pending.abort.signal.aborted && drafts[draftIndex]) {
|
|
||||||
drafts[draftIndex].postStateId = observation.stateId;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!isAbortError(error)) {
|
|
||||||
console.warn(
|
|
||||||
`[bsk record] post-action observation failed for step ${draftIndex + 1}`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (this.#pending.get(draftIndex) === pending) this.#pending.delete(draftIndex);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.#queue = this.#queue.then(task, task).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel(): void {
|
|
||||||
for (const pending of this.#pending.values()) pending.abort.abort();
|
|
||||||
this.#pending.clear();
|
|
||||||
this.clearRedirect();
|
|
||||||
}
|
|
||||||
|
|
||||||
async flush(): Promise<void> {
|
|
||||||
for (let round = 0; round < 10; round += 1) {
|
|
||||||
const current = this.#queue;
|
|
||||||
await current;
|
|
||||||
if (current === this.#queue) return;
|
|
||||||
}
|
|
||||||
console.warn("[bsk record] settle queue kept growing; using completed observations");
|
|
||||||
}
|
|
||||||
|
|
||||||
async settleTrailing(drafts: RecordingDraftStep[]): Promise<void> {
|
|
||||||
const trailing: RecordingDraftStep[] = [];
|
|
||||||
for (let index = drafts.length - 1; index >= 0; index -= 1) {
|
|
||||||
const draft = drafts[index];
|
|
||||||
if (!draft || draft.postStateId) break;
|
|
||||||
trailing.push(draft);
|
|
||||||
}
|
|
||||||
if (trailing.length === 0) return;
|
|
||||||
try {
|
|
||||||
const observation = await this.#captureWithRetry();
|
|
||||||
for (const draft of trailing) draft.postStateId = observation.stateId;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("[bsk record] final observation at stop failed", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearRedirect(): void {
|
|
||||||
this.#pendingRedirect?.abort.abort();
|
|
||||||
this.#pendingRedirect = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleRedirect(drafts: RecordingDraftStep[], url: string): void {
|
|
||||||
this.#pendingRedirect?.abort.abort();
|
|
||||||
const pending: PendingRedirect = { url, abort: new AbortController() };
|
|
||||||
this.#pendingRedirect = pending;
|
|
||||||
const task = () => this.#settleRedirect(drafts, pending);
|
|
||||||
this.#redirectQueue = this.#redirectQueue.then(task, task).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async #settleRedirect(drafts: RecordingDraftStep[], pending: PendingRedirect): Promise<void> {
|
|
||||||
const outcome = await waitForDocumentSettled(this.#cdp, this.#rootScope, {
|
|
||||||
signal: pending.abort.signal,
|
|
||||||
});
|
|
||||||
if (outcome === "cancelled" || this.#pendingRedirect !== pending) return;
|
|
||||||
|
|
||||||
let finalUrl = pending.url;
|
|
||||||
try {
|
|
||||||
finalUrl = (await this.#tabsApi.get(this.#tabId)).url || finalUrl;
|
|
||||||
} catch {
|
|
||||||
// The navigation event URL remains the best available destination.
|
|
||||||
}
|
|
||||||
if (this.#pendingRedirect !== pending) return;
|
|
||||||
this.#pendingRedirect = null;
|
|
||||||
if (
|
|
||||||
!finalUrl ||
|
|
||||||
finalUrl === "about:blank" ||
|
|
||||||
this.#session.cursor.lastSettled?.url === finalUrl
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const last = drafts[drafts.length - 1];
|
|
||||||
if (last?.op === "navigate" && last.url === finalUrl) {
|
|
||||||
if (!last.postStateId) this.schedule(drafts, drafts.length - 1);
|
|
||||||
await this.flush();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const draft: RecordingDraftStep = {
|
|
||||||
op: "navigate",
|
|
||||||
url: finalUrl,
|
|
||||||
pageUrl: finalUrl,
|
|
||||||
cause: "browser",
|
|
||||||
transitionQualifiers: ["server_redirect"],
|
|
||||||
preStateId: this.#session.cursor.lastSettled?.stateId,
|
|
||||||
};
|
|
||||||
drafts.push(draft);
|
|
||||||
const draftIndex = drafts.length - 1;
|
|
||||||
if (draft.preStateId) this.#session.registry.markStep(draft.preStateId, draftIndex + 1);
|
|
||||||
this.schedule(drafts, draftIndex);
|
|
||||||
await this.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
async flushRedirects(): Promise<void> {
|
|
||||||
for (let round = 0; round < 10; round += 1) {
|
|
||||||
const current = this.#redirectQueue;
|
|
||||||
await current;
|
|
||||||
if (current === this.#redirectQueue) return;
|
|
||||||
}
|
|
||||||
console.warn("[bsk record] redirect queue kept growing; using the final completed landing");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
export interface RecordedStateEntry {
|
|
||||||
id: string;
|
|
||||||
url: string;
|
|
||||||
title?: string;
|
|
||||||
/** Rendered VOM text; capture indexes and source DOM/AX data never enter the registry. */
|
|
||||||
vomText: string;
|
|
||||||
truncated: boolean;
|
|
||||||
stepsHere: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function stateIdentity(url: string, body: string): string {
|
|
||||||
return `${url}\0${body}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RecordingStateRegistry {
|
|
||||||
readonly #entriesById = new Map<string, RecordedStateEntry>();
|
|
||||||
readonly #idByIdentity = new Map<string, string>();
|
|
||||||
#nextId = 1;
|
|
||||||
|
|
||||||
register(input: {
|
|
||||||
url: string;
|
|
||||||
title?: string;
|
|
||||||
vomText: string;
|
|
||||||
truncated?: boolean;
|
|
||||||
}): RecordedStateEntry {
|
|
||||||
const identity = stateIdentity(input.url, input.vomText);
|
|
||||||
const existingId = this.#idByIdentity.get(identity);
|
|
||||||
if (existingId) {
|
|
||||||
const existing = this.#entriesById.get(existingId)!;
|
|
||||||
if (!existing.title && input.title) existing.title = input.title;
|
|
||||||
if (input.truncated) existing.truncated = true;
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry: RecordedStateEntry = {
|
|
||||||
id: `s${this.#nextId}`,
|
|
||||||
url: input.url,
|
|
||||||
...(input.title ? { title: input.title } : {}),
|
|
||||||
vomText: input.vomText,
|
|
||||||
truncated: input.truncated ?? false,
|
|
||||||
stepsHere: [],
|
|
||||||
};
|
|
||||||
this.#nextId += 1;
|
|
||||||
this.#entriesById.set(entry.id, entry);
|
|
||||||
this.#idByIdentity.set(identity, entry.id);
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
values(): RecordedStateEntry[] {
|
|
||||||
return [...this.#entriesById.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
markStep(stateId: string, draftId: number): void {
|
|
||||||
const entry = this.#entriesById.get(stateId);
|
|
||||||
if (entry && !entry.stepsHere.includes(draftId)) entry.stepsHere.push(draftId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import type { RecordStepPayload } from "../record-bridge";
|
|
||||||
import { hasRedirectQualifier } from "./navigation-policy";
|
|
||||||
import type { RecordingDraftStep, TargetMatchHint } from "./types";
|
|
||||||
|
|
||||||
export interface RecordingStepBuffer {
|
|
||||||
steps: RecordingDraftStep[];
|
|
||||||
navigation: RecordingNavigationCursor;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecordingNavigationCursor {
|
|
||||||
currentUrl?: string;
|
|
||||||
pendingNavigation: boolean;
|
|
||||||
pendingNavigationDeadline?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NAVIGATION_TRIGGER_WINDOW_MS = 3_000;
|
|
||||||
|
|
||||||
function toDraftStep(
|
|
||||||
payload: RecordStepPayload,
|
|
||||||
targetHint?: TargetMatchHint,
|
|
||||||
): RecordingDraftStep | null {
|
|
||||||
const pageUrl = payload.page_url;
|
|
||||||
const common = {
|
|
||||||
...(pageUrl ? { pageUrl } : {}),
|
|
||||||
...(targetHint ? { targetHint } : {}),
|
|
||||||
};
|
|
||||||
switch (payload.op) {
|
|
||||||
case "click":
|
|
||||||
return payload.target ? { op: "click", captureTarget: payload.target, ...common } : null;
|
|
||||||
case "hover":
|
|
||||||
return payload.target ? { op: "hover", captureTarget: payload.target, ...common } : null;
|
|
||||||
case "fill":
|
|
||||||
return payload.target
|
|
||||||
? {
|
|
||||||
op: "fill",
|
|
||||||
captureTarget: payload.target,
|
|
||||||
value: payload.value ?? "",
|
|
||||||
...(payload.commit ? { commit: payload.commit } : {}),
|
|
||||||
...(payload.redacted ? { redacted: true } : {}),
|
|
||||||
...common,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
case "press":
|
|
||||||
return payload.key
|
|
||||||
? {
|
|
||||||
op: "press",
|
|
||||||
key: payload.key,
|
|
||||||
...(payload.target ? { captureTarget: payload.target } : {}),
|
|
||||||
...(payload.modifiers?.length ? { modifiers: payload.modifiers } : {}),
|
|
||||||
...common,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
case "select":
|
|
||||||
return payload.target && payload.values
|
|
||||||
? {
|
|
||||||
op: "select",
|
|
||||||
captureTarget: payload.target,
|
|
||||||
values: payload.values,
|
|
||||||
...(payload.labels?.length ? { labels: payload.labels } : {}),
|
|
||||||
...common,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
case "navigate":
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function annotateLastStepNavigation(buffer: RecordingStepBuffer, url: string): number {
|
|
||||||
for (let index = buffer.steps.length - 1; index >= 0; index -= 1) {
|
|
||||||
const step = buffer.steps[index];
|
|
||||||
if (!step) continue;
|
|
||||||
if (step.op === "click" || step.op === "press" || step.op === "select" || step.op === "fill") {
|
|
||||||
step.navigatedTo = url;
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NavigationObserveResult =
|
|
||||||
| { kind: "noop" }
|
|
||||||
| { kind: "annotated"; index: number }
|
|
||||||
| { kind: "appended"; index: number }
|
|
||||||
| { kind: "coalesce_redirect"; url: string };
|
|
||||||
|
|
||||||
export function observeRecordedNavigation(
|
|
||||||
buffer: RecordingStepBuffer,
|
|
||||||
url: string,
|
|
||||||
causedByAction?: boolean,
|
|
||||||
transitionType?: string,
|
|
||||||
transitionQualifiers?: string[],
|
|
||||||
): NavigationObserveResult {
|
|
||||||
const navigation = buffer.navigation;
|
|
||||||
if (!url || url === navigation.currentUrl) return { kind: "noop" };
|
|
||||||
navigation.currentUrl = url;
|
|
||||||
|
|
||||||
const pendingIsCurrent =
|
|
||||||
navigation.pendingNavigation &&
|
|
||||||
(navigation.pendingNavigationDeadline === undefined ||
|
|
||||||
navigation.pendingNavigationDeadline >= Date.now());
|
|
||||||
|
|
||||||
if (causedByAction === true || (causedByAction === undefined && pendingIsCurrent)) {
|
|
||||||
navigation.pendingNavigation = false;
|
|
||||||
navigation.pendingNavigationDeadline = undefined;
|
|
||||||
const annotatedIndex = annotateLastStepNavigation(buffer, url);
|
|
||||||
if (annotatedIndex >= 0) return { kind: "annotated", index: annotatedIndex };
|
|
||||||
} else {
|
|
||||||
navigation.pendingNavigation = false;
|
|
||||||
navigation.pendingNavigationDeadline = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasRedirectQualifier(transitionQualifiers)) {
|
|
||||||
return { kind: "coalesce_redirect", url };
|
|
||||||
}
|
|
||||||
buffer.steps.push({
|
|
||||||
op: "navigate",
|
|
||||||
url,
|
|
||||||
pageUrl: url,
|
|
||||||
transitionType,
|
|
||||||
transitionQualifiers,
|
|
||||||
});
|
|
||||||
return { kind: "appended", index: buffer.steps.length - 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appendRecordedPayload(
|
|
||||||
buffer: RecordingStepBuffer,
|
|
||||||
payload: RecordStepPayload,
|
|
||||||
targetHint?: TargetMatchHint,
|
|
||||||
): number | null {
|
|
||||||
if (payload.op === "navigate") {
|
|
||||||
if (!payload.url) return null;
|
|
||||||
const result = observeRecordedNavigation(
|
|
||||||
buffer,
|
|
||||||
payload.url,
|
|
||||||
payload.navigation_caused_by_action,
|
|
||||||
payload.transitionType,
|
|
||||||
payload.transitionQualifiers,
|
|
||||||
);
|
|
||||||
return result.kind === "appended" ? result.index : null;
|
|
||||||
}
|
|
||||||
const step = toDraftStep(
|
|
||||||
{ ...payload, page_url: payload.page_url ?? buffer.navigation.currentUrl },
|
|
||||||
targetHint,
|
|
||||||
);
|
|
||||||
if (!step) return null;
|
|
||||||
buffer.steps.push(step);
|
|
||||||
if (step.op === "click" || step.op === "press" || step.op === "select" || step.op === "fill") {
|
|
||||||
buffer.navigation.pendingNavigation = payload.expects_navigation === true;
|
|
||||||
buffer.navigation.pendingNavigationDeadline = buffer.navigation.pendingNavigation
|
|
||||||
? Date.now() + NAVIGATION_TRIGGER_WINDOW_MS
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
return buffer.steps.length - 1;
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import type { RecordingNavigationCursor } from "./step-buffer";
|
|
||||||
|
|
||||||
export interface TabActivation {
|
|
||||||
tabId: number;
|
|
||||||
revision: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RecordingTabCoordinator {
|
|
||||||
readonly #navigationByTab = new Map<number, RecordingNavigationCursor>();
|
|
||||||
#activeTabId: number;
|
|
||||||
#currentTabId: number;
|
|
||||||
#activationRevision = 0;
|
|
||||||
|
|
||||||
constructor(initialTabId: number, initialUrl?: string) {
|
|
||||||
this.#activeTabId = initialTabId;
|
|
||||||
this.#currentTabId = initialTabId;
|
|
||||||
this.#navigationByTab.set(initialTabId, {
|
|
||||||
currentUrl: initialUrl,
|
|
||||||
pendingNavigation: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get activeTabId(): number {
|
|
||||||
return this.#activeTabId;
|
|
||||||
}
|
|
||||||
|
|
||||||
get currentTabId(): number {
|
|
||||||
return this.#currentTabId;
|
|
||||||
}
|
|
||||||
|
|
||||||
noteActivation(tabId: number): TabActivation {
|
|
||||||
this.#activeTabId = tabId;
|
|
||||||
this.#activationRevision += 1;
|
|
||||||
return { tabId, revision: this.#activationRevision };
|
|
||||||
}
|
|
||||||
|
|
||||||
isLatest(activation: TabActivation): boolean {
|
|
||||||
return (
|
|
||||||
activation.tabId === this.#activeTabId && activation.revision === this.#activationRevision
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
commit(tabId: number, currentUrl?: string): void {
|
|
||||||
if (currentUrl !== undefined) this.navigation(tabId).currentUrl = currentUrl;
|
|
||||||
this.#currentTabId = tabId;
|
|
||||||
}
|
|
||||||
|
|
||||||
navigation(tabId: number, fallbackUrl?: string): RecordingNavigationCursor {
|
|
||||||
const existing = this.#navigationByTab.get(tabId);
|
|
||||||
if (existing) {
|
|
||||||
if (!existing.currentUrl && fallbackUrl) existing.currentUrl = fallbackUrl;
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
const created: RecordingNavigationCursor = {
|
|
||||||
currentUrl: fallbackUrl,
|
|
||||||
pendingNavigation: false,
|
|
||||||
};
|
|
||||||
this.#navigationByTab.set(tabId, created);
|
|
||||||
return created;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import type { RenderedRef } from "@browser-skill/vom";
|
|
||||||
import type { TargetDescriptorV3 } from "@/transport/types";
|
|
||||||
import type { CaptureTargetDescriptor } from "../describe-target";
|
|
||||||
import type { IndexedObservationNode, RegisteredObservation } from "./observation-capture";
|
|
||||||
import type { TargetGeometry, TargetMatchHint } from "./types";
|
|
||||||
|
|
||||||
const MATCH_TOLERANCE_PX = 2;
|
|
||||||
|
|
||||||
function close(a: number, b: number): boolean {
|
|
||||||
return Math.abs(a - b) <= MATCH_TOLERANCE_PX;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rectMatches(a: TargetGeometry["rect"], b: TargetGeometry["rect"]): boolean {
|
|
||||||
return close(a.x, b.x) && close(a.y, b.y) && close(a.w, b.w) && close(a.h, b.h);
|
|
||||||
}
|
|
||||||
|
|
||||||
function candidateMatches(
|
|
||||||
candidate: IndexedObservationNode,
|
|
||||||
geometry: TargetGeometry,
|
|
||||||
geometrySpace: "top" | "local",
|
|
||||||
): boolean {
|
|
||||||
const rect = geometrySpace === "local" ? candidate.geometry.localRect : candidate.geometry.rect;
|
|
||||||
return rect != null && rectMatches(geometry.rect, rect);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unmatchedTarget(fallback?: CaptureTargetDescriptor): TargetDescriptorV3 {
|
|
||||||
return {
|
|
||||||
...(fallback?.role ? { role: fallback.role } : {}),
|
|
||||||
...(fallback?.name ? { name: fallback.name } : {}),
|
|
||||||
unmatched: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalized(value: string | undefined): string {
|
|
||||||
return value?.replace(/\s+/g, " ").trim().toLowerCase() ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesSemantics(ref: RenderedRef, fallback?: CaptureTargetDescriptor): boolean {
|
|
||||||
const role = normalized(fallback?.role);
|
|
||||||
const name = normalized(fallback?.name);
|
|
||||||
if (!role && !name) return false;
|
|
||||||
if (role && normalized(ref.role) !== role) return false;
|
|
||||||
if (name && normalized(ref.name) !== name) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function descriptor(ref: RenderedRef): TargetDescriptorV3 {
|
|
||||||
return {
|
|
||||||
ref: ref.ref,
|
|
||||||
...(ref.role ? { role: ref.role } : {}),
|
|
||||||
...(ref.name ? { name: ref.name } : {}),
|
|
||||||
...(ref.ctx ? { ctx: ref.ctx } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matchObservationTarget(input: {
|
|
||||||
observation: RegisteredObservation;
|
|
||||||
hint?: TargetMatchHint;
|
|
||||||
fallback?: CaptureTargetDescriptor;
|
|
||||||
}): TargetDescriptorV3 {
|
|
||||||
if (input.hint?.frameId === null) return unmatchedTarget(input.fallback);
|
|
||||||
const frameId = input.hint?.frameId ?? input.observation.rootFrameId;
|
|
||||||
const geometry = input.hint?.geometry;
|
|
||||||
if (geometry) {
|
|
||||||
const matches = input.observation.index
|
|
||||||
.candidates(frameId, geometry.tag)
|
|
||||||
.filter(
|
|
||||||
(candidate) =>
|
|
||||||
candidate.ref &&
|
|
||||||
candidateMatches(candidate, geometry, input.hint?.geometrySpace ?? "top"),
|
|
||||||
);
|
|
||||||
if (matches.length === 1) return descriptor(matches[0]!.ref!);
|
|
||||||
const semanticMatches = matches.filter(
|
|
||||||
(candidate) => candidate.ref && matchesSemantics(candidate.ref, input.fallback),
|
|
||||||
);
|
|
||||||
if (semanticMatches.length === 1) return descriptor(semanticMatches[0]!.ref!);
|
|
||||||
return unmatchedTarget(input.fallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
const semanticMatches = input.observation.index
|
|
||||||
.refs(frameId)
|
|
||||||
.filter((ref) => matchesSemantics(ref, input.fallback));
|
|
||||||
return semanticMatches.length === 1
|
|
||||||
? descriptor(semanticMatches[0]!)
|
|
||||||
: unmatchedTarget(input.fallback);
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import {
|
|
||||||
type StepV3,
|
|
||||||
type StopReason,
|
|
||||||
TRACE_VERSION_V3,
|
|
||||||
type TraceStateV3,
|
|
||||||
type TraceV3,
|
|
||||||
VOM_FORMAT_VERSION,
|
|
||||||
} from "@/transport/types";
|
|
||||||
import { resolveDraftStartUrl } from "./draft-policy";
|
|
||||||
import type { RecordedStateEntry, RecordingStateRegistry } from "./state-registry";
|
|
||||||
import { reduceTraceStepsV3 } from "./trace-reducer-v3";
|
|
||||||
import { formatTraceStateBody } from "./trace-state-body";
|
|
||||||
import type { RecordingDraftStep, StepAnnotation } from "./types";
|
|
||||||
|
|
||||||
function publishedEntries(registry: RecordingStateRegistry, steps: StepV3[]): RecordedStateEntry[] {
|
|
||||||
const entries = registry.values();
|
|
||||||
if (steps.length === 0) return entries.slice(0, 1);
|
|
||||||
const referenced = new Set(steps.flatMap((step) => [step.state, step.result.state]));
|
|
||||||
return entries.filter((entry) => referenced.has(entry.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
function remapDraftIds(draftIds: number[], stepIdByDraftId: Map<number, number>): number[] {
|
|
||||||
return [
|
|
||||||
...new Set(
|
|
||||||
draftIds.flatMap((id) => {
|
|
||||||
const stepId = stepIdByDraftId.get(id);
|
|
||||||
return stepId === undefined ? [] : [stepId];
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
].sort((a, b) => a - b);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildTraceV3(input: {
|
|
||||||
registry: RecordingStateRegistry;
|
|
||||||
drafts: RecordingDraftStep[];
|
|
||||||
annotations?: StepAnnotation[];
|
|
||||||
startedAt: string;
|
|
||||||
purpose?: string;
|
|
||||||
startUrl?: string;
|
|
||||||
stoppedBy: StopReason;
|
|
||||||
bskVersion: string;
|
|
||||||
redactValues?: boolean;
|
|
||||||
includeTabSwitches?: boolean;
|
|
||||||
}): TraceV3 {
|
|
||||||
const reduced = reduceTraceStepsV3(input.drafts, {
|
|
||||||
includeTabSwitches: input.includeTabSwitches,
|
|
||||||
redactValues: input.redactValues,
|
|
||||||
});
|
|
||||||
const entries = publishedEntries(input.registry, reduced.steps);
|
|
||||||
const publishedId = new Map(entries.map((entry, index) => [entry.id, `s${index + 1}`]));
|
|
||||||
const annotationsByState = new Map<string, StepAnnotation[]>();
|
|
||||||
for (const annotation of input.annotations ?? []) {
|
|
||||||
const bucket = annotationsByState.get(annotation.stateId) ?? [];
|
|
||||||
bucket.push(annotation);
|
|
||||||
annotationsByState.set(annotation.stateId, bucket);
|
|
||||||
}
|
|
||||||
const steps = reduced.steps.map((step) => ({
|
|
||||||
...step,
|
|
||||||
state: publishedId.get(step.state) ?? step.state,
|
|
||||||
result: { state: publishedId.get(step.result.state) ?? step.result.state },
|
|
||||||
}));
|
|
||||||
const states: TraceStateV3[] = entries.map((entry) => {
|
|
||||||
const id = publishedId.get(entry.id) ?? entry.id;
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
url: entry.url,
|
|
||||||
...(entry.title ? { title: entry.title } : {}),
|
|
||||||
body: formatTraceStateBody({
|
|
||||||
stateId: id,
|
|
||||||
url: entry.url,
|
|
||||||
title: entry.title,
|
|
||||||
stepIds: remapDraftIds(entry.stepsHere, reduced.stepIdByDraftId),
|
|
||||||
vomText: entry.vomText,
|
|
||||||
annotations: annotationsByState.get(entry.id) ?? [],
|
|
||||||
stepIdByDraftId: reduced.stepIdByDraftId,
|
|
||||||
}),
|
|
||||||
...(entry.truncated ? { truncated: true } : {}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
version: TRACE_VERSION_V3,
|
|
||||||
...(input.purpose ? { purpose: input.purpose } : {}),
|
|
||||||
recorded_at: new Date().toISOString(),
|
|
||||||
started_at: input.startedAt,
|
|
||||||
stopped_by: input.stoppedBy,
|
|
||||||
entry: { start_url: resolveDraftStartUrl(input.drafts, input.startUrl, states[0]?.url) },
|
|
||||||
recorder: { bsk: input.bskVersion, vom: VOM_FORMAT_VERSION },
|
|
||||||
states,
|
|
||||||
steps,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
import type { NavigationCause, StepV3 } from "@/transport/types";
|
|
||||||
import { shouldIncludeDraft } from "./draft-policy";
|
|
||||||
import { hasRedirectQualifier } from "./navigation-policy";
|
|
||||||
import { unmatchedTarget } from "./target-matcher";
|
|
||||||
import type { RecordingDraftStep } from "./types";
|
|
||||||
|
|
||||||
interface CollapsedDraft {
|
|
||||||
draft: RecordingDraftStep;
|
|
||||||
draftIds: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const TRANSITION_CAUSES: Record<string, NavigationCause> = {
|
|
||||||
typed: "user_typed",
|
|
||||||
generated: "user_typed",
|
|
||||||
keyword: "user_typed",
|
|
||||||
keyword_generated: "user_typed",
|
|
||||||
link: "link",
|
|
||||||
form_submit: "form_submit",
|
|
||||||
reload: "reload",
|
|
||||||
auto_bookmark: "browser",
|
|
||||||
start_page: "browser",
|
|
||||||
};
|
|
||||||
|
|
||||||
function isRedirect(step: Extract<RecordingDraftStep, { op: "navigate" }>): boolean {
|
|
||||||
return hasRedirectQualifier(step.transitionQualifiers);
|
|
||||||
}
|
|
||||||
|
|
||||||
function collapseRedirects(steps: RecordingDraftStep[]): CollapsedDraft[] {
|
|
||||||
const output: CollapsedDraft[] = [];
|
|
||||||
steps.forEach((step, index) => {
|
|
||||||
const previous = output[output.length - 1];
|
|
||||||
if (step.op === "navigate" && previous?.draft.op === "navigate" && isRedirect(step)) {
|
|
||||||
previous.draft = {
|
|
||||||
...previous.draft,
|
|
||||||
url: step.url,
|
|
||||||
postStateId: step.postStateId ?? previous.draft.postStateId,
|
|
||||||
};
|
|
||||||
previous.draftIds.push(index + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
output.push({ draft: { ...step }, draftIds: [index + 1] });
|
|
||||||
});
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigationCause(step: Extract<RecordingDraftStep, { op: "navigate" }>): NavigationCause {
|
|
||||||
if (step.cause) return step.cause;
|
|
||||||
const qualifiers = step.transitionQualifiers ?? [];
|
|
||||||
if (qualifiers.includes("forward_back")) return "history";
|
|
||||||
if (qualifiers.includes("from_address_bar")) return "user_typed";
|
|
||||||
return TRANSITION_CAUSES[step.transitionType ?? ""] ?? "browser";
|
|
||||||
}
|
|
||||||
|
|
||||||
function selection(values: string[], labels?: string[]): Array<{ value: string; label?: string }> {
|
|
||||||
return values.map((value, index) => ({
|
|
||||||
value,
|
|
||||||
...(labels?.[index] ? { label: labels[index] } : {}),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReduceDraftOptions {
|
|
||||||
includeTabSwitches: boolean;
|
|
||||||
redactValues: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reduceDraft(
|
|
||||||
draft: RecordingDraftStep,
|
|
||||||
id: number,
|
|
||||||
options: ReduceDraftOptions,
|
|
||||||
): StepV3 | null {
|
|
||||||
if (!shouldIncludeDraft(draft)) return null;
|
|
||||||
if (draft.op === "switch_tab") {
|
|
||||||
if (!options.includeTabSwitches || !draft.preStateId || !draft.postStateId) return null;
|
|
||||||
return {
|
|
||||||
op: "switch_tab",
|
|
||||||
id,
|
|
||||||
state: draft.preStateId,
|
|
||||||
result: { state: draft.postStateId },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const state = draft.preStateId ?? draft.postStateId;
|
|
||||||
const resultState = draft.postStateId ?? draft.preStateId;
|
|
||||||
if (!state || !resultState) return null;
|
|
||||||
const common = { id, state, result: { state: resultState } };
|
|
||||||
|
|
||||||
switch (draft.op) {
|
|
||||||
case "navigate":
|
|
||||||
return { op: "navigate", ...common, to: draft.url, cause: navigationCause(draft) };
|
|
||||||
case "click":
|
|
||||||
return {
|
|
||||||
op: "click",
|
|
||||||
...common,
|
|
||||||
target: draft.matchedTarget ?? unmatchedTarget(draft.captureTarget),
|
|
||||||
};
|
|
||||||
case "hover":
|
|
||||||
return {
|
|
||||||
op: "hover",
|
|
||||||
...common,
|
|
||||||
target: draft.matchedTarget ?? unmatchedTarget(draft.captureTarget),
|
|
||||||
};
|
|
||||||
case "fill":
|
|
||||||
const fillIsRedacted = options.redactValues || draft.redacted === true;
|
|
||||||
return {
|
|
||||||
op: "fill",
|
|
||||||
...common,
|
|
||||||
target: draft.matchedTarget ?? unmatchedTarget(draft.captureTarget),
|
|
||||||
value: fillIsRedacted ? "***" : draft.value,
|
|
||||||
commit: draft.commit ?? "blur",
|
|
||||||
...(fillIsRedacted ? { redacted: true } : {}),
|
|
||||||
};
|
|
||||||
case "press":
|
|
||||||
return {
|
|
||||||
op: "press",
|
|
||||||
...common,
|
|
||||||
key: draft.key,
|
|
||||||
...(draft.captureTarget || draft.matchedTarget
|
|
||||||
? { target: draft.matchedTarget ?? unmatchedTarget(draft.captureTarget) }
|
|
||||||
: {}),
|
|
||||||
...(draft.modifiers?.length ? { modifiers: draft.modifiers } : {}),
|
|
||||||
};
|
|
||||||
case "select":
|
|
||||||
return {
|
|
||||||
op: "select",
|
|
||||||
...common,
|
|
||||||
target: draft.matchedTarget ?? unmatchedTarget(draft.captureTarget),
|
|
||||||
...(!options.redactValues ? { selection: selection(draft.values, draft.labels) } : {}),
|
|
||||||
};
|
|
||||||
case "scroll":
|
|
||||||
return { op: "scroll", ...common };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReducedTraceV3 {
|
|
||||||
steps: StepV3[];
|
|
||||||
stepIdByDraftId: Map<number, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reduceTraceStepsV3(
|
|
||||||
steps: RecordingDraftStep[],
|
|
||||||
options: { includeTabSwitches?: boolean; redactValues?: boolean } = {},
|
|
||||||
): ReducedTraceV3 {
|
|
||||||
const output: StepV3[] = [];
|
|
||||||
const stepIdByDraftId = new Map<number, number>();
|
|
||||||
for (const { draft, draftIds } of collapseRedirects(steps)) {
|
|
||||||
const step = reduceDraft(draft, output.length + 1, {
|
|
||||||
includeTabSwitches: options.includeTabSwitches === true,
|
|
||||||
redactValues: options.redactValues === true,
|
|
||||||
});
|
|
||||||
if (!step) continue;
|
|
||||||
output.push(step);
|
|
||||||
for (const draftId of draftIds) stepIdByDraftId.set(draftId, step.id);
|
|
||||||
}
|
|
||||||
return { steps: output, stepIdByDraftId };
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import type { StepAnnotation } from "./types";
|
|
||||||
|
|
||||||
function annotationText(annotation: StepAnnotation, stepId: number): string {
|
|
||||||
return ` ⟵ step ${stepId}: ${annotation.op}${annotation.detail ? `: ${annotation.detail}` : ""}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatTraceStateBody(input: {
|
|
||||||
stateId: string;
|
|
||||||
url: string;
|
|
||||||
title?: string;
|
|
||||||
stepIds: number[];
|
|
||||||
vomText: string;
|
|
||||||
annotations: StepAnnotation[];
|
|
||||||
stepIdByDraftId: Map<number, number>;
|
|
||||||
}): string {
|
|
||||||
const lines = ["# bsk-observation 1", `state: ${JSON.stringify(input.stateId)}`];
|
|
||||||
lines.push(`url: ${JSON.stringify(input.url)}`);
|
|
||||||
if (input.title) lines.push(`title: ${JSON.stringify(input.title)}`);
|
|
||||||
if (input.stepIds.length > 0) lines.push(`steps_here: [${input.stepIds.join(", ")}]`);
|
|
||||||
lines.push("---");
|
|
||||||
|
|
||||||
const byLine = new Map<number, Array<{ annotation: StepAnnotation; stepId: number }>>();
|
|
||||||
for (const annotation of input.annotations) {
|
|
||||||
const stepId = input.stepIdByDraftId.get(annotation.draftId);
|
|
||||||
if (stepId === undefined) continue;
|
|
||||||
const bucket = byLine.get(annotation.line) ?? [];
|
|
||||||
bucket.push({ annotation, stepId });
|
|
||||||
byLine.set(annotation.line, bucket);
|
|
||||||
}
|
|
||||||
|
|
||||||
input.vomText.split("\n").forEach((bodyLine, lineIndex) => {
|
|
||||||
let line = bodyLine;
|
|
||||||
for (const item of byLine.get(lineIndex) ?? []) {
|
|
||||||
line += annotationText(item.annotation, item.stepId);
|
|
||||||
}
|
|
||||||
lines.push(line);
|
|
||||||
});
|
|
||||||
return `${lines.join("\n")}\n`;
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import type { CaptureTargetDescriptor } from "@/lib/describe-target";
|
|
||||||
import type {
|
|
||||||
FillCommit,
|
|
||||||
KeyModifier,
|
|
||||||
NavigationCause,
|
|
||||||
StepV3,
|
|
||||||
TargetDescriptorV3,
|
|
||||||
} from "@/transport/types";
|
|
||||||
|
|
||||||
export interface TargetGeometry {
|
|
||||||
/** Top-level viewport-relative CSS pixels, as defined by the geometry module. */
|
|
||||||
rect: { x: number; y: number; w: number; h: number };
|
|
||||||
tag: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TargetMatchHint {
|
|
||||||
geometry?: TargetGeometry;
|
|
||||||
/** Missing means top frame; null means the source Document could not be resolved. */
|
|
||||||
frameId?: string | null;
|
|
||||||
geometrySpace?: "top" | "local";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StepAnnotation {
|
|
||||||
draftId: number;
|
|
||||||
op: StepV3["op"];
|
|
||||||
line: number;
|
|
||||||
stateId: string;
|
|
||||||
detail?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DraftStateLink {
|
|
||||||
pageUrl?: string;
|
|
||||||
preStateId?: string;
|
|
||||||
postStateId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DraftTarget {
|
|
||||||
captureTarget?: CaptureTargetDescriptor;
|
|
||||||
targetHint?: TargetMatchHint;
|
|
||||||
matchedTarget?: TargetDescriptorV3;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DraftNavigationEffect {
|
|
||||||
navigatedTo?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RecordingDraftStep =
|
|
||||||
| ({ op: "click" } & DraftStateLink & DraftTarget & DraftNavigationEffect)
|
|
||||||
| ({ op: "hover" } & DraftStateLink & DraftTarget)
|
|
||||||
| ({
|
|
||||||
op: "fill";
|
|
||||||
value: string;
|
|
||||||
commit?: FillCommit;
|
|
||||||
redacted?: boolean;
|
|
||||||
} & DraftStateLink &
|
|
||||||
DraftTarget &
|
|
||||||
DraftNavigationEffect)
|
|
||||||
| ({
|
|
||||||
op: "press";
|
|
||||||
key: string;
|
|
||||||
modifiers?: KeyModifier[];
|
|
||||||
} & DraftStateLink &
|
|
||||||
DraftTarget &
|
|
||||||
DraftNavigationEffect)
|
|
||||||
| ({
|
|
||||||
op: "select";
|
|
||||||
values: string[];
|
|
||||||
labels?: string[];
|
|
||||||
} & DraftStateLink &
|
|
||||||
DraftTarget &
|
|
||||||
DraftNavigationEffect)
|
|
||||||
| ({ op: "scroll" } & DraftStateLink)
|
|
||||||
| ({ op: "switch_tab" } & DraftStateLink)
|
|
||||||
| ({
|
|
||||||
op: "navigate";
|
|
||||||
url: string;
|
|
||||||
cause?: NavigationCause;
|
|
||||||
transitionType?: string;
|
|
||||||
transitionQualifiers?: string[];
|
|
||||||
} & DraftStateLink);
|
|
||||||
|
|
||||||
export type TargetedRecordingDraft = Extract<
|
|
||||||
RecordingDraftStep,
|
|
||||||
{ op: "click" | "hover" | "fill" | "press" | "select" }
|
|
||||||
>;
|
|
||||||
+71
-62
@@ -1,10 +1,30 @@
|
|||||||
import type { PageRefV2, SelectedOptionV2, StepV2, TraceV2 } from "@/transport/types";
|
import type { DraftTraceStep, PageRef, SelectedOption, Step } from "@/transport/types";
|
||||||
import { resolveDraftStartUrl, shouldIncludeDraft } from "./draft-policy";
|
|
||||||
import type { RecordingDraftStep } from "./types";
|
const CLIPBOARD_KEYS = new Set(["a", "c", "v", "x", "A", "C", "V", "X"]);
|
||||||
|
const MODIFIER_ONLY_KEYS = new Set(["Meta", "Control", "Alt", "Shift", "OS", "Hyper", "Super"]);
|
||||||
|
|
||||||
|
export function shouldRecordPress(
|
||||||
|
key: string,
|
||||||
|
modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">,
|
||||||
|
): boolean {
|
||||||
|
if (MODIFIER_ONLY_KEYS.has(key)) return false;
|
||||||
|
const mods = modifiers ?? [];
|
||||||
|
const hasCtrlOrMeta = mods.includes("ctrl") || mods.includes("meta");
|
||||||
|
if (hasCtrlOrMeta && CLIPBOARD_KEYS.has(key)) return false;
|
||||||
|
if (key === "Enter" || key === "Escape") return true;
|
||||||
|
// Drop bare character typing — FillSession already records the value.
|
||||||
|
if (key.length === 1 && !hasCtrlOrMeta && !mods.includes("alt")) return false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIncludeDraft(step: DraftTraceStep): boolean {
|
||||||
|
if (step.op === "press" && !shouldRecordPress(step.key, step.modifiers)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Collapse consecutive navigations to the last hop. */
|
/** Collapse consecutive navigations to the last hop. */
|
||||||
function collapseNavigations(steps: RecordingDraftStep[]): RecordingDraftStep[] {
|
function collapseNavigations(steps: DraftTraceStep[]): DraftTraceStep[] {
|
||||||
const out: RecordingDraftStep[] = [];
|
const out: DraftTraceStep[] = [];
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
const prev = out[out.length - 1];
|
const prev = out[out.length - 1];
|
||||||
if (step.op === "navigate" && prev?.op === "navigate") {
|
if (step.op === "navigate" && prev?.op === "navigate") {
|
||||||
@@ -16,7 +36,7 @@ function collapseNavigations(steps: RecordingDraftStep[]): RecordingDraftStep[]
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectUrls(steps: RecordingDraftStep[], startUrl?: string): string[] {
|
function collectUrls(steps: DraftTraceStep[], startUrl?: string): string[] {
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
if (startUrl) urls.push(startUrl);
|
if (startUrl) urls.push(startUrl);
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
@@ -24,8 +44,8 @@ function collectUrls(steps: RecordingDraftStep[], startUrl?: string): string[] {
|
|||||||
urls.push(step.url);
|
urls.push(step.url);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (step.pageUrl) urls.push(step.pageUrl);
|
if ("page_url" in step && step.page_url) urls.push(step.page_url);
|
||||||
if ("navigatedTo" in step && step.navigatedTo) urls.push(step.navigatedTo);
|
if ("navigated_to" in step && step.navigated_to) urls.push(step.navigated_to);
|
||||||
}
|
}
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const unique: string[] = [];
|
const unique: string[] = [];
|
||||||
@@ -38,9 +58,9 @@ function collectUrls(steps: RecordingDraftStep[], startUrl?: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildPageRegistry(
|
function buildPageRegistry(
|
||||||
steps: RecordingDraftStep[],
|
steps: DraftTraceStep[],
|
||||||
startUrl?: string,
|
startUrl?: string,
|
||||||
): { pages: PageRefV2[]; urlToId: Map<string, string> } {
|
): { pages: PageRef[]; urlToId: Map<string, string> } {
|
||||||
const urls = collectUrls(steps, startUrl);
|
const urls = collectUrls(steps, startUrl);
|
||||||
const urlToId = new Map<string, string>();
|
const urlToId = new Map<string, string>();
|
||||||
const pages = urls.map((url, index) => {
|
const pages = urls.map((url, index) => {
|
||||||
@@ -61,28 +81,28 @@ function pageIdFor(
|
|||||||
return urlToId.values().next().value ?? "p1";
|
return urlToId.values().next().value ?? "p1";
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageUrlForDraft(step: RecordingDraftStep, fallbackUrl?: string): string | undefined {
|
function pageUrlForDraft(step: DraftTraceStep, fallbackUrl?: string): string | undefined {
|
||||||
if (step.op === "navigate") return step.pageUrl ?? step.url;
|
if (step.op === "navigate") return step.page_url ?? step.url;
|
||||||
if (step.pageUrl) return step.pageUrl;
|
if ("page_url" in step && step.page_url) return step.page_url;
|
||||||
return fallbackUrl;
|
return fallbackUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
function effectForNavigation(
|
function effectForNavigation(
|
||||||
navigatedTo: string | undefined,
|
navigatedTo: string | undefined,
|
||||||
urlToId: Map<string, string>,
|
urlToId: Map<string, string>,
|
||||||
): StepV2["effect"] {
|
): Step["effect"] {
|
||||||
if (!navigatedTo) return undefined;
|
if (!navigatedTo) return undefined;
|
||||||
const pageId = urlToId.get(navigatedTo);
|
const pageId = urlToId.get(navigatedTo);
|
||||||
if (!pageId) return undefined;
|
if (!pageId) return undefined;
|
||||||
return { navigated_to: pageId };
|
return { navigated_to: pageId };
|
||||||
}
|
}
|
||||||
|
|
||||||
function withEffect(step: StepV2, effect: StepV2["effect"]): StepV2 {
|
function withEffect(step: Step, effect: Step["effect"]): Step {
|
||||||
if (!effect) return step;
|
if (!effect) return step;
|
||||||
return { ...step, effect };
|
return { ...step, effect };
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSelection(values: string[], labels?: string[]): SelectedOptionV2[] {
|
function toSelection(values: string[], labels?: string[]): SelectedOption[] {
|
||||||
return values.map((value, index) => ({
|
return values.map((value, index) => ({
|
||||||
value,
|
value,
|
||||||
...(labels?.[index] ? { label: labels[index] } : {}),
|
...(labels?.[index] ? { label: labels[index] } : {}),
|
||||||
@@ -90,11 +110,11 @@ function toSelection(values: string[], labels?: string[]): SelectedOptionV2[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toV2Step(
|
function toV2Step(
|
||||||
step: RecordingDraftStep,
|
step: DraftTraceStep,
|
||||||
id: number,
|
id: number,
|
||||||
urlToId: Map<string, string>,
|
urlToId: Map<string, string>,
|
||||||
fallbackUrl?: string,
|
fallbackUrl?: string,
|
||||||
): StepV2 | null {
|
): Step | null {
|
||||||
if (!shouldIncludeDraft(step)) return null;
|
if (!shouldIncludeDraft(step)) return null;
|
||||||
|
|
||||||
const pageUrl = pageUrlForDraft(step, fallbackUrl);
|
const pageUrl = pageUrlForDraft(step, fallbackUrl);
|
||||||
@@ -109,23 +129,28 @@ function toV2Step(
|
|||||||
to: step.url,
|
to: step.url,
|
||||||
};
|
};
|
||||||
case "click":
|
case "click":
|
||||||
if (!step.captureTarget) return null;
|
|
||||||
return withEffect(
|
return withEffect(
|
||||||
{
|
{
|
||||||
op: "click",
|
op: "click",
|
||||||
id,
|
id,
|
||||||
page,
|
page,
|
||||||
target: step.captureTarget,
|
target: step.target,
|
||||||
},
|
},
|
||||||
effectForNavigation(step.navigatedTo, urlToId),
|
effectForNavigation(step.navigated_to, urlToId),
|
||||||
);
|
);
|
||||||
|
case "hover":
|
||||||
|
return {
|
||||||
|
op: "hover",
|
||||||
|
id,
|
||||||
|
page,
|
||||||
|
target: step.target,
|
||||||
|
};
|
||||||
case "fill":
|
case "fill":
|
||||||
if (!step.captureTarget) return null;
|
|
||||||
return {
|
return {
|
||||||
op: "fill",
|
op: "fill",
|
||||||
id,
|
id,
|
||||||
page,
|
page,
|
||||||
target: step.captureTarget,
|
target: step.target,
|
||||||
value: step.value,
|
value: step.value,
|
||||||
...(step.redacted ? { redacted: true } : {}),
|
...(step.redacted ? { redacted: true } : {}),
|
||||||
};
|
};
|
||||||
@@ -136,51 +161,44 @@ function toV2Step(
|
|||||||
id,
|
id,
|
||||||
page,
|
page,
|
||||||
key: step.key,
|
key: step.key,
|
||||||
...(step.captureTarget ? { target: step.captureTarget } : {}),
|
...(step.target ? { target: step.target } : {}),
|
||||||
...(step.modifiers?.length ? { modifiers: step.modifiers } : {}),
|
...(step.modifiers?.length ? { modifiers: step.modifiers } : {}),
|
||||||
},
|
},
|
||||||
effectForNavigation(step.navigatedTo, urlToId),
|
effectForNavigation(step.navigated_to, urlToId),
|
||||||
);
|
);
|
||||||
case "select":
|
case "select":
|
||||||
if (!step.captureTarget) return null;
|
|
||||||
return withEffect(
|
return withEffect(
|
||||||
{
|
{
|
||||||
op: "select",
|
op: "select",
|
||||||
id,
|
id,
|
||||||
page,
|
page,
|
||||||
target: step.captureTarget,
|
target: step.target,
|
||||||
selection: toSelection(step.values, step.labels),
|
selection: toSelection(step.values, step.labels),
|
||||||
},
|
},
|
||||||
effectForNavigation(step.navigatedTo, urlToId),
|
effectForNavigation(step.navigated_to, urlToId),
|
||||||
);
|
);
|
||||||
// `hover` only exists in Trace v3. Peers that negotiate v2 predate the
|
|
||||||
// step variant and fail to decode the whole result if we emit it.
|
|
||||||
case "hover":
|
|
||||||
case "scroll":
|
|
||||||
case "switch_tab":
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReducedTrace {
|
export interface ReducedTrace {
|
||||||
pages: PageRefV2[];
|
pages: PageRef[];
|
||||||
steps: StepV2[];
|
steps: Step[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compile capture drafts into record-only trace v2 steps.
|
* Compile capture drafts into record-only trace v2 steps.
|
||||||
* Variable inputs are NOT classified here — executing agents infer that at run time.
|
* Variable inputs are NOT classified here — executing agents infer that at run time.
|
||||||
*/
|
*/
|
||||||
function reduceTraceSteps(steps: RecordingDraftStep[], startUrl?: string): ReducedTrace {
|
export function reduceTraceSteps(steps: DraftTraceStep[], startUrl?: string): ReducedTrace {
|
||||||
const collapsed = collapseNavigations(steps);
|
const collapsed = collapseNavigations(steps);
|
||||||
const { pages, urlToId } = buildPageRegistry(collapsed, startUrl);
|
const { pages, urlToId } = buildPageRegistry(collapsed, startUrl);
|
||||||
const out: StepV2[] = [];
|
const out: Step[] = [];
|
||||||
let id = 1;
|
let id = 1;
|
||||||
let lastUrl = startUrl;
|
let lastUrl = startUrl;
|
||||||
for (const draft of collapsed) {
|
for (const draft of collapsed) {
|
||||||
if (draft.op === "navigate") lastUrl = draft.url;
|
if (draft.op === "navigate") lastUrl = draft.url;
|
||||||
else if ("navigatedTo" in draft && draft.navigatedTo) lastUrl = draft.navigatedTo;
|
else if ("navigated_to" in draft && draft.navigated_to) lastUrl = draft.navigated_to;
|
||||||
else if (draft.pageUrl) lastUrl = draft.pageUrl;
|
else if ("page_url" in draft && draft.page_url) lastUrl = draft.page_url;
|
||||||
const step = toV2Step(draft, id, urlToId, lastUrl);
|
const step = toV2Step(draft, id, urlToId, lastUrl);
|
||||||
if (!step) continue;
|
if (!step) continue;
|
||||||
out.push(step);
|
out.push(step);
|
||||||
@@ -189,27 +207,18 @@ function reduceTraceSteps(steps: RecordingDraftStep[], startUrl?: string): Reduc
|
|||||||
return { pages, steps: out };
|
return { pages, steps: out };
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTraceStartUrl(
|
export function resolveTraceStartUrl(
|
||||||
drafts: RecordingDraftStep[],
|
drafts: DraftTraceStep[],
|
||||||
startUrl?: string,
|
startUrl?: string,
|
||||||
pages?: PageRefV2[],
|
pages?: PageRef[],
|
||||||
): string {
|
): string {
|
||||||
return resolveDraftStartUrl(drafts, startUrl, pages?.[0]?.url);
|
if (startUrl) return startUrl;
|
||||||
}
|
const navigate = drafts.find((step): step is Extract<DraftTraceStep, { op: "navigate" }> => {
|
||||||
|
return step.op === "navigate";
|
||||||
export function buildTraceV2(input: {
|
});
|
||||||
steps: RecordingDraftStep[];
|
if (navigate) return navigate.url;
|
||||||
startedAt: string;
|
for (const draft of drafts) {
|
||||||
startUrl?: string;
|
if ("page_url" in draft && draft.page_url) return draft.page_url;
|
||||||
purpose?: string;
|
}
|
||||||
}): TraceV2 {
|
return pages?.[0]?.url ?? "about:blank";
|
||||||
const { pages, steps } = reduceTraceSteps(input.steps, input.startUrl);
|
|
||||||
return {
|
|
||||||
recorded_at: new Date().toISOString(),
|
|
||||||
started_at: input.startedAt,
|
|
||||||
...(input.purpose ? { purpose: input.purpose } : {}),
|
|
||||||
entry: { start_url: resolveTraceStartUrl(input.steps, input.startUrl, pages) },
|
|
||||||
pages,
|
|
||||||
steps,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -13,8 +13,6 @@ describe("RefStore", () => {
|
|||||||
backendNodeId: 42,
|
backendNodeId: 42,
|
||||||
tabId: 7,
|
tabId: 7,
|
||||||
generation: 0,
|
generation: 0,
|
||||||
kind: "dom",
|
|
||||||
capabilities: ["interact", "screenshot"],
|
|
||||||
});
|
});
|
||||||
expect(s.size()).toBe(1);
|
expect(s.size()).toBe(1);
|
||||||
expect(s.isEmpty()).toBe(false);
|
expect(s.isEmpty()).toBe(false);
|
||||||
@@ -50,31 +48,4 @@ describe("RefStore", () => {
|
|||||||
cdpSessionId: "child-session",
|
cdpSessionId: "child-session",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves explicit capabilities for visual surface refs", () => {
|
|
||||||
const s = new RefStore();
|
|
||||||
s.set("e1", 42, {
|
|
||||||
tabId: 7,
|
|
||||||
kind: "surface",
|
|
||||||
capabilities: ["screenshot"],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(s.resolveEntry("e1")).toMatchObject({
|
|
||||||
kind: "surface",
|
|
||||||
capabilities: ["screenshot"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never grants ordinary interaction to a surface through defaults or caller input", () => {
|
|
||||||
const s = new RefStore();
|
|
||||||
s.set("e1", 42, { tabId: 7, kind: "surface" });
|
|
||||||
s.set("e2", 43, {
|
|
||||||
tabId: 7,
|
|
||||||
kind: "surface",
|
|
||||||
capabilities: ["interact", "screenshot"],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(s.resolveEntry("e1")?.capabilities).toEqual(["screenshot"]);
|
|
||||||
expect(s.resolveEntry("e2")?.capabilities).toEqual(["screenshot"]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { type SurfaceCaptureInput, SurfaceCaptureStore } from "../surface-capture-store";
|
|
||||||
|
|
||||||
function input(): SurfaceCaptureInput {
|
|
||||||
return {
|
|
||||||
sessionId: "aa11",
|
|
||||||
tabId: 7,
|
|
||||||
navigationIdentity: "navigation",
|
|
||||||
surface: {
|
|
||||||
ref: "e3",
|
|
||||||
frameId: "main",
|
|
||||||
backendNodeId: 99,
|
|
||||||
observationGeneration: 2,
|
|
||||||
},
|
|
||||||
topViewportRect: { x: 10, y: 20, w: 100, h: 50 },
|
|
||||||
imageWidth: 200,
|
|
||||||
imageHeight: 100,
|
|
||||||
viewportSignature: "viewport",
|
|
||||||
frameProjectionSignature: "frame",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("SurfaceCaptureStore", () => {
|
|
||||||
it("creates metadata-only, short-lived captures and consumes them once", () => {
|
|
||||||
let now = 1_000;
|
|
||||||
const store = new SurfaceCaptureStore({
|
|
||||||
now: () => now,
|
|
||||||
ttlMs: 500,
|
|
||||||
createId: () => "sc_test",
|
|
||||||
});
|
|
||||||
const capture = store.create(input());
|
|
||||||
|
|
||||||
expect(capture).toMatchObject({
|
|
||||||
id: "sc_test",
|
|
||||||
createdAt: 1_000,
|
|
||||||
expiresAt: 1_500,
|
|
||||||
consumed: false,
|
|
||||||
});
|
|
||||||
expect(capture).not.toHaveProperty("imageBase64");
|
|
||||||
expect(store.consume("sc_test")).toMatchObject({ ok: true });
|
|
||||||
expect(store.consume("sc_test")).toEqual({ ok: false, reason: "consumed" });
|
|
||||||
|
|
||||||
now = 1_600;
|
|
||||||
expect(store.size()).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects expired and unknown captures", () => {
|
|
||||||
let now = 1_000;
|
|
||||||
const store = new SurfaceCaptureStore({
|
|
||||||
now: () => now,
|
|
||||||
ttlMs: 10,
|
|
||||||
createId: () => "sc_expired",
|
|
||||||
});
|
|
||||||
store.create(input());
|
|
||||||
now = 1_010;
|
|
||||||
|
|
||||||
expect(store.consume("sc_expired")).toEqual({ ok: false, reason: "expired" });
|
|
||||||
expect(store.consume("sc_missing")).toEqual({ ok: false, reason: "not_found" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears every capture on session cleanup", () => {
|
|
||||||
const store = new SurfaceCaptureStore({ createId: () => "sc_clear" });
|
|
||||||
store.create(input());
|
|
||||||
store.clear();
|
|
||||||
expect(store.size()).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { AGENT_WINDOW_HOME, type AgentWindowApi, chromeAgentWindowApi } from "./agent-window";
|
import { AGENT_WINDOW_HOME, type AgentWindowApi, chromeAgentWindowApi } from "./agent-window";
|
||||||
import { RefStore } from "./ref-store";
|
import { RefStore } from "./ref-store";
|
||||||
import { SurfaceCaptureStore } from "./surface-capture-store";
|
|
||||||
|
|
||||||
export interface SessionContext {
|
export interface SessionContext {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
agentWindowId: number;
|
agentWindowId: number;
|
||||||
refStore: RefStore;
|
refStore: RefStore;
|
||||||
surfaceCaptures: SurfaceCaptureStore;
|
|
||||||
borrowedTabs: Map<number, BorrowedTab>;
|
borrowedTabs: Map<number, BorrowedTab>;
|
||||||
createdAtMs: number;
|
createdAtMs: number;
|
||||||
}
|
}
|
||||||
@@ -188,7 +186,6 @@ export class SessionManager {
|
|||||||
sessionId,
|
sessionId,
|
||||||
agentWindowId: windowId,
|
agentWindowId: windowId,
|
||||||
refStore: new RefStore(),
|
refStore: new RefStore(),
|
||||||
surfaceCaptures: new SurfaceCaptureStore({ now: this.now }),
|
|
||||||
borrowedTabs: new Map(),
|
borrowedTabs: new Map(),
|
||||||
createdAtMs: this.now(),
|
createdAtMs: this.now(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { Rect } from "@browser-skill/vom";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-session map from `@e<N>` snapshot refs to a CDP node address.
|
* Per-session map from `@e<N>` snapshot refs to a CDP node address.
|
||||||
*
|
*
|
||||||
@@ -14,17 +12,12 @@ import type { Rect } from "@browser-skill/vom";
|
|||||||
* inside the `SessionContext`.
|
* inside the `SessionContext`.
|
||||||
*/
|
*/
|
||||||
export type BackendNodeId = number;
|
export type BackendNodeId = number;
|
||||||
export type RefCapability = "interact" | "screenshot";
|
|
||||||
export type RefTargetKind = "dom" | "surface";
|
|
||||||
|
|
||||||
export interface RefEntry {
|
export interface RefEntry {
|
||||||
backendNodeId: BackendNodeId;
|
backendNodeId: BackendNodeId;
|
||||||
tabId: number | null;
|
tabId: number | null;
|
||||||
frameId?: string;
|
frameId?: string;
|
||||||
cdpSessionId?: string;
|
cdpSessionId?: string;
|
||||||
visibleRect?: Rect;
|
|
||||||
kind: RefTargetKind;
|
|
||||||
capabilities: RefCapability[];
|
|
||||||
generation: number;
|
generation: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,19 +28,8 @@ export type RefInput =
|
|||||||
tabId: number;
|
tabId: number;
|
||||||
frameId?: string;
|
frameId?: string;
|
||||||
cdpSessionId?: string;
|
cdpSessionId?: string;
|
||||||
visibleRect?: Rect;
|
|
||||||
kind?: RefTargetKind;
|
|
||||||
capabilities?: RefCapability[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function capabilitiesFor(
|
|
||||||
kind: RefTargetKind,
|
|
||||||
capabilities: RefCapability[] | undefined,
|
|
||||||
): RefCapability[] {
|
|
||||||
if (kind === "surface") return ["screenshot"];
|
|
||||||
return capabilities ?? ["interact", "screenshot"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RefStore {
|
export class RefStore {
|
||||||
private readonly map = new Map<string, RefEntry>();
|
private readonly map = new Map<string, RefEntry>();
|
||||||
private generation = 0;
|
private generation = 0;
|
||||||
@@ -88,20 +70,13 @@ export class RefStore {
|
|||||||
tabId?: number;
|
tabId?: number;
|
||||||
frameId?: string;
|
frameId?: string;
|
||||||
cdpSessionId?: string;
|
cdpSessionId?: string;
|
||||||
visibleRect?: Rect;
|
|
||||||
kind?: RefTargetKind;
|
|
||||||
capabilities?: RefCapability[];
|
|
||||||
} = {},
|
} = {},
|
||||||
): void {
|
): void {
|
||||||
const kind = opts.kind ?? "dom";
|
|
||||||
this.map.set(normaliseRef(ref), {
|
this.map.set(normaliseRef(ref), {
|
||||||
backendNodeId: id,
|
backendNodeId: id,
|
||||||
tabId: opts.tabId ?? null,
|
tabId: opts.tabId ?? null,
|
||||||
...(opts.frameId ? { frameId: opts.frameId } : {}),
|
...(opts.frameId ? { frameId: opts.frameId } : {}),
|
||||||
...(opts.cdpSessionId ? { cdpSessionId: opts.cdpSessionId } : {}),
|
...(opts.cdpSessionId ? { cdpSessionId: opts.cdpSessionId } : {}),
|
||||||
...(opts.visibleRect ? { visibleRect: opts.visibleRect } : {}),
|
|
||||||
kind,
|
|
||||||
capabilities: capabilitiesFor(kind, opts.capabilities),
|
|
||||||
generation: this.generation,
|
generation: this.generation,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -119,20 +94,14 @@ export class RefStore {
|
|||||||
return {
|
return {
|
||||||
backendNodeId: input,
|
backendNodeId: input,
|
||||||
tabId: null,
|
tabId: null,
|
||||||
kind: "dom",
|
|
||||||
capabilities: ["interact", "screenshot"],
|
|
||||||
generation: this.generation,
|
generation: this.generation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const kind = input.kind ?? "dom";
|
|
||||||
return {
|
return {
|
||||||
backendNodeId: input.backendNodeId,
|
backendNodeId: input.backendNodeId,
|
||||||
tabId: input.tabId,
|
tabId: input.tabId,
|
||||||
...(input.frameId ? { frameId: input.frameId } : {}),
|
...(input.frameId ? { frameId: input.frameId } : {}),
|
||||||
...(input.cdpSessionId ? { cdpSessionId: input.cdpSessionId } : {}),
|
...(input.cdpSessionId ? { cdpSessionId: input.cdpSessionId } : {}),
|
||||||
...(input.visibleRect ? { visibleRect: input.visibleRect } : {}),
|
|
||||||
kind,
|
|
||||||
capabilities: capabilitiesFor(kind, input.capabilities),
|
|
||||||
generation: this.generation,
|
generation: this.generation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
import type { Rect } from "@browser-skill/vom";
|
|
||||||
|
|
||||||
export const SURFACE_CAPTURE_TTL_MS = 30_000;
|
|
||||||
|
|
||||||
export interface SurfaceCapture {
|
|
||||||
id: string;
|
|
||||||
sessionId: string;
|
|
||||||
tabId: number;
|
|
||||||
navigationIdentity: string;
|
|
||||||
surface: {
|
|
||||||
ref: string;
|
|
||||||
frameId?: string;
|
|
||||||
backendNodeId: number;
|
|
||||||
observationGeneration: number;
|
|
||||||
};
|
|
||||||
topViewportRect: Rect;
|
|
||||||
imageWidth: number;
|
|
||||||
imageHeight: number;
|
|
||||||
viewportSignature: string;
|
|
||||||
frameProjectionSignature: string;
|
|
||||||
createdAt: number;
|
|
||||||
expiresAt: number;
|
|
||||||
consumed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SurfaceCaptureInput = Omit<
|
|
||||||
SurfaceCapture,
|
|
||||||
"id" | "createdAt" | "expiresAt" | "consumed"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type SurfaceCaptureConsumeResult =
|
|
||||||
| { ok: true; capture: SurfaceCapture }
|
|
||||||
| { ok: false; reason: "not_found" | "expired" | "consumed" };
|
|
||||||
|
|
||||||
export interface SurfaceCaptureStoreOptions {
|
|
||||||
now?: () => number;
|
|
||||||
ttlMs?: number;
|
|
||||||
createId?: () => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomCaptureId(): string {
|
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(12));
|
|
||||||
return `sc_${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Per-session, metadata-only store for short-lived screenshot coordinate transactions. */
|
|
||||||
export class SurfaceCaptureStore {
|
|
||||||
private readonly captures = new Map<string, SurfaceCapture>();
|
|
||||||
private readonly now: () => number;
|
|
||||||
private readonly ttlMs: number;
|
|
||||||
private readonly createId: () => string;
|
|
||||||
|
|
||||||
constructor(options: SurfaceCaptureStoreOptions = {}) {
|
|
||||||
this.now = options.now ?? Date.now;
|
|
||||||
this.ttlMs = options.ttlMs ?? SURFACE_CAPTURE_TTL_MS;
|
|
||||||
this.createId = options.createId ?? randomCaptureId;
|
|
||||||
}
|
|
||||||
|
|
||||||
create(input: SurfaceCaptureInput): SurfaceCapture {
|
|
||||||
this.purgeExpired();
|
|
||||||
const createdAt = this.now();
|
|
||||||
const capture: SurfaceCapture = {
|
|
||||||
...input,
|
|
||||||
id: this.createId(),
|
|
||||||
createdAt,
|
|
||||||
expiresAt: createdAt + this.ttlMs,
|
|
||||||
consumed: false,
|
|
||||||
};
|
|
||||||
this.captures.set(capture.id, capture);
|
|
||||||
return capture;
|
|
||||||
}
|
|
||||||
|
|
||||||
consume(id: string): SurfaceCaptureConsumeResult {
|
|
||||||
const capture = this.captures.get(id);
|
|
||||||
if (!capture) return { ok: false, reason: "not_found" };
|
|
||||||
if (capture.expiresAt <= this.now()) {
|
|
||||||
this.captures.delete(id);
|
|
||||||
return { ok: false, reason: "expired" };
|
|
||||||
}
|
|
||||||
if (capture.consumed) return { ok: false, reason: "consumed" };
|
|
||||||
capture.consumed = true;
|
|
||||||
return { ok: true, capture };
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(): void {
|
|
||||||
this.captures.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
size(): number {
|
|
||||||
this.purgeExpired();
|
|
||||||
return this.captures.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
private purgeExpired(): void {
|
|
||||||
const now = this.now();
|
|
||||||
for (const [id, capture] of this.captures) {
|
|
||||||
if (capture.expiresAt <= now) this.captures.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
export const RECORD_DOCUMENT_ATTRIBUTE = "data-bsk-record-document";
|
|
||||||
|
|
||||||
const RECORDING_DOCUMENT_ID_PATTERN =
|
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
||||||
const RECORDING_DOCUMENT_MARKER_PREFIX = "bsk:";
|
|
||||||
|
|
||||||
export function recordingDocumentMarkerValue(identity: string): string {
|
|
||||||
if (!RECORDING_DOCUMENT_ID_PATTERN.test(identity)) {
|
|
||||||
throw new TypeError("recording Document identity must be a random UUID");
|
|
||||||
}
|
|
||||||
return `${RECORDING_DOCUMENT_MARKER_PREFIX}${identity}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read BrowserSkill's opaque per-Document recording identity from captured attributes. */
|
|
||||||
export function readRecordingDocumentIdentity(
|
|
||||||
attrs: Readonly<Record<string, string>>,
|
|
||||||
): string | undefined {
|
|
||||||
const marker = attrs[RECORD_DOCUMENT_ATTRIBUTE];
|
|
||||||
if (!marker?.startsWith(RECORDING_DOCUMENT_MARKER_PREFIX)) return undefined;
|
|
||||||
const identity = marker.slice(RECORDING_DOCUMENT_MARKER_PREFIX.length);
|
|
||||||
return identity && RECORDING_DOCUMENT_ID_PATTERN.test(identity) ? identity : undefined;
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import type {
|
|||||||
RequestFrame,
|
RequestFrame,
|
||||||
} from "@/transport/types";
|
} from "@/transport/types";
|
||||||
import { ToolDispatcher } from "../dispatcher";
|
import { ToolDispatcher } from "../dispatcher";
|
||||||
import { resetBrowserObservationForTests, setBrowserObservationAttachForTests } from "../record";
|
|
||||||
|
|
||||||
type TestDispatcherCdp = NonNullable<ConstructorParameters<typeof ToolDispatcher>[0]["cdp"]>;
|
type TestDispatcherCdp = NonNullable<ConstructorParameters<typeof ToolDispatcher>[0]["cdp"]>;
|
||||||
|
|
||||||
@@ -45,72 +44,9 @@ function makeRequest(method: string, params: unknown): RequestFrame {
|
|||||||
|
|
||||||
describe("ToolDispatcher", () => {
|
describe("ToolDispatcher", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
resetBrowserObservationForTests();
|
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the configured recording runtime for start and stop", async () => {
|
|
||||||
const { transport, sent, deliver } = fakeTransport();
|
|
||||||
const sessions = new SessionManager({
|
|
||||||
agentWindow: {
|
|
||||||
create: vi.fn(async () => 4242),
|
|
||||||
remove: vi.fn(),
|
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await sessions.start("aa11");
|
|
||||||
const tab = {
|
|
||||||
id: 7,
|
|
||||||
windowId: 4242,
|
|
||||||
active: true,
|
|
||||||
status: "complete",
|
|
||||||
url: "https://example.com/start",
|
|
||||||
} as chrome.tabs.Tab;
|
|
||||||
const frameCoordinator = {
|
|
||||||
begin: vi.fn(),
|
|
||||||
armTab: vi.fn(async () => true),
|
|
||||||
sourceFor: vi.fn(() => null),
|
|
||||||
stop: vi.fn(async () => true),
|
|
||||||
cancel: vi.fn(),
|
|
||||||
};
|
|
||||||
const sendToTab = vi.fn(async () => ({ ok: true }));
|
|
||||||
setBrowserObservationAttachForTests(
|
|
||||||
() => () => {},
|
|
||||||
() => () => {},
|
|
||||||
);
|
|
||||||
const dispatcher = new ToolDispatcher({
|
|
||||||
transport,
|
|
||||||
sessions,
|
|
||||||
recording: {
|
|
||||||
tabsApi: {
|
|
||||||
get: vi.fn(async () => tab),
|
|
||||||
query: vi.fn(async () => [tab]),
|
|
||||||
},
|
|
||||||
frameCoordinator,
|
|
||||||
sendToTab,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
dispatcher.start();
|
|
||||||
|
|
||||||
deliver(
|
|
||||||
makeRequest("tool.record_start", {
|
|
||||||
session_id: "aa11",
|
|
||||||
url: "https://example.com/start",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await vi.waitFor(() => expect(sent).toHaveLength(1));
|
|
||||||
|
|
||||||
expect(sent[0]).toMatchObject({ result: { tab_id: 7, recording: true } });
|
|
||||||
expect(frameCoordinator.begin).toHaveBeenCalledOnce();
|
|
||||||
expect(frameCoordinator.armTab).toHaveBeenCalledWith(expect.any(String), 7);
|
|
||||||
|
|
||||||
deliver({ id: "r-2", method: "tool.record_stop", params: { session_id: "aa11" } });
|
|
||||||
await vi.waitFor(() => expect(sent).toHaveLength(2));
|
|
||||||
|
|
||||||
expect(frameCoordinator.stop).toHaveBeenCalledOnce();
|
|
||||||
expect(sent[1]).toMatchObject({ id: "r-2", result: { trace: { steps: [] } } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes tool.session_start to the SessionManager and replies with the window id", async () => {
|
it("routes tool.session_start to the SessionManager and replies with the window id", async () => {
|
||||||
const { transport, sent, deliver } = fakeTransport();
|
const { transport, sent, deliver } = fakeTransport();
|
||||||
const sessions = new SessionManager({
|
const sessions = new SessionManager({
|
||||||
@@ -232,6 +168,107 @@ 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 () => {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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({
|
||||||
@@ -267,55 +304,6 @@ describe("ToolDispatcher", () => {
|
|||||||
expect(sent[0]).toEqual({ id: "r-1", result: {} });
|
expect(sent[0]).toEqual({ id: "r-1", result: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards daemon cancellation into an in-flight session stop", async () => {
|
|
||||||
const { transport, sent, deliver } = fakeTransport();
|
|
||||||
const remove = vi.fn(async () => {});
|
|
||||||
const sessions = new SessionManager({
|
|
||||||
agentWindow: {
|
|
||||||
create: vi.fn(async () => 4242),
|
|
||||||
remove,
|
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await sessions.start("aa11");
|
|
||||||
let finishDetach: () => void = () => {};
|
|
||||||
const detach = new Promise<void>((resolve) => {
|
|
||||||
finishDetach = resolve;
|
|
||||||
});
|
|
||||||
const cdp = {
|
|
||||||
send: vi.fn(),
|
|
||||||
detachSession: vi.fn(() => detach),
|
|
||||||
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.session_stop", { session_id: "aa11" }));
|
|
||||||
await vi.waitFor(() => expect(cdp.detachSession).toHaveBeenCalledWith("aa11"));
|
|
||||||
deliver({ id: "cancel-stop", method: "cancel", params: { rpc_id: "r-1" } });
|
|
||||||
await flushMicrotasks();
|
|
||||||
finishDetach();
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
expect(sent).toContainEqual({ id: "cancel-stop", result: { cancelled: true } });
|
|
||||||
expect(sent).toContainEqual({
|
|
||||||
id: "r-1",
|
|
||||||
error: expect.objectContaining({ code: "cancelled" }),
|
|
||||||
});
|
|
||||||
expect(sessions.has("aa11")).toBe(true);
|
|
||||||
expect(remove).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns not_found when stopping an unknown session", async () => {
|
it("returns not_found when stopping an unknown session", async () => {
|
||||||
const { transport, sent, deliver } = fakeTransport();
|
const { transport, sent, deliver } = fakeTransport();
|
||||||
const sessions = new SessionManager({
|
const sessions = new SessionManager({
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { fillEditable } from "../editable-input";
|
|
||||||
import type { CdpRunner } from "../shared";
|
|
||||||
|
|
||||||
interface FakeOptions {
|
|
||||||
initialValue?: string;
|
|
||||||
finalValue: string;
|
|
||||||
remount?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function editableState(value: string, connected = true, focused = true) {
|
|
||||||
return {
|
|
||||||
result: {
|
|
||||||
value: { supported: true, connected, focused, value, kind: "input" },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeEditableCdp(options: FakeOptions) {
|
|
||||||
const sent: Array<{ method: string; params?: object }> = [];
|
|
||||||
let inserted = false;
|
|
||||||
const send = vi.fn(async <T>(_tabId: number, method: string, params?: object): Promise<T> => {
|
|
||||||
sent.push({ method, params });
|
|
||||||
if (method === "Input.insertText") {
|
|
||||||
inserted = true;
|
|
||||||
return {} as T;
|
|
||||||
}
|
|
||||||
if (method === "Input.dispatchKeyEvent") {
|
|
||||||
if ((params as { type?: string }).type === "rawKeyDown") inserted = true;
|
|
||||||
return {} as T;
|
|
||||||
}
|
|
||||||
if (method !== "Runtime.callFunctionOn") throw new Error(`unexpected ${method}`);
|
|
||||||
|
|
||||||
const call = params as { objectId?: string; functionDeclaration?: string };
|
|
||||||
const fn = call.functionDeclaration ?? "";
|
|
||||||
if (fn.includes("bsk-editable-selection")) {
|
|
||||||
return { result: { value: { selected: true } } } as T;
|
|
||||||
}
|
|
||||||
if (fn.includes("bsk-editable-sync")) return { result: { value: undefined } } as T;
|
|
||||||
if (fn.includes("bsk-editable-live")) {
|
|
||||||
return {
|
|
||||||
result: options.remount && inserted ? { objectId: "replacement" } : { objectId: "anchor" },
|
|
||||||
} as T;
|
|
||||||
}
|
|
||||||
if (fn.includes("bsk-editable-state")) {
|
|
||||||
if (call.objectId === "replacement") return editableState(options.finalValue) as T;
|
|
||||||
if (options.remount && inserted) {
|
|
||||||
return editableState(options.finalValue, false, false) as T;
|
|
||||||
}
|
|
||||||
return editableState(inserted ? options.finalValue : (options.initialValue ?? "")) as T;
|
|
||||||
}
|
|
||||||
throw new Error("unexpected Runtime.callFunctionOn function");
|
|
||||||
});
|
|
||||||
return { cdp: { send } as CdpRunner, sent };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("fillEditable", () => {
|
|
||||||
it("replaces text through the browser input path without synthetic events or value setters", async () => {
|
|
||||||
const fake = makeEditableCdp({ initialValue: "old", finalValue: "新值" });
|
|
||||||
|
|
||||||
const result = await fillEditable(fake.cdp, 4, "anchor", "新值", true);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ value: "新值", replacedNode: false });
|
|
||||||
expect(fake.sent).toContainEqual({ method: "Input.insertText", params: { text: "新值" } });
|
|
||||||
const declarations = fake.sent
|
|
||||||
.map((call) => (call.params as { functionDeclaration?: string })?.functionDeclaration ?? "")
|
|
||||||
.join("\n");
|
|
||||||
expect(declarations).not.toMatch(/dispatchEvent|\.value\s*=/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reacquires the focused editable after a framework remount", async () => {
|
|
||||||
const fake = makeEditableCdp({ finalValue: "persisted", remount: true });
|
|
||||||
|
|
||||||
const result = await fillEditable(fake.cdp, 4, "anchor", "persisted", true);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ value: "persisted", replacedNode: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a page rollback after the bounded settlement", async () => {
|
|
||||||
const fake = makeEditableCdp({ finalValue: "" });
|
|
||||||
|
|
||||||
const result = await fillEditable(fake.cdp, 4, "anchor", "rejected", true);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
code: "cdp_failed",
|
|
||||||
data: {
|
|
||||||
reason: "input_not_applied",
|
|
||||||
expected_value_length: 8,
|
|
||||||
actual_value_length: 0,
|
|
||||||
phase: "readback",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears to an empty value with a browser Backspace edit", async () => {
|
|
||||||
const fake = makeEditableCdp({ initialValue: "old", finalValue: "" });
|
|
||||||
|
|
||||||
const result = await fillEditable(fake.cdp, 4, "anchor", "", true);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ value: "" });
|
|
||||||
expect(fake.sent.some((call) => call.method === "Input.insertText")).toBe(false);
|
|
||||||
expect(fake.sent.filter((call) => call.method === "Input.dispatchKeyEvent")).toHaveLength(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
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 { 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 () => {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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): ResolvedActionTarget {
|
||||||
|
return {
|
||||||
|
tab: { tabId: 4, windowId: 100, active: true },
|
||||||
|
backendNodeId: 123,
|
||||||
|
cdpTarget: { tabId: 4 },
|
||||||
|
...(frameId ? { frameId } : {}),
|
||||||
|
usedRef: "e3",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 === "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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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("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("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" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,12 +7,10 @@ import {
|
|||||||
handleHover,
|
handleHover,
|
||||||
handlePress,
|
handlePress,
|
||||||
handleSelect,
|
handleSelect,
|
||||||
handleType,
|
|
||||||
modifiersBitfield,
|
modifiersBitfield,
|
||||||
parseKeySpec,
|
parseKeySpec,
|
||||||
resolveKeyDescriptor,
|
resolveKeyDescriptor,
|
||||||
} from "../interaction";
|
} from "../interaction";
|
||||||
import { captureSurfaceEnvironment } from "../surface-target";
|
|
||||||
|
|
||||||
function fakeAgentWindow(ids: number[]) {
|
function fakeAgentWindow(ids: number[]) {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -35,15 +33,6 @@ function makeFakeCdp(handlers: Record<string, (params: unknown) => unknown>) {
|
|||||||
if (!h && method === "Page.getLayoutMetrics") {
|
if (!h && method === "Page.getLayoutMetrics") {
|
||||||
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
|
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
!h &&
|
|
||||||
method === "Runtime.evaluate" &&
|
|
||||||
String((params as { expression?: string })?.expression ?? "").includes(
|
|
||||||
"bsk-focus-target-probe",
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return { result: { value: { hasFocus: true, activeTag: "input" } } };
|
|
||||||
}
|
|
||||||
if (!h) throw new Error(`unexpected CDP call ${method}`);
|
if (!h) throw new Error(`unexpected CDP call ${method}`);
|
||||||
return h(params);
|
return h(params);
|
||||||
};
|
};
|
||||||
@@ -55,10 +44,6 @@ function makeFakeCdp(handlers: Record<string, (params: unknown) => unknown>) {
|
|||||||
params?: object,
|
params?: object,
|
||||||
) => Promise<T>,
|
) => Promise<T>,
|
||||||
trackSessionTab: vi.fn(),
|
trackSessionTab: vi.fn(),
|
||||||
getFrameGraph: vi.fn(async (tabId) => ({
|
|
||||||
rootFrameId: "main",
|
|
||||||
frames: [{ frameId: "main", target: { tabId } }],
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
const tabsApi = {
|
const tabsApi = {
|
||||||
get: vi.fn(
|
get: vi.fn(
|
||||||
@@ -69,84 +54,6 @@ function makeFakeCdp(handlers: Record<string, (params: unknown) => unknown>) {
|
|||||||
return { cdp, tabsApi, sent };
|
return { cdp, tabsApi, sent };
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillRuntimeReadback(value: string) {
|
|
||||||
return (params: unknown) => {
|
|
||||||
const fn = String((params as { functionDeclaration?: string }).functionDeclaration ?? "");
|
|
||||||
if (fn.includes("bsk-editable-state")) {
|
|
||||||
return {
|
|
||||||
result: {
|
|
||||||
value: {
|
|
||||||
supported: true,
|
|
||||||
connected: true,
|
|
||||||
focused: true,
|
|
||||||
value,
|
|
||||||
kind: "input",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (fn.includes("bsk-editable-selection")) {
|
|
||||||
return { result: { value: { selected: true } } };
|
|
||||||
}
|
|
||||||
return { result: { type: "undefined" } };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function makeSurfaceClickFixture(onMouse?: (params: Record<string, unknown>) => void) {
|
|
||||||
const manager = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
||||||
const context = await manager.start("aa11");
|
|
||||||
context.refStore.set("e3", 99, {
|
|
||||||
tabId: 4,
|
|
||||||
kind: "surface",
|
|
||||||
visibleRect: { x: 10, y: 20, w: 100, h: 40 },
|
|
||||||
});
|
|
||||||
const fake = makeFakeCdp({
|
|
||||||
"Page.getFrameTree": () => ({
|
|
||||||
frameTree: {
|
|
||||||
frame: { id: "main", loaderId: "loader-1", url: "https://fixture.test/" },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
"Page.getLayoutMetrics": () => ({
|
|
||||||
cssLayoutViewport: { clientWidth: 800, clientHeight: 600, pageX: 0, pageY: 0 },
|
|
||||||
cssVisualViewport: {
|
|
||||||
clientWidth: 800,
|
|
||||||
clientHeight: 600,
|
|
||||||
pageX: 0,
|
|
||||||
pageY: 0,
|
|
||||||
scale: 1,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
"DOM.getContentQuads": () => ({ quads: [[10, 20, 110, 20, 110, 60, 10, 60]] }),
|
|
||||||
"Runtime.evaluate": () => ({
|
|
||||||
result: { value: { overlayHostPresent: false, overlayHostConnected: false } },
|
|
||||||
}),
|
|
||||||
"Input.dispatchMouseEvent": (params) => {
|
|
||||||
onMouse?.(params as Record<string, unknown>);
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const environment = await captureSurfaceEnvironment(fake.cdp, 4, undefined);
|
|
||||||
if ("code" in environment) throw new Error(environment.message);
|
|
||||||
const entry = context.refStore.resolveEntry("e3");
|
|
||||||
if (!entry) throw new Error("missing Surface ref");
|
|
||||||
const capture = context.surfaceCaptures.create({
|
|
||||||
sessionId: "aa11",
|
|
||||||
tabId: 4,
|
|
||||||
navigationIdentity: environment.navigationIdentity,
|
|
||||||
surface: {
|
|
||||||
ref: "e3",
|
|
||||||
backendNodeId: 99,
|
|
||||||
observationGeneration: entry.generation,
|
|
||||||
},
|
|
||||||
topViewportRect: { x: 10, y: 20, w: 100, h: 40 },
|
|
||||||
imageWidth: 200,
|
|
||||||
imageHeight: 80,
|
|
||||||
viewportSignature: environment.viewportSignature,
|
|
||||||
frameProjectionSignature: environment.frameProjectionSignature,
|
|
||||||
});
|
|
||||||
return { manager, context, fake, capture };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("modifiersBitfield", () => {
|
describe("modifiersBitfield", () => {
|
||||||
it("matches CDP's expected bit layout", () => {
|
it("matches CDP's expected bit layout", () => {
|
||||||
expect(modifiersBitfield([])).toBe(0);
|
expect(modifiersBitfield([])).toBe(0);
|
||||||
@@ -216,29 +123,6 @@ describe("handleClick", () => {
|
|||||||
expect(fake.cdp.send).not.toHaveBeenCalled();
|
expect(fake.cdp.send).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects screenshot-only visual surface refs before issuing CDP calls", async () => {
|
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
||||||
const ctx = await sm.start("aa11");
|
|
||||||
ctx.refStore.set("e3", 1234, {
|
|
||||||
tabId: 4,
|
|
||||||
kind: "surface",
|
|
||||||
capabilities: ["screenshot"],
|
|
||||||
});
|
|
||||||
const fake = makeFakeCdp({});
|
|
||||||
|
|
||||||
const res = await handleClick(
|
|
||||||
sm,
|
|
||||||
{ session_id: "aa11", ref: "@e3" },
|
|
||||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res).toMatchObject({
|
|
||||||
code: "permission_denied",
|
|
||||||
data: { reason: "ref_capability_denied", required_capability: "interact" },
|
|
||||||
});
|
|
||||||
expect(fake.cdp.send).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clicks by ref, computes the quad centre, dispatches three mouse events", async () => {
|
it("clicks by ref, computes the quad centre, dispatches three mouse events", 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");
|
||||||
@@ -283,74 +167,6 @@ describe("handleClick", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
|
||||||
"left",
|
|
||||||
"middle",
|
|
||||||
"right",
|
|
||||||
] as const)("uses the common click executor for a %s-button Surface click", async (button) => {
|
|
||||||
const { manager, fake, capture } = await makeSurfaceClickFixture();
|
|
||||||
const result = await handleClick(
|
|
||||||
manager,
|
|
||||||
{
|
|
||||||
session_id: "aa11",
|
|
||||||
ref: "@e3",
|
|
||||||
capture_id: capture.id,
|
|
||||||
image_x: 50,
|
|
||||||
image_y: 20,
|
|
||||||
button,
|
|
||||||
click_count: 2,
|
|
||||||
modifiers: ["ctrl", "shift"],
|
|
||||||
},
|
|
||||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
|
||||||
);
|
|
||||||
|
|
||||||
if ("code" in result) throw new Error(JSON.stringify(result));
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
used_ref: "e3",
|
|
||||||
capture_id: capture.id,
|
|
||||||
image_x: 50,
|
|
||||||
image_y: 20,
|
|
||||||
x: 35,
|
|
||||||
y: 30,
|
|
||||||
});
|
|
||||||
const mouse = fake.sent.filter((call) => call.method === "Input.dispatchMouseEvent");
|
|
||||||
expect(mouse).toHaveLength(3);
|
|
||||||
expect(mouse[1].params).toMatchObject({
|
|
||||||
type: "mousePressed",
|
|
||||||
button,
|
|
||||||
clickCount: 2,
|
|
||||||
modifiers: 2 | 8,
|
|
||||||
});
|
|
||||||
expect(mouse[2].params).toMatchObject({
|
|
||||||
type: "mouseReleased",
|
|
||||||
button,
|
|
||||||
clickCount: 2,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("consumes a Surface capture when the common click executor fails", async () => {
|
|
||||||
let mouseCalls = 0;
|
|
||||||
const { manager, fake, capture } = await makeSurfaceClickFixture((params) => {
|
|
||||||
mouseCalls += 1;
|
|
||||||
if (params.type === "mousePressed") throw new Error("input failed");
|
|
||||||
});
|
|
||||||
const params = {
|
|
||||||
session_id: "aa11",
|
|
||||||
ref: "@e3",
|
|
||||||
capture_id: capture.id,
|
|
||||||
image_x: 50,
|
|
||||||
image_y: 20,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await handleClick(manager, params, { cdp: fake.cdp, tabsApi: fake.tabsApi }),
|
|
||||||
).toMatchObject({ code: "cdp_failed" });
|
|
||||||
expect(mouseCalls).toBe(2);
|
|
||||||
expect(
|
|
||||||
await handleClick(manager, params, { cdp: fake.cdp, tabsApi: fake.tabsApi }),
|
|
||||||
).toMatchObject({ data: { reason: "surface_capture_consumed" } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves frame refs in their CDP session and dispatches input in top coordinates", async () => {
|
it("resolves frame refs in their CDP session and dispatches input in top coordinates", 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");
|
||||||
@@ -770,7 +586,7 @@ describe("handleFill", () => {
|
|||||||
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
||||||
"DOM.focus": () => ({}),
|
"DOM.focus": () => ({}),
|
||||||
"DOM.resolveNode": () => ({ object: { objectId: "obj-1" } }),
|
"DOM.resolveNode": () => ({ object: { objectId: "obj-1" } }),
|
||||||
"Runtime.callFunctionOn": fillRuntimeReadback("hello"),
|
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }),
|
||||||
"Input.insertText": () => ({}),
|
"Input.insertText": () => ({}),
|
||||||
});
|
});
|
||||||
const res = await handleFill(
|
const res = await handleFill(
|
||||||
@@ -784,13 +600,13 @@ 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, so the edit transaction prepares a
|
// clear_before defaults to true → callFunctionOn invoked once to
|
||||||
// replacement selection and performs a settled live-value readback.
|
// 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.length).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clear_before=false skips replacement selection", async () => {
|
it("clear_before=false skips the wipe call", 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 });
|
||||||
@@ -803,20 +619,9 @@ describe("handleFill", () => {
|
|||||||
"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 fn = (p as { functionDeclaration?: string }).functionDeclaration ?? "";
|
||||||
if (fn.includes("bsk-editable-state")) {
|
// No "this.value = ''" clearing on a no-clear path; only the
|
||||||
return {
|
// post-input dispatchEvent.
|
||||||
result: {
|
expect(fn).not.toMatch(/this\.value\s*=\s*''/);
|
||||||
value: {
|
|
||||||
supported: true,
|
|
||||||
connected: true,
|
|
||||||
focused: true,
|
|
||||||
value: "existingx",
|
|
||||||
kind: "textarea",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
expect(fn).not.toMatch(/dispatchEvent|\.value\s*=/);
|
|
||||||
return { result: { type: "undefined" } };
|
return { result: { type: "undefined" } };
|
||||||
},
|
},
|
||||||
"Input.insertText": () => ({}),
|
"Input.insertText": () => ({}),
|
||||||
@@ -827,7 +632,7 @@ describe("handleFill", () => {
|
|||||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||||
);
|
);
|
||||||
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.value_length).toBe(9);
|
expect(res.value_length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats contenteditable=true as fillable", async () => {
|
it("treats contenteditable=true as fillable", async () => {
|
||||||
@@ -845,7 +650,7 @@ describe("handleFill", () => {
|
|||||||
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
||||||
"DOM.focus": () => ({}),
|
"DOM.focus": () => ({}),
|
||||||
"DOM.resolveNode": () => ({ object: { objectId: "obj-3" } }),
|
"DOM.resolveNode": () => ({ object: { objectId: "obj-3" } }),
|
||||||
"Runtime.callFunctionOn": fillRuntimeReadback("rich"),
|
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }),
|
||||||
"Input.insertText": () => ({}),
|
"Input.insertText": () => ({}),
|
||||||
});
|
});
|
||||||
const res = await handleFill(
|
const res = await handleFill(
|
||||||
@@ -856,103 +661,7 @@ describe("handleFill", () => {
|
|||||||
expect("code" in res).toBe(false);
|
expect("code" in res).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes OOPIF focus, text, DOM events, and readback through the node target", async () => {
|
it("stops before Input.insertText when abort fires after clearing", async () => {
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
||||||
const ctx = await sm.start("aa11");
|
|
||||||
ctx.refStore.set("e1", 77, {
|
|
||||||
tabId: 4,
|
|
||||||
frameId: "child-frame",
|
|
||||||
cdpSessionId: "child-session",
|
|
||||||
});
|
|
||||||
const fake = makeFakeCdp({ "DOM.scrollIntoViewIfNeeded": () => ({}) });
|
|
||||||
fake.cdp.getFrameGraph = vi.fn(async () => ({
|
|
||||||
rootFrameId: "main",
|
|
||||||
frames: [
|
|
||||||
{ frameId: "main", target: { tabId: 4 } },
|
|
||||||
{
|
|
||||||
frameId: "child-frame",
|
|
||||||
parentFrameId: "main",
|
|
||||||
ownerBackendNodeId: 12,
|
|
||||||
target: { tabId: 4, sessionId: "child-session" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
const childCalls: string[] = [];
|
|
||||||
fake.cdp.sendToTarget = vi.fn(async (target, method, params) => {
|
|
||||||
expect(target).toEqual({ tabId: 4, sessionId: "child-session" });
|
|
||||||
childCalls.push(method);
|
|
||||||
if (method === "DOM.describeNode") {
|
|
||||||
return { node: { backendNodeId: 77, nodeName: "INPUT", attributes: [] } };
|
|
||||||
}
|
|
||||||
if (method === "DOM.scrollIntoViewIfNeeded" || method === "DOM.focus") return {};
|
|
||||||
if (method === "DOM.resolveNode") return { object: { objectId: "child-input" } };
|
|
||||||
if (method === "Input.insertText") {
|
|
||||||
expect(params).toEqual({ text: "中文输入" });
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
if (method === "Runtime.callFunctionOn") {
|
|
||||||
const fn = String((params as { functionDeclaration?: string }).functionDeclaration ?? "");
|
|
||||||
if (fn.includes("bsk-editable-state")) {
|
|
||||||
return {
|
|
||||||
result: {
|
|
||||||
value: {
|
|
||||||
supported: true,
|
|
||||||
connected: true,
|
|
||||||
focused: true,
|
|
||||||
value: "中文输入",
|
|
||||||
kind: "input",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (fn.includes("bsk-editable-selection")) {
|
|
||||||
return { result: { value: { selected: true } } };
|
|
||||||
}
|
|
||||||
return { result: { type: "undefined" } };
|
|
||||||
}
|
|
||||||
throw new Error(`unexpected child call ${method}`);
|
|
||||||
}) as CdpRunner["sendToTarget"];
|
|
||||||
|
|
||||||
const res = await handleFill(
|
|
||||||
sm,
|
|
||||||
{ session_id: "aa11", ref: "@e1", value: "中文输入" },
|
|
||||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
|
||||||
);
|
|
||||||
|
|
||||||
if ("code" in res) throw new Error(JSON.stringify(res));
|
|
||||||
expect(res.value_length).toBe(4);
|
|
||||||
expect(childCalls).toContain("DOM.focus");
|
|
||||||
expect(childCalls).toContain("Input.insertText");
|
|
||||||
expect(childCalls.filter((method) => method === "Runtime.callFunctionOn")).toHaveLength(4);
|
|
||||||
expect(fake.sent.some((call) => call.method === "Input.insertText")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns input_not_applied when a controlled input resets the value", async () => {
|
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
||||||
const ctx = await sm.start("aa11");
|
|
||||||
ctx.refStore.set("e1", 8, { tabId: 4 });
|
|
||||||
const fake = makeFakeCdp({
|
|
||||||
"DOM.describeNode": () => ({ node: { backendNodeId: 8, nodeName: "INPUT" } }),
|
|
||||||
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
|
||||||
"DOM.focus": () => ({}),
|
|
||||||
"DOM.resolveNode": () => ({ object: { objectId: "controlled" } }),
|
|
||||||
"Input.insertText": () => ({}),
|
|
||||||
"Runtime.callFunctionOn": fillRuntimeReadback(""),
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await handleFill(
|
|
||||||
sm,
|
|
||||||
{ session_id: "aa11", ref: "@e1", value: "rejected" },
|
|
||||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res).toMatchObject({
|
|
||||||
code: "cdp_failed",
|
|
||||||
data: { reason: "input_not_applied", expected_value_length: 8, actual_value_length: 0 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stops before Input.insertText when abort fires after preparing replacement", 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", 555, { tabId: 4 });
|
ctx.refStore.set("e1", 555, { tabId: 4 });
|
||||||
@@ -964,25 +673,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": (params) => {
|
"Runtime.callFunctionOn": () => {
|
||||||
const fn = String((params as { functionDeclaration?: string }).functionDeclaration ?? "");
|
|
||||||
if (fn.includes("bsk-editable-state")) {
|
|
||||||
return {
|
|
||||||
result: {
|
|
||||||
value: {
|
|
||||||
supported: true,
|
|
||||||
connected: true,
|
|
||||||
focused: true,
|
|
||||||
value: "",
|
|
||||||
kind: "input",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (fn.includes("bsk-editable-selection")) {
|
|
||||||
abort.abort();
|
abort.abort();
|
||||||
return { result: { value: { selected: true } } };
|
|
||||||
}
|
|
||||||
return { result: { type: "undefined" } };
|
return { result: { type: "undefined" } };
|
||||||
},
|
},
|
||||||
"Input.insertText": () => ({}),
|
"Input.insertText": () => ({}),
|
||||||
@@ -1025,204 +717,6 @@ describe("handleFill", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("handleType", () => {
|
|
||||||
it("requires an explicit target mode instead of silently using focus", async () => {
|
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
||||||
await sm.start("aa11");
|
|
||||||
const fake = makeFakeCdp({});
|
|
||||||
|
|
||||||
const missing = await handleType(
|
|
||||||
sm,
|
|
||||||
{ session_id: "aa11", text: "x" },
|
|
||||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
|
||||||
);
|
|
||||||
expect(missing).toMatchObject({
|
|
||||||
code: "invalid_params",
|
|
||||||
message: expect.stringContaining("focused=true"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mixed = await handleType(
|
|
||||||
sm,
|
|
||||||
{ session_id: "aa11", ref: "@e1", focused: true, text: "x" },
|
|
||||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
|
||||||
);
|
|
||||||
expect(mixed).toMatchObject({
|
|
||||||
code: "invalid_params",
|
|
||||||
message: expect.stringContaining("not both"),
|
|
||||||
});
|
|
||||||
expect(fake.sent).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes committed IME text through the explicit OOPIF target without DOM readback", async () => {
|
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
||||||
const ctx = await sm.start("aa11");
|
|
||||||
ctx.refStore.set("e1", 77, {
|
|
||||||
tabId: 4,
|
|
||||||
frameId: "child-frame",
|
|
||||||
cdpSessionId: "child-session",
|
|
||||||
});
|
|
||||||
const fake = makeFakeCdp({ "DOM.scrollIntoViewIfNeeded": () => ({}) });
|
|
||||||
fake.cdp.getFrameGraph = vi.fn(async () => ({
|
|
||||||
rootFrameId: "main",
|
|
||||||
frames: [
|
|
||||||
{ frameId: "main", target: { tabId: 4 } },
|
|
||||||
{
|
|
||||||
frameId: "child-frame",
|
|
||||||
parentFrameId: "main",
|
|
||||||
ownerBackendNodeId: 12,
|
|
||||||
target: { tabId: 4, sessionId: "child-session" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
const childCalls: Array<{ method: string; params?: object }> = [];
|
|
||||||
fake.cdp.sendToTarget = vi.fn(async (_target, method, params) => {
|
|
||||||
childCalls.push({ method, params });
|
|
||||||
if (method === "DOM.describeNode") {
|
|
||||||
return { node: { backendNodeId: 77, nodeName: "TEXTAREA", attributes: [] } };
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}) as CdpRunner["sendToTarget"];
|
|
||||||
|
|
||||||
const result = await handleType(
|
|
||||||
sm,
|
|
||||||
{ session_id: "aa11", ref: "@e1", text: "测试" },
|
|
||||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
|
||||||
);
|
|
||||||
|
|
||||||
if ("code" in result) throw new Error(JSON.stringify(result));
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
tab_id: 4,
|
|
||||||
used_ref: "e1",
|
|
||||||
text_length: 2,
|
|
||||||
verification: "dispatched",
|
|
||||||
});
|
|
||||||
expect(childCalls.map((call) => call.method)).toEqual([
|
|
||||||
"DOM.describeNode",
|
|
||||||
"DOM.scrollIntoViewIfNeeded",
|
|
||||||
"DOM.focus",
|
|
||||||
"Input.dispatchKeyEvent",
|
|
||||||
"Input.insertText",
|
|
||||||
"Input.dispatchKeyEvent",
|
|
||||||
]);
|
|
||||||
expect(fake.sent.some((call) => call.method.startsWith("Input."))).toBe(false);
|
|
||||||
expect(childCalls.some((call) => call.method === "Runtime.callFunctionOn")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the focused OOPIF only when focused mode resolves a text-entry target", async () => {
|
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
||||||
await sm.start("aa11");
|
|
||||||
const fake = makeFakeCdp({
|
|
||||||
"Runtime.evaluate": () => ({
|
|
||||||
result: { value: { hasFocus: true, activeTag: "iframe", textEntry: false } },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
fake.cdp.getFrameGraph = vi.fn(async () => ({
|
|
||||||
rootFrameId: "main",
|
|
||||||
frames: [
|
|
||||||
{ frameId: "main", target: { tabId: 4 } },
|
|
||||||
{
|
|
||||||
frameId: "child-frame",
|
|
||||||
parentFrameId: "main",
|
|
||||||
target: { tabId: 4, sessionId: "child-session" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
const childCalls: string[] = [];
|
|
||||||
fake.cdp.sendToTarget = vi.fn(async (_target, method) => {
|
|
||||||
childCalls.push(method);
|
|
||||||
if (method === "Runtime.evaluate") {
|
|
||||||
return {
|
|
||||||
result: { value: { hasFocus: true, activeTag: "textarea", textEntry: true } },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}) as CdpRunner["sendToTarget"];
|
|
||||||
|
|
||||||
const result = await handleType(
|
|
||||||
sm,
|
|
||||||
{ session_id: "aa11", focused: true, text: "A" },
|
|
||||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
|
||||||
);
|
|
||||||
|
|
||||||
if ("code" in result) throw new Error(JSON.stringify(result));
|
|
||||||
expect(result).toMatchObject({ text_length: 1, verification: "dispatched" });
|
|
||||||
expect(childCalls).toEqual([
|
|
||||||
"Runtime.evaluate",
|
|
||||||
"Input.dispatchKeyEvent",
|
|
||||||
"Input.dispatchKeyEvent",
|
|
||||||
"Input.dispatchKeyEvent",
|
|
||||||
]);
|
|
||||||
expect(fake.sent.some((call) => call.method.startsWith("Input."))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses focused mode when focus is not owned by a text-entry target", async () => {
|
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
||||||
await sm.start("aa11");
|
|
||||||
const fake = makeFakeCdp({
|
|
||||||
"Runtime.evaluate": () => ({
|
|
||||||
result: { value: { hasFocus: true, activeTag: "button", textEntry: false } },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await handleType(
|
|
||||||
sm,
|
|
||||||
{ session_id: "aa11", focused: true, text: "x" },
|
|
||||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
code: "cdp_failed",
|
|
||||||
data: { reason: "focus_target_unresolved", candidate_count: 0 },
|
|
||||||
});
|
|
||||||
expect(fake.sent.some((call) => call.method.startsWith("Input."))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects explicit non-text input controls", async () => {
|
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
||||||
const ctx = await sm.start("aa11");
|
|
||||||
ctx.refStore.set("e1", 77, { tabId: 4 });
|
|
||||||
const fake = makeFakeCdp({
|
|
||||||
"DOM.describeNode": () => ({
|
|
||||||
node: { backendNodeId: 77, nodeName: "INPUT", attributes: ["type", "checkbox"] },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await handleType(
|
|
||||||
sm,
|
|
||||||
{ session_id: "aa11", ref: "@e1", text: "x" },
|
|
||||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
code: "invalid_params",
|
|
||||||
data: { reason: "target_not_fillable" },
|
|
||||||
});
|
|
||||||
expect(fake.sent.some((call) => call.method.startsWith("Input."))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a Surface ref instead of treating it as a text target", async () => {
|
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
||||||
const ctx = await sm.start("aa11");
|
|
||||||
ctx.refStore.set("e1", 77, {
|
|
||||||
tabId: 4,
|
|
||||||
kind: "surface",
|
|
||||||
capabilities: ["screenshot"],
|
|
||||||
});
|
|
||||||
const fake = makeFakeCdp({});
|
|
||||||
|
|
||||||
const result = await handleType(
|
|
||||||
sm,
|
|
||||||
{ session_id: "aa11", ref: "@e1", text: "x" },
|
|
||||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
code: "permission_denied",
|
|
||||||
data: { reason: "ref_capability_denied", required_capability: "interact" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseKeySpec", () => {
|
describe("parseKeySpec", () => {
|
||||||
it("splits compound expressions into modifiers + base key", () => {
|
it("splits compound expressions into modifiers + base key", () => {
|
||||||
expect(parseKeySpec("Ctrl+A")).toEqual({ key: "A", modifiers: ["ctrl"] });
|
expect(parseKeySpec("Ctrl+A")).toEqual({ key: "A", modifiers: ["ctrl"] });
|
||||||
@@ -1361,125 +855,6 @@ describe("handlePress", () => {
|
|||||||
expect(res.key).toBe("Enter");
|
expect(res.key).toBe("Enter");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes ref-targeted key events through the OOPIF session", async () => {
|
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
||||||
const ctx = await sm.start("aa11");
|
|
||||||
ctx.refStore.set("e1", 555, {
|
|
||||||
tabId: 4,
|
|
||||||
frameId: "child-frame",
|
|
||||||
cdpSessionId: "child-session",
|
|
||||||
});
|
|
||||||
const fake = makeFakeCdp({ "DOM.scrollIntoViewIfNeeded": () => ({}) });
|
|
||||||
fake.cdp.getFrameGraph = vi.fn(async () => ({
|
|
||||||
rootFrameId: "main",
|
|
||||||
frames: [
|
|
||||||
{ frameId: "main", target: { tabId: 4 } },
|
|
||||||
{
|
|
||||||
frameId: "child-frame",
|
|
||||||
parentFrameId: "main",
|
|
||||||
ownerBackendNodeId: 12,
|
|
||||||
target: { tabId: 4, sessionId: "child-session" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
const childCalls: string[] = [];
|
|
||||||
fake.cdp.sendToTarget = vi.fn(async (_target, method) => {
|
|
||||||
childCalls.push(method);
|
|
||||||
return {};
|
|
||||||
}) as CdpRunner["sendToTarget"];
|
|
||||||
|
|
||||||
const res = await handlePress(
|
|
||||||
sm,
|
|
||||||
{ session_id: "aa11", key: "Enter", ref: "@e1" },
|
|
||||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
|
||||||
);
|
|
||||||
|
|
||||||
expectPressOk(res);
|
|
||||||
expect(childCalls).toEqual([
|
|
||||||
"DOM.scrollIntoViewIfNeeded",
|
|
||||||
"DOM.focus",
|
|
||||||
"Input.dispatchKeyEvent",
|
|
||||||
"Input.dispatchKeyEvent",
|
|
||||||
"Input.dispatchKeyEvent",
|
|
||||||
]);
|
|
||||||
expect(fake.sent.some((call) => call.method === "Input.dispatchKeyEvent")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes an untargeted press to the uniquely focused OOPIF session", async () => {
|
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
||||||
await sm.start("aa11");
|
|
||||||
const fake = makeFakeCdp({
|
|
||||||
"Runtime.evaluate": () => ({
|
|
||||||
result: { value: { hasFocus: true, activeTag: "iframe" } },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
fake.cdp.getFrameGraph = vi.fn(async () => ({
|
|
||||||
rootFrameId: "main",
|
|
||||||
frames: [
|
|
||||||
{ frameId: "main", target: { tabId: 4 } },
|
|
||||||
{
|
|
||||||
frameId: "child-frame",
|
|
||||||
parentFrameId: "main",
|
|
||||||
target: { tabId: 4, sessionId: "child-session" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
const childCalls: string[] = [];
|
|
||||||
fake.cdp.sendToTarget = vi.fn(async (_target, method) => {
|
|
||||||
childCalls.push(method);
|
|
||||||
if (method === "Runtime.evaluate") {
|
|
||||||
return { result: { value: { hasFocus: true, activeTag: "textarea" } } };
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}) as CdpRunner["sendToTarget"];
|
|
||||||
|
|
||||||
const res = await handlePress(
|
|
||||||
sm,
|
|
||||||
{ session_id: "aa11", key: "a" },
|
|
||||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
|
||||||
);
|
|
||||||
|
|
||||||
expectPressOk(res);
|
|
||||||
expect(childCalls).toEqual([
|
|
||||||
"Runtime.evaluate",
|
|
||||||
"Input.dispatchKeyEvent",
|
|
||||||
"Input.dispatchKeyEvent",
|
|
||||||
"Input.dispatchKeyEvent",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses an untargeted press when focus ownership is ambiguous", async () => {
|
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
||||||
await sm.start("aa11");
|
|
||||||
const fake = makeFakeCdp({
|
|
||||||
"Runtime.evaluate": () => ({
|
|
||||||
result: { value: { hasFocus: true, activeTag: "input" } },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
fake.cdp.getFrameGraph = vi.fn(async () => ({
|
|
||||||
rootFrameId: "main",
|
|
||||||
frames: [
|
|
||||||
{ frameId: "main", target: { tabId: 4 } },
|
|
||||||
{ frameId: "child", target: { tabId: 4, sessionId: "child-session" } },
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
fake.cdp.sendToTarget = vi.fn(async () => ({
|
|
||||||
result: { value: { hasFocus: true, activeTag: "textarea" } },
|
|
||||||
})) as CdpRunner["sendToTarget"];
|
|
||||||
|
|
||||||
const res = await handlePress(
|
|
||||||
sm,
|
|
||||||
{ session_id: "aa11", key: "Enter" },
|
|
||||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res).toMatchObject({
|
|
||||||
code: "cdp_failed",
|
|
||||||
data: { reason: "focus_target_unresolved", candidate_count: 2 },
|
|
||||||
});
|
|
||||||
expect(fake.sent.some((call) => call.method === "Input.dispatchKeyEvent")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stops before focus when abort fires after press scroll", async () => {
|
it("stops before focus when abort fires after press scroll", 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");
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { dispatchMouseClick } from "../mouse-input";
|
|
||||||
import type { CdpRunner } from "../shared";
|
|
||||||
|
|
||||||
function fakeCdp(onSend?: (method: string, params: Record<string, unknown>) => void): {
|
|
||||||
cdp: CdpRunner;
|
|
||||||
events: Record<string, unknown>[];
|
|
||||||
} {
|
|
||||||
const events: Record<string, unknown>[] = [];
|
|
||||||
return {
|
|
||||||
events,
|
|
||||||
cdp: {
|
|
||||||
send: vi.fn(async (_tabId, method, params) => {
|
|
||||||
const event = params as Record<string, unknown>;
|
|
||||||
events.push(event);
|
|
||||||
onSend?.(method, event);
|
|
||||||
return {} as never;
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("dispatchMouseClick", () => {
|
|
||||||
it("emits one move, press, and release sequence", async () => {
|
|
||||||
const { cdp, events } = fakeCdp();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await dispatchMouseClick(
|
|
||||||
cdp,
|
|
||||||
4,
|
|
||||||
{ x: 12, y: 34 },
|
|
||||||
{
|
|
||||||
button: "left",
|
|
||||||
clickCount: 1,
|
|
||||||
modifiers: 0,
|
|
||||||
moveSettleMs: 0,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
).toBe("completed");
|
|
||||||
|
|
||||||
expect(events).toEqual([
|
|
||||||
{
|
|
||||||
type: "mouseMoved",
|
|
||||||
x: 12,
|
|
||||||
y: 34,
|
|
||||||
modifiers: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "mousePressed",
|
|
||||||
x: 12,
|
|
||||||
y: 34,
|
|
||||||
button: "left",
|
|
||||||
clickCount: 1,
|
|
||||||
modifiers: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "mouseReleased",
|
|
||||||
x: 12,
|
|
||||||
y: 34,
|
|
||||||
button: "left",
|
|
||||||
clickCount: 1,
|
|
||||||
modifiers: 0,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("releases a pressed button when cancellation arrives", async () => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const { cdp, events } = fakeCdp((_method, event) => {
|
|
||||||
if (event.type === "mousePressed") controller.abort();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await dispatchMouseClick(
|
|
||||||
cdp,
|
|
||||||
4,
|
|
||||||
{ x: 1, y: 2 },
|
|
||||||
{
|
|
||||||
button: "left",
|
|
||||||
clickCount: 1,
|
|
||||||
modifiers: 0,
|
|
||||||
signal: controller.signal,
|
|
||||||
moveSettleMs: 0,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
).toBe("cancelled");
|
|
||||||
expect(events.at(-1)).toMatchObject({ type: "mouseReleased" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("settles pointer-move hit testing before pressing", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
try {
|
|
||||||
const { cdp, events } = fakeCdp();
|
|
||||||
const click = dispatchMouseClick(
|
|
||||||
cdp,
|
|
||||||
4,
|
|
||||||
{ x: 1, y: 2 },
|
|
||||||
{
|
|
||||||
button: "left",
|
|
||||||
clickCount: 1,
|
|
||||||
modifiers: 0,
|
|
||||||
moveSettleMs: 32,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(31);
|
|
||||||
expect(events.map((event) => event.type)).toEqual(["mouseMoved"]);
|
|
||||||
await vi.advanceTimersByTimeAsync(1);
|
|
||||||
await click;
|
|
||||||
expect(events.map((event) => event.type)).toEqual([
|
|
||||||
"mouseMoved",
|
|
||||||
"mousePressed",
|
|
||||||
"mouseReleased",
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
buildFrameVomScene,
|
buildFrameVomScene,
|
||||||
buildVomScene,
|
buildVomScene,
|
||||||
type CdpAxNode,
|
type CdpAxNode,
|
||||||
captureVomObservation,
|
|
||||||
handleGetHtml,
|
handleGetHtml,
|
||||||
handleObserve,
|
handleObserve,
|
||||||
handleScreenshot,
|
handleScreenshot,
|
||||||
@@ -67,9 +66,6 @@ function makeFakeCdp(handlers: Record<string, (params?: object) => unknown>) {
|
|||||||
if (!handler && method === "Page.getLayoutMetrics") {
|
if (!handler && method === "Page.getLayoutMetrics") {
|
||||||
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
|
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
|
||||||
}
|
}
|
||||||
if (!handler && method === "Page.getFrameTree") {
|
|
||||||
return { frameTree: { frame: { id: "main", loaderId: "loader-1", url: "https://test/" } } };
|
|
||||||
}
|
|
||||||
if (!handler) throw new Error(`unexpected CDP call ${method}`);
|
if (!handler) throw new Error(`unexpected CDP call ${method}`);
|
||||||
return handler(params);
|
return handler(params);
|
||||||
});
|
});
|
||||||
@@ -303,82 +299,6 @@ describe("handleScreenshot", () => {
|
|||||||
expect(clip).toMatchObject({ x: 10, y: 20, width: 100, height: 40 });
|
expect(clip).toMatchObject({ x: 10, y: 20, width: 100, height: 40 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("captures a bounded visible crop for a visual surface without scrolling it", async () => {
|
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
||||||
const ctx = await sm.start("aa11");
|
|
||||||
ctx.refStore.set("e5", 999, {
|
|
||||||
tabId: 7,
|
|
||||||
kind: "surface",
|
|
||||||
capabilities: ["screenshot"],
|
|
||||||
});
|
|
||||||
const { cdp, sent } = makeFakeCdp({
|
|
||||||
"Page.getLayoutMetrics": () => ({
|
|
||||||
cssLayoutViewport: { clientWidth: 4096, clientHeight: 4096 },
|
|
||||||
}),
|
|
||||||
"DOM.getContentQuads": () => ({ quads: [[0, 0, 4096, 0, 4096, 4096, 0, 4096]] }),
|
|
||||||
"Page.captureScreenshot": () => ({ data: TINY_PNG }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await handleScreenshot(
|
|
||||||
sm,
|
|
||||||
{ session_id: "aa11", ref: "@e5", tab_id: 7 },
|
|
||||||
makeScreenshotDeps({
|
|
||||||
cdp,
|
|
||||||
get: vi.fn(async () => ({ id: 7, windowId: 100, active: false }) as chrome.tabs.Tab),
|
|
||||||
query: vi.fn(),
|
|
||||||
captureVisibleTab: vi.fn(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
|
||||||
expect(sent.some((call) => call.method === "DOM.scrollIntoViewIfNeeded")).toBe(false);
|
|
||||||
const clip = sent.find((call) => call.method === "Page.captureScreenshot")?.params as {
|
|
||||||
clip?: { width: number; height: number; scale: number };
|
|
||||||
};
|
|
||||||
expect(clip.clip).toMatchObject({ width: 4096, height: 4096 });
|
|
||||||
expect(clip.clip?.scale).toBeCloseTo(Math.sqrt(4_000_000 / (4096 * 4096)));
|
|
||||||
expect(res.capture).toMatchObject({
|
|
||||||
surface_ref: "@e5",
|
|
||||||
coordinate_space: "capture-image-pixel",
|
|
||||||
});
|
|
||||||
expect(ctx.surfaceCaptures.size()).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("crops a visual surface screenshot to its observation-time visible region", async () => {
|
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
||||||
const ctx = await sm.start("aa11");
|
|
||||||
ctx.refStore.set("e5", 999, {
|
|
||||||
tabId: 7,
|
|
||||||
kind: "surface",
|
|
||||||
visibleRect: { x: 25, y: 30, w: 50, h: 40 },
|
|
||||||
});
|
|
||||||
const { cdp, sent } = makeFakeCdp({
|
|
||||||
"Page.getLayoutMetrics": () => ({
|
|
||||||
cssLayoutViewport: { clientWidth: 800, clientHeight: 600 },
|
|
||||||
}),
|
|
||||||
"DOM.getContentQuads": () => ({ quads: [[0, 0, 200, 0, 200, 100, 0, 100]] }),
|
|
||||||
"Page.captureScreenshot": () => ({ data: TINY_PNG }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await handleScreenshot(
|
|
||||||
sm,
|
|
||||||
{ session_id: "aa11", ref: "@e5", tab_id: 7 },
|
|
||||||
makeScreenshotDeps({
|
|
||||||
cdp,
|
|
||||||
get: vi.fn(async () => ({ id: 7, windowId: 100, active: false }) as chrome.tabs.Tab),
|
|
||||||
query: vi.fn(),
|
|
||||||
captureVisibleTab: vi.fn(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
|
||||||
const clip = sent.find((call) => call.method === "Page.captureScreenshot")?.params as {
|
|
||||||
clip?: { x: number; y: number; width: number; height: number };
|
|
||||||
};
|
|
||||||
expect(clip.clip).toMatchObject({ x: 25, y: 30, width: 50, height: 40 });
|
|
||||||
expect(res.capture?.id).toMatch(/^sc_/);
|
|
||||||
});
|
|
||||||
|
|
||||||
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]) });
|
||||||
await sm.start("aa11");
|
await sm.start("aa11");
|
||||||
@@ -1409,7 +1329,7 @@ describe("buildVomScene", () => {
|
|||||||
expect(rendered).toContain(' @e2 textbox "验证码"');
|
expect(rendered).toContain(' @e2 textbox "验证码"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks current-password autocomplete as sensitive even for text inputs", () => {
|
it("does not mark non-password inputs sensitive from password-like labels", () => {
|
||||||
const axNodes: CdpAxNode[] = [
|
const axNodes: CdpAxNode[] = [
|
||||||
{
|
{
|
||||||
nodeId: "1",
|
nodeId: "1",
|
||||||
@@ -1444,7 +1364,7 @@ describe("buildVomScene", () => {
|
|||||||
expect(buildVomScene(axNodes, captured).nodes[0]).toEqual(
|
expect(buildVomScene(axNodes, captured).nodes[0]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: 10,
|
id: 10,
|
||||||
sensitive: true,
|
sensitive: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1735,13 +1655,11 @@ describe("buildVomScene", () => {
|
|||||||
|
|
||||||
const scene = buildVomScene(axNodes, captured);
|
const scene = buildVomScene(axNodes, captured);
|
||||||
expect(scene.nodes.find((n) => n.id === 20)).toEqual(
|
expect(scene.nodes.find((n) => n.id === 20)).toEqual(
|
||||||
expect.objectContaining({ id: 20, role: "button", name: "close", cursor: "pointer" }),
|
expect.objectContaining({ id: 20, role: "generic", cursor: "pointer" }),
|
||||||
);
|
);
|
||||||
const rendered = renderVom(scene);
|
const rendered = renderVom(scene);
|
||||||
expect(rendered.text).toContain('@e1 button "close"');
|
expect(rendered.text).toContain('@e1 button "close"');
|
||||||
expect(rendered.refs.map(({ ref, backendNodeId }) => ({ ref, backendNodeId }))).toEqual([
|
expect(rendered.refs).toEqual([{ ref: "e1", backendNodeId: 20 }]);
|
||||||
{ ref: "e1", backendNodeId: 20 },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not promote a clickable container that wraps a real interactive control", () => {
|
it("does not promote a clickable container that wraps a real interactive control", () => {
|
||||||
@@ -1811,7 +1729,7 @@ describe("buildVomScene", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const scene = buildVomScene(axNodes, captured);
|
const scene = buildVomScene(axNodes, captured);
|
||||||
expect(scene.nodes.find((n) => n.id === 20)).toBeUndefined();
|
expect(scene.nodes.find((n) => n.id === 20)?.role).toBe("generic");
|
||||||
expect(scene.nodes.find((n) => n.id === 30)?.role).toBe("link");
|
expect(scene.nodes.find((n) => n.id === 30)?.role).toBe("link");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1880,14 +1798,12 @@ describe("buildVomScene", () => {
|
|||||||
|
|
||||||
const scene = buildVomScene(axNodes, captured);
|
const scene = buildVomScene(axNodes, captured);
|
||||||
expect(scene.nodes.find((n) => n.id === 20)).toEqual(
|
expect(scene.nodes.find((n) => n.id === 20)).toEqual(
|
||||||
expect.objectContaining({ id: 20, role: "button", attrs: { "aria-label": "收藏" } }),
|
expect.objectContaining({ id: 20, role: "generic", attrs: { "aria-label": "收藏" } }),
|
||||||
);
|
);
|
||||||
expect(scene.nodes.find((n) => n.id === 30)).toBeUndefined();
|
expect(scene.nodes.find((n) => n.id === 30)?.role).toBe("generic");
|
||||||
const rendered = renderVom(scene);
|
const rendered = renderVom(scene);
|
||||||
expect(rendered.text).toContain('@e1 button "收藏"');
|
expect(rendered.text).toContain('@e1 button "收藏"');
|
||||||
expect(rendered.refs.map(({ ref, backendNodeId }) => ({ ref, backendNodeId }))).toEqual([
|
expect(rendered.refs).toEqual([{ ref: "e1", backendNodeId: 20 }]);
|
||||||
{ ref: "e1", backendNodeId: 20 },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds active scope blocks from active aria-controls relationships", () => {
|
it("builds active scope blocks from active aria-controls relationships", () => {
|
||||||
@@ -2210,7 +2126,7 @@ describe("buildVomScene", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(scene.surfaces).toEqual([
|
expect(scene.surfaces).toEqual([
|
||||||
{ triggerId: 20, triggerAction: "hover", subItems: ["My profile", "Sign out"] },
|
{ triggerId: 21, triggerAction: "hover", subItems: ["My profile", "Sign out"] },
|
||||||
]);
|
]);
|
||||||
expect(renderVom(scene).text).toContain(
|
expect(renderVom(scene).text).toContain(
|
||||||
'@e1 button "image" [hover first: My profile | Sign out]',
|
'@e1 button "image" [hover first: My profile | Sign out]',
|
||||||
@@ -2772,85 +2688,6 @@ describe("handleSnapshot", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("discovers rendered canvases only in observe and stores a screenshot-only ref", async () => {
|
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
||||||
const ctx = await sm.start("aa11");
|
|
||||||
const root: CdpAxNode = {
|
|
||||||
nodeId: "1",
|
|
||||||
role: { type: "role", value: "RootWebArea" },
|
|
||||||
name: { type: "computedString", value: "Canvas app" },
|
|
||||||
backendDOMNodeId: 100,
|
|
||||||
};
|
|
||||||
const strings = ["body", "canvas", "static", "auto"];
|
|
||||||
const i = (value: string) => strings.indexOf(value);
|
|
||||||
const send = vi.fn(async (_tabId: number, method: string) => {
|
|
||||||
if (method === "Accessibility.enable" || method === "DOMSnapshot.enable") return {};
|
|
||||||
if (method === "Accessibility.getFullAXTree") return { nodes: [root] };
|
|
||||||
if (method === "Page.getLayoutMetrics") {
|
|
||||||
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 } };
|
|
||||||
}
|
|
||||||
if (method === "DOMSnapshot.captureSnapshot") {
|
|
||||||
return {
|
|
||||||
strings,
|
|
||||||
documents: [
|
|
||||||
{
|
|
||||||
nodes: {
|
|
||||||
parentIndex: [-1, 0],
|
|
||||||
nodeName: [i("body"), i("canvas")],
|
|
||||||
backendNodeId: [100, 200],
|
|
||||||
attributes: [[], []],
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
nodeIndex: [0, 1],
|
|
||||||
styles: [
|
|
||||||
[i("static"), i("auto"), i("auto")],
|
|
||||||
[i("static"), i("auto"), i("auto")],
|
|
||||||
],
|
|
||||||
bounds: [
|
|
||||||
[0, 0, 1000, 800],
|
|
||||||
[20, 30, 800, 500],
|
|
||||||
],
|
|
||||||
paintOrders: [0, 1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
throw new Error(`unexpected CDP method ${method}`);
|
|
||||||
});
|
|
||||||
const deps = {
|
|
||||||
cdp: {
|
|
||||||
send: send as unknown as <T = unknown>(
|
|
||||||
tabId: number,
|
|
||||||
method: string,
|
|
||||||
params?: object,
|
|
||||||
) => Promise<T>,
|
|
||||||
trackSessionTab: vi.fn(),
|
|
||||||
},
|
|
||||||
tabsApi: {
|
|
||||||
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]),
|
|
||||||
},
|
|
||||||
conditionalSurfaceProbe: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const observed = await handleObserve(sm, { session_id: "aa11" }, deps);
|
|
||||||
if ("code" in observed) throw new Error(`unexpected error: ${JSON.stringify(observed)}`);
|
|
||||||
expect(observed.text).toContain('@e1 surface "canvas visual surface"');
|
|
||||||
expect(ctx.refStore.resolveEntry("e1")).toMatchObject({
|
|
||||||
backendNodeId: 200,
|
|
||||||
kind: "surface",
|
|
||||||
capabilities: ["screenshot"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const snapshot = await handleSnapshot(sm, { session_id: "aa11" }, deps);
|
|
||||||
if ("code" in snapshot) throw new Error(`unexpected error: ${JSON.stringify(snapshot)}`);
|
|
||||||
expect(snapshot.text).not.toContain("visual surface");
|
|
||||||
expect(snapshot.ref_count).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows passive snapshots of explicit user-window tabs", async () => {
|
it("allows passive snapshots of explicit user-window tabs", async () => {
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
await sm.start("aa11");
|
await sm.start("aa11");
|
||||||
@@ -3067,452 +2904,10 @@ describe("handleSnapshot", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function secretsSnapshotReply(secrets: {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
otp: string;
|
|
||||||
card: string;
|
|
||||||
}) {
|
|
||||||
const S = [
|
|
||||||
"html",
|
|
||||||
"body",
|
|
||||||
"input",
|
|
||||||
"position",
|
|
||||||
"static",
|
|
||||||
"pointer-events",
|
|
||||||
"auto",
|
|
||||||
"type",
|
|
||||||
"text",
|
|
||||||
"password",
|
|
||||||
"autocomplete",
|
|
||||||
"one-time-code",
|
|
||||||
"cc-number",
|
|
||||||
"value",
|
|
||||||
secrets.email,
|
|
||||||
secrets.password,
|
|
||||||
secrets.otp,
|
|
||||||
secrets.card,
|
|
||||||
];
|
|
||||||
const i = (s: string) => S.indexOf(s);
|
|
||||||
const style = [i("static"), i("auto"), i("auto")];
|
|
||||||
return {
|
|
||||||
strings: S,
|
|
||||||
documents: [
|
|
||||||
{
|
|
||||||
nodes: {
|
|
||||||
parentIndex: [-1, 0, 1, 1, 1, 1],
|
|
||||||
nodeName: [i("html"), i("body"), i("input"), i("input"), i("input"), i("input")],
|
|
||||||
backendNodeId: [10, 11, 12, 13, 14, 15],
|
|
||||||
attributes: [
|
|
||||||
[],
|
|
||||||
[],
|
|
||||||
[i("type"), i("text"), i("value"), i(secrets.email)],
|
|
||||||
[i("type"), i("password"), i("value"), i(secrets.password)],
|
|
||||||
[
|
|
||||||
i("type"),
|
|
||||||
i("text"),
|
|
||||||
i("autocomplete"),
|
|
||||||
i("one-time-code"),
|
|
||||||
i("value"),
|
|
||||||
i(secrets.otp),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
i("type"),
|
|
||||||
i("text"),
|
|
||||||
i("autocomplete"),
|
|
||||||
i("cc-number"),
|
|
||||||
i("value"),
|
|
||||||
i(secrets.card),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
inputValue: {
|
|
||||||
index: [2, 3, 4, 5],
|
|
||||||
value: [i(secrets.email), i(secrets.password), i(secrets.otp), i(secrets.card)],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
nodeIndex: [1, 2, 3, 4, 5],
|
|
||||||
styles: [style, style, style, style, style],
|
|
||||||
bounds: [
|
|
||||||
[0, 0, 1000, 800],
|
|
||||||
[10, 10, 200, 32],
|
|
||||||
[10, 50, 200, 32],
|
|
||||||
[10, 90, 200, 32],
|
|
||||||
[10, 130, 200, 32],
|
|
||||||
],
|
|
||||||
paintOrders: [0, 1, 2, 3, 4],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const VP_METRICS = {
|
const VP_METRICS = {
|
||||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
function makeFrameAwareDeps() {
|
|
||||||
const strings = ["html", "body", "iframe", "button", "title", "Remote", "static", "auto"];
|
|
||||||
const i = (value: string) => strings.indexOf(value);
|
|
||||||
const styles = [i("static"), i("auto"), i("auto")];
|
|
||||||
const snapshot = {
|
|
||||||
strings,
|
|
||||||
documents: [
|
|
||||||
{
|
|
||||||
frameId: "main",
|
|
||||||
nodes: {
|
|
||||||
parentIndex: [-1, 0, 1],
|
|
||||||
nodeName: [i("html"), i("body"), i("iframe")],
|
|
||||||
backendNodeId: [10, 11, 12],
|
|
||||||
attributes: [[], [], [i("title"), i("Remote")]],
|
|
||||||
contentDocumentIndex: { index: [2], value: [1] },
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
nodeIndex: [0, 1, 2],
|
|
||||||
styles: [styles, styles, styles],
|
|
||||||
bounds: [
|
|
||||||
[0, 0, 1000, 800],
|
|
||||||
[0, 0, 1000, 800],
|
|
||||||
[100, 100, 400, 300],
|
|
||||||
],
|
|
||||||
paintOrders: [0, 0, 1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
frameId: "child",
|
|
||||||
nodes: {
|
|
||||||
parentIndex: [-1, 0, 1],
|
|
||||||
nodeName: [i("html"), i("body"), i("button")],
|
|
||||||
backendNodeId: [20, 21, 22],
|
|
||||||
attributes: [[], [], []],
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
nodeIndex: [0, 1, 2],
|
|
||||||
styles: [styles, styles, styles],
|
|
||||||
bounds: [
|
|
||||||
[0, 0, 400, 300],
|
|
||||||
[0, 0, 400, 300],
|
|
||||||
[20, 30, 120, 40],
|
|
||||||
],
|
|
||||||
paintOrders: [0, 0, 1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const mainAx: CdpAxNode[] = [
|
|
||||||
{
|
|
||||||
nodeId: "main-root",
|
|
||||||
role: { type: "role", value: "RootWebArea" },
|
|
||||||
backendDOMNodeId: 11,
|
|
||||||
childIds: ["frame-owner"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "frame-owner",
|
|
||||||
parentId: "main-root",
|
|
||||||
role: { type: "role", value: "Iframe" },
|
|
||||||
name: { type: "computedString", value: "Remote" },
|
|
||||||
backendDOMNodeId: 12,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const childAx: CdpAxNode[] = [
|
|
||||||
{
|
|
||||||
nodeId: "child-root",
|
|
||||||
role: { type: "role", value: "RootWebArea" },
|
|
||||||
backendDOMNodeId: 21,
|
|
||||||
childIds: ["child-button"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "child-button",
|
|
||||||
parentId: "child-root",
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
name: { type: "computedString", value: "Frame action" },
|
|
||||||
backendDOMNodeId: 22,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const send = vi.fn(async (_tabId: number, method: string) => {
|
|
||||||
if (method === "Page.getLayoutMetrics") return VP_METRICS;
|
|
||||||
if (method === "DOMSnapshot.enable" || method === "Accessibility.enable") return {};
|
|
||||||
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
|
||||||
if (method === "Accessibility.getFullAXTree") return { nodes: mainAx };
|
|
||||||
throw new Error(`unexpected root CDP method ${method}`);
|
|
||||||
});
|
|
||||||
const sendToTarget = vi.fn(async (_target, method: string) => {
|
|
||||||
if (method === "Accessibility.enable") return {};
|
|
||||||
if (method === "Accessibility.getFullAXTree") return { nodes: childAx };
|
|
||||||
throw new Error(`unexpected child CDP method ${method}`);
|
|
||||||
});
|
|
||||||
const cdp = {
|
|
||||||
send: send as unknown as CdpRunner["send"],
|
|
||||||
sendToTarget: sendToTarget as unknown as NonNullable<CdpRunner["sendToTarget"]>,
|
|
||||||
getFrameGraph: vi.fn(async () => ({
|
|
||||||
rootFrameId: "main",
|
|
||||||
frames: [
|
|
||||||
{ frameId: "main", target: { tabId: 4 } },
|
|
||||||
{
|
|
||||||
frameId: "child",
|
|
||||||
parentFrameId: "main",
|
|
||||||
ownerBackendNodeId: 12,
|
|
||||||
target: { tabId: 4, sessionId: "child-session" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})),
|
|
||||||
trackSessionTab: vi.fn(),
|
|
||||||
} satisfies CdpRunner;
|
|
||||||
return {
|
|
||||||
cdp,
|
|
||||||
tabsApi: {
|
|
||||||
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]),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
it("exposes the frame-aware observation result for recording", async () => {
|
|
||||||
const ax: CdpAxNode[] = [
|
|
||||||
{
|
|
||||||
nodeId: "root",
|
|
||||||
role: { type: "role", value: "RootWebArea" },
|
|
||||||
backendDOMNodeId: 11,
|
|
||||||
childIds: ["password"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "password",
|
|
||||||
parentId: "root",
|
|
||||||
role: { type: "role", value: "textbox" },
|
|
||||||
name: { type: "computedString", value: "Password" },
|
|
||||||
value: { value: "hunter2" },
|
|
||||||
backendDOMNodeId: 13,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const deps = makeOverlayDeps(ax, loginSnapshotReply(), VP_METRICS);
|
|
||||||
|
|
||||||
const result = await captureVomObservation(deps.cdp, 4, "https://example.com", {
|
|
||||||
redactValues: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.text).not.toContain("hunter2");
|
|
||||||
expect(result.text).toContain("•••");
|
|
||||||
expect(result.refs[0]).toMatchObject({
|
|
||||||
backendNodeId: 13,
|
|
||||||
role: "textbox",
|
|
||||||
name: "Password",
|
|
||||||
line: expect.any(Number),
|
|
||||||
});
|
|
||||||
expect(result.rootFrameId).toBe("root");
|
|
||||||
expect(result).not.toHaveProperty("frameDocuments");
|
|
||||||
expect(result.frames).toEqual([{ frameId: "root", target: { tabId: 4 } }]);
|
|
||||||
expect(result.matchNodes.find((node) => node.backendNodeId === 13)).toMatchObject({
|
|
||||||
frameId: "root",
|
|
||||||
backendNodeId: 13,
|
|
||||||
tag: "input",
|
|
||||||
rect: { x: 400, y: 300, w: 200, h: 40 },
|
|
||||||
localRect: { x: 400, y: 300, w: 200, h: 40 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("decorates live observations with active controlled content", async () => {
|
|
||||||
const strings = [
|
|
||||||
"html",
|
|
||||||
"body",
|
|
||||||
"button",
|
|
||||||
"div",
|
|
||||||
"#text",
|
|
||||||
"role",
|
|
||||||
"tab",
|
|
||||||
"aria-selected",
|
|
||||||
"true",
|
|
||||||
"aria-controls",
|
|
||||||
"reviews-panel",
|
|
||||||
"id",
|
|
||||||
"static",
|
|
||||||
"auto",
|
|
||||||
"visible",
|
|
||||||
"1",
|
|
||||||
"A detailed review",
|
|
||||||
];
|
|
||||||
const index = (value: string) => strings.indexOf(value);
|
|
||||||
const style = [index("static"), index("auto"), index("auto"), index("visible"), index("1")];
|
|
||||||
const snapshot = {
|
|
||||||
strings,
|
|
||||||
documents: [
|
|
||||||
{
|
|
||||||
nodes: {
|
|
||||||
parentIndex: [-1, 0, 1, 1, 3],
|
|
||||||
nodeName: [index("html"), index("body"), index("button"), index("div"), index("#text")],
|
|
||||||
backendNodeId: [10, 11, 12, 13, 14],
|
|
||||||
attributes: [
|
|
||||||
[],
|
|
||||||
[],
|
|
||||||
[
|
|
||||||
index("role"),
|
|
||||||
index("tab"),
|
|
||||||
index("aria-selected"),
|
|
||||||
index("true"),
|
|
||||||
index("aria-controls"),
|
|
||||||
index("reviews-panel"),
|
|
||||||
],
|
|
||||||
[index("id"), index("reviews-panel")],
|
|
||||||
[],
|
|
||||||
],
|
|
||||||
nodeValue: [-1, -1, -1, -1, index("A detailed review")],
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
nodeIndex: [0, 1, 2, 3, 4],
|
|
||||||
styles: [style, style, style, style, style],
|
|
||||||
bounds: [
|
|
||||||
[0, 0, 1000, 800],
|
|
||||||
[0, 0, 1000, 800],
|
|
||||||
[20, 20, 120, 40],
|
|
||||||
[20, 80, 500, 200],
|
|
||||||
[20, 80, 200, 20],
|
|
||||||
],
|
|
||||||
paintOrders: [0, 0, 1, 1, 1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const ax: CdpAxNode[] = [
|
|
||||||
{
|
|
||||||
nodeId: "root",
|
|
||||||
backendDOMNodeId: 11,
|
|
||||||
role: { type: "role", value: "RootWebArea" },
|
|
||||||
childIds: ["reviews"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "reviews",
|
|
||||||
parentId: "root",
|
|
||||||
backendDOMNodeId: 12,
|
|
||||||
role: { type: "role", value: "tab" },
|
|
||||||
name: { type: "computedString", value: "Reviews" },
|
|
||||||
properties: [
|
|
||||||
{ name: "selected", value: { value: true } },
|
|
||||||
{ name: "controls", value: { value: "reviews-panel" } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await captureVomObservation(
|
|
||||||
makeOverlayDeps(ax, snapshot, VP_METRICS).cdp,
|
|
||||||
4,
|
|
||||||
"https://example.com",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.text).toContain('@e1 tab "Reviews"');
|
|
||||||
expect(result.text).toContain("[§ active: Reviews]");
|
|
||||||
expect(result.text).toContain("A detailed review");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not leak form secrets through the record-safe observation payload", async () => {
|
|
||||||
const secrets = {
|
|
||||||
email: "user@example.com",
|
|
||||||
password: "hunter2",
|
|
||||||
otp: "847291",
|
|
||||||
card: "4111111111111111",
|
|
||||||
};
|
|
||||||
const ax: CdpAxNode[] = [
|
|
||||||
{
|
|
||||||
nodeId: "root",
|
|
||||||
role: { type: "role", value: "RootWebArea" },
|
|
||||||
backendDOMNodeId: 11,
|
|
||||||
childIds: ["email", "password", "otp", "card"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "email",
|
|
||||||
parentId: "root",
|
|
||||||
role: { type: "role", value: "textbox" },
|
|
||||||
name: { type: "computedString", value: "Email" },
|
|
||||||
value: { value: secrets.email },
|
|
||||||
backendDOMNodeId: 12,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "password",
|
|
||||||
parentId: "root",
|
|
||||||
role: { type: "role", value: "textbox" },
|
|
||||||
name: { type: "computedString", value: "Password" },
|
|
||||||
value: { value: secrets.password },
|
|
||||||
backendDOMNodeId: 13,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "otp",
|
|
||||||
parentId: "root",
|
|
||||||
role: { type: "role", value: "textbox" },
|
|
||||||
name: { type: "computedString", value: "Code" },
|
|
||||||
value: { value: secrets.otp },
|
|
||||||
backendDOMNodeId: 14,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "card",
|
|
||||||
parentId: "root",
|
|
||||||
role: { type: "role", value: "textbox" },
|
|
||||||
name: { type: "computedString", value: "Card" },
|
|
||||||
value: { value: secrets.card },
|
|
||||||
backendDOMNodeId: 15,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const deps = makeOverlayDeps(ax, secretsSnapshotReply(secrets), VP_METRICS);
|
|
||||||
|
|
||||||
const result = await captureVomObservation(deps.cdp, 4, "https://example.com", {
|
|
||||||
redactValues: true,
|
|
||||||
});
|
|
||||||
const dumped = JSON.stringify(result);
|
|
||||||
|
|
||||||
expect(result).not.toHaveProperty("frameDocuments");
|
|
||||||
expect(dumped).not.toContain(secrets.email);
|
|
||||||
expect(dumped).not.toContain(secrets.password);
|
|
||||||
expect(dumped).not.toContain(secrets.otp);
|
|
||||||
expect(dumped).not.toContain(secrets.card);
|
|
||||||
expect(dumped).not.toContain("formValue");
|
|
||||||
expect(dumped).not.toContain("formDefaultValue");
|
|
||||||
expect(dumped).not.toContain("axNodes");
|
|
||||||
expect(dumped).not.toContain("domNodes");
|
|
||||||
expect(dumped).not.toContain("attrs");
|
|
||||||
expect(result.text).toContain("•••");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps frames and rendered refs on the same frame identity", async () => {
|
|
||||||
const deps = makeFrameAwareDeps();
|
|
||||||
|
|
||||||
const result = await captureVomObservation(deps.cdp, 4, "https://example.com");
|
|
||||||
|
|
||||||
expect(result.rootFrameId).toBe("main");
|
|
||||||
expect(result.frames).toHaveLength(2);
|
|
||||||
expect(result.frames.find((frame) => frame.frameId === "child")).toEqual({
|
|
||||||
frameId: "child",
|
|
||||||
parentFrameId: "main",
|
|
||||||
ownerBackendNodeId: 12,
|
|
||||||
target: { tabId: 4, sessionId: "child-session" },
|
|
||||||
});
|
|
||||||
expect(result.matchNodes.find((node) => node.backendNodeId === 22)).toMatchObject({
|
|
||||||
frameId: "child",
|
|
||||||
tag: "button",
|
|
||||||
localRect: { x: 20, y: 30, w: 120, h: 40 },
|
|
||||||
});
|
|
||||||
expect(result.refs.find((ref) => ref.backendNodeId === 22)).toMatchObject({
|
|
||||||
frameId: "child",
|
|
||||||
name: "Frame action",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores iframe refs with their owning CDP session", async () => {
|
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
||||||
const ctx = await sm.start("aa11");
|
|
||||||
const deps = makeFrameAwareDeps();
|
|
||||||
|
|
||||||
const result = await handleSnapshot(sm, { session_id: "aa11" }, deps);
|
|
||||||
|
|
||||||
if ("code" in result) throw new Error(`unexpected error: ${JSON.stringify(result)}`);
|
|
||||||
expect(result.text).toContain('@e1 button "Frame action"');
|
|
||||||
const frameRef = [...ctx.refStore.entries()].find(([, entry]) => entry.backendNodeId === 22);
|
|
||||||
expect(frameRef?.[1]).toMatchObject({
|
|
||||||
tabId: 4,
|
|
||||||
frameId: "child",
|
|
||||||
cdpSessionId: "child-session",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders a blocking login overlay as the focused top layer", async () => {
|
it("renders a blocking login overlay as the focused top layer", async () => {
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
await sm.start("aa11");
|
await sm.start("aa11");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -66,52 +66,6 @@ function makeApis(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("handleSessionStop with auto-return", () => {
|
describe("handleSessionStop with auto-return", () => {
|
||||||
it("leaves the session untouched when cancellation arrived before teardown", async () => {
|
|
||||||
const aw = fakeAgentWindow([100]);
|
|
||||||
const sm = new SessionManager({ agentWindow: aw });
|
|
||||||
await sm.start("aa11");
|
|
||||||
const cdp = { detachSession: vi.fn(async () => {}) };
|
|
||||||
const controller = new AbortController();
|
|
||||||
controller.abort();
|
|
||||||
|
|
||||||
const res = await handleSessionStop(
|
|
||||||
sm,
|
|
||||||
{ session_id: "aa11" },
|
|
||||||
{ cdp, signal: controller.signal },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res).toMatchObject({ code: "cancelled" });
|
|
||||||
expect(sm.has("aa11")).toBe(true);
|
|
||||||
expect(cdp.detachSession).not.toHaveBeenCalled();
|
|
||||||
expect(aw.remove).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not close the Agent Window when cancellation lands during CDP detach", async () => {
|
|
||||||
const aw = fakeAgentWindow([100]);
|
|
||||||
const sm = new SessionManager({ agentWindow: aw });
|
|
||||||
await sm.start("aa11");
|
|
||||||
const controller = new AbortController();
|
|
||||||
let finishDetach: () => void = () => {};
|
|
||||||
const detach = new Promise<void>((resolve) => {
|
|
||||||
finishDetach = resolve;
|
|
||||||
});
|
|
||||||
const cdp = { detachSession: vi.fn(() => detach) };
|
|
||||||
|
|
||||||
const stopping = handleSessionStop(
|
|
||||||
sm,
|
|
||||||
{ session_id: "aa11" },
|
|
||||||
{ cdp, signal: controller.signal },
|
|
||||||
);
|
|
||||||
await vi.waitFor(() => expect(cdp.detachSession).toHaveBeenCalledWith("aa11"));
|
|
||||||
controller.abort();
|
|
||||||
finishDetach();
|
|
||||||
|
|
||||||
const res = await stopping;
|
|
||||||
expect(res).toMatchObject({ code: "cancelled" });
|
|
||||||
expect(sm.has("aa11")).toBe(true);
|
|
||||||
expect(aw.remove).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns every borrowed tab and closes the Agent Window in the right order", async () => {
|
it("returns every borrowed tab and closes the Agent Window in the right order", async () => {
|
||||||
const aw = fakeAgentWindow([100]);
|
const aw = fakeAgentWindow([100]);
|
||||||
const sm = new SessionManager({ agentWindow: aw });
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
@@ -252,23 +206,12 @@ describe("handleSessionStop with auto-return", () => {
|
|||||||
expect(aw.remove).not.toHaveBeenCalled();
|
expect(aw.remove).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears refs and Surface captures before window teardown", async () => {
|
it("clears the RefStore before window teardown", async () => {
|
||||||
const aw = fakeAgentWindow([100]);
|
const aw = fakeAgentWindow([100]);
|
||||||
const sm = new SessionManager({ agentWindow: aw });
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
const ctx = await sm.start("aa11");
|
const ctx = await sm.start("aa11");
|
||||||
// Insert a fake ref so we can verify clear() ran.
|
// Insert a fake ref so we can verify clear() ran.
|
||||||
ctx.refStore.set("e1", 123, { tabId: 7 });
|
ctx.refStore.set("e1", 123, { tabId: 7 });
|
||||||
ctx.surfaceCaptures.create({
|
|
||||||
sessionId: "aa11",
|
|
||||||
tabId: 7,
|
|
||||||
navigationIdentity: "navigation",
|
|
||||||
surface: { ref: "e1", backendNodeId: 123, observationGeneration: 0 },
|
|
||||||
topViewportRect: { x: 0, y: 0, w: 100, h: 50 },
|
|
||||||
imageWidth: 100,
|
|
||||||
imageHeight: 50,
|
|
||||||
viewportSignature: "viewport",
|
|
||||||
frameProjectionSignature: "frame",
|
|
||||||
});
|
|
||||||
const state: FakeState = {
|
const state: FakeState = {
|
||||||
tabs: new Map(),
|
tabs: new Map(),
|
||||||
windowsClosed: new Set(),
|
windowsClosed: new Set(),
|
||||||
@@ -277,6 +220,5 @@ describe("handleSessionStop with auto-return", () => {
|
|||||||
const { tabs, windows } = makeApis(state);
|
const { tabs, windows } = makeApis(state);
|
||||||
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);
|
||||||
expect(ctx.surfaceCaptures.size()).toBe(0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,16 +24,10 @@ describe("lookupSnapshotRef", () => {
|
|||||||
expect(lookupSnapshotRef(ctx, "@e3", 4)).toEqual({
|
expect(lookupSnapshotRef(ctx, "@e3", 4)).toEqual({
|
||||||
backendNodeId: 1234,
|
backendNodeId: 1234,
|
||||||
refKey: "e3",
|
refKey: "e3",
|
||||||
kind: "dom",
|
|
||||||
capabilities: ["interact", "screenshot"],
|
|
||||||
generation: 0,
|
|
||||||
});
|
});
|
||||||
expect(lookupSnapshotRef(ctx, "e3", 4)).toEqual({
|
expect(lookupSnapshotRef(ctx, "e3", 4)).toEqual({
|
||||||
backendNodeId: 1234,
|
backendNodeId: 1234,
|
||||||
refKey: "e3",
|
refKey: "e3",
|
||||||
kind: "dom",
|
|
||||||
capabilities: ["interact", "screenshot"],
|
|
||||||
generation: 0,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,8 +53,6 @@ describe("resolveSnapshotRef", () => {
|
|||||||
tabId: 4,
|
tabId: 4,
|
||||||
frameId: "child-frame",
|
frameId: "child-frame",
|
||||||
cdpSessionId: "child-session",
|
cdpSessionId: "child-session",
|
||||||
kind: "dom",
|
|
||||||
capabilities: ["interact", "screenshot"],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const expected = {
|
const expected = {
|
||||||
@@ -68,9 +60,6 @@ describe("resolveSnapshotRef", () => {
|
|||||||
refKey: "e3",
|
refKey: "e3",
|
||||||
frameId: "child-frame",
|
frameId: "child-frame",
|
||||||
cdpSessionId: "child-session",
|
cdpSessionId: "child-session",
|
||||||
kind: "dom" as const,
|
|
||||||
capabilities: ["interact", "screenshot"] as const,
|
|
||||||
generation: 0,
|
|
||||||
};
|
};
|
||||||
expect(lookupSnapshotRef(ctx, "@e3", 4)).toEqual(expected);
|
expect(lookupSnapshotRef(ctx, "@e3", 4)).toEqual(expected);
|
||||||
expect(resolveSnapshotRef(ctx, "@e3", 4)).toEqual(expected);
|
expect(resolveSnapshotRef(ctx, "@e3", 4)).toEqual(expected);
|
||||||
@@ -116,29 +105,6 @@ describe("resolveSnapshotRef", () => {
|
|||||||
expect(resolveSnapshotRef(ctx, "@e3", 4)).toEqual({
|
expect(resolveSnapshotRef(ctx, "@e3", 4)).toEqual({
|
||||||
backendNodeId: 1234,
|
backendNodeId: 1234,
|
||||||
refKey: "e3",
|
refKey: "e3",
|
||||||
kind: "dom",
|
|
||||||
capabilities: ["interact", "screenshot"],
|
|
||||||
generation: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an operation outside the ref's declared capabilities", async () => {
|
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
||||||
const ctx = await sm.start("aa11");
|
|
||||||
ctx.refStore.set("e3", 1234, {
|
|
||||||
tabId: 4,
|
|
||||||
kind: "surface",
|
|
||||||
capabilities: ["screenshot"],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(resolveSnapshotRef(ctx, "@e3", 4, "interact")).toMatchObject({
|
|
||||||
code: "permission_denied",
|
|
||||||
message: "ref @e3 does not support interact",
|
|
||||||
data: {
|
|
||||||
reason: "ref_capability_denied",
|
|
||||||
required_capability: "interact",
|
|
||||||
kind: "surface",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
mapImagePointToViewport,
|
|
||||||
pointInRegion,
|
|
||||||
sameRect,
|
|
||||||
surfaceVisibleRect,
|
|
||||||
} from "../surface-coordinate";
|
|
||||||
|
|
||||||
describe("surface coordinate mapping", () => {
|
|
||||||
it("maps capture image pixels through the actual screenshot dimensions", () => {
|
|
||||||
expect(mapImagePointToViewport({ x: 100, y: 50, w: 400, h: 200 }, 800, 400, 200, 100)).toEqual({
|
|
||||||
x: 200,
|
|
||||||
y: 100,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects invalid image coordinates and dimensions", () => {
|
|
||||||
const rect = { x: 0, y: 0, w: 100, h: 50 };
|
|
||||||
expect(mapImagePointToViewport(rect, 100, 50, -1, 0)).toBeNull();
|
|
||||||
expect(mapImagePointToViewport(rect, 100, 50, 100, 0)).toBeNull();
|
|
||||||
expect(mapImagePointToViewport(rect, 100, 50, 0, 50)).toBeNull();
|
|
||||||
expect(mapImagePointToViewport(rect, 100, 50, Number.NaN, 0)).toBeNull();
|
|
||||||
expect(mapImagePointToViewport(rect, 100, 50, Number.POSITIVE_INFINITY, 0)).toBeNull();
|
|
||||||
expect(mapImagePointToViewport(rect, 0, 50, 0, 0)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("intersects live and observed visible regions and compares them strictly", () => {
|
|
||||||
const clipped = surfaceVisibleRect(
|
|
||||||
{ x: 0, y: 0, width: 200, height: 100 },
|
|
||||||
{ x: 25, y: 30, w: 50, h: 40 },
|
|
||||||
);
|
|
||||||
expect(clipped).toEqual({ x: 25, y: 30, w: 50, h: 40 });
|
|
||||||
expect(sameRect(clipped as NonNullable<typeof clipped>, { x: 25, y: 30, w: 50, h: 40 })).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(sameRect(clipped as NonNullable<typeof clipped>, { x: 26, y: 30, w: 50, h: 40 })).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("checks the projected visible region rather than only its bounding box", () => {
|
|
||||||
const triangle = [
|
|
||||||
[
|
|
||||||
{ x: 0, y: 0 },
|
|
||||||
{ x: 100, y: 0 },
|
|
||||||
{ x: 0, y: 100 },
|
|
||||||
],
|
|
||||||
];
|
|
||||||
expect(pointInRegion({ x: 20, y: 20 }, triangle)).toBe(true);
|
|
||||||
expect(pointInRegion({ x: 90, y: 90 }, triangle)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { SessionManager } from "@/session-manager/manager";
|
|
||||||
import type { CdpRunner } from "../shared";
|
|
||||||
import {
|
|
||||||
captureSurfaceEnvironment,
|
|
||||||
resolveSurfacePointerTarget,
|
|
||||||
SURFACE_POINTER_MOVE_SETTLE_MS,
|
|
||||||
} from "../surface-target";
|
|
||||||
|
|
||||||
function fakeAgentWindow() {
|
|
||||||
return {
|
|
||||||
create: vi.fn(async () => 100),
|
|
||||||
remove: vi.fn(async () => {}),
|
|
||||||
ensureActiveTab: vi.fn(async () => {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function fakeBrowser() {
|
|
||||||
let loaderId = "loader-1";
|
|
||||||
let pageY = 0;
|
|
||||||
let quad = [10, 20, 110, 20, 110, 60, 10, 60];
|
|
||||||
const sent: Array<{ method: string; params?: object }> = [];
|
|
||||||
const cdp: CdpRunner = {
|
|
||||||
send: vi.fn(async (_tabId: number, method: string, params?: object) => {
|
|
||||||
sent.push({ method, params });
|
|
||||||
if (method === "Page.getFrameTree") {
|
|
||||||
return {
|
|
||||||
frameTree: { frame: { id: "main", loaderId, url: "https://fixture.test/" } },
|
|
||||||
} as never;
|
|
||||||
}
|
|
||||||
if (method === "Page.getLayoutMetrics") {
|
|
||||||
return {
|
|
||||||
cssLayoutViewport: { clientWidth: 800, clientHeight: 600, pageX: 0, pageY },
|
|
||||||
cssVisualViewport: {
|
|
||||||
clientWidth: 800,
|
|
||||||
clientHeight: 600,
|
|
||||||
pageX: 0,
|
|
||||||
pageY,
|
|
||||||
scale: 1,
|
|
||||||
},
|
|
||||||
} as never;
|
|
||||||
}
|
|
||||||
if (method === "DOM.getContentQuads") return { quads: [quad] } as never;
|
|
||||||
throw new Error(`unexpected CDP call ${method}`);
|
|
||||||
}) as CdpRunner["send"],
|
|
||||||
trackSessionTab: vi.fn(),
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
cdp,
|
|
||||||
sent,
|
|
||||||
setLoaderId: (value: string) => {
|
|
||||||
loaderId = value;
|
|
||||||
},
|
|
||||||
setPageY: (value: number) => {
|
|
||||||
pageY = value;
|
|
||||||
},
|
|
||||||
setQuad: (value: number[]) => {
|
|
||||||
quad = value;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setupCapture() {
|
|
||||||
const manager = new SessionManager({ agentWindow: fakeAgentWindow() });
|
|
||||||
const context = await manager.start("aa11");
|
|
||||||
context.refStore.set("e3", 99, {
|
|
||||||
tabId: 4,
|
|
||||||
kind: "surface",
|
|
||||||
visibleRect: { x: 10, y: 20, w: 100, h: 40 },
|
|
||||||
});
|
|
||||||
const browser = fakeBrowser();
|
|
||||||
const environment = await captureSurfaceEnvironment(browser.cdp, 4, undefined);
|
|
||||||
if ("code" in environment) throw new Error(environment.message);
|
|
||||||
const entry = context.refStore.resolveEntry("e3");
|
|
||||||
if (!entry) throw new Error("missing ref");
|
|
||||||
const capture = context.surfaceCaptures.create({
|
|
||||||
sessionId: "aa11",
|
|
||||||
tabId: 4,
|
|
||||||
navigationIdentity: environment.navigationIdentity,
|
|
||||||
surface: {
|
|
||||||
ref: "e3",
|
|
||||||
backendNodeId: 99,
|
|
||||||
observationGeneration: entry.generation,
|
|
||||||
},
|
|
||||||
topViewportRect: { x: 10, y: 20, w: 100, h: 40 },
|
|
||||||
imageWidth: 200,
|
|
||||||
imageHeight: 80,
|
|
||||||
viewportSignature: environment.viewportSignature,
|
|
||||||
frameProjectionSignature: environment.frameProjectionSignature,
|
|
||||||
});
|
|
||||||
return { manager, context, browser, capture };
|
|
||||||
}
|
|
||||||
|
|
||||||
function pointParams(captureId: string) {
|
|
||||||
return {
|
|
||||||
ref: "@e3",
|
|
||||||
captureId,
|
|
||||||
imageX: 50,
|
|
||||||
imageY: 20,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Surface screenshot-bound pointer target", () => {
|
|
||||||
it("maps one fresh capture coordinate without executing an action", async () => {
|
|
||||||
const { context, browser, capture } = await setupCapture();
|
|
||||||
const result = await resolveSurfacePointerTarget(
|
|
||||||
context,
|
|
||||||
{ tabId: 4 },
|
|
||||||
pointParams(capture.id),
|
|
||||||
{ cdp: browser.cdp },
|
|
||||||
);
|
|
||||||
|
|
||||||
if ("code" in result) throw new Error(JSON.stringify(result));
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
usedRef: "e3",
|
|
||||||
point: { x: 35, y: 30 },
|
|
||||||
moveSettleMs: SURFACE_POINTER_MOVE_SETTLE_MS,
|
|
||||||
capture: { id: capture.id, imageX: 50, imageY: 20 },
|
|
||||||
});
|
|
||||||
expect(browser.sent.some((call) => call.method === "Input.dispatchMouseEvent")).toBe(false);
|
|
||||||
|
|
||||||
const repeated = await resolveSurfacePointerTarget(
|
|
||||||
context,
|
|
||||||
{ tabId: 4 },
|
|
||||||
pointParams(capture.id),
|
|
||||||
{ cdp: browser.cdp },
|
|
||||||
);
|
|
||||||
expect(repeated).toMatchObject({
|
|
||||||
code: "permission_denied",
|
|
||||||
data: { reason: "surface_capture_consumed" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a new observation generation before sending input", async () => {
|
|
||||||
const { context, browser, capture } = await setupCapture();
|
|
||||||
context.refStore.replace([
|
|
||||||
[
|
|
||||||
"e3",
|
|
||||||
{
|
|
||||||
backendNodeId: 99,
|
|
||||||
tabId: 4,
|
|
||||||
kind: "surface" as const,
|
|
||||||
visibleRect: { x: 10, y: 20, w: 100, h: 40 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await resolveSurfacePointerTarget(
|
|
||||||
context,
|
|
||||||
{ tabId: 4 },
|
|
||||||
pointParams(capture.id),
|
|
||||||
{ cdp: browser.cdp },
|
|
||||||
);
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
code: "permission_denied",
|
|
||||||
data: { reason: "surface_capture_stale" },
|
|
||||||
});
|
|
||||||
expect(browser.sent.some((call) => call.method === "Input.dispatchMouseEvent")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects navigation, scroll, and visible geometry changes", async () => {
|
|
||||||
for (const mutate of [
|
|
||||||
(browser: ReturnType<typeof fakeBrowser>) => browser.setLoaderId("loader-2"),
|
|
||||||
(browser: ReturnType<typeof fakeBrowser>) => browser.setPageY(20),
|
|
||||||
(browser: ReturnType<typeof fakeBrowser>) =>
|
|
||||||
browser.setQuad([11, 20, 111, 20, 111, 60, 11, 60]),
|
|
||||||
]) {
|
|
||||||
const { context, browser, capture } = await setupCapture();
|
|
||||||
mutate(browser);
|
|
||||||
const result = await resolveSurfacePointerTarget(
|
|
||||||
context,
|
|
||||||
{ tabId: 4 },
|
|
||||||
pointParams(capture.id),
|
|
||||||
{ cdp: browser.cdp },
|
|
||||||
);
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
code: "permission_denied",
|
|
||||||
data: { reason: "surface_capture_stale" },
|
|
||||||
});
|
|
||||||
expect(browser.sent.some((call) => call.method === "Input.dispatchMouseEvent")).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("consumes the capture when coordinate validation fails", async () => {
|
|
||||||
const invalid = await setupCapture();
|
|
||||||
const outside = await resolveSurfacePointerTarget(
|
|
||||||
invalid.context,
|
|
||||||
{ tabId: 4 },
|
|
||||||
{ ...pointParams(invalid.capture.id), imageX: 200 },
|
|
||||||
{ cdp: invalid.browser.cdp },
|
|
||||||
);
|
|
||||||
expect(outside).toMatchObject({
|
|
||||||
code: "invalid_params",
|
|
||||||
data: { reason: "surface_coordinate_invalid" },
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
await resolveSurfacePointerTarget(
|
|
||||||
invalid.context,
|
|
||||||
{ tabId: 4 },
|
|
||||||
pointParams(invalid.capture.id),
|
|
||||||
{ cdp: invalid.browser.cdp },
|
|
||||||
),
|
|
||||||
).toMatchObject({ data: { reason: "surface_capture_consumed" } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps image coordinates through an OOPIF frame projection", async () => {
|
|
||||||
const manager = new SessionManager({ agentWindow: fakeAgentWindow() });
|
|
||||||
const context = await manager.start("aa11");
|
|
||||||
context.refStore.set("e3", 99, {
|
|
||||||
tabId: 4,
|
|
||||||
frameId: "child",
|
|
||||||
cdpSessionId: "child-session",
|
|
||||||
kind: "surface",
|
|
||||||
visibleRect: { x: 120, y: 110, w: 100, h: 40 },
|
|
||||||
});
|
|
||||||
const graph = {
|
|
||||||
rootFrameId: "main",
|
|
||||||
frames: [
|
|
||||||
{ frameId: "main", target: { tabId: 4 } },
|
|
||||||
{
|
|
||||||
frameId: "child",
|
|
||||||
parentFrameId: "main",
|
|
||||||
ownerBackendNodeId: 77,
|
|
||||||
target: { tabId: 4, sessionId: "child-session" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const sent: Array<{ method: string; params?: object }> = [];
|
|
||||||
const cdp: CdpRunner = {
|
|
||||||
send: vi.fn(async (_tabId: number, method: string, params?: object) => {
|
|
||||||
sent.push({ method, params });
|
|
||||||
if (method === "Page.getFrameTree") {
|
|
||||||
return {
|
|
||||||
frameTree: {
|
|
||||||
frame: { id: "main", loaderId: "loader-1", url: "https://fixture.test/" },
|
|
||||||
},
|
|
||||||
} as never;
|
|
||||||
}
|
|
||||||
if (method === "Page.getLayoutMetrics") {
|
|
||||||
return {
|
|
||||||
cssLayoutViewport: { clientWidth: 800, clientHeight: 600, pageX: 0, pageY: 0 },
|
|
||||||
cssVisualViewport: {
|
|
||||||
clientWidth: 800,
|
|
||||||
clientHeight: 600,
|
|
||||||
pageX: 0,
|
|
||||||
pageY: 0,
|
|
||||||
scale: 1,
|
|
||||||
},
|
|
||||||
} as never;
|
|
||||||
}
|
|
||||||
if (method === "DOM.getBoxModel") {
|
|
||||||
return { model: { content: [100, 100, 300, 100, 300, 200, 100, 200] } } as never;
|
|
||||||
}
|
|
||||||
if (method === "Input.dispatchMouseEvent") return {} as never;
|
|
||||||
throw new Error(`unexpected root CDP call ${method}`);
|
|
||||||
}) as CdpRunner["send"],
|
|
||||||
sendToTarget: vi.fn(async (_target, method) => {
|
|
||||||
if (method === "DOM.getContentQuads") {
|
|
||||||
return { quads: [[20, 10, 120, 10, 120, 50, 20, 50]] } as never;
|
|
||||||
}
|
|
||||||
if (method === "Page.getLayoutMetrics") {
|
|
||||||
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } } as never;
|
|
||||||
}
|
|
||||||
throw new Error(`unexpected child CDP call ${method}`);
|
|
||||||
}) as CdpRunner["sendToTarget"],
|
|
||||||
getFrameGraph: vi.fn(async () => graph),
|
|
||||||
trackSessionTab: vi.fn(),
|
|
||||||
};
|
|
||||||
const environment = await captureSurfaceEnvironment(cdp, 4, "child");
|
|
||||||
if ("code" in environment) throw new Error(environment.message);
|
|
||||||
const entry = context.refStore.resolveEntry("e3");
|
|
||||||
if (!entry) throw new Error("missing ref");
|
|
||||||
const capture = context.surfaceCaptures.create({
|
|
||||||
sessionId: "aa11",
|
|
||||||
tabId: 4,
|
|
||||||
navigationIdentity: environment.navigationIdentity,
|
|
||||||
surface: {
|
|
||||||
ref: "e3",
|
|
||||||
frameId: "child",
|
|
||||||
backendNodeId: 99,
|
|
||||||
observationGeneration: entry.generation,
|
|
||||||
},
|
|
||||||
topViewportRect: { x: 120, y: 110, w: 100, h: 40 },
|
|
||||||
imageWidth: 200,
|
|
||||||
imageHeight: 80,
|
|
||||||
viewportSignature: environment.viewportSignature,
|
|
||||||
frameProjectionSignature: environment.frameProjectionSignature,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await resolveSurfacePointerTarget(
|
|
||||||
context,
|
|
||||||
{ tabId: 4 },
|
|
||||||
{
|
|
||||||
ref: "@e3",
|
|
||||||
captureId: capture.id,
|
|
||||||
imageX: 100,
|
|
||||||
imageY: 40,
|
|
||||||
},
|
|
||||||
{ cdp },
|
|
||||||
);
|
|
||||||
|
|
||||||
if ("code" in result) throw new Error(JSON.stringify(result));
|
|
||||||
expect(result).toMatchObject({ point: { x: 170, y: 130 } });
|
|
||||||
expect(sent.some((call) => call.method === "Input.dispatchMouseEvent")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import type { CdpRunner } from "../shared";
|
|
||||||
import { dispatchTextInput } from "../text-input";
|
|
||||||
|
|
||||||
function makeCdp(onSend?: (method: string, params?: object) => void) {
|
|
||||||
const sent: Array<{ method: string; params?: object }> = [];
|
|
||||||
const send = vi.fn(async <T>(_tabId: number, method: string, params?: object): Promise<T> => {
|
|
||||||
sent.push({ method, params });
|
|
||||||
onSend?.(method, params);
|
|
||||||
return {} as T;
|
|
||||||
});
|
|
||||||
return { cdp: { send } as CdpRunner, sent };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("dispatchTextInput", () => {
|
|
||||||
it("types ASCII through complete keydown, char, and keyup sequences", async () => {
|
|
||||||
const fake = makeCdp();
|
|
||||||
|
|
||||||
const result = await dispatchTextInput(fake.cdp, 4, "Ab");
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(
|
|
||||||
fake.sent.map((call) => `${call.method}:${(call.params as { type?: string }).type}`),
|
|
||||||
).toEqual([
|
|
||||||
"Input.dispatchKeyEvent:rawKeyDown",
|
|
||||||
"Input.dispatchKeyEvent:char",
|
|
||||||
"Input.dispatchKeyEvent:keyUp",
|
|
||||||
"Input.dispatchKeyEvent:rawKeyDown",
|
|
||||||
"Input.dispatchKeyEvent:char",
|
|
||||||
"Input.dispatchKeyEvent:keyUp",
|
|
||||||
]);
|
|
||||||
expect(fake.sent[0].params).toMatchObject({ key: "A", code: "KeyA", modifiers: 8 });
|
|
||||||
expect(fake.sent[1].params).toMatchObject({ text: "A" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("types non-ASCII through the committed IME insertion path", async () => {
|
|
||||||
const fake = makeCdp();
|
|
||||||
|
|
||||||
const result = await dispatchTextInput(fake.cdp, 4, "测试");
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(fake.sent).toEqual([
|
|
||||||
{
|
|
||||||
method: "Input.dispatchKeyEvent",
|
|
||||||
params: {
|
|
||||||
type: "rawKeyDown",
|
|
||||||
key: "Process",
|
|
||||||
code: "",
|
|
||||||
windowsVirtualKeyCode: 229,
|
|
||||||
nativeVirtualKeyCode: 229,
|
|
||||||
modifiers: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ method: "Input.insertText", params: { text: "测试" } },
|
|
||||||
{
|
|
||||||
method: "Input.dispatchKeyEvent",
|
|
||||||
params: {
|
|
||||||
type: "keyUp",
|
|
||||||
key: "Process",
|
|
||||||
code: "",
|
|
||||||
windowsVirtualKeyCode: 229,
|
|
||||||
nativeVirtualKeyCode: 229,
|
|
||||||
modifiers: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves text order across keyboard and IME segments", async () => {
|
|
||||||
const fake = makeCdp();
|
|
||||||
|
|
||||||
const result = await dispatchTextInput(fake.cdp, 4, "A测试1");
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
const meaningful = fake.sent.flatMap((call) => {
|
|
||||||
if (call.method === "Input.insertText") {
|
|
||||||
return [`ime:${(call.params as { text: string }).text}`];
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
call.method === "Input.dispatchKeyEvent" &&
|
|
||||||
(call.params as { type?: string }).type === "char"
|
|
||||||
) {
|
|
||||||
return [`key:${(call.params as { text: string }).text}`];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
expect(meaningful).toEqual(["key:A", "ime:测试", "key:1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports cancellation observed after an accepted IME insertion", async () => {
|
|
||||||
const abort = new AbortController();
|
|
||||||
const fake = makeCdp((method) => {
|
|
||||||
if (method === "Input.insertText") abort.abort();
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await dispatchTextInput(fake.cdp, 4, "测试", abort.signal);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ code: "cancelled" });
|
|
||||||
expect(fake.sent.map((call) => call.method)).toEqual([
|
|
||||||
"Input.dispatchKeyEvent",
|
|
||||||
"Input.insertText",
|
|
||||||
"Input.dispatchKeyEvent",
|
|
||||||
]);
|
|
||||||
expect(fake.sent.at(-1)?.params).toMatchObject({ type: "keyUp", key: "Process" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("releases the IME process key without committing when cancellation follows keydown", async () => {
|
|
||||||
const abort = new AbortController();
|
|
||||||
const fake = makeCdp((method, params) => {
|
|
||||||
if (
|
|
||||||
method === "Input.dispatchKeyEvent" &&
|
|
||||||
(params as { type?: string }).type === "rawKeyDown"
|
|
||||||
) {
|
|
||||||
abort.abort();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await dispatchTextInput(fake.cdp, 4, "测试", abort.signal);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ code: "cancelled" });
|
|
||||||
expect(fake.sent.map((call) => call.method)).toEqual([
|
|
||||||
"Input.dispatchKeyEvent",
|
|
||||||
"Input.dispatchKeyEvent",
|
|
||||||
]);
|
|
||||||
expect(fake.sent[0].params).toMatchObject({ type: "rawKeyDown", key: "Process" });
|
|
||||||
expect(fake.sent[1].params).toMatchObject({ type: "keyUp", key: "Process" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export {
|
|
||||||
type CaptureVomObservationOptions,
|
|
||||||
captureVomObservation,
|
|
||||||
} from "./observation";
|
|
||||||
export {
|
|
||||||
type CaptureVomFrame,
|
|
||||||
type CaptureVomMatchNode,
|
|
||||||
type CaptureVomObservationResult,
|
|
||||||
} from "./vom/record-safe-observation";
|
|
||||||
@@ -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,22 +29,16 @@ import type {
|
|||||||
ScreenshotParams,
|
ScreenshotParams,
|
||||||
SelectParams,
|
SelectParams,
|
||||||
SnapshotParams,
|
SnapshotParams,
|
||||||
TypeParams,
|
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 { handleEvaluate } from "./evaluate";
|
import { handleEvaluate } from "./evaluate";
|
||||||
import { handleRequestHelp } from "./human-loop";
|
import { handleRequestHelp } from "./human-loop";
|
||||||
import {
|
import { handleClick, handleFill, handleHover, handlePress, handleSelect } from "./interaction";
|
||||||
handleClick,
|
|
||||||
handleFill,
|
|
||||||
handleHover,
|
|
||||||
handlePress,
|
|
||||||
handleSelect,
|
|
||||||
handleType,
|
|
||||||
} from "./interaction";
|
|
||||||
import {
|
import {
|
||||||
handleNavigate,
|
handleNavigate,
|
||||||
handleNavigateBack,
|
handleNavigateBack,
|
||||||
@@ -59,12 +54,7 @@ import {
|
|||||||
handleScreenshot,
|
handleScreenshot,
|
||||||
handleSnapshot,
|
handleSnapshot,
|
||||||
} from "./observation";
|
} from "./observation";
|
||||||
import {
|
import { handleRecordAwait, handleRecordStart, handleRecordStop } from "./record";
|
||||||
handleRecordAwait,
|
|
||||||
handleRecordStart,
|
|
||||||
handleRecordStop,
|
|
||||||
type RecordRuntimeDeps,
|
|
||||||
} from "./record";
|
|
||||||
import {
|
import {
|
||||||
handleSessionStart,
|
handleSessionStart,
|
||||||
handleSessionStop,
|
handleSessionStop,
|
||||||
@@ -87,6 +77,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";
|
||||||
|
|
||||||
@@ -112,7 +103,6 @@ export interface DispatcherDeps {
|
|||||||
transport: Transport;
|
transport: Transport;
|
||||||
sessions: SessionManager;
|
sessions: SessionManager;
|
||||||
cdp?: DispatcherCdpRunner;
|
cdp?: DispatcherCdpRunner;
|
||||||
recording?: RecordRuntimeDeps;
|
|
||||||
/**
|
/**
|
||||||
* Invoked whenever a dispatched RPC may have changed the live
|
* Invoked whenever a dispatched RPC may have changed the live
|
||||||
* session set (currently `tool.session_start` and
|
* session set (currently `tool.session_start` and
|
||||||
@@ -148,7 +138,6 @@ export class ToolDispatcher {
|
|||||||
private readonly transport: Transport;
|
private readonly transport: Transport;
|
||||||
private readonly sessions: SessionManager;
|
private readonly sessions: SessionManager;
|
||||||
private readonly cdp?: DispatcherCdpRunner;
|
private readonly cdp?: DispatcherCdpRunner;
|
||||||
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 approveBorrow?: BorrowConfirmationApprover;
|
private readonly approveBorrow?: BorrowConfirmationApprover;
|
||||||
@@ -168,7 +157,6 @@ export class ToolDispatcher {
|
|||||||
this.transport = deps.transport;
|
this.transport = deps.transport;
|
||||||
this.sessions = deps.sessions;
|
this.sessions = deps.sessions;
|
||||||
this.cdp = deps.cdp;
|
this.cdp = deps.cdp;
|
||||||
this.recording = deps.recording;
|
|
||||||
this.onSessionsChanged = deps.onSessionsChanged;
|
this.onSessionsChanged = deps.onSessionsChanged;
|
||||||
this.onBrowserControlResumed = deps.onBrowserControlResumed;
|
this.onBrowserControlResumed = deps.onBrowserControlResumed;
|
||||||
this.approveBorrow = deps.approveBorrow;
|
this.approveBorrow = deps.approveBorrow;
|
||||||
@@ -306,7 +294,6 @@ 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,
|
||||||
signal,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
case "tool.tab_list":
|
case "tool.tab_list":
|
||||||
@@ -499,17 +486,6 @@ export class ToolDispatcher {
|
|||||||
),
|
),
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
case "tool.type":
|
|
||||||
return this.withHoverReleaseForRequest(
|
|
||||||
req.params as TypeParams,
|
|
||||||
() =>
|
|
||||||
handleType(
|
|
||||||
this.sessions,
|
|
||||||
req.params as TypeParams,
|
|
||||||
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
|
|
||||||
),
|
|
||||||
signal,
|
|
||||||
);
|
|
||||||
case "tool.press":
|
case "tool.press":
|
||||||
return this.withHoverReleaseForRequest(
|
return this.withHoverReleaseForRequest(
|
||||||
req.params as PressParams,
|
req.params as PressParams,
|
||||||
@@ -532,6 +508,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,
|
||||||
@@ -558,26 +568,44 @@ export class ToolDispatcher {
|
|||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
case "tool.record_start":
|
case "tool.record_start":
|
||||||
return this.recording
|
return handleRecordStart(this.sessions, req.params as RecordStartParams, {
|
||||||
? handleRecordStart(this.sessions, req.params as RecordStartParams, {
|
tabsApi: chromeTabsApi,
|
||||||
...this.recording,
|
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
|
||||||
|
bypassOverlay: async (tabId, enabled) => {
|
||||||
|
try {
|
||||||
|
await chrome.tabs.sendMessage(tabId, {
|
||||||
|
type: OVERLAY_AUTOMATION_BYPASS,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Content script may be unavailable on restricted pages.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...(this.cdp ? { cdp: this.cdp } : {}),
|
||||||
signal,
|
signal,
|
||||||
})
|
});
|
||||||
: recordingRuntimeUnavailable();
|
|
||||||
case "tool.record_stop":
|
case "tool.record_stop":
|
||||||
return this.recording
|
return handleRecordStop(this.sessions, req.params as RecordStopParams, {
|
||||||
? handleRecordStop(this.sessions, req.params as RecordStopParams, {
|
tabsApi: chromeTabsApi,
|
||||||
...this.recording,
|
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
|
||||||
|
bypassOverlay: async (tabId, enabled) => {
|
||||||
|
try {
|
||||||
|
await chrome.tabs.sendMessage(tabId, {
|
||||||
|
type: OVERLAY_AUTOMATION_BYPASS,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Content script may be unavailable on restricted pages.
|
||||||
|
}
|
||||||
|
},
|
||||||
signal,
|
signal,
|
||||||
})
|
});
|
||||||
: recordingRuntimeUnavailable();
|
|
||||||
case "tool.record_await":
|
case "tool.record_await":
|
||||||
return this.recording
|
return handleRecordAwait(this.sessions, req.params as RecordAwaitParams, {
|
||||||
? handleRecordAwait(this.sessions, req.params as RecordAwaitParams, {
|
tabsApi: chromeTabsApi,
|
||||||
...this.recording,
|
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
|
||||||
signal,
|
signal,
|
||||||
})
|
});
|
||||||
: recordingRuntimeUnavailable();
|
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
code: "unknown_method",
|
code: "unknown_method",
|
||||||
@@ -713,13 +741,6 @@ function isRpcError(v: unknown): v is RpcError {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordingRuntimeUnavailable(): RpcError {
|
|
||||||
return {
|
|
||||||
code: "protocol_error",
|
|
||||||
message: "recording runtime is unavailable",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function sessionIdForBrowserControlMethod(req: RequestFrame): string | null {
|
function sessionIdForBrowserControlMethod(req: RequestFrame): string | null {
|
||||||
switch (req.method) {
|
switch (req.method) {
|
||||||
case "tool.tab_create":
|
case "tool.tab_create":
|
||||||
@@ -736,9 +757,10 @@ function sessionIdForBrowserControlMethod(req: RequestFrame): string | null {
|
|||||||
case "tool.click":
|
case "tool.click":
|
||||||
case "tool.hover":
|
case "tool.hover":
|
||||||
case "tool.fill":
|
case "tool.fill":
|
||||||
case "tool.type":
|
|
||||||
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,349 @@
|
|||||||
|
// 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" } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureBrowserDownload(
|
||||||
|
options: DownloadCaptureOptions,
|
||||||
|
): Promise<DownloadCaptureResult | RpcError> {
|
||||||
|
let click: ClickResult | undefined;
|
||||||
|
let intent: DownloadIntent | undefined;
|
||||||
|
let capturedId: number | undefined;
|
||||||
|
let capturedItem: chrome.downloads.DownloadItem | 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;
|
||||||
|
capturedItem = item;
|
||||||
|
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;
|
||||||
|
capturedItem = candidate.item;
|
||||||
|
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;
|
||||||
|
capturedItem = item;
|
||||||
|
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;
|
||||||
|
capturedItem = item;
|
||||||
|
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) {
|
||||||
|
const item = capturedItem ?? createdItems.get(capturedId);
|
||||||
|
try {
|
||||||
|
if (item?.state === "complete") {
|
||||||
|
await options.downloads.removeFile(capturedId);
|
||||||
|
} else {
|
||||||
|
await options.downloads.cancel(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
// Browser-native editing transaction for input, textarea, and contenteditable
|
|
||||||
// targets. The CDP target/session is selected by interaction.ts; this module
|
|
||||||
// owns the shorter-lived editable identity inside that target.
|
|
||||||
|
|
||||||
import type { RpcError } from "@/transport/types";
|
|
||||||
import { rpcError } from "./errors";
|
|
||||||
import type { CdpRunner } from "./shared";
|
|
||||||
|
|
||||||
interface EditableState {
|
|
||||||
supported: boolean;
|
|
||||||
connected: boolean;
|
|
||||||
focused: boolean;
|
|
||||||
value: string;
|
|
||||||
kind: "input" | "textarea" | "contenteditable" | "unsupported";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EditableFillResult {
|
|
||||||
value: string;
|
|
||||||
replacedNode: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelled(signal: AbortSignal | undefined): RpcError | null {
|
|
||||||
return signal?.aborted ? { code: "cancelled", message: "fill aborted" } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function inspectEditable(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
objectId: string,
|
|
||||||
): Promise<EditableState> {
|
|
||||||
const reply = await cdp.send<{
|
|
||||||
result?: { value?: EditableState };
|
|
||||||
}>(tabId, "Runtime.callFunctionOn", {
|
|
||||||
objectId,
|
|
||||||
functionDeclaration: `function() {
|
|
||||||
/* bsk-editable-state */
|
|
||||||
const doc = this.ownerDocument;
|
|
||||||
const focused = !!doc && doc.activeElement === this;
|
|
||||||
if (this instanceof HTMLInputElement) {
|
|
||||||
return { supported: true, connected: this.isConnected, focused,
|
|
||||||
value: this.value, kind: 'input' };
|
|
||||||
}
|
|
||||||
if (this instanceof HTMLTextAreaElement) {
|
|
||||||
return { supported: true, connected: this.isConnected, focused,
|
|
||||||
value: this.value, kind: 'textarea' };
|
|
||||||
}
|
|
||||||
if (this.isContentEditable) {
|
|
||||||
return { supported: true, connected: this.isConnected, focused,
|
|
||||||
value: this.innerText ?? this.textContent ?? '', kind: 'contenteditable' };
|
|
||||||
}
|
|
||||||
return { supported: false, connected: this.isConnected === true, focused,
|
|
||||||
value: '', kind: 'unsupported' };
|
|
||||||
}`,
|
|
||||||
returnByValue: true,
|
|
||||||
});
|
|
||||||
const state = reply.result?.value;
|
|
||||||
if (!state || typeof state.value !== "string") {
|
|
||||||
throw new Error("editable state probe returned no value");
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the active editable from the anchor's own document. Using the
|
|
||||||
* anchor's execution context also works for same-process nested frames, where
|
|
||||||
* a target-level Runtime.evaluate would otherwise see the top document.
|
|
||||||
*/
|
|
||||||
async function focusedEditableFromAnchor(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
anchorObjectId: string,
|
|
||||||
): Promise<string | null> {
|
|
||||||
const reply = await cdp.send<{
|
|
||||||
result?: { objectId?: string; subtype?: string };
|
|
||||||
}>(tabId, "Runtime.callFunctionOn", {
|
|
||||||
objectId: anchorObjectId,
|
|
||||||
functionDeclaration: `function() {
|
|
||||||
/* bsk-editable-live */
|
|
||||||
const active = this.ownerDocument?.activeElement;
|
|
||||||
if (!active) return null;
|
|
||||||
const editable = active instanceof HTMLInputElement ||
|
|
||||||
active instanceof HTMLTextAreaElement || active.isContentEditable;
|
|
||||||
return editable ? active : null;
|
|
||||||
}`,
|
|
||||||
returnByValue: false,
|
|
||||||
});
|
|
||||||
if (reply.result?.subtype === "null") return null;
|
|
||||||
return typeof reply.result?.objectId === "string" ? reply.result.objectId : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveLiveEditable(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
anchorObjectId: string,
|
|
||||||
): Promise<{ objectId: string; state: EditableState; replaced: boolean } | RpcError> {
|
|
||||||
const anchor = await inspectEditable(cdp, tabId, anchorObjectId);
|
|
||||||
if (anchor.supported && anchor.connected && anchor.focused) {
|
|
||||||
return { objectId: anchorObjectId, state: anchor, replaced: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const focusedObjectId = await focusedEditableFromAnchor(cdp, tabId, anchorObjectId);
|
|
||||||
if (!focusedObjectId) {
|
|
||||||
return rpcError(
|
|
||||||
"cdp_failed",
|
|
||||||
"input_not_applied",
|
|
||||||
"the target editable lost focus or detached before input",
|
|
||||||
{ phase: "resolve_live_editable" },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const focused = await inspectEditable(cdp, tabId, focusedObjectId);
|
|
||||||
if (!focused.supported || !focused.connected || !focused.focused) {
|
|
||||||
return rpcError(
|
|
||||||
"cdp_failed",
|
|
||||||
"input_not_applied",
|
|
||||||
"the replacement editable was not live and focused",
|
|
||||||
{ phase: "resolve_live_editable" },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { objectId: focusedObjectId, state: focused, replaced: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectEditableContents(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
objectId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const reply = await cdp.send<{
|
|
||||||
result?: { value?: { selected?: boolean } };
|
|
||||||
}>(tabId, "Runtime.callFunctionOn", {
|
|
||||||
objectId,
|
|
||||||
functionDeclaration: `function() {
|
|
||||||
/* bsk-editable-selection */
|
|
||||||
if (!this.isConnected || this.ownerDocument?.activeElement !== this) {
|
|
||||||
return { selected: false };
|
|
||||||
}
|
|
||||||
if (this instanceof HTMLInputElement || this instanceof HTMLTextAreaElement) {
|
|
||||||
try {
|
|
||||||
this.setSelectionRange(0, this.value.length);
|
|
||||||
} catch {
|
|
||||||
if (typeof this.select !== 'function') return { selected: false };
|
|
||||||
this.select();
|
|
||||||
}
|
|
||||||
return { selected: true };
|
|
||||||
}
|
|
||||||
if (this.isContentEditable) {
|
|
||||||
const selection = this.ownerDocument.getSelection();
|
|
||||||
if (!selection) return { selected: false };
|
|
||||||
const range = this.ownerDocument.createRange();
|
|
||||||
range.selectNodeContents(this);
|
|
||||||
selection.removeAllRanges();
|
|
||||||
selection.addRange(range);
|
|
||||||
return { selected: true };
|
|
||||||
}
|
|
||||||
return { selected: false };
|
|
||||||
}`,
|
|
||||||
returnByValue: true,
|
|
||||||
});
|
|
||||||
if (reply.result?.value?.selected !== true) {
|
|
||||||
throw new Error("could not select the live editable contents");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForEditableSettlement(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
anchorObjectId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await cdp.send(tabId, "Runtime.callFunctionOn", {
|
|
||||||
objectId: anchorObjectId,
|
|
||||||
functionDeclaration: `function() {
|
|
||||||
/* bsk-editable-sync */
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let settled = false;
|
|
||||||
const finish = () => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
const timer = setTimeout(finish, 100);
|
|
||||||
const view = this.ownerDocument?.defaultView;
|
|
||||||
if (!view || typeof view.requestAnimationFrame !== 'function') { finish(); return; }
|
|
||||||
view.requestAnimationFrame(() => view.requestAnimationFrame(finish));
|
|
||||||
});
|
|
||||||
}`,
|
|
||||||
awaitPromise: true,
|
|
||||||
returnByValue: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteSelection(cdp: CdpRunner, tabId: number): Promise<void> {
|
|
||||||
await cdp.send(tabId, "Input.dispatchKeyEvent", {
|
|
||||||
type: "rawKeyDown",
|
|
||||||
key: "Backspace",
|
|
||||||
code: "Backspace",
|
|
||||||
windowsVirtualKeyCode: 8,
|
|
||||||
});
|
|
||||||
await cdp.send(tabId, "Input.dispatchKeyEvent", {
|
|
||||||
type: "keyUp",
|
|
||||||
key: "Backspace",
|
|
||||||
code: "Backspace",
|
|
||||||
windowsVirtualKeyCode: 8,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace or append text through Chromium's editing pipeline. No value setter
|
|
||||||
* or synthetic DOM events are used: controlled components observe the same
|
|
||||||
* beforeinput/input path as a real browser edit. The live editable is resolved
|
|
||||||
* again after settlement so a framework may remount it without producing a
|
|
||||||
* false failure or a stale-object success.
|
|
||||||
*/
|
|
||||||
export async function fillEditable(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
anchorObjectId: string,
|
|
||||||
value: string,
|
|
||||||
clearBefore: boolean,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<EditableFillResult | RpcError> {
|
|
||||||
try {
|
|
||||||
const abortedBefore = cancelled(signal);
|
|
||||||
if (abortedBefore) return abortedBefore;
|
|
||||||
|
|
||||||
const initial = await resolveLiveEditable(cdp, tabId, anchorObjectId);
|
|
||||||
if ("code" in initial) return initial;
|
|
||||||
let replacedNode = initial.replaced;
|
|
||||||
|
|
||||||
if (clearBefore) await selectEditableContents(cdp, tabId, initial.objectId);
|
|
||||||
const abortedAfterSelection = cancelled(signal);
|
|
||||||
if (abortedAfterSelection) return abortedAfterSelection;
|
|
||||||
|
|
||||||
if (value.length > 0) {
|
|
||||||
await cdp.send(tabId, "Input.insertText", { text: value });
|
|
||||||
} else if (clearBefore) {
|
|
||||||
await deleteSelection(cdp, tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const abortedAfterInput = cancelled(signal);
|
|
||||||
if (abortedAfterInput) return abortedAfterInput;
|
|
||||||
await waitForEditableSettlement(cdp, tabId, initial.objectId);
|
|
||||||
|
|
||||||
const abortedAfterSettlement = cancelled(signal);
|
|
||||||
if (abortedAfterSettlement) return abortedAfterSettlement;
|
|
||||||
|
|
||||||
let finalObjectId = initial.objectId;
|
|
||||||
let finalState = await inspectEditable(cdp, tabId, finalObjectId);
|
|
||||||
if (!finalState.connected || !finalState.focused) {
|
|
||||||
const live = await resolveLiveEditable(cdp, tabId, initial.objectId);
|
|
||||||
if ("code" in live) return live;
|
|
||||||
finalObjectId = live.objectId;
|
|
||||||
finalState = live.state;
|
|
||||||
replacedNode ||= live.replaced || finalObjectId !== initial.objectId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const applied = clearBefore
|
|
||||||
? finalState.value === value
|
|
||||||
: value.length === 0 || finalState.value.includes(value);
|
|
||||||
if (!finalState.supported || !applied) {
|
|
||||||
return rpcError(
|
|
||||||
"cdp_failed",
|
|
||||||
"input_not_applied",
|
|
||||||
"the page did not retain the requested input value",
|
|
||||||
{
|
|
||||||
expected_value_length: value.length,
|
|
||||||
actual_value_length: finalState.value.length,
|
|
||||||
phase: "readback",
|
|
||||||
editable_replaced: replacedNode,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { value: finalState.value, replacedNode };
|
|
||||||
} catch (err) {
|
|
||||||
return rpcError(
|
|
||||||
"cdp_failed",
|
|
||||||
"input_not_applied",
|
|
||||||
err instanceof Error ? err.message : String(err),
|
|
||||||
{ phase: "edit_transaction" },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,10 +2,23 @@
|
|||||||
// 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 };
|
||||||
|
|
||||||
|
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 +28,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,484 @@
|
|||||||
|
// 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";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BoundedWaitError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly kind: "timeout" | "aborted",
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remainingMs(deadline: number): number {
|
||||||
|
return Math.max(0, deadline - Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
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", "upload transaction 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", "upload transaction 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,5 +1,5 @@
|
|||||||
// DOM interaction tools — `tool.click`, `tool.fill`, `tool.type`,
|
// DOM interaction tools — `tool.click`, `tool.fill`, `tool.press`, and
|
||||||
// `tool.press`, and `tool.select`.
|
// `tool.select`.
|
||||||
//
|
//
|
||||||
// All interaction tools:
|
// All interaction tools:
|
||||||
// 1. Resolve target tab (sandbox: must be inside Agent Window).
|
// 1. Resolve target tab (sandbox: must be inside Agent Window).
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
// commands.
|
// commands.
|
||||||
|
|
||||||
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
|
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
|
||||||
import { type CdpTarget, cdpTargetKey } from "@/browser-driver/frame-graph";
|
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
||||||
import type { SessionContext, SessionManager } from "@/session-manager/manager";
|
import type { SessionContext, SessionManager } from "@/session-manager/manager";
|
||||||
import type {
|
import type {
|
||||||
ClickParams,
|
ClickParams,
|
||||||
@@ -27,15 +27,11 @@ import type {
|
|||||||
RpcError,
|
RpcError,
|
||||||
SelectParams,
|
SelectParams,
|
||||||
SelectResult,
|
SelectResult,
|
||||||
TypeParams,
|
|
||||||
TypeResult,
|
|
||||||
} from "@/transport/types";
|
} from "@/transport/types";
|
||||||
import { attachDialogs, markDialogCursor } from "./dialogs";
|
import { attachDialogs, markDialogCursor } from "./dialogs";
|
||||||
import { fillEditable } from "./editable-input";
|
|
||||||
import { backendNodeToObject } from "./element-geometry";
|
import { backendNodeToObject } from "./element-geometry";
|
||||||
import { rpcError } from "./errors";
|
import { rpcError } from "./errors";
|
||||||
import { resolveNodeGeometry, scrollElementAndFramesIntoView } from "./frame-geometry";
|
import { resolveNodeGeometry, scrollElementAndFramesIntoView } from "./frame-geometry";
|
||||||
import { dispatchMouseClick } from "./mouse-input";
|
|
||||||
import {
|
import {
|
||||||
type CdpRunner,
|
type CdpRunner,
|
||||||
type ChromeTabsApi,
|
type ChromeTabsApi,
|
||||||
@@ -44,11 +40,10 @@ 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";
|
||||||
import { resolveSurfacePointerTarget } from "./surface-target";
|
|
||||||
import { dispatchTextInput } from "./text-input";
|
|
||||||
|
|
||||||
export interface InteractionDeps {
|
export interface InteractionDeps {
|
||||||
cdp: CdpRunner;
|
cdp: CdpRunner;
|
||||||
@@ -62,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;
|
||||||
|
|
||||||
@@ -134,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 },
|
||||||
@@ -165,7 +169,7 @@ async function resolveBackendNode(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (hasRef) {
|
if (hasRef) {
|
||||||
const resolved = resolveSnapshotRef(ctx, params.ref as string, target.tabId, "interact");
|
const resolved = resolveSnapshotRef(ctx, params.ref as string, target.tabId);
|
||||||
if (isRpcError(resolved)) return resolved;
|
if (isRpcError(resolved)) return resolved;
|
||||||
return {
|
return {
|
||||||
backendNodeId: resolved.backendNodeId,
|
backendNodeId: resolved.backendNodeId,
|
||||||
@@ -221,146 +225,26 @@ async function resolveBackendNode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InputDispatchContext {
|
export async function resolveActionTarget(
|
||||||
target: CdpTarget;
|
|
||||||
cdp: CdpRunner;
|
|
||||||
backendNodeId?: number;
|
|
||||||
frameId?: string;
|
|
||||||
usedRef?: string;
|
|
||||||
usedSelector?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function inputContextForNode(
|
|
||||||
cdp: CdpRunner,
|
cdp: CdpRunner,
|
||||||
node: Awaited<ReturnType<typeof resolveBackendNode>>,
|
ctx: SessionContext,
|
||||||
): (InputDispatchContext & { backendNodeId: number }) | RpcError {
|
target: ResolvedTargetTab,
|
||||||
if (isRpcError(node)) return node;
|
params: { ref?: string; selector?: string },
|
||||||
return {
|
toolName: string,
|
||||||
target: node.cdpTarget,
|
): Promise<ResolvedActionTarget | RpcError> {
|
||||||
cdp: cdpRunnerForTarget(cdp, node.cdpTarget),
|
const node = await resolveBackendNode(cdp, ctx, target, params, toolName);
|
||||||
backendNodeId: node.backendNodeId,
|
return isRpcError(node) ? node : { tab: target, ...node };
|
||||||
frameId: node.frameId,
|
|
||||||
usedRef: node.usedRef,
|
|
||||||
usedSelector: node.usedSelector,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FocusProbe {
|
|
||||||
hasFocus: boolean;
|
|
||||||
activeTag: string;
|
|
||||||
textEntry: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve keyboard input to the CDP target that currently owns focus. */
|
|
||||||
async function resolveFocusedInputContext(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
mode: "press" | "type",
|
|
||||||
): Promise<InputDispatchContext | RpcError> {
|
|
||||||
if (!cdp.getFrameGraph) {
|
|
||||||
return rpcError(
|
|
||||||
"cdp_failed",
|
|
||||||
"focus_target_unresolved",
|
|
||||||
`${mode} cannot determine the focused frame target`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let targets: CdpTarget[];
|
|
||||||
try {
|
|
||||||
const graph = await cdp.getFrameGraph(tabId);
|
|
||||||
const unique = new Map<string, CdpTarget>();
|
|
||||||
unique.set(cdpTargetKey({ tabId }), { tabId });
|
|
||||||
for (const frame of graph.frames) unique.set(cdpTargetKey(frame.target), frame.target);
|
|
||||||
targets = [...unique.values()];
|
|
||||||
} catch (err) {
|
|
||||||
return rpcError(
|
|
||||||
"cdp_failed",
|
|
||||||
"focus_target_unresolved",
|
|
||||||
err instanceof Error ? err.message : String(err),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const focused: Array<{ target: CdpTarget; probe: FocusProbe }> = [];
|
|
||||||
await Promise.all(
|
|
||||||
targets.map(async (candidate) => {
|
|
||||||
try {
|
|
||||||
const reply = await cdpRunnerForTarget(cdp, candidate).send<{
|
|
||||||
result?: { value?: FocusProbe };
|
|
||||||
}>(tabId, "Runtime.evaluate", {
|
|
||||||
expression: `(() => {
|
|
||||||
/* bsk-focus-target-probe */
|
|
||||||
const active = document.activeElement;
|
|
||||||
const inputType = active instanceof HTMLInputElement ? active.type.toLowerCase() : '';
|
|
||||||
const nonTextInputTypes = new Set([
|
|
||||||
'button', 'checkbox', 'color', 'date', 'datetime-local', 'file',
|
|
||||||
'hidden', 'image', 'month', 'radio', 'range', 'reset', 'submit',
|
|
||||||
'time', 'week'
|
|
||||||
]);
|
|
||||||
const role = active instanceof Element ? active.getAttribute('role') : null;
|
|
||||||
return {
|
|
||||||
hasFocus: document.hasFocus(),
|
|
||||||
activeTag: active instanceof Element ? active.tagName.toLowerCase() : '',
|
|
||||||
textEntry:
|
|
||||||
(active instanceof HTMLInputElement && !nonTextInputTypes.has(inputType)) ||
|
|
||||||
active instanceof HTMLTextAreaElement ||
|
|
||||||
(active instanceof HTMLElement && active.isContentEditable) ||
|
|
||||||
role === 'textbox' || role === 'searchbox'
|
|
||||||
};
|
|
||||||
})()`,
|
|
||||||
returnByValue: true,
|
|
||||||
});
|
|
||||||
const probe = reply.result?.value;
|
|
||||||
if (probe?.hasFocus) focused.push({ target: candidate, probe });
|
|
||||||
} catch {
|
|
||||||
// A target may navigate or detach while the graph is being probed.
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// A top document whose active element is an iframe delegates focus to
|
|
||||||
// that frame. Removing those delegating targets leaves the deepest OOPIF
|
|
||||||
// target while same-process frames continue to use the shared root target.
|
|
||||||
const owners = focused.filter(({ probe }) => {
|
|
||||||
if (probe.activeTag === "iframe" || probe.activeTag === "frame") return false;
|
|
||||||
return mode !== "type" || probe.textEntry;
|
|
||||||
});
|
|
||||||
if (owners.length !== 1) {
|
|
||||||
return rpcError(
|
|
||||||
"cdp_failed",
|
|
||||||
"focus_target_unresolved",
|
|
||||||
owners.length === 0
|
|
||||||
? `${mode} found no unique focused${mode === "type" ? " text-entry" : " frame"} target`
|
|
||||||
: `${mode} found multiple focused frame targets`,
|
|
||||||
{ candidate_count: owners.length },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const target = owners[0].target;
|
|
||||||
return { target, cdp: cdpRunnerForTarget(cdp, target) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// tool.click
|
// tool.click
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface ResolvedClickPointerTarget {
|
|
||||||
point: { x: number; y: number };
|
|
||||||
usedRef?: string;
|
|
||||||
usedSelector?: string;
|
|
||||||
moveSettleMs: number;
|
|
||||||
capture?: {
|
|
||||||
id: string;
|
|
||||||
imageX: number;
|
|
||||||
imageY: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleClick(
|
export async function handleClick(
|
||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
params: ClickParams,
|
params: ClickParams,
|
||||||
deps: InteractionDeps = getDefaultDeps(),
|
deps: InteractionDeps = getDefaultDeps(),
|
||||||
): Promise<ClickResult | RpcError> {
|
): Promise<ClickResult | RpcError> {
|
||||||
const hasSurfacePointParams =
|
|
||||||
params.capture_id !== undefined || params.image_x !== undefined || params.image_y !== undefined;
|
|
||||||
const ctxOrErr = lookupSession(manager, params, "click");
|
const ctxOrErr = lookupSession(manager, params, "click");
|
||||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||||
const ctx = ctxOrErr;
|
const ctx = ctxOrErr;
|
||||||
@@ -370,69 +254,50 @@ 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 button: MouseButton = params.button ?? "left";
|
export async function clickResolvedTarget(
|
||||||
const clickCount = params.click_count ?? 1;
|
ctx: SessionContext,
|
||||||
if (!Number.isSafeInteger(clickCount) || clickCount < 1) {
|
resolved: ResolvedActionTarget,
|
||||||
return { code: "invalid_params", message: "click_count must be a positive integer" };
|
params: Pick<ClickParams, "button" | "click_count" | "modifiers">,
|
||||||
}
|
deps: InteractionDeps,
|
||||||
const modifiers = modifiersBitfield(params.modifiers);
|
): 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" };
|
||||||
}
|
}
|
||||||
|
|
||||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||||
const pointerTarget: ResolvedClickPointerTarget | RpcError = hasSurfacePointParams
|
|
||||||
? await resolveSurfacePointerTarget(
|
|
||||||
ctx,
|
|
||||||
target,
|
|
||||||
{
|
|
||||||
ref: params.ref,
|
|
||||||
selector: params.selector,
|
|
||||||
captureId: params.capture_id,
|
|
||||||
imageX: params.image_x,
|
|
||||||
imageY: params.image_y,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
cdp: deps.cdp,
|
|
||||||
signal: deps.signal,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: await (async () => {
|
|
||||||
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "click");
|
|
||||||
if (isRpcError(node)) return node;
|
|
||||||
const geometry = await resolveNodeGeometry(
|
const geometry = await resolveNodeGeometry(
|
||||||
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 },
|
||||||
);
|
);
|
||||||
if (isRpcError(geometry)) return geometry;
|
if (isRpcError(geometry)) return geometry;
|
||||||
return {
|
const centre = geometry.actionPoint;
|
||||||
point: geometry.actionPoint,
|
|
||||||
usedRef: node.usedRef,
|
|
||||||
usedSelector: node.usedSelector,
|
|
||||||
moveSettleMs: 0,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
if (isRpcError(pointerTarget)) return pointerTarget;
|
|
||||||
|
|
||||||
if (throwIfAborted(deps.signal)) {
|
if (throwIfAborted(deps.signal)) {
|
||||||
return { code: "cancelled", message: "click aborted" };
|
return { code: "cancelled", message: "click aborted" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const overlayBlocking = await checkOverlayAtPoint(
|
const button: MouseButton = params.button ?? "left";
|
||||||
deps.cdp,
|
const clickCount = params.click_count ?? 1;
|
||||||
target.tabId,
|
if (clickCount < 1) {
|
||||||
pointerTarget.point.x,
|
return { code: "invalid_params", message: "click_count must be greater than zero" };
|
||||||
pointerTarget.point.y,
|
}
|
||||||
);
|
const modifiers = modifiersBitfield(params.modifiers);
|
||||||
|
|
||||||
|
const overlayBlocking = await checkOverlayAtPoint(deps.cdp, target.tabId, centre.x, centre.y);
|
||||||
let automationBypassEnabled = false;
|
let automationBypassEnabled = false;
|
||||||
if (overlayBlocking && deps.bypassOverlay) {
|
if (overlayBlocking && deps.bypassOverlay) {
|
||||||
try {
|
try {
|
||||||
@@ -444,16 +309,47 @@ export async function handleClick(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dispatch = await dispatchMouseClick(deps.cdp, target.tabId, pointerTarget.point, {
|
// Move first so hover state activates, then press → release.
|
||||||
|
await deps.cdp.send(target.tabId, "Input.dispatchMouseEvent", {
|
||||||
|
type: "mouseMoved",
|
||||||
|
x: centre.x,
|
||||||
|
y: centre.y,
|
||||||
|
modifiers,
|
||||||
|
});
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "click aborted" };
|
||||||
|
}
|
||||||
|
await deps.cdp.send(target.tabId, "Input.dispatchMouseEvent", {
|
||||||
|
type: "mousePressed",
|
||||||
|
x: centre.x,
|
||||||
|
y: centre.y,
|
||||||
button,
|
button,
|
||||||
clickCount,
|
clickCount,
|
||||||
modifiers,
|
modifiers,
|
||||||
signal: deps.signal,
|
|
||||||
moveSettleMs: pointerTarget.moveSettleMs,
|
|
||||||
});
|
});
|
||||||
if (dispatch === "cancelled") {
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
try {
|
||||||
|
await deps.cdp.send(target.tabId, "Input.dispatchMouseEvent", {
|
||||||
|
type: "mouseReleased",
|
||||||
|
x: centre.x,
|
||||||
|
y: centre.y,
|
||||||
|
button,
|
||||||
|
clickCount,
|
||||||
|
modifiers,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.debug("[bsk interaction] best-effort mouseReleased after abort failed", err);
|
||||||
|
}
|
||||||
return { code: "cancelled", message: "click aborted" };
|
return { code: "cancelled", message: "click aborted" };
|
||||||
}
|
}
|
||||||
|
await deps.cdp.send(target.tabId, "Input.dispatchMouseEvent", {
|
||||||
|
type: "mouseReleased",
|
||||||
|
x: centre.x,
|
||||||
|
y: centre.y,
|
||||||
|
button,
|
||||||
|
clickCount,
|
||||||
|
modifiers,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
code: "cdp_failed",
|
code: "cdp_failed",
|
||||||
@@ -471,17 +367,10 @@ 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: pointerTarget.usedRef,
|
used_ref: resolved.usedRef,
|
||||||
used_selector: pointerTarget.usedSelector,
|
used_selector: resolved.usedSelector,
|
||||||
x: pointerTarget.point.x,
|
x: centre.x,
|
||||||
y: pointerTarget.point.y,
|
y: centre.y,
|
||||||
...(pointerTarget.capture
|
|
||||||
? {
|
|
||||||
capture_id: pointerTarget.capture.id,
|
|
||||||
image_x: pointerTarget.capture.imageX,
|
|
||||||
image_y: pointerTarget.capture.imageY,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,47 +590,6 @@ function isFillable(node: DescribedNode): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `type` also supports custom text-entry widgets that expose an ARIA role. */
|
|
||||||
function isTypeTarget(node: DescribedNode): boolean {
|
|
||||||
const tag = (node.nodeName ?? "").toUpperCase();
|
|
||||||
const attrs = node.attributes ?? [];
|
|
||||||
const attributes = new Map<string, string>();
|
|
||||||
for (let i = 0; i + 1 < attrs.length; i += 2) {
|
|
||||||
attributes.set(attrs[i].toLowerCase(), attrs[i + 1].toLowerCase());
|
|
||||||
}
|
|
||||||
if (tag === "TEXTAREA") return true;
|
|
||||||
if (tag === "INPUT") {
|
|
||||||
const type = attributes.get("type") ?? "text";
|
|
||||||
return !new Set([
|
|
||||||
"button",
|
|
||||||
"checkbox",
|
|
||||||
"color",
|
|
||||||
"date",
|
|
||||||
"datetime-local",
|
|
||||||
"file",
|
|
||||||
"hidden",
|
|
||||||
"image",
|
|
||||||
"month",
|
|
||||||
"radio",
|
|
||||||
"range",
|
|
||||||
"reset",
|
|
||||||
"submit",
|
|
||||||
"time",
|
|
||||||
"week",
|
|
||||||
]).has(type);
|
|
||||||
}
|
|
||||||
const contentEditable = attributes.get("contenteditable");
|
|
||||||
if (
|
|
||||||
contentEditable === "" ||
|
|
||||||
contentEditable === "true" ||
|
|
||||||
contentEditable === "plaintext-only"
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const role = attributes.get("role");
|
|
||||||
return role === "textbox" || role === "searchbox";
|
|
||||||
}
|
|
||||||
|
|
||||||
type SelectMutationResult =
|
type SelectMutationResult =
|
||||||
| {
|
| {
|
||||||
ok: true;
|
ok: true;
|
||||||
@@ -780,9 +628,8 @@ export async function handleFill(
|
|||||||
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
|
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
|
||||||
|
|
||||||
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "fill");
|
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "fill");
|
||||||
const input = inputContextForNode(deps.cdp, node);
|
if (isRpcError(node)) return node;
|
||||||
if (isRpcError(input)) return input;
|
const nodeCdp = cdpRunnerForTarget(deps.cdp, node.cdpTarget);
|
||||||
const nodeCdp = input.cdp;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||||
@@ -790,7 +637,7 @@ export async function handleFill(
|
|||||||
target.tabId,
|
target.tabId,
|
||||||
"DOM.describeNode",
|
"DOM.describeNode",
|
||||||
{
|
{
|
||||||
backendNodeId: input.backendNodeId,
|
backendNodeId: node.backendNodeId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!described.node || !isFillable(described.node)) {
|
if (!described.node || !isFillable(described.node)) {
|
||||||
@@ -803,15 +650,15 @@ export async function handleFill(
|
|||||||
const scrollErr = await scrollElementAndFramesIntoView(
|
const scrollErr = await scrollElementAndFramesIntoView(
|
||||||
deps.cdp,
|
deps.cdp,
|
||||||
target.tabId,
|
target.tabId,
|
||||||
input.target,
|
node.cdpTarget,
|
||||||
input.backendNodeId,
|
node.backendNodeId,
|
||||||
input.frameId,
|
node.frameId,
|
||||||
);
|
);
|
||||||
if (scrollErr) return scrollErr;
|
if (scrollErr) return scrollErr;
|
||||||
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: input.backendNodeId });
|
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
code: "cdp_failed",
|
code: "cdp_failed",
|
||||||
@@ -823,118 +670,62 @@ export async function handleFill(
|
|||||||
return { code: "cancelled", message: "fill aborted" };
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const objectIdOrErr = await backendNodeToObject(nodeCdp, target.tabId, input.backendNodeId);
|
const objectIdOrErr = await backendNodeToObject(nodeCdp, target.tabId, node.backendNodeId);
|
||||||
if (isRpcError(objectIdOrErr)) return objectIdOrErr;
|
if (isRpcError(objectIdOrErr)) return objectIdOrErr;
|
||||||
const objectId = objectIdOrErr;
|
const objectId = objectIdOrErr;
|
||||||
const clearBefore = params.clear_before ?? true;
|
const clearBefore = params.clear_before ?? true;
|
||||||
const fillResult = await fillEditable(
|
|
||||||
nodeCdp,
|
|
||||||
target.tabId,
|
|
||||||
objectId,
|
|
||||||
params.value,
|
|
||||||
clearBefore,
|
|
||||||
deps.signal,
|
|
||||||
);
|
|
||||||
if (isRpcError(fillResult)) return fillResult;
|
|
||||||
|
|
||||||
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
|
||||||
tab_id: target.tabId,
|
|
||||||
used_ref: input.usedRef,
|
|
||||||
used_selector: input.usedSelector,
|
|
||||||
value_length: fillResult.value.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// tool.type
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export async function handleType(
|
|
||||||
manager: SessionManager,
|
|
||||||
params: TypeParams,
|
|
||||||
deps: InteractionDeps = getDefaultDeps(),
|
|
||||||
): Promise<TypeResult | RpcError> {
|
|
||||||
if (!params || typeof params.text !== "string") {
|
|
||||||
return { code: "invalid_params", message: "type requires a text string" };
|
|
||||||
}
|
|
||||||
if (params.focused !== undefined && typeof params.focused !== "boolean") {
|
|
||||||
return { code: "invalid_params", message: "type focused must be a boolean" };
|
|
||||||
}
|
|
||||||
const hasExplicitTarget =
|
|
||||||
(typeof params.ref === "string" && params.ref.length > 0) ||
|
|
||||||
(typeof params.selector === "string" && params.selector.length > 0);
|
|
||||||
if (params.focused === true && hasExplicitTarget) {
|
|
||||||
return {
|
|
||||||
code: "invalid_params",
|
|
||||||
message: "type: pass a ref/selector or focused=true, not both",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (params.focused !== true && !hasExplicitTarget) {
|
|
||||||
return {
|
|
||||||
code: "invalid_params",
|
|
||||||
message: "type requires a ref/selector or focused=true",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const ctxOrErr = lookupSession(manager, params, "type");
|
|
||||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
|
||||||
const ctx = ctxOrErr;
|
|
||||||
if (throwIfAborted(deps.signal)) return { code: "cancelled", message: "type aborted" };
|
|
||||||
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
|
|
||||||
if (isRpcError(target)) return target;
|
|
||||||
const denied = enforceAgentWindow(ctx, target, "type");
|
|
||||||
if (denied) return denied;
|
|
||||||
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
|
|
||||||
|
|
||||||
let input: InputDispatchContext | RpcError;
|
|
||||||
if (hasExplicitTarget) {
|
|
||||||
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "type");
|
|
||||||
const explicitInput = inputContextForNode(deps.cdp, node);
|
|
||||||
if (isRpcError(explicitInput)) return explicitInput;
|
|
||||||
input = explicitInput;
|
|
||||||
try {
|
try {
|
||||||
const described = await explicitInput.cdp.send<{ node?: DescribedNode }>(
|
if (clearBefore) {
|
||||||
target.tabId,
|
// Clear input/textarea value or wipe contenteditable innerText,
|
||||||
"DOM.describeNode",
|
// then fire `input` so frameworks observe the empty state.
|
||||||
{ backendNodeId: explicitInput.backendNodeId },
|
await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", {
|
||||||
);
|
objectId,
|
||||||
if (!described.node || !isTypeTarget(described.node)) {
|
functionDeclaration: `function() {
|
||||||
return rpcError(
|
if (this.isContentEditable) { this.textContent = ''; }
|
||||||
"invalid_params",
|
else {
|
||||||
"target_not_fillable",
|
const proto = this instanceof HTMLTextAreaElement
|
||||||
`element ${described.node?.nodeName ?? "?"} not text-editable`,
|
? HTMLTextAreaElement.prototype
|
||||||
);
|
: HTMLInputElement.prototype;
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
|
||||||
|
if (descriptor && descriptor.set) descriptor.set.call(this, '');
|
||||||
|
else this.value = '';
|
||||||
}
|
}
|
||||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
this.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
const scrollErr = await scrollElementAndFramesIntoView(
|
}`,
|
||||||
deps.cdp,
|
returnByValue: true,
|
||||||
target.tabId,
|
});
|
||||||
explicitInput.target,
|
}
|
||||||
explicitInput.backendNodeId,
|
if (throwIfAborted(deps.signal)) {
|
||||||
explicitInput.frameId,
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
);
|
}
|
||||||
if (scrollErr) return scrollErr;
|
// CDP `Input.insertText` handles IME / multi-byte input out of the
|
||||||
if (throwIfAborted(deps.signal)) return { code: "cancelled", message: "type aborted" };
|
// box, much more reliably than per-key `dispatchKeyEvent`.
|
||||||
await explicitInput.cdp.send(target.tabId, "DOM.focus", {
|
await deps.cdp.send(target.tabId, "Input.insertText", { text: params.value });
|
||||||
backendNodeId: explicitInput.backendNodeId,
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
// Fire `input` + `change` so React / Vue controlled inputs commit.
|
||||||
|
await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", {
|
||||||
|
objectId,
|
||||||
|
functionDeclaration: `function() {
|
||||||
|
this.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}`,
|
||||||
|
returnByValue: true,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { code: "cdp_failed", message: err instanceof Error ? err.message : String(err) };
|
return {
|
||||||
|
code: "cdp_failed",
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
input = await resolveFocusedInputContext(deps.cdp, target.tabId, "type");
|
|
||||||
if (isRpcError(input)) return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
|
||||||
const dispatchError = await dispatchTextInput(input.cdp, target.tabId, params.text, deps.signal);
|
|
||||||
if (dispatchError) return dispatchError;
|
|
||||||
|
|
||||||
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
||||||
tab_id: target.tabId,
|
tab_id: target.tabId,
|
||||||
used_ref: input.usedRef,
|
used_ref: node.usedRef,
|
||||||
used_selector: input.usedSelector,
|
used_selector: node.usedSelector,
|
||||||
text_length: params.text.length,
|
value_length: params.value.length,
|
||||||
verification: "dispatched",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1109,38 +900,31 @@ export async function handlePress(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let input: InputDispatchContext | RpcError;
|
|
||||||
// Optional focus before key dispatch.
|
// Optional focus before key dispatch.
|
||||||
if (params.ref || params.selector) {
|
if (params.ref || params.selector) {
|
||||||
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "press");
|
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "press");
|
||||||
const explicitInput = inputContextForNode(deps.cdp, node);
|
if (isRpcError(node)) return node;
|
||||||
if (isRpcError(explicitInput)) return explicitInput;
|
const nodeCdp = cdpRunnerForTarget(deps.cdp, node.cdpTarget);
|
||||||
input = explicitInput;
|
|
||||||
try {
|
try {
|
||||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||||
const scrollErr = await scrollElementAndFramesIntoView(
|
const scrollErr = await scrollElementAndFramesIntoView(
|
||||||
deps.cdp,
|
deps.cdp,
|
||||||
target.tabId,
|
target.tabId,
|
||||||
explicitInput.target,
|
node.cdpTarget,
|
||||||
explicitInput.backendNodeId,
|
node.backendNodeId,
|
||||||
explicitInput.frameId,
|
node.frameId,
|
||||||
);
|
);
|
||||||
if (scrollErr) return scrollErr;
|
if (scrollErr) return scrollErr;
|
||||||
if (throwIfAborted(deps.signal)) {
|
if (throwIfAborted(deps.signal)) {
|
||||||
return { code: "cancelled", message: "press aborted" };
|
return { code: "cancelled", message: "press aborted" };
|
||||||
}
|
}
|
||||||
await explicitInput.cdp.send(target.tabId, "DOM.focus", {
|
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
|
||||||
backendNodeId: explicitInput.backendNodeId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
code: "cdp_failed",
|
code: "cdp_failed",
|
||||||
message: err instanceof Error ? err.message : String(err),
|
message: err instanceof Error ? err.message : String(err),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
input = await resolveFocusedInputContext(deps.cdp, target.tabId, "press");
|
|
||||||
if (isRpcError(input)) return input;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwIfAborted(deps.signal)) {
|
if (throwIfAborted(deps.signal)) {
|
||||||
@@ -1154,7 +938,7 @@ export async function handlePress(
|
|||||||
try {
|
try {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||||
await input.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
|
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
|
||||||
type: "rawKeyDown",
|
type: "rawKeyDown",
|
||||||
key: descriptor.key,
|
key: descriptor.key,
|
||||||
code: descriptor.code,
|
code: descriptor.code,
|
||||||
@@ -1168,7 +952,7 @@ export async function handlePress(
|
|||||||
typeof descriptor.text === "string" &&
|
typeof descriptor.text === "string" &&
|
||||||
descriptor.text.length > 0
|
descriptor.text.length > 0
|
||||||
) {
|
) {
|
||||||
await input.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
|
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
|
||||||
type: "char",
|
type: "char",
|
||||||
key: descriptor.key,
|
key: descriptor.key,
|
||||||
code: descriptor.code,
|
code: descriptor.code,
|
||||||
@@ -1185,7 +969,7 @@ export async function handlePress(
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await input.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
|
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
|
||||||
type: "keyUp",
|
type: "keyUp",
|
||||||
key: descriptor.key,
|
key: descriptor.key,
|
||||||
code: descriptor.code,
|
code: descriptor.code,
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import type { MouseButton } from "@/transport/types";
|
|
||||||
import type { Point } from "./geometry";
|
|
||||||
import type { CdpRunner } from "./shared";
|
|
||||||
|
|
||||||
interface MouseClickDispatchOptions {
|
|
||||||
button: MouseButton;
|
|
||||||
clickCount: number;
|
|
||||||
modifiers: number;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
moveSettleMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type MouseClickDispatchResult = "completed" | "cancelled";
|
|
||||||
|
|
||||||
function waitForSettle(ms: number, signal: AbortSignal | undefined): Promise<boolean> {
|
|
||||||
if (ms <= 0) return Promise.resolve(signal?.aborted !== true);
|
|
||||||
if (signal?.aborted) return Promise.resolve(false);
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let settled = false;
|
|
||||||
const finish = (completed: boolean) => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
signal?.removeEventListener("abort", onAbort);
|
|
||||||
resolve(completed);
|
|
||||||
};
|
|
||||||
const timer = setTimeout(() => finish(true), ms);
|
|
||||||
const onAbort = () => finish(false);
|
|
||||||
signal?.addEventListener("abort", onAbort, { once: true });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispatch one coherent CDP mouse click in top-viewport CSS coordinates.
|
|
||||||
*
|
|
||||||
* Keeping the sequence here prevents DOM and Surface interaction paths from
|
|
||||||
* duplicating cancellation and best-effort release behavior. Callers that
|
|
||||||
* target asynchronously hit-tested content may request a settle interval.
|
|
||||||
*/
|
|
||||||
export async function dispatchMouseClick(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
point: Point,
|
|
||||||
options: MouseClickDispatchOptions,
|
|
||||||
): Promise<MouseClickDispatchResult> {
|
|
||||||
const { button, clickCount, modifiers, signal, moveSettleMs = 0 } = options;
|
|
||||||
let pressed = false;
|
|
||||||
const release = () =>
|
|
||||||
cdp.send(tabId, "Input.dispatchMouseEvent", {
|
|
||||||
type: "mouseReleased",
|
|
||||||
x: point.x,
|
|
||||||
y: point.y,
|
|
||||||
button,
|
|
||||||
clickCount,
|
|
||||||
modifiers,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await cdp.send(tabId, "Input.dispatchMouseEvent", {
|
|
||||||
type: "mouseMoved",
|
|
||||||
x: point.x,
|
|
||||||
y: point.y,
|
|
||||||
modifiers,
|
|
||||||
});
|
|
||||||
if (!(await waitForSettle(moveSettleMs, signal))) return "cancelled";
|
|
||||||
|
|
||||||
await cdp.send(tabId, "Input.dispatchMouseEvent", {
|
|
||||||
type: "mousePressed",
|
|
||||||
x: point.x,
|
|
||||||
y: point.y,
|
|
||||||
button,
|
|
||||||
clickCount,
|
|
||||||
modifiers,
|
|
||||||
});
|
|
||||||
pressed = true;
|
|
||||||
|
|
||||||
if (signal?.aborted) {
|
|
||||||
await release();
|
|
||||||
pressed = false;
|
|
||||||
return "cancelled";
|
|
||||||
}
|
|
||||||
|
|
||||||
await release();
|
|
||||||
pressed = false;
|
|
||||||
return "completed";
|
|
||||||
} catch (error) {
|
|
||||||
if (pressed) {
|
|
||||||
try {
|
|
||||||
await release();
|
|
||||||
} catch (releaseError) {
|
|
||||||
console.debug("[bsk mouse-input] best-effort mouse release failed", releaseError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -15,37 +15,22 @@ import {
|
|||||||
type RecordQueryResponse,
|
type RecordQueryResponse,
|
||||||
type RecordStartAck,
|
type RecordStartAck,
|
||||||
type RecordStartMessage,
|
type RecordStartMessage,
|
||||||
type RecordStepAck,
|
|
||||||
type RecordStopMessage,
|
type RecordStopMessage,
|
||||||
} from "@/lib/record-bridge";
|
} from "@/lib/record-bridge";
|
||||||
import {
|
import { appendRecordedPayload, observeRecordedNavigation } from "@/lib/recording-step-buffer";
|
||||||
type RecordFrameCoordinator,
|
import { reduceTraceSteps, resolveTraceStartUrl } from "@/lib/trace-reducer";
|
||||||
type RecordingCaptureScope,
|
|
||||||
recordFrameCoordinator,
|
|
||||||
} from "@/lib/recording/frame-coordinator";
|
|
||||||
import { RecordingObservationRuntime } from "@/lib/recording/recording-runtime";
|
|
||||||
import {
|
|
||||||
appendRecordedPayload,
|
|
||||||
observeRecordedNavigation,
|
|
||||||
type RecordingStepBuffer,
|
|
||||||
} from "@/lib/recording/step-buffer";
|
|
||||||
import { RecordingTabCoordinator, type TabActivation } from "@/lib/recording/tab-coordinator";
|
|
||||||
import { buildTraceV2 } from "@/lib/recording/trace-reducer-v2";
|
|
||||||
import type { RecordingDraftStep } from "@/lib/recording/types";
|
|
||||||
import type { SessionManager } from "@/session-manager/manager";
|
import type { SessionManager } from "@/session-manager/manager";
|
||||||
import { EXTENSION_VERSION } from "@/transport/handshake";
|
|
||||||
import type {
|
import type {
|
||||||
|
DraftTraceStep,
|
||||||
RecordAwaitParams,
|
RecordAwaitParams,
|
||||||
RecordAwaitResult,
|
RecordAwaitResult,
|
||||||
RecordedTrace,
|
|
||||||
RecordStartParams,
|
RecordStartParams,
|
||||||
RecordStartResult,
|
RecordStartResult,
|
||||||
RecordStopParams,
|
RecordStopParams,
|
||||||
RecordStopResult,
|
RecordStopResult,
|
||||||
RpcError,
|
RpcError,
|
||||||
StopReason,
|
Trace,
|
||||||
} from "@/transport/types";
|
} from "@/transport/types";
|
||||||
import { TRACE_VERSION_V3 } from "@/transport/types";
|
|
||||||
import { handleNavigate } from "./navigation";
|
import { handleNavigate } from "./navigation";
|
||||||
import {
|
import {
|
||||||
type CdpRunner,
|
type CdpRunner,
|
||||||
@@ -58,46 +43,21 @@ import {
|
|||||||
|
|
||||||
interface ActiveRecording {
|
interface ActiveRecording {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
tabs: RecordingTabCoordinator;
|
tabId: number;
|
||||||
agentWindowId: number;
|
agentWindowId: number;
|
||||||
startUrl?: string;
|
startUrl?: string;
|
||||||
purpose?: string;
|
purpose?: string;
|
||||||
steps: RecordingDraftStep[];
|
steps: DraftTraceStep[];
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
startedAtMs: number;
|
startedAtMs: number;
|
||||||
traceVersion: 2 | 3;
|
finishPromise: Promise<Trace>;
|
||||||
supportsTabSwitchSteps: boolean;
|
resolveFinish: (trace: Trace) => void;
|
||||||
finishPromise: Promise<RecordedTrace>;
|
|
||||||
resolveFinish: (trace: RecordedTrace) => void;
|
|
||||||
rejectFinish: (err: Error) => void;
|
rejectFinish: (err: Error) => void;
|
||||||
settled: boolean;
|
settled: boolean;
|
||||||
finishAttempt: Promise<RecordedTrace | null> | null;
|
finishing: boolean;
|
||||||
observation: RecordingObservationRuntime | null;
|
currentUrl?: string;
|
||||||
stoppedBy: StopReason;
|
pendingNavigation: boolean;
|
||||||
/** Navigation callbacks tracked from event receipt through action enqueue. */
|
pendingNavigationDeadline?: number;
|
||||||
navigationCallbacks: Set<Promise<void>>;
|
|
||||||
/** Synchronous intake gate closed only after finish drains to stability. */
|
|
||||||
acceptingNavigation: boolean;
|
|
||||||
/**
|
|
||||||
* Serializes step appends with navigation observation so a click is always
|
|
||||||
* in `steps` before a same-turn `webNavigation` tries to annotate it.
|
|
||||||
*/
|
|
||||||
actionQueue: Promise<void>;
|
|
||||||
/** Last accepted sequence for each content-script document producer. */
|
|
||||||
lastStepSequenceByProducer: Map<string, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function enqueueRecordingAction(
|
|
||||||
recording: ActiveRecording,
|
|
||||||
task: () => Promise<void>,
|
|
||||||
): Promise<void> {
|
|
||||||
const queued = recording.actionQueue.then(task, task);
|
|
||||||
recording.actionQueue = queued.catch(() => {});
|
|
||||||
return queued;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecordingFinishing(recording: ActiveRecording): boolean {
|
|
||||||
return recording.settled || recording.finishAttempt !== null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const recordings = new Map<string, ActiveRecording>();
|
const recordings = new Map<string, ActiveRecording>();
|
||||||
@@ -118,9 +78,6 @@ function sleep(ms: number): Promise<void> {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recording producer version mirrored into trace.recorder.bsk. */
|
|
||||||
export const BSK_TRACE_VERSION = EXTENSION_VERSION;
|
|
||||||
|
|
||||||
/** Injectable http(s) landing page when `tool.record_start` omits `url`. */
|
/** Injectable http(s) landing page when `tool.record_start` omits `url`. */
|
||||||
export const RECORD_DEFAULT_START_URL = "https://example.com/";
|
export const RECORD_DEFAULT_START_URL = "https://example.com/";
|
||||||
|
|
||||||
@@ -180,11 +137,9 @@ async function sendRecordStartWithAck(
|
|||||||
tabId: number,
|
tabId: number,
|
||||||
msg: RecordStartMessage,
|
msg: RecordStartMessage,
|
||||||
sendToTab: RecordDeps["sendToTab"],
|
sendToTab: RecordDeps["sendToTab"],
|
||||||
cancelled?: () => boolean,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
for (let attempt = 0; attempt < RECORD_START_RETRIES; attempt += 1) {
|
for (let attempt = 0; attempt < RECORD_START_RETRIES; attempt += 1) {
|
||||||
if (cancelled?.()) throw new Error("record start cancelled");
|
|
||||||
try {
|
try {
|
||||||
const response = await sendToTab(tabId, msg);
|
const response = await sendToTab(tabId, msg);
|
||||||
if (isRecordStartAck(response)) return;
|
if (isRecordStartAck(response)) return;
|
||||||
@@ -192,7 +147,6 @@ async function sendRecordStartWithAck(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastError = err;
|
lastError = err;
|
||||||
}
|
}
|
||||||
if (cancelled?.()) throw new Error("record start cancelled");
|
|
||||||
if (attempt + 1 < RECORD_START_RETRIES) {
|
if (attempt + 1 < RECORD_START_RETRIES) {
|
||||||
await sleep(RECORD_START_RETRY_DELAY_MS);
|
await sleep(RECORD_START_RETRY_DELAY_MS);
|
||||||
}
|
}
|
||||||
@@ -200,83 +154,19 @@ async function sendRecordStartWithAck(
|
|||||||
throw lastError ?? new Error("failed to start recording in content script");
|
throw lastError ?? new Error("failed to start recording in content script");
|
||||||
}
|
}
|
||||||
|
|
||||||
function negotiatedTraceVersion(params: RecordStartParams): 2 | 3 | RpcError {
|
function buildTrace(recording: ActiveRecording): Trace {
|
||||||
if (params.trace_version === undefined) return 2;
|
const { pages, steps } = reduceTraceSteps(recording.steps, recording.startUrl);
|
||||||
if (params.trace_version === TRACE_VERSION_V3) return 3;
|
const startUrl = resolveTraceStartUrl(recording.steps, recording.startUrl, pages);
|
||||||
return {
|
return {
|
||||||
code: "invalid_params",
|
|
||||||
message: `unsupported trace_version ${params.trace_version}; supported values are omitted (v2) or ${TRACE_VERSION_V3} (v3)`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTrace(recording: ActiveRecording): RecordedTrace {
|
|
||||||
if (recording.traceVersion === 3 && recording.observation) {
|
|
||||||
return recording.observation.buildTrace({
|
|
||||||
drafts: recording.steps,
|
|
||||||
startedAt: recording.startedAt,
|
|
||||||
purpose: recording.purpose,
|
|
||||||
startUrl: recording.startUrl,
|
|
||||||
stoppedBy: recording.stoppedBy,
|
|
||||||
bskVersion: BSK_TRACE_VERSION,
|
|
||||||
includeTabSwitches: recording.supportsTabSwitchSteps,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (recording.traceVersion === 3) {
|
|
||||||
throw new Error("trace v3 observation runtime is unavailable");
|
|
||||||
}
|
|
||||||
return buildTraceV2({
|
|
||||||
steps: recording.steps,
|
|
||||||
startedAt: recording.startedAt,
|
|
||||||
...(recording.startUrl ? { startUrl: recording.startUrl } : {}),
|
|
||||||
...(recording.purpose ? { purpose: recording.purpose } : {}),
|
...(recording.purpose ? { purpose: recording.purpose } : {}),
|
||||||
});
|
recorded_at: new Date().toISOString(),
|
||||||
}
|
started_at: recording.startedAt,
|
||||||
|
entry: { start_url: startUrl },
|
||||||
function stepBufferFor(
|
pages,
|
||||||
recording: ActiveRecording,
|
steps,
|
||||||
tabId: number,
|
|
||||||
fallbackUrl?: string,
|
|
||||||
): RecordingStepBuffer {
|
|
||||||
return {
|
|
||||||
steps: recording.steps,
|
|
||||||
navigation: recording.tabs.navigation(tabId, fallbackUrl),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function activateRecordingTab(
|
|
||||||
recording: ActiveRecording,
|
|
||||||
targetTabId: number,
|
|
||||||
): Promise<void> {
|
|
||||||
const previousTabId = recording.tabs.currentTabId;
|
|
||||||
if (targetTabId === previousTabId) return;
|
|
||||||
|
|
||||||
const transition = recording.observation
|
|
||||||
? await recording.observation.captureTabTransition(previousTabId, targetTabId)
|
|
||||||
: {};
|
|
||||||
const { targetUrl, ...stateLinks } = transition;
|
|
||||||
const draft: Extract<RecordingDraftStep, { op: "switch_tab" }> = {
|
|
||||||
op: "switch_tab",
|
|
||||||
...stateLinks,
|
|
||||||
};
|
|
||||||
recording.steps.push(draft);
|
|
||||||
recording.observation?.bindTabTransition(draft, recording.steps.length);
|
|
||||||
recording.tabs.commit(targetTabId, targetUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processRecordedStep(
|
|
||||||
recording: ActiveRecording,
|
|
||||||
draftIndex: number,
|
|
||||||
tabId: number,
|
|
||||||
producerId?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!recording.observation) return;
|
|
||||||
try {
|
|
||||||
await recording.observation.processDraft(tabId, recording.steps, draftIndex, producerId);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[bsk record] observation failed for step ${draftIndex + 1}`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecordDeps {
|
export interface RecordDeps {
|
||||||
tabsApi: ChromeTabsApi;
|
tabsApi: ChromeTabsApi;
|
||||||
sendToTab(
|
sendToTab(
|
||||||
@@ -284,25 +174,16 @@ export interface RecordDeps {
|
|||||||
msg: RecordStartMessage | RecordStopMessage | RecordCancelMessage,
|
msg: RecordStartMessage | RecordStopMessage | RecordCancelMessage,
|
||||||
): Promise<unknown>;
|
): Promise<unknown>;
|
||||||
bypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
|
bypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
|
||||||
frameCoordinator?: Pick<
|
|
||||||
RecordFrameCoordinator,
|
|
||||||
"begin" | "armTab" | "sourceFor" | "stop" | "cancel"
|
|
||||||
>;
|
|
||||||
cdp?: CdpRunner;
|
cdp?: CdpRunner;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RecordRuntimeDeps = Omit<RecordDeps, "frameCoordinator" | "signal"> & {
|
|
||||||
frameCoordinator: NonNullable<RecordDeps["frameCoordinator"]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
let defaultDeps: RecordDeps | null = null;
|
let defaultDeps: RecordDeps | null = null;
|
||||||
function getDefaultDeps(): RecordDeps {
|
function getDefaultDeps(): RecordDeps {
|
||||||
if (!defaultDeps) {
|
if (!defaultDeps) {
|
||||||
defaultDeps = {
|
defaultDeps = {
|
||||||
tabsApi: chromeTabsApi,
|
tabsApi: chromeTabsApi,
|
||||||
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
|
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
|
||||||
frameCoordinator: recordFrameCoordinator,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return defaultDeps;
|
return defaultDeps;
|
||||||
@@ -360,58 +241,16 @@ export function releaseBrowserObservationListenersIfIdle(): void {
|
|||||||
detachBrowserObservation = null;
|
detachBrowserObservation = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attachRecordStepListener(deps: RecordDeps = getDefaultDeps()): () => void {
|
export function attachRecordStepListener(): () => void {
|
||||||
const listener = (
|
const listener = (
|
||||||
message: unknown,
|
message: unknown,
|
||||||
sender: chrome.runtime.MessageSender,
|
_sender: chrome.runtime.MessageSender,
|
||||||
sendResponse: (response: RecordStepAck) => void,
|
_sendResponse: () => void,
|
||||||
) => {
|
) => {
|
||||||
if (!isRecordStepMessage(message)) return false;
|
if (!isRecordStepMessage(message)) return false;
|
||||||
for (const recording of recordings.values()) {
|
for (const recording of recordings.values()) {
|
||||||
if (recording.requestId !== message.requestId) continue;
|
if (recording.requestId !== message.requestId) continue;
|
||||||
const source: RecordingCaptureScope | null | undefined = deps.frameCoordinator
|
appendRecordedPayload(recording, message.step);
|
||||||
? deps.frameCoordinator.sourceFor(message.requestId, message.producerId, sender)
|
|
||||||
: undefined;
|
|
||||||
if (deps.frameCoordinator && !source) return false;
|
|
||||||
const sourceTabId = source?.tabId ?? sender.tab?.id ?? recording.tabs.currentTabId;
|
|
||||||
const sourceWasActive = sender.tab?.active ?? sourceTabId === recording.tabs.activeTabId;
|
|
||||||
const producerKey = source
|
|
||||||
? `${source.tabId}:${source.documentId}:${source.producerId}`
|
|
||||||
: `${sourceTabId}:${message.producerId}`;
|
|
||||||
const expectedSequence = (recording.lastStepSequenceByProducer.get(producerKey) ?? 0) + 1;
|
|
||||||
if (message.sequence < expectedSequence) {
|
|
||||||
sendResponse({ ok: true, sequence: message.sequence });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (message.sequence > expectedSequence) {
|
|
||||||
sendResponse({
|
|
||||||
ok: false,
|
|
||||||
expectedSequence,
|
|
||||||
error: `out-of-order recorded step ${message.sequence}`,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
recording.lastStepSequenceByProducer.set(producerKey, message.sequence);
|
|
||||||
enqueueRecordingAction(recording, async () => {
|
|
||||||
if (sourceTabId !== recording.tabs.currentTabId) {
|
|
||||||
if (!sourceWasActive) return;
|
|
||||||
if (sourceTabId !== recording.tabs.activeTabId)
|
|
||||||
recording.tabs.noteActivation(sourceTabId);
|
|
||||||
await activateRecordingTab(recording, sourceTabId);
|
|
||||||
}
|
|
||||||
await recording.observation?.flushRedirects(sourceTabId);
|
|
||||||
const targetHint = message.step.geometry ? { geometry: message.step.geometry } : undefined;
|
|
||||||
const draftIndex = appendRecordedPayload(
|
|
||||||
stepBufferFor(recording, sourceTabId, message.step.page_url),
|
|
||||||
message.step,
|
|
||||||
targetHint,
|
|
||||||
);
|
|
||||||
if (draftIndex !== null) {
|
|
||||||
await processRecordedStep(recording, draftIndex, sourceTabId, source?.producerId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
sendResponse({ ok: true, sequence: message.sequence });
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -438,7 +277,7 @@ export function attachRecordFinishListener(deps: RecordDeps = getDefaultDeps()):
|
|||||||
|
|
||||||
function findRecordingByTabId(tabId: number): ActiveRecording | null {
|
function findRecordingByTabId(tabId: number): ActiveRecording | null {
|
||||||
for (const recording of recordings.values()) {
|
for (const recording of recordings.values()) {
|
||||||
if (recording.tabs.currentTabId === tabId && !recording.settled) return recording;
|
if (recording.tabId === tabId && !recording.settled) return recording;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -475,7 +314,7 @@ async function clearRearmTimersForRecording(
|
|||||||
recording: ActiveRecording,
|
recording: ActiveRecording,
|
||||||
deps: RecordDeps,
|
deps: RecordDeps,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
clearRearmTimer(recording.tabs.currentTabId);
|
clearRearmTimer(recording.tabId);
|
||||||
try {
|
try {
|
||||||
const tabs = await deps.tabsApi.query({ windowId: recording.agentWindowId });
|
const tabs = await deps.tabsApi.query({ windowId: recording.agentWindowId });
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
@@ -490,16 +329,13 @@ async function stopRecordingOnAllAgentTabs(
|
|||||||
recording: ActiveRecording,
|
recording: ActiveRecording,
|
||||||
deps: RecordDeps,
|
deps: RecordDeps,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (deps.frameCoordinator && !(await deps.frameCoordinator.stop(recording.requestId))) {
|
|
||||||
throw new Error("failed to flush one or more recording documents");
|
|
||||||
}
|
|
||||||
const stopMsg: RecordStopMessage = { type: RECORD_STOP, requestId: recording.requestId };
|
const stopMsg: RecordStopMessage = { type: RECORD_STOP, requestId: recording.requestId };
|
||||||
let tabIds = [recording.tabs.currentTabId];
|
let tabIds = [recording.tabId];
|
||||||
try {
|
try {
|
||||||
const tabs = await deps.tabsApi.query({ windowId: recording.agentWindowId });
|
const tabs = await deps.tabsApi.query({ windowId: recording.agentWindowId });
|
||||||
tabIds = [
|
tabIds = [
|
||||||
...new Set([
|
...new Set([
|
||||||
recording.tabs.currentTabId,
|
recording.tabId,
|
||||||
...tabs.flatMap((tab) => (typeof tab.id === "number" ? [tab.id] : [])),
|
...tabs.flatMap((tab) => (typeof tab.id === "number" ? [tab.id] : [])),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
@@ -510,11 +346,11 @@ async function stopRecordingOnAllAgentTabs(
|
|||||||
for (const tabId of tabIds) {
|
for (const tabId of tabIds) {
|
||||||
try {
|
try {
|
||||||
const response = await deps.sendToTab(tabId, stopMsg);
|
const response = await deps.sendToTab(tabId, stopMsg);
|
||||||
if (tabId === recording.tabs.currentTabId && !isRecordStartAck(response)) {
|
if (tabId === recording.tabId && !isRecordStartAck(response)) {
|
||||||
throw new Error("content script did not confirm recorded steps");
|
throw new Error("content script did not confirm recorded steps");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (tabId === recording.tabs.currentTabId) {
|
if (tabId === recording.tabId) {
|
||||||
throw new Error("failed to flush recorded steps");
|
throw new Error("failed to flush recorded steps");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -532,39 +368,22 @@ async function rearmRecording(
|
|||||||
recording: ActiveRecording,
|
recording: ActiveRecording,
|
||||||
targetTabId: number,
|
targetTabId: number,
|
||||||
deps: RecordDeps,
|
deps: RecordDeps,
|
||||||
activation?: TabActivation,
|
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
// Do NOT toggle automation-bypass here: each retry used to increment the
|
// Do NOT toggle automation-bypass here: each retry used to increment the
|
||||||
// content-script counter, and a single stop decrement left the ControlOverlay
|
// content-script counter, and a single stop decrement left the ControlOverlay
|
||||||
// stuck with pointer-events:none (page usable, Interrupt dead). RecordOverlay
|
// stuck with pointer-events:none (page usable, Interrupt dead). RecordOverlay
|
||||||
// already hides the control chrome while activeRecord is set.
|
// already hides the control chrome while activeRecord is set.
|
||||||
const isFinishing = () => isRecordingFinishing(recording);
|
|
||||||
for (let attempt = 0; attempt < RECORD_REARM_MAX_ATTEMPTS; attempt += 1) {
|
for (let attempt = 0; attempt < RECORD_REARM_MAX_ATTEMPTS; attempt += 1) {
|
||||||
if (isFinishing()) return false;
|
|
||||||
const startMsg: RecordStartMessage = {
|
const startMsg: RecordStartMessage = {
|
||||||
type: RECORD_START,
|
type: RECORD_START,
|
||||||
requestId: recording.requestId,
|
requestId: recording.requestId,
|
||||||
startedAtMs: recording.startedAtMs,
|
startedAtMs: recording.startedAtMs,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
if (deps.frameCoordinator) {
|
await sendRecordStartWithAck(targetTabId, startMsg, deps.sendToTab);
|
||||||
const frameStarted = await deps.frameCoordinator.armTab(recording.requestId, targetTabId);
|
recording.tabId = targetTabId;
|
||||||
if (!frameStarted) throw new Error("recording document did not start");
|
|
||||||
}
|
|
||||||
await sendRecordStartWithAck(targetTabId, startMsg, deps.sendToTab, isFinishing);
|
|
||||||
if (isFinishing()) return false;
|
|
||||||
if (activation) {
|
|
||||||
if (!recording.tabs.isLatest(activation)) return true;
|
|
||||||
await enqueueRecordingAction(recording, async () => {
|
|
||||||
if (isFinishing() || !recording.tabs.isLatest(activation)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await activateRecordingTab(recording, targetTabId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
if (isFinishing()) return false;
|
|
||||||
if (attempt + 1 < RECORD_REARM_MAX_ATTEMPTS) {
|
if (attempt + 1 < RECORD_REARM_MAX_ATTEMPTS) {
|
||||||
await sleep(RECORD_REARM_RETRY_DELAY_MS);
|
await sleep(RECORD_REARM_RETRY_DELAY_MS);
|
||||||
}
|
}
|
||||||
@@ -573,7 +392,7 @@ async function rearmRecording(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleRearmForTab(tabId: number, deps: RecordDeps, activation?: TabActivation): void {
|
function scheduleRearmForTab(tabId: number, deps: RecordDeps): void {
|
||||||
const existing = rearmTimers.get(tabId);
|
const existing = rearmTimers.get(tabId);
|
||||||
if (existing) clearTimeout(existing);
|
if (existing) clearTimeout(existing);
|
||||||
rearmTimers.set(
|
rearmTimers.set(
|
||||||
@@ -582,7 +401,7 @@ function scheduleRearmForTab(tabId: number, deps: RecordDeps, activation?: TabAc
|
|||||||
rearmTimers.delete(tabId);
|
rearmTimers.delete(tabId);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const current = await findRecordingForTab(tabId, deps);
|
const current = await findRecordingForTab(tabId, deps);
|
||||||
if (current) await rearmRecording(current, tabId, deps, activation);
|
if (current) await rearmRecording(current, tabId, deps);
|
||||||
})();
|
})();
|
||||||
}, RECORD_REARM_DEBOUNCE_MS),
|
}, RECORD_REARM_DEBOUNCE_MS),
|
||||||
);
|
);
|
||||||
@@ -594,20 +413,14 @@ export function attachRecordTabListener(deps: RecordDeps = getDefaultDeps()): ()
|
|||||||
const windowId = tab.windowId;
|
const windowId = tab.windowId;
|
||||||
if (tabId === undefined || windowId === undefined) return;
|
if (tabId === undefined || windowId === undefined) return;
|
||||||
for (const recording of recordings.values()) {
|
for (const recording of recordings.values()) {
|
||||||
if (isRecordingFinishing(recording) || recording.agentWindowId !== windowId) continue;
|
if (recording.settled || recording.agentWindowId !== windowId) continue;
|
||||||
scheduleRearmForTab(tabId, deps);
|
scheduleRearmForTab(tabId, deps);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onActivated = (activeInfo: chrome.tabs.TabActiveInfo) => {
|
const onActivated = (activeInfo: chrome.tabs.TabActiveInfo) => {
|
||||||
for (const recording of recordings.values()) {
|
scheduleRearmForTab(activeInfo.tabId, deps);
|
||||||
if (isRecordingFinishing(recording) || recording.agentWindowId !== activeInfo.windowId)
|
|
||||||
continue;
|
|
||||||
const activation = recording.tabs.noteActivation(activeInfo.tabId);
|
|
||||||
scheduleRearmForTab(activeInfo.tabId, deps, activation);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
chrome.tabs.onCreated.addListener(onCreated);
|
chrome.tabs.onCreated.addListener(onCreated);
|
||||||
@@ -619,47 +432,13 @@ export function attachRecordTabListener(deps: RecordDeps = getDefaultDeps()): ()
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function attachRecordNavigationListener(deps: RecordDeps = getDefaultDeps()): () => void {
|
export function attachRecordNavigationListener(deps: RecordDeps = getDefaultDeps()): () => void {
|
||||||
const observeMainFrame = (
|
const observeMainFrame = (tabId: number, url?: string, causedByAction?: boolean) => {
|
||||||
tabId: number,
|
void (async () => {
|
||||||
url?: string,
|
const recording = await findRecordingForTab(tabId, deps);
|
||||||
causedByAction?: boolean,
|
if (recording && url) {
|
||||||
transitionType?: string,
|
observeRecordedNavigation(recording, url, causedByAction);
|
||||||
transitionQualifiers?: string[],
|
|
||||||
) => {
|
|
||||||
if (!url) return;
|
|
||||||
const candidates = [...recordings.values()].filter(
|
|
||||||
(recording) =>
|
|
||||||
!recording.settled && recording.acceptingNavigation && recording.tabs.activeTabId === tabId,
|
|
||||||
);
|
|
||||||
for (const recording of candidates) {
|
|
||||||
const queued = enqueueRecordingAction(recording, async () => {
|
|
||||||
if (tabId !== recording.tabs.currentTabId) {
|
|
||||||
await activateRecordingTab(recording, tabId);
|
|
||||||
}
|
|
||||||
const result = observeRecordedNavigation(
|
|
||||||
stepBufferFor(recording, tabId, url),
|
|
||||||
url,
|
|
||||||
causedByAction,
|
|
||||||
transitionType,
|
|
||||||
transitionQualifiers,
|
|
||||||
);
|
|
||||||
if (result.kind === "coalesce_redirect") {
|
|
||||||
recording.observation?.scheduleRedirect(tabId, recording.steps, result.url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.kind === "noop") return;
|
|
||||||
recording.observation?.clearRedirect(tabId);
|
|
||||||
if (result.kind === "appended") {
|
|
||||||
await processRecordedStep(recording, result.index, tabId);
|
|
||||||
} else {
|
|
||||||
recording.observation?.scheduleSettle(tabId, recording.steps, result.index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const tracked = queued.catch(() => {});
|
|
||||||
recording.navigationCallbacks.add(tracked);
|
|
||||||
void tracked.finally(() => recording.navigationCallbacks.delete(tracked));
|
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
};
|
};
|
||||||
const onMainFrameComplete = (tabId: number, url?: string) => {
|
const onMainFrameComplete = (tabId: number, url?: string) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -679,13 +458,18 @@ export function attachRecordNavigationListener(deps: RecordDeps = getDefaultDeps
|
|||||||
details: chrome.webNavigation.WebNavigationTransitionCallbackDetails,
|
details: chrome.webNavigation.WebNavigationTransitionCallbackDetails,
|
||||||
) => {
|
) => {
|
||||||
if (details.frameId !== 0) return;
|
if (details.frameId !== 0) return;
|
||||||
observeMainFrame(
|
const causedByAction =
|
||||||
details.tabId,
|
details.transitionType === "link" || details.transitionType === "form_submit"
|
||||||
details.url,
|
? true
|
||||||
undefined,
|
: details.transitionType === "typed" ||
|
||||||
details.transitionType,
|
details.transitionType === "auto_bookmark" ||
|
||||||
details.transitionQualifiers,
|
details.transitionType === "generated" ||
|
||||||
);
|
details.transitionType === "keyword" ||
|
||||||
|
details.transitionType === "keyword_generated" ||
|
||||||
|
details.transitionType === "reload"
|
||||||
|
? false
|
||||||
|
: undefined;
|
||||||
|
observeMainFrame(details.tabId, details.url, causedByAction);
|
||||||
};
|
};
|
||||||
chrome.webNavigation.onCompleted.addListener(completedListener);
|
chrome.webNavigation.onCompleted.addListener(completedListener);
|
||||||
chrome.webNavigation.onCommitted?.addListener(committedListener);
|
chrome.webNavigation.onCommitted?.addListener(committedListener);
|
||||||
@@ -703,26 +487,6 @@ export function attachRecordNavigationListener(deps: RecordDeps = getDefaultDeps
|
|||||||
return () => chrome.tabs.onUpdated.removeListener(listener);
|
return () => chrome.tabs.onUpdated.removeListener(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_FINISH_DRAIN_ROUNDS = 10;
|
|
||||||
|
|
||||||
async function drainRecordingToStability(recording: ActiveRecording): Promise<boolean> {
|
|
||||||
for (let round = 0; round < MAX_FINISH_DRAIN_ROUNDS; round += 1) {
|
|
||||||
await Promise.all([...recording.navigationCallbacks]);
|
|
||||||
const actionTail = recording.actionQueue;
|
|
||||||
await actionTail;
|
|
||||||
await recording.observation?.flush();
|
|
||||||
|
|
||||||
if (recording.navigationCallbacks.size === 0 && recording.actionQueue === actionTail) {
|
|
||||||
// No event or promise continuation can interleave with this synchronous
|
|
||||||
// check-and-close, so work accepted before the cutoff is fully drained.
|
|
||||||
recording.acceptingNavigation = false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.warn("[bsk record] navigation/action queues did not stabilize at stop");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function attachRecordQueryListener(deps: RecordDeps = getDefaultDeps()): () => void {
|
export function attachRecordQueryListener(deps: RecordDeps = getDefaultDeps()): () => void {
|
||||||
const listener = (
|
const listener = (
|
||||||
message: unknown,
|
message: unknown,
|
||||||
@@ -741,8 +505,7 @@ export function attachRecordQueryListener(deps: RecordDeps = getDefaultDeps()):
|
|||||||
sendResponse({ active: false });
|
sendResponse({ active: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const activation = sender.tab?.active ? recording.tabs.noteActivation(tabId) : undefined;
|
await rearmRecording(recording, tabId, deps);
|
||||||
await rearmRecording(recording, tabId, deps, activation);
|
|
||||||
sendResponse({
|
sendResponse({
|
||||||
active: true,
|
active: true,
|
||||||
requestId: recording.requestId,
|
requestId: recording.requestId,
|
||||||
@@ -764,50 +527,23 @@ async function finishRecordingByRequest(
|
|||||||
if (recording.requestId !== requestId || recording.settled) continue;
|
if (recording.requestId !== requestId || recording.settled) continue;
|
||||||
const match = await findRecordingForTab(tabId, deps);
|
const match = await findRecordingForTab(tabId, deps);
|
||||||
if (match !== recording) continue;
|
if (match !== recording) continue;
|
||||||
await finishRecording(sessionId, deps, "user_finish");
|
await finishRecording(sessionId, deps);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function finishRecording(
|
async function finishRecording(sessionId: string, deps: RecordDeps): Promise<Trace | null> {
|
||||||
sessionId: string,
|
|
||||||
deps: RecordDeps,
|
|
||||||
stoppedBy: StopReason,
|
|
||||||
): Promise<RecordedTrace | null> {
|
|
||||||
const recording = recordings.get(sessionId);
|
const recording = recordings.get(sessionId);
|
||||||
if (!recording || recording.settled) return null;
|
if (!recording || recording.settled || recording.finishing) return null;
|
||||||
if (recording.finishAttempt) return recording.finishAttempt;
|
recording.finishing = true;
|
||||||
recording.stoppedBy = stoppedBy;
|
|
||||||
|
|
||||||
const attempt = finishRecordingAttempt(sessionId, recording, deps);
|
|
||||||
recording.finishAttempt = attempt;
|
|
||||||
try {
|
|
||||||
return await attempt;
|
|
||||||
} finally {
|
|
||||||
if (!recording.settled) {
|
|
||||||
recording.finishAttempt = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function finishRecordingAttempt(
|
|
||||||
sessionId: string,
|
|
||||||
recording: ActiveRecording,
|
|
||||||
deps: RecordDeps,
|
|
||||||
): Promise<RecordedTrace | null> {
|
|
||||||
await clearRearmTimersForRecording(recording, deps);
|
await clearRearmTimersForRecording(recording, deps);
|
||||||
try {
|
try {
|
||||||
// Disposing content capture may commit a final dirty fill. Flush content
|
|
||||||
// first so all resulting RECORD_STEP messages enter actionQueue before it
|
|
||||||
// and the observation queues are drained.
|
|
||||||
await stopRecordingOnAllAgentTabs(recording, deps);
|
await stopRecordingOnAllAgentTabs(recording, deps);
|
||||||
} catch {
|
} catch {
|
||||||
|
recording.finishing = false;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!(await drainRecordingToStability(recording))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
await recording.observation?.settleTrailing(recording.tabs.currentTabId, recording.steps);
|
|
||||||
|
|
||||||
recording.settled = true;
|
recording.settled = true;
|
||||||
recordings.delete(sessionId);
|
recordings.delete(sessionId);
|
||||||
@@ -822,15 +558,6 @@ export async function handleRecordStart(
|
|||||||
params: RecordStartParams,
|
params: RecordStartParams,
|
||||||
deps: RecordDeps = getDefaultDeps(),
|
deps: RecordDeps = getDefaultDeps(),
|
||||||
): Promise<RecordStartResult | RpcError> {
|
): Promise<RecordStartResult | RpcError> {
|
||||||
const traceVersionOrErr = negotiatedTraceVersion(params);
|
|
||||||
if (typeof traceVersionOrErr !== "number") return traceVersionOrErr;
|
|
||||||
if (traceVersionOrErr === 3 && !deps.cdp) {
|
|
||||||
return {
|
|
||||||
code: "protocol_error",
|
|
||||||
message: "trace v3 recording requires an active CDP connection",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctxOrErr = lookupSession(manager, params, "record_start");
|
const ctxOrErr = lookupSession(manager, params, "record_start");
|
||||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||||
const ctx = ctxOrErr;
|
const ctx = ctxOrErr;
|
||||||
@@ -847,64 +574,37 @@ export async function handleRecordStart(
|
|||||||
// on the destination page can RECORD_QUERY → rearm → show RecordOverlay
|
// on the destination page can RECORD_QUERY → rearm → show RecordOverlay
|
||||||
// instead of flashing ControlOverlay ("Agent 正在控制").
|
// instead of flashing ControlOverlay ("Agent 正在控制").
|
||||||
const requestId = makeRequestId(target.tabId);
|
const requestId = makeRequestId(target.tabId);
|
||||||
let resolveFinish!: (trace: RecordedTrace) => void;
|
let resolveFinish!: (trace: Trace) => void;
|
||||||
let rejectFinish!: (err: Error) => void;
|
let rejectFinish!: (err: Error) => void;
|
||||||
const finishPromise = new Promise<RecordedTrace>((resolve, reject) => {
|
const finishPromise = new Promise<Trace>((resolve, reject) => {
|
||||||
resolveFinish = resolve;
|
resolveFinish = resolve;
|
||||||
rejectFinish = reject;
|
rejectFinish = reject;
|
||||||
});
|
});
|
||||||
const navigateUrl = params.url ?? RECORD_DEFAULT_START_URL;
|
const navigateUrl = params.url ?? RECORD_DEFAULT_START_URL;
|
||||||
const startedAtMs = Date.now();
|
const startedAtMs = Date.now();
|
||||||
const maxPageTokens = params.max_page_tokens;
|
|
||||||
const redactValues = params.redact_values ?? false;
|
|
||||||
recordings.set(params.session_id, {
|
recordings.set(params.session_id, {
|
||||||
requestId,
|
requestId,
|
||||||
tabs: new RecordingTabCoordinator(target.tabId, navigateUrl),
|
tabId: target.tabId,
|
||||||
agentWindowId: ctx.agentWindowId,
|
agentWindowId: ctx.agentWindowId,
|
||||||
startUrl: navigateUrl,
|
startUrl: navigateUrl,
|
||||||
...(params.purpose ? { purpose: params.purpose } : {}),
|
...(params.purpose ? { purpose: params.purpose } : {}),
|
||||||
steps: [],
|
steps: [],
|
||||||
startedAt: new Date(startedAtMs).toISOString(),
|
startedAt: new Date(startedAtMs).toISOString(),
|
||||||
startedAtMs,
|
startedAtMs,
|
||||||
traceVersion: traceVersionOrErr,
|
|
||||||
supportsTabSwitchSteps: params.supports_tab_switch_steps === true,
|
|
||||||
finishPromise,
|
finishPromise,
|
||||||
resolveFinish,
|
resolveFinish,
|
||||||
rejectFinish,
|
rejectFinish,
|
||||||
settled: false,
|
settled: false,
|
||||||
finishAttempt: null,
|
finishing: false,
|
||||||
observation:
|
currentUrl: navigateUrl,
|
||||||
traceVersionOrErr === 3 && deps.cdp
|
pendingNavigation: false,
|
||||||
? new RecordingObservationRuntime({
|
pendingNavigationDeadline: undefined,
|
||||||
cdp: deps.cdp,
|
|
||||||
tabsApi: deps.tabsApi,
|
|
||||||
maxTokens: maxPageTokens,
|
|
||||||
redactValues,
|
|
||||||
})
|
|
||||||
: null,
|
|
||||||
stoppedBy: "user_finish",
|
|
||||||
navigationCallbacks: new Set(),
|
|
||||||
acceptingNavigation: true,
|
|
||||||
actionQueue: Promise.resolve(),
|
|
||||||
lastStepSequenceByProducer: new Map(),
|
|
||||||
});
|
|
||||||
deps.frameCoordinator?.begin(requestId, startedAtMs, target.tabId, async (scope) => {
|
|
||||||
const recording = recordings.get(params.session_id);
|
|
||||||
if (!recording || recording.requestId !== requestId || recording.settled) return;
|
|
||||||
try {
|
|
||||||
await recording.observation?.refreshDocument(scope.tabId, scope.producerId);
|
|
||||||
} catch {
|
|
||||||
// The normal action-time capture remains available if a child Document
|
|
||||||
// is replaced while its readiness refresh is in flight.
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
// Observe navigations for the whole recording lifetime; attach before
|
// Observe navigations for the whole recording lifetime; attach before
|
||||||
// optional navigate so the destination load can rearm capture.
|
// optional navigate so the destination load can rearm capture.
|
||||||
ensureBrowserObservationListeners(deps);
|
ensureBrowserObservationListeners(deps);
|
||||||
|
|
||||||
const abortPending = async (notifyContent: boolean) => {
|
const abortPending = async (notifyContent: boolean) => {
|
||||||
recordings.get(params.session_id)?.observation?.cancel();
|
|
||||||
deps.frameCoordinator?.cancel(requestId);
|
|
||||||
recordings.delete(params.session_id);
|
recordings.delete(params.session_id);
|
||||||
releaseBrowserObservationListenersIfIdle();
|
releaseBrowserObservationListenersIfIdle();
|
||||||
if (notifyContent) {
|
if (notifyContent) {
|
||||||
@@ -986,7 +686,7 @@ export async function handleRecordStart(
|
|||||||
return cancelledError();
|
return cancelledError();
|
||||||
}
|
}
|
||||||
active.startUrl = startUrl;
|
active.startUrl = startUrl;
|
||||||
active.tabs.navigation(target.tabId, startUrl).currentUrl = startUrl;
|
active.currentUrl = startUrl;
|
||||||
|
|
||||||
if (isContentScriptRestrictedUrl(startUrl)) {
|
if (isContentScriptRestrictedUrl(startUrl)) {
|
||||||
await abortPending(false);
|
await abortPending(false);
|
||||||
@@ -994,7 +694,7 @@ export async function handleRecordStart(
|
|||||||
code: "invalid_params",
|
code: "invalid_params",
|
||||||
message: params.url
|
message: params.url
|
||||||
? `cannot record on restricted URL (${startUrl}); use an http(s) page`
|
? `cannot record on restricted URL (${startUrl}); use an http(s) page`
|
||||||
: `cannot record on restricted URL (${startUrl}); default start page https://example.com/ did not load — pass --url with a page you can open`,
|
: `cannot record on restricted URL (${startUrl}); default start page must be injectable http(s)`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,10 +721,6 @@ export async function handleRecordStart(
|
|||||||
const startMsg: RecordStartMessage = { type: RECORD_START, requestId, startedAtMs };
|
const startMsg: RecordStartMessage = { type: RECORD_START, requestId, startedAtMs };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (deps.frameCoordinator) {
|
|
||||||
const frameStarted = await deps.frameCoordinator.armTab(requestId, target.tabId);
|
|
||||||
if (!frameStarted) throw new Error("top recording document did not start");
|
|
||||||
}
|
|
||||||
await sendRecordStartWithAck(target.tabId, startMsg, deps.sendToTab);
|
await sendRecordStartWithAck(target.tabId, startMsg, deps.sendToTab);
|
||||||
} catch {
|
} catch {
|
||||||
await abortPending(true);
|
await abortPending(true);
|
||||||
@@ -1035,17 +731,6 @@ export async function handleRecordStart(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (active.observation) {
|
|
||||||
const activeRecording = recordings.get(params.session_id);
|
|
||||||
if (activeRecording) {
|
|
||||||
try {
|
|
||||||
await activeRecording.observation?.captureInitial(target.tabId);
|
|
||||||
} catch {
|
|
||||||
// Proceed without initial observation; steps may be dropped by reducer.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{
|
||||||
const cancelled = await abortIfCancelled(true);
|
const cancelled = await abortIfCancelled(true);
|
||||||
if (cancelled) return cancelled;
|
if (cancelled) return cancelled;
|
||||||
@@ -1070,7 +755,7 @@ export async function handleRecordStop(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const trace = await finishRecording(params.session_id, deps, "cli_stop");
|
const trace = await finishRecording(params.session_id, deps);
|
||||||
if (!trace) {
|
if (!trace) {
|
||||||
return {
|
return {
|
||||||
code: "protocol_error",
|
code: "protocol_error",
|
||||||
@@ -1100,9 +785,9 @@ export async function handleRecordAwait(
|
|||||||
return { code: "cancelled", message: "record_await aborted" };
|
return { code: "cancelled", message: "record_await aborted" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const outcome = await new Promise<{ trace: RecordedTrace } | { error: RpcError }>((resolve) => {
|
const outcome = await new Promise<{ trace: Trace } | { error: RpcError }>((resolve) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const finish = (result: { trace: RecordedTrace } | { error: RpcError }) => {
|
const finish = (result: { trace: Trace } | { error: RpcError }) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
@@ -1144,8 +829,6 @@ export function clearRecordingForSession(sessionId: string): void {
|
|||||||
}
|
}
|
||||||
void clearRearmTimersForRecording(recording, getDefaultDeps());
|
void clearRearmTimersForRecording(recording, getDefaultDeps());
|
||||||
if (!recording.settled) {
|
if (!recording.settled) {
|
||||||
getDefaultDeps().frameCoordinator?.cancel(recording.requestId);
|
|
||||||
recording.observation?.cancel();
|
|
||||||
recording.settled = true;
|
recording.settled = true;
|
||||||
recording.rejectFinish(new Error("recording cleared"));
|
recording.rejectFinish(new Error("recording cleared"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ export interface SessionStopResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionStopDeps {
|
export interface SessionStopDeps {
|
||||||
signal?: AbortSignal;
|
|
||||||
cdp?: {
|
cdp?: {
|
||||||
detachSession(sessionId: string): Promise<void>;
|
detachSession(sessionId: string): Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -178,9 +177,6 @@ export async function handleSessionStop(
|
|||||||
message: `session ${params.session_id} unknown`,
|
message: `session ${params.session_id} unknown`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (deps.signal?.aborted) {
|
|
||||||
return { code: "cancelled", message: "session_stop aborted before teardown" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: auto-return borrowed tabs. Iterate over a snapshot of the
|
// Step 1: auto-return borrowed tabs. Iterate over a snapshot of the
|
||||||
// ids so deletions during iteration do not break the Map iterator.
|
// ids so deletions during iteration do not break the Map iterator.
|
||||||
@@ -191,7 +187,6 @@ export async function handleSessionStop(
|
|||||||
try {
|
try {
|
||||||
const tabManagement = {
|
const tabManagement = {
|
||||||
...(deps.tabManagement ?? {}),
|
...(deps.tabManagement ?? {}),
|
||||||
signal: deps.signal,
|
|
||||||
isAgentWindowId:
|
isAgentWindowId:
|
||||||
deps.tabManagement?.isAgentWindowId ??
|
deps.tabManagement?.isAgentWindowId ??
|
||||||
((windowId: number) => manager.findByWindowId(windowId) !== null),
|
((windowId: number) => manager.findByWindowId(windowId) !== null),
|
||||||
@@ -219,17 +214,6 @@ export async function handleSessionStop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deps.signal?.aborted) {
|
|
||||||
const nonCancelFailures = returnFailures?.filter((failure) => failure.code !== "cancelled");
|
|
||||||
if (nonCancelFailures && nonCancelFailures.length > 0) {
|
|
||||||
return {
|
|
||||||
...(returnedTabIds.length > 0 ? { returned_tab_ids: returnedTabIds } : {}),
|
|
||||||
return_failures: nonCancelFailures,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { code: "cancelled", message: "session_stop aborted during tab return" };
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (returnFailures && returnFailures.length > 0) {
|
||||||
@@ -243,17 +227,12 @@ export async function handleSessionStop(
|
|||||||
|
|
||||||
// Step 2: clear the per-session RefStore (review M6/M7 parity).
|
// Step 2: clear the per-session RefStore (review M6/M7 parity).
|
||||||
ctx.refStore.clear();
|
ctx.refStore.clear();
|
||||||
ctx.surfaceCaptures.clear();
|
|
||||||
|
|
||||||
clearRecordingForSession(params.session_id);
|
clearRecordingForSession(params.session_id);
|
||||||
|
|
||||||
// Step 3: detach CDP sessions this session opened (no-op if none).
|
// Step 3: detach CDP sessions this session opened (no-op if none).
|
||||||
await deps.cdp?.detachSession(params.session_id);
|
await deps.cdp?.detachSession(params.session_id);
|
||||||
|
|
||||||
if (deps.signal?.aborted) {
|
|
||||||
return { code: "cancelled", message: "session_stop aborted before window close" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: close the Agent Window and drop the context.
|
// Step 4: close the Agent Window and drop the context.
|
||||||
await manager.stop(params.session_id);
|
await manager.stop(params.session_id);
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ 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>;
|
||||||
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;
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
// compound CDP node identity via the session RefStore, and build stable
|
// compound CDP node identity via the session RefStore, and build stable
|
||||||
// `ref_not_found` errors for hard-failure tool paths.
|
// `ref_not_found` errors for hard-failure tool paths.
|
||||||
|
|
||||||
import type { Rect } from "@browser-skill/vom";
|
|
||||||
import type { SessionContext } from "@/session-manager/manager";
|
import type { SessionContext } from "@/session-manager/manager";
|
||||||
import { normaliseRef, type RefCapability, type RefTargetKind } from "@/session-manager/ref-store";
|
import { normaliseRef } from "@/session-manager/ref-store";
|
||||||
import type { RpcError } from "@/transport/types";
|
import type { RpcError } from "@/transport/types";
|
||||||
import { rpcError } from "./errors";
|
import { rpcError } from "./errors";
|
||||||
|
|
||||||
@@ -13,10 +12,6 @@ export interface SnapshotRefLookup {
|
|||||||
refKey: string;
|
refKey: string;
|
||||||
frameId?: string;
|
frameId?: string;
|
||||||
cdpSessionId?: string;
|
cdpSessionId?: string;
|
||||||
visibleRect?: Rect;
|
|
||||||
kind: RefTargetKind;
|
|
||||||
capabilities: RefCapability[];
|
|
||||||
generation: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function refEntryForTab(ctx: SessionContext, refKey: string, tabId: number) {
|
function refEntryForTab(ctx: SessionContext, refKey: string, tabId: number) {
|
||||||
@@ -42,10 +37,6 @@ export function lookupSnapshotRef(
|
|||||||
refKey,
|
refKey,
|
||||||
...(entry.frameId ? { frameId: entry.frameId } : {}),
|
...(entry.frameId ? { frameId: entry.frameId } : {}),
|
||||||
...(entry.cdpSessionId ? { cdpSessionId: entry.cdpSessionId } : {}),
|
...(entry.cdpSessionId ? { cdpSessionId: entry.cdpSessionId } : {}),
|
||||||
...(entry.visibleRect ? { visibleRect: entry.visibleRect } : {}),
|
|
||||||
kind: entry.kind,
|
|
||||||
capabilities: entry.capabilities,
|
|
||||||
generation: entry.generation,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +49,6 @@ export function resolveSnapshotRef(
|
|||||||
ctx: SessionContext,
|
ctx: SessionContext,
|
||||||
ref: string,
|
ref: string,
|
||||||
tabId: number,
|
tabId: number,
|
||||||
requiredCapability?: RefCapability,
|
|
||||||
): SnapshotRefLookup | RpcError {
|
): SnapshotRefLookup | RpcError {
|
||||||
const looked = lookupSnapshotRef(ctx, ref, tabId);
|
const looked = lookupSnapshotRef(ctx, ref, tabId);
|
||||||
if (looked === null) {
|
if (looked === null) {
|
||||||
@@ -68,13 +58,5 @@ export function resolveSnapshotRef(
|
|||||||
`ref ${ref} unknown for tab ${tabId} in session ${ctx.sessionId}`,
|
`ref ${ref} unknown for tab ${tabId} in session ${ctx.sessionId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (requiredCapability && !looked.capabilities.includes(requiredCapability)) {
|
|
||||||
return rpcError(
|
|
||||||
"permission_denied",
|
|
||||||
"ref_capability_denied",
|
|
||||||
`ref ${ref} does not support ${requiredCapability}`,
|
|
||||||
{ ref: looked.refKey, required_capability: requiredCapability, kind: looked.kind },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return looked;
|
return looked;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
import type { Rect } from "@browser-skill/vom";
|
|
||||||
import type { Point, Region, ViewportRect } from "./geometry";
|
|
||||||
|
|
||||||
const RECT_EPSILON = 0.01;
|
|
||||||
|
|
||||||
export function surfaceVisibleRect(
|
|
||||||
liveRect: ViewportRect,
|
|
||||||
observedRect: Rect | undefined,
|
|
||||||
): Rect | null {
|
|
||||||
const observed = observedRect ?? {
|
|
||||||
x: liveRect.x,
|
|
||||||
y: liveRect.y,
|
|
||||||
w: liveRect.width,
|
|
||||||
h: liveRect.height,
|
|
||||||
};
|
|
||||||
const x = Math.max(liveRect.x, observed.x);
|
|
||||||
const y = Math.max(liveRect.y, observed.y);
|
|
||||||
const right = Math.min(liveRect.x + liveRect.width, observed.x + observed.w);
|
|
||||||
const bottom = Math.min(liveRect.y + liveRect.height, observed.y + observed.h);
|
|
||||||
return right > x && bottom > y ? { x, y, w: right - x, h: bottom - y } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sameRect(a: Rect, b: Rect): boolean {
|
|
||||||
return (
|
|
||||||
Math.abs(a.x - b.x) <= RECT_EPSILON &&
|
|
||||||
Math.abs(a.y - b.y) <= RECT_EPSILON &&
|
|
||||||
Math.abs(a.w - b.w) <= RECT_EPSILON &&
|
|
||||||
Math.abs(a.h - b.h) <= RECT_EPSILON
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapImagePointToViewport(
|
|
||||||
rect: Rect,
|
|
||||||
imageWidth: number,
|
|
||||||
imageHeight: number,
|
|
||||||
imageX: number,
|
|
||||||
imageY: number,
|
|
||||||
): Point | null {
|
|
||||||
if (
|
|
||||||
!Number.isFinite(imageX) ||
|
|
||||||
!Number.isFinite(imageY) ||
|
|
||||||
!Number.isSafeInteger(imageWidth) ||
|
|
||||||
!Number.isSafeInteger(imageHeight) ||
|
|
||||||
imageWidth <= 0 ||
|
|
||||||
imageHeight <= 0 ||
|
|
||||||
imageX < 0 ||
|
|
||||||
imageY < 0 ||
|
|
||||||
imageX >= imageWidth ||
|
|
||||||
imageY >= imageHeight ||
|
|
||||||
!Number.isFinite(rect.x) ||
|
|
||||||
!Number.isFinite(rect.y) ||
|
|
||||||
!Number.isFinite(rect.w) ||
|
|
||||||
!Number.isFinite(rect.h) ||
|
|
||||||
rect.w <= 0 ||
|
|
||||||
rect.h <= 0
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
x: rect.x + (imageX / imageWidth) * rect.w,
|
|
||||||
y: rect.y + (imageY / imageHeight) * rect.h,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pointInPolygon(point: Point, polygon: Point[]): boolean {
|
|
||||||
for (let index = 0; index < polygon.length; index += 1) {
|
|
||||||
const a = polygon[index];
|
|
||||||
const b = polygon[(index + 1) % polygon.length];
|
|
||||||
const cross = (point.x - a.x) * (b.y - a.y) - (point.y - a.y) * (b.x - a.x);
|
|
||||||
const withinX = point.x >= Math.min(a.x, b.x) - 1e-9 && point.x <= Math.max(a.x, b.x) + 1e-9;
|
|
||||||
const withinY = point.y >= Math.min(a.y, b.y) - 1e-9 && point.y <= Math.max(a.y, b.y) + 1e-9;
|
|
||||||
if (Math.abs(cross) <= 1e-9 && withinX && withinY) return true;
|
|
||||||
}
|
|
||||||
let inside = false;
|
|
||||||
for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) {
|
|
||||||
const a = polygon[index];
|
|
||||||
const b = polygon[previous];
|
|
||||||
const intersects =
|
|
||||||
a.y > point.y !== b.y > point.y &&
|
|
||||||
point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x;
|
|
||||||
if (intersects) inside = !inside;
|
|
||||||
}
|
|
||||||
return inside;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pointInRegion(point: Point, region: Region): boolean {
|
|
||||||
return region.some((polygon) => polygon.length >= 3 && pointInPolygon(point, polygon));
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
import type { CdpFrameGraph } from "@/browser-driver/frame-graph";
|
|
||||||
import type { SessionContext } from "@/session-manager/manager";
|
|
||||||
import type { RpcError } from "@/transport/types";
|
|
||||||
import { rpcError } from "./errors";
|
|
||||||
import { resolveNodeGeometry } from "./frame-geometry";
|
|
||||||
import { type CdpRunner, isRpcError, normaliseRef } from "./shared";
|
|
||||||
import { resolveSnapshotRef } from "./snapshot-ref";
|
|
||||||
import {
|
|
||||||
mapImagePointToViewport,
|
|
||||||
pointInRegion,
|
|
||||||
sameRect,
|
|
||||||
surfaceVisibleRect,
|
|
||||||
} from "./surface-coordinate";
|
|
||||||
|
|
||||||
export interface SurfaceCaptureEnvironment {
|
|
||||||
navigationIdentity: string;
|
|
||||||
viewportSignature: string;
|
|
||||||
frameProjectionSignature: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SurfaceTargetResolverDeps {
|
|
||||||
cdp: CdpRunner;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SurfacePointerTargetInput {
|
|
||||||
ref?: string;
|
|
||||||
selector?: string;
|
|
||||||
captureId?: string;
|
|
||||||
imageX?: number;
|
|
||||||
imageY?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResolvedSurfacePointerTarget {
|
|
||||||
point: { x: number; y: number };
|
|
||||||
usedRef: string;
|
|
||||||
moveSettleMs: number;
|
|
||||||
capture: {
|
|
||||||
id: string;
|
|
||||||
imageX: number;
|
|
||||||
imageY: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Canvas-style controls commonly update their pointer hit-test on the next
|
|
||||||
// animation frame. Leave a scheduling boundary after moving and before pressing.
|
|
||||||
export const SURFACE_POINTER_MOVE_SETTLE_MS = 32;
|
|
||||||
|
|
||||||
function stale(message: string): RpcError {
|
|
||||||
return rpcError("permission_denied", "surface_capture_stale", message);
|
|
||||||
}
|
|
||||||
|
|
||||||
function finiteObject(value: unknown): unknown {
|
|
||||||
if (Array.isArray(value)) return value.map(finiteObject);
|
|
||||||
if (typeof value !== "object" || value === null) return value;
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(value)
|
|
||||||
.filter(
|
|
||||||
([, entry]) => entry !== undefined && (typeof entry !== "number" || Number.isFinite(entry)),
|
|
||||||
)
|
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
|
||||||
.map(([key, entry]) => [key, finiteObject(entry)]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function framePathSignature(graph: CdpFrameGraph, frameId: string | undefined): string | null {
|
|
||||||
if (!frameId) return "top";
|
|
||||||
const frames = new Map(graph.frames.map((frame) => [frame.frameId, frame]));
|
|
||||||
const path: Array<{
|
|
||||||
frameId: string;
|
|
||||||
parentFrameId?: string;
|
|
||||||
ownerBackendNodeId?: number;
|
|
||||||
targetSessionId?: string;
|
|
||||||
}> = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
let current = frames.get(frameId);
|
|
||||||
while (current) {
|
|
||||||
if (seen.has(current.frameId)) return null;
|
|
||||||
seen.add(current.frameId);
|
|
||||||
path.push({
|
|
||||||
frameId: current.frameId,
|
|
||||||
...(current.parentFrameId ? { parentFrameId: current.parentFrameId } : {}),
|
|
||||||
...(current.ownerBackendNodeId !== undefined
|
|
||||||
? { ownerBackendNodeId: current.ownerBackendNodeId }
|
|
||||||
: {}),
|
|
||||||
...(current.target.sessionId ? { targetSessionId: current.target.sessionId } : {}),
|
|
||||||
});
|
|
||||||
if (!current.parentFrameId) return JSON.stringify(path);
|
|
||||||
const parent = frames.get(current.parentFrameId);
|
|
||||||
if (!parent) return null;
|
|
||||||
current = parent;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function captureSurfaceEnvironment(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
frameId: string | undefined,
|
|
||||||
): Promise<SurfaceCaptureEnvironment | RpcError> {
|
|
||||||
try {
|
|
||||||
const [tree, metrics, graph] = await Promise.all([
|
|
||||||
cdp.send<{
|
|
||||||
frameTree?: { frame?: { id?: string; loaderId?: string; url?: string } };
|
|
||||||
}>(tabId, "Page.getFrameTree", {}),
|
|
||||||
cdp.send<Record<string, unknown>>(tabId, "Page.getLayoutMetrics", {}),
|
|
||||||
frameId && cdp.getFrameGraph ? cdp.getFrameGraph(tabId) : Promise.resolve(null),
|
|
||||||
]);
|
|
||||||
const root = tree.frameTree?.frame;
|
|
||||||
if (!root?.id || !root.loaderId) {
|
|
||||||
return { code: "cdp_failed", message: "could not identify the current navigation" };
|
|
||||||
}
|
|
||||||
const frameProjectionSignature = frameId
|
|
||||||
? graph
|
|
||||||
? framePathSignature(graph, frameId)
|
|
||||||
: null
|
|
||||||
: "top";
|
|
||||||
if (!frameProjectionSignature) {
|
|
||||||
return { code: "cdp_failed", message: `could not identify frame projection for ${frameId}` };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
navigationIdentity: JSON.stringify({ id: root.id, loaderId: root.loaderId, url: root.url }),
|
|
||||||
viewportSignature: JSON.stringify(
|
|
||||||
finiteObject({
|
|
||||||
cssLayoutViewport: metrics.cssLayoutViewport,
|
|
||||||
cssVisualViewport: metrics.cssVisualViewport,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
frameProjectionSignature,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { code: "cdp_failed", message: error instanceof Error ? error.message : String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validatePointParams(input: SurfacePointerTargetInput): RpcError | null {
|
|
||||||
if (!input.captureId || typeof input.captureId !== "string") {
|
|
||||||
return { code: "invalid_params", message: "surface point click requires capture_id" };
|
|
||||||
}
|
|
||||||
if (typeof input.imageX !== "number" || !Number.isFinite(input.imageX)) {
|
|
||||||
return { code: "invalid_params", message: "image_x must be a finite number" };
|
|
||||||
}
|
|
||||||
if (typeof input.imageY !== "number" || !Number.isFinite(input.imageY)) {
|
|
||||||
return { code: "invalid_params", message: "image_y must be a finite number" };
|
|
||||||
}
|
|
||||||
if (!input.ref || input.selector) {
|
|
||||||
return {
|
|
||||||
code: "invalid_params",
|
|
||||||
message: "surface point click requires one Surface ref and does not accept a selector",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function captureConsumeError(reason: "not_found" | "expired" | "consumed"): RpcError {
|
|
||||||
const code = reason === "not_found" ? "not_found" : "permission_denied";
|
|
||||||
return rpcError(code, `surface_capture_${reason}`, `surface capture is ${reason}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function aborted(signal: AbortSignal | undefined): RpcError | null {
|
|
||||||
return signal?.aborted
|
|
||||||
? { code: "cancelled", message: "surface target resolution aborted" }
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve one fresh Surface capture coordinate into a validated top-viewport pointer target. */
|
|
||||||
export async function resolveSurfacePointerTarget(
|
|
||||||
ctx: SessionContext,
|
|
||||||
target: { tabId: number; url?: string },
|
|
||||||
input: SurfacePointerTargetInput,
|
|
||||||
deps: SurfaceTargetResolverDeps,
|
|
||||||
): Promise<ResolvedSurfacePointerTarget | RpcError> {
|
|
||||||
const invalid = validatePointParams(input);
|
|
||||||
if (invalid) return invalid;
|
|
||||||
const earlyAbort = aborted(deps.signal);
|
|
||||||
if (earlyAbort) return earlyAbort;
|
|
||||||
|
|
||||||
const consumed = ctx.surfaceCaptures.consume(input.captureId as string);
|
|
||||||
if (!consumed.ok) return captureConsumeError(consumed.reason);
|
|
||||||
const capture = consumed.capture;
|
|
||||||
const postConsumeAbort = aborted(deps.signal);
|
|
||||||
if (postConsumeAbort) return postConsumeAbort;
|
|
||||||
|
|
||||||
if (capture.sessionId !== ctx.sessionId || capture.tabId !== target.tabId) {
|
|
||||||
return stale("surface capture belongs to a different session or tab");
|
|
||||||
}
|
|
||||||
if (normaliseRef(input.ref as string) !== capture.surface.ref) {
|
|
||||||
return stale("surface ref does not match the capture");
|
|
||||||
}
|
|
||||||
const node = resolveSnapshotRef(ctx, input.ref as string, target.tabId, "screenshot");
|
|
||||||
if (isRpcError(node)) return stale(node.message);
|
|
||||||
if (
|
|
||||||
node.kind !== "surface" ||
|
|
||||||
node.backendNodeId !== capture.surface.backendNodeId ||
|
|
||||||
node.frameId !== capture.surface.frameId ||
|
|
||||||
node.generation !== capture.surface.observationGeneration
|
|
||||||
) {
|
|
||||||
return stale("surface observation generation or node identity changed");
|
|
||||||
}
|
|
||||||
|
|
||||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
|
||||||
try {
|
|
||||||
await deps.cdp.ensureAttachedToUrl?.(target.tabId, target.url);
|
|
||||||
} catch (error) {
|
|
||||||
return { code: "cdp_failed", message: error instanceof Error ? error.message : String(error) };
|
|
||||||
}
|
|
||||||
const environment = await captureSurfaceEnvironment(deps.cdp, target.tabId, node.frameId);
|
|
||||||
if (isRpcError(environment)) return environment;
|
|
||||||
if (
|
|
||||||
environment.navigationIdentity !== capture.navigationIdentity ||
|
|
||||||
environment.viewportSignature !== capture.viewportSignature ||
|
|
||||||
environment.frameProjectionSignature !== capture.frameProjectionSignature
|
|
||||||
) {
|
|
||||||
return stale("navigation, viewport, zoom, scroll, or frame projection changed");
|
|
||||||
}
|
|
||||||
|
|
||||||
const geometry = await resolveNodeGeometry(
|
|
||||||
deps.cdp,
|
|
||||||
target.tabId,
|
|
||||||
{
|
|
||||||
target: {
|
|
||||||
tabId: target.tabId,
|
|
||||||
...(node.cdpSessionId ? { sessionId: node.cdpSessionId } : {}),
|
|
||||||
},
|
|
||||||
backendNodeId: node.backendNodeId,
|
|
||||||
...(node.frameId ? { frameId: node.frameId } : {}),
|
|
||||||
},
|
|
||||||
{ scrollIntoView: false },
|
|
||||||
);
|
|
||||||
if (isRpcError(geometry)) return stale(geometry.message);
|
|
||||||
const currentRect = surfaceVisibleRect(geometry.topBounds, node.visibleRect);
|
|
||||||
if (!currentRect || !sameRect(currentRect, capture.topViewportRect)) {
|
|
||||||
return stale("surface visible region changed");
|
|
||||||
}
|
|
||||||
const point = mapImagePointToViewport(
|
|
||||||
capture.topViewportRect,
|
|
||||||
capture.imageWidth,
|
|
||||||
capture.imageHeight,
|
|
||||||
input.imageX as number,
|
|
||||||
input.imageY as number,
|
|
||||||
);
|
|
||||||
if (!point || !pointInRegion(point, geometry.topVisibleRegions)) {
|
|
||||||
return rpcError(
|
|
||||||
"invalid_params",
|
|
||||||
"surface_coordinate_invalid",
|
|
||||||
"image coordinate is outside the captured Surface",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
point,
|
|
||||||
usedRef: capture.surface.ref,
|
|
||||||
moveSettleMs: SURFACE_POINTER_MOVE_SETTLE_MS,
|
|
||||||
capture: {
|
|
||||||
id: capture.id,
|
|
||||||
imageX: input.imageX as number,
|
|
||||||
imageY: input.imageY as number,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
// Browser-native text entry for live editing sessions. Unlike fill, this
|
|
||||||
// module does not assume that the focused editable retains a DOM value: a
|
|
||||||
// Canvas editor may use an input/textarea only as a transient keyboard/IME
|
|
||||||
// sink and clear it after consuming each event.
|
|
||||||
|
|
||||||
import type { RpcError } from "@/transport/types";
|
|
||||||
import type { CdpRunner } from "./shared";
|
|
||||||
|
|
||||||
function cancelled(signal: AbortSignal | undefined): RpcError | null {
|
|
||||||
return signal?.aborted ? { code: "cancelled", message: "type aborted" } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KeyDescriptor {
|
|
||||||
key: string;
|
|
||||||
code: string;
|
|
||||||
windowsVirtualKeyCode: number;
|
|
||||||
nativeVirtualKeyCode?: number;
|
|
||||||
modifiers: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asciiKeyDescriptor(character: string): KeyDescriptor {
|
|
||||||
if (/^[a-z]$/.test(character)) {
|
|
||||||
return {
|
|
||||||
key: character,
|
|
||||||
code: `Key${character.toUpperCase()}`,
|
|
||||||
windowsVirtualKeyCode: character.toUpperCase().charCodeAt(0),
|
|
||||||
modifiers: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (/^[A-Z]$/.test(character)) {
|
|
||||||
return {
|
|
||||||
key: character,
|
|
||||||
code: `Key${character}`,
|
|
||||||
windowsVirtualKeyCode: character.charCodeAt(0),
|
|
||||||
modifiers: 8,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (/^[0-9]$/.test(character)) {
|
|
||||||
return {
|
|
||||||
key: character,
|
|
||||||
code: `Digit${character}`,
|
|
||||||
windowsVirtualKeyCode: character.charCodeAt(0),
|
|
||||||
modifiers: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (character === " ") {
|
|
||||||
return { key: " ", code: "Space", windowsVirtualKeyCode: 32, modifiers: 0 };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
key: character,
|
|
||||||
code: "",
|
|
||||||
windowsVirtualKeyCode: character.charCodeAt(0),
|
|
||||||
modifiers: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run one complete browser key transaction and always release the key. The
|
|
||||||
* commit callback owns the text-producing phase: a `char` event for a
|
|
||||||
* keyboard character, or `Input.insertText` for committed IME text.
|
|
||||||
*/
|
|
||||||
async function dispatchKeyTransaction(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
descriptor: KeyDescriptor,
|
|
||||||
commit: () => Promise<void>,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<RpcError | null> {
|
|
||||||
let keyDown = false;
|
|
||||||
try {
|
|
||||||
await cdp.send(tabId, "Input.dispatchKeyEvent", {
|
|
||||||
type: "rawKeyDown",
|
|
||||||
...descriptor,
|
|
||||||
});
|
|
||||||
keyDown = true;
|
|
||||||
const abortedAfterDown = cancelled(signal);
|
|
||||||
if (!abortedAfterDown) await commit();
|
|
||||||
await cdp.send(tabId, "Input.dispatchKeyEvent", {
|
|
||||||
type: "keyUp",
|
|
||||||
...descriptor,
|
|
||||||
});
|
|
||||||
keyDown = false;
|
|
||||||
return abortedAfterDown ?? cancelled(signal);
|
|
||||||
} catch (err) {
|
|
||||||
if (keyDown) {
|
|
||||||
try {
|
|
||||||
await cdp.send(tabId, "Input.dispatchKeyEvent", {
|
|
||||||
type: "keyUp",
|
|
||||||
...descriptor,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Best-effort cleanup keeps the page from observing a stuck key.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function typeAsciiCharacter(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
character: string,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<RpcError | null> {
|
|
||||||
const descriptor = asciiKeyDescriptor(character);
|
|
||||||
return dispatchKeyTransaction(
|
|
||||||
cdp,
|
|
||||||
tabId,
|
|
||||||
descriptor,
|
|
||||||
async () => {
|
|
||||||
await cdp.send(tabId, "Input.dispatchKeyEvent", {
|
|
||||||
type: "char",
|
|
||||||
...descriptor,
|
|
||||||
text: character,
|
|
||||||
unmodifiedText: character,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
signal,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function typeImeSegment(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
text: string,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<RpcError | null> {
|
|
||||||
const abortedBefore = cancelled(signal);
|
|
||||||
if (abortedBefore) return abortedBefore;
|
|
||||||
|
|
||||||
// A committed IME value is still part of a keyboard transaction. The
|
|
||||||
// Process key (VK_PROCESSKEY / 229) is the platform-neutral signal that an
|
|
||||||
// IME owns the keystroke. It lets selection-based editors enter text mode
|
|
||||||
// before insertText commits the Unicode value, without inventing a visible
|
|
||||||
// ASCII prefix or leaving a composition transaction open.
|
|
||||||
const processKey: KeyDescriptor = {
|
|
||||||
key: "Process",
|
|
||||||
code: "",
|
|
||||||
windowsVirtualKeyCode: 229,
|
|
||||||
nativeVirtualKeyCode: 229,
|
|
||||||
modifiers: 0,
|
|
||||||
};
|
|
||||||
return dispatchKeyTransaction(
|
|
||||||
cdp,
|
|
||||||
tabId,
|
|
||||||
processKey,
|
|
||||||
() => cdp.send(tabId, "Input.insertText", { text }).then(() => undefined),
|
|
||||||
signal,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispatch text through the browser's keyboard/IME pipelines. ASCII runs are
|
|
||||||
* delivered as physical-key-shaped events; non-ASCII runs use CDP's committed
|
|
||||||
* IME/software-keyboard insertion path. Successful return means the browser
|
|
||||||
* accepted the input commands, not that a DOM value retained the text.
|
|
||||||
*/
|
|
||||||
export async function dispatchTextInput(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
text: string,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<RpcError | null> {
|
|
||||||
const abortedBefore = cancelled(signal);
|
|
||||||
if (abortedBefore) return abortedBefore;
|
|
||||||
|
|
||||||
const segments: Array<{ ime: boolean; text: string }> = [];
|
|
||||||
for (const character of text) {
|
|
||||||
const ime = character.codePointAt(0)! > 0x7f;
|
|
||||||
const previous = segments.at(-1);
|
|
||||||
if (previous?.ime === ime) previous.text += character;
|
|
||||||
else segments.push({ ime, text: character });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const segment of segments) {
|
|
||||||
if (segment.ime) {
|
|
||||||
const error = await typeImeSegment(cdp, tabId, segment.text, signal);
|
|
||||||
if (error) return error;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const character of segment.text) {
|
|
||||||
const error = await typeAsciiCharacter(cdp, tabId, character, signal);
|
|
||||||
if (error) return error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
code: "cdp_failed",
|
|
||||||
message: err instanceof Error ? err.message : String(err),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// 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 { SessionManager } from "@/session-manager/manager";
|
||||||
|
import type { RpcError, UploadParams, UploadResult } from "@/transport/types";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 transaction = await uploadThroughActivatedFileInput({
|
||||||
|
cdp: deps.cdp,
|
||||||
|
actionTarget: address,
|
||||||
|
files: params.files.map((file) => file.staged_path as string),
|
||||||
|
timeoutMs,
|
||||||
|
signal: deps.signal,
|
||||||
|
trigger: () => clickResolvedTarget(ctx, address, {}, deps),
|
||||||
|
});
|
||||||
|
if (isRpcError(transaction)) return transaction;
|
||||||
|
return {
|
||||||
|
tab_id: target.tabId,
|
||||||
|
used_ref: transaction.click.used_ref,
|
||||||
|
used_selector: transaction.click.used_selector,
|
||||||
|
file_names: params.files.map((file) => file.name),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ function fakeSnapshotReply() {
|
|||||||
},
|
},
|
||||||
layout: {
|
layout: {
|
||||||
nodeIndex: [1, 2, 3],
|
nodeIndex: [1, 2, 3],
|
||||||
// Legacy fixture: omitted style columns fall back to visible defaults.
|
// styles columns follow requested computedStyles order: [position, pointer-events]
|
||||||
styles: [
|
styles: [
|
||||||
[i("static"), i("auto")],
|
[i("static"), i("auto")],
|
||||||
[i("fixed"), i("auto")],
|
[i("fixed"), i("auto")],
|
||||||
@@ -682,84 +682,6 @@ describe("captureViewModel", () => {
|
|||||||
expect(nodes.find((n) => n.backendNodeId === 11)?.cursor).toBe("auto");
|
expect(nodes.find((n) => n.backendNodeId === 11)?.cursor).toBe("auto");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("captures painted visibility as DOM fallback evidence", async () => {
|
|
||||||
const S = ["html", "body", "button", "static", "auto", "visible", "hidden", "1", "0"];
|
|
||||||
const i = (value: string) => S.indexOf(value);
|
|
||||||
const snapshot = {
|
|
||||||
strings: S,
|
|
||||||
documents: [
|
|
||||||
{
|
|
||||||
nodes: {
|
|
||||||
parentIndex: [-1, 0, 1, 1, 1],
|
|
||||||
nodeName: [i("html"), i("body"), i("button"), i("button"), i("button")],
|
|
||||||
backendNodeId: [10, 11, 12, 13, 14],
|
|
||||||
attributes: [[], [], [], [], []],
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
nodeIndex: [1, 2, 3, 4],
|
|
||||||
styles: [
|
|
||||||
[i("static"), i("auto"), i("auto"), i("visible"), i("1")],
|
|
||||||
[i("static"), i("auto"), i("auto"), i("visible"), i("1")],
|
|
||||||
[i("static"), i("auto"), i("auto"), i("hidden"), i("1")],
|
|
||||||
[i("static"), i("auto"), i("auto"), i("visible"), i("0")],
|
|
||||||
],
|
|
||||||
bounds: [
|
|
||||||
[0, 0, 1000, 800],
|
|
||||||
[10, 10, 100, 30],
|
|
||||||
[10, 50, 100, 30],
|
|
||||||
[10, 90, 100, 30],
|
|
||||||
],
|
|
||||||
paintOrders: [0, 1, 2, 3],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const { nodes } = await captureViewModel(makeCdp(snapshot), 4);
|
|
||||||
|
|
||||||
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 === 14)?.rendered).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("propagates fully transparent ancestor suppression to painted descendants", async () => {
|
|
||||||
const S = ["html", "body", "div", "canvas", "static", "auto", "visible", "1", "0"];
|
|
||||||
const i = (value: string) => S.indexOf(value);
|
|
||||||
const snapshot = {
|
|
||||||
strings: S,
|
|
||||||
documents: [
|
|
||||||
{
|
|
||||||
nodes: {
|
|
||||||
parentIndex: [-1, 0, 1, 2],
|
|
||||||
nodeName: [i("html"), i("body"), i("div"), i("canvas")],
|
|
||||||
backendNodeId: [10, 11, 12, 13],
|
|
||||||
attributes: [[], [], [], []],
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
nodeIndex: [1, 2, 3],
|
|
||||||
styles: [
|
|
||||||
[i("static"), i("auto"), i("auto"), i("visible"), i("1")],
|
|
||||||
[i("static"), i("auto"), i("auto"), i("visible"), i("0")],
|
|
||||||
[i("static"), i("auto"), i("auto"), i("visible"), i("1")],
|
|
||||||
],
|
|
||||||
bounds: [
|
|
||||||
[0, 0, 1000, 800],
|
|
||||||
[10, 10, 200, 100],
|
|
||||||
[10, 10, 200, 100],
|
|
||||||
],
|
|
||||||
paintOrders: [0, 1, 2],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const { nodes } = await captureViewModel(makeCdp(snapshot), 4);
|
|
||||||
const canvas = nodes.find((node) => node.backendNodeId === 13);
|
|
||||||
|
|
||||||
expect(canvas?.rendered).toBe(true);
|
|
||||||
expect(canvas?.visuallySuppressed).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("subtracts scroll offset to make rects viewport-relative", async () => {
|
it("subtracts scroll offset to make rects viewport-relative", async () => {
|
||||||
const cdp = {
|
const cdp = {
|
||||||
send: vi.fn(async (_t: number, method: string) => {
|
send: vi.fn(async (_t: number, method: string) => {
|
||||||
|
|||||||
@@ -132,67 +132,4 @@ describe("captureFrameData", () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
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,183 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
|
||||||
import type { CdpRunner } from "../../shared";
|
|
||||||
import type { CapturedNode } from "../capture";
|
|
||||||
import type { FrameDocument, FrameOwnedAxNode } from "../frame-document";
|
|
||||||
import { probeTooltipNames } from "../name-enrichment";
|
|
||||||
import { buildSemanticGraph, resolveSemanticGraph } from "../semantic-graph";
|
|
||||||
import { stableIdentifierName } from "../semantic-graph/name-evidence";
|
|
||||||
import { frameBackendKey } from "../semantic-graph/types";
|
|
||||||
|
|
||||||
function document(
|
|
||||||
target: CdpTarget,
|
|
||||||
backendNodeId: number,
|
|
||||||
accessibleName?: string,
|
|
||||||
frameId = "child",
|
|
||||||
x = 10,
|
|
||||||
): FrameDocument<FrameOwnedAxNode> {
|
|
||||||
const node: CapturedNode = {
|
|
||||||
backendNodeId,
|
|
||||||
parentBackendNodeId: null,
|
|
||||||
frameId,
|
|
||||||
tag: "button",
|
|
||||||
attrs: {
|
|
||||||
id: "toolInsertRecord",
|
|
||||||
"data-test-id": "toolInsertRecord",
|
|
||||||
...(accessibleName ? { "aria-label": accessibleName } : {}),
|
|
||||||
},
|
|
||||||
rect: { x, y: 20, w: 40, h: 30 },
|
|
||||||
localRect: { x: 2, y: 3, w: 40, h: 30 },
|
|
||||||
paintOrder: 1,
|
|
||||||
position: "static",
|
|
||||||
pointerEvents: "auto",
|
|
||||||
cursor: "pointer",
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
frameId,
|
|
||||||
parentFrameId: "main",
|
|
||||||
contextScopeId: "child",
|
|
||||||
target,
|
|
||||||
axNodes: [],
|
|
||||||
domNodes: [node],
|
|
||||||
url: "https://example.com/frame",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function semantics(documents: FrameDocument<FrameOwnedAxNode>[]) {
|
|
||||||
return resolveSemanticGraph(
|
|
||||||
buildSemanticGraph({
|
|
||||||
documents,
|
|
||||||
viewport: { width: 800, height: 600 },
|
|
||||||
rootFrameId: documents[0]?.frameId,
|
|
||||||
}),
|
|
||||||
{ identifierFallback: false },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("tooltip name enrichment", () => {
|
|
||||||
it("uses the owning frame target and upgrades an identifier name from a tooltip", async () => {
|
|
||||||
const target = { tabId: 7, sessionId: "oopif-session" };
|
|
||||||
let hovered = false;
|
|
||||||
const cdp: CdpRunner = {
|
|
||||||
send: vi.fn(async (_tabId, method, params) => {
|
|
||||||
if (method === "Input.dispatchMouseEvent") {
|
|
||||||
hovered = (params as { x?: number }).x === 30;
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}) as CdpRunner["send"],
|
|
||||||
sendToTarget: vi.fn(async (_target, method) => {
|
|
||||||
if (method === "Page.createIsolatedWorld") return { executionContextId: 11 };
|
|
||||||
if (method === "DOM.resolveNode") return { object: { objectId: "button-501" } };
|
|
||||||
if (method === "Runtime.callFunctionOn") {
|
|
||||||
return { result: { value: hovered ? ["插入行"] : [] } };
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}) as CdpRunner["sendToTarget"],
|
|
||||||
};
|
|
||||||
|
|
||||||
const documents = [document(target, 501)];
|
|
||||||
const graph = semantics(documents);
|
|
||||||
const names = await probeTooltipNames(cdp, 7, documents, graph);
|
|
||||||
|
|
||||||
expect(names.get(frameBackendKey("child", 501))).toBe("插入行");
|
|
||||||
expect(cdp.sendToTarget).toHaveBeenCalledWith(
|
|
||||||
target,
|
|
||||||
"Page.createIsolatedWorld",
|
|
||||||
expect.objectContaining({ frameId: "child" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const sendsBeforeCache = vi.mocked(cdp.send).mock.calls.length;
|
|
||||||
const targetSendsBeforeCache = vi.mocked(
|
|
||||||
cdp.sendToTarget as NonNullable<CdpRunner["sendToTarget"]>,
|
|
||||||
).mock.calls.length;
|
|
||||||
const cached = await probeTooltipNames(cdp, 7, documents, graph);
|
|
||||||
expect(cached.get(frameBackendKey("child", 501))).toBe("插入行");
|
|
||||||
expect(cdp.send).toHaveBeenCalledTimes(sendsBeforeCache);
|
|
||||||
expect(cdp.sendToTarget).toHaveBeenCalledTimes(targetSendsBeforeCache);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not probe controls that already have a higher-confidence name", async () => {
|
|
||||||
const cdp: CdpRunner = {
|
|
||||||
send: vi.fn(async () => ({})) as CdpRunner["send"],
|
|
||||||
sendToTarget: vi.fn(async () => ({ executionContextId: 12 })) as CdpRunner["sendToTarget"],
|
|
||||||
};
|
|
||||||
|
|
||||||
const documents = [document({ tabId: 8 }, 601, "Add a new record")];
|
|
||||||
const names = await probeTooltipNames(cdp, 8, documents, semantics(documents));
|
|
||||||
|
|
||||||
expect(names.size).toBe(0);
|
|
||||||
expect(cdp.send).not.toHaveBeenCalled();
|
|
||||||
expect(cdp.sendToTarget).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not collect supplemental names from form controls", async () => {
|
|
||||||
const cdp: CdpRunner = {
|
|
||||||
send: vi.fn(async () => ({})) as CdpRunner["send"],
|
|
||||||
sendToTarget: vi.fn(async () => ({ executionContextId: 12 })) as CdpRunner["sendToTarget"],
|
|
||||||
};
|
|
||||||
const inputDocument = document({ tabId: 8 }, 602);
|
|
||||||
inputDocument.domNodes[0] = {
|
|
||||||
...inputDocument.domNodes[0],
|
|
||||||
tag: "input",
|
|
||||||
attrs: { type: "email" },
|
|
||||||
formValue: "user@example.com",
|
|
||||||
formDefaultValue: "",
|
|
||||||
formState: "filled",
|
|
||||||
};
|
|
||||||
|
|
||||||
const names = await probeTooltipNames(cdp, 8, [inputDocument], semantics([inputDocument]));
|
|
||||||
|
|
||||||
expect(names.size).toBe(0);
|
|
||||||
expect(cdp.send).not.toHaveBeenCalled();
|
|
||||||
expect(cdp.sendToTarget).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps tooltip evidence isolated across sibling frame sessions", async () => {
|
|
||||||
let hoveredX = -10;
|
|
||||||
const cdp: CdpRunner = {
|
|
||||||
send: vi.fn(async (_tabId, method, params) => {
|
|
||||||
if (method === "Input.dispatchMouseEvent") {
|
|
||||||
hoveredX = (params as { x: number }).x;
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}) as CdpRunner["send"],
|
|
||||||
sendToTarget: vi.fn(async (target, method, params) => {
|
|
||||||
if (method === "Page.createIsolatedWorld") {
|
|
||||||
return { executionContextId: target.sessionId === "left-session" ? 21 : 22 };
|
|
||||||
}
|
|
||||||
if (method === "DOM.resolveNode") {
|
|
||||||
return { object: { objectId: target.sessionId === "left-session" ? "left" : "right" } };
|
|
||||||
}
|
|
||||||
if (method === "Runtime.callFunctionOn") {
|
|
||||||
const objectId = (params as { objectId: string }).objectId;
|
|
||||||
const value =
|
|
||||||
objectId === "left" && hoveredX === 30
|
|
||||||
? ["Left action"]
|
|
||||||
: objectId === "right" && hoveredX === 130
|
|
||||||
? ["Right action"]
|
|
||||||
: [];
|
|
||||||
return { result: { value } };
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}) as CdpRunner["sendToTarget"],
|
|
||||||
};
|
|
||||||
const documents = [
|
|
||||||
document({ tabId: 9, sessionId: "left-session" }, 701, undefined, "left", 10),
|
|
||||||
document({ tabId: 9, sessionId: "right-session" }, 701, undefined, "right", 110),
|
|
||||||
];
|
|
||||||
|
|
||||||
const names = await probeTooltipNames(cdp, 9, documents, semantics(documents));
|
|
||||||
|
|
||||||
expect(names.get(frameBackendKey("left", 701))).toBe("Left action");
|
|
||||||
expect(names.get(frameBackendKey("right", 701))).toBe("Right action");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("stable identifier evidence", () => {
|
|
||||||
it("accepts readable action identifiers and rejects framework-generated ids", () => {
|
|
||||||
expect(stableIdentifierName({ id: "toolInsertRecord" })).toBe("Insert record");
|
|
||||||
expect(stableIdentifierName({ id: "reactivateAccount" })).toBe("Reactivate account");
|
|
||||||
expect(stableIdentifierName({ id: "headlessui-menu-button-r1" })).toBeUndefined();
|
|
||||||
expect(stableIdentifierName({ id: "7f15a3bc91" })).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
RECORD_DOCUMENT_ATTRIBUTE,
|
|
||||||
recordingDocumentMarkerValue,
|
|
||||||
} from "@/shared/recording-document-identity";
|
|
||||||
import { projectRecordSafeObservation } from "../record-safe-observation";
|
|
||||||
|
|
||||||
describe("projectRecordSafeObservation", () => {
|
|
||||||
it("projects the opaque recording identity without exposing captured attributes", () => {
|
|
||||||
const recordingDocumentId = "123e4567-e89b-42d3-a456-426614174000";
|
|
||||||
const result = projectRecordSafeObservation({
|
|
||||||
rootFrameId: "child",
|
|
||||||
frameDocuments: [
|
|
||||||
{
|
|
||||||
frameId: "child",
|
|
||||||
target: { tabId: 4, sessionId: "oopif-session" },
|
|
||||||
contextScopeId: "scope-1",
|
|
||||||
axNodes: [],
|
|
||||||
domNodes: [
|
|
||||||
{
|
|
||||||
backendNodeId: 1,
|
|
||||||
parentBackendNodeId: null,
|
|
||||||
tag: "html",
|
|
||||||
attrs: {
|
|
||||||
[RECORD_DOCUMENT_ATTRIBUTE]: recordingDocumentMarkerValue(recordingDocumentId),
|
|
||||||
value: "user@example.com",
|
|
||||||
},
|
|
||||||
rect: null,
|
|
||||||
paintOrder: 0,
|
|
||||||
position: "static",
|
|
||||||
pointerEvents: "auto",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
rendered: { text: '@vom 1\nRootWebArea "Example"', refs: [], truncated: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.frames).toEqual([
|
|
||||||
{
|
|
||||||
frameId: "child",
|
|
||||||
target: { tabId: 4, sessionId: "oopif-session" },
|
|
||||||
recordingDocumentId,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const dumped = JSON.stringify(result);
|
|
||||||
expect(dumped).not.toContain("user@example.com");
|
|
||||||
expect(dumped).not.toContain("attrs");
|
|
||||||
expect(dumped).not.toContain("domNodes");
|
|
||||||
expect(dumped).not.toContain("axNodes");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not project a page-controlled marker value as recording identity", () => {
|
|
||||||
const result = projectRecordSafeObservation({
|
|
||||||
rootFrameId: "root",
|
|
||||||
frameDocuments: [
|
|
||||||
{
|
|
||||||
frameId: "root",
|
|
||||||
target: { tabId: 4 },
|
|
||||||
contextScopeId: "scope-1",
|
|
||||||
axNodes: [],
|
|
||||||
domNodes: [
|
|
||||||
{
|
|
||||||
backendNodeId: 1,
|
|
||||||
parentBackendNodeId: null,
|
|
||||||
tag: "html",
|
|
||||||
attrs: { [RECORD_DOCUMENT_ATTRIBUTE]: "bsk:user@example.com" },
|
|
||||||
rect: null,
|
|
||||||
paintOrder: 0,
|
|
||||||
position: "static",
|
|
||||||
pointerEvents: "auto",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
rendered: { text: '@vom 1\nRootWebArea "Example"', refs: [], truncated: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.frames).toEqual([{ frameId: "root", target: { tabId: 4 } }]);
|
|
||||||
expect(JSON.stringify(result)).not.toContain("user@example.com");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
import type { VomScene } from "@browser-skill/vom";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type { CapturedNode } from "../capture";
|
|
||||||
import type { CapturedFrameDocument } from "../frame-capture";
|
|
||||||
import {
|
|
||||||
clusterRenderedSurfaces,
|
|
||||||
discoverRenderedSurfaces,
|
|
||||||
projectRenderedSurfaces,
|
|
||||||
} from "../rendered-surfaces";
|
|
||||||
import type { SemanticAxNode } from "../semantic-graph";
|
|
||||||
|
|
||||||
function domNode(backendNodeId: number, overrides: Partial<CapturedNode> = {}): CapturedNode {
|
|
||||||
return {
|
|
||||||
backendNodeId,
|
|
||||||
parentBackendNodeId: null,
|
|
||||||
frameId: "main",
|
|
||||||
ownerFrameBackendNodeId: null,
|
|
||||||
tag: "canvas",
|
|
||||||
attrs: {},
|
|
||||||
rect: { x: 10, y: 20, w: 800, h: 500 },
|
|
||||||
localRect: { x: 10, y: 20, w: 800, h: 500 },
|
|
||||||
paintOrder: 1,
|
|
||||||
position: "static",
|
|
||||||
pointerEvents: "auto",
|
|
||||||
rendered: true,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function document(
|
|
||||||
domNodes: CapturedNode[],
|
|
||||||
axNodes: SemanticAxNode[] = [],
|
|
||||||
): CapturedFrameDocument<SemanticAxNode> {
|
|
||||||
return {
|
|
||||||
frameId: domNodes[0]?.frameId ?? "main",
|
|
||||||
contextScopeId: domNodes[0]?.frameId ?? "main",
|
|
||||||
target: { tabId: 4 },
|
|
||||||
domNodes,
|
|
||||||
axNodes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("rendered surface discovery", () => {
|
|
||||||
it("finds prominent rendered canvases and excludes hidden descendants", () => {
|
|
||||||
const visible = domNode(2, { parentBackendNodeId: 1 });
|
|
||||||
const hidden = domNode(3, { parentBackendNodeId: 4 });
|
|
||||||
const surfaces = discoverRenderedSurfaces(
|
|
||||||
[
|
|
||||||
document([
|
|
||||||
domNode(1, { tag: "main", rect: null, localRect: null }),
|
|
||||||
visible,
|
|
||||||
domNode(4, { tag: "section", attrs: { "aria-hidden": "true" }, rect: null }),
|
|
||||||
hidden,
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
{ width: 1280, height: 720 },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(surfaces).toHaveLength(1);
|
|
||||||
expect(surfaces[0]).toMatchObject({ backendNodeId: 2 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps tiny canvases as individually addressable exact discoveries", () => {
|
|
||||||
const surfaces = discoverRenderedSurfaces(
|
|
||||||
[document([domNode(2, { rect: { x: 1, y: 1, w: 8, h: 8 } })])],
|
|
||||||
{ width: 1280, height: 720 },
|
|
||||||
);
|
|
||||||
expect(surfaces).toEqual([expect.objectContaining({ backendNodeId: 2 })]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a surface when the canvas only has partial native accessibility content", () => {
|
|
||||||
const axNodes = [
|
|
||||||
{
|
|
||||||
nodeId: "canvas",
|
|
||||||
backendDOMNodeId: 2,
|
|
||||||
role: { type: "role", value: "presentation" },
|
|
||||||
childIds: ["button"],
|
|
||||||
frameId: "main",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "button",
|
|
||||||
backendDOMNodeId: 3,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
frameId: "main",
|
|
||||||
},
|
|
||||||
] as SemanticAxNode[];
|
|
||||||
expect(
|
|
||||||
discoverRenderedSurfaces([document([domNode(2)], axNodes)], { width: 1280, height: 720 }),
|
|
||||||
).toEqual([expect.objectContaining({ backendNodeId: 2 })]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("discovers unnamed canvases without guessing labels from engineering attributes", () => {
|
|
||||||
const canvas = domNode(2, {
|
|
||||||
attrs: {
|
|
||||||
id: "must-not-be-a-label",
|
|
||||||
class: "must-not-be-a-label",
|
|
||||||
"data-testid": "must-not-be-a-label",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(discoverRenderedSurfaces([document([canvas])], { width: 1280, height: 720 })).toEqual([
|
|
||||||
expect.not.objectContaining({
|
|
||||||
label: expect.anything(),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats blank explicit Canvas names as unnamed", () => {
|
|
||||||
expect(
|
|
||||||
discoverRenderedSurfaces(
|
|
||||||
[document([domNode(2, { attrs: { "aria-label": " ", title: "\n\t" } })])],
|
|
||||||
{ width: 1280, height: 720 },
|
|
||||||
),
|
|
||||||
).toEqual([expect.not.objectContaining({ label: expect.anything() })]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes canvases inside BrowserSkill-owned DOM subtrees", () => {
|
|
||||||
const owner = domNode(1, { tag: "div", rect: null, localRect: null });
|
|
||||||
const canvas = domNode(2, { parentBackendNodeId: 1 });
|
|
||||||
|
|
||||||
expect(
|
|
||||||
discoverRenderedSurfaces(
|
|
||||||
[document([owner, canvas])],
|
|
||||||
{ width: 1280, height: 720 },
|
|
||||||
new Set([1]),
|
|
||||||
),
|
|
||||||
).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes canvases suppressed by a fully transparent ancestor", () => {
|
|
||||||
const owner = domNode(1, {
|
|
||||||
tag: "div",
|
|
||||||
rect: { x: 0, y: 0, w: 900, h: 600 },
|
|
||||||
visuallySuppressed: true,
|
|
||||||
});
|
|
||||||
const canvas = domNode(2, { parentBackendNodeId: 1 });
|
|
||||||
|
|
||||||
expect(
|
|
||||||
discoverRenderedSurfaces([document([owner, canvas])], { width: 1280, height: 720 }),
|
|
||||||
).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clips canvases to overflow ancestors and drops fully clipped canvases", () => {
|
|
||||||
const owner = domNode(1, {
|
|
||||||
tag: "div",
|
|
||||||
rect: { x: 10, y: 20, w: 100, h: 70 },
|
|
||||||
overflowX: "hidden",
|
|
||||||
overflowY: "hidden",
|
|
||||||
});
|
|
||||||
const partial = domNode(2, {
|
|
||||||
parentBackendNodeId: 1,
|
|
||||||
rect: { x: 55, y: 30, w: 140, h: 60 },
|
|
||||||
});
|
|
||||||
const fullyClipped = domNode(3, {
|
|
||||||
parentBackendNodeId: 1,
|
|
||||||
rect: { x: 140, y: 30, w: 80, h: 50 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
discoverRenderedSurfaces([document([owner, partial, fullyClipped])], {
|
|
||||||
width: 1280,
|
|
||||||
height: 720,
|
|
||||||
}),
|
|
||||||
).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
backendNodeId: 2,
|
|
||||||
visibleRect: { x: 55, y: 30, w: 55, h: 60 },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("rendered surface clustering and projection", () => {
|
|
||||||
it("collapses overlapping canvas layers and attaches them to the nearest retained parent", () => {
|
|
||||||
const body = domNode(1, { tag: "body", rect: null, localRect: null });
|
|
||||||
const surfaces = discoverRenderedSurfaces(
|
|
||||||
[
|
|
||||||
document([
|
|
||||||
body,
|
|
||||||
domNode(2, { parentBackendNodeId: 1, paintOrder: 1 }),
|
|
||||||
domNode(3, { parentBackendNodeId: 1, paintOrder: 2 }),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
{ width: 1280, height: 720 },
|
|
||||||
);
|
|
||||||
const groups = clusterRenderedSurfaces(surfaces);
|
|
||||||
const scene: VomScene = {
|
|
||||||
viewport: { width: 1280, height: 720 },
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 10,
|
|
||||||
parentId: null,
|
|
||||||
backendNodeId: 1,
|
|
||||||
frameId: "main",
|
|
||||||
tag: "body",
|
|
||||||
role: "RootWebArea",
|
|
||||||
rect: null,
|
|
||||||
paintOrder: 0,
|
|
||||||
position: "static",
|
|
||||||
pointerEvents: "auto",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(groups).toHaveLength(1);
|
|
||||||
expect(groups[0]?.members).toHaveLength(2);
|
|
||||||
expect(groups[0]?.label).toBeUndefined();
|
|
||||||
expect(
|
|
||||||
projectRenderedSurfaces(
|
|
||||||
scene,
|
|
||||||
[document([body, ...surfaces.map((s) => domNode(s.backendNodeId))])],
|
|
||||||
groups,
|
|
||||||
),
|
|
||||||
).toMatchObject({
|
|
||||||
visualSurfaces: [expect.objectContaining({ parentId: 10, backendNodeId: 3, memberCount: 2 })],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not merge a contained canvas with a larger independent canvas", () => {
|
|
||||||
const groups = clusterRenderedSurfaces([
|
|
||||||
...discoverRenderedSurfaces(
|
|
||||||
[
|
|
||||||
document([
|
|
||||||
domNode(1, { tag: "main", rect: null, localRect: null }),
|
|
||||||
domNode(2, { parentBackendNodeId: 1 }),
|
|
||||||
domNode(3, {
|
|
||||||
parentBackendNodeId: 1,
|
|
||||||
rect: { x: 30, y: 40, w: 200, h: 100 },
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
{ width: 1280, height: 720 },
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(groups).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not merge coincident canvases owned by different containers", () => {
|
|
||||||
const groups = clusterRenderedSurfaces(
|
|
||||||
discoverRenderedSurfaces(
|
|
||||||
[
|
|
||||||
document([
|
|
||||||
domNode(1, { tag: "section", rect: null, localRect: null }),
|
|
||||||
domNode(2, { tag: "section", rect: null, localRect: null }),
|
|
||||||
domNode(3, { parentBackendNodeId: 1 }),
|
|
||||||
domNode(4, { parentBackendNodeId: 2 }),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
{ width: 1280, height: 720 },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(groups).toHaveLength(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,782 +0,0 @@
|
|||||||
import { isVomReferenceNode, renderVom } from "@browser-skill/vom";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type { CapturedNode } from "../capture";
|
|
||||||
import type { FrameDocument } from "../frame-document";
|
|
||||||
import { buildSemanticVomScene, type SemanticAxNode } from "../semantic-graph";
|
|
||||||
|
|
||||||
function dom(
|
|
||||||
backendNodeId: number,
|
|
||||||
parentBackendNodeId: number | null,
|
|
||||||
tag: string,
|
|
||||||
attrs: Record<string, string> = {},
|
|
||||||
textContent?: string,
|
|
||||||
): CapturedNode {
|
|
||||||
return {
|
|
||||||
backendNodeId,
|
|
||||||
parentBackendNodeId,
|
|
||||||
tag,
|
|
||||||
attrs,
|
|
||||||
rect: { x: 0, y: 0, w: 100, h: 30 },
|
|
||||||
paintOrder: backendNodeId,
|
|
||||||
position: "static",
|
|
||||||
pointerEvents: "auto",
|
|
||||||
...(textContent ? { textContent } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function document(
|
|
||||||
frameId: string,
|
|
||||||
axNodes: SemanticAxNode[],
|
|
||||||
domNodes: CapturedNode[],
|
|
||||||
frame?: Pick<FrameDocument<SemanticAxNode>, "parentFrameId" | "ownerBackendNodeId">,
|
|
||||||
): FrameDocument<SemanticAxNode> {
|
|
||||||
return {
|
|
||||||
frameId,
|
|
||||||
contextScopeId: frameId,
|
|
||||||
target: { tabId: 1 },
|
|
||||||
axNodes,
|
|
||||||
domNodes,
|
|
||||||
...frame,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("semantic VOM graph", () => {
|
|
||||||
it("preserves backend-less AX structure without making it referenceable", () => {
|
|
||||||
const scene = buildSemanticVomScene({
|
|
||||||
viewport: { width: 800, height: 600 },
|
|
||||||
rootFrameId: "main",
|
|
||||||
documents: [
|
|
||||||
document(
|
|
||||||
"main",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
nodeId: "root",
|
|
||||||
backendDOMNodeId: 1,
|
|
||||||
role: { type: "role", value: "RootWebArea" },
|
|
||||||
childIds: ["toolbar"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "toolbar",
|
|
||||||
parentId: "root",
|
|
||||||
role: { type: "role", value: "toolbar" },
|
|
||||||
name: { type: "computedString", value: "Formatting" },
|
|
||||||
childIds: ["bold", "italic"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "bold",
|
|
||||||
parentId: "toolbar",
|
|
||||||
backendDOMNodeId: 2,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
name: { type: "computedString", value: "Bold" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "italic",
|
|
||||||
parentId: "toolbar",
|
|
||||||
backendDOMNodeId: 3,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
name: { type: "computedString", value: "Italic" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[dom(1, null, "body"), dom(2, 1, "button"), dom(3, 1, "button")],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const toolbar = scene.nodes.find((node) => node.role === "toolbar");
|
|
||||||
const buttons = scene.nodes.filter((node) => node.role === "button");
|
|
||||||
expect(toolbar).toEqual(expect.objectContaining({ name: "Formatting", referenceable: false }));
|
|
||||||
expect(buttons.map((node) => node.parentId)).toEqual([toolbar?.id, toolbar?.id]);
|
|
||||||
expect(toolbar && isVomReferenceNode(toolbar)).toBe(false);
|
|
||||||
expect(renderVom(scene).refs.map((ref) => ref.backendNodeId)).toEqual([2, 3]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves missing AX names through one DOM naming pipeline", () => {
|
|
||||||
const scene = buildSemanticVomScene({
|
|
||||||
viewport: { width: 800, height: 600 },
|
|
||||||
rootFrameId: "main",
|
|
||||||
documents: [
|
|
||||||
document(
|
|
||||||
"main",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
nodeId: "root",
|
|
||||||
backendDOMNodeId: 1,
|
|
||||||
role: { type: "role", value: "RootWebArea" },
|
|
||||||
childIds: ["labelled", "titled", "text"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "labelled",
|
|
||||||
parentId: "root",
|
|
||||||
backendDOMNodeId: 2,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "titled",
|
|
||||||
parentId: "root",
|
|
||||||
backendDOMNodeId: 3,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "text",
|
|
||||||
parentId: "root",
|
|
||||||
backendDOMNodeId: 4,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
dom(1, null, "body"),
|
|
||||||
dom(2, 1, "button", { "aria-label": "Add row" }),
|
|
||||||
dom(3, 1, "button", { title: "Settings" }),
|
|
||||||
dom(4, 1, "button"),
|
|
||||||
dom(5, 4, "span", {}, "Save"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(scene.nodes.filter((node) => node.role === "button").map((node) => node.name)).toEqual([
|
|
||||||
"Add row",
|
|
||||||
"Settings",
|
|
||||||
"Save",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("aggregates AX text at the semantic text frontier", () => {
|
|
||||||
const scene = buildSemanticVomScene({
|
|
||||||
viewport: { width: 800, height: 600 },
|
|
||||||
rootFrameId: "main",
|
|
||||||
documents: [
|
|
||||||
document(
|
|
||||||
"main",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
nodeId: "root",
|
|
||||||
backendDOMNodeId: 1,
|
|
||||||
role: { type: "role", value: "RootWebArea" },
|
|
||||||
childIds: ["covered", "inline-only", "repeated", "labelled"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "covered",
|
|
||||||
parentId: "root",
|
|
||||||
backendDOMNodeId: 2,
|
|
||||||
role: { type: "role", value: "paragraph" },
|
|
||||||
childIds: ["covered-static"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "covered-static",
|
|
||||||
parentId: "covered",
|
|
||||||
role: { type: "role", value: "StaticText" },
|
|
||||||
name: { type: "computedString", value: "Covered text" },
|
|
||||||
childIds: ["covered-inline"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "covered-inline",
|
|
||||||
parentId: "covered-static",
|
|
||||||
role: { type: "role", value: "InlineTextBox" },
|
|
||||||
name: { type: "computedString", value: "Covered text" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "inline-only",
|
|
||||||
parentId: "root",
|
|
||||||
backendDOMNodeId: 3,
|
|
||||||
role: { type: "role", value: "paragraph" },
|
|
||||||
childIds: ["inline-leaf"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "inline-leaf",
|
|
||||||
parentId: "inline-only",
|
|
||||||
role: { type: "role", value: "InlineTextBox" },
|
|
||||||
name: { type: "computedString", value: "Inline fallback" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "repeated",
|
|
||||||
parentId: "root",
|
|
||||||
backendDOMNodeId: 4,
|
|
||||||
role: { type: "role", value: "paragraph" },
|
|
||||||
childIds: ["first-go", "second-go"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "first-go",
|
|
||||||
parentId: "repeated",
|
|
||||||
role: { type: "role", value: "StaticText" },
|
|
||||||
name: { type: "computedString", value: "Go" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "second-go",
|
|
||||||
parentId: "repeated",
|
|
||||||
role: { type: "role", value: "StaticText" },
|
|
||||||
name: { type: "computedString", value: "Go" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "labelled",
|
|
||||||
parentId: "root",
|
|
||||||
backendDOMNodeId: 5,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
childIds: ["visible-label"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "visible-label",
|
|
||||||
parentId: "labelled",
|
|
||||||
role: { type: "role", value: "StaticText" },
|
|
||||||
name: { type: "computedString", value: "Visible text" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
dom(1, null, "body"),
|
|
||||||
dom(2, 1, "p"),
|
|
||||||
dom(3, 1, "p"),
|
|
||||||
dom(4, 1, "p"),
|
|
||||||
dom(5, 1, "button", { "aria-label": "Explicit action" }),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(scene.nodes.find((node) => node.backendNodeId === 2)?.name).toBe("Covered text");
|
|
||||||
expect(scene.nodes.find((node) => node.backendNodeId === 3)?.name).toBe("Inline fallback");
|
|
||||||
expect(scene.nodes.find((node) => node.backendNodeId === 4)?.name).toBe("Go Go");
|
|
||||||
expect(scene.nodes.find((node) => node.backendNodeId === 5)?.name).toBe("Explicit action");
|
|
||||||
expect(renderVom(scene).text).not.toContain("Covered text Covered text");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses stable identifiers only as a final name fallback", () => {
|
|
||||||
const base = document(
|
|
||||||
"main",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
nodeId: "root",
|
|
||||||
backendDOMNodeId: 1,
|
|
||||||
role: { type: "role", value: "RootWebArea" },
|
|
||||||
childIds: ["fallback", "labelled", "container"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "fallback",
|
|
||||||
parentId: "root",
|
|
||||||
backendDOMNodeId: 2,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "labelled",
|
|
||||||
parentId: "root",
|
|
||||||
backendDOMNodeId: 3,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
name: { type: "computedString", value: "Localized name" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "container",
|
|
||||||
parentId: "root",
|
|
||||||
backendDOMNodeId: 4,
|
|
||||||
role: { type: "role", value: "generic" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
dom(1, null, "body"),
|
|
||||||
dom(2, 1, "button", { "data-test-id": "toolInsertRecord" }),
|
|
||||||
dom(3, 1, "button", { id: "toolIgnoredFallback" }),
|
|
||||||
dom(4, 1, "div", { id: "toolbarContainer" }),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
const staticScene = buildSemanticVomScene({
|
|
||||||
viewport: { width: 800, height: 600 },
|
|
||||||
rootFrameId: "main",
|
|
||||||
documents: [base],
|
|
||||||
});
|
|
||||||
const enrichedScene = buildSemanticVomScene({
|
|
||||||
viewport: { width: 800, height: 600 },
|
|
||||||
rootFrameId: "main",
|
|
||||||
documents: [base],
|
|
||||||
supplementalNames: new Map([["main\u00002", "插入行"]]),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(staticScene.nodes.find((node) => node.backendNodeId === 2)?.name).toBe("Insert record");
|
|
||||||
expect(staticScene.nodes.find((node) => node.backendNodeId === 3)?.name).toBe("Localized name");
|
|
||||||
expect(staticScene.nodes.find((node) => node.backendNodeId === 4)?.name).toBeUndefined();
|
|
||||||
expect(enrichedScene.nodes.find((node) => node.backendNodeId === 2)?.name).toBe("插入行");
|
|
||||||
expect(enrichedScene.nodes.find((node) => node.backendNodeId === 3)?.name).toBe(
|
|
||||||
"Localized name",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("isolates equal backend node ids in sibling frames", () => {
|
|
||||||
const scene = buildSemanticVomScene({
|
|
||||||
viewport: { width: 1000, height: 800 },
|
|
||||||
rootFrameId: "main",
|
|
||||||
documents: [
|
|
||||||
document(
|
|
||||||
"main",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
nodeId: "root",
|
|
||||||
backendDOMNodeId: 1,
|
|
||||||
role: { type: "role", value: "RootWebArea" },
|
|
||||||
childIds: ["owner-a", "owner-b"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "owner-a",
|
|
||||||
parentId: "root",
|
|
||||||
backendDOMNodeId: 10,
|
|
||||||
role: { type: "role", value: "Iframe" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "owner-b",
|
|
||||||
parentId: "root",
|
|
||||||
backendDOMNodeId: 20,
|
|
||||||
role: { type: "role", value: "Iframe" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[dom(1, null, "body"), dom(10, 1, "iframe"), dom(20, 1, "iframe")],
|
|
||||||
),
|
|
||||||
document(
|
|
||||||
"frame-a",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
nodeId: "button-a",
|
|
||||||
backendDOMNodeId: 5,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
childIds: ["text-a"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "text-a",
|
|
||||||
parentId: "button-a",
|
|
||||||
role: { type: "role", value: "StaticText" },
|
|
||||||
name: { type: "computedString", value: "Accept" },
|
|
||||||
childIds: ["inline-a"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "inline-a",
|
|
||||||
parentId: "text-a",
|
|
||||||
role: { type: "role", value: "InlineTextBox" },
|
|
||||||
name: { type: "computedString", value: "Accept" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[dom(5, null, "button")],
|
|
||||||
{ parentFrameId: "main", ownerBackendNodeId: 10 },
|
|
||||||
),
|
|
||||||
document(
|
|
||||||
"frame-b",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
nodeId: "button-b",
|
|
||||||
backendDOMNodeId: 5,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
childIds: ["text-b"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "text-b",
|
|
||||||
parentId: "button-b",
|
|
||||||
role: { type: "role", value: "StaticText" },
|
|
||||||
name: { type: "computedString", value: "Decline" },
|
|
||||||
childIds: ["inline-b"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "inline-b",
|
|
||||||
parentId: "text-b",
|
|
||||||
role: { type: "role", value: "InlineTextBox" },
|
|
||||||
name: { type: "computedString", value: "Decline" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[dom(5, null, "button")],
|
|
||||||
{ parentFrameId: "main", ownerBackendNodeId: 20 },
|
|
||||||
),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const frameButtons = scene.nodes.filter((node) => node.backendNodeId === 5);
|
|
||||||
const owners = new Map(
|
|
||||||
scene.nodes
|
|
||||||
.filter((node) => node.backendNodeId === 10 || node.backendNodeId === 20)
|
|
||||||
.map((node) => [node.backendNodeId, node.id]),
|
|
||||||
);
|
|
||||||
expect(frameButtons.map((node) => node.frameId)).toEqual(["frame-a", "frame-b"]);
|
|
||||||
expect(frameButtons.map((node) => node.name)).toEqual(["Accept", "Decline"]);
|
|
||||||
expect(frameButtons.map((node) => node.parentId)).toEqual([owners.get(10), owners.get(20)]);
|
|
||||||
expect(renderVom(scene).refs.map((ref) => [ref.frameId, ref.backendNodeId])).toEqual([
|
|
||||||
["frame-a", 5],
|
|
||||||
["frame-b", 5],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requires render evidence before promoting DOM fallback semantics", () => {
|
|
||||||
const hiddenOwner = {
|
|
||||||
...dom(10, 1, "iframe", { hidden: "" }),
|
|
||||||
rect: null,
|
|
||||||
localRect: null,
|
|
||||||
rendered: false,
|
|
||||||
};
|
|
||||||
const hiddenButton = {
|
|
||||||
...dom(2, 1, "button", { "aria-label": "Hidden action" }),
|
|
||||||
rendered: false,
|
|
||||||
};
|
|
||||||
const scene = buildSemanticVomScene({
|
|
||||||
viewport: { width: 800, height: 600 },
|
|
||||||
rootFrameId: "main",
|
|
||||||
documents: [
|
|
||||||
document(
|
|
||||||
"main",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
nodeId: "root",
|
|
||||||
backendDOMNodeId: 1,
|
|
||||||
role: { type: "role", value: "RootWebArea" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[dom(1, null, "body"), hiddenButton, hiddenOwner],
|
|
||||||
),
|
|
||||||
document("hidden-frame", [], [dom(20, null, "button", {}, "Frame action")], {
|
|
||||||
parentFrameId: "main",
|
|
||||||
ownerBackendNodeId: 10,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(scene.nodes.some((node) => node.backendNodeId === 2)).toBe(false);
|
|
||||||
expect(scene.nodes.some((node) => node.backendNodeId === 10)).toBe(false);
|
|
||||||
expect(scene.nodes.some((node) => node.frameId === "hidden-frame")).toBe(false);
|
|
||||||
expect(renderVom(scene).refs).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("retains rendered DOM fallback controls when AX semantics are unavailable", () => {
|
|
||||||
const scene = buildSemanticVomScene({
|
|
||||||
viewport: { width: 800, height: 600 },
|
|
||||||
rootFrameId: "main",
|
|
||||||
documents: [
|
|
||||||
document(
|
|
||||||
"main",
|
|
||||||
[],
|
|
||||||
[dom(1, null, "body"), { ...dom(2, 1, "button", {}, "Save"), rendered: true }],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(renderVom(scene).refs).toEqual([
|
|
||||||
expect.objectContaining({ backendNodeId: 2, role: "button", name: "Save" }),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lets explicit DOM structure override ignored AX metadata", () => {
|
|
||||||
const scene = buildSemanticVomScene({
|
|
||||||
viewport: { width: 800, height: 600 },
|
|
||||||
rootFrameId: "main",
|
|
||||||
documents: [
|
|
||||||
document(
|
|
||||||
"main",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
nodeId: "root",
|
|
||||||
backendDOMNodeId: 1,
|
|
||||||
role: { type: "role", value: "RootWebArea" },
|
|
||||||
childIds: ["toolbar"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "toolbar",
|
|
||||||
parentId: "root",
|
|
||||||
backendDOMNodeId: 2,
|
|
||||||
ignored: true,
|
|
||||||
role: { type: "role", value: "generic" },
|
|
||||||
childIds: ["wrapper"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "wrapper",
|
|
||||||
parentId: "toolbar",
|
|
||||||
backendDOMNodeId: 5,
|
|
||||||
role: { type: "role", value: "generic" },
|
|
||||||
childIds: ["bold", "italic"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "bold",
|
|
||||||
parentId: "wrapper",
|
|
||||||
backendDOMNodeId: 3,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
name: { type: "computedString", value: "Bold" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "italic",
|
|
||||||
parentId: "wrapper",
|
|
||||||
backendDOMNodeId: 4,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
name: { type: "computedString", value: "Italic" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
dom(1, null, "body"),
|
|
||||||
dom(2, 1, "div", { role: "toolbar" }),
|
|
||||||
dom(5, 2, "div"),
|
|
||||||
dom(3, 5, "button"),
|
|
||||||
dom(4, 5, "button"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const toolbar = scene.nodes.find((node) => node.backendNodeId === 2);
|
|
||||||
const buttons = scene.nodes.filter((node) => [3, 4].includes(node.backendNodeId ?? -1));
|
|
||||||
expect(toolbar).toEqual(expect.objectContaining({ role: "toolbar" }));
|
|
||||||
expect(toolbar && isVomReferenceNode(toolbar)).toBe(false);
|
|
||||||
expect(scene.nodes.some((node) => node.role === "group")).toBe(false);
|
|
||||||
expect(buttons.map((node) => node.parentId)).toEqual([toolbar?.id, toolbar?.id]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("infers an unnamed group only from independent interaction branches", () => {
|
|
||||||
const scene = buildSemanticVomScene({
|
|
||||||
viewport: { width: 800, height: 600 },
|
|
||||||
rootFrameId: "main",
|
|
||||||
documents: [
|
|
||||||
document(
|
|
||||||
"main",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
nodeId: "root",
|
|
||||||
backendDOMNodeId: 1,
|
|
||||||
role: { type: "role", value: "RootWebArea" },
|
|
||||||
childIds: ["wrapper"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "wrapper",
|
|
||||||
parentId: "root",
|
|
||||||
backendDOMNodeId: 2,
|
|
||||||
role: { type: "role", value: "generic" },
|
|
||||||
childIds: ["first", "second"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "first",
|
|
||||||
parentId: "wrapper",
|
|
||||||
backendDOMNodeId: 3,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
name: { type: "computedString", value: "First" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "second",
|
|
||||||
parentId: "wrapper",
|
|
||||||
backendDOMNodeId: 4,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
name: { type: "computedString", value: "Second" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[dom(1, null, "body"), dom(2, 1, "div"), dom(3, 2, "button"), dom(4, 2, "button")],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const group = scene.nodes.find((node) => node.backendNodeId === 2);
|
|
||||||
const buttons = scene.nodes.filter((node) => [3, 4].includes(node.backendNodeId ?? -1));
|
|
||||||
expect(group).toEqual(expect.objectContaining({ role: "group" }));
|
|
||||||
expect(group?.name).toBeUndefined();
|
|
||||||
expect(group && isVomReferenceNode(group)).toBe(false);
|
|
||||||
expect(buttons.map((node) => node.parentId)).toEqual([group?.id, group?.id]);
|
|
||||||
|
|
||||||
const rendered = renderVom(scene);
|
|
||||||
const lines = rendered.text.split("\n");
|
|
||||||
const groupLine = lines.findIndex((line) => line.trim() === "group");
|
|
||||||
expect(groupLine).toBeGreaterThanOrEqual(0);
|
|
||||||
for (const ref of rendered.refs) {
|
|
||||||
expect(ref.line).toBeGreaterThan(groupLine);
|
|
||||||
expect(lines[ref.line]).toMatch(/^\s+@e\d+ button/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never promotes a form value into its semantic name through supplemental evidence", () => {
|
|
||||||
const secret = "user@example.com";
|
|
||||||
const input = {
|
|
||||||
...dom(2, 1, "input", { type: "email" }),
|
|
||||||
formValue: secret,
|
|
||||||
formDefaultValue: "",
|
|
||||||
formState: "filled" as const,
|
|
||||||
};
|
|
||||||
const scene = buildSemanticVomScene({
|
|
||||||
viewport: { width: 800, height: 600 },
|
|
||||||
rootFrameId: "main",
|
|
||||||
supplementalNames: new Map([["main\u00002", secret]]),
|
|
||||||
identifierFallback: false,
|
|
||||||
documents: [
|
|
||||||
document(
|
|
||||||
"main",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
nodeId: "root",
|
|
||||||
backendDOMNodeId: 1,
|
|
||||||
role: { type: "role", value: "RootWebArea" },
|
|
||||||
childIds: ["email"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "email",
|
|
||||||
parentId: "root",
|
|
||||||
backendDOMNodeId: 2,
|
|
||||||
role: { type: "role", value: "textbox" },
|
|
||||||
value: { value: secret },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[dom(1, null, "body"), input],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const field = scene.nodes.find((node) => node.backendNodeId === 2);
|
|
||||||
expect(field?.name).toBeUndefined();
|
|
||||||
const rendered = renderVom(scene, { redactValues: true });
|
|
||||||
expect(rendered.text).toContain("•••");
|
|
||||||
expect(JSON.stringify(rendered)).not.toContain(secret);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps uncertain single-branch wrappers flat", () => {
|
|
||||||
const scene = buildSemanticVomScene({
|
|
||||||
viewport: { width: 800, height: 600 },
|
|
||||||
rootFrameId: "main",
|
|
||||||
documents: [
|
|
||||||
document(
|
|
||||||
"main",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
nodeId: "root",
|
|
||||||
backendDOMNodeId: 1,
|
|
||||||
role: { type: "role", value: "RootWebArea" },
|
|
||||||
childIds: ["wrapper"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "wrapper",
|
|
||||||
parentId: "root",
|
|
||||||
backendDOMNodeId: 2,
|
|
||||||
role: { type: "role", value: "generic" },
|
|
||||||
childIds: ["button"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "button",
|
|
||||||
parentId: "wrapper",
|
|
||||||
backendDOMNodeId: 3,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
name: { type: "computedString", value: "Only action" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[dom(1, null, "body"), dom(2, 1, "div"), dom(3, 2, "button")],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(scene.nodes.some((node) => node.role === "group")).toBe(false);
|
|
||||||
expect(scene.nodes.find((node) => node.backendNodeId === 3)?.parentId).toBe(
|
|
||||||
scene.nodes.find((node) => node.backendNodeId === 1)?.id,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not infer across mixed content boundaries", () => {
|
|
||||||
const scene = buildSemanticVomScene({
|
|
||||||
viewport: { width: 800, height: 600 },
|
|
||||||
rootFrameId: "main",
|
|
||||||
documents: [
|
|
||||||
document(
|
|
||||||
"main",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
nodeId: "root",
|
|
||||||
backendDOMNodeId: 1,
|
|
||||||
role: { type: "role", value: "RootWebArea" },
|
|
||||||
childIds: ["wrapper"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "wrapper",
|
|
||||||
parentId: "root",
|
|
||||||
backendDOMNodeId: 2,
|
|
||||||
role: { type: "role", value: "generic" },
|
|
||||||
childIds: ["first", "second", "paragraph"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "first",
|
|
||||||
parentId: "wrapper",
|
|
||||||
backendDOMNodeId: 3,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
name: { type: "computedString", value: "First" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "second",
|
|
||||||
parentId: "wrapper",
|
|
||||||
backendDOMNodeId: 4,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
name: { type: "computedString", value: "Second" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "paragraph",
|
|
||||||
parentId: "wrapper",
|
|
||||||
backendDOMNodeId: 5,
|
|
||||||
role: { type: "role", value: "paragraph" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
dom(1, null, "body"),
|
|
||||||
dom(2, 1, "div"),
|
|
||||||
dom(3, 2, "button"),
|
|
||||||
dom(4, 2, "button"),
|
|
||||||
dom(5, 2, "p", {}, "Description"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(scene.nodes.some((node) => node.role === "group")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps only the innermost reliable inferred group", () => {
|
|
||||||
const scene = buildSemanticVomScene({
|
|
||||||
viewport: { width: 800, height: 600 },
|
|
||||||
rootFrameId: "main",
|
|
||||||
documents: [
|
|
||||||
document(
|
|
||||||
"main",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
nodeId: "root",
|
|
||||||
backendDOMNodeId: 1,
|
|
||||||
role: { type: "role", value: "RootWebArea" },
|
|
||||||
childIds: ["outer"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "outer",
|
|
||||||
parentId: "root",
|
|
||||||
backendDOMNodeId: 2,
|
|
||||||
role: { type: "role", value: "generic" },
|
|
||||||
childIds: ["inner", "third"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "inner",
|
|
||||||
parentId: "outer",
|
|
||||||
backendDOMNodeId: 3,
|
|
||||||
role: { type: "role", value: "generic" },
|
|
||||||
childIds: ["first", "second"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "first",
|
|
||||||
parentId: "inner",
|
|
||||||
backendDOMNodeId: 4,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
name: { type: "computedString", value: "First" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "second",
|
|
||||||
parentId: "inner",
|
|
||||||
backendDOMNodeId: 5,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
name: { type: "computedString", value: "Second" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeId: "third",
|
|
||||||
parentId: "outer",
|
|
||||||
backendDOMNodeId: 6,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
name: { type: "computedString", value: "Third" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
dom(1, null, "body"),
|
|
||||||
dom(2, 1, "div"),
|
|
||||||
dom(3, 2, "div"),
|
|
||||||
dom(4, 3, "button"),
|
|
||||||
dom(5, 3, "button"),
|
|
||||||
dom(6, 2, "button"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const groups = scene.nodes.filter((node) => node.role === "group");
|
|
||||||
expect(groups.map((node) => node.backendNodeId)).toEqual([3]);
|
|
||||||
expect(scene.nodes.find((node) => node.backendNodeId === 6)?.parentId).not.toBe(groups[0]?.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,25 +1,16 @@
|
|||||||
// CDP capture adapter: DOMSnapshot.captureSnapshot + Page.getLayoutMetrics
|
// CDP capture adapter: DOMSnapshot.captureSnapshot + Page.getLayoutMetrics
|
||||||
// → CapturedNode[] + Viewport. This is the ONLY VOM module that touches
|
// → CapturedNode[] + Viewport. This is the ONLY VOM module that touches
|
||||||
// raw CDP. The captureSnapshot reply is columnar (parallel arrays + a
|
// raw CDP. The captureSnapshot reply is columnar (parallel arrays + a
|
||||||
// shared string table); requested computed styles are decoded through
|
// shared string table); we request exactly three computed styles so the
|
||||||
// STYLE_COL so capture and visibility policy share one explicit contract.
|
// `styles` columns are [position, pointer-events, cursor] in that order.
|
||||||
|
|
||||||
import type { Rect, Viewport } from "@browser-skill/vom";
|
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 { isOverlayHostNode, OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge";
|
||||||
import { childFrameProjection, type GeometryProjection, projectRectToViewport } from "../geometry";
|
import { childFrameProjection, type GeometryProjection, projectRectToViewport } from "../geometry";
|
||||||
import type { CdpRunner } from "../shared";
|
import type { CdpRunner } from "../shared";
|
||||||
import { clearHover, waitForHover } from "./hover-perception";
|
|
||||||
|
|
||||||
const REQUESTED_STYLES = [
|
const REQUESTED_STYLES = ["position", "pointer-events", "cursor"] as const;
|
||||||
"position",
|
|
||||||
"pointer-events",
|
|
||||||
"cursor",
|
|
||||||
"visibility",
|
|
||||||
"opacity",
|
|
||||||
"overflow-x",
|
|
||||||
"overflow-y",
|
|
||||||
] as const;
|
|
||||||
const STYLE_COL = Object.fromEntries(
|
const STYLE_COL = Object.fromEntries(
|
||||||
REQUESTED_STYLES.map((name, index) => [name, index]),
|
REQUESTED_STYLES.map((name, index) => [name, index]),
|
||||||
) as Record<(typeof REQUESTED_STYLES)[number], number>;
|
) as Record<(typeof REQUESTED_STYLES)[number], number>;
|
||||||
@@ -47,16 +38,6 @@ export interface CapturedNode {
|
|||||||
* `textContent`: the live parser always sets it, hand-built fixtures may not.
|
* `textContent`: the live parser always sets it, hand-built fixtures may not.
|
||||||
*/
|
*/
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
overflowX?: string;
|
|
||||||
overflowY?: string;
|
|
||||||
/** True when this node or one of its DOM ancestors makes the subtree fully transparent/hidden. */
|
|
||||||
visuallySuppressed?: boolean;
|
|
||||||
/**
|
|
||||||
* 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;
|
textContent?: string;
|
||||||
formState?: "empty" | "filled" | "default";
|
formState?: "empty" | "filled" | "default";
|
||||||
formValue?: string;
|
formValue?: string;
|
||||||
@@ -479,6 +460,36 @@ function hoverStateExpression(): string {
|
|||||||
})()`;
|
})()`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function wait(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
resolve();
|
||||||
|
}, ms);
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
reject(captureAbortError());
|
||||||
|
};
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearHover(cdp: CdpRunner, tabId: number): Promise<void> {
|
||||||
|
await cdp
|
||||||
|
.send(tabId, "Input.dispatchMouseEvent", {
|
||||||
|
type: "mouseMoved",
|
||||||
|
x: -10,
|
||||||
|
y: -10,
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
cdp.send(tabId, "Input.dispatchMouseEvent", { type: "mouseMoved", x: 0, y: 0 }).catch(() => {
|
||||||
|
// best effort
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function capturedText(node: CapturedNode): string | undefined {
|
function capturedText(node: CapturedNode): string | undefined {
|
||||||
const value =
|
const value =
|
||||||
node.attrs["aria-label"] ?? node.attrs.title ?? node.attrs.alt ?? node.textContent ?? "";
|
node.attrs["aria-label"] ?? node.attrs.title ?? node.attrs.alt ?? node.textContent ?? "";
|
||||||
@@ -658,7 +669,7 @@ async function probeHoverSurfaces(
|
|||||||
try {
|
try {
|
||||||
await clearHover(cdp, tabId);
|
await clearHover(cdp, tabId);
|
||||||
throwIfAborted(options.signal);
|
throwIfAborted(options.signal);
|
||||||
await waitForHover(HOVER_SETTLE_MS, options.signal);
|
await wait(HOVER_SETTLE_MS, options.signal);
|
||||||
const baselineReply = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
const baselineReply = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
||||||
expression: hoverStateExpression(),
|
expression: hoverStateExpression(),
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
@@ -673,7 +684,7 @@ async function probeHoverSurfaces(
|
|||||||
y: candidate.y,
|
y: candidate.y,
|
||||||
});
|
});
|
||||||
throwIfAborted(options.signal);
|
throwIfAborted(options.signal);
|
||||||
await waitForHover(HOVER_SETTLE_MS, options.signal);
|
await wait(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,
|
||||||
@@ -806,10 +817,6 @@ function parseDocumentNodes(
|
|||||||
const posCol = STYLE_COL.position;
|
const posCol = STYLE_COL.position;
|
||||||
const peCol = STYLE_COL["pointer-events"];
|
const peCol = STYLE_COL["pointer-events"];
|
||||||
const cursorCol = STYLE_COL.cursor;
|
const cursorCol = STYLE_COL.cursor;
|
||||||
const visibilityCol = STYLE_COL.visibility;
|
|
||||||
const opacityCol = STYLE_COL.opacity;
|
|
||||||
const overflowXCol = STYLE_COL["overflow-x"];
|
|
||||||
const overflowYCol = STYLE_COL["overflow-y"];
|
|
||||||
const inputValues = sparseIndexMap(dn.inputValue);
|
const inputValues = sparseIndexMap(dn.inputValue);
|
||||||
const textValues = sparseIndexMap(dn.textValue);
|
const textValues = sparseIndexMap(dn.textValue);
|
||||||
const checkedInputs = new Set(dn.inputChecked?.index ?? []);
|
const checkedInputs = new Set(dn.inputChecked?.index ?? []);
|
||||||
@@ -835,7 +842,6 @@ function parseDocumentNodes(
|
|||||||
|
|
||||||
// Build CapturedNodes.
|
// Build CapturedNodes.
|
||||||
const nodes: CapturedNode[] = [];
|
const nodes: CapturedNode[] = [];
|
||||||
const visuallySuppressedByNodeIndex = new Array<boolean>(count).fill(false);
|
|
||||||
for (let n = 0; n < count; n++) {
|
for (let n = 0; n < count; n++) {
|
||||||
if (excluded[n]) continue;
|
if (excluded[n]) continue;
|
||||||
const backendNodeId = dn.backendNodeId[n];
|
const backendNodeId = dn.backendNodeId[n];
|
||||||
@@ -855,11 +861,6 @@ function parseDocumentNodes(
|
|||||||
let position = "static";
|
let position = "static";
|
||||||
let pointerEvents = "auto";
|
let pointerEvents = "auto";
|
||||||
let cursor = "auto";
|
let cursor = "auto";
|
||||||
let overflowX = "visible";
|
|
||||||
let overflowY = "visible";
|
|
||||||
let rendered = false;
|
|
||||||
let visuallySuppressed =
|
|
||||||
parentIdx >= 0 ? visuallySuppressedByNodeIndex[parentIdx] === true : false;
|
|
||||||
const li = layoutByNode.get(n);
|
const li = layoutByNode.get(n);
|
||||||
if (li !== undefined) {
|
if (li !== undefined) {
|
||||||
const b = dl?.bounds?.[li];
|
const b = dl?.bounds?.[li];
|
||||||
@@ -880,21 +881,7 @@ function parseDocumentNodes(
|
|||||||
position = str(strings, styleRow[posCol]) || "static";
|
position = str(strings, styleRow[posCol]) || "static";
|
||||||
pointerEvents = str(strings, styleRow[peCol]) || "auto";
|
pointerEvents = str(strings, styleRow[peCol]) || "auto";
|
||||||
cursor = str(strings, styleRow[cursorCol]) || "auto";
|
cursor = str(strings, styleRow[cursorCol]) || "auto";
|
||||||
const visibility = str(strings, styleRow[visibilityCol]) || "visible";
|
|
||||||
const opacity = str(strings, styleRow[opacityCol]) || "1";
|
|
||||||
overflowX = str(strings, styleRow[overflowXCol]) || "visible";
|
|
||||||
overflowY = str(strings, styleRow[overflowYCol]) || "visible";
|
|
||||||
visuallySuppressed ||=
|
|
||||||
visibility === "hidden" ||
|
|
||||||
visibility === "collapse" ||
|
|
||||||
(Number.parseFloat(opacity) || 0) <= 0;
|
|
||||||
rendered =
|
|
||||||
localRect !== null &&
|
|
||||||
visibility !== "hidden" &&
|
|
||||||
visibility !== "collapse" &&
|
|
||||||
(Number.parseFloat(opacity) || 0) > 0;
|
|
||||||
}
|
}
|
||||||
visuallySuppressedByNodeIndex[n] = visuallySuppressed;
|
|
||||||
|
|
||||||
// Skip non-element nodes (#text, #cdata-section, etc.) — they carry no
|
// Skip non-element nodes (#text, #cdata-section, etc.) — they carry no
|
||||||
// geometry and are never queried by callers.
|
// geometry and are never queried by callers.
|
||||||
@@ -929,10 +916,6 @@ function parseDocumentNodes(
|
|||||||
position,
|
position,
|
||||||
pointerEvents,
|
pointerEvents,
|
||||||
cursor,
|
cursor,
|
||||||
overflowX,
|
|
||||||
overflowY,
|
|
||||||
visuallySuppressed,
|
|
||||||
rendered,
|
|
||||||
textContent,
|
textContent,
|
||||||
...(formValue !== undefined ? { formValue } : {}),
|
...(formValue !== undefined ? { formValue } : {}),
|
||||||
...(tag === "input" || tag === "textarea"
|
...(tag === "input" || tag === "textarea"
|
||||||
@@ -1037,13 +1020,7 @@ export async function captureViewModel(
|
|||||||
options: CaptureViewModelOptions = {},
|
options: CaptureViewModelOptions = {},
|
||||||
): Promise<CapturedViewModel> {
|
): Promise<CapturedViewModel> {
|
||||||
throwIfAborted(options.signal);
|
throwIfAborted(options.signal);
|
||||||
let metrics: LayoutMetricsReply = {};
|
const metrics = await cdp.send<LayoutMetricsReply>(tabId, "Page.getLayoutMetrics", {});
|
||||||
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);
|
throwIfAborted(options.signal);
|
||||||
const dpr = devicePixelRatio(metrics);
|
const dpr = devicePixelRatio(metrics);
|
||||||
const vpSrc = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
|
const vpSrc = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
|
||||||
|
|||||||
@@ -33,19 +33,25 @@ async function discoverFrameGraph(cdp: CdpRunner, tabId: number): Promise<CdpFra
|
|||||||
|
|
||||||
function transformFrameNodes(
|
function transformFrameNodes(
|
||||||
nodes: CapturedNode[],
|
nodes: CapturedNode[],
|
||||||
projection: GeometryProjection | null,
|
projection: GeometryProjection,
|
||||||
): CapturedNode[] {
|
): CapturedNode[] {
|
||||||
return nodes.map((node) => ({
|
return nodes.map((node) => ({
|
||||||
...node,
|
...node,
|
||||||
rect: projection
|
rect: (() => {
|
||||||
? (() => {
|
|
||||||
const bounds = projectRectToViewport(node.rect, projection);
|
const bounds = projectRectToViewport(node.rect, projection);
|
||||||
return bounds ? { x: bounds.x, y: bounds.y, w: bounds.width, h: bounds.height } : null;
|
return bounds ? { x: bounds.x, y: bounds.y, w: bounds.width, h: bounds.height } : null;
|
||||||
})()
|
})(),
|
||||||
: null,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function frameOwnerNode(captured: CapturedViewModel, frame: CdpFrame): CapturedNode | undefined {
|
||||||
|
if (frame.ownerBackendNodeId === undefined) return undefined;
|
||||||
|
const parentNodes = frame.parentFrameId
|
||||||
|
? captured.frameNodes?.get(frame.parentFrameId)
|
||||||
|
: captured.nodes;
|
||||||
|
return parentNodes?.find((node) => node.backendNodeId === frame.ownerBackendNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
async function captureMissingFrameDocuments(
|
async function captureMissingFrameDocuments(
|
||||||
cdp: CdpRunner,
|
cdp: CdpRunner,
|
||||||
tabId: number,
|
tabId: number,
|
||||||
@@ -59,20 +65,14 @@ async function captureMissingFrameDocuments(
|
|||||||
|
|
||||||
for (const frame of graph.frames) {
|
for (const frame of graph.frames) {
|
||||||
if (!frame.target.sessionId || captured.frameNodes.has(frame.frameId)) continue;
|
if (!frame.target.sessionId || captured.frameNodes.has(frame.frameId)) continue;
|
||||||
|
const owner = frameOwnerNode(captured, frame);
|
||||||
|
if (!owner?.rect || frame.ownerBackendNodeId === undefined) continue;
|
||||||
try {
|
try {
|
||||||
const child = await captureViewModel(cdpRunnerForTarget(cdp, frame.target), tabId, {
|
const child = await captureViewModel(cdpRunnerForTarget(cdp, frame.target), tabId, {
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
let projection: GeometryProjection | null = null;
|
const projection = await resolveFrameProjection(cdp, graph, frame.frameId);
|
||||||
try {
|
if (!projection) continue;
|
||||||
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 rootFrameId = child.rootFrameId ?? frame.frameId;
|
||||||
const childFrames = child.frameNodes ?? new Map<string, CapturedNode[]>();
|
const childFrames = child.frameNodes ?? new Map<string, CapturedNode[]>();
|
||||||
if (!childFrames.has(rootFrameId)) childFrames.set(rootFrameId, child.nodes);
|
if (!childFrames.has(rootFrameId)) childFrames.set(rootFrameId, child.nodes);
|
||||||
@@ -85,13 +85,11 @@ async function captureMissingFrameDocuments(
|
|||||||
for (const [childFrameId, nodes] of childFrames) {
|
for (const [childFrameId, nodes] of childFrames) {
|
||||||
captured.frameNodes.set(childFrameId, transformFrameNodes(nodes, projection));
|
captured.frameNodes.set(childFrameId, transformFrameNodes(nodes, projection));
|
||||||
}
|
}
|
||||||
if (frame.ownerBackendNodeId !== undefined) {
|
|
||||||
captured.iframeNodes.set(
|
captured.iframeNodes.set(
|
||||||
frame.ownerBackendNodeId,
|
frame.ownerBackendNodeId,
|
||||||
captured.frameNodes.get(frame.frameId) ?? [],
|
captured.frameNodes.get(frame.frameId) ?? [],
|
||||||
);
|
);
|
||||||
captured.frameOwnerBackendNodeIds.set(frame.frameId, frame.ownerBackendNodeId);
|
captured.frameOwnerBackendNodeIds.set(frame.frameId, frame.ownerBackendNodeId);
|
||||||
}
|
|
||||||
if (frame.parentFrameId) captured.frameParentIds.set(frame.frameId, frame.parentFrameId);
|
if (frame.parentFrameId) captured.frameParentIds.set(frame.frameId, frame.parentFrameId);
|
||||||
for (const [childFrameId, ownerBackendNodeId] of child.frameOwnerBackendNodeIds ?? []) {
|
for (const [childFrameId, ownerBackendNodeId] of child.frameOwnerBackendNodeIds ?? []) {
|
||||||
captured.frameOwnerBackendNodeIds.set(childFrameId, ownerBackendNodeId);
|
captured.frameOwnerBackendNodeIds.set(childFrameId, ownerBackendNodeId);
|
||||||
|
|||||||
@@ -77,12 +77,9 @@ export function buildFrameDocuments<T extends FrameOwnedAxNode>(
|
|||||||
): FrameDocument<T>[] {
|
): FrameDocument<T>[] {
|
||||||
const frames = frameList(graph, batches, captured);
|
const frames = frameList(graph, batches, captured);
|
||||||
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 domNodesForFrame = (frameId: string): CapturedNode[] =>
|
|
||||||
captured.frameNodes?.get(frameId) ?? (frameId === rootFrameId ? captured.nodes : []);
|
|
||||||
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 captured.frameNodes?.get(frame.frameId) ?? []) {
|
||||||
backendOwner.set(targetBackendKey(frame.target, node.backendNodeId), frame.frameId);
|
backendOwner.set(targetBackendKey(frame.target, node.backendNodeId), frame.frameId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,6 +165,6 @@ export function buildFrameDocuments<T extends FrameOwnedAxNode>(
|
|||||||
...frame,
|
...frame,
|
||||||
contextScopeId: frame.frameId,
|
contextScopeId: frame.frameId,
|
||||||
axNodes: axNodesByFrame.get(frame.frameId) ?? [],
|
axNodes: axNodesByFrame.get(frame.frameId) ?? [],
|
||||||
domNodes: domNodesForFrame(frame.frameId),
|
domNodes: captured.frameNodes?.get(frame.frameId) ?? [],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user