mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a5d45d156 | ||
|
|
11cd12c9cd | ||
|
|
873971f4cb | ||
|
|
a9c7444139 | ||
|
|
59e2c3224e | ||
|
|
d70df753f6 | ||
|
|
66ae555e40 | ||
|
|
fee7c0983c | ||
|
|
51473c01ff | ||
|
|
49744233f7 | ||
|
|
95571f8b70 | ||
|
|
8344e311fa | ||
|
|
63338cd98e | ||
|
|
bd193ceb5d | ||
|
|
5ed828736f | ||
|
|
ee620acf64 | ||
|
|
05a36075b8 | ||
|
|
1bd13d5c3c | ||
|
|
59d6533eff | ||
|
|
536d3a9844 | ||
|
|
641d934337 | ||
|
|
3b21f2f0a7 | ||
|
|
405433ab65 | ||
|
|
58c7f38a05 | ||
|
|
e0c88295c7 | ||
|
|
dc36e38cb9 | ||
|
|
d5de9b0154 | ||
|
|
995061514c | ||
|
|
406bb21b7e | ||
|
|
f06a514bd2 | ||
|
|
1e53ee63ae | ||
|
|
120dc441bb | ||
|
|
710b1219f4 | ||
|
|
177ecd3ebf | ||
|
|
0e6b9c4542 | ||
|
|
506adbe708 | ||
|
|
984d017b0f | ||
|
|
5df93143c5 | ||
|
|
7163f2f9b0 | ||
|
|
e9f0584af3 | ||
|
|
2997f495db | ||
|
|
f96bd72c3c | ||
|
|
329c68054b | ||
|
|
5cd43553ed | ||
|
|
9ad33801c7 | ||
|
|
7aaa5a2d01 | ||
|
|
210b21b637 | ||
|
|
48f64ec0df | ||
|
|
c48a53269e | ||
|
|
3bbd6ab19f | ||
|
|
1a70d2200e | ||
|
|
554861d6b1 | ||
|
|
0d7f976335 | ||
|
|
d1d3eb6a94 | ||
|
|
38fd7d3f4c | ||
|
|
b5062ac19b | ||
|
|
41cb576256 | ||
|
|
f060a7c074 | ||
|
|
60b62d5420 | ||
|
|
70b851a3e8 | ||
|
|
da3534d066 | ||
|
|
a004291848 | ||
|
|
60fbf30e24 | ||
|
|
a4b6a61d29 | ||
|
|
cad0b5cbc9 |
@@ -325,3 +325,180 @@ jobs:
|
||||
|
||||
echo "Chrome Web Store submission created successfully:"
|
||||
jq '{itemId, state, warningInfo}' "$PUBLISH_RESPONSE"
|
||||
|
||||
publish-edge-addons:
|
||||
needs: [resolve, build-extension]
|
||||
runs-on: ubuntu-latest
|
||||
environment: edge-addons
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: extension-zip
|
||||
path: dist
|
||||
|
||||
- name: Validate Microsoft Edge Add-ons configuration
|
||||
shell: bash
|
||||
env:
|
||||
API_KEY: ${{ secrets.EDGE_ADDONS_API_KEY }}
|
||||
CLIENT_ID: ${{ vars.EDGE_ADDONS_CLIENT_ID }}
|
||||
PRODUCT_ID: ${{ vars.EDGE_ADDONS_PRODUCT_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$API_KEY" ]; then
|
||||
echo "::error::Environment secret EDGE_ADDONS_API_KEY is not configured"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$CLIENT_ID" ]; then
|
||||
echo "::error::Environment variable EDGE_ADDONS_CLIENT_ID is not configured"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$PRODUCT_ID" ]; then
|
||||
echo "::error::Environment variable EDGE_ADDONS_PRODUCT_ID is not configured"
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$PRODUCT_ID" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then
|
||||
echo "::error::EDGE_ADDONS_PRODUCT_ID must be the Partner Center product GUID, not the public Edge Add-ons extension ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ARCHIVE="$(find dist -maxdepth 1 -name '*.zip' -type f -print -quit)"
|
||||
if [ -z "$ARCHIVE" ]; then
|
||||
echo "::error::No extension zip found in the downloaded artifact"
|
||||
exit 1
|
||||
fi
|
||||
echo "ARCHIVE=$ARCHIVE" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Upload and publish Microsoft Edge Add-ons extension
|
||||
shell: bash
|
||||
env:
|
||||
API_KEY: ${{ secrets.EDGE_ADDONS_API_KEY }}
|
||||
CLIENT_ID: ${{ vars.EDGE_ADDONS_CLIENT_ID }}
|
||||
PRODUCT_ID: ${{ vars.EDGE_ADDONS_PRODUCT_ID }}
|
||||
RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
API_ROOT="https://api.addons.microsoftedge.microsoft.com/v1"
|
||||
UPLOAD_HEADERS="$RUNNER_TEMP/edge-upload-headers.txt"
|
||||
UPLOAD_RESPONSE="$RUNNER_TEMP/edge-upload-response.json"
|
||||
STATUS_RESPONSE="$RUNNER_TEMP/edge-status-response.json"
|
||||
PUBLISH_HEADERS="$RUNNER_TEMP/edge-publish-headers.txt"
|
||||
PUBLISH_RESPONSE="$RUNNER_TEMP/edge-publish-response.json"
|
||||
|
||||
operation_id_from_headers() {
|
||||
local headers="$1"
|
||||
local location
|
||||
location="$(awk '
|
||||
tolower($1) == "location:" {
|
||||
sub(/\r$/, "")
|
||||
sub(/^[^:]*:[[:space:]]*/, "")
|
||||
print
|
||||
exit
|
||||
}
|
||||
' "$headers")"
|
||||
location="${location%%\?*}"
|
||||
printf '%s' "${location##*/}"
|
||||
}
|
||||
|
||||
poll_operation() {
|
||||
local operation_name="$1"
|
||||
local status_url="$2"
|
||||
local status="InProgress"
|
||||
local http_status
|
||||
|
||||
for attempt in $(seq 1 30); do
|
||||
sleep 5
|
||||
http_status="$(curl --silent --show-error \
|
||||
--output "$STATUS_RESPONSE" \
|
||||
--write-out '%{http_code}' \
|
||||
--header "Authorization: ApiKey $API_KEY" \
|
||||
--header "X-ClientID: $CLIENT_ID" \
|
||||
"$status_url")"
|
||||
if [ "$http_status" -lt 200 ] || [ "$http_status" -ge 300 ]; then
|
||||
echo "::error::Microsoft Edge Add-ons $operation_name status check failed with HTTP $http_status"
|
||||
jq . "$STATUS_RESPONSE" || cat "$STATUS_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
status="$(jq -r '.status // empty' "$STATUS_RESPONSE")"
|
||||
echo "$operation_name status check $attempt/30: $status"
|
||||
case "$status" in
|
||||
Succeeded)
|
||||
return 0
|
||||
;;
|
||||
InProgress)
|
||||
;;
|
||||
Failed)
|
||||
echo "::error::Microsoft Edge Add-ons $operation_name failed"
|
||||
jq . "$STATUS_RESPONSE"
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "::error::Microsoft Edge Add-ons returned an unknown $operation_name status: $status"
|
||||
jq . "$STATUS_RESPONSE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "::error::Microsoft Edge Add-ons $operation_name did not complete after 30 checks"
|
||||
jq . "$STATUS_RESPONSE"
|
||||
exit 1
|
||||
}
|
||||
|
||||
HTTP_STATUS="$(curl --silent --show-error \
|
||||
--output "$UPLOAD_RESPONSE" \
|
||||
--dump-header "$UPLOAD_HEADERS" \
|
||||
--write-out '%{http_code}' \
|
||||
--request POST \
|
||||
--header "Authorization: ApiKey $API_KEY" \
|
||||
--header "X-ClientID: $CLIENT_ID" \
|
||||
--header "Content-Type: application/zip" \
|
||||
--data-binary "@$ARCHIVE" \
|
||||
"$API_ROOT/products/$PRODUCT_ID/submissions/draft/package")"
|
||||
if [ "$HTTP_STATUS" != "202" ]; then
|
||||
echo "::error::Microsoft Edge Add-ons upload failed with HTTP $HTTP_STATUS"
|
||||
jq . "$UPLOAD_RESPONSE" || cat "$UPLOAD_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
UPLOAD_OPERATION_ID="$(operation_id_from_headers "$UPLOAD_HEADERS")"
|
||||
if [ -z "$UPLOAD_OPERATION_ID" ]; then
|
||||
echo "::error::Microsoft Edge Add-ons upload response has no Location operation ID"
|
||||
cat "$UPLOAD_HEADERS"
|
||||
exit 1
|
||||
fi
|
||||
poll_operation \
|
||||
"package upload" \
|
||||
"$API_ROOT/products/$PRODUCT_ID/submissions/draft/package/operations/$UPLOAD_OPERATION_ID"
|
||||
|
||||
HTTP_STATUS="$(curl --silent --show-error \
|
||||
--output "$PUBLISH_RESPONSE" \
|
||||
--dump-header "$PUBLISH_HEADERS" \
|
||||
--write-out '%{http_code}' \
|
||||
--request POST \
|
||||
--header "Authorization: ApiKey $API_KEY" \
|
||||
--header "X-ClientID: $CLIENT_ID" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data "{\"notes\":\"Automated BrowserSkill extension v${RELEASE_VERSION} release from GitHub Actions.\"}" \
|
||||
"$API_ROOT/products/$PRODUCT_ID/submissions")"
|
||||
if [ "$HTTP_STATUS" != "202" ]; then
|
||||
echo "::error::Microsoft Edge Add-ons publish failed with HTTP $HTTP_STATUS"
|
||||
jq . "$PUBLISH_RESPONSE" || cat "$PUBLISH_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PUBLISH_OPERATION_ID="$(operation_id_from_headers "$PUBLISH_HEADERS")"
|
||||
if [ -z "$PUBLISH_OPERATION_ID" ]; then
|
||||
echo "::error::Microsoft Edge Add-ons publish response has no Location operation ID"
|
||||
cat "$PUBLISH_HEADERS"
|
||||
exit 1
|
||||
fi
|
||||
poll_operation \
|
||||
"submission" \
|
||||
"$API_ROOT/products/$PRODUCT_ID/submissions/operations/$PUBLISH_OPERATION_ID"
|
||||
|
||||
echo "Microsoft Edge Add-ons submission created successfully:"
|
||||
jq '{status, message, errorCode, errors}' "$STATUS_RESPONSE"
|
||||
|
||||
@@ -7,10 +7,18 @@ dist/
|
||||
.output/
|
||||
.wxt/
|
||||
.web-extension-id
|
||||
.pnpm-store/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Local browser evaluation artifacts
|
||||
evals/browser/results/
|
||||
evals/browser/agents.local.json
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -134,7 +134,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bsk"
|
||||
version = "0.1.10"
|
||||
version = "0.1.11"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -169,7 +169,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bsk-protocol"
|
||||
version = "0.1.10"
|
||||
version = "0.1.11"
|
||||
dependencies = [
|
||||
"schemars",
|
||||
"semver",
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ members = [
|
||||
default-members = ["crates/bsk-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.10"
|
||||
version = "0.1.11"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT"
|
||||
|
||||
@@ -208,6 +208,7 @@ The repository is a Cargo + pnpm workspace:
|
||||
- `apps/extension` — browser extension
|
||||
- `packages/ui` and `packages/i18n` — shared extension UI support
|
||||
- `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
|
||||
|
||||
|
||||
@@ -178,6 +178,7 @@ Agent 不直接与浏览器通信。它通过 `bsk` CLI 下发浏览器任务;
|
||||
- `apps/extension` — 浏览器扩展
|
||||
- `packages/ui` 和 `packages/i18n` — 扩展 UI 共享支持
|
||||
- `packages/dsh-plugin-browserskill` — DeepSeek Harness 插件(`@wxg-prc-cpg/browser-skill-dsh-plugin`)
|
||||
- [`evals/browser`](evals/browser/README.zh-CN.md) — 确定性本地页面与 Agent 无关的浏览器能力测试台
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ The Extension requests the following Chrome permissions. Each is used solely for
|
||||
|
||||
## 6. Where Data Goes
|
||||
|
||||
All Extension activity stays on the user's local device. The only network traffic the Extension generates is a WebSocket connection to `ws://127.0.0.1:52800` (loopback only). What the AI agent connected to that local daemon does with the data afterwards (for example, sending a screenshot to an LLM provider) is governed by the privacy policy of that agent or LLM provider, **not** by this policy. BrowserSkill is not a party to those communications.
|
||||
All Extension activity stays on the user's local device. The only network traffic the Extension generates is a WebSocket connection to the local bsk daemon on `127.0.0.1` (loopback only; default port **52800**, configurable in the extension popup). What the AI agent connected to that local daemon does with the data afterwards (for example, sending a screenshot to an LLM provider) is governed by the privacy policy of that agent or LLM provider, **not** by this policy. BrowserSkill is not a party to those communications.
|
||||
|
||||
## 7. Data Retention
|
||||
|
||||
@@ -80,7 +80,7 @@ The Extension is a developer tool and is not directed at children under 13. It d
|
||||
|
||||
## 10. Security
|
||||
|
||||
Because the Extension communicates only with `127.0.0.1`, no data is exposed to the network. Users should still avoid running BrowserSkill in untrusted environments, since any local process able to bind to `127.0.0.1:52800` could send commands to the Extension. Run BrowserSkill only on machines you control.
|
||||
Because the Extension communicates only with `127.0.0.1`, no data is exposed to the network. Users should still avoid running BrowserSkill in untrusted environments, since any local process able to bind to the configured loopback port could send commands to the Extension. Run BrowserSkill only on machines you control.
|
||||
|
||||
## 11. Open Source and Auditability
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ BrowserSkill **不会**:
|
||||
|
||||
## 6. 数据流向
|
||||
|
||||
本扩展的所有活动都停留在用户本地设备上。本扩展产生的唯一网络流量是与 `ws://127.0.0.1:52800`(仅回环地址)的 WebSocket 连接。连接到该本地守护进程的 AI 助手在拿到数据之后如何处理(例如将截图发送给某个 LLM 服务),由该助手或 LLM 提供商自身的隐私政策约束,**不在本政策范围内**。BrowserSkill 不参与那些通信。
|
||||
本扩展的所有活动都停留在用户本地设备上。本扩展产生的唯一网络流量是与本地 bsk 守护进程在 `127.0.0.1`(仅回环地址;默认端口 **52800**,可在扩展弹窗中配置)上的 WebSocket 连接。连接到该本地守护进程的 AI 助手在拿到数据之后如何处理(例如将截图发送给某个 LLM 服务),由该助手或 LLM 提供商自身的隐私政策约束,**不在本政策范围内**。BrowserSkill 不参与那些通信。
|
||||
|
||||
## 7. 数据保留
|
||||
|
||||
@@ -76,7 +76,7 @@ BrowserSkill **不会**:
|
||||
|
||||
## 10. 安全性
|
||||
|
||||
由于本扩展仅与 `127.0.0.1` 通信,因此不会向网络暴露任何数据。但用户仍应避免在不可信的环境中运行 BrowserSkill —— 任何能够绑定到 `127.0.0.1:52800` 的本地进程理论上都可以向本扩展发送指令。请仅在您本人控制的机器上运行 BrowserSkill。
|
||||
由于本扩展仅与 `127.0.0.1` 通信,因此不会向网络暴露任何数据。但用户仍应避免在不可信的环境中运行 BrowserSkill —— 任何能够绑定到所配置回环端口的本地进程理论上都可以向本扩展发送指令。请仅在您本人控制的机器上运行 BrowserSkill。
|
||||
|
||||
## 11. 开源与可审计性
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@browser-skill/extension",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -23,7 +23,7 @@ describe("BorrowConfirmationOverlay", () => {
|
||||
id: "req-1",
|
||||
isActiveTab: true,
|
||||
tabTitle: "Example",
|
||||
timeoutMs: 5000,
|
||||
timeoutMs: 60_000,
|
||||
onAllow,
|
||||
onDeny,
|
||||
},
|
||||
@@ -32,7 +32,7 @@ describe("BorrowConfirmationOverlay", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText("允许借用标签页?")).toBeTruthy();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
@@ -66,7 +66,7 @@ describe("BorrowConfirmationOverlay", () => {
|
||||
id: "req-fallback",
|
||||
isActiveTab: true,
|
||||
tabTitle: "Example",
|
||||
timeoutMs: 5000,
|
||||
timeoutMs: 60_000,
|
||||
onAllow,
|
||||
onDeny,
|
||||
},
|
||||
@@ -74,7 +74,7 @@ describe("BorrowConfirmationOverlay", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@ import { ConnectionController } from "@/lib/connection-controller";
|
||||
import { startHeartbeat } from "@/lib/heartbeat";
|
||||
import {
|
||||
getConnectionEnabled,
|
||||
getDaemonPort,
|
||||
setConnectionEnabled as persistConnectionEnabled,
|
||||
STORAGE_KEYS,
|
||||
setLabel,
|
||||
} from "@/lib/instance-id";
|
||||
import { startKeepalive } from "@/lib/keepalive";
|
||||
@@ -41,6 +43,7 @@ import {
|
||||
attachRecordStepListener,
|
||||
type RecordRuntimeDeps,
|
||||
} from "@/tools/record";
|
||||
import { resolveDaemonWsUrl } from "@/transport/daemon-endpoint";
|
||||
import { detectBrowserMeta } from "@/transport/handshake";
|
||||
import type { Transport } from "@/transport/transport";
|
||||
import { WSTransport } from "@/transport/ws-transport";
|
||||
@@ -54,6 +57,27 @@ export default defineBackground(() => {
|
||||
let overlayGeneration = 0;
|
||||
const controlModes = new Map<string, OverlayMode>();
|
||||
|
||||
async function applyDaemonPort(port: number): Promise<void> {
|
||||
const url = resolveDaemonWsUrl(port);
|
||||
if (!transport.setUrl(url)) return;
|
||||
await transport.disconnect();
|
||||
if (!controller.isConnectionEnabled) return;
|
||||
try {
|
||||
await transport.connect();
|
||||
} catch (err) {
|
||||
console.debug("[browser-skill] reconnect after port change failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof chrome !== "undefined" && chrome.storage?.onChanged) {
|
||||
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||
if (areaName !== "local") return;
|
||||
const change = changes[STORAGE_KEYS.DAEMON_PORT];
|
||||
if (!change || typeof change.newValue !== "number") return;
|
||||
void applyDaemonPort(change.newValue);
|
||||
});
|
||||
}
|
||||
|
||||
function setControlMode(sessionId: string, mode: OverlayMode): void {
|
||||
if (controlModes.get(sessionId) === mode) return;
|
||||
controlModes.set(sessionId, mode);
|
||||
@@ -279,6 +303,8 @@ export default defineBackground(() => {
|
||||
|
||||
void (async () => {
|
||||
const connectionEnabled = await getConnectionEnabled();
|
||||
const port = await getDaemonPort();
|
||||
transport.setUrl(resolveDaemonWsUrl(port));
|
||||
await controller.attach(transport, detectBrowserMeta(), connectionEnabled, {
|
||||
beforeDisconnect: async () => {
|
||||
const report = await cleanupAfterDisconnect();
|
||||
@@ -345,11 +371,6 @@ export default defineBackground(() => {
|
||||
if (msg && typeof msg === "object" && "kind" in msg) {
|
||||
if (msg.kind === "set_label") {
|
||||
void setLabel(msg.value).then(() => controller.refreshLabel());
|
||||
} else if (msg.kind === "set_port") {
|
||||
// Placeholder for the future custom-port UI; warn loudly so
|
||||
// any reintroduced popup control is caught instead of
|
||||
// silently doing nothing (review M4/M5 C2).
|
||||
console.warn("[browser-skill] set_port is not wired yet; ignoring", msg.value);
|
||||
} else if (msg.kind === "set_connection_enabled") {
|
||||
void controller
|
||||
.setConnectionEnabled(msg.value)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SnapshotInfo } from "@/lib/connection-controller";
|
||||
import { STORAGE_KEYS } from "@/lib/instance-id";
|
||||
import { DEFAULT_DAEMON_PORT } from "@/transport/daemon-endpoint";
|
||||
import { EXTENSION_VERSION } from "@/transport/handshake";
|
||||
import { App } from "./App";
|
||||
import { useConnectionState } from "./use-connection-state";
|
||||
@@ -66,9 +67,32 @@ describe("App", () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByText("未连接")).toBeTruthy();
|
||||
expect(screen.getByText("无法连接,请确认 daemon 已启动且端口一致。")).toBeTruthy();
|
||||
expect(screen.queryByText("请先打开 BrowserSkill。")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the connection switch off and hides transport errors when disconnected", () => {
|
||||
mockUseConnectionState.mockReturnValue({
|
||||
snapshot: {
|
||||
...baseSnapshot,
|
||||
lastError: "[WSTransport] disconnect during connect",
|
||||
},
|
||||
statusState: "disconnected",
|
||||
setLabel,
|
||||
setConnectionEnabled,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByText("未连接")).toBeTruthy();
|
||||
expect(screen.getByText("无法连接,请确认 daemon 已启动且端口一致。")).toBeTruthy();
|
||||
expect(screen.queryByText("端口不匹配")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("switch", { name: "BrowserSkill 连接" }).getAttribute("aria-checked"),
|
||||
).toBe("false");
|
||||
expect(screen.queryByText("[WSTransport] disconnect during connect")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render record UI on the main view", () => {
|
||||
render(<App />);
|
||||
|
||||
@@ -136,6 +160,13 @@ describe("App", () => {
|
||||
});
|
||||
|
||||
it("renders the connection toggle with switch semantics", () => {
|
||||
mockUseConnectionState.mockReturnValue({
|
||||
snapshot: { ...baseSnapshot, state: "connected" },
|
||||
statusState: "connected",
|
||||
setLabel,
|
||||
setConnectionEnabled,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
const toggle = screen.getByRole("switch", { name: "BrowserSkill 连接" });
|
||||
@@ -143,6 +174,13 @@ describe("App", () => {
|
||||
});
|
||||
|
||||
it("calls setConnectionEnabled(false) when the toggle is turned off", () => {
|
||||
mockUseConnectionState.mockReturnValue({
|
||||
snapshot: { ...baseSnapshot, state: "connected" },
|
||||
statusState: "connected",
|
||||
setLabel,
|
||||
setConnectionEnabled,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: "BrowserSkill 连接" }));
|
||||
@@ -160,6 +198,7 @@ describe("App", () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByText("连接已关闭")).toBeTruthy();
|
||||
expect(screen.queryByText("无法连接,请确认 daemon 已启动且端口一致。")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("switch", { name: "BrowserSkill 连接" }).getAttribute("aria-checked"),
|
||||
).toBe("false");
|
||||
@@ -247,6 +286,41 @@ describe("App", () => {
|
||||
expect(copyButton.getAttribute("disabled")).not.toBeNull();
|
||||
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", () => {
|
||||
@@ -319,14 +393,20 @@ describe("control hints toggle", () => {
|
||||
|
||||
const info = await screen.findByRole("button", { name: "控制提示说明" });
|
||||
expect(info).toBeTruthy();
|
||||
const tooltip = screen.getByRole("tooltip");
|
||||
expect(tooltip.textContent).toBe("Agent 控制页面时显示提示条和橙色闪光。");
|
||||
const tooltip = screen.getByText("Agent 控制页面时显示提示条和橙色闪光。");
|
||||
expect(tooltip.getAttribute("role")).toBe("tooltip");
|
||||
// Hidden until the info button is hovered or focused.
|
||||
expect(tooltip.className).toContain("opacity-0");
|
||||
});
|
||||
|
||||
it("uses the same switch component and size for both settings rows", async () => {
|
||||
stubChromeStorage();
|
||||
mockUseConnectionState.mockReturnValue({
|
||||
snapshot: { ...baseSnapshot, state: "connected" },
|
||||
statusState: "connected",
|
||||
setLabel: vi.fn(),
|
||||
setConnectionEnabled: vi.fn(),
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
@@ -339,3 +419,109 @@ describe("control hints toggle", () => {
|
||||
expect(hintsToggle.className).toBe(connectionToggle.className);
|
||||
});
|
||||
});
|
||||
|
||||
describe("daemon port input", () => {
|
||||
function stubChromeStorage(initial: Record<string, unknown> = {}) {
|
||||
const store = { ...initial };
|
||||
vi.stubGlobal("chrome", {
|
||||
runtime: { lastError: undefined },
|
||||
storage: {
|
||||
local: {
|
||||
get: (keys: string | string[], cb: (items: Record<string, unknown>) => void) => {
|
||||
const items: Record<string, unknown> = {};
|
||||
for (const k of Array.isArray(keys) ? keys : [keys]) {
|
||||
if (k in store) items[k] = store[k];
|
||||
}
|
||||
cb(items);
|
||||
},
|
||||
set: (items: Record<string, unknown>, cb?: () => void) => {
|
||||
Object.assign(store, items);
|
||||
cb?.();
|
||||
},
|
||||
},
|
||||
onChanged: {
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
},
|
||||
},
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("prefills the port from storage", async () => {
|
||||
stubChromeStorage({ [STORAGE_KEYS.DAEMON_PORT]: 53200 });
|
||||
|
||||
render(<App />);
|
||||
|
||||
const input = await screen.findByRole("textbox", { name: "连接端口" });
|
||||
await waitFor(() => expect((input as HTMLInputElement).value).toBe("53200"));
|
||||
});
|
||||
|
||||
it("persists a valid port on blur", async () => {
|
||||
const store = stubChromeStorage();
|
||||
|
||||
render(<App />);
|
||||
|
||||
const input = await screen.findByRole("textbox", { name: "连接端口" });
|
||||
fireEvent.change(input, { target: { value: "53200" } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(store[STORAGE_KEYS.DAEMON_PORT]).toBe(53200);
|
||||
expect((input as HTMLInputElement).value).toBe("53200");
|
||||
});
|
||||
|
||||
it("persists a valid port when Enter blurs the field", async () => {
|
||||
const store = stubChromeStorage();
|
||||
|
||||
render(<App />);
|
||||
|
||||
const input = await screen.findByRole("textbox", { name: "连接端口" });
|
||||
fireEvent.change(input, { target: { value: "53200" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(store[STORAGE_KEYS.DAEMON_PORT]).toBe(53200);
|
||||
});
|
||||
|
||||
it("shows an error and does not write invalid ports", async () => {
|
||||
const store = stubChromeStorage();
|
||||
|
||||
render(<App />);
|
||||
|
||||
const input = await screen.findByRole("textbox", { name: "连接端口" });
|
||||
fireEvent.change(input, { target: { value: "abc" } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(screen.getByText("请输入 1 到 65535 之间的端口号。")).toBeTruthy();
|
||||
expect(store[STORAGE_KEYS.DAEMON_PORT]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores the default port when the field is cleared", async () => {
|
||||
const store = stubChromeStorage({ [STORAGE_KEYS.DAEMON_PORT]: 53200 });
|
||||
|
||||
render(<App />);
|
||||
|
||||
const input = await screen.findByRole("textbox", { name: "连接端口" });
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(store[STORAGE_KEYS.DAEMON_PORT]).toBe(DEFAULT_DAEMON_PORT);
|
||||
expect((input as HTMLInputElement).value).toBe(String(DEFAULT_DAEMON_PORT));
|
||||
});
|
||||
|
||||
it("keeps the port hint copy in an accessible info tooltip", async () => {
|
||||
stubChromeStorage();
|
||||
|
||||
render(<App />);
|
||||
|
||||
const info = await screen.findByRole("button", { name: "连接端口说明" });
|
||||
expect(info).toBeTruthy();
|
||||
const tooltip = screen.getByText("扩展通过此端口连接本机 daemon。非必要请勿修改。");
|
||||
expect(tooltip.getAttribute("role")).toBe("tooltip");
|
||||
expect(tooltip.className).toContain("opacity-0");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { POPUP_FEATURES, type PopupView } from "./features";
|
||||
import { Switch } from "./switch";
|
||||
import { type PopupStatusState, useConnectionState } from "./use-connection-state";
|
||||
import { useControlHintsHidden } from "./use-control-hints-hidden";
|
||||
import { useDaemonPort } from "./use-daemon-port";
|
||||
|
||||
const STATE_LABEL_KEYS = {
|
||||
disconnected: "popup.stateLabel.disconnected",
|
||||
@@ -41,6 +42,12 @@ export function App() {
|
||||
const { t } = useTranslation("extension");
|
||||
const { snapshot, statusState, setConnectionEnabled } = useConnectionState();
|
||||
const [controlHintsHidden, setControlHintsHidden] = useControlHintsHidden();
|
||||
const {
|
||||
draft: daemonPortDraft,
|
||||
setDraft: setDaemonPortDraft,
|
||||
commit: commitDaemonPort,
|
||||
invalid: daemonPortInvalid,
|
||||
} = useDaemonPort();
|
||||
const [view, setView] = useState<PopupView>("main");
|
||||
const [copiedInstanceId, setCopiedInstanceId] = useState(false);
|
||||
const [purposeDraft, setPurposeDraft] = useState("");
|
||||
@@ -79,6 +86,8 @@ export function App() {
|
||||
}, [copiedTick]);
|
||||
|
||||
const isSkewed = statusState === "version_skew";
|
||||
const isDisconnected = statusState === "disconnected";
|
||||
const connectionLive = statusState === "connected" || isSkewed;
|
||||
const daemonVersion = snapshot.handshake?.version ?? "—";
|
||||
const daemonProtocol = snapshot.handshake?.protocol_version ?? "—";
|
||||
const extensionVersion = snapshot.extensionVersion || "—";
|
||||
@@ -90,7 +99,7 @@ export function App() {
|
||||
setCopiedInstanceId(true);
|
||||
};
|
||||
|
||||
const recordReady = statusState === "connected" && Boolean(snapshot.instanceId);
|
||||
const recordReady = connectionLive && Boolean(snapshot.instanceId);
|
||||
const recordPurpose = purposeDraft.trim();
|
||||
const recordStartUrl = startUrlDraft.trim();
|
||||
const recordCommand = snapshot.instanceId
|
||||
@@ -197,21 +206,29 @@ export function App() {
|
||||
{t(STATE_BADGE_KEYS[statusState])}
|
||||
</Badge>
|
||||
<Switch
|
||||
checked={snapshot.connectionEnabled}
|
||||
checked={snapshot.connectionEnabled && !isDisconnected}
|
||||
onCheckedChange={setConnectionEnabled}
|
||||
aria-label={t("popup.connectionToggleTitle")}
|
||||
data-slot="popup-connection-toggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isDisconnected && (
|
||||
<p
|
||||
className="mt-2 text-xs leading-snug text-muted-foreground"
|
||||
data-slot="popup-daemon-unreachable"
|
||||
>
|
||||
{t("popup.daemonUnreachable")}
|
||||
</p>
|
||||
)}
|
||||
{isSkewed && (
|
||||
<p
|
||||
className="mt-2 text-xs leading-snug text-amber-600 dark:text-amber-400"
|
||||
className="mt-2 text-xs leading-snug text-muted-foreground"
|
||||
data-slot="popup-version-skew-warning"
|
||||
>
|
||||
{t("popup.versionSkewWarning", {
|
||||
extensionProtocol: PROTOCOL_VERSION,
|
||||
daemonProtocol,
|
||||
cliProtocol: daemonProtocol,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
@@ -252,7 +269,66 @@ export function App() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{snapshot.lastError && (
|
||||
<section
|
||||
className="rounded-xl border border-border/80 bg-card/60 px-3 py-2.5"
|
||||
data-slot="popup-daemon-port-card"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
<Label
|
||||
htmlFor="bh-daemon-port"
|
||||
className="truncate text-sm font-medium leading-normal"
|
||||
>
|
||||
{t("popup.daemonPortLabel")}
|
||||
</Label>
|
||||
<span className="group relative inline-flex shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("popup.daemonPortInfoLabel")}
|
||||
data-slot="popup-daemon-port-info"
|
||||
className="flex size-4 items-center justify-center rounded-full text-muted-foreground/70 transition-colors hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
>
|
||||
<RiInformationLine className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
<span
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute bottom-full left-0 z-10 mb-1.5 w-56 whitespace-normal rounded-md bg-foreground/65 px-2 py-1 text-[10px] font-medium leading-snug text-background opacity-0 shadow-md backdrop-blur-sm transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
>
|
||||
{t("popup.daemonPortHint")}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<Input
|
||||
id="bh-daemon-port"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={daemonPortDraft}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setDaemonPortDraft(event.target.value)
|
||||
}
|
||||
onBlur={commitDaemonPort}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commitDaemonPort();
|
||||
}
|
||||
}}
|
||||
className="mt-0 h-7.5 w-19 shrink-0 rounded-md px-2 py-0 text-center text-sm leading-none shadow-none"
|
||||
aria-invalid={daemonPortInvalid || undefined}
|
||||
data-slot="popup-daemon-port-input"
|
||||
/>
|
||||
</div>
|
||||
{daemonPortInvalid && (
|
||||
<p
|
||||
className="mt-1.5 text-[11px] leading-snug text-destructive"
|
||||
data-slot="popup-daemon-port-error"
|
||||
>
|
||||
{t("popup.daemonPortInvalid")}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{snapshot.lastError && !isDisconnected && (
|
||||
<div
|
||||
className="rounded-lg border border-destructive/25 bg-destructive/10 px-3 py-2 text-xs leading-snug text-destructive"
|
||||
data-slot="popup-error"
|
||||
|
||||
@@ -40,13 +40,8 @@ const FALLBACK_SNAPSHOT: SnapshotInfo = {
|
||||
/**
|
||||
* Live snapshot of the background's `ConnectionController` for popup UI.
|
||||
*
|
||||
* Posts `set_label` mutations back to the background; the incoming
|
||||
* snapshot stream reflects the canonical state. The `set_port` channel
|
||||
* is defined by the popup-bridge but is not currently routed to the
|
||||
* background — wiring it requires `ConnectionController` to swap its
|
||||
* Transport URL and persist the chosen port. Tracked for a follow-up
|
||||
* milestone; the popup intentionally does not expose a control until
|
||||
* the underlying support lands (review M4/M5 C2).
|
||||
* Posts `set_label` and `set_connection_enabled` mutations back to the
|
||||
* background; the incoming snapshot stream reflects the canonical state.
|
||||
*/
|
||||
export function useConnectionState(): {
|
||||
snapshot: SnapshotInfo;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getDaemonPort, STORAGE_KEYS, setDaemonPort } from "@/lib/instance-id";
|
||||
import { parseDaemonPortInput } from "@/transport/daemon-endpoint";
|
||||
|
||||
/**
|
||||
* Popup-side daemon port preference stored in `chrome.storage.local`.
|
||||
* Commits on blur, Enter, and pagehide.
|
||||
*/
|
||||
export function useDaemonPort(): {
|
||||
draft: string;
|
||||
setDraft: (value: string) => void;
|
||||
commit: () => void;
|
||||
invalid: boolean;
|
||||
} {
|
||||
const [draft, setDraftState] = useState("");
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
const draftRef = useRef(draft);
|
||||
draftRef.current = draft;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof chrome === "undefined" || !chrome.storage?.local) return undefined;
|
||||
let cancelled = false;
|
||||
getDaemonPort()
|
||||
.then((port) => {
|
||||
if (!cancelled) setDraftState(String(port));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.debug("[browser-skill] daemon port read failed", err);
|
||||
});
|
||||
const onChanged = (changes: Record<string, chrome.storage.StorageChange>, areaName: string) => {
|
||||
if (areaName !== "local") return;
|
||||
const change = changes[STORAGE_KEYS.DAEMON_PORT];
|
||||
if (change && typeof change.newValue === "number") {
|
||||
setDraftState(String(change.newValue));
|
||||
setInvalid(false);
|
||||
}
|
||||
};
|
||||
chrome.storage.onChanged.addListener(onChanged);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
chrome.storage.onChanged.removeListener(onChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const commit = useCallback(() => {
|
||||
const parsed = parseDaemonPortInput(draftRef.current);
|
||||
if (parsed === null) {
|
||||
setInvalid(true);
|
||||
return;
|
||||
}
|
||||
setInvalid(false);
|
||||
setDraftState(String(parsed));
|
||||
if (typeof chrome === "undefined" || !chrome.storage?.local) return;
|
||||
setDaemonPort(parsed).catch((err) => {
|
||||
console.debug("[browser-skill] daemon port write failed", err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onPageHide = () => commit();
|
||||
window.addEventListener("pagehide", onPageHide);
|
||||
return () => window.removeEventListener("pagehide", onPageHide);
|
||||
}, [commit]);
|
||||
|
||||
const setDraft = useCallback((value: string) => {
|
||||
setDraftState(value);
|
||||
setInvalid(false);
|
||||
}, []);
|
||||
|
||||
return { draft, setDraft, commit, invalid };
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_DAEMON_PORT } from "@/transport/daemon-endpoint";
|
||||
import {
|
||||
getConnectionEnabled,
|
||||
getControlHintsHidden,
|
||||
getDaemonPort,
|
||||
getLabel,
|
||||
getOrCreateInstanceId,
|
||||
STORAGE_KEYS,
|
||||
setConnectionEnabled,
|
||||
setControlHintsHidden,
|
||||
setDaemonPort,
|
||||
setLabel,
|
||||
} from "../instance-id";
|
||||
|
||||
@@ -117,4 +120,21 @@ describe("instance-id", () => {
|
||||
expect(store[STORAGE_KEYS.CONTROL_HINTS_HIDDEN]).toBe(true);
|
||||
expect(await getControlHintsHidden(backend)).toBe(true);
|
||||
});
|
||||
|
||||
it("getDaemonPort returns default when storage is empty", async () => {
|
||||
const { backend } = fakeStorage();
|
||||
expect(await getDaemonPort(backend)).toBe(DEFAULT_DAEMON_PORT);
|
||||
});
|
||||
|
||||
it("getDaemonPort normalizes invalid stored values to default", async () => {
|
||||
const { backend } = fakeStorage({ [STORAGE_KEYS.DAEMON_PORT]: 0 });
|
||||
expect(await getDaemonPort(backend)).toBe(DEFAULT_DAEMON_PORT);
|
||||
});
|
||||
|
||||
it("setDaemonPort persists the value retrievable by getDaemonPort", async () => {
|
||||
const { backend, store } = fakeStorage();
|
||||
await setDaemonPort(53200, backend);
|
||||
expect(store[STORAGE_KEYS.DAEMON_PORT]).toBe(53200);
|
||||
expect(await getDaemonPort(backend)).toBe(53200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { normalizeDaemonPort } from "@/transport/daemon-endpoint";
|
||||
|
||||
const STORAGE_KEY = "bsk_instance_id";
|
||||
const LABEL_STORAGE_KEY = "bh_label";
|
||||
const CONNECTION_ENABLED_KEY = "bh_connection_enabled";
|
||||
const CONTROL_HINTS_HIDDEN_KEY = "bsk_control_hints_hidden";
|
||||
const DAEMON_PORT_KEY = "bsk_daemon_port";
|
||||
|
||||
export interface StorageBackend {
|
||||
get(keys: string | string[]): Promise<Record<string, unknown>>;
|
||||
@@ -127,9 +130,23 @@ export async function setControlHintsHidden(
|
||||
await storage.set({ [CONTROL_HINTS_HIDDEN_KEY]: hidden });
|
||||
}
|
||||
|
||||
/** Defaults to {@link DEFAULT_DAEMON_PORT} when unset or invalid. */
|
||||
export async function getDaemonPort(storage: StorageBackend = defaultStorage()): Promise<number> {
|
||||
const items = await storage.get(DAEMON_PORT_KEY);
|
||||
return normalizeDaemonPort(items[DAEMON_PORT_KEY]);
|
||||
}
|
||||
|
||||
export async function setDaemonPort(
|
||||
port: number,
|
||||
storage: StorageBackend = defaultStorage(),
|
||||
): Promise<void> {
|
||||
await storage.set({ [DAEMON_PORT_KEY]: port });
|
||||
}
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
INSTANCE_ID: STORAGE_KEY,
|
||||
LABEL: LABEL_STORAGE_KEY,
|
||||
CONNECTION_ENABLED: CONNECTION_ENABLED_KEY,
|
||||
CONTROL_HINTS_HIDDEN: CONTROL_HINTS_HIDDEN_KEY,
|
||||
DAEMON_PORT: DAEMON_PORT_KEY,
|
||||
} as const;
|
||||
|
||||
@@ -5,19 +5,14 @@ import type { SnapshotInfo } from "./connection-controller";
|
||||
* - Background pushes `{ kind: "snapshot", data: SnapshotInfo }`.
|
||||
* - Popup sends `{ kind: "set_label" }`, `{ kind: "set_connection_enabled" }`, etc.
|
||||
*
|
||||
* NOTE(review M4/M5 C2): the `set_port` variant is defined as a
|
||||
* placeholder so the future custom-port UI does not have to re-design
|
||||
* the bridge, but the popup does NOT render a control that emits it
|
||||
* yet and the background does NOT route it. The wiring requires
|
||||
* ConnectionController to dispose its current Transport and persist
|
||||
* the port in chrome.storage; tracked as a follow-up.
|
||||
* Daemon port preference is persisted via `chrome.storage.local` instead
|
||||
* of this bridge (see `use-daemon-port.ts`).
|
||||
*/
|
||||
|
||||
export const POPUP_PORT_NAME = "popup";
|
||||
|
||||
export type PopupOutbound =
|
||||
| { kind: "set_label"; value: string }
|
||||
| { kind: "set_port"; value: number }
|
||||
| { kind: "set_connection_enabled"; value: boolean };
|
||||
|
||||
export type PopupInbound = { kind: "snapshot"; data: SnapshotInfo };
|
||||
|
||||
@@ -127,6 +127,38 @@ describe("requestBorrowConfirmation", () => {
|
||||
expect(message.requestId.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("proactively focuses the user window before sending the overlay message", async () => {
|
||||
tabs.get.mockResolvedValueOnce({ id: 42, title: "Tab" });
|
||||
windows.getLastFocused.mockResolvedValueOnce(
|
||||
userWindowWithActiveTab({ windowId: 77, tabId: 7, url: "https://app.example/" }),
|
||||
);
|
||||
tabs.sendMessage.mockResolvedValueOnce({ type: "borrow-response", allowed: true });
|
||||
|
||||
const pending = requestBorrowConfirmation(42, {
|
||||
deps: { tabs, windows, notifications },
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
await pending;
|
||||
|
||||
expect(windows.update).toHaveBeenCalledWith(77, { focused: true });
|
||||
});
|
||||
|
||||
it("still proceeds when proactive window focus fails", async () => {
|
||||
tabs.get.mockResolvedValueOnce({ id: 42, title: "Tab" });
|
||||
windows.getLastFocused.mockResolvedValueOnce(
|
||||
userWindowWithActiveTab({ windowId: 77, tabId: 7, url: "https://app.example/" }),
|
||||
);
|
||||
windows.update.mockRejectedValueOnce(new Error("window gone"));
|
||||
tabs.sendMessage.mockResolvedValueOnce({ type: "borrow-response", allowed: true });
|
||||
|
||||
const pending = requestBorrowConfirmation(42, {
|
||||
deps: { tabs, windows, notifications },
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(await pending).toBe(true);
|
||||
});
|
||||
|
||||
it("sends confirmation to the borrow target when it is the active tab", async () => {
|
||||
tabs.get.mockResolvedValueOnce({ id: 42, title: "Active Tab" });
|
||||
windows.getLastFocused.mockResolvedValueOnce(
|
||||
|
||||
@@ -246,6 +246,104 @@ describe("file transfer tools", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not dispatch browser work when upload is already cancelled", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const { cdp, calls } = uploadCdp();
|
||||
|
||||
const result = await uploadThroughActivatedFileInput({
|
||||
cdp,
|
||||
actionTarget: actionTarget(),
|
||||
files: ["/private/stage/one"],
|
||||
timeoutMs: 100,
|
||||
signal: controller.signal,
|
||||
trigger: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
code: "cancelled",
|
||||
data: { effect_state: "none", phase: "arm_interception" },
|
||||
});
|
||||
expect(calls).not.toContainEqual({
|
||||
method: "Page.setInterceptFileChooserDialog",
|
||||
params: { enabled: true },
|
||||
});
|
||||
expect(calls.some((call) => call.method === "DOM.setFileInputFiles")).toBe(false);
|
||||
});
|
||||
|
||||
it("stops before file assignment when cancellation interrupts input resolution", async () => {
|
||||
const controller = new AbortController();
|
||||
const fixture = uploadCdp();
|
||||
const originalSend = fixture.cdp.send;
|
||||
let markProbeStarted!: () => void;
|
||||
const probeStarted = new Promise<void>((resolve) => {
|
||||
markProbeStarted = resolve;
|
||||
});
|
||||
fixture.cdp.send = vi.fn((tabId: number, method: string, params?: object) => {
|
||||
const declaration = (params as { functionDeclaration?: string } | undefined)
|
||||
?.functionDeclaration;
|
||||
if (
|
||||
method === "Runtime.callFunctionOn" &&
|
||||
declaration?.includes("count: state.inputs.length")
|
||||
) {
|
||||
markProbeStarted();
|
||||
return new Promise<never>(() => {});
|
||||
}
|
||||
return originalSend(tabId, method, params);
|
||||
}) as CdpRunner["send"];
|
||||
|
||||
const transaction = uploadThroughActivatedFileInput({
|
||||
cdp: fixture.cdp,
|
||||
actionTarget: actionTarget(),
|
||||
files: ["/private/stage/one"],
|
||||
timeoutMs: 1_000,
|
||||
signal: controller.signal,
|
||||
trigger: async () => ({ tab_id: 4, x: 10, y: 10 }),
|
||||
});
|
||||
await probeStarted;
|
||||
controller.abort();
|
||||
const result = await transaction;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
code: "cancelled",
|
||||
data: { effect_state: "none", phase: "resolve_input" },
|
||||
});
|
||||
expect(fixture.calls.some((call) => call.method === "DOM.setFileInputFiles")).toBe(false);
|
||||
});
|
||||
|
||||
it("disarms chooser interception when its enable response times out", async () => {
|
||||
const calls: Array<{ method: string; params?: object }> = [];
|
||||
const cdp: CdpRunner = {
|
||||
send: vi.fn(async (_tabId: number, method: string, params?: object) => {
|
||||
calls.push({ method, params });
|
||||
if (
|
||||
method === "Page.setInterceptFileChooserDialog" &&
|
||||
(params as { enabled?: boolean }).enabled
|
||||
) {
|
||||
return new Promise<never>(() => {});
|
||||
}
|
||||
return {};
|
||||
}) as CdpRunner["send"],
|
||||
};
|
||||
|
||||
const result = await uploadThroughActivatedFileInput({
|
||||
cdp,
|
||||
actionTarget: actionTarget(),
|
||||
files: ["/private/stage/one"],
|
||||
timeoutMs: 5,
|
||||
trigger: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
code: "timeout",
|
||||
data: { effect_state: "none", phase: "arm_interception" },
|
||||
});
|
||||
expect(calls).toContainEqual({
|
||||
method: "Page.setInterceptFileChooserDialog",
|
||||
params: { enabled: false, cancel: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("marks a timed-out file assignment unknown and detaches browser state", async () => {
|
||||
const fixture = uploadCdp();
|
||||
const originalSend = fixture.cdp.send;
|
||||
@@ -567,7 +665,10 @@ describe("file transfer tools", () => {
|
||||
expect(downloads.cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reconciles a download that completes while cancellation is being requested", async () => {
|
||||
it.each([
|
||||
["cancel resolves", false],
|
||||
["cancel reports that completion won", true],
|
||||
])("reconciles a download that completes while %s", async (_label, cancelThrows) => {
|
||||
const onCreated = fakeEvent<(item: chrome.downloads.DownloadItem) => void>();
|
||||
const onChanged = fakeEvent<(delta: chrome.downloads.DownloadDelta) => void>();
|
||||
const onDeterminingFilename =
|
||||
@@ -601,7 +702,7 @@ describe("file transfer tools", () => {
|
||||
onDeterminingFilename,
|
||||
search: vi.fn().mockResolvedValueOnce([initial]).mockResolvedValueOnce([complete]),
|
||||
cancel: vi.fn(async () => {
|
||||
throw new Error("download already complete");
|
||||
if (cancelThrows) throw new Error("download already complete");
|
||||
}),
|
||||
removeFile: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
@@ -1656,7 +1656,7 @@ describe("buildVomScene", () => {
|
||||
|
||||
const scene = buildVomScene(axNodes, captured);
|
||||
expect(scene.nodes.find((n) => n.id === 20)).toEqual(
|
||||
expect.objectContaining({ id: 20, role: "generic", cursor: "pointer" }),
|
||||
expect.objectContaining({ id: 20, role: "button", name: "close", cursor: "pointer" }),
|
||||
);
|
||||
const rendered = renderVom(scene);
|
||||
expect(rendered.text).toContain('@e1 button "close"');
|
||||
@@ -1732,7 +1732,7 @@ describe("buildVomScene", () => {
|
||||
};
|
||||
|
||||
const scene = buildVomScene(axNodes, captured);
|
||||
expect(scene.nodes.find((n) => n.id === 20)?.role).toBe("generic");
|
||||
expect(scene.nodes.find((n) => n.id === 20)).toBeUndefined();
|
||||
expect(scene.nodes.find((n) => n.id === 30)?.role).toBe("link");
|
||||
});
|
||||
|
||||
@@ -1801,9 +1801,9 @@ describe("buildVomScene", () => {
|
||||
|
||||
const scene = buildVomScene(axNodes, captured);
|
||||
expect(scene.nodes.find((n) => n.id === 20)).toEqual(
|
||||
expect.objectContaining({ id: 20, role: "generic", attrs: { "aria-label": "收藏" } }),
|
||||
expect.objectContaining({ id: 20, role: "button", attrs: { "aria-label": "收藏" } }),
|
||||
);
|
||||
expect(scene.nodes.find((n) => n.id === 30)?.role).toBe("generic");
|
||||
expect(scene.nodes.find((n) => n.id === 30)).toBeUndefined();
|
||||
const rendered = renderVom(scene);
|
||||
expect(rendered.text).toContain('@e1 button "收藏"');
|
||||
expect(rendered.refs.map(({ ref, backendNodeId }) => ({ ref, backendNodeId }))).toEqual([
|
||||
@@ -1904,36 +1904,41 @@ describe("buildVomScene", () => {
|
||||
backendDOMNodeId: 20,
|
||||
},
|
||||
];
|
||||
const scene = buildVomScene(axNodes, {
|
||||
viewport: { width: 1000, height: 800 },
|
||||
iframeNodes: new Map(),
|
||||
excludedBackendNodeIds: new Set(),
|
||||
surfaceProbes: [
|
||||
{ triggerBackendNodeId: 20, triggerAction: "hover", subItems: ["Shoes", "Bags"] },
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
backendNodeId: 10,
|
||||
parentBackendNodeId: null,
|
||||
tag: "body",
|
||||
attrs: {},
|
||||
rect: { x: 0, y: 0, w: 1000, h: 800 },
|
||||
paintOrder: 0,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
{
|
||||
backendNodeId: 20,
|
||||
parentBackendNodeId: 10,
|
||||
tag: "button",
|
||||
attrs: {},
|
||||
rect: { x: 20, y: 20, w: 120, h: 40 },
|
||||
paintOrder: 1,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
],
|
||||
});
|
||||
const scene = buildVomScene(
|
||||
axNodes,
|
||||
{
|
||||
viewport: { width: 1000, height: 800 },
|
||||
iframeNodes: new Map(),
|
||||
excludedBackendNodeIds: new Set(),
|
||||
nodes: [
|
||||
{
|
||||
backendNodeId: 10,
|
||||
parentBackendNodeId: null,
|
||||
tag: "body",
|
||||
attrs: {},
|
||||
rect: { x: 0, y: 0, w: 1000, h: 800 },
|
||||
paintOrder: 0,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
{
|
||||
backendNodeId: 20,
|
||||
parentBackendNodeId: 10,
|
||||
tag: "button",
|
||||
attrs: {},
|
||||
rect: { x: 20, y: 20, w: 120, h: 40 },
|
||||
paintOrder: 1,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
surfaceProbes: [
|
||||
{ triggerBackendNodeId: 20, triggerAction: "hover", subItems: ["Shoes", "Bags"] },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(scene.surfaces).toEqual([
|
||||
{ triggerId: 20, triggerAction: "hover", subItems: ["Shoes", "Bags"] },
|
||||
@@ -1957,46 +1962,51 @@ describe("buildVomScene", () => {
|
||||
backendDOMNodeId: 21,
|
||||
},
|
||||
];
|
||||
const scene = buildVomScene(axNodes, {
|
||||
viewport: { width: 1000, height: 800 },
|
||||
iframeNodes: new Map(),
|
||||
excludedBackendNodeIds: new Set(),
|
||||
surfaceProbes: [
|
||||
{ triggerBackendNodeId: 20, triggerAction: "hover", subItems: ["My profile"] },
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
backendNodeId: 10,
|
||||
parentBackendNodeId: null,
|
||||
tag: "body",
|
||||
attrs: {},
|
||||
rect: { x: 0, y: 0, w: 1000, h: 800 },
|
||||
paintOrder: 0,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
{
|
||||
backendNodeId: 20,
|
||||
parentBackendNodeId: 10,
|
||||
tag: "div",
|
||||
attrs: { class: "tg-avatar" },
|
||||
rect: { x: 900, y: 10, w: 30, h: 30 },
|
||||
paintOrder: 1,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
{
|
||||
backendNodeId: 21,
|
||||
parentBackendNodeId: 20,
|
||||
tag: "div",
|
||||
attrs: { class: "tg-avatar__inner" },
|
||||
rect: { x: 902, y: 12, w: 26, h: 26 },
|
||||
paintOrder: 2,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
],
|
||||
});
|
||||
const scene = buildVomScene(
|
||||
axNodes,
|
||||
{
|
||||
viewport: { width: 1000, height: 800 },
|
||||
iframeNodes: new Map(),
|
||||
excludedBackendNodeIds: new Set(),
|
||||
nodes: [
|
||||
{
|
||||
backendNodeId: 10,
|
||||
parentBackendNodeId: null,
|
||||
tag: "body",
|
||||
attrs: {},
|
||||
rect: { x: 0, y: 0, w: 1000, h: 800 },
|
||||
paintOrder: 0,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
{
|
||||
backendNodeId: 20,
|
||||
parentBackendNodeId: 10,
|
||||
tag: "div",
|
||||
attrs: { class: "tg-avatar" },
|
||||
rect: { x: 900, y: 10, w: 30, h: 30 },
|
||||
paintOrder: 1,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
{
|
||||
backendNodeId: 21,
|
||||
parentBackendNodeId: 20,
|
||||
tag: "div",
|
||||
attrs: { class: "tg-avatar__inner" },
|
||||
rect: { x: 902, y: 12, w: 26, h: 26 },
|
||||
paintOrder: 2,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
surfaceProbes: [
|
||||
{ triggerBackendNodeId: 20, triggerAction: "hover", subItems: ["My profile"] },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(renderVom(scene).text).toContain('@e1 button "image" [hover first: My profile]');
|
||||
});
|
||||
@@ -2017,48 +2027,53 @@ describe("buildVomScene", () => {
|
||||
backendDOMNodeId: 21,
|
||||
},
|
||||
];
|
||||
const scene = buildVomScene(axNodes, {
|
||||
viewport: { width: 1000, height: 800 },
|
||||
iframeNodes: new Map(),
|
||||
excludedBackendNodeIds: new Set(),
|
||||
surfaceProbes: [
|
||||
{ triggerBackendNodeId: 20, triggerAction: "hover", subItems: ["My profile"] },
|
||||
{ triggerBackendNodeId: 20, triggerAction: "hover", subItems: ["Sign out"] },
|
||||
{ triggerBackendNodeId: 21, triggerAction: "hover", subItems: ["Settings"] },
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
backendNodeId: 10,
|
||||
parentBackendNodeId: null,
|
||||
tag: "body",
|
||||
attrs: {},
|
||||
rect: { x: 0, y: 0, w: 1000, h: 800 },
|
||||
paintOrder: 0,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
{
|
||||
backendNodeId: 20,
|
||||
parentBackendNodeId: 10,
|
||||
tag: "div",
|
||||
attrs: { class: "tg-avatar" },
|
||||
rect: { x: 900, y: 10, w: 30, h: 30 },
|
||||
paintOrder: 1,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
{
|
||||
backendNodeId: 21,
|
||||
parentBackendNodeId: 20,
|
||||
tag: "div",
|
||||
attrs: { class: "tg-avatar__inner" },
|
||||
rect: { x: 902, y: 12, w: 26, h: 26 },
|
||||
paintOrder: 2,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
],
|
||||
});
|
||||
const scene = buildVomScene(
|
||||
axNodes,
|
||||
{
|
||||
viewport: { width: 1000, height: 800 },
|
||||
iframeNodes: new Map(),
|
||||
excludedBackendNodeIds: new Set(),
|
||||
nodes: [
|
||||
{
|
||||
backendNodeId: 10,
|
||||
parentBackendNodeId: null,
|
||||
tag: "body",
|
||||
attrs: {},
|
||||
rect: { x: 0, y: 0, w: 1000, h: 800 },
|
||||
paintOrder: 0,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
{
|
||||
backendNodeId: 20,
|
||||
parentBackendNodeId: 10,
|
||||
tag: "div",
|
||||
attrs: { class: "tg-avatar" },
|
||||
rect: { x: 900, y: 10, w: 30, h: 30 },
|
||||
paintOrder: 1,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
{
|
||||
backendNodeId: 21,
|
||||
parentBackendNodeId: 20,
|
||||
tag: "div",
|
||||
attrs: { class: "tg-avatar__inner" },
|
||||
rect: { x: 902, y: 12, w: 26, h: 26 },
|
||||
paintOrder: 2,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
surfaceProbes: [
|
||||
{ triggerBackendNodeId: 20, triggerAction: "hover", subItems: ["My profile"] },
|
||||
{ triggerBackendNodeId: 20, triggerAction: "hover", subItems: ["Sign out"] },
|
||||
{ triggerBackendNodeId: 21, triggerAction: "hover", subItems: ["Settings"] },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(scene.surfaces).toEqual([
|
||||
{ triggerId: 21, triggerAction: "hover", subItems: ["My profile"] },
|
||||
@@ -2081,57 +2096,62 @@ describe("buildVomScene", () => {
|
||||
backendDOMNodeId: 21,
|
||||
},
|
||||
];
|
||||
const scene = buildVomScene(axNodes, {
|
||||
viewport: { width: 1000, height: 800 },
|
||||
iframeNodes: new Map(),
|
||||
excludedBackendNodeIds: new Set(),
|
||||
surfaceProbes: [
|
||||
{
|
||||
triggerBackendNodeId: 20,
|
||||
triggerPoint: { x: 915, y: 25 },
|
||||
triggerAction: "hover",
|
||||
subItems: ["My profile", "Sign out"],
|
||||
},
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
backendNodeId: 10,
|
||||
parentBackendNodeId: null,
|
||||
tag: "body",
|
||||
attrs: {},
|
||||
rect: { x: 0, y: 0, w: 1000, h: 800 },
|
||||
paintOrder: 0,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
cursor: "auto",
|
||||
},
|
||||
{
|
||||
backendNodeId: 20,
|
||||
parentBackendNodeId: 10,
|
||||
tag: "div",
|
||||
attrs: { class: "tg-avatar" },
|
||||
rect: { x: 900, y: 10, w: 30, h: 30 },
|
||||
paintOrder: 1,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
cursor: "pointer",
|
||||
},
|
||||
{
|
||||
backendNodeId: 21,
|
||||
parentBackendNodeId: 20,
|
||||
tag: "div",
|
||||
attrs: { class: "tg-avatar__inner" },
|
||||
rect: { x: 902, y: 12, w: 26, h: 26 },
|
||||
paintOrder: 2,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
cursor: "pointer",
|
||||
},
|
||||
],
|
||||
});
|
||||
const scene = buildVomScene(
|
||||
axNodes,
|
||||
{
|
||||
viewport: { width: 1000, height: 800 },
|
||||
iframeNodes: new Map(),
|
||||
excludedBackendNodeIds: new Set(),
|
||||
nodes: [
|
||||
{
|
||||
backendNodeId: 10,
|
||||
parentBackendNodeId: null,
|
||||
tag: "body",
|
||||
attrs: {},
|
||||
rect: { x: 0, y: 0, w: 1000, h: 800 },
|
||||
paintOrder: 0,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
cursor: "auto",
|
||||
},
|
||||
{
|
||||
backendNodeId: 20,
|
||||
parentBackendNodeId: 10,
|
||||
tag: "div",
|
||||
attrs: { class: "tg-avatar" },
|
||||
rect: { x: 900, y: 10, w: 30, h: 30 },
|
||||
paintOrder: 1,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
cursor: "pointer",
|
||||
},
|
||||
{
|
||||
backendNodeId: 21,
|
||||
parentBackendNodeId: 20,
|
||||
tag: "div",
|
||||
attrs: { class: "tg-avatar__inner" },
|
||||
rect: { x: 902, y: 12, w: 26, h: 26 },
|
||||
paintOrder: 2,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
cursor: "pointer",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
surfaceProbes: [
|
||||
{
|
||||
triggerBackendNodeId: 20,
|
||||
triggerPoint: { x: 915, y: 25 },
|
||||
triggerAction: "hover",
|
||||
subItems: ["My profile", "Sign out"],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(scene.surfaces).toEqual([
|
||||
{ triggerId: 21, triggerAction: "hover", subItems: ["My profile", "Sign out"] },
|
||||
{ triggerId: 20, triggerAction: "hover", subItems: ["My profile", "Sign out"] },
|
||||
]);
|
||||
expect(renderVom(scene).text).toContain(
|
||||
'@e1 button "image" [hover first: My profile | Sign out]',
|
||||
@@ -2154,41 +2174,46 @@ describe("buildVomScene", () => {
|
||||
backendDOMNodeId: 42,
|
||||
},
|
||||
];
|
||||
const scene = buildVomScene(axNodes, {
|
||||
viewport: { width: 1000, height: 800 },
|
||||
iframeNodes: new Map(),
|
||||
excludedBackendNodeIds: new Set(),
|
||||
surfaceProbes: [
|
||||
{
|
||||
triggerBackendNodeId: 999,
|
||||
triggerPoint: { x: 900, y: 20 },
|
||||
triggerAction: "hover",
|
||||
subItems: ["My profile"],
|
||||
},
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
backendNodeId: 10,
|
||||
parentBackendNodeId: null,
|
||||
tag: "body",
|
||||
attrs: {},
|
||||
rect: { x: 0, y: 0, w: 1000, h: 800 },
|
||||
paintOrder: 0,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
{
|
||||
backendNodeId: 42,
|
||||
parentBackendNodeId: 10,
|
||||
tag: "a",
|
||||
attrs: {},
|
||||
rect: { x: 860, y: 120, w: 40, h: 30 },
|
||||
paintOrder: 1,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
],
|
||||
});
|
||||
const scene = buildVomScene(
|
||||
axNodes,
|
||||
{
|
||||
viewport: { width: 1000, height: 800 },
|
||||
iframeNodes: new Map(),
|
||||
excludedBackendNodeIds: new Set(),
|
||||
nodes: [
|
||||
{
|
||||
backendNodeId: 10,
|
||||
parentBackendNodeId: null,
|
||||
tag: "body",
|
||||
attrs: {},
|
||||
rect: { x: 0, y: 0, w: 1000, h: 800 },
|
||||
paintOrder: 0,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
{
|
||||
backendNodeId: 42,
|
||||
parentBackendNodeId: 10,
|
||||
tag: "a",
|
||||
attrs: {},
|
||||
rect: { x: 860, y: 120, w: 40, h: 30 },
|
||||
paintOrder: 1,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
surfaceProbes: [
|
||||
{
|
||||
triggerBackendNodeId: 999,
|
||||
triggerPoint: { x: 900, y: 20 },
|
||||
triggerAction: "hover",
|
||||
subItems: ["My profile"],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(scene.surfaces).toBeUndefined();
|
||||
expect(renderVom(scene).text).not.toContain("[hover first:");
|
||||
@@ -2606,7 +2631,106 @@ describe("handleSnapshot", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("enables conditional surface probing for semantic observe", async () => {
|
||||
it("hovers the page only after the accessibility tree has been captured", async () => {
|
||||
// Hovering can open menus and reflow the page. Probing between the DOM and
|
||||
// AX captures would leave the two halves of one observation describing the
|
||||
// page on either side of that change.
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const root: CdpAxNode = {
|
||||
nodeId: "1",
|
||||
role: { type: "role", value: "RootWebArea" },
|
||||
name: { type: "computedString", value: "Example" },
|
||||
backendDOMNodeId: 100,
|
||||
childIds: ["2"],
|
||||
};
|
||||
const button: CdpAxNode = {
|
||||
nodeId: "2",
|
||||
parentId: "1",
|
||||
role: { type: "role", value: "button" },
|
||||
name: { type: "computedString", value: "Products" },
|
||||
backendDOMNodeId: 200,
|
||||
};
|
||||
const strings = ["body", "button", "position", "static", "pointer-events", "auto", "cursor"];
|
||||
const i = (s: string) => strings.indexOf(s);
|
||||
const methodOrder: string[] = [];
|
||||
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
|
||||
methodOrder.push(method);
|
||||
if (method === "Accessibility.enable") return {};
|
||||
if (method === "Accessibility.getFullAXTree") return { nodes: [root, button] };
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 } };
|
||||
}
|
||||
if (method === "DOMSnapshot.enable") return {};
|
||||
if (method === "DOMSnapshot.captureSnapshot") {
|
||||
return {
|
||||
strings,
|
||||
documents: [
|
||||
{
|
||||
nodes: {
|
||||
parentIndex: [-1, 0],
|
||||
nodeName: [i("body"), i("button")],
|
||||
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, 20, 120, 40],
|
||||
],
|
||||
paintOrders: [0, 1],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "Input.dispatchMouseEvent") return {};
|
||||
if (method === "Runtime.evaluate") {
|
||||
const expression = (params as { expression?: string } | undefined)?.expression ?? "";
|
||||
// Report the button as a `:hover` rule target so a real hover fires.
|
||||
if (expression.includes("document.styleSheets")) {
|
||||
return { result: { value: [{ x: 80, y: 40 }] } };
|
||||
}
|
||||
return { result: { value: [] } };
|
||||
}
|
||||
throw new Error(`unexpected CDP method ${method}`);
|
||||
});
|
||||
|
||||
const res = await handleObserve(
|
||||
sm,
|
||||
{ session_id: "aa11", probe_hover: true },
|
||||
{
|
||||
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]),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||
const firstHover = methodOrder.indexOf("Input.dispatchMouseEvent");
|
||||
expect(firstHover).toBeGreaterThan(-1);
|
||||
expect(methodOrder.lastIndexOf("DOMSnapshot.captureSnapshot")).toBeLessThan(firstHover);
|
||||
expect(methodOrder.lastIndexOf("Accessibility.getFullAXTree")).toBeLessThan(firstHover);
|
||||
});
|
||||
|
||||
it("runs conditional surface probing only when observe opts in", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const root: CdpAxNode = {
|
||||
@@ -2662,30 +2786,46 @@ describe("handleSnapshot", () => {
|
||||
if (method === "Runtime.evaluate") return { result: { value: [] } };
|
||||
throw new Error(`unexpected CDP method ${method}`);
|
||||
});
|
||||
const res = await handleObserve(
|
||||
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]),
|
||||
},
|
||||
};
|
||||
|
||||
const withoutProbe = await handleObserve(
|
||||
sm,
|
||||
{ session_id: "aa11", debug_surfaces: true },
|
||||
{
|
||||
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]),
|
||||
},
|
||||
},
|
||||
deps,
|
||||
);
|
||||
if ("code" in withoutProbe) {
|
||||
throw new Error(`unexpected error: ${JSON.stringify(withoutProbe)}`);
|
||||
}
|
||||
expect(withoutProbe.hover_probe).toBeUndefined();
|
||||
expect(send).not.toHaveBeenCalledWith(
|
||||
4,
|
||||
"Runtime.evaluate",
|
||||
expect.objectContaining({ returnByValue: true }),
|
||||
);
|
||||
|
||||
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||
expect(res.debug).toEqual({ surface_probes: [] });
|
||||
const withProbe = await handleObserve(
|
||||
sm,
|
||||
{ session_id: "aa11", debug_surfaces: true, probe_hover: true },
|
||||
deps,
|
||||
);
|
||||
if ("code" in withProbe) throw new Error(`unexpected error: ${JSON.stringify(withProbe)}`);
|
||||
expect(withProbe.debug).toEqual({ surface_probes: [] });
|
||||
expect(withProbe.hover_probe).toEqual({ performed: true, revealed_content: false });
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
4,
|
||||
"Runtime.evaluate",
|
||||
@@ -3155,6 +3295,98 @@ describe("handleSnapshot", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface BorrowResponseMessage {
|
||||
allowed: boolean;
|
||||
}
|
||||
|
||||
export const CONFIRMATION_TIMEOUT_MS = 5000;
|
||||
export const CONFIRMATION_TIMEOUT_MS = 60_000;
|
||||
const EXIT_ANIMATION_MS = 150;
|
||||
/** Matches BorrowConfirmationOverlay progress ring/bar transition (duration-1000). */
|
||||
export const PROGRESS_TRANSITION_MS = 1000;
|
||||
@@ -480,6 +480,13 @@ export async function requestBorrowConfirmation(
|
||||
settle(false);
|
||||
}, BACKGROUND_TIMEOUT_MS);
|
||||
|
||||
// Bring the user window to the front so the overlay is visible even
|
||||
// when the Agent Window has stolen focus. This is fire-and-forget: if
|
||||
// it fails the overlay / OS notification still give the user a path.
|
||||
void windowsApi.update(notificationAnchor.windowId, { focused: true }).catch((err) => {
|
||||
console.debug("[bsk borrow] proactive focus of user window failed", err);
|
||||
});
|
||||
|
||||
// Surface the OS notification *before* messaging any candidate so the
|
||||
// user has a parallel signal even if every content script is missing.
|
||||
// The Allow / Deny buttons let the user authorize explicitly when the
|
||||
|
||||
@@ -387,7 +387,11 @@ export class ToolDispatcher {
|
||||
? {
|
||||
cdp: this.cdp,
|
||||
tabsApi: chromeTabsCaptureApi,
|
||||
conditionalSurfaceProbe: !this.hasHoverLatchForScope(hoverScope),
|
||||
// Active hover probing is opt-in. A held hover latch still
|
||||
// suppresses it, because probing would move the cursor off
|
||||
// the element the caller is deliberately holding.
|
||||
conditionalSurfaceProbe:
|
||||
params.probe_hover === true && !this.hasHoverLatchForScope(hoverScope),
|
||||
hoverProbeBypassOverlay: bypassOverlay,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -9,6 +9,8 @@ import { type CdpRunner, isRpcError } from "./shared";
|
||||
|
||||
const CORRELATION_GRACE_MS = 750;
|
||||
const UNIQUE_SETTLE_MS = 50;
|
||||
const CLEANUP_RECONCILE_MS = 1_000;
|
||||
const CLEANUP_POLL_MS = 25;
|
||||
const SIZE_POLL_MS = 250;
|
||||
|
||||
type DeterminingFilenameListener = (
|
||||
@@ -110,26 +112,35 @@ function captureError(
|
||||
|
||||
async function cleanupClaimedDownload(downloads: DownloadsApi, downloadId: number): Promise<void> {
|
||||
const lookup = async () => (await downloads.search({ id: downloadId }))[0];
|
||||
const item = await lookup();
|
||||
let item = await lookup();
|
||||
if (!item || item.state === "interrupted") return;
|
||||
if (item.state === "complete") {
|
||||
await downloads.removeFile(downloadId);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelError: unknown;
|
||||
try {
|
||||
await downloads.cancel(downloadId);
|
||||
} catch (cancelError) {
|
||||
// Completion can win the race after the lookup but before cancellation.
|
||||
// Reconcile against Chrome's authoritative state before declaring cleanup
|
||||
// failed so every terminal state has one explicit cleanup path.
|
||||
const reconciled = await lookup();
|
||||
if (!reconciled || reconciled.state === "interrupted") return;
|
||||
if (reconciled.state === "complete") {
|
||||
} catch (error) {
|
||||
cancelError = error;
|
||||
}
|
||||
|
||||
const deadline = Date.now() + CLEANUP_RECONCILE_MS;
|
||||
while (true) {
|
||||
item = await lookup();
|
||||
if (!item || item.state === "interrupted") return;
|
||||
if (item.state === "complete") {
|
||||
await downloads.removeFile(downloadId);
|
||||
return;
|
||||
}
|
||||
throw cancelError;
|
||||
if (Date.now() >= deadline) {
|
||||
if (cancelError instanceof Error) throw cancelError;
|
||||
throw new Error("download remained in progress after cancellation");
|
||||
}
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, Math.min(CLEANUP_POLL_MS, Math.max(1, deadline - Date.now()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { ResolvedActionTarget } from "./interaction";
|
||||
import { type CdpRunner, isRpcError, sendToCdpTarget } from "./shared";
|
||||
|
||||
const CLEANUP_TIMEOUT_MS = 1_000;
|
||||
const DETACH_TIMEOUT_MS = 500;
|
||||
const ACTIVATION_GRACE_MS = 1_000;
|
||||
const PROBE_INTERVAL_MS = 20;
|
||||
|
||||
@@ -59,7 +60,7 @@ function remainingMs(deadline: number): number {
|
||||
}
|
||||
|
||||
async function waitBounded<T>(
|
||||
promise: Promise<T>,
|
||||
operation: () => Promise<T>,
|
||||
deadline: number,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMessage: string,
|
||||
@@ -78,7 +79,12 @@ async function waitBounded<T>(
|
||||
}
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, boundary]);
|
||||
const work = Promise.resolve().then(() => {
|
||||
if (signal?.aborted) throw new BoundedWaitError("aborted", "upload transaction aborted");
|
||||
if (remainingMs(deadline) === 0) throw new BoundedWaitError("timeout", timeoutMessage);
|
||||
return operation();
|
||||
});
|
||||
return await Promise.race([work, boundary]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
||||
@@ -122,13 +128,18 @@ function sameTarget(
|
||||
|
||||
async function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
await waitBounded(
|
||||
new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
() => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
Date.now() + ms + 1,
|
||||
signal,
|
||||
"upload activation probe timed out",
|
||||
);
|
||||
}
|
||||
|
||||
function waitErrorCode(error: unknown): RpcError["code"] {
|
||||
if (!(error instanceof BoundedWaitError)) return "cdp_failed";
|
||||
return error.kind === "aborted" ? "cancelled" : "timeout";
|
||||
}
|
||||
|
||||
async function callOnTrigger<T = RuntimeReply>(
|
||||
options: FileInputTransactionOptions,
|
||||
objectId: string,
|
||||
@@ -153,8 +164,8 @@ export async function uploadThroughActivatedFileInput(
|
||||
const objectGroup = `bsk-upload-${crypto.randomUUID()}`;
|
||||
const stateKey = `__bskUpload_${crypto.randomUUID().replaceAll("-", "")}`;
|
||||
const chooserEvents: ChooserEvent[] = [];
|
||||
let interceptionArmed = false;
|
||||
let probeArmed = false;
|
||||
let interceptionState: "not_started" | "possibly_active" | "active" = "not_started";
|
||||
let probeState: "not_started" | "possibly_active" | "active" = "not_started";
|
||||
let triggerObjectId: string | undefined;
|
||||
let outcome: FileInputTransactionResult | RpcError = transferFailure(
|
||||
"protocol_error",
|
||||
@@ -172,18 +183,20 @@ export async function uploadThroughActivatedFileInput(
|
||||
|
||||
try {
|
||||
try {
|
||||
interceptionState = "possibly_active";
|
||||
await waitBounded(
|
||||
sendToCdpTarget(options.cdp, target, "Page.setInterceptFileChooserDialog", {
|
||||
enabled: true,
|
||||
}),
|
||||
() =>
|
||||
sendToCdpTarget(options.cdp, target, "Page.setInterceptFileChooserDialog", {
|
||||
enabled: true,
|
||||
}),
|
||||
deadline,
|
||||
options.signal,
|
||||
"arming native file chooser interception timed out",
|
||||
);
|
||||
interceptionArmed = true;
|
||||
interceptionState = "active";
|
||||
} catch (err) {
|
||||
outcome = transferFailure(
|
||||
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
|
||||
waitErrorCode(err),
|
||||
"file_input_probe_failed",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
"none",
|
||||
@@ -194,15 +207,16 @@ export async function uploadThroughActivatedFileInput(
|
||||
|
||||
try {
|
||||
const resolved = await waitBounded(
|
||||
sendToCdpTarget<{ object?: { objectId?: string } }>(
|
||||
options.cdp,
|
||||
target,
|
||||
"DOM.resolveNode",
|
||||
{
|
||||
backendNodeId: options.actionTarget.backendNodeId,
|
||||
objectGroup,
|
||||
},
|
||||
),
|
||||
() =>
|
||||
sendToCdpTarget<{ object?: { objectId?: string } }>(
|
||||
options.cdp,
|
||||
target,
|
||||
"DOM.resolveNode",
|
||||
{
|
||||
backendNodeId: options.actionTarget.backendNodeId,
|
||||
objectGroup,
|
||||
},
|
||||
),
|
||||
deadline,
|
||||
options.signal,
|
||||
"resolving upload trigger timed out",
|
||||
@@ -210,11 +224,13 @@ export async function uploadThroughActivatedFileInput(
|
||||
triggerObjectId = resolved.object?.objectId;
|
||||
if (!triggerObjectId) throw new Error("DOM.resolveNode returned no trigger objectId");
|
||||
|
||||
probeState = "possibly_active";
|
||||
const armed = await waitBounded(
|
||||
callOnTrigger<RuntimeReply>(
|
||||
options,
|
||||
triggerObjectId,
|
||||
`function(key) {
|
||||
() =>
|
||||
callOnTrigger<RuntimeReply>(
|
||||
options,
|
||||
triggerObjectId as string,
|
||||
`function(key) {
|
||||
const doc = this.ownerDocument;
|
||||
const owner = doc.defaultView;
|
||||
if (!owner) return false;
|
||||
@@ -232,9 +248,9 @@ export async function uploadThroughActivatedFileInput(
|
||||
doc.addEventListener("click", state.listener, true);
|
||||
return true;
|
||||
}`,
|
||||
[stateKey],
|
||||
true,
|
||||
),
|
||||
[stateKey],
|
||||
true,
|
||||
),
|
||||
deadline,
|
||||
options.signal,
|
||||
"arming frame-scoped file input probe timed out",
|
||||
@@ -243,10 +259,10 @@ export async function uploadThroughActivatedFileInput(
|
||||
if (armError || armed.result?.value !== true) {
|
||||
throw armError ?? new Error("file input probe did not arm");
|
||||
}
|
||||
probeArmed = true;
|
||||
probeState = "active";
|
||||
} catch (err) {
|
||||
outcome = transferFailure(
|
||||
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
|
||||
waitErrorCode(err),
|
||||
"file_input_probe_failed",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
"none",
|
||||
@@ -258,14 +274,14 @@ export async function uploadThroughActivatedFileInput(
|
||||
let click: ClickResult | RpcError;
|
||||
try {
|
||||
click = await waitBounded(
|
||||
options.trigger(),
|
||||
options.trigger,
|
||||
deadline,
|
||||
options.signal,
|
||||
"upload trigger timed out",
|
||||
);
|
||||
} catch (err) {
|
||||
outcome = transferFailure(
|
||||
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
|
||||
waitErrorCode(err),
|
||||
"file_input_probe_failed",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
"none",
|
||||
@@ -283,17 +299,23 @@ export async function uploadThroughActivatedFileInput(
|
||||
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 reply = await waitBounded(
|
||||
() =>
|
||||
callOnTrigger<RuntimeReply>(
|
||||
options,
|
||||
triggerObjectId as string,
|
||||
`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,
|
||||
[stateKey],
|
||||
true,
|
||||
),
|
||||
activationDeadline,
|
||||
options.signal,
|
||||
"inspecting activated file input timed out",
|
||||
);
|
||||
const summaryError = runtimeError(reply, "failed to inspect activated file input");
|
||||
if (summaryError) throw summaryError;
|
||||
@@ -356,21 +378,33 @@ export async function uploadThroughActivatedFileInput(
|
||||
);
|
||||
return outcome;
|
||||
}
|
||||
const input = await callOnTrigger<RuntimeReply>(
|
||||
options,
|
||||
triggerObjectId,
|
||||
`function(key) { return this.ownerDocument.defaultView?.[key]?.inputs[0]; }`,
|
||||
[stateKey],
|
||||
false,
|
||||
const input = await waitBounded(
|
||||
() =>
|
||||
callOnTrigger<RuntimeReply>(
|
||||
options,
|
||||
triggerObjectId as string,
|
||||
`function(key) { return this.ownerDocument.defaultView?.[key]?.inputs[0]; }`,
|
||||
[stateKey],
|
||||
false,
|
||||
),
|
||||
deadline,
|
||||
options.signal,
|
||||
"resolving activated file input timed out",
|
||||
);
|
||||
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 },
|
||||
const described = await waitBounded(
|
||||
() =>
|
||||
sendToCdpTarget<{ node?: { backendNodeId?: number } }>(
|
||||
options.cdp,
|
||||
target,
|
||||
"DOM.describeNode",
|
||||
{ objectId: input.result?.objectId },
|
||||
),
|
||||
deadline,
|
||||
options.signal,
|
||||
"describing activated file input timed out",
|
||||
);
|
||||
backendNodeId = described.node?.backendNodeId;
|
||||
if (typeof backendNodeId !== "number") {
|
||||
@@ -389,17 +423,18 @@ export async function uploadThroughActivatedFileInput(
|
||||
|
||||
try {
|
||||
await waitBounded(
|
||||
sendToCdpTarget(options.cdp, target, "DOM.setFileInputFiles", {
|
||||
files: options.files,
|
||||
backendNodeId,
|
||||
}),
|
||||
() =>
|
||||
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",
|
||||
waitErrorCode(err),
|
||||
"set_file_input_failed",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
"unknown",
|
||||
@@ -411,7 +446,7 @@ export async function uploadThroughActivatedFileInput(
|
||||
return outcome;
|
||||
} catch (err) {
|
||||
outcome = transferFailure(
|
||||
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
|
||||
waitErrorCode(err),
|
||||
"file_input_probe_failed",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
"none",
|
||||
@@ -421,61 +456,70 @@ export async function uploadThroughActivatedFileInput(
|
||||
}
|
||||
} finally {
|
||||
chooserSubscription?.dispose();
|
||||
let cleanupFailed = false;
|
||||
if (probeArmed && triggerObjectId) {
|
||||
const cleanupDeadline = Date.now() + CLEANUP_TIMEOUT_MS;
|
||||
const cleanupTasks: Promise<boolean>[] = [];
|
||||
const cleanup = async (operation: () => Promise<unknown>, message: string) => {
|
||||
try {
|
||||
await waitBounded(
|
||||
callOnTrigger(
|
||||
options,
|
||||
triggerObjectId,
|
||||
`function(key) {
|
||||
await waitBounded(operation, cleanupDeadline, undefined, message);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if (probeState !== "not_started" && triggerObjectId) {
|
||||
cleanupTasks.push(
|
||||
cleanup(
|
||||
() =>
|
||||
callOnTrigger(
|
||||
options,
|
||||
triggerObjectId as string,
|
||||
`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,
|
||||
[stateKey],
|
||||
true,
|
||||
),
|
||||
"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;
|
||||
}
|
||||
if (interceptionState !== "not_started") {
|
||||
cleanupTasks.push(
|
||||
cleanup(
|
||||
() =>
|
||||
sendToCdpTarget(options.cdp, target, "Page.setInterceptFileChooserDialog", {
|
||||
enabled: false,
|
||||
cancel: true,
|
||||
}),
|
||||
"disabling file chooser interception timed out",
|
||||
),
|
||||
);
|
||||
}
|
||||
let cleanupFailed = (await Promise.all(cleanupTasks)).some((completed) => !completed);
|
||||
if (!cleanupFailed) {
|
||||
cleanupFailed = !(await cleanup(
|
||||
() => sendToCdpTarget(options.cdp, target, "Runtime.releaseObjectGroup", { objectGroup }),
|
||||
"releasing upload object group timed out",
|
||||
));
|
||||
}
|
||||
|
||||
const effect = isRpcError(outcome)
|
||||
? (outcome.data?.effect_state as TransferEffectState | undefined)
|
||||
: "committed";
|
||||
if (effect === "unknown" || cleanupFailed) {
|
||||
await options.cdp.detach?.(target.tabId);
|
||||
try {
|
||||
await waitBounded(
|
||||
() => options.cdp.detach?.(target.tabId) ?? Promise.resolve(),
|
||||
Date.now() + DETACH_TIMEOUT_MS,
|
||||
undefined,
|
||||
"detaching upload target timed out",
|
||||
);
|
||||
} catch {
|
||||
cleanupFailed = true;
|
||||
}
|
||||
}
|
||||
if (cleanupFailed && isRpcError(outcome)) {
|
||||
outcome.data = { ...outcome.data, cleanup_state: "failed" };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { OVERLAY_HOST_MARKER_ATTR, OVERLAY_HOST_NAME } from "../../../lib/overlay-bridge";
|
||||
import { captureViewModel, collectOverlayExcludedBackendIds } from "../capture";
|
||||
import { captureViewModel, collectOverlayExcludedBackendIds, probeHoverSurfaces } from "../capture";
|
||||
|
||||
// Minimal but format-accurate captureSnapshot reply: a body with one
|
||||
// fixed full-screen overlay div carrying a password input.
|
||||
@@ -36,7 +36,7 @@ function fakeSnapshotReply() {
|
||||
},
|
||||
layout: {
|
||||
nodeIndex: [1, 2, 3],
|
||||
// styles columns follow requested computedStyles order: [position, pointer-events]
|
||||
// Legacy fixture: omitted style columns fall back to visible defaults.
|
||||
styles: [
|
||||
[i("static"), i("auto")],
|
||||
[i("fixed"), i("auto")],
|
||||
@@ -251,11 +251,10 @@ describe("captureViewModel", () => {
|
||||
expect(input?.attrs).toEqual({ type: "password" });
|
||||
});
|
||||
|
||||
it("does not run conditional surface probes by default", async () => {
|
||||
it("never hovers the page while capturing", async () => {
|
||||
const cdp = makeCdp(fakeSnapshotReply());
|
||||
const result = await captureViewModel(cdp, 4);
|
||||
await captureViewModel(cdp, 4);
|
||||
|
||||
expect(result.surfaceProbes).toEqual([]);
|
||||
expect(cdp.send).not.toHaveBeenCalledWith(4, "Input.dispatchMouseEvent", expect.anything());
|
||||
});
|
||||
|
||||
@@ -319,9 +318,10 @@ describe("captureViewModel", () => {
|
||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||
};
|
||||
|
||||
const result = await captureViewModel(cdp, 4, { conditionalSurfaceProbe: true });
|
||||
const captured = await captureViewModel(cdp, 4);
|
||||
const surfaceProbes = await probeHoverSurfaces(cdp, 4, captured.nodes);
|
||||
|
||||
expect(result.surfaceProbes).toEqual([
|
||||
expect(surfaceProbes).toEqual([
|
||||
{
|
||||
triggerBackendNodeId: 12,
|
||||
triggerPoint: { x: 956, y: 32 },
|
||||
@@ -378,9 +378,10 @@ describe("captureViewModel", () => {
|
||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||
};
|
||||
|
||||
const result = await captureViewModel(cdp, 4, { conditionalSurfaceProbe: true });
|
||||
const captured = await captureViewModel(cdp, 4);
|
||||
const surfaceProbes = await probeHoverSurfaces(cdp, 4, captured.nodes);
|
||||
|
||||
expect(result.surfaceProbes).toEqual([
|
||||
expect(surfaceProbes).toEqual([
|
||||
expect.objectContaining({
|
||||
triggerBackendNodeId: 12,
|
||||
subItems: ["My profile", "Sign out"],
|
||||
@@ -422,7 +423,8 @@ describe("captureViewModel", () => {
|
||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||
};
|
||||
|
||||
await captureViewModel(cdp, 4, { conditionalSurfaceProbe: true });
|
||||
const captured = await captureViewModel(cdp, 4);
|
||||
await probeHoverSurfaces(cdp, 4, captured.nodes);
|
||||
|
||||
expect(hoverMoves).toBe(1);
|
||||
});
|
||||
@@ -682,6 +684,46 @@ describe("captureViewModel", () => {
|
||||
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("subtracts scroll offset to make rects viewport-relative", async () => {
|
||||
const cdp = {
|
||||
send: vi.fn(async (_t: number, method: string) => {
|
||||
|
||||
@@ -132,4 +132,67 @@ 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 } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ProbeBudget, resetOverlayBypassDepth, withOverlayBypass } from "../hover-perception";
|
||||
|
||||
afterEach(() => {
|
||||
resetOverlayBypassDepth();
|
||||
});
|
||||
|
||||
describe("ProbeBudget", () => {
|
||||
it("refuses a candidate that cannot finish inside the budget", () => {
|
||||
const budget = new ProbeBudget(500);
|
||||
|
||||
expect(budget.canAfford(400)).toBe(true);
|
||||
expect(budget.canAfford(600)).toBe(false);
|
||||
});
|
||||
|
||||
it("stops admitting candidates once the remaining budget is smaller than one", async () => {
|
||||
const budget = new ProbeBudget(60);
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
|
||||
// A top-of-loop `elapsed > total` check would still admit this candidate
|
||||
// and then overshoot by its full cost.
|
||||
expect(budget.canAfford(50)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withOverlayBypass", () => {
|
||||
it("toggles the overlay once around nested probe phases", async () => {
|
||||
const bypass = vi.fn(async () => {});
|
||||
|
||||
await withOverlayBypass(bypass, 4, async () => {
|
||||
await withOverlayBypass(bypass, 4, async () => {
|
||||
expect(bypass).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(bypass).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(bypass.mock.calls).toEqual([
|
||||
[4, true],
|
||||
[4, false],
|
||||
]);
|
||||
});
|
||||
|
||||
it("restores the overlay when the probe throws", async () => {
|
||||
const bypass = vi.fn(async () => {});
|
||||
|
||||
await expect(
|
||||
withOverlayBypass(bypass, 4, async () => {
|
||||
throw new Error("probe exploded");
|
||||
}),
|
||||
).rejects.toThrow("probe exploded");
|
||||
|
||||
expect(bypass).toHaveBeenLastCalledWith(4, false);
|
||||
});
|
||||
|
||||
it("reports a failed restore instead of swallowing it", async () => {
|
||||
const onRestoreFailure = vi.fn();
|
||||
const bypass = vi.fn(async (_tabId: number, enabled: boolean) => {
|
||||
if (!enabled) throw new Error("tab is gone");
|
||||
});
|
||||
|
||||
await withOverlayBypass(bypass, 4, async () => undefined, { onRestoreFailure });
|
||||
|
||||
expect(onRestoreFailure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not pin the overlay off for the tab when a restore fails", async () => {
|
||||
const failing = vi.fn(async (_tabId: number, enabled: boolean) => {
|
||||
if (!enabled) throw new Error("tab is gone");
|
||||
});
|
||||
await withOverlayBypass(failing, 4, async () => undefined, { onRestoreFailure: () => {} });
|
||||
|
||||
const bypass = vi.fn(async () => {});
|
||||
await withOverlayBypass(bypass, 4, async () => undefined);
|
||||
|
||||
expect(bypass.mock.calls).toEqual([
|
||||
[4, true],
|
||||
[4, false],
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs the probe untouched when no bypass is wired up", async () => {
|
||||
await expect(withOverlayBypass(undefined, 4, async () => "done")).resolves.toBe("done");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
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("remembers controls that revealed no tooltip so the miss is paid once", async () => {
|
||||
const cdp: CdpRunner = {
|
||||
send: vi.fn(async () => ({})) as CdpRunner["send"],
|
||||
sendToTarget: vi.fn(async (_target, method) => {
|
||||
if (method === "Page.createIsolatedWorld") return { executionContextId: 31 };
|
||||
if (method === "DOM.resolveNode") return { object: { objectId: "button-801" } };
|
||||
if (method === "Runtime.callFunctionOn") return { result: { value: [] } };
|
||||
return {};
|
||||
}) as CdpRunner["sendToTarget"],
|
||||
};
|
||||
|
||||
const documents = [
|
||||
document({ tabId: 11, sessionId: "miss-session" }, 801, undefined, "miss-frame"),
|
||||
];
|
||||
const graph = semantics(documents);
|
||||
|
||||
const first = await probeTooltipNames(cdp, 11, documents, graph);
|
||||
expect(first.size).toBe(0);
|
||||
const hoversAfterFirst = vi
|
||||
.mocked(cdp.send)
|
||||
.mock.calls.filter(([, method]) => method === "Input.dispatchMouseEvent").length;
|
||||
expect(hoversAfterFirst).toBeGreaterThan(0);
|
||||
|
||||
const second = await probeTooltipNames(cdp, 11, documents, graph);
|
||||
expect(second.size).toBe(0);
|
||||
expect(
|
||||
vi.mocked(cdp.send).mock.calls.filter(([, method]) => method === "Input.dispatchMouseEvent")
|
||||
.length,
|
||||
).toBe(hoversAfterFirst);
|
||||
});
|
||||
|
||||
it("keeps tooltip evidence isolated across sibling frame sessions", async () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,782 @@
|
||||
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,16 +1,17 @@
|
||||
// CDP capture adapter: DOMSnapshot.captureSnapshot + Page.getLayoutMetrics
|
||||
// → CapturedNode[] + Viewport. This is the ONLY VOM module that touches
|
||||
// raw CDP. The captureSnapshot reply is columnar (parallel arrays + a
|
||||
// shared string table); we request exactly three computed styles so the
|
||||
// `styles` columns are [position, pointer-events, cursor] in that order.
|
||||
// shared string table); requested computed styles are decoded through
|
||||
// STYLE_COL so capture and visibility policy share one explicit contract.
|
||||
|
||||
import type { Rect, Viewport } from "@browser-skill/vom";
|
||||
import { evaluateHoverTrigger } from "@/lib/hover-trigger-policy";
|
||||
import { isOverlayHostNode, OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge";
|
||||
import { childFrameProjection, type GeometryProjection, projectRectToViewport } from "../geometry";
|
||||
import type { CdpRunner } from "../shared";
|
||||
import { clearHover, ProbeBudget, waitForHover } from "./hover-perception";
|
||||
|
||||
const REQUESTED_STYLES = ["position", "pointer-events", "cursor"] as const;
|
||||
const REQUESTED_STYLES = ["position", "pointer-events", "cursor", "visibility", "opacity"] as const;
|
||||
const STYLE_COL = Object.fromEntries(
|
||||
REQUESTED_STYLES.map((name, index) => [name, index]),
|
||||
) as Record<(typeof REQUESTED_STYLES)[number], number>;
|
||||
@@ -38,6 +39,12 @@ export interface CapturedNode {
|
||||
* `textContent`: the live parser always sets it, hand-built fixtures may not.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
* Whether the live DOM snapshot provides a painted, non-hidden box for this
|
||||
* node. Semantic resolution uses this only for DOM fallback nodes; AX-backed
|
||||
* nodes remain authoritative even when they are outside the viewport.
|
||||
*/
|
||||
rendered?: boolean;
|
||||
textContent?: string;
|
||||
formState?: "empty" | "filled" | "default";
|
||||
formValue?: string;
|
||||
@@ -64,14 +71,11 @@ export interface CapturedViewModel {
|
||||
/** Explicit DOMSnapshot frame ancestry; never inferred from backend node ids. */
|
||||
frameParentIds?: Map<string, string>;
|
||||
rootFrameId?: string;
|
||||
surfaceProbes?: CapturedSurfaceProbe[];
|
||||
/** Backend node ids belonging to the agent overlay host + its shadow subtree. */
|
||||
excludedBackendNodeIds: Set<number>;
|
||||
}
|
||||
|
||||
export interface CaptureViewModelOptions {
|
||||
conditionalSurfaceProbe?: boolean;
|
||||
hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
@@ -361,7 +365,16 @@ function collectBackendIdsFromDomNode(node: CdpDomNode | undefined, out: Set<num
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_HOVER_PROBE_MS = 2_000;
|
||||
/**
|
||||
* Ceiling for the whole hover-surface phase.
|
||||
*
|
||||
* The previous 2000 was only a floor: the loop checked elapsed time at the top,
|
||||
* so a candidate could start with 1ms left and still run its two settle
|
||||
* windows, pushing real cost to ~2.6s. This is that true ceiling, now actually
|
||||
* enforced by an up-front affordability check, so the same number of candidates
|
||||
* get probed under a bound that no longer lies.
|
||||
*/
|
||||
const MAX_HOVER_PROBE_MS = 2_600;
|
||||
const MAX_HOVER_TRIGGERS = 6;
|
||||
const MAX_HOVER_SURFACES = 3;
|
||||
const HOVER_SETTLE_MS = 300;
|
||||
@@ -460,36 +473,6 @@ 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 {
|
||||
const value =
|
||||
node.attrs["aria-label"] ?? node.attrs.title ?? node.attrs.alt ?? node.textContent ?? "";
|
||||
@@ -637,13 +620,35 @@ function confidenceForHover(
|
||||
return "low";
|
||||
}
|
||||
|
||||
async function probeHoverSurfaces(
|
||||
/**
|
||||
* One candidate costs two settle windows (baseline + post-hover) plus a few
|
||||
* CDP round trips. Used to decide whether the next candidate still fits the
|
||||
* budget before paying for it.
|
||||
*/
|
||||
const HOVER_CANDIDATE_COST_MS = HOVER_SETTLE_MS * 2;
|
||||
|
||||
export interface HoverSurfaceProbeOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hovers a bounded set of likely menu triggers and reports the sub-items each
|
||||
* one reveals.
|
||||
*
|
||||
* Runs after DOM *and* accessibility capture. Hovering can open menus and
|
||||
* change layout, so probing between the two captures would leave the DOM half
|
||||
* of an observation describing the page before the change and the AX half
|
||||
* describing it after.
|
||||
*
|
||||
* The caller owns the overlay bypass span (see `withOverlayBypass`).
|
||||
*/
|
||||
export async function probeHoverSurfaces(
|
||||
cdp: CdpRunner,
|
||||
tabId: number,
|
||||
nodes: CapturedNode[],
|
||||
options: CaptureViewModelOptions,
|
||||
options: HoverSurfaceProbeOptions = {},
|
||||
): Promise<CapturedSurfaceProbe[]> {
|
||||
const started = Date.now();
|
||||
const budget = new ProbeBudget(MAX_HOVER_PROBE_MS);
|
||||
try {
|
||||
throwIfAborted(options.signal);
|
||||
const cssScan = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
||||
@@ -658,60 +663,54 @@ async function probeHoverSurfaces(
|
||||
const results: CapturedSurfaceProbe[] = [];
|
||||
const seen = new Set<number>();
|
||||
throwIfAborted(options.signal);
|
||||
await options.hoverProbeBypassOverlay?.(tabId, true).catch(() => undefined);
|
||||
try {
|
||||
for (const candidate of candidates.slice(0, MAX_HOVER_TRIGGERS)) {
|
||||
throwIfAborted(options.signal);
|
||||
for (const candidate of candidates.slice(0, MAX_HOVER_TRIGGERS)) {
|
||||
if (!budget.canAfford(HOVER_CANDIDATE_COST_MS)) break;
|
||||
if (results.length >= MAX_HOVER_SURFACES) break;
|
||||
if (seen.has(candidate.backendNodeId)) continue;
|
||||
try {
|
||||
await clearHover(cdp, tabId);
|
||||
throwIfAborted(options.signal);
|
||||
if (Date.now() - started > MAX_HOVER_PROBE_MS) break;
|
||||
if (results.length >= MAX_HOVER_SURFACES) break;
|
||||
if (seen.has(candidate.backendNodeId)) continue;
|
||||
try {
|
||||
await clearHover(cdp, tabId);
|
||||
throwIfAborted(options.signal);
|
||||
await wait(HOVER_SETTLE_MS, options.signal);
|
||||
const baselineReply = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
||||
expression: hoverStateExpression(),
|
||||
returnByValue: true,
|
||||
});
|
||||
throwIfAborted(options.signal);
|
||||
const baselineItems = runtimeValue<HoverRuntimeItem[]>(baselineReply) ?? [];
|
||||
await waitForHover(HOVER_SETTLE_MS, options.signal);
|
||||
const baselineReply = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
||||
expression: hoverStateExpression(),
|
||||
returnByValue: true,
|
||||
});
|
||||
throwIfAborted(options.signal);
|
||||
const baselineItems = runtimeValue<HoverRuntimeItem[]>(baselineReply) ?? [];
|
||||
|
||||
throwIfAborted(options.signal);
|
||||
await cdp.send(tabId, "Input.dispatchMouseEvent", {
|
||||
type: "mouseMoved",
|
||||
x: candidate.x,
|
||||
y: candidate.y,
|
||||
});
|
||||
throwIfAborted(options.signal);
|
||||
await wait(HOVER_SETTLE_MS, options.signal);
|
||||
const collected = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
||||
expression: hoverStateExpression(),
|
||||
returnByValue: true,
|
||||
});
|
||||
throwIfAborted(options.signal);
|
||||
const subItems = diffHoverItems(
|
||||
baselineItems,
|
||||
runtimeValue<HoverRuntimeItem[]>(collected) ?? [],
|
||||
);
|
||||
if (subItems.length === 0) continue;
|
||||
seen.add(candidate.backendNodeId);
|
||||
results.push({
|
||||
triggerBackendNodeId: candidate.backendNodeId,
|
||||
triggerPoint: { x: candidate.x, y: candidate.y },
|
||||
triggerAction: "hover",
|
||||
subItems,
|
||||
confidence: confidenceForHover(candidate, subItems),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) throw error;
|
||||
continue;
|
||||
} finally {
|
||||
await clearHover(cdp, tabId);
|
||||
}
|
||||
throwIfAborted(options.signal);
|
||||
await cdp.send(tabId, "Input.dispatchMouseEvent", {
|
||||
type: "mouseMoved",
|
||||
x: candidate.x,
|
||||
y: candidate.y,
|
||||
});
|
||||
throwIfAborted(options.signal);
|
||||
await waitForHover(HOVER_SETTLE_MS, options.signal);
|
||||
const collected = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
||||
expression: hoverStateExpression(),
|
||||
returnByValue: true,
|
||||
});
|
||||
throwIfAborted(options.signal);
|
||||
const subItems = diffHoverItems(
|
||||
baselineItems,
|
||||
runtimeValue<HoverRuntimeItem[]>(collected) ?? [],
|
||||
);
|
||||
if (subItems.length === 0) continue;
|
||||
seen.add(candidate.backendNodeId);
|
||||
results.push({
|
||||
triggerBackendNodeId: candidate.backendNodeId,
|
||||
triggerPoint: { x: candidate.x, y: candidate.y },
|
||||
triggerAction: "hover",
|
||||
subItems,
|
||||
confidence: confidenceForHover(candidate, subItems),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) throw error;
|
||||
continue;
|
||||
} finally {
|
||||
await clearHover(cdp, tabId);
|
||||
}
|
||||
} finally {
|
||||
await options.hoverProbeBypassOverlay?.(tabId, false).catch(() => undefined);
|
||||
}
|
||||
return results;
|
||||
} catch (err) {
|
||||
@@ -817,6 +816,8 @@ function parseDocumentNodes(
|
||||
const posCol = STYLE_COL.position;
|
||||
const peCol = STYLE_COL["pointer-events"];
|
||||
const cursorCol = STYLE_COL.cursor;
|
||||
const visibilityCol = STYLE_COL.visibility;
|
||||
const opacityCol = STYLE_COL.opacity;
|
||||
const inputValues = sparseIndexMap(dn.inputValue);
|
||||
const textValues = sparseIndexMap(dn.textValue);
|
||||
const checkedInputs = new Set(dn.inputChecked?.index ?? []);
|
||||
@@ -861,6 +862,7 @@ function parseDocumentNodes(
|
||||
let position = "static";
|
||||
let pointerEvents = "auto";
|
||||
let cursor = "auto";
|
||||
let rendered = false;
|
||||
const li = layoutByNode.get(n);
|
||||
if (li !== undefined) {
|
||||
const b = dl?.bounds?.[li];
|
||||
@@ -881,6 +883,13 @@ function parseDocumentNodes(
|
||||
position = str(strings, styleRow[posCol]) || "static";
|
||||
pointerEvents = str(strings, styleRow[peCol]) || "auto";
|
||||
cursor = str(strings, styleRow[cursorCol]) || "auto";
|
||||
const visibility = str(strings, styleRow[visibilityCol]) || "visible";
|
||||
const opacity = str(strings, styleRow[opacityCol]) || "1";
|
||||
rendered =
|
||||
localRect !== null &&
|
||||
visibility !== "hidden" &&
|
||||
visibility !== "collapse" &&
|
||||
(Number.parseFloat(opacity) || 0) > 0;
|
||||
}
|
||||
|
||||
// Skip non-element nodes (#text, #cdata-section, etc.) — they carry no
|
||||
@@ -916,6 +925,7 @@ function parseDocumentNodes(
|
||||
position,
|
||||
pointerEvents,
|
||||
cursor,
|
||||
rendered,
|
||||
textContent,
|
||||
...(formValue !== undefined ? { formValue } : {}),
|
||||
...(tag === "input" || tag === "textarea"
|
||||
@@ -1020,7 +1030,13 @@ export async function captureViewModel(
|
||||
options: CaptureViewModelOptions = {},
|
||||
): Promise<CapturedViewModel> {
|
||||
throwIfAborted(options.signal);
|
||||
const metrics = await cdp.send<LayoutMetricsReply>(tabId, "Page.getLayoutMetrics", {});
|
||||
let metrics: LayoutMetricsReply = {};
|
||||
try {
|
||||
metrics = await cdp.send<LayoutMetricsReply>(tabId, "Page.getLayoutMetrics", {});
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) throw error;
|
||||
console.debug("[bsk capture] layout metrics unavailable", error);
|
||||
}
|
||||
throwIfAborted(options.signal);
|
||||
const dpr = devicePixelRatio(metrics);
|
||||
const vpSrc = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
|
||||
@@ -1048,7 +1064,6 @@ export async function captureViewModel(
|
||||
nodes: [],
|
||||
viewport,
|
||||
iframeNodes: new Map(),
|
||||
surfaceProbes: [],
|
||||
excludedBackendNodeIds: new Set(),
|
||||
};
|
||||
}
|
||||
@@ -1077,11 +1092,6 @@ export async function captureViewModel(
|
||||
await enrichFormControlStates(cdp, tabId, [nodes, ...iframeNodes.values()], options.signal);
|
||||
throwIfAborted(options.signal);
|
||||
|
||||
const surfaceProbes = options.conditionalSurfaceProbe
|
||||
? await probeHoverSurfaces(cdp, tabId, nodes, options)
|
||||
: [];
|
||||
throwIfAborted(options.signal);
|
||||
|
||||
const frameNodes = new Map<string, CapturedNode[]>();
|
||||
if (topContext.frameId) frameNodes.set(topContext.frameId, nodes);
|
||||
for (const iframeFrameNodes of iframeNodes.values()) {
|
||||
@@ -1102,7 +1112,6 @@ export async function captureViewModel(
|
||||
frameOwnerBackendNodeIds: frameParsed.frameOwnerBackendNodeIds,
|
||||
frameParentIds: frameParsed.frameParentIds,
|
||||
...(topContext.frameId ? { rootFrameId: topContext.frameId } : {}),
|
||||
surfaceProbes,
|
||||
excludedBackendNodeIds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,25 +33,19 @@ async function discoverFrameGraph(cdp: CdpRunner, tabId: number): Promise<CdpFra
|
||||
|
||||
function transformFrameNodes(
|
||||
nodes: CapturedNode[],
|
||||
projection: GeometryProjection,
|
||||
projection: GeometryProjection | null,
|
||||
): CapturedNode[] {
|
||||
return nodes.map((node) => ({
|
||||
...node,
|
||||
rect: (() => {
|
||||
const bounds = projectRectToViewport(node.rect, projection);
|
||||
return bounds ? { x: bounds.x, y: bounds.y, w: bounds.width, h: bounds.height } : null;
|
||||
})(),
|
||||
rect: projection
|
||||
? (() => {
|
||||
const bounds = projectRectToViewport(node.rect, projection);
|
||||
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(
|
||||
cdp: CdpRunner,
|
||||
tabId: number,
|
||||
@@ -65,14 +59,20 @@ async function captureMissingFrameDocuments(
|
||||
|
||||
for (const frame of graph.frames) {
|
||||
if (!frame.target.sessionId || captured.frameNodes.has(frame.frameId)) continue;
|
||||
const owner = frameOwnerNode(captured, frame);
|
||||
if (!owner?.rect || frame.ownerBackendNodeId === undefined) continue;
|
||||
try {
|
||||
const child = await captureViewModel(cdpRunnerForTarget(cdp, frame.target), tabId, {
|
||||
signal,
|
||||
});
|
||||
const projection = await resolveFrameProjection(cdp, graph, frame.frameId);
|
||||
if (!projection) continue;
|
||||
let projection: GeometryProjection | null = null;
|
||||
try {
|
||||
projection = await resolveFrameProjection(cdp, graph, frame.frameId);
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) throw err;
|
||||
console.debug("[bsk observation] child frame geometry projection failed", {
|
||||
frameId: frame.frameId,
|
||||
err,
|
||||
});
|
||||
}
|
||||
const rootFrameId = child.rootFrameId ?? frame.frameId;
|
||||
const childFrames = child.frameNodes ?? new Map<string, CapturedNode[]>();
|
||||
if (!childFrames.has(rootFrameId)) childFrames.set(rootFrameId, child.nodes);
|
||||
@@ -85,11 +85,13 @@ async function captureMissingFrameDocuments(
|
||||
for (const [childFrameId, nodes] of childFrames) {
|
||||
captured.frameNodes.set(childFrameId, transformFrameNodes(nodes, projection));
|
||||
}
|
||||
captured.iframeNodes.set(
|
||||
frame.ownerBackendNodeId,
|
||||
captured.frameNodes.get(frame.frameId) ?? [],
|
||||
);
|
||||
captured.frameOwnerBackendNodeIds.set(frame.frameId, frame.ownerBackendNodeId);
|
||||
if (frame.ownerBackendNodeId !== undefined) {
|
||||
captured.iframeNodes.set(
|
||||
frame.ownerBackendNodeId,
|
||||
captured.frameNodes.get(frame.frameId) ?? [],
|
||||
);
|
||||
captured.frameOwnerBackendNodeIds.set(frame.frameId, frame.ownerBackendNodeId);
|
||||
}
|
||||
if (frame.parentFrameId) captured.frameParentIds.set(frame.frameId, frame.parentFrameId);
|
||||
for (const [childFrameId, ownerBackendNodeId] of child.frameOwnerBackendNodeIds ?? []) {
|
||||
captured.frameOwnerBackendNodeIds.set(childFrameId, ownerBackendNodeId);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { CdpRunner } from "../shared";
|
||||
|
||||
function abortError(): Error {
|
||||
const error = new Error("hover perception aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
export async function waitForHover(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) throw abortError();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(abortError());
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export 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 cleanup.
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall-clock budget for a probe phase.
|
||||
*
|
||||
* Callers ask whether the *next* candidate still fits before starting it,
|
||||
* rather than checking elapsed time at the top of the loop. A top-of-loop
|
||||
* check lets a candidate start with 1ms of budget left and then run to
|
||||
* completion, overshooting by a full settle window.
|
||||
*/
|
||||
export class ProbeBudget {
|
||||
private readonly startedAt = Date.now();
|
||||
|
||||
constructor(private readonly totalMs: number) {}
|
||||
|
||||
get elapsedMs(): number {
|
||||
return Date.now() - this.startedAt;
|
||||
}
|
||||
|
||||
/** True when a candidate costing `estimatedMs` still fits in the budget. */
|
||||
canAfford(estimatedMs: number): boolean {
|
||||
return this.elapsedMs + estimatedMs <= this.totalMs;
|
||||
}
|
||||
}
|
||||
|
||||
export type OverlayBypass = (tabId: number, enabled: boolean) => Promise<void>;
|
||||
|
||||
export interface OverlayBypassScopeOptions {
|
||||
/**
|
||||
* Called when the overlay could not be restored. Defaults to `console.error`
|
||||
* so a stuck-bypassed overlay is never silent; tests inject a spy.
|
||||
*/
|
||||
onRestoreFailure?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
const overlayBypassDepth = new Map<number, number>();
|
||||
|
||||
/**
|
||||
* Runs `body` with the agent overlay bypassed, restoring it afterwards.
|
||||
*
|
||||
* Reference counted per tab so nested probe phases share a single bypass span
|
||||
* instead of toggling the overlay once per phase. Restore failures are
|
||||
* reported rather than swallowed, and the depth is released even when restore
|
||||
* throws so a failure cannot pin the overlay off for the tab's lifetime.
|
||||
*/
|
||||
export async function withOverlayBypass<T>(
|
||||
bypass: OverlayBypass | undefined,
|
||||
tabId: number,
|
||||
body: () => Promise<T>,
|
||||
options: OverlayBypassScopeOptions = {},
|
||||
): Promise<T> {
|
||||
if (!bypass) return body();
|
||||
|
||||
const depth = overlayBypassDepth.get(tabId) ?? 0;
|
||||
overlayBypassDepth.set(tabId, depth + 1);
|
||||
if (depth === 0) {
|
||||
try {
|
||||
await bypass(tabId, true);
|
||||
} catch (error) {
|
||||
// Failing to hide the overlay only risks probing our own UI, so continue.
|
||||
console.debug("[bsk hover] overlay bypass could not be enabled", error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await body();
|
||||
} finally {
|
||||
const current = overlayBypassDepth.get(tabId) ?? 1;
|
||||
if (current <= 1) {
|
||||
overlayBypassDepth.delete(tabId);
|
||||
try {
|
||||
await bypass(tabId, false);
|
||||
} catch (error) {
|
||||
(options.onRestoreFailure ?? defaultRestoreFailure)(error);
|
||||
}
|
||||
} else {
|
||||
overlayBypassDepth.set(tabId, current - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function defaultRestoreFailure(error: unknown): void {
|
||||
console.error("[bsk hover] agent overlay could not be restored after probing", error);
|
||||
}
|
||||
|
||||
/** Test seam: drops any bypass depth left behind by a crashed probe. */
|
||||
export function resetOverlayBypassDepth(): void {
|
||||
overlayBypassDepth.clear();
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { isVomReferenceNode } from "@browser-skill/vom";
|
||||
import { type CdpRunner, sendToCdpTarget } from "../shared";
|
||||
import type { FrameDocument, FrameOwnedAxNode } from "./frame-document";
|
||||
import { clearHover, ProbeBudget, waitForHover } from "./hover-perception";
|
||||
import { stableIdentifierName } from "./semantic-graph/name-evidence";
|
||||
import { frameBackendKey, type ResolvedSemanticGraph } from "./semantic-graph/types";
|
||||
|
||||
const MAX_TOOLTIP_CANDIDATES = 8;
|
||||
const MAX_TOOLTIP_PROBE_MS = 2_400;
|
||||
const TOOLTIP_SETTLE_MS = 250;
|
||||
const CACHE_TTL_MS = 5 * 60_000;
|
||||
/**
|
||||
* Misses expire sooner than hits: a control that gains a tooltip once the app
|
||||
* finishes booting should still be discoverable, but not at the cost of
|
||||
* re-probing every unlabelled control on every observation.
|
||||
*/
|
||||
const MISS_CACHE_TTL_MS = 60_000;
|
||||
const MAX_CACHE_ENTRIES = 500;
|
||||
const FORM_CONTROL_TAGS = new Set(["input", "select", "textarea"]);
|
||||
|
||||
interface TooltipCacheEntry {
|
||||
/** `null` records "probed, revealed no tooltip" so the miss is not re-paid. */
|
||||
name: string | null;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface TooltipCandidate<T extends FrameOwnedAxNode> {
|
||||
document: FrameDocument<T>;
|
||||
backendNodeId: number;
|
||||
x: number;
|
||||
y: number;
|
||||
stableIdentifier: boolean;
|
||||
}
|
||||
|
||||
export interface TooltipNameProbeOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const tooltipCache = new Map<string, TooltipCacheEntry>();
|
||||
|
||||
function cacheKey<T extends FrameOwnedAxNode>(
|
||||
document: FrameDocument<T>,
|
||||
backendNodeId: number,
|
||||
): string {
|
||||
return `${document.target.tabId}\u0000${document.frameId}\u0000${document.url ?? ""}\u0000${backendNodeId}`;
|
||||
}
|
||||
|
||||
/** Returns the cache entry, or `undefined` when this control was never probed. */
|
||||
function cachedEntry<T extends FrameOwnedAxNode>(
|
||||
document: FrameDocument<T>,
|
||||
backendNodeId: number,
|
||||
): TooltipCacheEntry | undefined {
|
||||
const key = cacheKey(document, backendNodeId);
|
||||
const entry = tooltipCache.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
tooltipCache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function storeName<T extends FrameOwnedAxNode>(
|
||||
document: FrameDocument<T>,
|
||||
backendNodeId: number,
|
||||
name: string | null,
|
||||
): void {
|
||||
if (tooltipCache.size >= MAX_CACHE_ENTRIES) {
|
||||
const oldest = tooltipCache.keys().next().value;
|
||||
if (oldest !== undefined) tooltipCache.delete(oldest);
|
||||
}
|
||||
tooltipCache.set(cacheKey(document, backendNodeId), {
|
||||
name,
|
||||
expiresAt: Date.now() + (name === null ? MISS_CACHE_TTL_MS : CACHE_TTL_MS),
|
||||
});
|
||||
}
|
||||
|
||||
function tooltipCollectorFunction(): string {
|
||||
return `function() {
|
||||
const ownRoot = this && typeof this.getRootNode === "function" ? this.getRootNode() : null;
|
||||
const roots = ownRoot && ownRoot !== document ? [ownRoot, document] : [document];
|
||||
const items = [];
|
||||
const seen = new Set();
|
||||
for (const root of roots) {
|
||||
for (const el of Array.from(root.querySelectorAll("[role='tooltip']"))) {
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) continue;
|
||||
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") continue;
|
||||
const text = String(el.getAttribute("aria-label") || el.getAttribute("title") || el.textContent || "")
|
||||
.replace(/\\s+/g, " ")
|
||||
.trim();
|
||||
if (!text || text.length > 160 || seen.has(text)) continue;
|
||||
seen.add(text);
|
||||
items.push(text);
|
||||
}
|
||||
}
|
||||
return items.slice(0, 20);
|
||||
}`;
|
||||
}
|
||||
|
||||
function cleanTooltipDiff(before: string[], after: string[]): string | undefined {
|
||||
const existing = new Set(before.map((value) => value.toLowerCase()));
|
||||
const added = [...new Set(after.map((value) => value.replace(/\s+/g, " ").trim()))].filter(
|
||||
(value) => value && !existing.has(value.toLowerCase()),
|
||||
);
|
||||
return added.length === 1 ? added[0] : undefined;
|
||||
}
|
||||
|
||||
function candidates<T extends FrameOwnedAxNode>(
|
||||
documents: FrameDocument<T>[],
|
||||
graph: ResolvedSemanticGraph,
|
||||
): TooltipCandidate<T>[] {
|
||||
const documentByFrame = new Map(documents.map((document) => [document.frameId, document]));
|
||||
const byFrame = new Map<string, TooltipCandidate<T>[]>();
|
||||
for (const node of graph.nodes.values()) {
|
||||
if (
|
||||
node.vom.name ||
|
||||
FORM_CONTROL_TAGS.has(node.dom?.tag.toLowerCase() ?? "") ||
|
||||
!node.referenceable ||
|
||||
node.backendNodeId === undefined ||
|
||||
!isVomReferenceNode({
|
||||
...node.vom,
|
||||
id: 0,
|
||||
parentId: null,
|
||||
backendNodeId: node.backendNodeId,
|
||||
frameId: node.frameId,
|
||||
referenceable: true,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const document = documentByFrame.get(node.frameId);
|
||||
const rect = node.dom?.rect;
|
||||
if (!document || !rect || rect.w <= 0 || rect.h <= 0) continue;
|
||||
if (
|
||||
rect.x + rect.w <= 0 ||
|
||||
rect.y + rect.h <= 0 ||
|
||||
rect.x >= graph.viewport.width ||
|
||||
rect.y >= graph.viewport.height
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
node.vom.disabled ||
|
||||
node.vom.inert ||
|
||||
node.vom.pointerEvents === "none" ||
|
||||
node.vom.sensitive
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const frameCandidates = byFrame.get(node.frameId) ?? [];
|
||||
frameCandidates.push({
|
||||
document,
|
||||
backendNodeId: node.backendNodeId,
|
||||
x: (Math.max(0, rect.x) + Math.min(graph.viewport.width, rect.x + rect.w)) / 2,
|
||||
y: (Math.max(0, rect.y) + Math.min(graph.viewport.height, rect.y + rect.h)) / 2,
|
||||
stableIdentifier: stableIdentifierName(node.dom?.attrs ?? {}) !== undefined,
|
||||
});
|
||||
byFrame.set(node.frameId, frameCandidates);
|
||||
}
|
||||
|
||||
const out: TooltipCandidate<T>[] = [];
|
||||
for (const stableIdentifier of [true, false]) {
|
||||
const queues = documents.map((document) =>
|
||||
(byFrame.get(document.frameId) ?? []).filter(
|
||||
(candidate) => candidate.stableIdentifier === stableIdentifier,
|
||||
),
|
||||
);
|
||||
for (let index = 0; queues.some((queue) => index < queue.length); index += 1) {
|
||||
for (const queue of queues) {
|
||||
const candidate = queue[index];
|
||||
if (candidate) out.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function executionContextId<T extends FrameOwnedAxNode>(
|
||||
cdp: CdpRunner,
|
||||
document: FrameDocument<T>,
|
||||
): Promise<number | undefined> {
|
||||
const result = await sendToCdpTarget<{ executionContextId?: number }>(
|
||||
cdp,
|
||||
document.target,
|
||||
"Page.createIsolatedWorld",
|
||||
{
|
||||
frameId: document.frameId,
|
||||
worldName: "bsk-vom-tooltip",
|
||||
grantUniveralAccess: false,
|
||||
},
|
||||
);
|
||||
return result.executionContextId;
|
||||
}
|
||||
|
||||
async function resolvedObjectId<T extends FrameOwnedAxNode>(
|
||||
cdp: CdpRunner,
|
||||
document: FrameDocument<T>,
|
||||
contextId: number,
|
||||
backendNodeId: number,
|
||||
): Promise<string | undefined> {
|
||||
const result = await sendToCdpTarget<{
|
||||
object?: { objectId?: string };
|
||||
}>(cdp, document.target, "DOM.resolveNode", {
|
||||
backendNodeId,
|
||||
executionContextId: contextId,
|
||||
});
|
||||
return result.object?.objectId;
|
||||
}
|
||||
|
||||
async function tooltipTexts<T extends FrameOwnedAxNode>(
|
||||
cdp: CdpRunner,
|
||||
document: FrameDocument<T>,
|
||||
objectId: string,
|
||||
): Promise<string[]> {
|
||||
const result = await sendToCdpTarget<{
|
||||
result?: { value?: string[] };
|
||||
}>(cdp, document.target, "Runtime.callFunctionOn", {
|
||||
functionDeclaration: tooltipCollectorFunction(),
|
||||
objectId,
|
||||
returnByValue: true,
|
||||
});
|
||||
return result.result?.value ?? [];
|
||||
}
|
||||
|
||||
async function releaseObject<T extends FrameOwnedAxNode>(
|
||||
cdp: CdpRunner,
|
||||
document: FrameDocument<T>,
|
||||
objectId: string,
|
||||
): Promise<void> {
|
||||
await sendToCdpTarget(cdp, document.target, "Runtime.releaseObject", { objectId }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export async function probeTooltipNames<T extends FrameOwnedAxNode>(
|
||||
cdp: CdpRunner,
|
||||
tabId: number,
|
||||
documents: FrameDocument<T>[],
|
||||
graph: ResolvedSemanticGraph,
|
||||
options: TooltipNameProbeOptions = {},
|
||||
): Promise<Map<string, string>> {
|
||||
const names = new Map<string, string>();
|
||||
const pending: TooltipCandidate<T>[] = [];
|
||||
for (const candidate of candidates(documents, graph)) {
|
||||
const cached = cachedEntry(candidate.document, candidate.backendNodeId);
|
||||
if (cached) {
|
||||
// A cached miss is still a decision: skip the control instead of re-probing.
|
||||
if (cached.name !== null) {
|
||||
names.set(
|
||||
frameBackendKey(candidate.document.frameId, candidate.backendNodeId),
|
||||
cached.name,
|
||||
);
|
||||
}
|
||||
} else if (pending.length < MAX_TOOLTIP_CANDIDATES) {
|
||||
pending.push(candidate);
|
||||
}
|
||||
}
|
||||
if (pending.length === 0) return names;
|
||||
|
||||
const budget = new ProbeBudget(MAX_TOOLTIP_PROBE_MS);
|
||||
const contexts = new Map<string, number>();
|
||||
try {
|
||||
for (const candidate of pending) {
|
||||
if (options.signal?.aborted) throw new DOMException("observation aborted", "AbortError");
|
||||
if (!budget.canAfford(TOOLTIP_SETTLE_MS)) break;
|
||||
let objectId: string | undefined;
|
||||
try {
|
||||
let contextId = contexts.get(candidate.document.frameId);
|
||||
if (contextId === undefined) {
|
||||
contextId = await executionContextId(cdp, candidate.document);
|
||||
if (contextId === undefined) continue;
|
||||
contexts.set(candidate.document.frameId, contextId);
|
||||
}
|
||||
objectId = await resolvedObjectId(
|
||||
cdp,
|
||||
candidate.document,
|
||||
contextId,
|
||||
candidate.backendNodeId,
|
||||
);
|
||||
if (!objectId) continue;
|
||||
await clearHover(cdp, tabId);
|
||||
const before = await tooltipTexts(cdp, candidate.document, objectId);
|
||||
await cdp.send(tabId, "Input.dispatchMouseEvent", {
|
||||
type: "mouseMoved",
|
||||
x: candidate.x,
|
||||
y: candidate.y,
|
||||
});
|
||||
await waitForHover(TOOLTIP_SETTLE_MS, options.signal);
|
||||
const after = await tooltipTexts(cdp, candidate.document, objectId);
|
||||
const name = cleanTooltipDiff(before, after);
|
||||
storeName(candidate.document, candidate.backendNodeId, name ?? null);
|
||||
if (!name) continue;
|
||||
names.set(frameBackendKey(candidate.document.frameId, candidate.backendNodeId), name);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") throw error;
|
||||
} finally {
|
||||
if (objectId) await releaseObject(cdp, candidate.document, objectId);
|
||||
await clearHover(cdp, tabId);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await clearHover(cdp, tabId);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
@@ -37,6 +37,17 @@ export interface CaptureVomObservationResult {
|
||||
frames: CaptureVomFrame[];
|
||||
matchNodes: CaptureVomMatchNode[];
|
||||
surfaceProbes?: CapturedSurfaceProbe[];
|
||||
/**
|
||||
* Whether this observation actively hovered the page, and whether that
|
||||
* revealed content the static tree does not contain. Lets callers judge how
|
||||
* far the page may have drifted from the returned snapshot.
|
||||
*/
|
||||
hoverProbe?: HoverProbeReport;
|
||||
}
|
||||
|
||||
export interface HoverProbeReport {
|
||||
performed: boolean;
|
||||
revealedContent: boolean;
|
||||
}
|
||||
|
||||
function projectFrames(documents: CapturedFrameDocument<FrameAxNode>[]): CaptureVomFrame[] {
|
||||
@@ -75,6 +86,7 @@ export function projectRecordSafeObservation(input: {
|
||||
frameDocuments: CapturedFrameDocument<FrameAxNode>[];
|
||||
rendered: VomResult;
|
||||
surfaceProbes?: CapturedSurfaceProbe[];
|
||||
hoverProbe?: HoverProbeReport;
|
||||
}): CaptureVomObservationResult {
|
||||
return {
|
||||
text: input.rendered.text,
|
||||
@@ -84,5 +96,6 @@ export function projectRecordSafeObservation(input: {
|
||||
frames: projectFrames(input.frameDocuments),
|
||||
matchNodes: projectMatchNodes(input.frameDocuments),
|
||||
surfaceProbes: input.surfaceProbes,
|
||||
...(input.hoverProbe ? { hoverProbe: input.hoverProbe } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { Viewport } from "@browser-skill/vom";
|
||||
import type { FrameDocument } from "../frame-document";
|
||||
import {
|
||||
frameAxKey,
|
||||
frameBackendKey,
|
||||
type SemanticAxNode,
|
||||
type SemanticFrame,
|
||||
type SemanticGraph,
|
||||
type SemanticGraphNode,
|
||||
type SemanticNodeId,
|
||||
} from "./types";
|
||||
|
||||
export interface BuildSemanticGraphInput {
|
||||
documents: FrameDocument<SemanticAxNode>[];
|
||||
viewport: Viewport;
|
||||
rootFrameId?: string;
|
||||
excludedBackendNodeIds?: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
function domNodeId(frameId: string, backendNodeId: number): SemanticNodeId {
|
||||
return JSON.stringify([frameId, "dom", backendNodeId]);
|
||||
}
|
||||
|
||||
function axNodeId(frameId: string, nodeId: string): SemanticNodeId {
|
||||
return JSON.stringify([frameId, "ax", nodeId]);
|
||||
}
|
||||
|
||||
export function buildSemanticGraph(input: BuildSemanticGraphInput): SemanticGraph {
|
||||
const rootFrameId =
|
||||
input.rootFrameId ??
|
||||
input.documents.find((document) => !document.parentFrameId)?.frameId ??
|
||||
input.documents[0]?.frameId ??
|
||||
"root";
|
||||
const frames = new Map<string, SemanticFrame>();
|
||||
const nodes = new Map<SemanticNodeId, SemanticGraphNode>();
|
||||
const nodeIdsByFrame = new Map<string, SemanticNodeId[]>();
|
||||
const nodeByFrameBackend = new Map<string, SemanticNodeId>();
|
||||
const nodeByFrameAx = new Map<string, SemanticNodeId>();
|
||||
|
||||
for (const [frameOrder, document] of input.documents.entries()) {
|
||||
frames.set(document.frameId, {
|
||||
frameId: document.frameId,
|
||||
...(document.parentFrameId ? { parentFrameId: document.parentFrameId } : {}),
|
||||
...(document.ownerBackendNodeId !== undefined
|
||||
? { ownerBackendNodeId: document.ownerBackendNodeId }
|
||||
: {}),
|
||||
contextScopeId: document.contextScopeId,
|
||||
target: document.target,
|
||||
...(document.url ? { url: document.url } : {}),
|
||||
order: frameOrder,
|
||||
});
|
||||
|
||||
const frameNodeIds: SemanticNodeId[] = [];
|
||||
nodeIdsByFrame.set(document.frameId, frameNodeIds);
|
||||
for (const [index, dom] of document.domNodes.entries()) {
|
||||
const id = domNodeId(document.frameId, dom.backendNodeId);
|
||||
const node: SemanticGraphNode = {
|
||||
id,
|
||||
frameId: document.frameId,
|
||||
backendNodeId: dom.backendNodeId,
|
||||
dom,
|
||||
sourceOrder: index,
|
||||
excluded:
|
||||
document.frameId === rootFrameId &&
|
||||
input.excludedBackendNodeIds?.has(dom.backendNodeId) === true,
|
||||
};
|
||||
nodes.set(id, node);
|
||||
frameNodeIds.push(id);
|
||||
nodeByFrameBackend.set(frameBackendKey(document.frameId, dom.backendNodeId), id);
|
||||
}
|
||||
|
||||
for (const [index, ax] of document.axNodes.entries()) {
|
||||
const joinedId =
|
||||
typeof ax.backendDOMNodeId === "number"
|
||||
? nodeByFrameBackend.get(frameBackendKey(document.frameId, ax.backendDOMNodeId))
|
||||
: undefined;
|
||||
const id = joinedId ?? axNodeId(document.frameId, ax.nodeId);
|
||||
const existing = nodes.get(id);
|
||||
if (existing) {
|
||||
existing.ax = ax;
|
||||
existing.axNodeId = ax.nodeId;
|
||||
} else {
|
||||
nodes.set(id, {
|
||||
id,
|
||||
frameId: document.frameId,
|
||||
...(typeof ax.backendDOMNodeId === "number"
|
||||
? { backendNodeId: ax.backendDOMNodeId }
|
||||
: {}),
|
||||
axNodeId: ax.nodeId,
|
||||
ax,
|
||||
sourceOrder: document.domNodes.length + index,
|
||||
excluded:
|
||||
document.frameId === rootFrameId &&
|
||||
typeof ax.backendDOMNodeId === "number" &&
|
||||
input.excludedBackendNodeIds?.has(ax.backendDOMNodeId) === true,
|
||||
});
|
||||
frameNodeIds.push(id);
|
||||
}
|
||||
nodeByFrameAx.set(frameAxKey(document.frameId, ax.nodeId), id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const document of input.documents) {
|
||||
for (const dom of document.domNodes) {
|
||||
const id = nodeByFrameBackend.get(frameBackendKey(document.frameId, dom.backendNodeId));
|
||||
const node = id ? nodes.get(id) : undefined;
|
||||
if (!node || dom.parentBackendNodeId === null) continue;
|
||||
node.domParentId = nodeByFrameBackend.get(
|
||||
frameBackendKey(document.frameId, dom.parentBackendNodeId),
|
||||
);
|
||||
}
|
||||
for (const ax of document.axNodes) {
|
||||
const id = nodeByFrameAx.get(frameAxKey(document.frameId, ax.nodeId));
|
||||
const node = id ? nodes.get(id) : undefined;
|
||||
if (!node || !ax.parentId) continue;
|
||||
node.axParentId = nodeByFrameAx.get(frameAxKey(document.frameId, ax.parentId));
|
||||
}
|
||||
}
|
||||
|
||||
const axChildren = new Map<SemanticNodeId, SemanticNodeId[]>();
|
||||
const excludedQueue: SemanticNodeId[] = [];
|
||||
for (const node of nodes.values()) {
|
||||
if (node.axParentId) {
|
||||
const children = axChildren.get(node.axParentId) ?? [];
|
||||
children.push(node.id);
|
||||
axChildren.set(node.axParentId, children);
|
||||
}
|
||||
if (node.excluded) excludedQueue.push(node.id);
|
||||
}
|
||||
for (let index = 0; index < excludedQueue.length; index += 1) {
|
||||
for (const childId of axChildren.get(excludedQueue[index]) ?? []) {
|
||||
const child = nodes.get(childId);
|
||||
if (!child || child.excluded) continue;
|
||||
child.excluded = true;
|
||||
excludedQueue.push(childId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const frame of frames.values()) {
|
||||
if (!frame.parentFrameId || frame.ownerBackendNodeId === undefined) continue;
|
||||
frame.ownerNodeId = nodeByFrameBackend.get(
|
||||
frameBackendKey(frame.parentFrameId, frame.ownerBackendNodeId),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
viewport: input.viewport,
|
||||
rootFrameId,
|
||||
frames,
|
||||
nodes,
|
||||
nodeIdsByFrame,
|
||||
nodeByFrameBackend,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Viewport, VomScene } from "@browser-skill/vom";
|
||||
import type { FrameDocument } from "../frame-document";
|
||||
import { buildSemanticGraph as buildGraph } from "./build";
|
||||
import { projectSemanticGraph as projectGraph } from "./project";
|
||||
import { resolveSemanticGraph as resolveGraph } from "./resolve";
|
||||
import { normalizeSemanticStructure as normalizeStructure } from "./structure";
|
||||
import type {
|
||||
ResolvedSemanticGraph,
|
||||
SemanticAxNode,
|
||||
SemanticGraph,
|
||||
StructuredSemanticGraph,
|
||||
} from "./types";
|
||||
|
||||
export type { SemanticAxNode } from "./types";
|
||||
|
||||
export interface SemanticVomInput {
|
||||
documents: FrameDocument<SemanticAxNode>[];
|
||||
viewport: Viewport;
|
||||
rootFrameId?: string;
|
||||
excludedBackendNodeIds?: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
export function buildSemanticGraph(input: SemanticVomInput): SemanticGraph {
|
||||
return buildGraph(input);
|
||||
}
|
||||
|
||||
export function resolveSemanticGraph(
|
||||
graph: SemanticGraph,
|
||||
options: {
|
||||
supplementalNames?: ReadonlyMap<string, string>;
|
||||
identifierFallback?: boolean;
|
||||
} = {},
|
||||
): ResolvedSemanticGraph {
|
||||
return resolveGraph(graph, options);
|
||||
}
|
||||
|
||||
export function normalizeSemanticStructure(graph: ResolvedSemanticGraph): StructuredSemanticGraph {
|
||||
return normalizeStructure(graph);
|
||||
}
|
||||
|
||||
export function projectSemanticGraph(graph: StructuredSemanticGraph): VomScene {
|
||||
return projectGraph(graph);
|
||||
}
|
||||
|
||||
export function buildSemanticVomScene(
|
||||
input: SemanticVomInput & {
|
||||
supplementalNames?: ReadonlyMap<string, string>;
|
||||
identifierFallback?: boolean;
|
||||
},
|
||||
): VomScene {
|
||||
return projectSemanticGraph(
|
||||
normalizeSemanticStructure(
|
||||
resolveSemanticGraph(buildSemanticGraph(input), {
|
||||
supplementalNames: input.supplementalNames,
|
||||
identifierFallback: input.identifierFallback,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
const IDENTIFIER_ATTRS = ["data-testid", "data-test-id", "id"] as const;
|
||||
const GENERIC_TOKENS = new Set(["btn", "button", "control", "icon", "item", "test", "tool"]);
|
||||
const GENERATED_PREFIXES = new Set(["ember", "headlessui", "mui", "radix", "react", "rc"]);
|
||||
|
||||
function identifierTokens(value: string): string[] {
|
||||
return value
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.split(/[^a-zA-Z0-9]+/)
|
||||
.map((token) => token.toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function readableIdentifier(value: string | undefined): string | undefined {
|
||||
const raw = value?.trim();
|
||||
if (!raw || raw.length < 3 || raw.length > 80) return undefined;
|
||||
if (/^[a-f0-9]{8,}$/i.test(raw)) return undefined;
|
||||
|
||||
const tokens = identifierTokens(raw);
|
||||
if (GENERATED_PREFIXES.has(tokens[0])) return undefined;
|
||||
while (tokens.length > 1 && GENERIC_TOKENS.has(tokens[0])) tokens.shift();
|
||||
while (tokens.length > 1 && GENERIC_TOKENS.has(tokens[tokens.length - 1])) tokens.pop();
|
||||
if (tokens.length === 0 || !tokens.some((token) => /[a-z]{3,}/.test(token))) return undefined;
|
||||
return tokens
|
||||
.map((token, index) => (index === 0 ? `${token[0].toUpperCase()}${token.slice(1)}` : token))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function stableIdentifierName(attrs: Readonly<Record<string, string>>): string | undefined {
|
||||
for (const name of IDENTIFIER_ATTRS) {
|
||||
const label = readableIdentifier(attrs[name]);
|
||||
if (label) return label;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { VomNode, VomScene } from "@browser-skill/vom";
|
||||
import type { SemanticNodeId, StructuredSemanticGraph, StructuredSemanticNode } from "./types";
|
||||
|
||||
function retained(node: StructuredSemanticNode): boolean {
|
||||
return node.structure.kind === "keep" || node.structure.kind === "inferred-group";
|
||||
}
|
||||
|
||||
function connectedFrameIds(graph: StructuredSemanticGraph): Set<string> {
|
||||
const connected = new Set<string>([graph.rootFrameId]);
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
for (const frame of graph.frames.values()) {
|
||||
if (!frame.parentFrameId) continue;
|
||||
const children = childrenByParent.get(frame.parentFrameId) ?? [];
|
||||
children.push(frame.frameId);
|
||||
childrenByParent.set(frame.parentFrameId, children);
|
||||
}
|
||||
const queue = [graph.rootFrameId];
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
for (const frameId of childrenByParent.get(queue[index]) ?? []) {
|
||||
if (connected.has(frameId)) continue;
|
||||
const frame = graph.frames.get(frameId);
|
||||
if (!frame?.ownerNodeId) continue;
|
||||
const owner = graph.nodes.get(frame.ownerNodeId);
|
||||
if (!owner || !retained(owner)) continue;
|
||||
connected.add(frame.frameId);
|
||||
queue.push(frame.frameId);
|
||||
}
|
||||
}
|
||||
return connected;
|
||||
}
|
||||
|
||||
function domAncestors(
|
||||
graph: StructuredSemanticGraph,
|
||||
node: StructuredSemanticNode,
|
||||
numericId: ReadonlyMap<SemanticNodeId, number>,
|
||||
): number[] {
|
||||
const ancestors: number[] = [];
|
||||
const seen = new Set<SemanticNodeId>();
|
||||
let current = node.domParentId;
|
||||
while (current && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
const id = numericId.get(current);
|
||||
if (id !== undefined) ancestors.push(id);
|
||||
current = graph.nodes.get(current)?.domParentId;
|
||||
}
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
export function projectSemanticGraph(graph: StructuredSemanticGraph): VomScene {
|
||||
const connectedFrames = connectedFrameIds(graph);
|
||||
const orderedNodes = [...graph.frames.values()]
|
||||
.filter((frame) => connectedFrames.has(frame.frameId))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.flatMap((frame) =>
|
||||
(graph.nodeIdsByFrame.get(frame.frameId) ?? [])
|
||||
.map((id) => graph.nodes.get(id))
|
||||
.filter((node): node is StructuredSemanticNode => node !== undefined && retained(node))
|
||||
.sort((a, b) => a.sourceOrder - b.sourceOrder),
|
||||
);
|
||||
|
||||
const numericId = new Map<SemanticNodeId, number>();
|
||||
orderedNodes.forEach((node, index) => numericId.set(node.id, index + 1));
|
||||
const nodes: VomNode[] = orderedNodes.map((node) => ({
|
||||
...node.vom,
|
||||
id: numericId.get(node.id) as number,
|
||||
parentId: node.semanticParentId ? (numericId.get(node.semanticParentId) ?? null) : null,
|
||||
...(node.backendNodeId !== undefined ? { backendNodeId: node.backendNodeId } : {}),
|
||||
frameId: node.frameId,
|
||||
contextScopeId: graph.frames.get(node.frameId)?.contextScopeId ?? node.frameId,
|
||||
referenceable: node.referenceable,
|
||||
...(node.dom
|
||||
? {
|
||||
domParentId:
|
||||
node.domParentId === undefined ? null : (numericId.get(node.domParentId) ?? null),
|
||||
domAncestorIds: domAncestors(graph, node, numericId),
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
|
||||
return {
|
||||
viewport: graph.viewport,
|
||||
nodes,
|
||||
rootFrameId: graph.rootFrameId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import type { VomNode } from "@browser-skill/vom";
|
||||
import { stableIdentifierName } from "./name-evidence";
|
||||
import type {
|
||||
ResolvedSemanticGraph,
|
||||
ResolvedSemanticNode,
|
||||
SemanticAxNode,
|
||||
SemanticGraph,
|
||||
SemanticGraphNode,
|
||||
SemanticNodeId,
|
||||
} from "./types";
|
||||
import { frameBackendKey } from "./types";
|
||||
|
||||
const FORM_TAGS = new Set(["input", "textarea", "select"]);
|
||||
const NATIVE_CONTROL_TAGS = new Set(["button", "input", "select", "textarea"]);
|
||||
const TEXT_AX_ROLES = new Set(["statictext", "inlinetextbox", "labeltext", "text"]);
|
||||
const INTERACTIVE_ROLES = new Set([
|
||||
"button",
|
||||
"checkbox",
|
||||
"combobox",
|
||||
"link",
|
||||
"listbox",
|
||||
"menuitem",
|
||||
"menuitemcheckbox",
|
||||
"menuitemradio",
|
||||
"option",
|
||||
"radio",
|
||||
"searchbox",
|
||||
"slider",
|
||||
"spinbutton",
|
||||
"switch",
|
||||
"tab",
|
||||
"textbox",
|
||||
"treeitem",
|
||||
]);
|
||||
const SENSITIVE_INPUT_TYPES = new Set([
|
||||
"password",
|
||||
"credit-card",
|
||||
"one-time-code",
|
||||
"current-password",
|
||||
"new-password",
|
||||
]);
|
||||
|
||||
function clean(value: string | undefined): string | undefined {
|
||||
const normalized = value?.replace(/\s+/g, " ").trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function axValue(field?: { value?: string | number | boolean }): string | undefined {
|
||||
return field?.value === undefined ? undefined : clean(String(field.value));
|
||||
}
|
||||
|
||||
function axProperty(node: SemanticAxNode | undefined, name: string): string | undefined {
|
||||
return axValue(node?.properties?.find((property) => property.name === name)?.value);
|
||||
}
|
||||
|
||||
function normalizeRole(value: string | undefined): string | undefined {
|
||||
const role = clean(value)?.toLowerCase();
|
||||
return role || undefined;
|
||||
}
|
||||
|
||||
function nativeRole(tag: string, attrs: Record<string, string>): string | undefined {
|
||||
if (tag === "button") return "button";
|
||||
if (tag === "textarea") return "textbox";
|
||||
if (tag === "select") return attrs.multiple === undefined ? "combobox" : "listbox";
|
||||
if (tag === "a" && attrs.href) return "link";
|
||||
if (tag === "iframe") return "Iframe";
|
||||
if (tag === "dialog") return "dialog";
|
||||
if (tag === "img") return "img";
|
||||
if (tag === "table") return "table";
|
||||
if (tag === "tr") return "row";
|
||||
if (tag === "th") return "columnheader";
|
||||
if (tag === "td") return "cell";
|
||||
if (tag === "ul" || tag === "ol") return "list";
|
||||
if (tag === "li") return "listitem";
|
||||
if (/^h[1-6]$/.test(tag)) return "heading";
|
||||
if (tag === "nav") return "navigation";
|
||||
if (tag === "main") return "main";
|
||||
if (tag === "form") return "form";
|
||||
if (tag === "input") {
|
||||
const type = (attrs.type ?? "text").toLowerCase();
|
||||
if (type === "hidden") return undefined;
|
||||
if (type === "checkbox") return "checkbox";
|
||||
if (type === "radio") return "radio";
|
||||
if (type === "range") return "slider";
|
||||
if (type === "number") return "spinbutton";
|
||||
if (type === "search") return "searchbox";
|
||||
if (["button", "submit", "reset", "image"].includes(type)) return "button";
|
||||
return "textbox";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function domRole(tag: string, attrs: Record<string, string>): string | undefined {
|
||||
return normalizeRole(attrs.role) ?? nativeRole(tag, attrs);
|
||||
}
|
||||
|
||||
function resolvedRole(node: SemanticGraphNode): {
|
||||
value?: string;
|
||||
source: ResolvedSemanticNode["roleSource"];
|
||||
} {
|
||||
const rawAxRole = clean(axValue(node.ax?.role));
|
||||
const axRole = normalizeRole(rawAxRole);
|
||||
const attrs = node.dom?.attrs ?? {};
|
||||
const explicitDomRole = normalizeRole(attrs.role);
|
||||
const nativeDomRole = nativeRole(node.dom?.tag.toLowerCase() ?? "", attrs);
|
||||
const usableAxRole = node.ax?.ignored !== true;
|
||||
if (usableAxRole && axRole && !["generic", "none", "presentation"].includes(axRole)) {
|
||||
return { value: rawAxRole, source: "ax" };
|
||||
}
|
||||
if (explicitDomRole) return { value: explicitDomRole, source: "dom-explicit" };
|
||||
if (nativeDomRole) return { value: nativeDomRole, source: "dom-native" };
|
||||
if (rawAxRole) {
|
||||
return { value: rawAxRole, source: usableAxRole ? "ax" : "ax-ignored" };
|
||||
}
|
||||
return { source: "none" };
|
||||
}
|
||||
|
||||
function sensitive(node: SemanticGraphNode): boolean {
|
||||
const attrs = node.dom?.attrs ?? {};
|
||||
const type = (attrs.type ?? "").toLowerCase();
|
||||
const autocomplete = (attrs.autocomplete ?? "").toLowerCase();
|
||||
return (
|
||||
type === "password" ||
|
||||
autocomplete.startsWith("cc-") ||
|
||||
SENSITIVE_INPUT_TYPES.has(autocomplete) ||
|
||||
SENSITIVE_INPUT_TYPES.has(axProperty(node.ax, "inputType") ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
function inputState(
|
||||
node: SemanticGraphNode,
|
||||
role: string | undefined,
|
||||
value: string | undefined,
|
||||
isSensitive: boolean,
|
||||
): VomNode["inputState"] {
|
||||
if (node.dom?.formState) return node.dom.formState;
|
||||
const tag = node.dom?.tag.toLowerCase() ?? "";
|
||||
if (!FORM_TAGS.has(tag) && !["textbox", "searchbox"].includes(role ?? "")) return undefined;
|
||||
if (isSensitive) return value ? "filled" : "empty";
|
||||
if (node.dom?.formValue !== undefined) {
|
||||
if (node.dom.formValue === "") return "empty";
|
||||
return node.dom.formValue === (node.dom.formDefaultValue ?? "") ? "default" : "filled";
|
||||
}
|
||||
return value ? "filled" : "empty";
|
||||
}
|
||||
|
||||
function externalHrefHost(
|
||||
href: string | undefined,
|
||||
pageUrl: string | undefined,
|
||||
): string | undefined {
|
||||
if (!href || !pageUrl) return undefined;
|
||||
try {
|
||||
const page = new URL(pageUrl);
|
||||
const target = new URL(href, page);
|
||||
return target.origin === page.origin ? undefined : target.hostname;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
interface ResolveIndexes {
|
||||
domChildren: Map<SemanticNodeId, SemanticNodeId[]>;
|
||||
axChildren: Map<SemanticNodeId, SemanticNodeId[]>;
|
||||
domIdIndex: Map<string, SemanticNodeId>;
|
||||
labelForIndex: Map<string, SemanticNodeId[]>;
|
||||
domText: (nodeId: SemanticNodeId) => string | undefined;
|
||||
axText: (nodeId: SemanticNodeId) => string | undefined;
|
||||
hasNativeDescendant: (nodeId: SemanticNodeId) => boolean;
|
||||
}
|
||||
|
||||
function buildIndexes(graph: SemanticGraph): ResolveIndexes {
|
||||
const domChildren = new Map<SemanticNodeId, SemanticNodeId[]>();
|
||||
const axChildren = new Map<SemanticNodeId, SemanticNodeId[]>();
|
||||
const domIdIndex = new Map<string, SemanticNodeId>();
|
||||
const labelForIndex = new Map<string, SemanticNodeId[]>();
|
||||
for (const node of graph.nodes.values()) {
|
||||
if (node.domParentId) {
|
||||
const children = domChildren.get(node.domParentId) ?? [];
|
||||
children.push(node.id);
|
||||
domChildren.set(node.domParentId, children);
|
||||
}
|
||||
if (node.axParentId) {
|
||||
const children = axChildren.get(node.axParentId) ?? [];
|
||||
children.push(node.id);
|
||||
axChildren.set(node.axParentId, children);
|
||||
}
|
||||
const id = clean(node.dom?.attrs.id);
|
||||
if (id) domIdIndex.set(`${node.frameId}\u0000${id}`, node.id);
|
||||
const targetId = clean(node.dom?.attrs.for);
|
||||
if (node.dom?.tag.toLowerCase() === "label" && targetId) {
|
||||
const key = `${node.frameId}\u0000${targetId}`;
|
||||
const labels = labelForIndex.get(key) ?? [];
|
||||
labels.push(node.id);
|
||||
labelForIndex.set(key, labels);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [parentId, children] of axChildren) {
|
||||
const order = graph.nodes.get(parentId)?.ax?.childIds ?? [];
|
||||
const orderByAxId = new Map(order.map((nodeId, index) => [nodeId, index]));
|
||||
children.sort((a, b) => {
|
||||
const aIndex = orderByAxId.get(graph.nodes.get(a)?.axNodeId ?? "") ?? -1;
|
||||
const bIndex = orderByAxId.get(graph.nodes.get(b)?.axNodeId ?? "") ?? -1;
|
||||
return (
|
||||
(aIndex < 0 ? Number.MAX_SAFE_INTEGER : aIndex) -
|
||||
(bIndex < 0 ? Number.MAX_SAFE_INTEGER : bIndex)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const textMemo = new Map<SemanticNodeId, string | undefined>();
|
||||
const resolvingText = new Set<SemanticNodeId>();
|
||||
const domText = (nodeId: SemanticNodeId): string | undefined => {
|
||||
if (textMemo.has(nodeId)) return textMemo.get(nodeId);
|
||||
if (resolvingText.has(nodeId)) return undefined;
|
||||
resolvingText.add(nodeId);
|
||||
const node = graph.nodes.get(nodeId);
|
||||
const parts: string[] = [];
|
||||
const own = clean(node?.dom?.textContent);
|
||||
if (own) parts.push(own);
|
||||
for (const childId of domChildren.get(nodeId) ?? []) {
|
||||
const child = graph.nodes.get(childId);
|
||||
const childRole = child
|
||||
? domRole(child.dom?.tag.toLowerCase() ?? "", child.dom?.attrs ?? {})
|
||||
: undefined;
|
||||
if (childRole && INTERACTIVE_ROLES.has(childRole.toLowerCase())) continue;
|
||||
const childText = domText(childId);
|
||||
if (childText) parts.push(childText);
|
||||
if (parts.join(" ").length >= 240) break;
|
||||
}
|
||||
resolvingText.delete(nodeId);
|
||||
const result = clean(parts.join(" ").slice(0, 240));
|
||||
textMemo.set(nodeId, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const axTextMemo = new Map<SemanticNodeId, string | undefined>();
|
||||
const resolvingAxText = new Set<SemanticNodeId>();
|
||||
const axText = (nodeId: SemanticNodeId): string | undefined => {
|
||||
if (axTextMemo.has(nodeId)) return axTextMemo.get(nodeId);
|
||||
if (resolvingAxText.has(nodeId)) return undefined;
|
||||
resolvingAxText.add(nodeId);
|
||||
const complete = (value: string | undefined): string | undefined => {
|
||||
resolvingAxText.delete(nodeId);
|
||||
axTextMemo.set(nodeId, value);
|
||||
return value;
|
||||
};
|
||||
const node = graph.nodes.get(nodeId);
|
||||
const role = normalizeRole(axValue(node?.ax?.role)) ?? "";
|
||||
if (TEXT_AX_ROLES.has(role)) {
|
||||
const own = clean(axValue(node?.ax?.name) ?? axValue(node?.ax?.value));
|
||||
if (own) return complete(own);
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const childId of axChildren.get(nodeId) ?? []) {
|
||||
const child = graph.nodes.get(childId);
|
||||
const childRole = normalizeRole(axValue(child?.ax?.role)) ?? "";
|
||||
if (INTERACTIVE_ROLES.has(childRole)) continue;
|
||||
const childText = axText(childId);
|
||||
if (childText) parts.push(childText);
|
||||
if (parts.join(" ").length >= 240) break;
|
||||
}
|
||||
return complete(clean(parts.join(" ").slice(0, 240)));
|
||||
};
|
||||
|
||||
const nativeMemo = new Map<SemanticNodeId, boolean>();
|
||||
const hasNativeDescendant = (nodeId: SemanticNodeId): boolean => {
|
||||
const cached = nativeMemo.get(nodeId);
|
||||
if (cached !== undefined) return cached;
|
||||
nativeMemo.set(nodeId, false);
|
||||
for (const childId of domChildren.get(nodeId) ?? []) {
|
||||
const child = graph.nodes.get(childId);
|
||||
const childRole =
|
||||
(child?.ax?.ignored === true ? undefined : normalizeRole(axValue(child?.ax?.role))) ??
|
||||
(child ? domRole(child.dom?.tag.toLowerCase() ?? "", child.dom?.attrs ?? {}) : undefined);
|
||||
if (
|
||||
(child?.dom && NATIVE_CONTROL_TAGS.has(child.dom.tag.toLowerCase())) ||
|
||||
INTERACTIVE_ROLES.has(childRole ?? "") ||
|
||||
hasNativeDescendant(childId)
|
||||
) {
|
||||
nativeMemo.set(nodeId, true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return {
|
||||
domChildren,
|
||||
axChildren,
|
||||
domIdIndex,
|
||||
labelForIndex,
|
||||
domText,
|
||||
axText,
|
||||
hasNativeDescendant,
|
||||
};
|
||||
}
|
||||
|
||||
function ariaLabelledText(node: SemanticGraphNode, indexes: ResolveIndexes): string | undefined {
|
||||
const ids = (node.dom?.attrs["aria-labelledby"] ?? "").split(/\s+/).filter(Boolean);
|
||||
const parts = ids.flatMap((id) => {
|
||||
const target = indexes.domIdIndex.get(`${node.frameId}\u0000${id}`);
|
||||
return target ? (indexes.domText(target) ?? []) : [];
|
||||
});
|
||||
return clean(parts.join(" "));
|
||||
}
|
||||
|
||||
function nativeLabelText(
|
||||
node: SemanticGraphNode,
|
||||
graph: SemanticGraph,
|
||||
indexes: ResolveIndexes,
|
||||
): string | undefined {
|
||||
const id = clean(node.dom?.attrs.id);
|
||||
if (id) {
|
||||
const labels = indexes.labelForIndex.get(`${node.frameId}\u0000${id}`) ?? [];
|
||||
const explicit = clean(
|
||||
labels
|
||||
.map((labelId) => indexes.domText(labelId))
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
);
|
||||
if (explicit) return explicit;
|
||||
}
|
||||
let parentId = node.domParentId;
|
||||
let guard = 0;
|
||||
while (parentId && guard <= graph.nodes.size) {
|
||||
const parent = graph.nodes.get(parentId);
|
||||
if (!parent) break;
|
||||
if (parent.dom?.tag.toLowerCase() === "label") return indexes.domText(parentId);
|
||||
parentId = parent.domParentId;
|
||||
guard += 1;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function precedingAxText(
|
||||
node: SemanticGraphNode,
|
||||
graph: SemanticGraph,
|
||||
indexes: ResolveIndexes,
|
||||
): string | undefined {
|
||||
if (!node.axParentId) return undefined;
|
||||
const siblings = indexes.axChildren.get(node.axParentId) ?? [];
|
||||
const index = siblings.indexOf(node.id);
|
||||
for (let offset = 1; offset <= 4 && index - offset >= 0; offset += 1) {
|
||||
const sibling = graph.nodes.get(siblings[index - offset]);
|
||||
const role = normalizeRole(axValue(sibling?.ax?.role)) ?? "";
|
||||
if (INTERACTIVE_ROLES.has(role)) break;
|
||||
if (!TEXT_AX_ROLES.has(role) && role !== "generic") continue;
|
||||
const text = clean(axValue(sibling?.ax?.name) ?? axValue(sibling?.ax?.value))?.replace(
|
||||
/[::]\s*$/,
|
||||
"",
|
||||
);
|
||||
if (text && text.length <= 80) return text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function precedingDomText(
|
||||
node: SemanticGraphNode,
|
||||
graph: SemanticGraph,
|
||||
indexes: ResolveIndexes,
|
||||
): string | undefined {
|
||||
if (!node.domParentId) return undefined;
|
||||
const siblings = indexes.domChildren.get(node.domParentId) ?? [];
|
||||
const index = siblings.indexOf(node.id);
|
||||
for (let offset = 1; offset <= 4 && index - offset >= 0; offset += 1) {
|
||||
const sibling = graph.nodes.get(siblings[index - offset]);
|
||||
const siblingRole = sibling
|
||||
? domRole(sibling.dom?.tag.toLowerCase() ?? "", sibling.dom?.attrs ?? {})
|
||||
: undefined;
|
||||
if (siblingRole && INTERACTIVE_ROLES.has(siblingRole.toLowerCase())) break;
|
||||
const text = indexes.domText(siblings[index - offset])?.replace(/[::]\s*$/, "");
|
||||
if (text && text.length <= 80) return text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolvedName(
|
||||
node: SemanticGraphNode,
|
||||
role: string | undefined,
|
||||
graph: SemanticGraph,
|
||||
indexes: ResolveIndexes,
|
||||
supplementalNames: ReadonlyMap<string, string>,
|
||||
identifierFallback: boolean,
|
||||
): string | undefined {
|
||||
const attrs = node.dom?.attrs ?? {};
|
||||
const formControl = FORM_TAGS.has(node.dom?.tag.toLowerCase() ?? "");
|
||||
const descendantText = indexes.domText(node.id);
|
||||
const interactive =
|
||||
INTERACTIVE_ROLES.has(role?.toLowerCase() ?? "") ||
|
||||
NATIVE_CONTROL_TAGS.has(node.dom?.tag.toLowerCase() ?? "");
|
||||
const candidates = [
|
||||
node.ax?.ignored === true ? undefined : axValue(node.ax?.name),
|
||||
ariaLabelledText(node, indexes),
|
||||
clean(attrs["aria-label"]),
|
||||
nativeLabelText(node, graph, indexes),
|
||||
clean(attrs.title),
|
||||
clean(attrs.alt),
|
||||
node.ax?.ignored === true ? undefined : indexes.axText(node.id),
|
||||
descendantText,
|
||||
formControl ? precedingAxText(node, graph, indexes) : undefined,
|
||||
formControl ? precedingDomText(node, graph, indexes) : undefined,
|
||||
formControl ? clean(node.dom?.formPlaceholder ?? attrs.placeholder) : undefined,
|
||||
formControl || !interactive || node.backendNodeId === undefined
|
||||
? undefined
|
||||
: clean(supplementalNames.get(frameBackendKey(node.frameId, node.backendNodeId))),
|
||||
identifierFallback && interactive ? stableIdentifierName(attrs) : undefined,
|
||||
];
|
||||
let name = candidates.find((candidate) => candidate !== undefined);
|
||||
const hasPopup = axProperty(node.ax, "hasPopup");
|
||||
if (name && hasPopup && hasPopup !== "false") {
|
||||
name =
|
||||
axProperty(node.ax, "expanded") === "true" ? `${name} [expanded]` : `${name} [has-submenu]`;
|
||||
}
|
||||
if (!name && role?.toLowerCase() === "iframe") return clean(attrs.id);
|
||||
return name;
|
||||
}
|
||||
|
||||
function insideNative(node: SemanticGraphNode, graph: SemanticGraph): boolean {
|
||||
let parentId = node.domParentId;
|
||||
let guard = 0;
|
||||
while (parentId && guard <= graph.nodes.size) {
|
||||
const parent = graph.nodes.get(parentId);
|
||||
if (!parent) break;
|
||||
if (parent.dom && NATIVE_CONTROL_TAGS.has(parent.dom.tag.toLowerCase())) return true;
|
||||
parentId = parent.domParentId;
|
||||
guard += 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function nearbyText(
|
||||
node: SemanticGraphNode,
|
||||
graph: SemanticGraph,
|
||||
indexes: ResolveIndexes,
|
||||
): string | undefined {
|
||||
if (!node.domParentId) return undefined;
|
||||
const siblings = indexes.domChildren.get(node.domParentId) ?? [];
|
||||
const index = siblings.indexOf(node.id);
|
||||
if (index < 0) return undefined;
|
||||
const texts = siblings
|
||||
.slice(Math.max(0, index - 3), index + 4)
|
||||
.filter((id) => id !== node.id)
|
||||
.flatMap((id) => indexes.domText(id) ?? [])
|
||||
.slice(0, 3);
|
||||
return clean(texts.join(" "));
|
||||
}
|
||||
|
||||
export function resolveSemanticGraph(
|
||||
graph: SemanticGraph,
|
||||
options: {
|
||||
supplementalNames?: ReadonlyMap<string, string>;
|
||||
identifierFallback?: boolean;
|
||||
} = {},
|
||||
): ResolvedSemanticGraph {
|
||||
const indexes = buildIndexes(graph);
|
||||
const supplementalNames = options.supplementalNames ?? new Map<string, string>();
|
||||
const identifierFallback = options.identifierFallback ?? true;
|
||||
const nodes = new Map<SemanticNodeId, ResolvedSemanticNode>();
|
||||
for (const node of graph.nodes.values()) {
|
||||
const roleResolution = resolvedRole(node);
|
||||
const role = roleResolution.value;
|
||||
const name = resolvedName(node, role, graph, indexes, supplementalNames, identifierFallback);
|
||||
const isSensitive = sensitive(node);
|
||||
const sourceValue = node.dom?.formValue ?? axValue(node.ax?.value);
|
||||
const value = isSensitive ? undefined : sourceValue;
|
||||
const attrs = { ...(node.dom?.attrs ?? {}) };
|
||||
if (isSensitive) delete attrs.value;
|
||||
const frame = graph.frames.get(node.frameId);
|
||||
const tag = node.dom?.tag.toLowerCase() ?? "";
|
||||
const vom: ResolvedSemanticNode["vom"] = {
|
||||
tag,
|
||||
rect: node.dom?.rect ?? null,
|
||||
paintOrder: node.dom?.paintOrder ?? 0,
|
||||
position: node.dom?.position || "static",
|
||||
pointerEvents: node.dom?.pointerEvents || "auto",
|
||||
...(node.dom?.cursor ? { cursor: node.dom.cursor } : {}),
|
||||
...(role ? { role } : {}),
|
||||
...(name ? { name } : {}),
|
||||
...(value !== undefined ? { value } : {}),
|
||||
...(clean(node.dom?.formPlaceholder ?? attrs.placeholder)
|
||||
? { placeholder: clean(node.dom?.formPlaceholder ?? attrs.placeholder) }
|
||||
: {}),
|
||||
attrs,
|
||||
...(clean(node.dom?.textContent) ? { text: clean(node.dom?.textContent) } : {}),
|
||||
...(nearbyText(node, graph, indexes) ? { nearbyText: nearbyText(node, graph, indexes) } : {}),
|
||||
inputState: inputState(node, role, sourceValue, isSensitive),
|
||||
sensitive: isSensitive,
|
||||
modal:
|
||||
["dialog", "alertdialog"].includes(role?.toLowerCase() ?? "") ||
|
||||
tag === "dialog" ||
|
||||
(attrs["aria-modal"] ?? "").toLowerCase() === "true",
|
||||
disabled:
|
||||
Object.prototype.hasOwnProperty.call(attrs, "disabled") ||
|
||||
(attrs["aria-disabled"] ?? "").toLowerCase() === "true",
|
||||
inert: Object.prototype.hasOwnProperty.call(attrs, "inert"),
|
||||
hasNativeDescendant: indexes.hasNativeDescendant(node.id),
|
||||
insideNative: insideNative(node, graph),
|
||||
...(role?.toLowerCase() === "link" ? { href: externalHrefHost(attrs.href, frame?.url) } : {}),
|
||||
};
|
||||
const selected = node.ax?.ignored === true ? undefined : axProperty(node.ax, "selected");
|
||||
const expanded = node.ax?.ignored === true ? undefined : axProperty(node.ax, "expanded");
|
||||
const controls = node.ax?.ignored === true ? undefined : axProperty(node.ax, "controls");
|
||||
if (selected === "true" && !vom.attrs?.["aria-selected"]) {
|
||||
vom.attrs = { ...(vom.attrs ?? {}), "aria-selected": "true" };
|
||||
}
|
||||
if (expanded === "true" && !vom.attrs?.["aria-expanded"]) {
|
||||
vom.attrs = { ...(vom.attrs ?? {}), "aria-expanded": "true" };
|
||||
}
|
||||
if (controls && !vom.attrs?.["aria-controls"]) {
|
||||
vom.attrs = { ...(vom.attrs ?? {}), "aria-controls": controls };
|
||||
}
|
||||
nodes.set(node.id, {
|
||||
...node,
|
||||
vom,
|
||||
referenceable: node.backendNodeId !== undefined,
|
||||
roleSource: roleResolution.source,
|
||||
});
|
||||
}
|
||||
return { ...graph, nodes };
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
import {
|
||||
applyVomInteractionRecovery,
|
||||
isVomReferenceNode,
|
||||
isVomStructuralRole,
|
||||
type VomNode,
|
||||
} from "@browser-skill/vom";
|
||||
import type {
|
||||
ResolvedSemanticGraph,
|
||||
ResolvedSemanticNode,
|
||||
SemanticNodeId,
|
||||
StructureDecision,
|
||||
StructuredSemanticGraph,
|
||||
StructuredSemanticNode,
|
||||
} from "./types";
|
||||
|
||||
const TRANSPARENT_ROLES = new Set(["", "generic", "none", "presentation", "inlinetextbox"]);
|
||||
const ROOT_ROLES = new Set(["rootwebarea", "webarea"]);
|
||||
const CONTENT_BOUNDARY_ROLES = new Set([
|
||||
"article",
|
||||
"dialog",
|
||||
"document",
|
||||
"feed",
|
||||
"form",
|
||||
"grid",
|
||||
"heading",
|
||||
"iframe",
|
||||
"list",
|
||||
"main",
|
||||
"navigation",
|
||||
"paragraph",
|
||||
"region",
|
||||
"section",
|
||||
"table",
|
||||
"tree",
|
||||
]);
|
||||
|
||||
function hasMeaningfulRelations(node: ResolvedSemanticNode): boolean {
|
||||
const attrs = node.vom.attrs ?? {};
|
||||
return ["aria-controls", "aria-labelledby", "aria-owns", "aria-describedby"].some((name) =>
|
||||
attrs[name]?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function explicitStructural(node: ResolvedSemanticNode): boolean {
|
||||
return (
|
||||
isVomStructuralRole(node.vom.role) &&
|
||||
node.roleSource !== "none" &&
|
||||
node.roleSource !== "ax-ignored"
|
||||
);
|
||||
}
|
||||
|
||||
function hasUsableAxSemantics(node: ResolvedSemanticNode): boolean {
|
||||
return node.ax !== undefined && node.ax.ignored !== true;
|
||||
}
|
||||
|
||||
function isRenderedDomFallback(node: ResolvedSemanticNode): boolean {
|
||||
if (!node.dom) return false;
|
||||
const attrs = node.dom.attrs;
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(attrs, "hidden") ||
|
||||
Object.prototype.hasOwnProperty.call(attrs, "inert") ||
|
||||
(attrs["aria-hidden"] ?? "").toLowerCase() === "true"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return node.dom.rendered ?? (node.dom.localRect != null || node.dom.rect != null);
|
||||
}
|
||||
|
||||
function initialParentId(
|
||||
graph: ResolvedSemanticGraph,
|
||||
node: ResolvedSemanticNode,
|
||||
): SemanticNodeId | undefined {
|
||||
let parentId = node.axParentId ?? node.domParentId;
|
||||
if (node.axParentId && node.domParentId && node.axParentId !== node.domParentId) {
|
||||
const axParent = graph.nodes.get(node.axParentId);
|
||||
const axRole = axParent?.vom.role?.toLowerCase() ?? "";
|
||||
const meaningfulAxParent =
|
||||
axParent !== undefined &&
|
||||
!ROOT_ROLES.has(axRole) &&
|
||||
(explicitStructural(axParent) ||
|
||||
!TRANSPARENT_ROLES.has(axRole) ||
|
||||
Boolean(axParent.vom.name || axParent.vom.text || hasMeaningfulRelations(axParent)));
|
||||
parentId = meaningfulAxParent ? node.axParentId : node.domParentId;
|
||||
}
|
||||
if (parentId) return parentId;
|
||||
if (node.frameId === graph.rootFrameId) return undefined;
|
||||
return graph.frames.get(node.frameId)?.ownerNodeId;
|
||||
}
|
||||
|
||||
function orderedNodes(graph: ResolvedSemanticGraph): ResolvedSemanticNode[] {
|
||||
return [...graph.frames.values()]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.flatMap((frame) =>
|
||||
(graph.nodeIdsByFrame.get(frame.frameId) ?? [])
|
||||
.map((id) => graph.nodes.get(id))
|
||||
.filter((node): node is ResolvedSemanticNode => node !== undefined)
|
||||
.sort((a, b) => a.sourceOrder - b.sourceOrder),
|
||||
);
|
||||
}
|
||||
|
||||
function recoveredNodes(
|
||||
graph: ResolvedSemanticGraph,
|
||||
nodes: ResolvedSemanticNode[],
|
||||
): Map<SemanticNodeId, VomNode> {
|
||||
const numericId = new Map(nodes.map((node, index) => [node.id, index + 1]));
|
||||
const semanticId = new Map([...numericId].map(([id, numeric]) => [numeric, id]));
|
||||
const provisional = nodes.map((node) => ({
|
||||
...node.vom,
|
||||
id: numericId.get(node.id) as number,
|
||||
parentId: (() => {
|
||||
const parent = initialParentId(graph, node);
|
||||
return parent ? (numericId.get(parent) ?? null) : null;
|
||||
})(),
|
||||
...(node.backendNodeId !== undefined ? { backendNodeId: node.backendNodeId } : {}),
|
||||
frameId: node.frameId,
|
||||
contextScopeId: graph.frames.get(node.frameId)?.contextScopeId ?? node.frameId,
|
||||
referenceable: node.referenceable,
|
||||
}));
|
||||
return new Map(
|
||||
applyVomInteractionRecovery(provisional).flatMap((node) => {
|
||||
const id = semanticId.get(node.id);
|
||||
return id ? [[id, node] as const] : [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function redundantSourceNodes(
|
||||
graph: ResolvedSemanticGraph,
|
||||
nodes: ResolvedSemanticNode[],
|
||||
recovered: ReadonlyMap<SemanticNodeId, VomNode>,
|
||||
): Set<SemanticNodeId> {
|
||||
const byKey = new Map<string, ResolvedSemanticNode[]>();
|
||||
for (const node of nodes) {
|
||||
const output = recovered.get(node.id) as VomNode;
|
||||
const name = output.name?.trim().toLowerCase() ?? "";
|
||||
if (!name || !isVomReferenceNode({ ...output, referenceable: true })) continue;
|
||||
const key = `${node.frameId}\u0000${initialParentId(graph, node) ?? ""}\u0000${output.role?.toLowerCase() ?? ""}\u0000${name}\u0000${output.sensitive === true}`;
|
||||
const matches = byKey.get(key) ?? [];
|
||||
matches.push(node);
|
||||
byKey.set(key, matches);
|
||||
}
|
||||
const redundant = new Set<SemanticNodeId>();
|
||||
for (const matches of byKey.values()) {
|
||||
const axOnly = matches.filter((node) => node.ax && !node.dom);
|
||||
const domOnly = matches.filter((node) => node.dom && !node.ax);
|
||||
if (matches.length === 2 && axOnly.length === 1 && domOnly.length === 1) {
|
||||
redundant.add(domOnly[0].id);
|
||||
}
|
||||
}
|
||||
return redundant;
|
||||
}
|
||||
|
||||
function baseDecision(
|
||||
graph: ResolvedSemanticGraph,
|
||||
node: ResolvedSemanticNode,
|
||||
output: VomNode,
|
||||
frameOwnerIds: ReadonlySet<SemanticNodeId>,
|
||||
redundant: ReadonlySet<SemanticNodeId>,
|
||||
): StructureDecision {
|
||||
if (node.excluded) return { kind: "excluded", reason: "excluded" };
|
||||
if (redundant.has(node.id)) return { kind: "excluded", reason: "redundant-source" };
|
||||
if (!hasUsableAxSemantics(node) && !isRenderedDomFallback(node)) {
|
||||
return { kind: "excluded", reason: "non-rendered-dom-fallback" };
|
||||
}
|
||||
if (frameOwnerIds.has(node.id)) return { kind: "keep", reason: "frame-owner" };
|
||||
if (node.frameId !== graph.rootFrameId && ROOT_ROLES.has(output.role?.toLowerCase() ?? "")) {
|
||||
return { kind: "transparent", reason: "child-document-root" };
|
||||
}
|
||||
if (node.ax?.ignored === true && !node.dom) {
|
||||
return { kind: "transparent", reason: "ax-ignored" };
|
||||
}
|
||||
const role = output.role?.toLowerCase() ?? "";
|
||||
if (!TRANSPARENT_ROLES.has(role)) return { kind: "keep", reason: "semantic" };
|
||||
if (
|
||||
output.name ||
|
||||
output.text ||
|
||||
output.value ||
|
||||
output.placeholder ||
|
||||
hasMeaningfulRelations(node) ||
|
||||
(output.rect && ["fixed", "sticky"].includes(output.position))
|
||||
) {
|
||||
return { kind: "keep", reason: "semantic" };
|
||||
}
|
||||
return { kind: "transparent", reason: "wrapper" };
|
||||
}
|
||||
|
||||
function buildChildren(
|
||||
nodes: ResolvedSemanticNode[],
|
||||
relation: "axParentId" | "domParentId",
|
||||
): Map<SemanticNodeId, SemanticNodeId[]> {
|
||||
const children = new Map<SemanticNodeId, SemanticNodeId[]>();
|
||||
for (const node of nodes) {
|
||||
const parentId = node[relation];
|
||||
if (!parentId) continue;
|
||||
const siblings = children.get(parentId) ?? [];
|
||||
siblings.push(node.id);
|
||||
children.set(parentId, siblings);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
function explicitDomAncestors(
|
||||
graph: ResolvedSemanticGraph,
|
||||
nodes: ResolvedSemanticNode[],
|
||||
domChildren: ReadonlyMap<SemanticNodeId, SemanticNodeId[]>,
|
||||
): Set<SemanticNodeId> {
|
||||
const inside = new Set<SemanticNodeId>();
|
||||
const roots = nodes.filter((node) => !node.domParentId);
|
||||
const queue = roots.map((node) => node.id);
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const parentId = queue[index];
|
||||
const parent = graph.nodes.get(parentId);
|
||||
const parentIsBoundary =
|
||||
parent !== undefined &&
|
||||
explicitStructural(parent) &&
|
||||
!ROOT_ROLES.has(parent.vom.role?.toLowerCase() ?? "");
|
||||
for (const childId of domChildren.get(parentId) ?? []) {
|
||||
if (inside.has(parentId) || parentIsBoundary) inside.add(childId);
|
||||
queue.push(childId);
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
function reliableGroupCandidates(
|
||||
graph: ResolvedSemanticGraph,
|
||||
nodes: ResolvedSemanticNode[],
|
||||
recovered: ReadonlyMap<SemanticNodeId, VomNode>,
|
||||
decisions: ReadonlyMap<SemanticNodeId, StructureDecision>,
|
||||
domChildren: ReadonlyMap<SemanticNodeId, SemanticNodeId[]>,
|
||||
): Set<SemanticNodeId> {
|
||||
const insideExplicitStructure = explicitDomAncestors(graph, nodes, domChildren);
|
||||
const remainingChildren = new Map(
|
||||
nodes.map((node) => [node.id, domChildren.get(node.id)?.length ?? 0]),
|
||||
);
|
||||
const summaries = new Map<SemanticNodeId, { blocked: boolean; interactions: number }>();
|
||||
const queue = nodes
|
||||
.filter((node) => (remainingChildren.get(node.id) ?? 0) === 0)
|
||||
.map((node) => node.id);
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const nodeId = queue[index];
|
||||
const node = graph.nodes.get(nodeId);
|
||||
const output = recovered.get(nodeId);
|
||||
if (!node || !output) continue;
|
||||
const role = output.role?.toLowerCase() ?? "";
|
||||
const excluded = decisions.get(nodeId)?.kind === "excluded";
|
||||
const blocked = excluded || explicitStructural(node) || CONTENT_BOUNDARY_ROLES.has(role);
|
||||
const summary = excluded
|
||||
? { blocked: true, interactions: 0 }
|
||||
: isVomReferenceNode(output)
|
||||
? { blocked: false, interactions: 1 }
|
||||
: blocked
|
||||
? { blocked: true, interactions: 0 }
|
||||
: (domChildren.get(nodeId) ?? []).reduce(
|
||||
(combined, childId) => {
|
||||
const child = summaries.get(childId);
|
||||
return {
|
||||
blocked: combined.blocked || !child || child.blocked,
|
||||
interactions: Math.min(2, combined.interactions + (child?.interactions ?? 0)),
|
||||
};
|
||||
},
|
||||
{ blocked: false, interactions: 0 },
|
||||
);
|
||||
summaries.set(nodeId, summary);
|
||||
const parentId = node.domParentId;
|
||||
if (!parentId) continue;
|
||||
const remaining = (remainingChildren.get(parentId) ?? 1) - 1;
|
||||
remainingChildren.set(parentId, remaining);
|
||||
if (remaining === 0) queue.push(parentId);
|
||||
}
|
||||
|
||||
const candidates = new Set<SemanticNodeId>();
|
||||
for (const node of nodes) {
|
||||
const output = recovered.get(node.id) as VomNode;
|
||||
const role = output.role?.toLowerCase() ?? "";
|
||||
if (
|
||||
!node.dom ||
|
||||
node.dom.tag.toLowerCase() !== "div" ||
|
||||
!TRANSPARENT_ROLES.has(role) ||
|
||||
insideExplicitStructure.has(node.id) ||
|
||||
decisions.get(node.id)?.kind === "excluded" ||
|
||||
node.dom.attrs.hidden !== undefined ||
|
||||
(node.dom.attrs["aria-hidden"] ?? "").toLowerCase() === "true" ||
|
||||
node.dom.attrs.inert !== undefined
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const branches = (domChildren.get(node.id) ?? []).map((id) => summaries.get(id));
|
||||
const blocked = branches.some((summary) => !summary || summary.blocked);
|
||||
const contributingBranches = branches.filter(
|
||||
(summary) => (summary?.interactions ?? 0) > 0,
|
||||
).length;
|
||||
const interactions = branches.reduce(
|
||||
(count, summary) => Math.min(2, count + (summary?.interactions ?? 0)),
|
||||
0,
|
||||
);
|
||||
if (!blocked && contributingBranches >= 2 && interactions >= 2) {
|
||||
candidates.add(node.id);
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function leafCandidates(
|
||||
nodes: ResolvedSemanticNode[],
|
||||
candidates: ReadonlySet<SemanticNodeId>,
|
||||
domChildren: ReadonlyMap<SemanticNodeId, SemanticNodeId[]>,
|
||||
): Set<SemanticNodeId> {
|
||||
const parentById = new Map(
|
||||
nodes.flatMap((node) => (node.domParentId ? ([[node.id, node.domParentId]] as const) : [])),
|
||||
);
|
||||
const childCount = new Map(nodes.map((node) => [node.id, domChildren.get(node.id)?.length ?? 0]));
|
||||
const containsCandidate = new Set<SemanticNodeId>();
|
||||
const queue = nodes.filter((node) => (childCount.get(node.id) ?? 0) === 0).map((node) => node.id);
|
||||
const selected = new Set<SemanticNodeId>();
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const nodeId = queue[index];
|
||||
if (candidates.has(nodeId) && !containsCandidate.has(nodeId)) selected.add(nodeId);
|
||||
const parentId = parentById.get(nodeId);
|
||||
if (!parentId) continue;
|
||||
if (candidates.has(nodeId) || containsCandidate.has(nodeId)) containsCandidate.add(parentId);
|
||||
const remaining = (childCount.get(parentId) ?? 1) - 1;
|
||||
childCount.set(parentId, remaining);
|
||||
if (remaining === 0) queue.push(parentId);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function nearestKept(
|
||||
graph: ResolvedSemanticGraph,
|
||||
startId: SemanticNodeId | undefined,
|
||||
relation: "axParentId" | "domParentId",
|
||||
kept: ReadonlySet<SemanticNodeId>,
|
||||
memo: Map<SemanticNodeId, SemanticNodeId | undefined>,
|
||||
): SemanticNodeId | undefined {
|
||||
let currentId = startId;
|
||||
const path: SemanticNodeId[] = [];
|
||||
const seen = new Set<SemanticNodeId>();
|
||||
let result: SemanticNodeId | undefined;
|
||||
while (currentId && !seen.has(currentId)) {
|
||||
if (kept.has(currentId)) {
|
||||
result = currentId;
|
||||
break;
|
||||
}
|
||||
if (memo.has(currentId)) {
|
||||
result = memo.get(currentId);
|
||||
break;
|
||||
}
|
||||
seen.add(currentId);
|
||||
path.push(currentId);
|
||||
currentId = graph.nodes.get(currentId)?.[relation];
|
||||
}
|
||||
for (const id of path) memo.set(id, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function finalParentId(
|
||||
graph: ResolvedSemanticGraph,
|
||||
node: ResolvedSemanticNode,
|
||||
kept: ReadonlySet<SemanticNodeId>,
|
||||
decisions: ReadonlyMap<SemanticNodeId, StructureDecision>,
|
||||
axParentMemo: Map<SemanticNodeId, SemanticNodeId | undefined>,
|
||||
domParentMemo: Map<SemanticNodeId, SemanticNodeId | undefined>,
|
||||
): SemanticNodeId | undefined {
|
||||
const axParent = nearestKept(graph, node.axParentId, "axParentId", kept, axParentMemo);
|
||||
const domParent = nearestKept(graph, node.domParentId, "domParentId", kept, domParentMemo);
|
||||
const axNode = axParent ? graph.nodes.get(axParent) : undefined;
|
||||
const domNode = domParent ? graph.nodes.get(domParent) : undefined;
|
||||
const axIsRoot = ROOT_ROLES.has(axNode?.vom.role?.toLowerCase() ?? "");
|
||||
const axIsExplicit = axNode ? explicitStructural(axNode) && !axIsRoot : false;
|
||||
const domIsStructural =
|
||||
domNode !== undefined &&
|
||||
(explicitStructural(domNode) || decisions.get(domNode.id)?.kind === "inferred-group");
|
||||
if (axIsExplicit) return axParent;
|
||||
if (domIsStructural) return domParent;
|
||||
if (axParent && !axIsRoot) return axParent;
|
||||
if (domParent) return domParent;
|
||||
if (axParent) return axParent;
|
||||
if (node.frameId === graph.rootFrameId) return undefined;
|
||||
return graph.frames.get(node.frameId)?.ownerNodeId;
|
||||
}
|
||||
|
||||
function recoveredVom(node: VomNode, inferredGroup: boolean): ResolvedSemanticNode["vom"] {
|
||||
const {
|
||||
id: _id,
|
||||
parentId: _parentId,
|
||||
backendNodeId: _backendNodeId,
|
||||
frameId: _frameId,
|
||||
contextScopeId: _contextScopeId,
|
||||
referenceable: _referenceable,
|
||||
...vom
|
||||
} = node;
|
||||
if (!inferredGroup) return vom;
|
||||
const { name: _name, text: _text, nearbyText: _nearbyText, ...groupVom } = vom;
|
||||
return { ...groupVom, role: "group" };
|
||||
}
|
||||
|
||||
export function normalizeSemanticStructure(graph: ResolvedSemanticGraph): StructuredSemanticGraph {
|
||||
const nodes = orderedNodes(graph);
|
||||
const recovered = recoveredNodes(graph, nodes);
|
||||
const redundant = redundantSourceNodes(graph, nodes, recovered);
|
||||
const frameOwnerIds = new Set(
|
||||
[...graph.frames.values()].flatMap((frame) => frame.ownerNodeId ?? []),
|
||||
);
|
||||
const decisions = new Map<SemanticNodeId, StructureDecision>();
|
||||
for (const node of nodes) {
|
||||
decisions.set(
|
||||
node.id,
|
||||
baseDecision(graph, node, recovered.get(node.id) as VomNode, frameOwnerIds, redundant),
|
||||
);
|
||||
}
|
||||
|
||||
const domChildren = buildChildren(nodes, "domParentId");
|
||||
const candidates = reliableGroupCandidates(graph, nodes, recovered, decisions, domChildren);
|
||||
for (const nodeId of leafCandidates(nodes, candidates, domChildren)) {
|
||||
decisions.set(nodeId, {
|
||||
kind: "inferred-group",
|
||||
reason: "independent-interaction-branches",
|
||||
});
|
||||
}
|
||||
|
||||
const kept = new Set(
|
||||
nodes
|
||||
.filter((node) => {
|
||||
const kind = decisions.get(node.id)?.kind;
|
||||
return kind === "keep" || kind === "inferred-group";
|
||||
})
|
||||
.map((node) => node.id),
|
||||
);
|
||||
const axParentMemo = new Map<SemanticNodeId, SemanticNodeId | undefined>();
|
||||
const domParentMemo = new Map<SemanticNodeId, SemanticNodeId | undefined>();
|
||||
const structuredNodes = new Map<SemanticNodeId, StructuredSemanticNode>();
|
||||
for (const node of nodes) {
|
||||
const structure = decisions.get(node.id) as StructureDecision;
|
||||
const inferredGroup = structure.kind === "inferred-group";
|
||||
structuredNodes.set(node.id, {
|
||||
...node,
|
||||
vom: recoveredVom(recovered.get(node.id) as VomNode, inferredGroup),
|
||||
structure,
|
||||
...(kept.has(node.id)
|
||||
? {
|
||||
semanticParentId: finalParentId(
|
||||
graph,
|
||||
node,
|
||||
kept,
|
||||
decisions,
|
||||
axParentMemo,
|
||||
domParentMemo,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return { ...graph, nodes: structuredNodes };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Viewport, VomNode } from "@browser-skill/vom";
|
||||
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
||||
import type { CapturedNode } from "../capture";
|
||||
|
||||
export interface SemanticAxNode {
|
||||
nodeId: string;
|
||||
frameId?: string;
|
||||
parentId?: string;
|
||||
backendDOMNodeId?: number;
|
||||
ignored?: boolean;
|
||||
role?: { type: string; value?: string };
|
||||
name?: { type: string; value?: string };
|
||||
description?: { value?: string };
|
||||
value?: { value?: string | number | boolean };
|
||||
properties?: Array<{ name?: string; value?: { value?: string | number | boolean } }>;
|
||||
childIds?: string[];
|
||||
}
|
||||
|
||||
export type SemanticNodeId = string;
|
||||
|
||||
export interface SemanticFrame {
|
||||
frameId: string;
|
||||
parentFrameId?: string;
|
||||
ownerBackendNodeId?: number;
|
||||
ownerNodeId?: SemanticNodeId;
|
||||
contextScopeId: string;
|
||||
target: CdpTarget;
|
||||
url?: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface SemanticGraphNode {
|
||||
id: SemanticNodeId;
|
||||
frameId: string;
|
||||
backendNodeId?: number;
|
||||
axNodeId?: string;
|
||||
ax?: SemanticAxNode;
|
||||
dom?: CapturedNode;
|
||||
axParentId?: SemanticNodeId;
|
||||
domParentId?: SemanticNodeId;
|
||||
sourceOrder: number;
|
||||
excluded: boolean;
|
||||
}
|
||||
|
||||
export interface SemanticGraph {
|
||||
viewport: Viewport;
|
||||
rootFrameId: string;
|
||||
frames: Map<string, SemanticFrame>;
|
||||
nodes: Map<SemanticNodeId, SemanticGraphNode>;
|
||||
nodeIdsByFrame: Map<string, SemanticNodeId[]>;
|
||||
nodeByFrameBackend: Map<string, SemanticNodeId>;
|
||||
}
|
||||
|
||||
export interface ResolvedSemanticNode extends SemanticGraphNode {
|
||||
vom: Omit<VomNode, "id" | "parentId" | "backendNodeId" | "frameId" | "contextScopeId">;
|
||||
referenceable: boolean;
|
||||
roleSource: "ax" | "ax-ignored" | "dom-explicit" | "dom-native" | "none";
|
||||
}
|
||||
|
||||
export interface ResolvedSemanticGraph extends Omit<SemanticGraph, "nodes"> {
|
||||
nodes: Map<SemanticNodeId, ResolvedSemanticNode>;
|
||||
}
|
||||
|
||||
export type StructureDecision =
|
||||
| { kind: "keep"; reason: "semantic" | "frame-owner" }
|
||||
| { kind: "inferred-group"; reason: "independent-interaction-branches" }
|
||||
| { kind: "transparent"; reason: "wrapper" | "child-document-root" | "ax-ignored" }
|
||||
| {
|
||||
kind: "excluded";
|
||||
reason: "excluded" | "redundant-source" | "non-rendered-dom-fallback";
|
||||
};
|
||||
|
||||
export interface StructuredSemanticNode extends ResolvedSemanticNode {
|
||||
structure: StructureDecision;
|
||||
semanticParentId?: SemanticNodeId;
|
||||
}
|
||||
|
||||
export interface StructuredSemanticGraph extends Omit<ResolvedSemanticGraph, "nodes"> {
|
||||
nodes: Map<SemanticNodeId, StructuredSemanticNode>;
|
||||
}
|
||||
|
||||
export function frameBackendKey(frameId: string, backendNodeId: number): string {
|
||||
return `${frameId}\u0000${backendNodeId}`;
|
||||
}
|
||||
|
||||
export function frameAxKey(frameId: string, axNodeId: string): string {
|
||||
return `${frameId}\u0000${axNodeId}`;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_DAEMON_PORT,
|
||||
normalizeDaemonPort,
|
||||
parseDaemonPortInput,
|
||||
resolveDaemonWsUrl,
|
||||
} from "../daemon-endpoint";
|
||||
|
||||
describe("daemon-endpoint", () => {
|
||||
it("resolveDaemonWsUrl(DEFAULT) matches the build-time constant", () => {
|
||||
expect(resolveDaemonWsUrl(DEFAULT_DAEMON_PORT)).toBe(__BSK_DAEMON_WS_URL__);
|
||||
});
|
||||
|
||||
it("resolveDaemonWsUrl replaces only the port", () => {
|
||||
expect(resolveDaemonWsUrl(53200)).toBe("ws://127.0.0.1:53200");
|
||||
});
|
||||
|
||||
it("normalizeDaemonPort falls back to default for invalid values", () => {
|
||||
expect(normalizeDaemonPort(undefined)).toBe(DEFAULT_DAEMON_PORT);
|
||||
expect(normalizeDaemonPort(0)).toBe(DEFAULT_DAEMON_PORT);
|
||||
expect(normalizeDaemonPort(65536)).toBe(DEFAULT_DAEMON_PORT);
|
||||
expect(normalizeDaemonPort("abc")).toBe(DEFAULT_DAEMON_PORT);
|
||||
expect(normalizeDaemonPort(1.5)).toBe(DEFAULT_DAEMON_PORT);
|
||||
});
|
||||
|
||||
it("normalizeDaemonPort accepts valid numbers and numeric strings", () => {
|
||||
expect(normalizeDaemonPort(53200)).toBe(53200);
|
||||
expect(normalizeDaemonPort("53200")).toBe(53200);
|
||||
});
|
||||
|
||||
it("parseDaemonPortInput treats empty as default and rejects invalid", () => {
|
||||
expect(parseDaemonPortInput("")).toBe(DEFAULT_DAEMON_PORT);
|
||||
expect(parseDaemonPortInput(" ")).toBe(DEFAULT_DAEMON_PORT);
|
||||
expect(parseDaemonPortInput("abc")).toBeNull();
|
||||
expect(parseDaemonPortInput("0")).toBeNull();
|
||||
expect(parseDaemonPortInput("65536")).toBeNull();
|
||||
});
|
||||
|
||||
it("parseDaemonPortInput accepts valid ports", () => {
|
||||
expect(parseDaemonPortInput("53200")).toBe(53200);
|
||||
expect(parseDaemonPortInput(" 52800 ")).toBe(52800);
|
||||
});
|
||||
});
|
||||
@@ -270,4 +270,19 @@ describe("WSTransport", () => {
|
||||
lastSocket().receive({ id: "2", result: 2 });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("setUrl returns false for the same URL and uses the new URL on the next connect", async () => {
|
||||
const t = new WSTransport({
|
||||
url: "ws://127.0.0.1:52800",
|
||||
webSocketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
expect(t.setUrl("ws://127.0.0.1:52800")).toBe(false);
|
||||
expect(t.setUrl("ws://127.0.0.1:53200")).toBe(true);
|
||||
|
||||
const p = t.connect();
|
||||
expect(lastSocket().url).toBe("ws://127.0.0.1:53200");
|
||||
lastSocket().open();
|
||||
await p;
|
||||
expect(t.state).toBe("connected");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/** Build-time default WebSocket URL (see wxt.config.ts). */
|
||||
const base = new URL(__BSK_DAEMON_WS_URL__);
|
||||
|
||||
export const DEFAULT_DAEMON_PORT = Number(base.port) || 52800;
|
||||
|
||||
const MIN_PORT = 1;
|
||||
const MAX_PORT = 65535;
|
||||
|
||||
function isValidPort(n: number): boolean {
|
||||
return Number.isInteger(n) && n >= MIN_PORT && n <= MAX_PORT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the daemon WebSocket URL for a loopback port, preserving the
|
||||
* build-time host and path from {@link __BSK_DAEMON_WS_URL__}.
|
||||
*/
|
||||
export function resolveDaemonWsUrl(port: number): string {
|
||||
const path = base.pathname === "/" ? "" : base.pathname;
|
||||
return `${base.protocol}//${base.hostname}:${port}${path}`;
|
||||
}
|
||||
|
||||
/** Non-integer / out-of-range / unset → {@link DEFAULT_DAEMON_PORT}. */
|
||||
export function normalizeDaemonPort(raw: unknown): number {
|
||||
if (typeof raw === "number" && isValidPort(raw)) return raw;
|
||||
if (typeof raw === "string" && raw.trim() !== "") {
|
||||
const parsed = Number.parseInt(raw.trim(), 10);
|
||||
if (isValidPort(parsed)) return parsed;
|
||||
}
|
||||
return DEFAULT_DAEMON_PORT;
|
||||
}
|
||||
|
||||
/** Input semantics: empty string → default port; invalid → null. */
|
||||
export function parseDaemonPortInput(text: string): number | null {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === "") return DEFAULT_DAEMON_PORT;
|
||||
if (!/^\d+$/.test(trimmed)) return null;
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
if (!isValidPort(parsed)) return null;
|
||||
return parsed;
|
||||
}
|
||||
@@ -330,9 +330,14 @@ export interface SnapshotResult {
|
||||
|
||||
export interface ObserveParams extends SnapshotParams {
|
||||
debug_surfaces?: boolean;
|
||||
probe_hover?: boolean;
|
||||
}
|
||||
|
||||
export interface ObserveResult extends SnapshotResult {
|
||||
hover_probe?: {
|
||||
performed: boolean;
|
||||
revealed_content: boolean;
|
||||
};
|
||||
debug?: {
|
||||
surface_probes?: Array<{
|
||||
trigger_backend_node_id: number;
|
||||
|
||||
@@ -40,7 +40,7 @@ interface MessageLikeEvent {
|
||||
* (1s, 2s, 4s, …, capped at 5s) until `disconnect()` is called.
|
||||
*/
|
||||
export class WSTransport implements Transport {
|
||||
private readonly url: string;
|
||||
private url: string;
|
||||
private readonly factory: WebSocketFactory;
|
||||
private readonly initialDelayMs: number;
|
||||
private readonly maxDelayMs: number;
|
||||
@@ -69,6 +69,13 @@ export class WSTransport implements Transport {
|
||||
return this.currentState;
|
||||
}
|
||||
|
||||
/** Returns whether the URL changed. Does not reconnect. */
|
||||
setUrl(url: string): boolean {
|
||||
if (url === this.url) return false;
|
||||
this.url = url;
|
||||
return true;
|
||||
}
|
||||
|
||||
connect(): Promise<void> {
|
||||
if (this.currentState === "connected") return Promise.resolve();
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"!packages/dsh-plugin-browserskill/src/client/bsk-ui.nomodule.css",
|
||||
"!packages/dsh-plugin-browserskill/.bsk-ui.raw.css",
|
||||
"!packages/dsh-plugin-browserskill/.bsk-ui.input.css",
|
||||
"!evals/browser/results/**",
|
||||
"!**/target/**",
|
||||
"!**/.wxt/**",
|
||||
"!**/.output/**",
|
||||
|
||||
+104
-161
@@ -1,160 +1,125 @@
|
||||
---
|
||||
name: browser-skill
|
||||
description: |
|
||||
Use when the user asks to perform browser automation tasks against their
|
||||
logged-in browser: visit and read pages, fill forms, scrape data, click
|
||||
through a flow, regression-test a PR's UI, validate a deployed page.
|
||||
Requires the bsk CLI installed and the browser-skill extension loaded.
|
||||
Use when the user asks to automate their logged-in Chromium browser: visit
|
||||
and read pages, fill forms, scrape data, click through flows, regression-test
|
||||
a PR's UI, validate a deployed page, or operate a tab they identify. Requires
|
||||
the bsk CLI and browser extension.
|
||||
---
|
||||
|
||||
# browser-skill
|
||||
|
||||
Drive the user's **real Chromium browser** (with their logins and cookies) through the `bsk` CLI. The extension opens an isolated **Agent Window** for automation; the user's normal windows stay protected unless you explicitly borrow a tab.
|
||||
Drive the user's real Chromium browser through `bsk`. Automation runs in an isolated **Agent
|
||||
Window** with the user's existing logins and cookies. User-window tabs remain protected unless they
|
||||
are explicitly borrowed.
|
||||
|
||||
## When to use
|
||||
Do not use this skill for tasks with no browser, for extension installation, or when the user only
|
||||
wants instructions. Never extract credentials, cookies, tokens, or other secrets from pages.
|
||||
|
||||
- Open pages, read titles/text, scrape structured data from sites the user can already access
|
||||
- Fill forms, click through multi-step flows, smoke-test a UI change
|
||||
- Understand pages with `bsk snapshot` first; use `bsk get-html` or `bsk screenshot` only when the snapshot is insufficient
|
||||
- Operate on a specific user tab they point you at (after `bsk tab borrow`)
|
||||
## Required lifecycle
|
||||
|
||||
## When NOT to use
|
||||
Every browser task owns a bounded session:
|
||||
|
||||
- Tasks with **no browser** involved (files, APIs, databases only)
|
||||
- Installing or configuring the extension (point the user to setup docs instead)
|
||||
- **Credential harvesting** — never run `bsk evaluate` on banking, SSO, or password-manager pages to extract tokens, cookies, or secrets
|
||||
- Long-lived control of a user's personal login window — borrow only for the immediate step, then `bsk tab return` or end the session
|
||||
- Replacing the user's manual browsing when they only wanted an explanation
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. `bsk` on `PATH` (Rust CLI from browser-skill)
|
||||
2. browser-skill **extension** loaded in Chromium and connected (popup shows green)
|
||||
3. Any `bsk` command auto-starts background services as needed; use `bsk doctor` if anything fails
|
||||
|
||||
## Mandatory workflow
|
||||
|
||||
Every automation task **must** follow this lifecycle. Do **not** rely on idle timeouts (default session idle is 5 minutes).
|
||||
|
||||
```
|
||||
1. bsk session start → capture the 4-letter session id printed on stdout
|
||||
2. … every tool command … → always pass --session <id>
|
||||
3. bsk session stop <id> → REQUIRED when done (even on error paths)
|
||||
```text
|
||||
1. bsk session start # retain the printed 4-letter session id
|
||||
2. bsk ... --session <id> # pass it to every session-scoped command
|
||||
3. bsk session stop <id> # always run on success and error paths
|
||||
```
|
||||
|
||||
Optional: `bsk session start --browser <instance-id-or-label>` when multiple browsers are connected (`bsk browsers` / error output lists them). Add `--no-focus` to open the Agent Window in the background without stealing focus from the user's current window.
|
||||
Do not rely on the idle timeout for cleanup. Stop the session as soon as the goal is met unless the
|
||||
user explicitly asks to keep it open. Stopping also returns borrowed tabs.
|
||||
|
||||
Emergency cleanup: `bsk session stop --all` or the Agent Window overlay **Stop all**.
|
||||
Any `bsk` command auto-starts the background services it needs; never manage the daemon by hand.
|
||||
When multiple browsers are connected, use `bsk browsers` and start with
|
||||
`bsk session start --browser <id-or-label>`. Add `--no-focus` to that same start command when the
|
||||
Agent Window does not need to interrupt the user's current work; it is not a flag on other commands.
|
||||
Run `bsk doctor` when startup or transport problems persist after one retry.
|
||||
|
||||
## Stop when the goal is met
|
||||
## Work toward one observable goal
|
||||
|
||||
Every task is a **bounded goal**, not open-ended browsing. The goal may come from the user's request, a recorded `trace.json`, or both.
|
||||
- Derive a concrete success condition from the user's request or a supplied trace.
|
||||
- Take the shortest purposeful path: observe, act, then make at most one observation to confirm an
|
||||
ambiguous result.
|
||||
- Once success is visible, do not click, refresh, navigate, switch tabs, or perform extra checks.
|
||||
- If a human-only step appears or two attempts make no progress, request help instead of
|
||||
brute-forcing.
|
||||
|
||||
1. **Define success first** — one concrete, observable condition derived from the user's words, `purpose`, or the last meaningful step in a trace (e.g. "form submitted", "item added to cart", "playback started").
|
||||
2. **Take the shortest path** — snapshot → act → at most one check. Do not wander, re-try unrelated actions, or stack exploratory steps.
|
||||
3. **Stop as soon as success is reached** — run `bsk session stop <id>` immediately unless the user explicitly asked to keep the session open (e.g. "don't close yet", "keep browsing").
|
||||
4. **No post-success work** — once the goal is met, do not click, refresh, navigate, re-search, switch tabs, or "double-check" that it worked. Further verification is a new task.
|
||||
5. **When blocked, pause — do not brute-force** — if the page requires human input (login, captcha, OTP, payment confirmation) or an action fails twice with no progress, call `bsk request-help` instead of retrying blindly. See **Ask the human for help** below.
|
||||
6. **When unsure** — at most one extra `bsk snapshot`. If success looks met, stop. If not, ask the user; do not keep clicking.
|
||||
With a trace, follow its semantic target information and values in order, but treat its refs as
|
||||
record-local hints. Stop when its purpose or last meaningful effect is satisfied. A trace guides the
|
||||
task; it does not expand the user's goal or authorize additional actions.
|
||||
|
||||
**With a trace:** replay steps in order using `target` role/name/tag and raw `value`/`selection` fields. After the last step (or when its `effect.navigated_to` / success hint is satisfied), apply rules 3–4 immediately. The trace guides execution; it does not extend control beyond the goal.
|
||||
## Observe, act, observe
|
||||
|
||||
**Without a trace:** the user's request *is* the success condition. Satisfying it ends the task — same stop rules apply.
|
||||
Use this default loop:
|
||||
|
||||
## Core interaction loop
|
||||
|
||||
Write operations only affect tabs in the **Agent Window** (or tabs you **borrowed** into it).
|
||||
|
||||
```
|
||||
```text
|
||||
bsk navigate <url> --session <id>
|
||||
bsk observe --session <id> → primary semantic VOM view; reveals hover/focus surfaces
|
||||
bsk snapshot --session <id> → static aria tree fallback when VOM is insufficient
|
||||
bsk hover @e3 --session <id> → reveal hover-triggered menus before re-observing/clicking
|
||||
bsk click @e4 --session <id> → or bsk fill, bsk select, bsk press
|
||||
bsk observe --session <id> → again after navigation / DOM change
|
||||
bsk observe --session <id>
|
||||
bsk click|hover|fill|select|press ... --session <id>
|
||||
bsk observe --session <id> # after navigation or a meaningful DOM change
|
||||
```
|
||||
|
||||
**Refs invalidate after navigation** — always re-snapshot before clicking, filling, or selecting on a new page.
|
||||
Prefer fresh `@eN` refs over CSS selectors. Navigation invalidates refs; large DOM changes may also
|
||||
make them stale. Observe again before the next interaction.
|
||||
|
||||
Prefer `@eN` refs from the latest snapshot over raw CSS selectors. Use `--ref` / `--selector` when ambiguous (`bsk click --help`).
|
||||
An observation marks a hover-only surface as `@e1 button "Products" [hover first: Shoes | Bags]`.
|
||||
The listed items are labels, not usable refs: hover the trigger, observe again, then act on the
|
||||
revealed item's own ref. Do not click the trigger itself unless the user wants the trigger's action.
|
||||
`[has-submenu]` and `[expanded]` mark the same kind of trigger without listing what it hides.
|
||||
|
||||
When VOM renders `[hover first: …]` on an element, the listed items are not currently clickable refs. Run `bsk hover <that-ref> --session <id>`, then immediately run `bsk snapshot` or `bsk observe` again and click the newly visible menu item ref. Do not click the trigger itself unless the user explicitly wants the trigger action.
|
||||
`bsk observe` does not hover the page on its own. Reach for `--probe-hover` when a control you have
|
||||
good reason to expect is absent **and** no marker points at a trigger — that combination is what a
|
||||
CSS-only hover menu looks like from here. It hovers a bounded set of likely triggers, so it costs a
|
||||
few seconds and touches the live page; once you know which element hides the menu, `bsk hover <ref>`
|
||||
is cheaper and more precise.
|
||||
|
||||
## Observation priority
|
||||
Escalate page reading only as needed:
|
||||
|
||||
Start with `bsk observe` to understand page structure, text, controls, element refs, and conditional hover/focus surfaces. Use `bsk snapshot` only when you need the stricter static accessibility tree or VOM is insufficient. Only escalate to raw HTML or screenshots when the latest observation cannot answer the question:
|
||||
1. `bsk observe` for normal semantic understanding, text, controls, and refs.
|
||||
2. `bsk observe --probe-hover` once when an expected control is missing and no marker points at a
|
||||
trigger.
|
||||
3. `bsk snapshot` when a stricter static accessibility tree is more useful.
|
||||
4. `bsk get-html` for exact markup or hidden metadata that semantic views cannot provide.
|
||||
5. `bsk screenshot` for layout, styling, canvas, images, or requested visual evidence.
|
||||
|
||||
1. `bsk observe` — primary semantic VOM observation; may run bounded perception probes such as hover-surface discovery
|
||||
2. `bsk snapshot` — strict static accessibility tree fallback
|
||||
3. `bsk get-html` — when hidden DOM, metadata, or markup details are required
|
||||
4. `bsk screenshot` — when visual layout, canvas/image content, or styling cannot be inferred from the observation. Use `--ref @eN` (from the latest snapshot/observe) to crop to one element; omit `--ref` for the full visible tab.
|
||||
Do not start with raw HTML or screenshots merely to discover ordinary controls. When interaction is
|
||||
needed, obtain a fresh observation before acting on screenshot or HTML findings.
|
||||
|
||||
Do **not** call `bsk get-html` or `bsk screenshot` first just to inspect a page.
|
||||
## Respect the Agent Window boundary
|
||||
|
||||
## Sandbox rules
|
||||
Normal page writes affect only Agent Window tabs. To operate a user tab, first list it with
|
||||
`bsk tab list --scope user --session <id>`, then `bsk tab borrow <tab-id>`. Return it immediately
|
||||
after the relevant step with `bsk tab return <tab-id>`; never invent a tab id or keep a personal tab
|
||||
borrowed across unrelated work.
|
||||
|
||||
| Rule | Detail |
|
||||
|------|--------|
|
||||
| Agent Window | `bsk tab create`, `bsk navigate`, `bsk click`, etc. work on agent tabs by default |
|
||||
| User tabs | Read-only until borrowed: `bsk tab list --session <id> --scope user` then `bsk tab borrow <tab-id> --session <id>` |
|
||||
| Return borrowed tabs | Call `bsk tab return <tab-id> --session <id>` when finished; unreturned tabs are **auto-returned** on `bsk session stop` |
|
||||
| Writes off-agent | Commands that mutate the page fail if the tab is not in the Agent Window — borrow or create a tab first |
|
||||
## Ask the human when needed
|
||||
|
||||
## Global flags
|
||||
Use `bsk request-help` for login, captcha, OTP, payment confirmation, consent, or another step the
|
||||
user must complete. Give a precise prompt and pass fresh `--target` refs/selectors when concrete
|
||||
controls can be highlighted. Use completion criteria only when the page has a clear stable success
|
||||
signal.
|
||||
|
||||
| Flag | Purpose |
|
||||
|------|---------|
|
||||
| `--json` | Machine-readable JSON on stdout (errors too) |
|
||||
| `--quiet` | Suppress informational stderr |
|
||||
| `-v` / `-vv` | More verbose logging |
|
||||
The result `outcome` is one of `continued`, `completed`, `cancelled`, `timed_out`, or `disabled`
|
||||
(`navigated` is deprecated — never treat navigation as a completion signal). Resume only after
|
||||
`continued` or `completed`. Treat `cancelled` as rejection, and `timed_out` or `disabled` as a
|
||||
blocker rather than a reason to retry. After control returns, run a fresh `bsk observe` before
|
||||
reasoning about the page or using refs.
|
||||
|
||||
Auto-update is **on by default** — the background daemon upgrades `bsk` itself when a new release is available (postponed while a session is active). Set `BSK_AUTO_UPDATE=off` to disable it and upgrade manually with `bsk update`.
|
||||
## Command inventory
|
||||
|
||||
Command-specific flags (timeouts, `--tab-id`, `--wait-until`, …): **`bsk <cmd> --help`**
|
||||
This list of names is complete. Never invent a command outside it; read
|
||||
`bsk <command...> --help` for flags instead of guessing them.
|
||||
|
||||
## CLI command reference (one line each)
|
||||
|
||||
Details and flags: **`bsk <cmd> --help`**
|
||||
|
||||
### Diagnostics
|
||||
|
||||
| Command | Summary |
|
||||
|---------|---------|
|
||||
| `bsk status` | Connection health, connected browsers, active sessions |
|
||||
| `bsk doctor` | Deep diagnostics and repair hints |
|
||||
| `bsk browsers` | List connected browser instances (ids, labels, versions) |
|
||||
|
||||
### Session
|
||||
|
||||
| Command | Summary |
|
||||
|---------|---------|
|
||||
| `bsk session start` | Open Agent Window (`--width`/`--height` for initial size); prints **4-letter session id** |
|
||||
| `bsk session start --no-focus` | Open Agent Window in the background without stealing focus |
|
||||
| `bsk session stop <id>` | End session, close Agent Window, auto-return borrowed tabs |
|
||||
| `bsk session stop --all` | Stop every active session |
|
||||
| `bsk session list` | List active sessions |
|
||||
|
||||
### Window (require `--session <id>`)
|
||||
|
||||
| Command | Summary |
|
||||
|---------|---------|
|
||||
| `bsk window resize` | Resize the Agent Window (`--width`, `--height`; 100..=7680 CSS px) |
|
||||
|
||||
### Device emulation — `bsk emulate` (requires `--session <id>`)
|
||||
|
||||
Emulate a mobile device environment on the agent tab via CDP — viewport, User-Agent, and touch — to debug a page's mobile layout and behaviour:
|
||||
|
||||
```bash
|
||||
bsk emulate --session <id> --device iphone-14
|
||||
bsk emulate --session <id> --width 390 --height 844 --dpr 3 --mobile --ua "Mozilla/5.0 (iPhone…" --touch
|
||||
bsk emulate --session <id> --off
|
||||
```text
|
||||
session start|stop|list browsers status doctor update logs
|
||||
navigate navigate-back navigate-forward reload wait-for-navigation wait-ms
|
||||
observe snapshot get-html screenshot console network
|
||||
click hover fill select press evaluate
|
||||
tab list|create|close|select|borrow|return window resize emulate
|
||||
request-help record start|stop
|
||||
```
|
||||
|
||||
- Presets (`--device`): `iphone-14`, `iphone-14-pro-max`, `iphone-se`, `pixel-7`, `galaxy-s23`, `ipad-mini`, `galaxy-tab-s8`. Manual flags (`--width`/`--height`/`--dpr`/`--mobile`/`--ua`/`--accept-language`/`--touch`/`--max-touch-points`) also work without a preset, or override individual preset fields; `--no-mobile`/`--no-touch` turn a preset's mobile viewport / touch emulation back off.
|
||||
- Repeated runs merge field by field onto the tab's current emulation state — only the flags you pass change. E.g. after `--device iphone-14`, `bsk emulate --session <id> --width 390 --height 844` keeps the preset's dpr (`3`) and mobile viewport. (The extension remembers the state per tab; after an extension reload the next run applies only the fields it carries.)
|
||||
- `--off` clears every override (viewport, UA, touch) and the remembered state, restoring the tab's real environment.
|
||||
- Scope: overrides apply to **one tab only** (CDP per-target) and are **not inherited by new tabs** — re-run `bsk emulate` after opening or switching to another tab (default target is the session's active tab; `--tab-id` overrides).
|
||||
- Emulation covers viewport/UA/touch only; it does not throttle the network, fake geolocation, or synthesise real touch-event streams.
|
||||
Required flags that are easy to get wrong:
|
||||
|
||||
### Tabs (require `--session <id>`)
|
||||
|
||||
@@ -233,7 +198,7 @@ If `request-help` returns `outcome="disabled"`, do not retry it. Stop gracefully
|
||||
|
||||
| Command | Summary |
|
||||
|---------|---------|
|
||||
| `bsk evaluate <expression>` | Run JS in agent tab (see red lines); JS throw → stderr, **exit 0** |
|
||||
| `bsk evaluate <expression>` | Run JS in agent tab (see red lines). JS throw → stderr, **exit 0** (RPC success); use `--json` and check `.ok` to detect JS errors |
|
||||
| `bsk wait-for-navigation` | Block until load/DOM idle/etc. (`--wait-until`, `--timeout`) |
|
||||
| `bsk wait-ms <duration>` | Sleep (`500ms`, `2s`, `1m`; **no** `--session`) |
|
||||
|
||||
@@ -303,48 +268,26 @@ bsk record start --browser <instance-id-or-label> \
|
||||
bsk record stop [--output trace] # terminal fallback if the browser panel is unavailable
|
||||
```
|
||||
|
||||
- **v3 bundle (preferred):** `--output` is a directory (default `./trace`) containing `trace.json` and `states/`. `trace.json` lists `states[]` (page observation ids) and `steps[]` (each step binds `state` = observe snapshot *before* the action and `result.state` = snapshot *after* settle). Page bodies live in `states/sN.txt` using the same VOM format as `bsk observe`.
|
||||
- **v2 fallback:** when the connected extension is older, export may contain only legacy `trace.json` with `pages[]` action context and no `states/` observation files. Update the extension for full v3 bundles.
|
||||
- Each `states[]` entry is one **settled page observation** (captured after recording start, navigation landing, or action settle — not on a timer).
|
||||
- `target.ref` values like `@e12` exist **only inside** the bundle for disambiguation; do **not** copy `@eN` refs into SKILL.md or agent runbooks — use visible names from the observation text instead.
|
||||
- `--purpose` is optional context metadata; it does **not** change what gets captured.
|
||||
- `--redact-values` masks all form values in page files as `[filled]` / `[empty]`.
|
||||
- Do **not** record on banking/SSO/password-manager pages; passwords are redacted but traces may still contain sensitive text.
|
||||
`select` matches an option's `value` attribute, not its visible label. Device preset ids are
|
||||
lowercase and hyphenated, such as `iphone-14`.
|
||||
|
||||
## Error handling
|
||||
- `console` and `network` provide bounded, read-only debugging evidence.
|
||||
- `emulate` applies viewport, user-agent, and touch overrides to one tab; new tabs do not inherit
|
||||
them. Use `--off` to restore the real environment.
|
||||
- `evaluate` is a last resort when observe plus normal interactions cannot complete the task. With
|
||||
`--json`, inspect `.ok`: a JavaScript exception may still have CLI exit code 0 because the RPC
|
||||
succeeded. Never evaluate credential surfaces to read storage, cookies, or auth data.
|
||||
- `record` captures a user's actions for later replay. Read `bsk record start --help` before use,
|
||||
and never record banking, SSO, password-manager, or other sensitive pages.
|
||||
|
||||
### Exit codes (`echo $?` after `bsk …`)
|
||||
## Recover without wandering
|
||||
|
||||
| Code | Meaning | What to do |
|
||||
|------|---------|------------|
|
||||
| `0` | Success (including `evaluate` where JS threw but RPC succeeded) | Continue |
|
||||
| `1` | User error — bad args, unknown session, tab not in Agent Window, stale ref | Fix args; `bsk session list`; re-snapshot |
|
||||
| `2` | Protocol / transport — service unreachable, IPC failure | `bsk doctor`; check extension connected; retry the command |
|
||||
| `3` | Browser / CDP execution failed | Retry; simplify selector; check tab still open |
|
||||
| `4` | Timeout | Increase `--timeout`; try `--wait-until domcontentloaded` |
|
||||
| `5` | Version skew (CLI vs extension) | Upgrade/reinstall matching versions |
|
||||
- Stale ref: observe again and retry the intended action once.
|
||||
- Unknown tab or session: list current tabs/sessions; never guess identifiers.
|
||||
- Timeout: inspect current page state before deciding whether one longer purposeful wait is useful.
|
||||
- Unsupported command: continue with available capabilities; suggest updating only when the missing
|
||||
command is necessary.
|
||||
- Unrecoverable failure: report the blocker and stop the session in a finally-style path.
|
||||
|
||||
Human errors print `error:` + `hint:` on stderr; `--json` includes `code`, `message`, `hint`, `exit_code`.
|
||||
|
||||
### When to run diagnostics
|
||||
|
||||
| Situation | Command |
|
||||
|-----------|---------|
|
||||
| Before first task in a session | `bsk status` — extension connected? |
|
||||
| Any failure you cannot fix in one retry | `bsk doctor` |
|
||||
| Multiple browsers / wrong target | `bsk browsers` then `bsk session start --browser <id>` |
|
||||
|
||||
Always **`bsk session stop <id>`** in a `finally`-style path so the Agent Window closes and borrowed tabs return.
|
||||
|
||||
## Red lines
|
||||
|
||||
1. **No token theft** — do not `bsk evaluate` on sensitive sites to read `localStorage`, cookies, or auth headers for exfiltration.
|
||||
2. **No long borrow** — do not leave a user's personal tab in the Agent Window across unrelated tasks.
|
||||
3. **No skip stop** — always `bsk session stop <id>`; never assume idle timeout will clean up.
|
||||
4. **No post-success control** — once the user’s goal (or last trace step) is met, do not keep operating the page; stop the session unless they asked to keep it open.
|
||||
5. **No raw observe escalation before snapshot/observe** — use `bsk snapshot` first; use `bsk observe` when VOM semantics or conditional surfaces help. Only use `bsk get-html` or `bsk screenshot` when snapshot/observe is insufficient. Element screenshots (`--ref @eN`) still require a fresh snapshot/observe ref — never skip observation just to grab a visual.
|
||||
6. **`evaluate` is powerful and risky** — use only when snapshot + click/fill/select cannot suffice; never on credential surfaces.
|
||||
|
||||
---
|
||||
|
||||
**More detail for any command:** `bsk <cmd> --help`
|
||||
The CLI's current help and error hints are authoritative for flags, parameters, and recovery
|
||||
details.
|
||||
|
||||
@@ -351,10 +351,10 @@ fn check_version_compatible(status: Option<&StatusResult>) -> CheckResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// `bsk doctor` check: every connected browser should use the same
|
||||
/// protocol version as the daemon. Minor protocol drift is accepted by
|
||||
/// the daemon but flagged here so the user can see who needs updating
|
||||
/// (M10.4).
|
||||
/// `bsk doctor` check: connected browsers should speak a protocol the
|
||||
/// daemon accepts. A different protocol string is still a live
|
||||
/// connection — report Ok so agents keep working. The detail is an
|
||||
/// upgrade reminder, not a blocker.
|
||||
///
|
||||
/// Review M2 (round-1 minor): when no browsers are connected, the
|
||||
/// check has nothing to compare against, so it now reports
|
||||
@@ -396,13 +396,12 @@ fn check_browsers_protocol_compatible(status: Option<&StatusResult>) -> CheckRes
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
CheckResult::fail(
|
||||
CheckResult::ok(
|
||||
name,
|
||||
format!(
|
||||
"{} browser(s) have protocol minor drift from the daemon: {stale}",
|
||||
"{} browser(s) report a different protocol version (still usable — continue, and upgrade soon): {stale}",
|
||||
status.version_skew_browsers.len()
|
||||
),
|
||||
"upgrade the browser-skill extension or bsk CLI so both sides use the same protocol version",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -581,7 +580,7 @@ mod m2_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browsers_check_reports_fail_when_skew_present() {
|
||||
fn browsers_check_reports_ok_when_skew_present() {
|
||||
let status = fake_status(
|
||||
vec![BrowserStatusEntry {
|
||||
instance_id: "alpha".into(),
|
||||
@@ -605,9 +604,17 @@ mod m2_tests {
|
||||
}],
|
||||
);
|
||||
let check = check_browsers_protocol_compatible(Some(&status));
|
||||
assert_eq!(check.status, CheckStatus::Fail);
|
||||
assert_eq!(check.status, CheckStatus::Ok);
|
||||
assert!(check.detail.contains("alpha"));
|
||||
assert!(check.hint.is_some());
|
||||
assert!(
|
||||
check
|
||||
.detail
|
||||
.contains("still usable — continue, and upgrade soon")
|
||||
);
|
||||
assert!(
|
||||
check.hint.is_none(),
|
||||
"a protocol-version note must not hint to stop"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -635,7 +642,7 @@ mod m2_tests {
|
||||
}],
|
||||
);
|
||||
let check = check_browsers_protocol_compatible(Some(&status));
|
||||
assert_eq!(check.status, CheckStatus::Fail);
|
||||
assert_eq!(check.status, CheckStatus::Ok);
|
||||
assert!(
|
||||
check
|
||||
.detail
|
||||
|
||||
@@ -33,6 +33,12 @@ pub struct ObserveArgs {
|
||||
/// Include conditional surface probe diagnostics in JSON output.
|
||||
#[arg(long = "debug-surfaces")]
|
||||
pub debug_surfaces: bool,
|
||||
|
||||
/// Actively hover page controls to reveal hover-only menus and tooltips.
|
||||
/// Costs seconds of wall clock and touches the live page, so it is off
|
||||
/// unless a static observation looks like it is missing hover content.
|
||||
#[arg(long = "probe-hover")]
|
||||
pub probe_hover: bool,
|
||||
}
|
||||
|
||||
pub fn dispatch(args: ObserveArgs, format: Format) -> Result<(), CliError> {
|
||||
@@ -47,6 +53,7 @@ fn run(sock: PathBuf, args: ObserveArgs, format: Format) -> Result<(), CliError>
|
||||
max_depth: args.max_depth,
|
||||
max_tokens: args.max_tokens,
|
||||
debug_surfaces: args.debug_surfaces,
|
||||
probe_hover: args.probe_hover,
|
||||
};
|
||||
let reply: ObserveResult = call(sock, params)?;
|
||||
match format {
|
||||
|
||||
@@ -65,7 +65,7 @@ fn render_human(s: &StatusResult) {
|
||||
let bold = "\x1b[1m";
|
||||
let reset = "\x1b[0m";
|
||||
eprintln!(
|
||||
"{bold}{yellow}warning:{reset} {} browser(s) have protocol version drift from the daemon{reset}",
|
||||
"{bold}{yellow}note:{reset} {} browser(s) report a different protocol version; still usable — continue, and run `bsk update` soon{reset}",
|
||||
s.version_skew_browsers.len()
|
||||
);
|
||||
for skew in &s.version_skew_browsers {
|
||||
@@ -75,7 +75,7 @@ fn render_human(s: &StatusResult) {
|
||||
skew.label.clone()
|
||||
};
|
||||
eprintln!(
|
||||
" {} ({}) — protocol ext {} vs daemon {} (app ext v{}, daemon v{}) — please align protocol versions",
|
||||
" {} ({}) — protocol ext {} vs CLI {} (app ext v{}, CLI v{}) — still usable; please upgrade",
|
||||
skew.instance_id,
|
||||
label,
|
||||
display_protocol(&skew.client_protocol_version),
|
||||
|
||||
@@ -478,6 +478,7 @@ async fn observe_returns_semantic_text_and_ref_count() {
|
||||
tab_id: 13,
|
||||
truncated: false,
|
||||
dialogs: vec![],
|
||||
hover_probe: None,
|
||||
debug: None,
|
||||
})
|
||||
.unwrap(),
|
||||
@@ -494,6 +495,7 @@ async fn observe_returns_semantic_text_and_ref_count() {
|
||||
max_depth: None,
|
||||
max_tokens: None,
|
||||
debug_surfaces: false,
|
||||
probe_hover: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "ObserveParams",
|
||||
"description": "Parameters for `tool.observe` — produce a semantic VOM observation for one tab plus a fresh `@e<N>` ref-store on the extension side.\n\nUnlike `tool.snapshot`, this path may run bounded perception probes such as hover-surface discovery. It must not submit input, click, navigate, or otherwise commit page state.",
|
||||
"description": "Parameters for `tool.observe` — produce a semantic VOM observation for one tab plus a fresh `@e<N>` ref-store on the extension side.\n\nUnlike `tool.snapshot`, this path may run bounded perception probes such as hover-surface discovery, but only when `probe_hover` asks for them. It must not submit input, click, navigate, or otherwise commit page state.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"session_id"
|
||||
@@ -30,6 +30,11 @@
|
||||
"format": "uint32",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"probe_hover": {
|
||||
"description": "Opt in to active hover probing: the extension moves the real cursor over a bounded set of controls to discover hover-only menus and tooltips.\n\nOff by default. Probing costs seconds of wall clock, mutates the live page, and pays off only on pages whose content is genuinely hidden behind hover. Request it when a static observation looks like it is missing hover-revealed structure.",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -24,6 +24,17 @@
|
||||
"$ref": "#/definitions/JavaScriptDialogInfo"
|
||||
}
|
||||
},
|
||||
"hover_probe": {
|
||||
"description": "Present only when `probe_hover` was requested and probing actually ran.",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/HoverProbeReport"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ref_count": {
|
||||
"description": "Number of `@e<N>` refs registered for this session by this observation. Equivalent to the size of the new ref-store map.",
|
||||
"type": "integer",
|
||||
@@ -46,6 +57,24 @@
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"HoverProbeReport": {
|
||||
"description": "Reports what active hover probing did during an observation, so the agent can judge how far the live page may have drifted from the returned text.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"performed",
|
||||
"revealed_content"
|
||||
],
|
||||
"properties": {
|
||||
"performed": {
|
||||
"description": "The real cursor was moved over page content during this observation.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"revealed_content": {
|
||||
"description": "Hovering surfaced content the static tree does not contain, so the page reacts to the cursor and may not have fully reverted.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"JavaScriptDialogHandledAction": {
|
||||
"description": "How the extension resolved the dialog so CDP could continue.",
|
||||
"type": "string",
|
||||
|
||||
@@ -58,8 +58,9 @@ pub struct SnapshotResult {
|
||||
/// for one tab plus a fresh `@e<N>` ref-store on the extension side.
|
||||
///
|
||||
/// Unlike `tool.snapshot`, this path may run bounded perception probes
|
||||
/// such as hover-surface discovery. It must not submit input, click,
|
||||
/// navigate, or otherwise commit page state.
|
||||
/// such as hover-surface discovery, but only when `probe_hover` asks for
|
||||
/// them. It must not submit input, click, navigate, or otherwise commit
|
||||
/// page state.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ObserveParams {
|
||||
pub session_id: String,
|
||||
@@ -77,6 +78,16 @@ pub struct ObserveParams {
|
||||
/// Include implementation diagnostics for conditional surface probes.
|
||||
#[serde(default)]
|
||||
pub debug_surfaces: bool,
|
||||
/// Opt in to active hover probing: the extension moves the real cursor
|
||||
/// over a bounded set of controls to discover hover-only menus and
|
||||
/// tooltips.
|
||||
///
|
||||
/// Off by default. Probing costs seconds of wall clock, mutates the live
|
||||
/// page, and pays off only on pages whose content is genuinely hidden
|
||||
/// behind hover. Request it when a static observation looks like it is
|
||||
/// missing hover-revealed structure.
|
||||
#[serde(default)]
|
||||
pub probe_hover: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
@@ -102,6 +113,17 @@ pub struct ObserveDebug {
|
||||
pub surface_probes: Vec<SurfaceProbeDebug>,
|
||||
}
|
||||
|
||||
/// Reports what active hover probing did during an observation, so the agent
|
||||
/// can judge how far the live page may have drifted from the returned text.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct HoverProbeReport {
|
||||
/// The real cursor was moved over page content during this observation.
|
||||
pub performed: bool,
|
||||
/// Hovering surfaced content the static tree does not contain, so the page
|
||||
/// reacts to the cursor and may not have fully reverted.
|
||||
pub revealed_content: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ObserveResult {
|
||||
/// Semantic VOM observation text. Refs are rendered as `@e<N>` so
|
||||
@@ -118,6 +140,9 @@ pub struct ObserveResult {
|
||||
pub truncated: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
/// Present only when `probe_hover` was requested and probing actually ran.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hover_probe: Option<HoverProbeReport>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub debug: Option<ObserveDebug>,
|
||||
}
|
||||
@@ -308,6 +333,7 @@ mod tests {
|
||||
tab_id: 42,
|
||||
truncated: false,
|
||||
dialogs: Vec::new(),
|
||||
hover_probe: None,
|
||||
debug: None,
|
||||
};
|
||||
let v = serde_json::to_value(&r).unwrap();
|
||||
@@ -315,4 +341,30 @@ mod tests {
|
||||
let round: ObserveResult = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_params_default_to_no_hover_probing() {
|
||||
let params: ObserveParams =
|
||||
serde_json::from_value(serde_json::json!({ "session_id": "s1" })).unwrap();
|
||||
assert!(!params.probe_hover);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_result_round_trips_with_hover_probe_report() {
|
||||
let r = ObserveResult {
|
||||
text: "@vom 1\n".into(),
|
||||
ref_count: 0,
|
||||
tab_id: 42,
|
||||
truncated: false,
|
||||
dialogs: Vec::new(),
|
||||
hover_probe: Some(HoverProbeReport {
|
||||
performed: true,
|
||||
revealed_content: true,
|
||||
}),
|
||||
debug: None,
|
||||
};
|
||||
let v = serde_json::to_value(&r).unwrap();
|
||||
let round: ObserveResult = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, r);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ Key modules:
|
||||
|
||||
### bsk daemon (same binary: `bsk daemon`)
|
||||
|
||||
- Listens on loopback WebSocket (default **52800**) for extensions.
|
||||
- Listens on loopback WebSocket (default **52800**, configurable in the extension popup) for extensions.
|
||||
- Validates `Origin: chrome-extension://…` on handshake.
|
||||
- Maintains `browsers` (connected extensions) and `sessions` (Agent Window bindings).
|
||||
- **Per-session queue** serializes tool calls targeting one session.
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
# Browser capability evaluation corpus
|
||||
|
||||
This directory is a deterministic, local browser evaluation environment for BrowserSkill, DSH,
|
||||
and other command-line agents. Cases, fixture pages, direct `bsk` workflows, and assertions are
|
||||
data-driven and discovered automatically. Adding a case does not require editing a central switch.
|
||||
|
||||
[中文说明](README.zh-CN.md)
|
||||
|
||||
## Design goals
|
||||
|
||||
- Agent-neutral: prompts describe observable page goals so the same cases can run through different
|
||||
agents and adapters.
|
||||
- Deterministic: pages run locally and use stable markers and run-scoped events.
|
||||
- Extensible: manifests, fixture modules, and workflows are auto-discovered.
|
||||
- Reproducible: generated cases record a seed and derive every DOM variation from it.
|
||||
- Honest verification: page-observable results, response markers, and adapter evidence are reported
|
||||
separately. Missing adapter evidence is `unverified`, never silently treated as passed.
|
||||
- Privacy-safe: user badcases must be reduced to synthetic DOM and anonymized data before commit.
|
||||
|
||||
The corpus currently has six stable `core` cases and one seeded `matrix` case. The operation
|
||||
inventory contains 28 browser operations; that count is capability coverage, not the number of
|
||||
cases. Cases can grow independently and many cases may cover the same operation under different
|
||||
page conditions.
|
||||
|
||||
## Quick start
|
||||
|
||||
These commands validate manifests and run unit/integration tests without launching a browser:
|
||||
|
||||
```sh
|
||||
pnpm eval:browser validate
|
||||
pnpm eval:browser list
|
||||
pnpm eval:browser coverage
|
||||
pnpm eval:browser:test
|
||||
```
|
||||
|
||||
Build the repository CLI and exercise the real CLI, daemon, extension, Agent Window, and pages:
|
||||
|
||||
```sh
|
||||
cargo build -p bsk
|
||||
BSK_AUTO_UPDATE=off pnpm eval:browser smoke --suite core --bsk ./target/debug/bsk
|
||||
BSK_AUTO_UPDATE=off pnpm eval:browser smoke --suite matrix --seed 4,7,14 --bsk ./target/debug/bsk
|
||||
```
|
||||
|
||||
Every smoke case starts a fresh browser session and stops it in a `finally` cleanup path. Reports
|
||||
and screenshots are written to the ignored `evals/browser/results/` directory.
|
||||
|
||||
`pnpm run eval:browser -- coverage` is also supported. The CLI intentionally accepts pnpm's leading
|
||||
`--` separator.
|
||||
|
||||
## Suites and selection
|
||||
|
||||
| Suite | Intended use | Default execution |
|
||||
| --- | --- | --- |
|
||||
| `core` | Small stable acceptance set for everyday smoke and CI | Yes |
|
||||
| `matrix` | Seeded DOM, timing, and layout variations | Explicit |
|
||||
| `regression` | Minimized user badcases tied to an issue or fix | Explicit |
|
||||
| `stress` | Higher volume or slower boundary testing | Explicit |
|
||||
| `manual` | Human-dependent capabilities that cannot be deterministic | Explicit |
|
||||
|
||||
`smoke` and `run-agent` default to `core`, so adding a stress or regression case cannot silently
|
||||
slow down existing CI. `list`, `coverage`, and `validate` inspect the full corpus by default.
|
||||
|
||||
```sh
|
||||
pnpm eval:browser list --suite core
|
||||
pnpm eval:browser list --tag form
|
||||
pnpm eval:browser smoke --case form-controls
|
||||
pnpm eval:browser smoke --case form-controls,generated-form
|
||||
pnpm eval:browser smoke --case all
|
||||
```
|
||||
|
||||
Filters from different dimensions are combined. Repeated/comma-separated suites or case ids are
|
||||
alternatives; every requested tag must be present. `--task` remains a backward-compatible alias for
|
||||
`--case`.
|
||||
|
||||
## Directory and discovery model
|
||||
|
||||
```text
|
||||
evals/browser/
|
||||
cases/
|
||||
core/*.case.json
|
||||
matrix/*.case.json
|
||||
regression/<case-id>/
|
||||
<case-id>.case.json
|
||||
<case-id>.fixture.mjs
|
||||
README.md
|
||||
fixtures/pages/*.fixture.mjs
|
||||
schemas/case.schema.json
|
||||
lib/
|
||||
tests/
|
||||
```
|
||||
|
||||
- Any `cases/**/*.case.json` file is loaded as a case manifest.
|
||||
- Any `fixtures/**/*.fixture.mjs` or `cases/**/*.fixture.mjs` file is registered as a fixture.
|
||||
- Manifests are ordered by suite, optional numeric `order`, then id.
|
||||
- `validate` rejects duplicate case ids, duplicate routes, missing fixture routes, unknown
|
||||
operations/actions, invalid workflow variables, and adapter assertions with no evidence producer.
|
||||
|
||||
## Case manifest
|
||||
|
||||
A case owns all information needed by both an agent evaluation and direct CLI smoke:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "../../schemas/case.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "example-case",
|
||||
"title": "Example browser behavior",
|
||||
"order": 10,
|
||||
"suite": "core",
|
||||
"tags": ["form", "keyboard"],
|
||||
"seed": 17,
|
||||
"fixture": { "startPath": "/example" },
|
||||
"prompts": { "en": "Open {url} ...", "zh-CN": "打开 {url}……" },
|
||||
"coverage": ["page.navigate", "interact.press"],
|
||||
"assertions": { "site": [], "response": [], "adapter": [] },
|
||||
"smoke": { "steps": [{ "action": "navigate" }] }
|
||||
}
|
||||
```
|
||||
|
||||
Prompt placeholders are `{url}`, `{baseUrl}`, `{runId}`, and `{seed}`. Agent adapter arguments also
|
||||
support `{prompt}`, `{caseId}` / `{taskId}`, and `{variant}`.
|
||||
|
||||
Assertions have three independent sources:
|
||||
|
||||
- `site`: events emitted by the local page, such as submitted values or a history navigation.
|
||||
- `response`: stable text that must appear in the agent or CLI output.
|
||||
- `adapter`: facts outside page JavaScript, such as a non-empty screenshot or confirmed session
|
||||
cleanup.
|
||||
|
||||
The declarative smoke workflow supports navigation/history, waits, observe/snapshot/HTML,
|
||||
screenshot/console/network, click/hover/fill/select/press, tabs, resize/emulation, and the manual
|
||||
ownership/help operations. A step may `saveAs` its JSON result and later reference values such as
|
||||
`{child.tab_id}`. A step may set `evidence` for an adapter assertion. See existing manifests for
|
||||
complete examples.
|
||||
|
||||
## Adding a normal case
|
||||
|
||||
1. Add a `*.fixture.mjs` module exporting `{ id, routes, render(context) }`. It can live in shared
|
||||
`fixtures/pages/` or beside a self-contained regression case.
|
||||
2. Add a `*.case.json` manifest with prompts, operation coverage, assertions, and smoke steps.
|
||||
3. Make all page events run-scoped using the shared `browserEval.send(...)` client helper.
|
||||
4. Run `pnpm eval:browser validate` and `pnpm eval:browser:test`.
|
||||
5. Run the new case against the real local build with `smoke --case <id>`.
|
||||
|
||||
Prefer one behavior or failure mechanism per case. Stable synthetic markers make failures easier to
|
||||
diagnose than assertions against incidental prose.
|
||||
|
||||
## Turning a user badcase into a regression
|
||||
|
||||
Create a self-contained starting point:
|
||||
|
||||
```sh
|
||||
pnpm eval:browser scaffold reported-timeout \
|
||||
--title "Navigation settles after a late frame" \
|
||||
--source "issue-123"
|
||||
```
|
||||
|
||||
The command creates a manifest, fixture, and reproduction notes under
|
||||
`cases/regression/reported-timeout/`. Then reduce the report to the smallest relevant mechanism:
|
||||
|
||||
- Keep DOM structure, timing, redirects, frames, or browser state that triggers the bug.
|
||||
- Replace names, account data, URLs, tokens, screenshots, and page copy with synthetic values.
|
||||
- Do not commit HAR files, cookies, credentials, production HTML, or proprietary assets.
|
||||
- Record the issue/PR reference, seed when relevant, expected result, and the fixing PR.
|
||||
- Confirm the new case fails on the known-bad build and passes on the fixed build when practical.
|
||||
|
||||
## Seeded matrix cases
|
||||
|
||||
`generated-form` varies label association, nesting depth, hydration delay, decoy controls, field
|
||||
order, and element ids while keeping the task and oracle stable. Inspect a seed without a browser:
|
||||
|
||||
```sh
|
||||
pnpm eval:browser generate --seed user-badcase-42
|
||||
pnpm eval:browser prompt generated-form --seed user-badcase-42 --run-id manual-42
|
||||
pnpm eval:browser smoke --case generated-form --seed user-badcase-42
|
||||
pnpm eval:browser smoke --case generated-form --seed 4,7,14
|
||||
```
|
||||
|
||||
Repeated/comma-separated seeds run as independent results. Reports store every normalized seed, so
|
||||
a failure can be replayed exactly and promoted into a named regression case if it represents a
|
||||
distinct bug.
|
||||
|
||||
## Run any command-line agent
|
||||
|
||||
Copy `agents.example.json` to the ignored `agents.local.json` and adapt its command. Arguments are
|
||||
passed directly to the executable without a shell.
|
||||
|
||||
```sh
|
||||
pnpm eval:browser run-agent \
|
||||
--config evals/browser/agents.local.json \
|
||||
--agent dsh-local \
|
||||
--suite core \
|
||||
--repeat 3
|
||||
```
|
||||
|
||||
An adapter may define `command`, `args`, `cwd`, `env`, `timeoutMs`, `variant`, free-form `metadata`,
|
||||
and optional `metrics.errorPattern` / `metrics.toolCallPattern` regular expressions.
|
||||
|
||||
To compare 28 granular tools with domain tools, create two isolated adapter entries and run both
|
||||
against the same selected cases and seeds:
|
||||
|
||||
```sh
|
||||
pnpm eval:browser run-agent \
|
||||
--config evals/browser/agents.local.json \
|
||||
--agent dsh-granular,dsh-domain \
|
||||
--suite core \
|
||||
--repeat 10
|
||||
```
|
||||
|
||||
Keep browser runs sequential to avoid session contention. Use fresh profiles and alternate adapter
|
||||
order in larger experiments to reduce warm-cache bias.
|
||||
|
||||
## Reading results
|
||||
|
||||
```sh
|
||||
pnpm eval:browser summarize evals/browser/results/*.json
|
||||
```
|
||||
|
||||
The summary groups by adapter and variant and reports pass rate, fully verified rate, process
|
||||
failures, matched error count, average tool calls, and average duration. The raw JSON retains case,
|
||||
suite, tags, seed, prompt, command output, page event count, and individual oracle checks.
|
||||
|
||||
A healthy run has:
|
||||
|
||||
- no execution timeout or non-zero exit;
|
||||
- no `executionError` in direct smoke;
|
||||
- `verification.status` equal to `passed` (or intentionally `passed-with-unverified` for an agent
|
||||
adapter that cannot expose external evidence);
|
||||
- every session cleanup assertion verified;
|
||||
- no unexpected increase in errors, tool calls, or duration versus the chosen baseline.
|
||||
|
||||
## Development baseline
|
||||
|
||||
Use these lanes as commit and PR gates:
|
||||
|
||||
| Change | Required checks |
|
||||
| --- | --- |
|
||||
| Any case, fixture, oracle, or harness change | `pnpm eval:browser:check` |
|
||||
| CLI, daemon, extension, DSH plugin, or browser-operation change | The check above, `cargo build -p bsk`, then real-browser `smoke --suite core` |
|
||||
| DOM discovery, form interaction, timing, or waiting change | The checks above, then `smoke --suite matrix --seed 4,7,14` |
|
||||
| A bug fix with a known reproduction | Add a `regression` case and run that case on the fixed build |
|
||||
|
||||
Before committing this harness, the complete acceptance sequence is:
|
||||
|
||||
```sh
|
||||
pnpm eval:browser:check
|
||||
cargo build -p bsk
|
||||
BSK_AUTO_UPDATE=off pnpm eval:browser smoke --case all --bsk ./target/debug/bsk
|
||||
BSK_AUTO_UPDATE=off pnpm eval:browser smoke --suite matrix --seed 4,7,14 --bsk ./target/debug/bsk
|
||||
```
|
||||
|
||||
All commands must exit zero. The smoke summary must have 100% pass and fully verified rates, zero
|
||||
execution failures/errors, and `bsk session list --json` must be `[]` afterward. Do not weaken an
|
||||
oracle or replace a stable marker merely to make a regression pass; behavior changes should update
|
||||
the case and expected baseline in the same reviewed change.
|
||||
|
||||
The direct smoke lane covers 25 of 28 operations. `tabs.borrow`, `tabs.return`, and
|
||||
`assist.request-help` stay manual because they require a real user tab or human interaction and
|
||||
would make the default suite destructive or non-deterministic.
|
||||
@@ -0,0 +1,197 @@
|
||||
# 浏览器能力测试语料库
|
||||
|
||||
这里是一套供 BrowserSkill、DSH 以及其他命令行 Agent 共用的本地确定性浏览器测试环境。
|
||||
case、测试页面、`bsk` 直连流程和断言都会自动发现;新增 case 不需要修改中央 `switch`。
|
||||
|
||||
[English](README.md)
|
||||
|
||||
## 核心原则
|
||||
|
||||
- 与 Agent 无关:prompt 只描述可观察的页面目标,同一套 case 可以被不同 Agent 和
|
||||
adapter 使用。
|
||||
- 本地且确定:页面只依赖本地服务,使用稳定 marker 和按 run 隔离的事件。
|
||||
- 可扩展:manifest、fixture 和 workflow 都从目录自动发现。
|
||||
- 可复现:生成式页面的所有变化由 seed 决定,报告会保留 seed。
|
||||
- 如实验证:页面事件、回答文本和 adapter 外部证据分别统计;拿不到的证据标为
|
||||
`unverified`,不会冒充通过。
|
||||
- 隐私安全:用户 badcase 必须先缩减为合成 DOM 和匿名数据,再进入仓库。
|
||||
|
||||
当前有 6 个稳定的 `core` case 和 1 个 seed 驱动的 `matrix` case。28 表示浏览器操作能力
|
||||
清单,并不是 case 数量;同一种操作可以在不同页面结构、时序和浏览器状态下由多个 case
|
||||
重复覆盖。
|
||||
|
||||
## 快速使用
|
||||
|
||||
不启动浏览器的校验:
|
||||
|
||||
```sh
|
||||
pnpm eval:browser validate
|
||||
pnpm eval:browser list
|
||||
pnpm eval:browser coverage
|
||||
pnpm eval:browser:test
|
||||
```
|
||||
|
||||
使用仓库当前代码编译 `bsk`,再跑真实 CLI、daemon、扩展、小窗和页面链路:
|
||||
|
||||
```sh
|
||||
cargo build -p bsk
|
||||
BSK_AUTO_UPDATE=off pnpm eval:browser smoke --suite core --bsk ./target/debug/bsk
|
||||
BSK_AUTO_UPDATE=off pnpm eval:browser smoke --suite matrix --seed 4,7,14 --bsk ./target/debug/bsk
|
||||
```
|
||||
|
||||
每个 smoke case 都会创建独立 session,并在 `finally` 中关闭。JSON 报告和截图输出到已被
|
||||
Git 忽略的 `evals/browser/results/`。
|
||||
|
||||
也可以写成 `pnpm run eval:browser -- coverage`;CLI 已兼容 pnpm 传入的前导 `--`。
|
||||
|
||||
## Suite 与筛选
|
||||
|
||||
| Suite | 用途 | 默认执行 |
|
||||
| --- | --- | --- |
|
||||
| `core` | 日常 smoke 和 CI 使用的小而稳定的验收集 | 是 |
|
||||
| `matrix` | seed 驱动的 DOM、时序、布局组合 | 显式选择 |
|
||||
| `regression` | 从用户 badcase 缩减并与 issue/修复关联的回归 | 显式选择 |
|
||||
| `stress` | 更高次数或更慢的边界测试 | 显式选择 |
|
||||
| `manual` | 必须依赖真人或真实用户标签页的能力 | 显式选择 |
|
||||
|
||||
`smoke` 和 `run-agent` 不带筛选时只跑 `core`,所以以后新增 stress/badcase 不会突然拖慢
|
||||
已有 CI。`list`、`coverage`、`validate` 默认检查全部语料。
|
||||
|
||||
```sh
|
||||
pnpm eval:browser list --suite core
|
||||
pnpm eval:browser list --tag form
|
||||
pnpm eval:browser smoke --case form-controls
|
||||
pnpm eval:browser smoke --case form-controls,generated-form
|
||||
pnpm eval:browser smoke --case all
|
||||
```
|
||||
|
||||
不同维度的筛选会叠加;逗号分隔的 suite/case 是“或”,多个 tag 必须全部命中。旧参数
|
||||
`--task` 仍作为 `--case` 的兼容别名。
|
||||
|
||||
## 自动发现结构
|
||||
|
||||
```text
|
||||
evals/browser/
|
||||
cases/
|
||||
core/*.case.json
|
||||
matrix/*.case.json
|
||||
regression/<case-id>/
|
||||
<case-id>.case.json
|
||||
<case-id>.fixture.mjs
|
||||
README.md
|
||||
fixtures/pages/*.fixture.mjs
|
||||
schemas/case.schema.json
|
||||
lib/
|
||||
tests/
|
||||
```
|
||||
|
||||
- `cases/**/*.case.json` 都会作为 case manifest 加载。
|
||||
- `fixtures/**/*.fixture.mjs` 和 `cases/**/*.fixture.mjs` 都会注册为页面 fixture。
|
||||
- 顺序按 suite、可选的数字 `order`、id 排列。
|
||||
- `validate` 会阻止重复 case id、重复路由、缺失 fixture、未知操作/action、无效 workflow
|
||||
变量,以及没有任何步骤产出的 adapter evidence。
|
||||
|
||||
一个 manifest 同时描述:中英文 prompt、能力 coverage、页面事件/回答/adapter 三类断言,以及
|
||||
`bsk` 直连 smoke 的声明式步骤。prompt 可用 `{url}`、`{baseUrl}`、`{runId}`、`{seed}`;
|
||||
workflow 可以用 `saveAs` 保存 JSON 结果,并用 `{child.tab_id}` 之类的变量引用后续步骤。
|
||||
完整写法可直接参考 `cases/core/` 和 `cases/matrix/`。
|
||||
|
||||
## 新增普通 case
|
||||
|
||||
1. 新增导出 `{ id, routes, render(context) }` 的 `*.fixture.mjs`,可放共享 fixture 目录,也可
|
||||
和 regression case 放在一起。
|
||||
2. 新增 `*.case.json`,写好 prompt、operation coverage、断言和 smoke steps。
|
||||
3. 页面使用共享的 `browserEval.send(...)` 上报按 run 隔离的事件。
|
||||
4. 执行 `pnpm eval:browser validate` 和 `pnpm eval:browser:test`。
|
||||
5. 用 `smoke --case <id>` 验证真实本地编译链路。
|
||||
|
||||
建议一个 case 只表达一种行为或故障机制,并使用稳定的合成 marker,避免断言偶然文案。
|
||||
|
||||
## 把用户 badcase 固化成回归
|
||||
|
||||
先生成一个自包含模板:
|
||||
|
||||
```sh
|
||||
pnpm eval:browser scaffold reported-timeout \
|
||||
--title "Navigation settles after a late frame" \
|
||||
--source "issue-123"
|
||||
```
|
||||
|
||||
它会在 `cases/regression/reported-timeout/` 下生成 manifest、fixture 和复现说明。随后只保留
|
||||
真正触发问题的 DOM 结构、时序、跳转、frame 或浏览器状态,并遵守:
|
||||
|
||||
- 所有姓名、账号、网址、token、截图和文案都替换为合成值。
|
||||
- 不提交 HAR、cookie、凭据、生产 HTML 或私有素材。
|
||||
- 记录 issue/PR 来源、相关 seed、期望行为和后续修复 PR。
|
||||
- 条件允许时,证明新 case 在旧版本失败、修复版本通过。
|
||||
|
||||
## Seed 矩阵
|
||||
|
||||
`generated-form` 会确定性改变 label 关联方式、DOM 嵌套、hydration 延迟、干扰控件、字段
|
||||
顺序和 element id,但任务和 oracle 保持不变:
|
||||
|
||||
```sh
|
||||
pnpm eval:browser generate --seed user-badcase-42
|
||||
pnpm eval:browser prompt generated-form --seed user-badcase-42 --run-id manual-42
|
||||
pnpm eval:browser smoke --case generated-form --seed user-badcase-42
|
||||
pnpm eval:browser smoke --case generated-form --seed 4,7,14
|
||||
```
|
||||
|
||||
逗号分隔或重复的 seed 会生成相互独立的结果。任何失败都能按报告中的规范化 seed 精确重放;
|
||||
若它代表独立 bug,再提升为命名 regression case。
|
||||
|
||||
## 测任意 Agent 与比较 28/6 工具
|
||||
|
||||
把 `agents.example.json` 复制为已被 Git 忽略的 `agents.local.json`,分别配置两个隔离的 Agent
|
||||
profile/build。参数以数组直接传给可执行程序,不经过 shell。
|
||||
|
||||
```sh
|
||||
pnpm eval:browser run-agent \
|
||||
--config evals/browser/agents.local.json \
|
||||
--agent dsh-granular,dsh-domain \
|
||||
--suite core \
|
||||
--repeat 10
|
||||
```
|
||||
|
||||
浏览器任务应串行运行,避免 session 竞争;更大实验中使用干净 profile 并轮换 adapter 顺序,
|
||||
降低缓存偏差。
|
||||
|
||||
## 如何看结果
|
||||
|
||||
```sh
|
||||
pnpm eval:browser summarize evals/browser/results/*.json
|
||||
```
|
||||
|
||||
汇总按 adapter/variant 展示通过率、完全验证率、进程失败、错误数、平均工具调用数和耗时。
|
||||
原始 JSON 会保留 case、suite、tag、seed、prompt、命令输出、页面事件数和每条 oracle 结果。
|
||||
|
||||
健康运行应满足:没有 timeout/非零退出,direct smoke 没有 `executionError`,验证状态为
|
||||
`passed`(无法暴露外部证据的 Agent adapter 可明确为 `passed-with-unverified`),session cleanup
|
||||
通过,并且错误数、调用数和耗时没有相对基线异常上涨。
|
||||
|
||||
## 开发基准线
|
||||
|
||||
后续开发统一按下面的门禁执行:
|
||||
|
||||
| 改动范围 | 必跑检查 |
|
||||
| --- | --- |
|
||||
| 任意 case、fixture、oracle 或 harness 改动 | `pnpm eval:browser:check` |
|
||||
| CLI、daemon、扩展、DSH 插件或浏览器操作改动 | 上述检查、`cargo build -p bsk`、真实浏览器 `smoke --suite core` |
|
||||
| DOM 发现、表单交互、时序或等待逻辑改动 | 上述检查,再跑 `smoke --suite matrix --seed 4,7,14` |
|
||||
| 修复已有明确复现的 bug | 新增 `regression` case,并在修复版本单独运行该 case |
|
||||
|
||||
提交这套 harness 前,完整验收命令为:
|
||||
|
||||
```sh
|
||||
pnpm eval:browser:check
|
||||
cargo build -p bsk
|
||||
BSK_AUTO_UPDATE=off pnpm eval:browser smoke --case all --bsk ./target/debug/bsk
|
||||
BSK_AUTO_UPDATE=off pnpm eval:browser smoke --suite matrix --seed 4,7,14 --bsk ./target/debug/bsk
|
||||
```
|
||||
|
||||
所有命令必须以 0 退出;smoke 汇总必须达到 100% pass、100% fully verified、0 execution
|
||||
failure/error,结束后 `bsk session list --json` 必须为 `[]`。不能为了让回归通过而削弱 oracle
|
||||
或替换稳定 marker;如果产品行为确实改变,case 与预期基线必须在同一个受审查改动中更新。
|
||||
|
||||
直连 smoke 自动覆盖 28 项中的 25 项。`tabs.borrow`、`tabs.return`、`assist.request-help` 需要
|
||||
真实用户标签页或真人交互,因此保留在 manual lane,避免默认测试具有破坏性或不确定性。
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"agents": {
|
||||
"dsh-local": {
|
||||
"variant": "granular-28",
|
||||
"command": "dsh",
|
||||
"args": ["--profile", "headless", "{prompt}"],
|
||||
"cwd": "../..",
|
||||
"timeoutMs": 300000,
|
||||
"metadata": {
|
||||
"toolCount": 28
|
||||
},
|
||||
"metrics": {
|
||||
"toolCallPattern": "browser[_-][a-z_-]+"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# Browser evaluation cases
|
||||
|
||||
Cases are discovered recursively from `*.case.json`; colocated `*.fixture.mjs` files are discovered
|
||||
as fixtures. Use `core` for small deterministic acceptance checks, `matrix` for seeded variations,
|
||||
and `regression/<case-id>/` for privacy-safe minimized user badcases.
|
||||
|
||||
Run `pnpm eval:browser validate` after every case change. See the parent
|
||||
[`README.md`](../README.md) for the manifest contract, workflow actions, scaffolding, and acceptance
|
||||
criteria.
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "../../schemas/case.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "diagnostics",
|
||||
"title": "Console and network inspection",
|
||||
"order": 50,
|
||||
"suite": "core",
|
||||
"tags": ["console", "network", "diagnostics"],
|
||||
"fixture": { "startPath": "/diagnostics" },
|
||||
"prompts": {
|
||||
"en": "Open {url}. Use browser diagnostics to inspect the page console and recent network activity. Report the console marker and the token returned by the ping request, then close the browser session.",
|
||||
"zh-CN": "打开 {url}。使用浏览器诊断能力检查页面 console 和最近的 network 活动,报告 console marker 以及 ping 请求返回的 token,最后关闭浏览器会话。"
|
||||
},
|
||||
"coverage": [
|
||||
"session.start",
|
||||
"session.stop",
|
||||
"page.navigate",
|
||||
"inspect.console",
|
||||
"inspect.network"
|
||||
],
|
||||
"assertions": {
|
||||
"site": [
|
||||
{
|
||||
"label": "diagnostic ping completed",
|
||||
"type": "network.ping",
|
||||
"minCount": 1
|
||||
}
|
||||
],
|
||||
"response": [
|
||||
{ "label": "reported console marker", "includes": "CONSOLE-61" },
|
||||
{ "label": "reported network token", "includes": "NETWORK-73" }
|
||||
],
|
||||
"adapter": [
|
||||
{ "label": "console inspection was executed", "key": "consoleRead" },
|
||||
{ "label": "network inspection was executed", "key": "networkRead" },
|
||||
{ "label": "browser session was closed", "key": "sessionStopped" }
|
||||
]
|
||||
},
|
||||
"smoke": {
|
||||
"steps": [
|
||||
{ "action": "navigate" },
|
||||
{ "action": "wait", "duration": "300ms" },
|
||||
{ "action": "console", "evidence": "consoleRead" },
|
||||
{ "action": "network", "evidence": "networkRead" },
|
||||
{ "action": "observe" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"$schema": "../../schemas/case.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "form-controls",
|
||||
"title": "Form, select, keyboard, and screenshot",
|
||||
"order": 20,
|
||||
"suite": "core",
|
||||
"tags": ["form", "keyboard", "screenshot", "select"],
|
||||
"fixture": { "startPath": "/form" },
|
||||
"prompts": {
|
||||
"en": "Open {url}. Fill Text input with \"agent-parity\" and Textarea with \"BrowserSkill works\". Select the visible option \"Two\" from Dropdown. Capture a screenshot of the form, focus Submit, and press Enter to submit. Confirm that the result says \"Received!\", then close the browser session.",
|
||||
"zh-CN": "打开 {url}。把 Text input 填成“agent-parity”,Textarea 填成“BrowserSkill works”,Dropdown 选择可见选项“Two”。截取表单截图,然后聚焦 Submit 并按 Enter 提交。确认结果页出现“Received!”,最后关闭浏览器会话。"
|
||||
},
|
||||
"coverage": [
|
||||
"session.start",
|
||||
"session.stop",
|
||||
"page.navigate",
|
||||
"interact.fill",
|
||||
"interact.select",
|
||||
"interact.press",
|
||||
"inspect.screenshot"
|
||||
],
|
||||
"assertions": {
|
||||
"site": [
|
||||
{
|
||||
"label": "Enter was pressed on Submit",
|
||||
"type": "form.enter_pressed",
|
||||
"minCount": 1
|
||||
},
|
||||
{
|
||||
"label": "form values were submitted",
|
||||
"type": "form.submitted",
|
||||
"where": {
|
||||
"data.text": "agent-parity",
|
||||
"data.notes": "BrowserSkill works",
|
||||
"data.choice": "two"
|
||||
},
|
||||
"minCount": 1
|
||||
},
|
||||
{
|
||||
"label": "result page was displayed",
|
||||
"type": "page.shown",
|
||||
"where": { "data.path": "/result" },
|
||||
"minCount": 1
|
||||
}
|
||||
],
|
||||
"response": [{ "label": "confirmed result", "includes": "Received!" }],
|
||||
"adapter": [
|
||||
{ "label": "screenshot artifact was created", "key": "screenshotCreated" },
|
||||
{ "label": "browser session was closed", "key": "sessionStopped" }
|
||||
]
|
||||
},
|
||||
"smoke": {
|
||||
"steps": [
|
||||
{ "action": "navigate" },
|
||||
{
|
||||
"action": "fill",
|
||||
"selector": "#text-input",
|
||||
"value": "agent-parity"
|
||||
},
|
||||
{
|
||||
"action": "fill",
|
||||
"selector": "#notes",
|
||||
"value": "BrowserSkill works"
|
||||
},
|
||||
{ "action": "select", "selector": "#choice", "values": ["two"] },
|
||||
{
|
||||
"action": "screenshot",
|
||||
"artifactName": "form.png",
|
||||
"evidence": "screenshotCreated"
|
||||
},
|
||||
{ "action": "press", "key": "Enter", "selector": "#submit" },
|
||||
{
|
||||
"action": "wait-site-event",
|
||||
"type": "page.shown",
|
||||
"where": { "path": "/result" }
|
||||
},
|
||||
{ "action": "wait", "duration": "200ms" },
|
||||
{ "action": "observe" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"$schema": "../../schemas/case.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "hover-inspect",
|
||||
"title": "Hover, local HTML, and screenshot",
|
||||
"order": 30,
|
||||
"suite": "core",
|
||||
"tags": ["hover", "html", "screenshot"],
|
||||
"fixture": { "startPath": "/hover-inspect" },
|
||||
"prompts": {
|
||||
"en": "Open {url}. Hover over Account, then choose Profile beta from the revealed menu. Inspect the local HTML around the Inspection card and capture a screenshot. Report both marker codes you find, then close the browser session.",
|
||||
"zh-CN": "打开 {url}。把鼠标悬停在“Account”上,然后点击展开菜单中的“Profile beta”。检查 Inspection card 附近的局部 HTML,并截取页面截图。报告找到的两个标记代码,最后关闭浏览器会话。"
|
||||
},
|
||||
"coverage": [
|
||||
"session.start",
|
||||
"session.stop",
|
||||
"page.navigate",
|
||||
"interact.hover",
|
||||
"interact.click",
|
||||
"inspect.html",
|
||||
"inspect.screenshot"
|
||||
],
|
||||
"assertions": {
|
||||
"site": [
|
||||
{
|
||||
"label": "Account menu was hovered",
|
||||
"type": "hover.menu_opened",
|
||||
"minCount": 1
|
||||
},
|
||||
{
|
||||
"label": "Profile beta was selected",
|
||||
"type": "hover.profile_clicked",
|
||||
"minCount": 1
|
||||
}
|
||||
],
|
||||
"response": [
|
||||
{ "label": "reported hover marker", "includes": "HOVER-17" },
|
||||
{ "label": "reported HTML marker", "includes": "INSPECT-27" }
|
||||
],
|
||||
"adapter": [
|
||||
{ "label": "HTML inspection was executed", "key": "htmlInspected" },
|
||||
{ "label": "screenshot artifact was created", "key": "screenshotCreated" },
|
||||
{ "label": "browser session was closed", "key": "sessionStopped" }
|
||||
]
|
||||
},
|
||||
"smoke": {
|
||||
"steps": [
|
||||
{ "action": "navigate" },
|
||||
{ "action": "hover", "selector": "#account-menu" },
|
||||
{ "action": "click", "selector": "#profile-link" },
|
||||
{ "action": "html", "evidence": "htmlInspected" },
|
||||
{
|
||||
"action": "screenshot",
|
||||
"artifactName": "hover-inspect.png",
|
||||
"evidence": "screenshotCreated"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$schema": "../../schemas/case.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "mobile-emulation",
|
||||
"title": "Mobile emulation",
|
||||
"order": 60,
|
||||
"suite": "core",
|
||||
"tags": ["emulation", "mobile", "viewport"],
|
||||
"fixture": { "startPath": "/responsive" },
|
||||
"prompts": {
|
||||
"en": "Open {url}, emulate an iPhone 14, and report the viewport marker shown on the page. Close the browser session when finished.",
|
||||
"zh-CN": "打开 {url},模拟 iPhone 14,并报告页面显示的 viewport marker。完成后关闭浏览器会话。"
|
||||
},
|
||||
"coverage": ["session.start", "session.stop", "page.navigate", "assist.emulate"],
|
||||
"assertions": {
|
||||
"site": [
|
||||
{
|
||||
"label": "iPhone-sized viewport was observed",
|
||||
"type": "viewport.sample",
|
||||
"where": { "data.width": 390, "data.height": 844 },
|
||||
"minCount": 1
|
||||
}
|
||||
],
|
||||
"response": [{ "label": "reported viewport marker", "includes": "VIEWPORT-84" }],
|
||||
"adapter": [
|
||||
{ "label": "device emulation was executed", "key": "emulatedDevice" },
|
||||
{ "label": "browser session was closed", "key": "sessionStopped" }
|
||||
]
|
||||
},
|
||||
"smoke": {
|
||||
"steps": [
|
||||
{ "action": "navigate" },
|
||||
{ "action": "resize", "width": 900, "height": 700 },
|
||||
{
|
||||
"action": "emulate",
|
||||
"device": "iphone-14",
|
||||
"evidence": "emulatedDevice"
|
||||
},
|
||||
{ "action": "wait", "duration": "300ms" },
|
||||
{ "action": "html" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"$schema": "../../schemas/case.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "navigation-history",
|
||||
"title": "Navigation, history, and reload",
|
||||
"order": 10,
|
||||
"suite": "core",
|
||||
"tags": ["navigation", "history", "reload"],
|
||||
"fixture": { "startPath": "/navigation/start" },
|
||||
"prompts": {
|
||||
"en": "Open {url}. Read the main heading, follow the Details link, and report the code shown there. Then go back, go forward, reload the page, and close the browser session. Use only the browser capability for page operations.",
|
||||
"zh-CN": "打开 {url}。读取页面主标题,点击“详情”,并报告详情页上的代码。然后依次后退、前进、刷新页面,最后关闭浏览器会话。页面操作只使用浏览器能力。"
|
||||
},
|
||||
"coverage": [
|
||||
"session.start",
|
||||
"session.stop",
|
||||
"page.navigate",
|
||||
"page.back",
|
||||
"page.forward",
|
||||
"page.reload",
|
||||
"interact.click"
|
||||
],
|
||||
"assertions": {
|
||||
"site": [
|
||||
{
|
||||
"label": "details link was clicked",
|
||||
"type": "navigation.detail.clicked",
|
||||
"minCount": 1
|
||||
},
|
||||
{
|
||||
"label": "back returned to the start page",
|
||||
"type": "page.shown",
|
||||
"where": { "data.path": "/navigation/start" },
|
||||
"minCount": 2
|
||||
},
|
||||
{
|
||||
"label": "forward returned to the detail page",
|
||||
"type": "page.shown",
|
||||
"where": { "data.path": "/navigation/detail" },
|
||||
"minCount": 2
|
||||
},
|
||||
{
|
||||
"label": "detail page was reloaded",
|
||||
"type": "page.shown",
|
||||
"where": {
|
||||
"data.path": "/navigation/detail",
|
||||
"data.navigationType": "reload"
|
||||
},
|
||||
"minCount": 1
|
||||
}
|
||||
],
|
||||
"response": [{ "label": "reported detail code", "includes": "NAV-42" }],
|
||||
"adapter": [{ "label": "browser session was closed", "key": "sessionStopped" }]
|
||||
},
|
||||
"smoke": {
|
||||
"steps": [
|
||||
{ "action": "navigate" },
|
||||
{ "action": "observe" },
|
||||
{ "action": "snapshot" },
|
||||
{ "action": "click", "selector": "#details-link" },
|
||||
{ "action": "html" },
|
||||
{ "action": "back" },
|
||||
{ "action": "forward" },
|
||||
{ "action": "reload" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"$schema": "../../schemas/case.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "tabs",
|
||||
"title": "Create, select, and close a tab",
|
||||
"order": 40,
|
||||
"suite": "core",
|
||||
"tags": ["tabs", "ownership", "lifecycle"],
|
||||
"fixture": { "startPath": "/tabs" },
|
||||
"prompts": {
|
||||
"en": "Open {url}. Create a new browser tab at {baseUrl}/tabs/child?run={runId}, read its token, click \"Mark child visited\", close the child tab, return to the original tab, and close the browser session.",
|
||||
"zh-CN": "打开 {url}。新建一个浏览器标签页并访问 {baseUrl}/tabs/child?run={runId},读取其中的 token,点击“Mark child visited”,关闭子标签页,回到原标签页,最后关闭浏览器会话。"
|
||||
},
|
||||
"coverage": [
|
||||
"session.start",
|
||||
"session.stop",
|
||||
"page.navigate",
|
||||
"interact.click",
|
||||
"tabs.create",
|
||||
"tabs.select",
|
||||
"tabs.close"
|
||||
],
|
||||
"assertions": {
|
||||
"site": [
|
||||
{
|
||||
"label": "child tab page was displayed",
|
||||
"type": "page.shown",
|
||||
"where": { "data.path": "/tabs/child" },
|
||||
"minCount": 1
|
||||
},
|
||||
{
|
||||
"label": "child tab action ran",
|
||||
"type": "tabs.child_marked",
|
||||
"minCount": 1
|
||||
}
|
||||
],
|
||||
"response": [{ "label": "reported child token", "includes": "TAB-29" }],
|
||||
"adapter": [
|
||||
{ "label": "child tab was closed", "key": "childTabClosed" },
|
||||
{ "label": "browser session was closed", "key": "sessionStopped" }
|
||||
]
|
||||
},
|
||||
"smoke": {
|
||||
"steps": [
|
||||
{ "action": "navigate", "saveAs": "original" },
|
||||
{ "action": "tab-list" },
|
||||
{
|
||||
"action": "tab-create",
|
||||
"url": "{baseUrl}/tabs/child?run={runId}",
|
||||
"saveAs": "child"
|
||||
},
|
||||
{
|
||||
"action": "navigate",
|
||||
"url": "{baseUrl}/tabs/child?run={runId}",
|
||||
"tabId": "{child.tab_id}"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "#mark-child",
|
||||
"tabId": "{child.tab_id}"
|
||||
},
|
||||
{ "action": "html", "tabId": "{child.tab_id}" },
|
||||
{
|
||||
"action": "tab-close",
|
||||
"tabId": "{child.tab_id}",
|
||||
"evidence": "childTabClosed"
|
||||
},
|
||||
{ "action": "tab-select", "tabId": "{original.tab_id}" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Seeded matrix cases
|
||||
|
||||
Matrix cases vary page structure or timing deterministically from a recorded seed. The task,
|
||||
assertions, and success marker remain stable so a failed seed can be replayed exactly. A seed that
|
||||
exposes a distinct product bug should be minimized and promoted to a named regression case.
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"$schema": "../../schemas/case.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "generated-form",
|
||||
"title": "Seeded form structure and hydration variants",
|
||||
"order": 10,
|
||||
"suite": "matrix",
|
||||
"tags": ["dynamic-dom", "form", "hydration", "seeded"],
|
||||
"seed": 20260827,
|
||||
"fixture": { "startPath": "/matrix/generated-form" },
|
||||
"prompts": {
|
||||
"en": "Open {url}. Wait for the seeded form to finish hydrating. Fill Text input with \"seeded-agent\", select the visible option \"Two\", submit with Enter, and report the result marker and seed. Close the browser session.",
|
||||
"zh-CN": "打开 {url}。等待 seeded form 完成 hydration,把 Text input 填成“seeded-agent”,选择可见选项“Two”,用 Enter 提交,并报告结果 marker 和 seed。最后关闭浏览器会话。"
|
||||
},
|
||||
"coverage": [
|
||||
"session.start",
|
||||
"session.stop",
|
||||
"page.navigate",
|
||||
"page.wait",
|
||||
"inspect.observe",
|
||||
"interact.fill",
|
||||
"interact.select",
|
||||
"interact.press"
|
||||
],
|
||||
"assertions": {
|
||||
"site": [
|
||||
{ "label": "generated form hydrated", "type": "matrix.ready", "minCount": 1 },
|
||||
{
|
||||
"label": "Enter was pressed on generated form",
|
||||
"type": "matrix.enter_pressed",
|
||||
"minCount": 1
|
||||
},
|
||||
{
|
||||
"label": "generated values were submitted",
|
||||
"type": "matrix.submitted",
|
||||
"where": { "data.text": "seeded-agent", "data.choice": "two" },
|
||||
"minCount": 1
|
||||
},
|
||||
{
|
||||
"label": "matrix result page was displayed",
|
||||
"type": "page.shown",
|
||||
"where": { "data.path": "/matrix/generated-result" },
|
||||
"minCount": 1
|
||||
}
|
||||
],
|
||||
"response": [
|
||||
{ "label": "reported matrix result", "includes": "MATRIX-OK" },
|
||||
{ "label": "reported selected seed", "includes": "SEED-" }
|
||||
],
|
||||
"adapter": [{ "label": "browser session was closed", "key": "sessionStopped" }]
|
||||
},
|
||||
"smoke": {
|
||||
"steps": [
|
||||
{ "action": "navigate" },
|
||||
{
|
||||
"action": "wait-site-event",
|
||||
"type": "matrix.ready",
|
||||
"timeoutMs": 5000
|
||||
},
|
||||
{ "action": "observe" },
|
||||
{ "action": "fill", "selector": "[name=text]", "value": "seeded-agent" },
|
||||
{ "action": "select", "selector": "[name=choice]", "values": ["two"] },
|
||||
{
|
||||
"action": "press",
|
||||
"key": "Enter",
|
||||
"selector": "button[type=submit]"
|
||||
},
|
||||
{
|
||||
"action": "wait-site-event",
|
||||
"type": "page.shown",
|
||||
"where": { "path": "/matrix/generated-result" }
|
||||
},
|
||||
{ "action": "wait", "duration": "200ms" },
|
||||
{ "action": "observe" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# Regression cases
|
||||
|
||||
Each user-reported badcase should live in its own directory with a manifest, synthetic fixture, and
|
||||
README describing the symptom, minimal reproduction, expected behavior, source reference, and fix.
|
||||
Never commit credentials, cookies, HAR files, production HTML, private screenshots, or proprietary
|
||||
assets.
|
||||
|
||||
Create a starting point with:
|
||||
|
||||
```sh
|
||||
pnpm eval:browser scaffold <case-id> --title "..." --source "issue-or-pr"
|
||||
```
|
||||
@@ -0,0 +1,467 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { loadAgentConfig, runCommandTask } from "./lib/agent-runner.mjs";
|
||||
import { runBskSmokeTask } from "./lib/bsk-runner.mjs";
|
||||
import { buildOperationCoverage } from "./lib/coverage.mjs";
|
||||
import { loadFixtureRegistry } from "./lib/fixture-registry.mjs";
|
||||
import { verifyTask } from "./lib/oracle.mjs";
|
||||
import { resultsDirectory } from "./lib/paths.mjs";
|
||||
import { repositorySummary, validateRepositoryCases } from "./lib/repository-validation.mjs";
|
||||
import { scaffoldCase } from "./lib/scaffold-case.mjs";
|
||||
import { describeSeed, normalizeSeed } from "./lib/seeded-generator.mjs";
|
||||
import { createEvalServer } from "./lib/server.mjs";
|
||||
import { printSummary, summarizeReports } from "./lib/summary.mjs";
|
||||
import { allTasks, getTask, listTasks, renderPrompt } from "./lib/tasks.mjs";
|
||||
|
||||
const booleanOptions = new Set(["help", "json"]);
|
||||
|
||||
function parseArguments(argv) {
|
||||
const normalized = argv[0] === "--" ? argv.slice(1) : argv;
|
||||
let [command, ...rest] = normalized;
|
||||
const options = new Map();
|
||||
const positionals = [];
|
||||
if (command === "--help" || command === "-h") {
|
||||
command = undefined;
|
||||
options.set("help", [true]);
|
||||
}
|
||||
for (let index = 0; index < rest.length; index += 1) {
|
||||
const argument = rest[index];
|
||||
if (!argument.startsWith("--")) {
|
||||
positionals.push(argument);
|
||||
continue;
|
||||
}
|
||||
const equals = argument.indexOf("=");
|
||||
const name = argument.slice(2, equals === -1 ? undefined : equals);
|
||||
let value = equals === -1 ? undefined : argument.slice(equals + 1);
|
||||
if (value === undefined && !booleanOptions.has(name)) {
|
||||
const candidate = rest[index + 1];
|
||||
if (candidate === undefined || candidate.startsWith("--")) {
|
||||
throw new Error(`--${name} requires a value`);
|
||||
}
|
||||
value = candidate;
|
||||
index += 1;
|
||||
}
|
||||
const values = options.get(name) ?? [];
|
||||
values.push(value ?? true);
|
||||
options.set(name, values);
|
||||
}
|
||||
return { command, options, positionals };
|
||||
}
|
||||
|
||||
function option(parsed, name, fallback) {
|
||||
return parsed.options.get(name)?.at(-1) ?? fallback;
|
||||
}
|
||||
|
||||
function optionList(parsed, name) {
|
||||
return (parsed.options.get(name) ?? [])
|
||||
.flatMap((value) => String(value).split(","))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function numericOption(parsed, name, fallback) {
|
||||
const value = Number(option(parsed, name, fallback));
|
||||
if (!Number.isFinite(value) || value < 0) throw new Error(`--${name} must be non-negative`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function seedOptions(parsed, fallback) {
|
||||
const requested = optionList(parsed, "seed");
|
||||
return requested.length > 0 ? unique(requested.map(normalizeSeed)) : [fallback];
|
||||
}
|
||||
|
||||
function seedOption(parsed, fallback) {
|
||||
const seeds = seedOptions(parsed, fallback);
|
||||
if (seeds.length > 1) throw new Error("this command accepts only one --seed value");
|
||||
return seeds[0];
|
||||
}
|
||||
|
||||
function unique(values) {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function selectCases(cases, parsed, { defaultSuite } = {}) {
|
||||
const requestedIds = unique([...optionList(parsed, "case"), ...optionList(parsed, "task")]);
|
||||
const requestedSuites = unique(optionList(parsed, "suite"));
|
||||
const requestedTags = unique(optionList(parsed, "tag"));
|
||||
const hasSelector =
|
||||
requestedIds.length > 0 || requestedSuites.length > 0 || requestedTags.length > 0;
|
||||
|
||||
if (requestedIds.includes("all") && requestedIds.length > 1) {
|
||||
throw new Error("--case all cannot be combined with named cases");
|
||||
}
|
||||
if (requestedSuites.includes("all") && requestedSuites.length > 1) {
|
||||
throw new Error("--suite all cannot be combined with named suites");
|
||||
}
|
||||
|
||||
const caseIds = requestedIds.includes("all") ? [] : requestedIds;
|
||||
const suites = requestedSuites.includes("all")
|
||||
? []
|
||||
: requestedSuites.length > 0
|
||||
? requestedSuites
|
||||
: !hasSelector && defaultSuite
|
||||
? [defaultSuite]
|
||||
: [];
|
||||
|
||||
const knownIds = new Set(cases.map(({ id }) => id));
|
||||
const missingIds = caseIds.filter((id) => !knownIds.has(id));
|
||||
if (missingIds.length > 0) throw new Error(`unknown case(s): ${missingIds.join(", ")}`);
|
||||
const knownSuites = new Set(cases.map(({ suite }) => suite));
|
||||
const missingSuites = suites.filter((suite) => !knownSuites.has(suite));
|
||||
if (missingSuites.length > 0) throw new Error(`unknown suite(s): ${missingSuites.join(", ")}`);
|
||||
const knownTags = new Set(cases.flatMap(({ tags }) => tags));
|
||||
const missingTags = requestedTags.filter((tag) => !knownTags.has(tag));
|
||||
if (missingTags.length > 0) throw new Error(`unknown tag(s): ${missingTags.join(", ")}`);
|
||||
|
||||
const selected = cases.filter(
|
||||
(testCase) =>
|
||||
(caseIds.length === 0 || caseIds.includes(testCase.id)) &&
|
||||
(suites.length === 0 || suites.includes(testCase.suite)) &&
|
||||
requestedTags.every((tag) => testCase.tags.includes(tag)),
|
||||
);
|
||||
if (selected.length === 0) throw new Error("case selectors matched no cases");
|
||||
return selected;
|
||||
}
|
||||
|
||||
function reportFilename(mode) {
|
||||
return `${new Date().toISOString().replaceAll(/[:.]/g, "-")}-${mode}.json`;
|
||||
}
|
||||
|
||||
async function saveReport(report, outputDirectory) {
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
const path = join(outputDirectory, reportFilename(report.mode));
|
||||
await writeFile(path, `${JSON.stringify(report, null, 2)}\n`);
|
||||
return path;
|
||||
}
|
||||
|
||||
async function loadValidatedCorpus() {
|
||||
const [cases, fixtureRegistry] = await Promise.all([allTasks(), loadFixtureRegistry()]);
|
||||
const errors = validateRepositoryCases(cases, fixtureRegistry);
|
||||
if (errors.length > 0) throw new Error(`browser eval validation failed:\n${errors.join("\n")}`);
|
||||
return { cases, fixtureRegistry };
|
||||
}
|
||||
|
||||
function selectionMetadata(cases) {
|
||||
return {
|
||||
cases: cases.map(({ id }) => id),
|
||||
suites: unique(cases.map(({ suite }) => suite)),
|
||||
tags: unique(cases.flatMap(({ tags }) => tags)).sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function usage() {
|
||||
console.log(`BrowserSkill local browser evaluation
|
||||
|
||||
Usage:
|
||||
node evals/browser/cli.mjs validate [--json]
|
||||
node evals/browser/cli.mjs list [--suite NAME] [--tag NAME] [--case ID] [--json]
|
||||
node evals/browser/cli.mjs coverage [--suite NAME] [--tag NAME] [--case ID] [--json]
|
||||
node evals/browser/cli.mjs serve [--host 127.0.0.1] [--port 4173]
|
||||
node evals/browser/cli.mjs prompt <case> [--base-url URL] [--run-id ID] [--seed VALUE]
|
||||
node evals/browser/cli.mjs verify <case> --base-url URL --run-id ID [--response TEXT]
|
||||
node evals/browser/cli.mjs smoke [--bsk bsk] [--suite core] [--case ID] [--seed VALUE]
|
||||
node evals/browser/cli.mjs run-agent --config FILE --agent NAME [--suite core] [--case ID]
|
||||
node evals/browser/cli.mjs generate --seed VALUE [--json]
|
||||
node evals/browser/cli.mjs scaffold <case-id> [--suite regression] [--title TEXT]
|
||||
node evals/browser/cli.mjs summarize <report.json> [more reports...]
|
||||
|
||||
Selection:
|
||||
--case ID[,ID] Select case ids; --task is a backward-compatible alias
|
||||
--suite NAME[,NAME] Select suites
|
||||
--tag NAME[,NAME] Require every selected tag
|
||||
--case all Explicitly include every suite (run commands default to core)
|
||||
--seed VALUE[,VALUE] Run one or more reproducible matrix seeds
|
||||
|
||||
run-agent options:
|
||||
--agent NAME[,NAME] One or more entries from the config file
|
||||
--variant NAME Override the report label (does not switch plugin code)
|
||||
--locale en|zh-CN Prompt language (default zh-CN)
|
||||
--repeat N Repeat every selected case (default 1)
|
||||
--timeout MS Per-agent timeout (default 300000)
|
||||
--out DIRECTORY Report/artifact directory
|
||||
`);
|
||||
}
|
||||
|
||||
async function commandValidate(parsed) {
|
||||
const { cases, fixtureRegistry } = await loadValidatedCorpus();
|
||||
const summary = { ok: true, ...repositorySummary(cases, fixtureRegistry) };
|
||||
if (option(parsed, "json", false)) console.log(JSON.stringify(summary, null, 2));
|
||||
else {
|
||||
console.log(`Validated ${summary.cases} cases and ${summary.fixtureRoutes} fixture routes.`);
|
||||
console.log(
|
||||
`Suites: ${Object.entries(summary.suites)
|
||||
.map(([suite, count]) => `${suite}=${count}`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function commandList(parsed) {
|
||||
const cases = selectCases(await allTasks(), parsed);
|
||||
const tasks = listTasks(cases);
|
||||
if (option(parsed, "json", false)) console.log(JSON.stringify(tasks, null, 2));
|
||||
else {
|
||||
for (const task of tasks) {
|
||||
console.log(`${task.id}\t${task.suite}\t${task.tags.join(",")}\t${task.title}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function commandCoverage(parsed) {
|
||||
const cases = selectCases(await allTasks(), parsed);
|
||||
const coverage = buildOperationCoverage(cases);
|
||||
if (option(parsed, "json", false)) {
|
||||
console.log(JSON.stringify(coverage, null, 2));
|
||||
} else {
|
||||
console.table(
|
||||
coverage.map(({ operation, agentCases, smokeCases, directSmoke, note }) => ({
|
||||
operation,
|
||||
agent_cases: agentCases.join(", ") || "-",
|
||||
smoke_cases: smokeCases.join(", ") || "-",
|
||||
direct_smoke: directSmoke ? "yes" : "manual",
|
||||
note: note ?? "",
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function commandServe(parsed) {
|
||||
const { fixtureRegistry } = await loadValidatedCorpus();
|
||||
const server = createEvalServer({
|
||||
host: option(parsed, "host", "127.0.0.1"),
|
||||
port: numericOption(parsed, "port", 4173),
|
||||
fixtureRegistry,
|
||||
});
|
||||
const info = await server.start();
|
||||
console.log(`Browser eval server listening at ${info.baseUrl}`);
|
||||
console.log("Press Ctrl-C to stop.");
|
||||
await new Promise((resolveSignal) => {
|
||||
process.once("SIGINT", resolveSignal);
|
||||
process.once("SIGTERM", resolveSignal);
|
||||
});
|
||||
await server.stop();
|
||||
}
|
||||
|
||||
async function commandPrompt(parsed) {
|
||||
const cases = await allTasks();
|
||||
const task = getTask(parsed.positionals[0], cases);
|
||||
const prompt = renderPrompt(task, {
|
||||
baseUrl: option(parsed, "base-url", "http://127.0.0.1:4173"),
|
||||
runId: option(parsed, "run-id", randomUUID()),
|
||||
locale: option(parsed, "locale", "zh-CN"),
|
||||
seed: seedOption(parsed, task.seed),
|
||||
});
|
||||
console.log(prompt);
|
||||
}
|
||||
|
||||
async function commandVerify(parsed) {
|
||||
const task = getTask(parsed.positionals[0], await allTasks());
|
||||
const baseUrl = option(parsed, "base-url");
|
||||
const runId = option(parsed, "run-id");
|
||||
if (!baseUrl || !runId) throw new Error("verify requires --base-url and --run-id");
|
||||
let responseText = option(parsed, "response", "");
|
||||
const responseFile = option(parsed, "response-file");
|
||||
if (responseFile) responseText = await readFile(resolve(responseFile), "utf8");
|
||||
const stateResponse = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runId)}`);
|
||||
if (!stateResponse.ok) throw new Error(`oracle request failed with ${stateResponse.status}`);
|
||||
const state = await stateResponse.json();
|
||||
const verification = verifyTask(task, { events: state.events, responseText });
|
||||
console.log(JSON.stringify(verification, null, 2));
|
||||
if (verification.status === "failed") process.exitCode = 1;
|
||||
}
|
||||
|
||||
async function commandRunAgent(parsed) {
|
||||
const configPath = option(parsed, "config");
|
||||
const agentNames = optionList(parsed, "agent");
|
||||
if (!configPath || agentNames.length === 0) {
|
||||
throw new Error("run-agent requires --config and at least one --agent");
|
||||
}
|
||||
const { cases, fixtureRegistry } = await loadValidatedCorpus();
|
||||
const selected = selectCases(cases, parsed, { defaultSuite: "core" });
|
||||
const repeat = numericOption(parsed, "repeat", 1);
|
||||
if (!Number.isInteger(repeat) || repeat < 1) throw new Error("--repeat must be at least 1");
|
||||
const outputDirectory = resolve(option(parsed, "out", resultsDirectory));
|
||||
const server = createEvalServer({ fixtureRegistry });
|
||||
const serverInfo = await server.start();
|
||||
const results = [];
|
||||
const startedAt = new Date().toISOString();
|
||||
try {
|
||||
for (const agentName of agentNames) {
|
||||
const { adapter, configDirectory } = await loadAgentConfig(configPath, agentName);
|
||||
for (let iteration = 1; iteration <= repeat; iteration += 1) {
|
||||
for (const task of selected) {
|
||||
for (const seed of seedOptions(parsed, task.seed)) {
|
||||
const seedLabel = seed === undefined ? "" : ` seed=${seed}`;
|
||||
console.log(`[run] ${agentName} ${task.id}${seedLabel} (${iteration}/${repeat})`);
|
||||
const result = await runCommandTask({
|
||||
server,
|
||||
serverInfo,
|
||||
task,
|
||||
adapter,
|
||||
adapterName: agentName,
|
||||
configDirectory,
|
||||
iteration,
|
||||
locale: option(parsed, "locale", "zh-CN"),
|
||||
seed,
|
||||
timeoutMs: numericOption(parsed, "timeout", 300_000),
|
||||
variantOverride: option(parsed, "variant"),
|
||||
});
|
||||
results.push(result);
|
||||
console.log(` ${result.verification.status} in ${result.execution.durationMs}ms`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
mode: "agent",
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
selection: selectionMetadata(selected),
|
||||
seeds: unique(results.map(({ seed }) => seed).filter((seed) => seed !== undefined)),
|
||||
results,
|
||||
};
|
||||
const path = await saveReport(report, outputDirectory);
|
||||
printSummary(summarizeReports([report]));
|
||||
console.log(`Report: ${path}`);
|
||||
if (
|
||||
results.some(
|
||||
(result) =>
|
||||
result.verification.status === "failed" ||
|
||||
result.execution.timedOut ||
|
||||
result.execution.exitCode !== 0,
|
||||
)
|
||||
) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function commandSmoke(parsed) {
|
||||
const { cases, fixtureRegistry } = await loadValidatedCorpus();
|
||||
const selected = selectCases(cases, parsed, { defaultSuite: "core" });
|
||||
const outputDirectory = resolve(option(parsed, "out", resultsDirectory));
|
||||
const server = createEvalServer({ fixtureRegistry });
|
||||
const serverInfo = await server.start();
|
||||
const results = [];
|
||||
const startedAt = new Date().toISOString();
|
||||
try {
|
||||
for (const task of selected) {
|
||||
for (const seed of seedOptions(parsed, task.seed)) {
|
||||
const runId = `bsk-${task.id}-${randomUUID().slice(0, 8)}`;
|
||||
const seedLabel = seed === undefined ? "" : ` seed=${seed}`;
|
||||
console.log(`[smoke] ${task.id}${seedLabel}`);
|
||||
const result = await runBskSmokeTask({
|
||||
bskCommand: option(parsed, "bsk", "bsk"),
|
||||
outputDirectory,
|
||||
server,
|
||||
serverInfo,
|
||||
task,
|
||||
runId,
|
||||
seed,
|
||||
timeoutMs: numericOption(parsed, "timeout", 60_000),
|
||||
});
|
||||
results.push(result);
|
||||
console.log(
|
||||
` ${result.verification.status}${result.executionError ? `: ${result.executionError}` : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
mode: "bsk-smoke",
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
selection: selectionMetadata(selected),
|
||||
seeds: unique(results.map(({ seed }) => seed).filter((seed) => seed !== undefined)),
|
||||
results,
|
||||
};
|
||||
const path = await saveReport(report, outputDirectory);
|
||||
printSummary(summarizeReports([report]));
|
||||
console.log(`Report: ${path}`);
|
||||
if (results.some((result) => result.executionError || result.verification.status === "failed")) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function commandGenerate(parsed) {
|
||||
if (!parsed.options.has("seed")) throw new Error("generate requires --seed");
|
||||
const dimensions = unique(optionList(parsed, "seed").map(normalizeSeed)).map(describeSeed);
|
||||
const output = dimensions.length === 1 ? dimensions[0] : dimensions;
|
||||
if (option(parsed, "json", false)) console.log(JSON.stringify(output, null, 2));
|
||||
else console.table(dimensions);
|
||||
}
|
||||
|
||||
async function commandScaffold(parsed) {
|
||||
const id = parsed.positionals[0];
|
||||
if (!id) throw new Error("scaffold requires a case id");
|
||||
const result = await scaffoldCase({
|
||||
id,
|
||||
title: option(parsed, "title", id),
|
||||
suite: option(parsed, "suite", "regression"),
|
||||
source: option(parsed, "source"),
|
||||
});
|
||||
if (option(parsed, "json", false)) console.log(JSON.stringify(result, null, 2));
|
||||
else {
|
||||
console.log(`Created ${result.directory}`);
|
||||
console.log(
|
||||
"Edit the fixture, prompt, workflow, and assertions, then run eval:browser validate.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function commandSummarize(parsed) {
|
||||
const paths = [...parsed.positionals, ...optionList(parsed, "input")];
|
||||
if (paths.length === 0) throw new Error("summarize requires at least one report path");
|
||||
const reports = await Promise.all(
|
||||
paths.map(async (path) => JSON.parse(await readFile(resolve(path), "utf8"))),
|
||||
);
|
||||
const rows = summarizeReports(reports);
|
||||
if (option(parsed, "json", false)) console.log(JSON.stringify(rows, null, 2));
|
||||
else printSummary(rows);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const parsed = parseArguments(process.argv.slice(2));
|
||||
if (!parsed.command || option(parsed, "help", false)) return usage();
|
||||
switch (parsed.command) {
|
||||
case "validate":
|
||||
return commandValidate(parsed);
|
||||
case "list":
|
||||
return commandList(parsed);
|
||||
case "coverage":
|
||||
return commandCoverage(parsed);
|
||||
case "serve":
|
||||
return commandServe(parsed);
|
||||
case "prompt":
|
||||
return commandPrompt(parsed);
|
||||
case "verify":
|
||||
return commandVerify(parsed);
|
||||
case "run-agent":
|
||||
return commandRunAgent(parsed);
|
||||
case "smoke":
|
||||
return commandSmoke(parsed);
|
||||
case "generate":
|
||||
return commandGenerate(parsed);
|
||||
case "scaffold":
|
||||
return commandScaffold(parsed);
|
||||
case "summarize":
|
||||
return commandSummarize(parsed);
|
||||
default:
|
||||
throw new Error(`unknown command ${JSON.stringify(parsed.command)}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`browser eval: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { page } from "../../lib/fixtures.mjs";
|
||||
|
||||
export default {
|
||||
id: "diagnostics",
|
||||
routes: ["/diagnostics"],
|
||||
render() {
|
||||
return page({
|
||||
title: "Diagnostics Lab",
|
||||
body: `<section class="card"><h1>Diagnostics Lab</h1><p>Inspect console and network buffers.</p><p id="network-status" class="muted">Ping pending</p></section>`,
|
||||
script: `
|
||||
console.log("Browser eval console marker: CONSOLE-61");
|
||||
fetch("/api/ping?run=" + encodeURIComponent(browserEval.runId))
|
||||
.then((response) => response.json())
|
||||
.then(({ token }) => { document.querySelector("#network-status").textContent = "Ping completed: " + token; });
|
||||
`,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { escapeHtml, page } from "../../lib/fixtures.mjs";
|
||||
|
||||
export default {
|
||||
id: "form",
|
||||
routes: ["/form", "/result"],
|
||||
render({ pathname, runId, query, record }) {
|
||||
if (pathname === "/form") {
|
||||
return page({
|
||||
title: "Browser Eval Form",
|
||||
body: `<section class="card"><h1>Web form</h1><form action="/result" method="get"><input type="hidden" name="run" value="${escapeHtml(runId)}"><label>Text input<input id="text-input" name="text" autocomplete="off"></label><label>Textarea<textarea id="notes" name="notes"></textarea></label><label>Dropdown<select id="choice" name="choice"><option value="one">One</option><option value="two">Two</option><option value="three">Three</option></select></label><button id="submit" type="submit">Submit</button></form></section>`,
|
||||
script: `document.querySelector("#submit").addEventListener("keydown", (event) => { if (event.key === "Enter") browserEval.send("form.enter_pressed"); });`,
|
||||
});
|
||||
}
|
||||
const values = {
|
||||
text: query.get("text") ?? "",
|
||||
notes: query.get("notes") ?? "",
|
||||
choice: query.get("choice") ?? "",
|
||||
};
|
||||
record("form.submitted", values, { path: pathname });
|
||||
return page({
|
||||
title: "Form Result",
|
||||
body: `<section class="card"><h1>Received!</h1><p id="submitted-values" class="marker">${escapeHtml(values.text)} | ${escapeHtml(values.notes)} | ${escapeHtml(values.choice)}</p></section>`,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { escapeHtml, page } from "../../lib/fixtures.mjs";
|
||||
import { describeSeed } from "../../lib/seeded-generator.mjs";
|
||||
|
||||
function nest(content, depth) {
|
||||
let nested = content;
|
||||
for (let index = 0; index < depth; index += 1) {
|
||||
nested = `<div data-depth="${index + 1}">${nested}</div>`;
|
||||
}
|
||||
return nested;
|
||||
}
|
||||
|
||||
function textField(dimensions) {
|
||||
const id = `matrix-text-${dimensions.idSuffix}`;
|
||||
const input = `<input id="${id}" name="text" autocomplete="off">`;
|
||||
if (dimensions.labelMode === "wrapped") return `<label>Text input${input}</label>`;
|
||||
if (dimensions.labelMode === "for") return `<label for="${id}">Text input</label>${input}`;
|
||||
return `<span id="${id}-label">Text input</span><input id="${id}" name="text" aria-labelledby="${id}-label" autocomplete="off">`;
|
||||
}
|
||||
|
||||
function formMarkup(dimensions, runId) {
|
||||
const text = textField(dimensions);
|
||||
const select = `<label>Dropdown<select name="choice"><option value="one">One</option><option value="two">Two</option><option value="three">Three</option></select></label>`;
|
||||
const fields = dimensions.fieldOrder === "text-first" ? `${text}${select}` : `${select}${text}`;
|
||||
const decoy =
|
||||
dimensions.decoy === "disabled-input"
|
||||
? `<label>Text input<input disabled value="decoy"></label>`
|
||||
: dimensions.decoy === "duplicate-text"
|
||||
? `<p class="muted">Text input is intentionally repeated in helper copy.</p>`
|
||||
: "";
|
||||
return nest(
|
||||
`<form action="/matrix/generated-result" method="get"><input type="hidden" name="run" value="${escapeHtml(runId)}"><input type="hidden" name="seed" value="${dimensions.seed}">${decoy}${fields}<button type="submit">Submit matrix form</button></form>`,
|
||||
dimensions.nestingDepth,
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
id: "generated-form",
|
||||
routes: ["/matrix/generated-form", "/matrix/generated-result"],
|
||||
render({ pathname, runId, query, record }) {
|
||||
const dimensions = describeSeed(query.get("seed") ?? 1);
|
||||
if (pathname === "/matrix/generated-form") {
|
||||
const markup = JSON.stringify(formMarkup(dimensions, runId));
|
||||
const dimensionsJson = JSON.stringify(dimensions);
|
||||
return page({
|
||||
title: "Seeded Matrix Form",
|
||||
body: `<section class="card"><h1>Seeded Matrix Form</h1><p class="marker">SEED-${dimensions.seed}</p><div id="matrix-root">Hydrating…</div></section>`,
|
||||
script: `
|
||||
const hydrate = () => {
|
||||
const root = document.querySelector("#matrix-root");
|
||||
root.innerHTML = ${markup};
|
||||
root.querySelector("button[type=submit]").addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") browserEval.send("matrix.enter_pressed");
|
||||
});
|
||||
browserEval.send("matrix.ready", ${dimensionsJson});
|
||||
};
|
||||
setTimeout(hydrate, ${dimensions.hydrationDelayMs});
|
||||
`,
|
||||
});
|
||||
}
|
||||
const values = {
|
||||
seed: dimensions.seed,
|
||||
text: query.get("text") ?? "",
|
||||
choice: query.get("choice") ?? "",
|
||||
};
|
||||
record("matrix.submitted", values, { path: pathname });
|
||||
return page({
|
||||
title: "Matrix Result",
|
||||
body: `<section class="card"><h1>Matrix Result</h1><p class="marker">MATRIX-OK</p><p>${escapeHtml(values.text)} | ${escapeHtml(values.choice)} | seed ${values.seed}</p></section>`,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { page } from "../../lib/fixtures.mjs";
|
||||
|
||||
export default {
|
||||
id: "hover-inspect",
|
||||
routes: ["/hover-inspect"],
|
||||
render() {
|
||||
return page({
|
||||
title: "Hover and Inspect",
|
||||
body: `<section class="card"><h1>Hover and Inspect</h1><div id="account-menu" tabindex="0"><button id="account-button" type="button">Account</button><div id="hover-panel"><button id="profile-link" type="button">Profile beta</button></div></div><p id="hover-result" class="marker" hidden>HOVER-17</p><article id="inspect-card" class="card" data-build="2026.08" data-marker="INSPECT-27"><h2>Inspection card</h2><p>Local HTML marker: <code>INSPECT-27</code></p></article></section>`,
|
||||
script: `
|
||||
const menu = document.querySelector("#account-menu");
|
||||
menu.addEventListener("mouseenter", () => { menu.classList.add("open"); browserEval.send("hover.menu_opened"); });
|
||||
document.querySelector("#profile-link").addEventListener("click", () => { document.querySelector("#hover-result").hidden = false; browserEval.send("hover.profile_clicked"); });
|
||||
`,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { page } from "../../lib/fixtures.mjs";
|
||||
|
||||
export default {
|
||||
id: "fixture-index",
|
||||
routes: ["/"],
|
||||
render() {
|
||||
return page({
|
||||
title: "Browser Eval Fixtures",
|
||||
body: `<section class="card"><h1>Browser Eval Fixtures</h1><p class="muted">Deterministic local pages for agent-neutral browser evaluation.</p></section>`,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { page, withRun } from "../../lib/fixtures.mjs";
|
||||
|
||||
export default {
|
||||
id: "navigation",
|
||||
routes: ["/navigation/start", "/navigation/detail"],
|
||||
render({ pathname, runId }) {
|
||||
if (pathname === "/navigation/start") {
|
||||
return page({
|
||||
title: "Navigation Start",
|
||||
body: `<section class="card"><h1>Navigation Start</h1><p>Use the link to open the deterministic detail page.</p><a id="details-link" class="button" href="${withRun("/navigation/detail", runId)}">Details</a></section>`,
|
||||
script: `document.querySelector("#details-link").addEventListener("click", () => browserEval.send("navigation.detail.clicked"));`,
|
||||
});
|
||||
}
|
||||
return page({
|
||||
title: "Navigation Detail",
|
||||
body: `<section class="card"><h1>Navigation Detail</h1><p>Stable detail code:</p><p id="navigation-code" class="marker">NAV-42</p></section>`,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { page } from "../../lib/fixtures.mjs";
|
||||
|
||||
export default {
|
||||
id: "responsive",
|
||||
routes: ["/responsive"],
|
||||
render() {
|
||||
return page({
|
||||
title: "Responsive Lab",
|
||||
body: `<section class="card"><h1>Responsive Lab</h1><p id="viewport-marker" class="marker">VIEWPORT-84</p><p id="viewport-value"></p></section>`,
|
||||
script: `
|
||||
const sample = () => {
|
||||
const data = { width: innerWidth, height: innerHeight, touchPoints: navigator.maxTouchPoints, userAgent: navigator.userAgent };
|
||||
document.querySelector("#viewport-value").textContent = data.width + "x" + data.height;
|
||||
browserEval.send("viewport.sample", data);
|
||||
};
|
||||
window.addEventListener("resize", sample);
|
||||
sample();
|
||||
`,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { escapeHtml, page, withRun } from "../../lib/fixtures.mjs";
|
||||
|
||||
export default {
|
||||
id: "tabs",
|
||||
routes: ["/tabs", "/tabs/child"],
|
||||
render({ pathname, runId }) {
|
||||
if (pathname === "/tabs") {
|
||||
return page({
|
||||
title: "Tab Lab",
|
||||
body: `<section class="card"><h1>Tab Lab</h1><p>Keep this original tab open while visiting the child page in a new tab.</p><p class="muted">Child URL: <code>${escapeHtml(withRun("/tabs/child", runId))}</code></p></section>`,
|
||||
});
|
||||
}
|
||||
return page({
|
||||
title: "Child Tab",
|
||||
body: `<section class="card"><h1>Child Tab</h1><p id="tab-token" class="marker">TAB-29</p><button id="mark-child" type="button">Mark child visited</button><p id="child-status" hidden>Marked</p></section>`,
|
||||
script: `document.querySelector("#mark-child").addEventListener("click", () => { document.querySelector("#child-status").hidden = false; browserEval.send("tabs.child_marked"); });`,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { verifyTask } from "./oracle.mjs";
|
||||
import { countPattern, runProcess, substituteArgs } from "./process.mjs";
|
||||
import { renderPrompt } from "./tasks.mjs";
|
||||
|
||||
function safeRunPart(value) {
|
||||
return value.replaceAll(/[^A-Za-z0-9._-]/g, "-").slice(0, 48);
|
||||
}
|
||||
|
||||
export async function loadAgentConfig(configPath, agentName) {
|
||||
const absolute = resolve(configPath);
|
||||
const config = JSON.parse(await readFile(absolute, "utf8"));
|
||||
const adapter = config.agents?.[agentName];
|
||||
if (!adapter) {
|
||||
const names = Object.keys(config.agents ?? {}).join(", ");
|
||||
throw new Error(`unknown agent ${JSON.stringify(agentName)}; configured agents: ${names}`);
|
||||
}
|
||||
if (typeof adapter.command !== "string" || !Array.isArray(adapter.args)) {
|
||||
throw new Error(`agent ${JSON.stringify(agentName)} must define command and args`);
|
||||
}
|
||||
return { adapter, configDirectory: dirname(absolute) };
|
||||
}
|
||||
|
||||
export async function runCommandTask({
|
||||
server,
|
||||
serverInfo,
|
||||
task,
|
||||
adapter,
|
||||
adapterName,
|
||||
configDirectory,
|
||||
iteration,
|
||||
locale,
|
||||
seed = task.seed,
|
||||
timeoutMs,
|
||||
variantOverride,
|
||||
}) {
|
||||
const variant = variantOverride ?? adapter.variant ?? "unspecified";
|
||||
const runId = [
|
||||
safeRunPart(adapterName),
|
||||
safeRunPart(variant),
|
||||
safeRunPart(task.id),
|
||||
iteration,
|
||||
randomUUID().slice(0, 8),
|
||||
].join("-");
|
||||
server.reset(runId);
|
||||
const prompt = renderPrompt(task, { baseUrl: serverInfo.baseUrl, runId, locale, seed });
|
||||
const values = {
|
||||
prompt,
|
||||
caseId: task.id,
|
||||
taskId: task.id,
|
||||
runId,
|
||||
baseUrl: serverInfo.baseUrl,
|
||||
seed: seed ?? "",
|
||||
variant,
|
||||
};
|
||||
const args = substituteArgs(adapter.args, values);
|
||||
const execution = await runProcess(adapter.command, args, {
|
||||
cwd: adapter.cwd ? resolve(configDirectory, adapter.cwd) : configDirectory,
|
||||
env: adapter.env,
|
||||
timeoutMs: adapter.timeoutMs ?? timeoutMs,
|
||||
});
|
||||
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
|
||||
const responseText = execution.stdout;
|
||||
const combinedOutput = `${execution.stdout}\n${execution.stderr}`;
|
||||
const verification = verifyTask(task, {
|
||||
events: server.snapshot(runId).events,
|
||||
responseText,
|
||||
});
|
||||
const defaultErrorPattern = "(?:\\bError:|timed out|unfinished command|session already has)";
|
||||
return {
|
||||
runId,
|
||||
caseId: task.id,
|
||||
taskId: task.id,
|
||||
suite: task.suite,
|
||||
tags: task.tags,
|
||||
seed,
|
||||
iteration,
|
||||
adapter: adapterName,
|
||||
variant,
|
||||
adapterMetadata: adapter.metadata ?? {},
|
||||
locale,
|
||||
prompt,
|
||||
execution,
|
||||
metrics: {
|
||||
errorCount: countPattern(
|
||||
combinedOutput,
|
||||
adapter.metrics?.errorPattern ?? defaultErrorPattern,
|
||||
),
|
||||
toolCallCount: countPattern(combinedOutput, adapter.metrics?.toolCallPattern),
|
||||
responseChars: responseText.length,
|
||||
},
|
||||
eventCount: server.snapshot(runId).events.length,
|
||||
verification,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { mkdir, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { verifyTask } from "./oracle.mjs";
|
||||
import { runProcess } from "./process.mjs";
|
||||
import { taskUrl } from "./tasks.mjs";
|
||||
|
||||
function parseJsonOutput(step) {
|
||||
try {
|
||||
return JSON.parse(step.stdout);
|
||||
} catch (error) {
|
||||
throw new Error(`could not parse bsk JSON output: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStepError(step) {
|
||||
const raw = step.stderr.trim() || step.stdout.trim();
|
||||
if (!raw) return `exit ${step.exitCode}`;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed.hint ? `${parsed.message} (hint: ${parsed.hint})` : parsed.message;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
async function hasNonemptyFile(path) {
|
||||
try {
|
||||
return (await stat(path)).size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readPath(value, path) {
|
||||
return path.split(".").reduce((current, part) => current?.[part], value);
|
||||
}
|
||||
|
||||
function resolveValue(value, variables) {
|
||||
if (typeof value !== "string") return value;
|
||||
const exact = value.match(/^\{([^{}]+)}$/);
|
||||
if (exact) {
|
||||
const resolved = readPath(variables, exact[1]);
|
||||
if (resolved === undefined) throw new Error(`unknown workflow variable ${exact[1]}`);
|
||||
return resolved;
|
||||
}
|
||||
return value.replaceAll(/\{([^{}]+)\}/g, (_match, path) => {
|
||||
const resolved = readPath(variables, path);
|
||||
if (resolved === undefined) throw new Error(`unknown workflow variable ${path}`);
|
||||
return String(resolved);
|
||||
});
|
||||
}
|
||||
|
||||
function targetArgs(step, variables) {
|
||||
if (step.selector) return ["--selector", String(resolveValue(step.selector, variables))];
|
||||
if (step.ref) return ["--ref", String(resolveValue(step.ref, variables))];
|
||||
return [];
|
||||
}
|
||||
|
||||
function tabArgs(step, variables) {
|
||||
return step.tabId === undefined ? [] : ["--tab-id", String(resolveValue(step.tabId, variables))];
|
||||
}
|
||||
|
||||
function matchesWhere(value, where = {}) {
|
||||
return Object.entries(where).every(([path, expected]) => readPath(value, path) === expected);
|
||||
}
|
||||
|
||||
export async function runBskSmokeTask({
|
||||
bskCommand,
|
||||
outputDirectory,
|
||||
server,
|
||||
serverInfo,
|
||||
task,
|
||||
runId,
|
||||
seed = task.seed,
|
||||
timeoutMs = 60_000,
|
||||
}) {
|
||||
const steps = [];
|
||||
const evidence = {};
|
||||
const variables = {
|
||||
baseUrl: serverInfo.baseUrl,
|
||||
runId,
|
||||
seed: seed ?? "",
|
||||
url: taskUrl(task, serverInfo.baseUrl, runId, seed),
|
||||
};
|
||||
let sessionId;
|
||||
let executionError;
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
async function bsk(args, { timeout = timeoutMs } = {}) {
|
||||
const execution = await runProcess(bskCommand, [...args, "--json"], { timeoutMs: timeout });
|
||||
steps.push(execution);
|
||||
if (execution.exitCode !== 0 || execution.timedOut || execution.error) {
|
||||
throw new Error(
|
||||
`bsk ${args.join(" ")} failed: ${execution.error ?? renderStepError(execution)}`,
|
||||
);
|
||||
}
|
||||
return parseJsonOutput(execution);
|
||||
}
|
||||
|
||||
async function waitForSiteEvent(step) {
|
||||
const timeout = step.timeoutMs ?? 3_000;
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
if (
|
||||
server
|
||||
.snapshot(runId)
|
||||
.events.some(
|
||||
(event) => event.type === step.type && matchesWhere(event.data ?? {}, step.where ?? {}),
|
||||
)
|
||||
) {
|
||||
return { observed: true };
|
||||
}
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 50));
|
||||
}
|
||||
throw new Error(`local fixture did not report ${step.type} within ${timeout}ms`);
|
||||
}
|
||||
|
||||
async function executeWorkflowStep(step) {
|
||||
const session = ["--session", sessionId];
|
||||
let result;
|
||||
switch (step.action) {
|
||||
case "navigate":
|
||||
result = await bsk([
|
||||
"navigate",
|
||||
String(resolveValue(step.url ?? "{url}", variables)),
|
||||
...session,
|
||||
...tabArgs(step, variables),
|
||||
]);
|
||||
break;
|
||||
case "back":
|
||||
case "forward":
|
||||
result = await bsk([`navigate-${step.action}`, ...session, ...tabArgs(step, variables)]);
|
||||
break;
|
||||
case "reload":
|
||||
result = await bsk(["reload", ...session, ...tabArgs(step, variables)]);
|
||||
break;
|
||||
case "wait":
|
||||
result = await bsk(["wait-ms", String(resolveValue(step.duration, variables))]);
|
||||
break;
|
||||
case "wait-site-event":
|
||||
result = await waitForSiteEvent(step);
|
||||
break;
|
||||
case "observe":
|
||||
case "snapshot":
|
||||
case "html": {
|
||||
const command = step.action === "html" ? "get-html" : step.action;
|
||||
result = await bsk([command, ...session, ...tabArgs(step, variables)]);
|
||||
break;
|
||||
}
|
||||
case "screenshot": {
|
||||
const name = resolveValue(step.artifactName ?? "screenshot.png", variables);
|
||||
const path = join(outputDirectory, `${runId}-${name}`);
|
||||
result = await bsk(["screenshot", ...session, ...tabArgs(step, variables), "--out", path]);
|
||||
if (step.evidence) evidence[step.evidence] = await hasNonemptyFile(path);
|
||||
break;
|
||||
}
|
||||
case "console":
|
||||
case "network":
|
||||
result = await bsk([step.action, ...session, ...tabArgs(step, variables)]);
|
||||
break;
|
||||
case "click":
|
||||
case "hover":
|
||||
result = await bsk([
|
||||
step.action,
|
||||
...targetArgs(step, variables),
|
||||
...session,
|
||||
...tabArgs(step, variables),
|
||||
]);
|
||||
break;
|
||||
case "fill":
|
||||
result = await bsk([
|
||||
"fill",
|
||||
...targetArgs(step, variables),
|
||||
"--value",
|
||||
String(resolveValue(step.value, variables)),
|
||||
...session,
|
||||
...tabArgs(step, variables),
|
||||
]);
|
||||
break;
|
||||
case "select":
|
||||
result = await bsk([
|
||||
"select",
|
||||
...targetArgs(step, variables),
|
||||
...step.values.flatMap((value) => ["--value", String(resolveValue(value, variables))]),
|
||||
...session,
|
||||
...tabArgs(step, variables),
|
||||
]);
|
||||
break;
|
||||
case "press":
|
||||
result = await bsk([
|
||||
"press",
|
||||
String(resolveValue(step.key, variables)),
|
||||
...targetArgs(step, variables),
|
||||
...session,
|
||||
...tabArgs(step, variables),
|
||||
]);
|
||||
break;
|
||||
case "tab-list":
|
||||
result = await bsk(["tab", "list", ...session]);
|
||||
break;
|
||||
case "tab-create":
|
||||
result = await bsk([
|
||||
"tab",
|
||||
"create",
|
||||
...session,
|
||||
...(step.url ? ["--url", String(resolveValue(step.url, variables))] : []),
|
||||
]);
|
||||
break;
|
||||
case "tab-select":
|
||||
case "tab-close":
|
||||
result = await bsk([
|
||||
"tab",
|
||||
step.action.slice(4),
|
||||
String(resolveValue(step.tabId, variables)),
|
||||
...session,
|
||||
]);
|
||||
break;
|
||||
case "borrow":
|
||||
case "return":
|
||||
result = await bsk([
|
||||
"tab",
|
||||
step.action,
|
||||
String(resolveValue(step.tabId, variables)),
|
||||
...session,
|
||||
]);
|
||||
break;
|
||||
case "resize":
|
||||
result = await bsk([
|
||||
"window",
|
||||
"resize",
|
||||
...session,
|
||||
"--width",
|
||||
String(step.width),
|
||||
"--height",
|
||||
String(step.height),
|
||||
]);
|
||||
break;
|
||||
case "emulate":
|
||||
result = await bsk([
|
||||
"emulate",
|
||||
...session,
|
||||
...tabArgs(step, variables),
|
||||
"--device",
|
||||
String(resolveValue(step.device, variables)),
|
||||
]);
|
||||
break;
|
||||
case "request-help":
|
||||
result = await bsk([
|
||||
"request-help",
|
||||
...session,
|
||||
"--prompt",
|
||||
String(resolveValue(step.prompt, variables)),
|
||||
...(step.title ? ["--title", String(resolveValue(step.title, variables))] : []),
|
||||
...(step.timeout ? ["--timeout", String(step.timeout)] : []),
|
||||
]);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unsupported smoke workflow action ${step.action}`);
|
||||
}
|
||||
if (step.saveAs) variables[step.saveAs] = result;
|
||||
if (step.evidence && step.action !== "screenshot") evidence[step.evidence] = true;
|
||||
}
|
||||
|
||||
server.reset(runId);
|
||||
try {
|
||||
if (task.smokeSteps.length === 0) throw new Error(`case ${task.id} has no smoke workflow`);
|
||||
const started = await bsk(["session", "start", "--no-focus"], { timeout: 90_000 });
|
||||
sessionId = started.session_id;
|
||||
variables.sessionId = sessionId;
|
||||
await bsk(["session", "list"]);
|
||||
for (const step of task.smokeSteps) await executeWorkflowStep(step);
|
||||
} catch (error) {
|
||||
executionError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
if (sessionId) {
|
||||
try {
|
||||
const stopped = await bsk(["session", "stop", sessionId], { timeout: 90_000 });
|
||||
evidence.sessionStopped =
|
||||
stopped.stopped?.includes(sessionId) === true && (stopped.failed?.length ?? 0) === 0;
|
||||
if (!evidence.sessionStopped) {
|
||||
executionError ??= `bsk did not confirm that session ${sessionId} stopped`;
|
||||
}
|
||||
} catch (error) {
|
||||
evidence.sessionStopped = false;
|
||||
executionError ??= error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 350));
|
||||
const responseText = steps.map(({ stdout }) => stdout).join("\n");
|
||||
const events = server.snapshot(runId).events;
|
||||
return {
|
||||
runId,
|
||||
caseId: task.id,
|
||||
taskId: task.id,
|
||||
suite: task.suite,
|
||||
tags: task.tags,
|
||||
seed,
|
||||
adapter: "bsk-direct",
|
||||
variant: "cli-direct",
|
||||
executionError,
|
||||
steps,
|
||||
evidence,
|
||||
eventCount: events.length,
|
||||
verification: verifyTask(task, { events, responseText, adapterEvidence: evidence }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { discoverFiles } from "./discovery.mjs";
|
||||
import { OPERATION_NAMES, WORKFLOW_ACTIONS } from "./operations.mjs";
|
||||
import { casesDirectory as defaultCasesDirectory } from "./paths.mjs";
|
||||
|
||||
const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const EVIDENCE_PATTERN = /^[A-Za-z][A-Za-z0-9]*$/;
|
||||
const TOP_LEVEL_KEYS = new Set([
|
||||
"$schema",
|
||||
"schemaVersion",
|
||||
"id",
|
||||
"title",
|
||||
"order",
|
||||
"suite",
|
||||
"tags",
|
||||
"seed",
|
||||
"source",
|
||||
"fixture",
|
||||
"prompts",
|
||||
"coverage",
|
||||
"assertions",
|
||||
"smoke",
|
||||
]);
|
||||
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function checkString(errors, value, path) {
|
||||
if (typeof value !== "string" || value.trim() === "") errors.push(`${path} must be a string`);
|
||||
}
|
||||
|
||||
function validateAssertion(errors, assertion, path, kind) {
|
||||
if (!isObject(assertion)) {
|
||||
errors.push(`${path} must be an object`);
|
||||
return;
|
||||
}
|
||||
checkString(errors, assertion.label, `${path}.label`);
|
||||
if (kind === "site") {
|
||||
checkString(errors, assertion.type, `${path}.type`);
|
||||
if (!Number.isInteger(assertion.minCount) || assertion.minCount < 1) {
|
||||
errors.push(`${path}.minCount must be a positive integer`);
|
||||
}
|
||||
if (assertion.where !== undefined && !isObject(assertion.where)) {
|
||||
errors.push(`${path}.where must be an object`);
|
||||
}
|
||||
} else if (kind === "response") {
|
||||
checkString(errors, assertion.includes, `${path}.includes`);
|
||||
} else {
|
||||
checkString(errors, assertion.key, `${path}.key`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateWorkflowStep(errors, step, path) {
|
||||
if (!isObject(step)) {
|
||||
errors.push(`${path} must be an object`);
|
||||
return;
|
||||
}
|
||||
if (!WORKFLOW_ACTIONS.has(step.action)) {
|
||||
errors.push(`${path}.action is unsupported: ${JSON.stringify(step.action)}`);
|
||||
}
|
||||
if (step.saveAs !== undefined && !ID_PATTERN.test(step.saveAs)) {
|
||||
errors.push(`${path}.saveAs must use lowercase kebab-case`);
|
||||
}
|
||||
if (step.evidence !== undefined && !EVIDENCE_PATTERN.test(step.evidence)) {
|
||||
errors.push(`${path}.evidence must be an identifier`);
|
||||
}
|
||||
if (["click", "hover", "fill", "select"].includes(step.action) && !step.selector && !step.ref) {
|
||||
errors.push(`${path} requires selector or ref`);
|
||||
}
|
||||
if (step.action === "fill") checkString(errors, step.value, `${path}.value`);
|
||||
if (step.action === "select") {
|
||||
if (!Array.isArray(step.values) || step.values.length === 0) {
|
||||
errors.push(`${path}.values must be a non-empty array`);
|
||||
} else if (step.values.some((value) => typeof value !== "string")) {
|
||||
errors.push(`${path}.values must contain only strings`);
|
||||
}
|
||||
}
|
||||
if (step.action === "press") checkString(errors, step.key, `${path}.key`);
|
||||
if (step.action === "wait") checkString(errors, step.duration, `${path}.duration`);
|
||||
if (step.action === "wait-site-event") checkString(errors, step.type, `${path}.type`);
|
||||
if (["tab-select", "tab-close", "borrow", "return"].includes(step.action)) {
|
||||
checkString(errors, step.tabId, `${path}.tabId`);
|
||||
}
|
||||
if (step.action === "resize") {
|
||||
if (
|
||||
!Number.isInteger(step.width) ||
|
||||
step.width < 1 ||
|
||||
!Number.isInteger(step.height) ||
|
||||
step.height < 1
|
||||
) {
|
||||
errors.push(`${path}.width and height must be positive integers`);
|
||||
}
|
||||
}
|
||||
if (step.action === "emulate") checkString(errors, step.device, `${path}.device`);
|
||||
if (step.action === "request-help") checkString(errors, step.prompt, `${path}.prompt`);
|
||||
if (step.timeoutMs !== undefined && (!Number.isInteger(step.timeoutMs) || step.timeoutMs < 1)) {
|
||||
errors.push(`${path}.timeoutMs must be a positive integer`);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCaseManifest(manifest, { source = "case manifest" } = {}) {
|
||||
const errors = [];
|
||||
if (!isObject(manifest)) return [`${source} must contain a JSON object`];
|
||||
for (const key of Object.keys(manifest)) {
|
||||
if (!TOP_LEVEL_KEYS.has(key)) errors.push(`unknown top-level property ${key}`);
|
||||
}
|
||||
if (manifest.schemaVersion !== 1) errors.push("schemaVersion must equal 1");
|
||||
if (!ID_PATTERN.test(manifest.id ?? "")) errors.push("id must use lowercase kebab-case");
|
||||
checkString(errors, manifest.title, "title");
|
||||
if (manifest.order !== undefined && (!Number.isInteger(manifest.order) || manifest.order < 0)) {
|
||||
errors.push("order must be a non-negative integer");
|
||||
}
|
||||
if (!ID_PATTERN.test(manifest.suite ?? "")) errors.push("suite must use lowercase kebab-case");
|
||||
if (!Array.isArray(manifest.tags) || manifest.tags.some((tag) => !ID_PATTERN.test(tag))) {
|
||||
errors.push("tags must be an array of lowercase kebab-case strings");
|
||||
} else if (new Set(manifest.tags).size !== manifest.tags.length) {
|
||||
errors.push("tags must not contain duplicates");
|
||||
}
|
||||
if (!isObject(manifest.fixture)) errors.push("fixture must be an object");
|
||||
else if (
|
||||
typeof manifest.fixture.startPath !== "string" ||
|
||||
!manifest.fixture.startPath.startsWith("/")
|
||||
) {
|
||||
errors.push("fixture.startPath must be an absolute URL path");
|
||||
}
|
||||
if (!isObject(manifest.prompts)) errors.push("prompts must be an object");
|
||||
else {
|
||||
checkString(errors, manifest.prompts.en, "prompts.en");
|
||||
checkString(errors, manifest.prompts["zh-CN"], "prompts.zh-CN");
|
||||
}
|
||||
if (!Array.isArray(manifest.coverage)) errors.push("coverage must be an array");
|
||||
else {
|
||||
for (const operation of manifest.coverage) {
|
||||
if (!OPERATION_NAMES.has(operation))
|
||||
errors.push(`coverage contains unknown operation ${operation}`);
|
||||
}
|
||||
if (new Set(manifest.coverage).size !== manifest.coverage.length) {
|
||||
errors.push("coverage must not contain duplicates");
|
||||
}
|
||||
}
|
||||
if (!isObject(manifest.assertions)) errors.push("assertions must be an object");
|
||||
else {
|
||||
for (const kind of ["site", "response", "adapter"]) {
|
||||
if (!Array.isArray(manifest.assertions[kind])) {
|
||||
errors.push(`assertions.${kind} must be an array`);
|
||||
} else {
|
||||
manifest.assertions[kind].forEach((assertion, index) =>
|
||||
validateAssertion(errors, assertion, `assertions.${kind}[${index}]`, kind),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isObject(manifest.smoke) || !Array.isArray(manifest.smoke.steps)) {
|
||||
errors.push("smoke.steps must be an array");
|
||||
} else {
|
||||
manifest.smoke.steps.forEach((step, index) =>
|
||||
validateWorkflowStep(errors, step, `smoke.steps[${index}]`),
|
||||
);
|
||||
}
|
||||
if (manifest.seed !== undefined && !Number.isSafeInteger(manifest.seed)) {
|
||||
errors.push("seed must be a safe integer");
|
||||
}
|
||||
if (manifest.source !== undefined && !isObject(manifest.source)) {
|
||||
errors.push("source must be an object");
|
||||
}
|
||||
return errors.map((error) => `${source}: ${error}`);
|
||||
}
|
||||
|
||||
function normalizeCase(manifest, sourceFile) {
|
||||
return Object.freeze({
|
||||
...manifest,
|
||||
startPath: manifest.fixture.startPath,
|
||||
siteAssertions: manifest.assertions.site,
|
||||
responseAssertions: manifest.assertions.response,
|
||||
adapterAssertions: manifest.assertions.adapter,
|
||||
smokeSteps: manifest.smoke.steps,
|
||||
sourceFile,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadCaseManifests({ casesDirectory = defaultCasesDirectory } = {}) {
|
||||
const files = await discoverFiles(casesDirectory, (path) => path.endsWith(".case.json"));
|
||||
const cases = [];
|
||||
const ids = new Map();
|
||||
const errors = [];
|
||||
for (const file of files) {
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(await readFile(file, "utf8"));
|
||||
} catch (error) {
|
||||
errors.push(`${file}: invalid JSON: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
const manifestErrors = validateCaseManifest(manifest, { source: file });
|
||||
errors.push(...manifestErrors);
|
||||
if (typeof manifest?.id === "string") {
|
||||
if (ids.has(manifest.id)) {
|
||||
errors.push(`${file}: duplicate id also declared by ${ids.get(manifest.id)}`);
|
||||
} else {
|
||||
ids.set(manifest.id, file);
|
||||
}
|
||||
}
|
||||
if (manifestErrors.length === 0) cases.push(normalizeCase(manifest, file));
|
||||
}
|
||||
if (files.length === 0) errors.push(`${casesDirectory}: no .case.json files found`);
|
||||
if (errors.length > 0) throw new Error(`case validation failed:\n${errors.join("\n")}`);
|
||||
cases.sort(
|
||||
(left, right) =>
|
||||
left.suite.localeCompare(right.suite) ||
|
||||
(left.order ?? Number.MAX_SAFE_INTEGER) - (right.order ?? Number.MAX_SAFE_INTEGER) ||
|
||||
left.id.localeCompare(right.id),
|
||||
);
|
||||
return Object.freeze(cases);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { OPERATION_CATALOG, WORKFLOW_ACTION_OPERATIONS } from "./operations.mjs";
|
||||
|
||||
export function buildOperationCoverage(cases) {
|
||||
return OPERATION_CATALOG.map(({ operation, manualNote }) => {
|
||||
const agentCases = cases
|
||||
.filter((testCase) => testCase.coverage.includes(operation))
|
||||
.map(({ id }) => id);
|
||||
let smokeCases;
|
||||
if (["session.start", "session.stop", "session.list"].includes(operation)) {
|
||||
smokeCases = cases.filter(({ smokeSteps }) => smokeSteps.length > 0).map(({ id }) => id);
|
||||
} else {
|
||||
smokeCases = cases
|
||||
.filter(({ smokeSteps }) =>
|
||||
smokeSteps.some((step) => WORKFLOW_ACTION_OPERATIONS[step.action] === operation),
|
||||
)
|
||||
.map(({ id }) => id);
|
||||
}
|
||||
return {
|
||||
operation,
|
||||
agentCases,
|
||||
smokeCases,
|
||||
directSmoke: smokeCases.length > 0,
|
||||
note: smokeCases.length === 0 && manualNote ? `manual lane: ${manualNote}` : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
export async function discoverFiles(root, predicate) {
|
||||
const files = [];
|
||||
|
||||
async function visit(directory) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return;
|
||||
throw error;
|
||||
}
|
||||
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) await visit(path);
|
||||
else if (entry.isFile() && predicate(path)) files.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
await visit(root);
|
||||
return files;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { discoverFiles } from "./discovery.mjs";
|
||||
import { casesDirectory, fixturesDirectory } from "./paths.mjs";
|
||||
|
||||
export async function loadFixtureRegistry({ roots = [fixturesDirectory, casesDirectory] } = {}) {
|
||||
const files = (
|
||||
await Promise.all(
|
||||
roots.map((root) => discoverFiles(root, (path) => path.endsWith(".fixture.mjs"))),
|
||||
)
|
||||
).flat();
|
||||
const routes = new Map();
|
||||
const definitions = [];
|
||||
const errors = [];
|
||||
|
||||
for (const file of files.sort()) {
|
||||
let definition;
|
||||
try {
|
||||
definition = (await import(pathToFileURL(file).href)).default;
|
||||
} catch (error) {
|
||||
errors.push(`${file}: could not import fixture: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
if (!definition || typeof definition.id !== "string") {
|
||||
errors.push(`${file}: default export must define id`);
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(definition.routes) || definition.routes.length === 0) {
|
||||
errors.push(`${file}: default export must define non-empty routes`);
|
||||
continue;
|
||||
}
|
||||
if (typeof definition.render !== "function") {
|
||||
errors.push(`${file}: default export must define render(context)`);
|
||||
continue;
|
||||
}
|
||||
definitions.push({ ...definition, sourceFile: file });
|
||||
for (const route of definition.routes) {
|
||||
if (typeof route !== "string" || !route.startsWith("/")) {
|
||||
errors.push(`${file}: invalid route ${JSON.stringify(route)}`);
|
||||
} else if (routes.has(route)) {
|
||||
errors.push(
|
||||
`${file}: route ${route} is already registered by ${routes.get(route).sourceFile}`,
|
||||
);
|
||||
} else {
|
||||
routes.set(route, { definition, sourceFile: file });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) throw new Error(`fixture validation failed:\n${errors.join("\n")}`);
|
||||
return Object.freeze({
|
||||
definitions: Object.freeze(definitions),
|
||||
routes: Object.freeze([...routes.keys()].sort()),
|
||||
hasRoute(pathname) {
|
||||
return routes.has(pathname);
|
||||
},
|
||||
render(pathname, context) {
|
||||
const entry = routes.get(pathname);
|
||||
return entry ? entry.definition.render({ ...context, pathname }) : null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function validateCaseFixtureLinks(cases, fixtureRegistry) {
|
||||
return cases
|
||||
.filter(({ startPath }) => !fixtureRegistry.hasRoute(startPath))
|
||||
.map(({ id, startPath }) => `case ${id} references unregistered fixture route ${startPath}`);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
const stylesheet = `
|
||||
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }
|
||||
body { margin: 0; background: #f4f7fb; color: #182033; }
|
||||
main { width: min(760px, calc(100% - 40px)); margin: 48px auto; }
|
||||
.card { background: white; border: 1px solid #d9e1ef; border-radius: 14px; padding: 24px; box-shadow: 0 8px 28px #20304a12; }
|
||||
h1 { margin-top: 0; }
|
||||
a, button { font: inherit; }
|
||||
button, .button { display: inline-block; border: 0; border-radius: 8px; padding: 10px 16px; background: #3157d5; color: white; cursor: pointer; text-decoration: none; }
|
||||
label { display: block; margin: 16px 0; font-weight: 600; }
|
||||
input, textarea, select { display: block; box-sizing: border-box; width: 100%; margin-top: 6px; border: 1px solid #9caac2; border-radius: 7px; padding: 9px; font: inherit; }
|
||||
textarea { min-height: 90px; }
|
||||
.marker { border-left: 4px solid #3157d5; padding: 8px 12px; background: #eef2ff; font-family: ui-monospace, monospace; }
|
||||
.muted { color: #5c667a; }
|
||||
#account-menu { position: relative; display: inline-block; padding-bottom: 12px; }
|
||||
#hover-panel { display: none; position: absolute; z-index: 2; top: 34px; left: 0; width: 190px; padding: 10px; background: white; border: 1px solid #9caac2; border-radius: 8px; box-shadow: 0 8px 24px #20304a22; }
|
||||
#account-menu:hover #hover-panel, #account-menu.open #hover-panel { display: block; }
|
||||
#hover-panel button { width: 100%; }
|
||||
#inspect-card { margin-top: 80px; }
|
||||
code { font-family: ui-monospace, monospace; }
|
||||
`;
|
||||
|
||||
const clientScript = `
|
||||
(() => {
|
||||
const runId = new URLSearchParams(location.search).get("run");
|
||||
const navigation = performance.getEntriesByType("navigation")[0];
|
||||
const send = (type, data = {}) => {
|
||||
if (!runId) return Promise.resolve();
|
||||
return fetch("/api/events", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ runId, type, path: location.pathname, data }),
|
||||
keepalive: true,
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
window.browserEval = { runId, send };
|
||||
window.addEventListener("pageshow", (event) => {
|
||||
send("page.shown", {
|
||||
path: location.pathname,
|
||||
persisted: event.persisted,
|
||||
navigationType: navigation?.type ?? "unknown",
|
||||
});
|
||||
});
|
||||
})();
|
||||
`;
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
export function withRun(path, runId, extra = {}) {
|
||||
const url = new URL(path, "http://browser-eval.local");
|
||||
url.searchParams.set("run", runId);
|
||||
for (const [name, value] of Object.entries(extra)) {
|
||||
if (value !== undefined) url.searchParams.set(name, String(value));
|
||||
}
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
export function page({ title, body, script = "" }) {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>${stylesheet}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>${body}</main>
|
||||
<script>${clientScript}</script>
|
||||
${script ? `<script>${script}</script>` : ""}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
export const OPERATION_CATALOG = [
|
||||
{ operation: "session.start" },
|
||||
{ operation: "session.stop" },
|
||||
{ operation: "session.list" },
|
||||
{ operation: "page.navigate" },
|
||||
{ operation: "page.back" },
|
||||
{ operation: "page.forward" },
|
||||
{ operation: "page.reload" },
|
||||
{ operation: "page.wait" },
|
||||
{ operation: "inspect.observe" },
|
||||
{ operation: "inspect.snapshot" },
|
||||
{ operation: "inspect.html" },
|
||||
{ operation: "inspect.screenshot" },
|
||||
{ operation: "inspect.console" },
|
||||
{ operation: "inspect.network" },
|
||||
{ operation: "interact.click" },
|
||||
{ operation: "interact.hover" },
|
||||
{ operation: "interact.fill" },
|
||||
{ operation: "interact.select" },
|
||||
{ operation: "interact.press" },
|
||||
{ operation: "tabs.list" },
|
||||
{ operation: "tabs.create" },
|
||||
{ operation: "tabs.select" },
|
||||
{ operation: "tabs.close" },
|
||||
{
|
||||
operation: "tabs.borrow",
|
||||
manualNote: "requires an existing user tab and ownership confirmation",
|
||||
},
|
||||
{ operation: "tabs.return", manualNote: "paired with borrowing a real user tab" },
|
||||
{ operation: "assist.resize" },
|
||||
{ operation: "assist.emulate" },
|
||||
{ operation: "assist.request-help", manualNote: "intentionally waits for human interaction" },
|
||||
];
|
||||
|
||||
export const WORKFLOW_ACTION_OPERATIONS = {
|
||||
navigate: "page.navigate",
|
||||
back: "page.back",
|
||||
forward: "page.forward",
|
||||
reload: "page.reload",
|
||||
wait: "page.wait",
|
||||
observe: "inspect.observe",
|
||||
snapshot: "inspect.snapshot",
|
||||
html: "inspect.html",
|
||||
screenshot: "inspect.screenshot",
|
||||
console: "inspect.console",
|
||||
network: "inspect.network",
|
||||
click: "interact.click",
|
||||
hover: "interact.hover",
|
||||
fill: "interact.fill",
|
||||
select: "interact.select",
|
||||
press: "interact.press",
|
||||
"tab-list": "tabs.list",
|
||||
"tab-create": "tabs.create",
|
||||
"tab-select": "tabs.select",
|
||||
"tab-close": "tabs.close",
|
||||
borrow: "tabs.borrow",
|
||||
return: "tabs.return",
|
||||
resize: "assist.resize",
|
||||
emulate: "assist.emulate",
|
||||
"request-help": "assist.request-help",
|
||||
};
|
||||
|
||||
export const WORKFLOW_ACTIONS = new Set([
|
||||
...Object.keys(WORKFLOW_ACTION_OPERATIONS),
|
||||
"wait-site-event",
|
||||
]);
|
||||
|
||||
export const OPERATION_NAMES = new Set(OPERATION_CATALOG.map(({ operation }) => operation));
|
||||
@@ -0,0 +1,63 @@
|
||||
function readPath(value, path) {
|
||||
return path.split(".").reduce((current, part) => current?.[part], value);
|
||||
}
|
||||
|
||||
function eventMatches(event, assertion) {
|
||||
if (event.type !== assertion.type) return false;
|
||||
return Object.entries(assertion.where ?? {}).every(
|
||||
([path, expected]) => readPath(event, path) === expected,
|
||||
);
|
||||
}
|
||||
|
||||
export function verifyTask(task, { events = [], responseText = "", adapterEvidence = {} } = {}) {
|
||||
const site = task.siteAssertions.map((assertion) => {
|
||||
const actual = events.filter((event) => eventMatches(event, assertion)).length;
|
||||
return {
|
||||
kind: "site",
|
||||
label: assertion.label,
|
||||
status: actual >= assertion.minCount ? "passed" : "failed",
|
||||
expected: `at least ${assertion.minCount}`,
|
||||
actual,
|
||||
};
|
||||
});
|
||||
|
||||
const response = task.responseAssertions.map((assertion) => ({
|
||||
kind: "response",
|
||||
label: assertion.label,
|
||||
status: responseText.includes(assertion.includes) ? "passed" : "failed",
|
||||
expected: `response includes ${JSON.stringify(assertion.includes)}`,
|
||||
}));
|
||||
|
||||
const adapter = task.adapterAssertions.map((assertion) => {
|
||||
if (!Object.hasOwn(adapterEvidence, assertion.key)) {
|
||||
return {
|
||||
kind: "adapter",
|
||||
label: assertion.label,
|
||||
status: "unverified",
|
||||
expected: assertion.key,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "adapter",
|
||||
label: assertion.label,
|
||||
status: adapterEvidence[assertion.key] ? "passed" : "failed",
|
||||
expected: assertion.key,
|
||||
actual: adapterEvidence[assertion.key],
|
||||
};
|
||||
});
|
||||
|
||||
const checks = [...site, ...response, ...adapter];
|
||||
const failed = checks.filter(({ status }) => status === "failed").length;
|
||||
const unverified = checks.filter(({ status }) => status === "unverified").length;
|
||||
return {
|
||||
taskId: task.id,
|
||||
status: failed > 0 ? "failed" : unverified > 0 ? "passed-with-unverified" : "passed",
|
||||
summary: {
|
||||
passed: checks.length - failed - unverified,
|
||||
failed,
|
||||
unverified,
|
||||
total: checks.length,
|
||||
},
|
||||
checks,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const evalDirectory = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
export const casesDirectory = join(evalDirectory, "cases");
|
||||
export const fixturesDirectory = join(evalDirectory, "fixtures");
|
||||
export const schemasDirectory = join(evalDirectory, "schemas");
|
||||
export const resultsDirectory = join(evalDirectory, "results");
|
||||
@@ -0,0 +1,71 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
function appendCaptured(current, chunk) {
|
||||
if (current.length >= MAX_CAPTURE_BYTES) return current;
|
||||
return `${current}${chunk}`.slice(0, MAX_CAPTURE_BYTES);
|
||||
}
|
||||
|
||||
export function runProcess(command, args, { cwd, env, timeoutMs = 300_000 } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const startedAt = Date.now();
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
let settled = false;
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...env },
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout = appendCaptured(stdout, chunk.toString());
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr = appendCaptured(stderr, chunk.toString());
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => child.kill("SIGKILL"), 2_000).unref();
|
||||
}, timeoutMs);
|
||||
|
||||
const finish = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
command,
|
||||
args,
|
||||
stdout,
|
||||
stderr,
|
||||
timedOut,
|
||||
durationMs: Date.now() - startedAt,
|
||||
...result,
|
||||
});
|
||||
};
|
||||
|
||||
child.once("error", (error) => finish({ exitCode: null, signal: null, error: error.message }));
|
||||
child.once("close", (exitCode, signal) => finish({ exitCode, signal }));
|
||||
});
|
||||
}
|
||||
|
||||
export function substituteArgs(args, values) {
|
||||
return args.map((argument) => {
|
||||
let result = argument;
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
result = result.replaceAll(`{${key}}`, String(value));
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export function countPattern(text, pattern) {
|
||||
if (!pattern) return null;
|
||||
const matches = text.match(new RegExp(pattern, "g"));
|
||||
return matches?.length ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { relative, sep } from "node:path";
|
||||
|
||||
import { validateCaseFixtureLinks } from "./fixture-registry.mjs";
|
||||
import { casesDirectory } from "./paths.mjs";
|
||||
|
||||
function templateRoots(value) {
|
||||
if (typeof value === "string") {
|
||||
return [...value.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1].split(".")[0]);
|
||||
}
|
||||
if (Array.isArray(value)) return value.flatMap(templateRoots);
|
||||
if (value && typeof value === "object") return Object.values(value).flatMap(templateRoots);
|
||||
return [];
|
||||
}
|
||||
|
||||
export function validateRepositoryCases(cases, fixtureRegistry) {
|
||||
const errors = [...validateCaseFixtureLinks(cases, fixtureRegistry)];
|
||||
for (const testCase of cases) {
|
||||
const relativePath = relative(casesDirectory, testCase.sourceFile);
|
||||
const suiteDirectory = relativePath.split(sep)[0];
|
||||
if (suiteDirectory !== testCase.suite) {
|
||||
errors.push(
|
||||
`case ${testCase.id} declares suite ${testCase.suite} but is stored under ${suiteDirectory}`,
|
||||
);
|
||||
}
|
||||
|
||||
const availableVariables = new Set(["baseUrl", "runId", "seed", "sessionId", "url"]);
|
||||
const evidence = new Set(["sessionStopped"]);
|
||||
for (const [index, step] of testCase.smokeSteps.entries()) {
|
||||
for (const root of templateRoots(step)) {
|
||||
if (!availableVariables.has(root)) {
|
||||
errors.push(
|
||||
`case ${testCase.id} smoke.steps[${index}] references unavailable variable ${root}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (step.saveAs) availableVariables.add(step.saveAs);
|
||||
if (step.evidence) evidence.add(step.evidence);
|
||||
}
|
||||
for (const assertion of testCase.adapterAssertions) {
|
||||
if (!evidence.has(assertion.key)) {
|
||||
errors.push(
|
||||
`case ${testCase.id} expects adapter evidence ${assertion.key} but no smoke step produces it`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function repositorySummary(cases, fixtureRegistry) {
|
||||
const suites = Object.fromEntries(
|
||||
[...new Set(cases.map(({ suite }) => suite))]
|
||||
.sort()
|
||||
.map((suite) => [suite, cases.filter((testCase) => testCase.suite === suite).length]),
|
||||
);
|
||||
const tags = [...new Set(cases.flatMap(({ tags }) => tags))].sort();
|
||||
return {
|
||||
cases: cases.length,
|
||||
suites,
|
||||
tags: tags.length,
|
||||
fixtureModules: fixtureRegistry.definitions.length,
|
||||
fixtureRoutes: fixtureRegistry.routes.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { mkdir, realpath, stat, writeFile } from "node:fs/promises";
|
||||
import { join, relative, sep } from "node:path";
|
||||
|
||||
import { casesDirectory, evalDirectory } from "./paths.mjs";
|
||||
|
||||
const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
async function exists(path) {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function moduleSpecifier(fromDirectory, target) {
|
||||
const path = relative(await realpath(fromDirectory), await realpath(target))
|
||||
.split(sep)
|
||||
.join("/");
|
||||
return path.startsWith(".") ? path : `./${path}`;
|
||||
}
|
||||
|
||||
export async function scaffoldCase({
|
||||
id,
|
||||
title = id,
|
||||
suite = "regression",
|
||||
source,
|
||||
rootCasesDirectory = casesDirectory,
|
||||
}) {
|
||||
if (!ID_PATTERN.test(id ?? "")) throw new Error("case id must use lowercase kebab-case");
|
||||
if (!ID_PATTERN.test(suite ?? "")) throw new Error("suite must use lowercase kebab-case");
|
||||
const directory = join(rootCasesDirectory, suite, id);
|
||||
if (await exists(directory)) throw new Error(`case directory already exists: ${directory}`);
|
||||
await mkdir(directory, { recursive: true });
|
||||
|
||||
const marker = `CASE-${id.toUpperCase().replaceAll("-", "_")}`;
|
||||
const startPath = `/cases/${id}`;
|
||||
const manifest = {
|
||||
$schema: "../../../schemas/case.schema.json",
|
||||
schemaVersion: 1,
|
||||
id,
|
||||
title,
|
||||
order: 10,
|
||||
suite,
|
||||
tags: ["badcase", "regression"],
|
||||
...(source ? { source: { type: "user-badcase", reference: source } } : {}),
|
||||
fixture: { startPath },
|
||||
prompts: {
|
||||
en: `Open {url}, complete the minimal regression scenario, report ${marker}, and close the browser session.`,
|
||||
"zh-CN": `打开 {url},完成最小回归场景,报告 ${marker},最后关闭浏览器会话。`,
|
||||
},
|
||||
coverage: ["session.start", "session.stop", "page.navigate", "inspect.observe"],
|
||||
assertions: {
|
||||
site: [
|
||||
{
|
||||
label: "regression fixture was displayed",
|
||||
type: "page.shown",
|
||||
where: { "data.path": startPath },
|
||||
minCount: 1,
|
||||
},
|
||||
],
|
||||
response: [{ label: "reported regression marker", includes: marker }],
|
||||
adapter: [{ label: "browser session was closed", key: "sessionStopped" }],
|
||||
},
|
||||
smoke: { steps: [{ action: "navigate" }, { action: "observe" }] },
|
||||
};
|
||||
|
||||
const fixtureHelper = await moduleSpecifier(
|
||||
directory,
|
||||
join(evalDirectory, "lib", "fixtures.mjs"),
|
||||
);
|
||||
const fixture = `import { escapeHtml, page } from ${JSON.stringify(fixtureHelper)};
|
||||
|
||||
const title = ${JSON.stringify(title)};
|
||||
|
||||
export default {
|
||||
id: ${JSON.stringify(id)},
|
||||
routes: [${JSON.stringify(startPath)}],
|
||||
render() {
|
||||
return page({
|
||||
title,
|
||||
body: \`<section class="card"><h1>\${escapeHtml(title)}</h1><p class="marker">${marker}</p><p>Replace this page with a privacy-safe minimal reproduction.</p></section>\`,
|
||||
});
|
||||
},
|
||||
};
|
||||
`;
|
||||
const readme = `# ${title}
|
||||
|
||||
## Original symptom
|
||||
|
||||
Describe what the user observed without including private data or proprietary page content.
|
||||
|
||||
## Minimal reproduction
|
||||
|
||||
Explain the smallest DOM, timing, or browser-state condition that reproduces the failure.
|
||||
|
||||
## Expected behavior
|
||||
|
||||
Describe the observable success condition and update the case assertions accordingly.
|
||||
|
||||
## Regression history
|
||||
|
||||
- Source: ${source ?? "add issue, PR, or anonymized user report reference"}
|
||||
- Fix: add the fixing PR when available
|
||||
`;
|
||||
|
||||
const files = {
|
||||
manifest: join(directory, `${id}.case.json`),
|
||||
fixture: join(directory, `${id}.fixture.mjs`),
|
||||
readme: join(directory, "README.md"),
|
||||
};
|
||||
await Promise.all([
|
||||
writeFile(files.manifest, `${JSON.stringify(manifest, null, 2)}\n`, { flag: "wx" }),
|
||||
writeFile(files.fixture, fixture, { flag: "wx" }),
|
||||
writeFile(files.readme, readme, { flag: "wx" }),
|
||||
]);
|
||||
return { directory, files };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
function hashSeed(value) {
|
||||
const text = String(value);
|
||||
let hash = 2166136261;
|
||||
for (const character of text) {
|
||||
hash ^= character.codePointAt(0);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function mulberry32(seed) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state += 0x6d2b79f5;
|
||||
let value = state;
|
||||
value = Math.imul(value ^ (value >>> 15), value | 1);
|
||||
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSeed(value) {
|
||||
const numeric = Number(value);
|
||||
return Number.isSafeInteger(numeric) ? numeric : hashSeed(value);
|
||||
}
|
||||
|
||||
export function describeSeed(value) {
|
||||
const seed = normalizeSeed(value);
|
||||
const random = mulberry32(seed);
|
||||
const choose = (values) => values[Math.floor(random() * values.length)];
|
||||
return {
|
||||
seed,
|
||||
labelMode: choose(["wrapped", "for", "aria"]),
|
||||
nestingDepth: 1 + Math.floor(random() * 4),
|
||||
hydrationDelayMs: choose([0, 40, 120, 250]),
|
||||
decoy: choose(["none", "disabled-input", "duplicate-text"]),
|
||||
fieldOrder: choose(["text-first", "select-first"]),
|
||||
idSuffix: hashSeed(`${seed}:id`).toString(36).slice(0, 6),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { createServer } from "node:http";
|
||||
|
||||
import { loadFixtureRegistry } from "./fixture-registry.mjs";
|
||||
|
||||
const RUN_ID_PATTERN = /^[A-Za-z0-9._-]{1,120}$/;
|
||||
|
||||
function validRunId(value) {
|
||||
return typeof value === "string" && RUN_ID_PATTERN.test(value);
|
||||
}
|
||||
|
||||
function json(response, status, value) {
|
||||
response.writeHead(status, {
|
||||
"cache-control": "no-store",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
});
|
||||
response.end(`${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function html(response, status, value) {
|
||||
response.writeHead(status, {
|
||||
"cache-control": "no-store",
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
});
|
||||
response.end(value);
|
||||
}
|
||||
|
||||
async function readJson(request) {
|
||||
let raw = "";
|
||||
for await (const chunk of request) {
|
||||
raw += chunk;
|
||||
if (raw.length > 64 * 1024) {
|
||||
throw new Error("request body exceeds 64 KiB");
|
||||
}
|
||||
}
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
}
|
||||
|
||||
export function createEvalServer({ host = "127.0.0.1", port = 0, fixtureRegistry } = {}) {
|
||||
const runs = new Map();
|
||||
let sequence = 0;
|
||||
let server;
|
||||
let registry = fixtureRegistry;
|
||||
|
||||
function eventsFor(runId) {
|
||||
if (!runs.has(runId)) runs.set(runId, []);
|
||||
return runs.get(runId);
|
||||
}
|
||||
|
||||
function record(runId, type, data = {}, metadata = {}) {
|
||||
if (!validRunId(runId)) return null;
|
||||
const event = {
|
||||
sequence: ++sequence,
|
||||
timestamp: new Date().toISOString(),
|
||||
type,
|
||||
data,
|
||||
...metadata,
|
||||
};
|
||||
eventsFor(runId).push(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
function snapshot(runId) {
|
||||
return { runId, events: [...(runs.get(runId) ?? [])] };
|
||||
}
|
||||
|
||||
function reset(runId) {
|
||||
runs.set(runId, []);
|
||||
}
|
||||
|
||||
async function handle(request, response) {
|
||||
const origin = `http://${request.headers.host ?? `${host}:${port}`}`;
|
||||
const url = new URL(request.url ?? "/", origin);
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/healthz") {
|
||||
return json(response, 200, { ok: true });
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/api/events") {
|
||||
const body = await readJson(request);
|
||||
if (!validRunId(body.runId) || typeof body.type !== "string") {
|
||||
return json(response, 400, { error: "runId and type are required" });
|
||||
}
|
||||
const event = record(body.runId, body.type, body.data ?? {}, {
|
||||
path: typeof body.path === "string" ? body.path : undefined,
|
||||
});
|
||||
return json(response, 202, { accepted: true, sequence: event.sequence });
|
||||
}
|
||||
|
||||
const runMatch = url.pathname.match(/^\/api\/runs\/([^/]+)$/);
|
||||
if (runMatch) {
|
||||
const runId = decodeURIComponent(runMatch[1]);
|
||||
if (!validRunId(runId)) return json(response, 400, { error: "invalid run id" });
|
||||
if (request.method === "GET") return json(response, 200, snapshot(runId));
|
||||
if (request.method === "DELETE") {
|
||||
reset(runId);
|
||||
return json(response, 200, { runId, reset: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/api/ping") {
|
||||
const runId = url.searchParams.get("run");
|
||||
if (!validRunId(runId)) return json(response, 400, { error: "invalid run id" });
|
||||
record(runId, "network.ping", { token: "NETWORK-73" }, { path: url.pathname });
|
||||
return json(response, 200, { ok: true, token: "NETWORK-73" });
|
||||
}
|
||||
|
||||
if (request.method !== "GET") return json(response, 405, { error: "method not allowed" });
|
||||
|
||||
const runId = url.searchParams.get("run") ?? "manual";
|
||||
if (!validRunId(runId)) return json(response, 400, { error: "invalid run id" });
|
||||
|
||||
const fixture = registry.render(url.pathname, {
|
||||
runId,
|
||||
query: url.searchParams,
|
||||
record: (type, data, metadata) => record(runId, type, data, metadata),
|
||||
});
|
||||
if (!fixture) return html(response, 404, "<!doctype html><h1>Not found</h1>");
|
||||
record(runId, "page.request", { path: url.pathname }, { path: url.pathname });
|
||||
return html(response, 200, fixture);
|
||||
}
|
||||
|
||||
return {
|
||||
record,
|
||||
reset,
|
||||
snapshot,
|
||||
async start() {
|
||||
if (server) throw new Error("eval server is already running");
|
||||
registry ??= await loadFixtureRegistry();
|
||||
server = createServer((request, response) => {
|
||||
handle(request, response).catch((error) => {
|
||||
json(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, host, resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("unexpected server address");
|
||||
return { host, port: address.port, baseUrl: `http://${host}:${address.port}` };
|
||||
},
|
||||
async stop() {
|
||||
if (!server) return;
|
||||
const active = server;
|
||||
server = undefined;
|
||||
await new Promise((resolve, reject) => {
|
||||
active.close((error) => (error ? reject(error) : resolve()));
|
||||
active.closeAllConnections?.();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
function durationFor(result) {
|
||||
if (result.execution?.durationMs !== undefined) return result.execution.durationMs;
|
||||
return (result.steps ?? []).reduce((sum, step) => sum + step.durationMs, 0);
|
||||
}
|
||||
|
||||
export function summarizeReports(reports) {
|
||||
const groups = new Map();
|
||||
for (const report of reports) {
|
||||
for (const result of report.results ?? []) {
|
||||
const adapter = result.adapter ?? report.adapter ?? "unknown";
|
||||
const variant = result.variant ?? report.variant ?? "unspecified";
|
||||
const key = `${adapter}\u0000${variant}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
adapter,
|
||||
variant,
|
||||
runs: 0,
|
||||
passed: 0,
|
||||
fullyVerified: 0,
|
||||
executionFailures: 0,
|
||||
errors: 0,
|
||||
durationMs: 0,
|
||||
toolCalls: 0,
|
||||
toolCallSamples: 0,
|
||||
});
|
||||
}
|
||||
const group = groups.get(key);
|
||||
group.runs += 1;
|
||||
group.durationMs += durationFor(result);
|
||||
group.errors += result.metrics?.errorCount ?? (result.executionError ? 1 : 0);
|
||||
if (result.metrics?.toolCallCount !== null && result.metrics?.toolCallCount !== undefined) {
|
||||
group.toolCalls += result.metrics.toolCallCount;
|
||||
group.toolCallSamples += 1;
|
||||
}
|
||||
const executionFailed =
|
||||
Boolean(result.executionError) ||
|
||||
result.execution?.timedOut ||
|
||||
(result.execution?.exitCode !== undefined && result.execution.exitCode !== 0);
|
||||
if (executionFailed) group.executionFailures += 1;
|
||||
if (result.verification?.status !== "failed" && !executionFailed) group.passed += 1;
|
||||
if (result.verification?.status === "passed" && !executionFailed) group.fullyVerified += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return [...groups.values()].map((group) => ({
|
||||
adapter: group.adapter,
|
||||
variant: group.variant,
|
||||
runs: group.runs,
|
||||
passRate: group.runs ? group.passed / group.runs : 0,
|
||||
fullyVerifiedRate: group.runs ? group.fullyVerified / group.runs : 0,
|
||||
executionFailures: group.executionFailures,
|
||||
errors: group.errors,
|
||||
averageToolCalls: group.toolCallSamples
|
||||
? Math.round((group.toolCalls / group.toolCallSamples) * 10) / 10
|
||||
: null,
|
||||
averageDurationMs: group.runs ? Math.round(group.durationMs / group.runs) : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export function printSummary(rows) {
|
||||
const printable = rows.map((row) => ({
|
||||
adapter: row.adapter,
|
||||
variant: row.variant,
|
||||
runs: row.runs,
|
||||
pass: `${Math.round(row.passRate * 100)}%`,
|
||||
verified: `${Math.round(row.fullyVerifiedRate * 100)}%`,
|
||||
exec_failures: row.executionFailures,
|
||||
errors: row.errors,
|
||||
avg_tools: row.averageToolCalls ?? "-",
|
||||
avg_ms: row.averageDurationMs,
|
||||
}));
|
||||
console.table(printable);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { loadCaseManifests } from "./case-loader.mjs";
|
||||
|
||||
let casesPromise;
|
||||
|
||||
export function allTasks({ reload = false } = {}) {
|
||||
if (reload || !casesPromise) casesPromise = loadCaseManifests();
|
||||
return casesPromise;
|
||||
}
|
||||
|
||||
export function listTasks(cases) {
|
||||
return cases.map(({ id, title, suite, tags, seed }) => ({ id, title, suite, tags, seed }));
|
||||
}
|
||||
|
||||
export function getTask(taskId, cases) {
|
||||
const task = cases.find(({ id }) => id === taskId);
|
||||
if (!task) {
|
||||
const ids = cases.map(({ id }) => id).join(", ");
|
||||
throw new Error(`unknown case ${JSON.stringify(taskId)}; expected one of: ${ids}`);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
export function taskUrl(task, baseUrl, runId, seed = task.seed) {
|
||||
const url = new URL(task.startPath, baseUrl);
|
||||
url.searchParams.set("run", runId);
|
||||
if (seed !== undefined) url.searchParams.set("seed", String(seed));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function expandTemplate(template, values) {
|
||||
return Object.entries(values).reduce(
|
||||
(result, [name, value]) => result.replaceAll(`{${name}}`, String(value)),
|
||||
template,
|
||||
);
|
||||
}
|
||||
|
||||
export function renderPrompt(task, { baseUrl, runId, locale = "zh-CN", seed = task.seed }) {
|
||||
const template = task.prompts[locale];
|
||||
if (!template) {
|
||||
throw new Error(`unsupported locale ${JSON.stringify(locale)}; expected en or zh-CN`);
|
||||
}
|
||||
return expandTemplate(template, {
|
||||
baseUrl,
|
||||
runId: encodeURIComponent(runId),
|
||||
seed: seed ?? "",
|
||||
url: taskUrl(task, baseUrl, runId, seed),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://browser-skill.local/schemas/browser-eval-case.schema.json",
|
||||
"title": "BrowserSkill browser evaluation case",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"id",
|
||||
"title",
|
||||
"suite",
|
||||
"tags",
|
||||
"fixture",
|
||||
"prompts",
|
||||
"coverage",
|
||||
"assertions",
|
||||
"smoke"
|
||||
],
|
||||
"properties": {
|
||||
"$schema": { "type": "string" },
|
||||
"schemaVersion": { "const": 1 },
|
||||
"id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
|
||||
"title": { "type": "string", "minLength": 1 },
|
||||
"order": { "type": "integer", "minimum": 0 },
|
||||
"suite": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
|
||||
"uniqueItems": true
|
||||
},
|
||||
"fixture": {
|
||||
"type": "object",
|
||||
"required": ["startPath"],
|
||||
"properties": { "startPath": { "type": "string", "pattern": "^/" } },
|
||||
"additionalProperties": false
|
||||
},
|
||||
"prompts": {
|
||||
"type": "object",
|
||||
"required": ["en", "zh-CN"],
|
||||
"properties": {
|
||||
"en": { "type": "string", "minLength": 1 },
|
||||
"zh-CN": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"coverage": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
|
||||
"assertions": {
|
||||
"type": "object",
|
||||
"required": ["site", "response", "adapter"],
|
||||
"properties": {
|
||||
"site": { "type": "array", "items": { "type": "object" } },
|
||||
"response": { "type": "array", "items": { "type": "object" } },
|
||||
"adapter": { "type": "array", "items": { "type": "object" } }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"smoke": {
|
||||
"type": "object",
|
||||
"required": ["steps"],
|
||||
"properties": {
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["action"],
|
||||
"properties": { "action": { "type": "string" } },
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"seed": { "type": "integer" },
|
||||
"source": { "type": "object" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { runCommandTask } from "../lib/agent-runner.mjs";
|
||||
import { loadCaseManifests, validateCaseManifest } from "../lib/case-loader.mjs";
|
||||
import { buildOperationCoverage } from "../lib/coverage.mjs";
|
||||
import { loadFixtureRegistry } from "../lib/fixture-registry.mjs";
|
||||
import { verifyTask } from "../lib/oracle.mjs";
|
||||
import { runProcess } from "../lib/process.mjs";
|
||||
import { repositorySummary, validateRepositoryCases } from "../lib/repository-validation.mjs";
|
||||
import { scaffoldCase } from "../lib/scaffold-case.mjs";
|
||||
import { describeSeed } from "../lib/seeded-generator.mjs";
|
||||
import { createEvalServer } from "../lib/server.mjs";
|
||||
import { summarizeReports } from "../lib/summary.mjs";
|
||||
import { allTasks, getTask, renderPrompt } from "../lib/tasks.mjs";
|
||||
|
||||
const cases = await allTasks();
|
||||
const fixtureRegistry = await loadFixtureRegistry();
|
||||
|
||||
async function withServer(run) {
|
||||
const server = createEvalServer({ fixtureRegistry });
|
||||
const info = await server.start();
|
||||
try {
|
||||
await run(server, info);
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
}
|
||||
|
||||
test("case manifests are discovered, ordered, and grouped into suites", () => {
|
||||
assert.deepEqual(
|
||||
cases.map(({ id }) => id),
|
||||
[
|
||||
"navigation-history",
|
||||
"form-controls",
|
||||
"hover-inspect",
|
||||
"tabs",
|
||||
"diagnostics",
|
||||
"mobile-emulation",
|
||||
"generated-form",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(repositorySummary(cases, fixtureRegistry).suites, { core: 6, matrix: 1 });
|
||||
});
|
||||
|
||||
test("repository validation links every case to a fixture and valid workflow evidence", () => {
|
||||
assert.deepEqual(validateRepositoryCases(cases, fixtureRegistry), []);
|
||||
const summary = repositorySummary(cases, fixtureRegistry);
|
||||
assert.equal(summary.cases, 7);
|
||||
assert.equal(summary.fixtureModules, 8);
|
||||
assert.equal(summary.fixtureRoutes, 12);
|
||||
});
|
||||
|
||||
test("manifest validation rejects unknown operations and incomplete workflow steps", () => {
|
||||
const invalid = {
|
||||
schemaVersion: 1,
|
||||
id: "invalid-case",
|
||||
title: "Invalid",
|
||||
suite: "regression",
|
||||
tags: ["regression"],
|
||||
fixture: { startPath: "/invalid" },
|
||||
prompts: { en: "Open {url}", "zh-CN": "打开 {url}" },
|
||||
coverage: ["page.teleport"],
|
||||
assertions: { site: [], response: [], adapter: [] },
|
||||
smoke: { steps: [{ action: "select", selector: "#choice", values: [] }] },
|
||||
typo: true,
|
||||
};
|
||||
const errors = validateCaseManifest(invalid);
|
||||
assert.ok(errors.some((error) => error.includes("unknown top-level property typo")));
|
||||
assert.ok(errors.some((error) => error.includes("unknown operation page.teleport")));
|
||||
assert.ok(errors.some((error) => error.includes("values must be a non-empty array")));
|
||||
});
|
||||
|
||||
test("fixture server exposes deterministic task and seeded matrix markers", async () => {
|
||||
await withServer(async (_server, { baseUrl }) => {
|
||||
const fixtures = [
|
||||
["/navigation/detail?run=test-markers", "NAV-42"],
|
||||
["/form?run=test-markers", '<option value="two">Two</option>'],
|
||||
["/hover-inspect?run=test-markers", "INSPECT-27"],
|
||||
["/tabs/child?run=test-markers", "TAB-29"],
|
||||
["/diagnostics?run=test-markers", "CONSOLE-61"],
|
||||
["/responsive?run=test-markers", "VIEWPORT-84"],
|
||||
["/matrix/generated-form?run=test-markers&seed=17", "SEED-17"],
|
||||
];
|
||||
for (const [path, marker] of fixtures) {
|
||||
const response = await fetch(`${baseUrl}${path}`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(await response.text(), new RegExp(marker));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("server records and resets run-scoped events", async () => {
|
||||
await withServer(async (server, { baseUrl }) => {
|
||||
const response = await fetch(`${baseUrl}/api/events`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ runId: "event-test", type: "example", data: { value: 7 } }),
|
||||
});
|
||||
assert.equal(response.status, 202);
|
||||
assert.equal(server.snapshot("event-test").events[0].data.value, 7);
|
||||
const resetResponse = await fetch(`${baseUrl}/api/runs/event-test`, { method: "DELETE" });
|
||||
assert.equal(resetResponse.status, 200);
|
||||
assert.deepEqual(server.snapshot("event-test").events, []);
|
||||
});
|
||||
});
|
||||
|
||||
test("form case oracle checks submitted values and separates adapter evidence", () => {
|
||||
const task = getTask("form-controls", cases);
|
||||
const events = [
|
||||
{ type: "form.enter_pressed", data: {} },
|
||||
{
|
||||
type: "form.submitted",
|
||||
data: { text: "agent-parity", notes: "BrowserSkill works", choice: "two" },
|
||||
},
|
||||
{ type: "page.shown", data: { path: "/result" } },
|
||||
];
|
||||
const partial = verifyTask(task, { events, responseText: "The page says Received!" });
|
||||
assert.equal(partial.status, "passed-with-unverified");
|
||||
assert.equal(partial.summary.unverified, 2);
|
||||
|
||||
const complete = verifyTask(task, {
|
||||
events,
|
||||
responseText: "The page says Received!",
|
||||
adapterEvidence: { screenshotCreated: true, sessionStopped: true },
|
||||
});
|
||||
assert.equal(complete.status, "passed");
|
||||
});
|
||||
|
||||
test("navigation case fails when reload is missing", () => {
|
||||
const task = getTask("navigation-history", cases);
|
||||
const verification = verifyTask(task, {
|
||||
events: [
|
||||
{ type: "navigation.detail.clicked", data: {} },
|
||||
{ type: "page.shown", data: { path: "/navigation/start" } },
|
||||
{ type: "page.shown", data: { path: "/navigation/start" } },
|
||||
{ type: "page.shown", data: { path: "/navigation/detail" } },
|
||||
{ type: "page.shown", data: { path: "/navigation/detail" } },
|
||||
],
|
||||
responseText: "NAV-42",
|
||||
});
|
||||
assert.equal(verification.status, "failed");
|
||||
assert.equal(
|
||||
verification.checks.find(({ label }) => label === "detail page was reloaded").status,
|
||||
"failed",
|
||||
);
|
||||
});
|
||||
|
||||
test("prompt rendering is run-scoped, localized, and seed-aware", () => {
|
||||
const task = getTask("generated-form", cases);
|
||||
const input = { baseUrl: "http://127.0.0.1:4173", runId: "prompt-test", seed: 17 };
|
||||
assert.match(renderPrompt(task, { ...input, locale: "en" }), /seed=17/);
|
||||
assert.match(renderPrompt(task, { ...input, locale: "zh-CN" }), /seeded form/);
|
||||
});
|
||||
|
||||
test("command adapter carries a run from process launch through oracle verification", async () => {
|
||||
await withServer(async (server, serverInfo) => {
|
||||
const script = `
|
||||
const [baseUrl, runId] = process.argv.slice(1);
|
||||
const send = (type, data) => fetch(baseUrl + "/api/events", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ runId, type, data }),
|
||||
});
|
||||
await send("navigation.detail.clicked", {});
|
||||
await send("page.shown", { path: "/navigation/start" });
|
||||
await send("page.shown", { path: "/navigation/start" });
|
||||
await send("page.shown", { path: "/navigation/detail" });
|
||||
await send("page.shown", { path: "/navigation/detail" });
|
||||
await send("page.shown", { path: "/navigation/detail", navigationType: "reload" });
|
||||
console.log("NAV-42");
|
||||
`;
|
||||
const result = await runCommandTask({
|
||||
server,
|
||||
serverInfo,
|
||||
task: getTask("navigation-history", cases),
|
||||
adapter: {
|
||||
command: process.execPath,
|
||||
args: ["--input-type=module", "-e", script, "{baseUrl}", "{runId}"],
|
||||
variant: "fake-test",
|
||||
},
|
||||
adapterName: "fake-agent",
|
||||
configDirectory: process.cwd(),
|
||||
iteration: 1,
|
||||
locale: "en",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
assert.equal(result.execution.exitCode, 0);
|
||||
assert.equal(result.verification.status, "passed-with-unverified");
|
||||
assert.equal(result.eventCount, 6);
|
||||
});
|
||||
});
|
||||
|
||||
test("report summary keeps partial verification distinct from full verification", () => {
|
||||
const rows = summarizeReports([
|
||||
{
|
||||
results: [
|
||||
{
|
||||
adapter: "agent-a",
|
||||
variant: "granular-28",
|
||||
execution: { exitCode: 0, durationMs: 100 },
|
||||
metrics: { errorCount: 0, toolCallCount: 4 },
|
||||
verification: { status: "passed-with-unverified" },
|
||||
},
|
||||
{
|
||||
adapter: "agent-a",
|
||||
variant: "granular-28",
|
||||
execution: { exitCode: 0, durationMs: 300 },
|
||||
metrics: { errorCount: 1, toolCallCount: 6 },
|
||||
verification: { status: "passed" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.equal(rows[0].passRate, 1);
|
||||
assert.equal(rows[0].fullyVerifiedRate, 0.5);
|
||||
assert.equal(rows[0].averageDurationMs, 200);
|
||||
assert.equal(rows[0].averageToolCalls, 5);
|
||||
assert.equal(rows[0].errors, 1);
|
||||
});
|
||||
|
||||
test("coverage inventory contains all 28 operations and three manual lanes", () => {
|
||||
const coverage = buildOperationCoverage(cases);
|
||||
const operations = coverage.map(({ operation }) => operation);
|
||||
assert.equal(operations.length, 28);
|
||||
assert.equal(new Set(operations).size, 28);
|
||||
assert.deepEqual(
|
||||
coverage.filter(({ directSmoke }) => !directSmoke).map(({ operation }) => operation),
|
||||
["tabs.borrow", "tabs.return", "assist.request-help"],
|
||||
);
|
||||
assert.deepEqual(coverage.find(({ operation }) => operation === "interact.fill").smokeCases, [
|
||||
"form-controls",
|
||||
"generated-form",
|
||||
]);
|
||||
});
|
||||
|
||||
test("seeded dimensions are reproducible and vary across seeds", () => {
|
||||
assert.deepEqual(describeSeed("badcase-17"), describeSeed("badcase-17"));
|
||||
assert.notDeepEqual(describeSeed("badcase-17"), describeSeed("badcase-18"));
|
||||
assert.equal(describeSeed(17).seed, 17);
|
||||
});
|
||||
|
||||
test("badcase scaffold creates a loadable, escaped, self-contained case", async () => {
|
||||
const temporaryRoot = await mkdtemp(join(tmpdir(), "browser-eval-scaffold-"));
|
||||
try {
|
||||
const title = "Quoted `title` ${notCode} <unsafe>";
|
||||
const result = await scaffoldCase({
|
||||
id: "reported-timeout",
|
||||
title,
|
||||
suite: "regression",
|
||||
source: "issue-123",
|
||||
rootCasesDirectory: temporaryRoot,
|
||||
});
|
||||
const loaded = await loadCaseManifests({ casesDirectory: temporaryRoot });
|
||||
assert.equal(loaded[0].id, "reported-timeout");
|
||||
assert.equal(loaded[0].source.reference, "issue-123");
|
||||
|
||||
const generatedRegistry = await loadFixtureRegistry({ roots: [temporaryRoot] });
|
||||
const html = generatedRegistry.render("/cases/reported-timeout", {
|
||||
runId: "scaffold-test",
|
||||
query: new URLSearchParams(),
|
||||
record() {},
|
||||
});
|
||||
assert.match(html, /Quoted `title` \$\{notCode\} <unsafe>/);
|
||||
assert.match(await readFile(result.files.readme, "utf8"), /issue-123/);
|
||||
} finally {
|
||||
await rm(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("CLI accepts pnpm separator and supports suite and tag filters", async () => {
|
||||
const cli = resolve("evals/browser/cli.mjs");
|
||||
const coverageResult = await runProcess(process.execPath, [cli, "--", "coverage", "--json"], {
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
assert.equal(coverageResult.exitCode, 0, coverageResult.stderr);
|
||||
assert.equal(JSON.parse(coverageResult.stdout).length, 28);
|
||||
|
||||
const coreResult = await runProcess(
|
||||
process.execPath,
|
||||
[cli, "list", "--suite", "core", "--json"],
|
||||
{
|
||||
timeoutMs: 5_000,
|
||||
},
|
||||
);
|
||||
assert.equal(coreResult.exitCode, 0, coreResult.stderr);
|
||||
assert.equal(JSON.parse(coreResult.stdout).length, 6);
|
||||
|
||||
const formResult = await runProcess(process.execPath, [cli, "list", "--tag", "form", "--json"], {
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
assert.equal(formResult.exitCode, 0, formResult.stderr);
|
||||
assert.deepEqual(
|
||||
JSON.parse(formResult.stdout).map(({ id }) => id),
|
||||
["form-controls", "generated-form"],
|
||||
);
|
||||
});
|
||||
|
||||
test("CLI validation and seed generation are machine-readable", async () => {
|
||||
const cli = resolve("evals/browser/cli.mjs");
|
||||
const validation = await runProcess(process.execPath, [cli, "validate", "--json"], {
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
assert.equal(validation.exitCode, 0, validation.stderr);
|
||||
assert.equal(JSON.parse(validation.stdout).ok, true);
|
||||
|
||||
const generated = await runProcess(
|
||||
process.execPath,
|
||||
[cli, "generate", "--seed", "known-badcase", "--json"],
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
assert.equal(generated.exitCode, 0, generated.stderr);
|
||||
assert.equal(typeof JSON.parse(generated.stdout).hydrationDelayMs, "number");
|
||||
|
||||
const generatedSet = await runProcess(
|
||||
process.execPath,
|
||||
[cli, "generate", "--seed", "4,7,14", "--json"],
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
assert.equal(generatedSet.exitCode, 0, generatedSet.stderr);
|
||||
assert.deepEqual(
|
||||
JSON.parse(generatedSet.stdout).map(({ seed }) => seed),
|
||||
[4, 7, 14],
|
||||
);
|
||||
});
|
||||
@@ -4,6 +4,9 @@
|
||||
"packageManager": "pnpm@10.17.0",
|
||||
"scripts": {
|
||||
"cli:build": "cargo run -p bsk-protocol --bin dump-schema --locked && pnpm -r build",
|
||||
"eval:browser": "node evals/browser/cli.mjs",
|
||||
"eval:browser:check": "node evals/browser/cli.mjs validate && node --test evals/browser/tests/*.test.mjs",
|
||||
"eval:browser:test": "node --test evals/browser/tests/*.test.mjs",
|
||||
"ext:build": "pnpm --filter @browser-skill/extension build",
|
||||
"ext:build:zip": "pnpm --filter @browser-skill/extension zip",
|
||||
"ext:dev": "pnpm --filter @browser-skill/extension dev",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user