Compare commits

..
Author SHA1 Message Date
Ljy-0827 435a6bb9be feat(vom): canvas design and fixtures 2026-09-08 16:49:43 +08:00
Zhang GH 3c5f838c56 Merge pull request #192 from Tencent/fix/windows-update-reliability
fix(update): make Windows self-updates reliable
2026-09-07 20:37:32 +08:00
iuyo5678 9337bd557b test(update): use blocking connections in Windows release server 2026-09-07 20:27:04 +08:00
iuyo5678 162a31ae92 fix(update): make Windows self-updates reliable 2026-09-07 20:04:15 +08:00
Zhang GH 2f84234a31 Merge pull request #191 from Tencent/fix/windows-ipc-process-liveness
fix(windows): preserve pipe continuity and verify process liveness
2026-09-07 19:06:38 +08:00
iuyo5678 72c10aab75 fix(windows): preserve pipe continuity and verify process liveness 2026-09-07 18:25:32 +08:00
Zhang GH 1eeb55af02 Merge pull request #190 from Tencent/fix/dsh-observation-sync-and-capture
fix(dsh): synchronize observation state and manage thumbnail capture
2026-09-07 17:46:31 +08:00
drakezhang 4992a54864 docs(dsh): clarify observation subscription and snapshot contracts 2026-09-07 17:38:35 +08:00
drakezhang 8b195eb0df test(extension): return tab id from navigation recovery mock 2026-09-07 17:26:01 +08:00
drakezhang eae4db1729 Merge main for navigation recovery test compatibility 2026-09-07 17:25:22 +08:00
drakezhang e961395be9 fix(dsh): synchronize observation state and manage thumbnail capture 2026-09-07 16:24:04 +08:00
Zhang GH a6d1d02acc Merge pull request #179 from Tencent/fix/cdp-extension-frame-recovery
fix: recover navigation from cross-extension CDP restrictions
2026-09-07 16:05:46 +08:00
Zhang GH 5ebe609c40 Merge pull request #189 from Tencent/fix/verify-fill-before-success
fix(extension): verify fill results before reporting success
2026-09-07 15:59:21 +08:00
drakezhang 321494d93f test(fill): cover caret positioning and clarify append behavior 2026-09-07 15:56:13 +08:00
drakezhang f2184e74c5 fix(fill): support background input and automatic append 2026-09-07 15:28:21 +08:00
drakezhang eeb633d68e fix(extension): verify fill results before reporting success 2026-09-07 13:25:54 +08:00
Zhang GH 6d181105f2 Merge pull request #188 from Tencent/fix/match-form-state-by-element
fix(extension): match form state by element identity
2026-09-07 12:57:42 +08:00
drakezhang ebad0ed1b5 fix(extension): match form state by element identity 2026-09-07 12:21:01 +08:00
Zhang GH 498f8fb4a7 Merge pull request #187 from Tencent/fix/release-automation-state-on-tab-return
Fix/release automation state on tab return
2026-09-07 12:12:41 +08:00
drakezhang cef59137e4 fix(extension): handle tab return cleanup edge cases 2026-09-07 11:44:11 +08:00
drakezhang f70978bfcf fix(extension): release automation state when returning borrowed tabs 2026-09-07 11:30:01 +08:00
Zhang GH 1498cd425c Merge pull request #186 from Tencent/fix/closed-borrowed-tabs-block-session-stop
fix(extension): prevent closed borrowed tabs from blocking session stop
2026-09-07 11:19:12 +08:00
drakezhang e831d65db3 fix(extension): prevent closed borrowed tabs from blocking session stop 2026-09-07 11:12:44 +08:00
Zhang GH 47ac947f18 Merge pull request #174 from iAstro/fix/extension-locale-normalization
fix(i18n): match browser locale by language subtag instead of exact code
2026-09-04 17:29:31 +08:00
Zhang GH 69d64c4eb1 Merge pull request #176 from Tencent/fix/windows-update-detach
fix(bsk): fix Windows self-update failing to replace binary
2026-09-04 17:11:31 +08:00
Zhang GH 99c997d6d5 Merge pull request #66 from iAstro/fix/issue-57-user-tab-freedom
fix(extension): keep user-opened tabs free & fix agent-tab leak on session stop (issue #57)
2026-09-04 17:10:15 +08:00
iAstro 93853ebc65 refactor(i18n): drive locale normalisation from shipped resource keys
Per review feedback on #174: the hardcoded if/else in
normalizeLanguageCode required touching the function for every new
language, so a bare `ko` would miss the `ko-KR` resource #161 is about
to add and Korean users would land on English despite the fix.

Replace it with createLanguageNormalizer(resourceKeys), a factory that
derives its mapping from the resource keys i18next is configured with:

  1. exact resource key              -> unchanged
  2. one resource for the language   -> that key (ko -> ko-KR)
  3. several (zh-CN / zh-TW)         -> script subtag, then region
  4. nothing shipped                 -> unchanged, fallbackLng applies

getLanguageDetectionOptions now takes the resource keys and i18n.ts
passes Object.keys(resources). Chinese disambiguation stays as the only
special case - one language, two scripts; every other mapping derives
from the index automatically.

Test expectations for the zh-TW family change from "zh-TW" to "zh-CN":
only zh-CN ships today and the factory maps to shipped keys only, so
Traditional resolves directly to Simplified instead of leaking into the
fallback chain. The rendered UI is identical either way, and the new
extensibility cases lock in the automatic disambiguation once a zh-TW
bundle registers - plus the auto-resolution of a newly registered
language like ko-KR.

28 -> 31 tests; extension suite 832/832; tsc, biome and ext:build clean.
2026-09-04 15:42:40 +08:00
drakezhang 248bb90a2d test(extension): return home tab ids from mocks 2026-09-04 14:37:04 +08:00
drakezhang 6e6c178434 Merge remote-tracking branch 'origin/main' into fix/issue-57-explicit-tab-claims 2026-09-04 14:31:48 +08:00
drakezhang 386ef91c24 fix(extension): preserve existing tab scope semantics 2026-09-04 13:14:26 +08:00
drakezhang 0dc11b30ea fix(extension): claim agent tabs explicitly 2026-09-04 11:17:05 +08:00
iAstro 759eebd4b6 fix(i18n): match browser locale by language subtag instead of exact code
The extension ships en-US and zh-CN resources, but only en-US was ever
selected: every other browser language fell through to fallbackLng
("zh-CN"), so users on en, en-GB, en-CN, en-AU, ja-JP, fr-FR saw Chinese.

Match on the BCP 47 language subtag and normalise it to a resource key:

  en*                                -> en-US
  zh-Hans* / zh-CN / zh-SG / zh      -> zh-CN
  zh-Hant* / zh-TW / zh-HK / zh-MO   -> zh-TW
  anything else                      -> unchanged (fallbackLng default: en-US)

Simplified vs Traditional is decided by the script subtag (Hans/Hant) when
present, then by the region subtag, since Chrome reports zh-CN / zh-TW
rather than script subtags. The zh-TW branch is written now so adding a
Traditional translation later needs no code change.

chrome.i18n.getUILanguage() is now preferred over navigator.language, which
is unreliable on the chrome-extension:// origin the popup runs on.

Adds a locale-resolution regression matrix (28 cases).

Closes #168
2026-09-04 10:03:43 +08:00
drakezhang fd3ce202a1 fix: recover navigation from cross-extension CDP restrictions
Classify Chrome extension-frame access failures and provide an actionable CLI hint. Recover navigate and reload only when their initial CDP setup is denied, using browser navigation events and verifying that CDP access returns.

Preserve normal navigation, Agent Window checks, transfer error details, and network-idle semantics. Never replay an already-dispatched navigation.
2026-09-03 18:21:21 +08:00
drakezhangandCursor 618bd6a0df fix(bsk): fix Windows self-update failing to replace binary
Two issues prevented the detached update helper from completing:

1. Only CREATE_NO_WINDOW was set — the child cmd.exe process was still
   attached to the parent console session and got killed when the
   terminal closed. Add CREATE_NEW_PROCESS_GROUP to fully detach it.

2. `timeout /t 1 /nobreak` requires a console and fails silently in a
   no-window context, causing the retry loop to spin with no delay.
   Replace with `ping -n 2 127.0.0.1` which works in any environment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 12:24:53 +08:00
Zhang GH c455a09fc3 Merge pull request #163 from dayi/fix/recording-http-random-uuid
fix(extension): support UUID generation on insecure origins
2026-09-03 10:46:12 +08:00
Dean Wang b6be356e40 test(extension): cover UUID fallback during recording startup 2026-09-03 07:40:57 +08:00
Zhang GH 945bf1523d Merge pull request #173 from Tencent/release/0.2.0
Release/0.2.0
2026-09-02 19:00:21 +08:00
drakezhangandCursor a928a9fb76 test: update popup version display assertions for CLI/Ext labels
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 18:32:33 +08:00
drakezhangandCursor 0b2d7e95cc style: format release script for biome
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 18:26:35 +08:00
drakezhangandCursor 556bea1645 chore(release): 0.2.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 18:17:56 +08:00
drakezhangandCursor 0302d5592b chore: add release script, changelog, and version display labels
- scripts/release.mjs: unified version bump + tag + push automation
- CHANGELOG.md: document all changes from 0.1.x through 0.2.0
- popup: show "CLI {ver} / Ext {ver}" instead of bare version numbers

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 18:05:32 +08:00
Hannah Liu eb34ec0dbf Merge pull request #124 from Tencent/feat/expand-upload-download
feat(bsk): support drop to upload
2026-09-02 17:56:55 +08:00
drakezhangandCursor 43067b6bfe style: remove extra blank lines in file-transfer test
Fix biome formatter error on CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 17:52:57 +08:00
drakezhangandCursor 6cc0d38489 docs(skill): compress CLI SKILL.md with file transfer guidance
Restore the lean style from PR #167 while integrating essential file
transfer documentation for upload/download. Reduces the skill from
362 lines / 26k chars to 180 lines / 9.2k chars.

A/B eval (24 browser runs, Composer 2.5 Fast, core suite) confirms
no pass-rate regression: candidate 12/12 vs baseline 10/12 (baseline
failures were a network blip and a behavioral variance, not skill
quality).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 17:43:58 +08:00
drakezhangandCursor 0b71963344 merge: resolve conflicts with updated feat/upload-download base
Keep feat/expand-upload-download (drop mode superset) over the base
branch version for all 12 conflicting files. The drop-to-upload branch
already includes all upload/download functionality plus the new --mode
drop mechanism, overlay suppression, OOPIF coordinate handling, and
expanded error reasons.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 17:03:35 +08:00
Hannah Liu 59e2c3224e Merge pull request #123 from Tencent/feat/upload-download
feat(bsk): add secure, session-scoped file upload and download | 支持安全、会话级的文件上传与下载
2026-09-02 16:47:19 +08:00
drakezhangandCursor d70df753f6 merge: resolve SKILL.md conflicts with main
Keep the expanded command-reference tables from feat/upload-download
(including the new File Transfer section) over main's compact two-line
format.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 16:42:15 +08:00
Zhang GH 66ae555e40 Merge pull request #172 from Tencent/fix/pip-close-and-obs-mediatype
fix(dsh-plugin): PiP window close button & observation thumbnail media type detection
2026-09-02 16:25:33 +08:00
drakezhangandCursor fee7c0983c fix: sort import order to satisfy biome organizeImports
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 16:21:26 +08:00
drakezhangandCursor 51473c01ff fix(dsh-plugin): add PiP close button and sniff observation thumbnail media type
The Document PiP window had no close/return entry once popped out —
users could not dismiss it from inside the window. Add an onClosePip
callback and render a close button in the PiP header; clicking it
calls pipWindow.close() and the content returns to its carrier.

The observation thumbnail publisher hardcoded mediaType as "image/png"
regardless of the actual capture bytes. Reuse the existing
sniffImageMediaType helper (exported from image.ts) so the HTTP
thumbnail endpoint declares the correct Content-Type when Chromium
returns JPEG captures.

Closes #153 (Bug 2 fully; Bug 1 was fixed in #135, this covers the
remaining observation-thumbnail path).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 16:14:24 +08:00
Zhang GH 49744233f7 Merge pull request #171 from Tencent/fix/borrow-confirmation-ux
fix(borrow): improve confirmation UX — proactive focus and longer tim…
2026-09-02 15:23:12 +08:00
drakezhangandCursor 95571f8b70 fix(borrow): improve confirmation UX — proactive focus and longer timeout
The borrow confirmation overlay was easily missed because the Agent
Window stole focus and the 5-second auto-deny was too aggressive for
users who had context-switched away.

- Focus the user window proactively when a borrow request is issued so
  the overlay is immediately visible even when the Agent Window was
  last focused.
- Raise CONFIRMATION_TIMEOUT_MS from 5 s to 60 s, giving users enough
  time to notice and respond — the OS notification with
  requireInteraction: true now has a matching window of usefulness.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:11:40 +08:00
Zhang GH 8344e311fa Merge pull request #169 from Tencent/perf/vom-hover-probe-opt-in
Perf/vom hover probe opt in
2026-09-02 14:27:29 +08:00
drakezhangandCursor 63338cd98e Merge branch 'main' into perf/vom-hover-probe-opt-in
main rewrote SKILL.md into a leaner core (#167) while this branch documented
the opt-in --probe-hover flag against the old structure. Keep main's rewritten
prose and re-apply the probe-hover guidance on top of it: the [has-submenu] /
[expanded] markers, when reaching for --probe-hover is warranted, and its slot
in the page-reading escalation ladder ahead of get-html/screenshot.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 12:18:06 +08:00
Zhang GH bd193ceb5d Merge pull request #167 from Tencent/refactor/lean-browser-skill
Refactor/lean browser skill
2026-09-02 12:09:42 +08:00
Zhang GH 5ed828736f Merge pull request #165 from Tencent/perf/vom-algorithmic-optimizations
perf(vom): optimize rendering algorithms
2026-09-02 12:09:22 +08:00
drakezhang ee620acf64 docs(skill): give probe-hover a decidable trigger condition
The previous wording asked the agent to notice when a page "looks like"
it hides content behind hover. That is not something an agent reading a
text tree can judge: hover-only content is invisible by definition, and
the marker that used to point at it came from the probing that is now off
by default.

Replaces it with a condition the agent can actually evaluate — an
expected control is missing and no [has-submenu] marker points at a
trigger — and places it on the escalation ladder ahead of get-html and
screenshot, which is where that dead end is currently reached.

Also documents [has-submenu]/[expanded] as the primary hover signal. It
comes from aria-haspopup in the accessibility tree, costs nothing, and is
unaffected by probing being off, so it covers menus that are marked up
correctly.
2026-09-02 10:59:18 +08:00
drakezhang 05a36075b8 perf(vom): make active hover probing opt-in on observe
Every tool.observe moved the real cursor across the page to look for
hover-only menus and tooltips. Measured against real Chrome, that cost a
median of 2.5s and up to 4.7s per observation — 73% to 99% of total
observe wall clock — and returned 3 hits across 12 pages. A positive
control confirms the probes work; the yield on real pages simply does not
justify putting them on the default path.

observe now probes only when asked, via `probe_hover` (`--probe-hover`).
Agents that know which element hides a menu should keep using
`bsk hover <ref>`, which is both cheaper and precise. A held hover latch
still suppresses probing, since probing would move the cursor off the
element the caller is deliberately holding.

Correctness fixes that apply whether or not probing is requested:

- Hover probing ran between the DOM snapshot and the accessibility tree
  fetch, so a hover that opened a menu left one half of an observation
  describing the page before the change and the other half after it. Both
  chains now run after all capture is complete.
- The overlay bypass toggled once per chain and swallowed restore
  failures, which could leave the agent overlay hidden for the rest of
  the tab's life. It is now reference counted across both chains, and a
  failed restore is reported instead of discarded.
- Observations report whether they hovered the page and whether that
  revealed content the static tree lacks, so callers can judge how far
  the live page may have drifted from the returned snapshot.

Probe budgets were checked at the top of the loop, so a candidate could
start with 1ms remaining and still run a full settle window, overshooting
by up to 634ms. Candidates are now admitted only if they fit. Raising
MAX_HOVER_PROBE_MS to 2600 is part of that fix rather than a relaxation:
2000 was never the real ceiling, and enforcing it literally would have
silently dropped a candidate the positive control depends on.

Tooltip misses are cached too, with a shorter TTL than hits, so repeat
observations of the same page no longer re-probe every unlabelled control
from scratch.
2026-09-02 10:59:18 +08:00
Ljy-0827 1bd13d5c3c fix(bsk): fix upload download racing 2026-09-02 10:56:02 +08:00
drakezhangandCursor 59d6533eff docs(skill): trim agent instructions to a leaner core
The CLI skill had grown to 20.8 KB of command tables and flag catalogs that
`bsk --help` already answers at runtime, so every agent paid for them on every
task. Rewrite both skills around the durable rules an agent cannot rediscover
-- session lifecycle, the observe/act/observe loop, the Agent Window boundary,
and the stop-on-success contract -- and delegate flag details to the CLI's own
help output. The CLI skill drops to 6.9 KB (-67%) and the DSH skill to 5.1 KB.

Keep the parts that help discovery has no answer for: the literal
`[hover first: ...]` observation marker, the `request-help` outcome values, the
complete command-name inventory, and the required flags agents guess wrong
(`fill --value`, `select --value`, `screenshot --out`, `emulate --device`).
Also bind `--no-focus` explicitly to `bsk session start`, since a bare mention
led agents to pass it to `bsk navigate`.

Validated with 36 agent runs of the `core` eval suite against a real browser,
comparing the old and new instructions with everything else held constant.
Pass rate did not regress (the lean version was better on `form-controls`) and
token cost per task came out even. The inventory and flag signatures cut
`--help` round-trips from 1.7 to 0.7 per task and removed the invented
`bsk back` / `bsk forward` calls the first draft produced.

Tighten the DSH size assertion to a range so the budget is bounded from both
sides and accidental truncation fails the test.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 18:39:19 +08:00
drakezhangandCursor 536d3a9844 chore: ignore local pnpm store and git worktrees
Both directories are local scratch space that kept showing up as untracked
noise in `git status`.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 18:39:06 +08:00
iAstro a77353bce8 fix(extension): classify the home tab by id, not a racy boot flag (issue #57)
SessionManager.classifyNewTab's `initializing` branch relied on a
windowInitializing flag that start() cleared *before* the home tab's
chrome.tabs.onCreated event was dispatched (the event is asynchronous), so
the branch never ran in production and the home tab's classification was
subject to a timing race. Match the home tab by its tab id instead — the id
is already returned by ensureActiveTab and is reliable regardless of event
ordering.

Also refresh the overlay-bridge docstring: overlay.ready now makes the
background *push* the per-tab overlay state (and the Agent Window also
pushes on tab create / control-mode change), rather than a request/reply.
2026-09-01 15:45:36 +08:00
iAstro 91621b2545 fix(extension): release the pending agent-tab slot when tab creation fails
tool.tab_create increments pendingAgentTabCount *before* chrome.tabs.create
so the onCreated listener can tell an agent tab from a user-opened tab. When
the create failed, no onCreated event arrived and the counter leaked, so the
next tab the user opened was misclassified as agent-created — the exact
confusion the counter exists to prevent, a variant of issue #57.

handleTabCreate now releases the slot on failure via
SessionManager.releaseAgentTabPending. An abort (cancelled) is excluded: the
tab did open (and was cleaned up), so its onCreated still consumes the slot.

Added a regression test asserting that after a failed tab_create, a
user-opened tab is still classified as 'user' (not 'agent').
2026-09-01 15:36:44 +08:00
drakezhang 641d934337 perf(vom): optimize rendering algorithms 2026-09-01 14:55:36 +08:00
Ljy-0827 a4fed136e3 fix(extension): keep user-opened tabs free & fix agent-tab leak on session stop (issue #57)
- session_stop: exclude tabs still tracked in agentCreatedTabs from the
  release decision so a tab that failed to close is no longer mistaken for
  a user tab (which previously kept the window open and leaked the tab).
- Distinguish user-created tabs from agent-created tabs in the Agent Window
  via a pending-tab counter consumed by chrome.tabs.onCreated, so tabs the
  user opens themselves (new-tab button / Cmd+T / open in new tab) are never
  taken over by the BrowserSkill control mask.
- background overlay: decide per-tab state BEFORE showing controls
  (overlayStateForTab + pushOverlayStateForTab) so a user tab receives hidden
  on its first overlay.ready ping instead of flashing control then hiding.
- AgentWindowApi.ensureActiveTab now resolves with the activated/created tab
  id instead of void, so SessionManager can record homeTabId. This fixes the
  tsc failure flagged in review (the interface previously returned
  Promise<void> while callers treated the result as number | null).
- Wire the production tabManagement.tabs / tabsQuery deps into
  tool.session_stop, both in the dispatcher and in the disconnect cleanup.
  They were never injected, which left the agent-tab cleanup and the
  window-release path as dead code outside tests (behaviour identical to
  main). Added a dispatcher-level integration test that fails without the
  wiring: a surviving user tab must release the window, not close it.
2026-09-01 11:24:56 +08:00
Dean Wang 3c77ef7b01 fix(extension): support UUID generation on insecure origins 2026-09-01 10:30:40 +08:00
Ljy-0827 3b21f2f0a7 fix(bsk): download bug fix 2026-08-31 17:58:33 +08:00
Ljy-0827 405433ab65 fix(bsk): fix ci 2026-08-31 17:58:33 +08:00
Ljy-0827 58c7f38a05 feat(bsk): harden browser file transfer transactions 2026-08-31 17:58:33 +08:00
Ljy-0827 e0c88295c7 fix(bsk): fix ci 2026-08-31 17:58:33 +08:00
Ljy-0827 dc36e38cb9 fix(bsk): upload function safety 2026-08-31 17:58:33 +08:00
Ljy-0827 d5de9b0154 feat(bsk): fix upload function that cannot bypass layout 2026-08-31 17:58:33 +08:00
Ljy-0827 995061514c feat(bsk): fix different type of upload 2026-08-31 17:58:33 +08:00
Ljy-0827 406bb21b7e feat(bsk): support upload and download 2026-08-31 17:58:33 +08:00
Zhang GH f06a514bd2 Merge pull request #149 from Tencent/fix/protocol-upgrade-reminder
fix:将协议版本差异改为升级提醒
2026-08-31 12:25:29 +08:00
Zhang GH 1e53ee63ae Merge pull request #155 from Tencent/ci/edge-addons-publishing
ci(extension): publish updates to Microsoft Edge Add-ons
2026-08-31 11:24:22 +08:00
Zhang GH 120dc441bb Merge pull request #158 from Tencent/codex/fix-dsh-screenshot-test-name
test(dsh-plugin): use renamed screenshot tool
2026-08-29 16:08:01 +08:00
drakezhang 710b1219f4 test(dsh-plugin): use renamed screenshot tool 2026-08-29 16:05:06 +08:00
Zhang GH 177ecd3ebf Merge pull request #157 from Tencent/refactor/simplify-dsh-commands
Refactor/simplify dsh commands
2026-08-29 15:55:16 +08:00
Zhang GH 0e6b9c4542 Merge pull request #135 from cyhano/fix/screenshot-media-type
fix: use detected media type for screenshots instead of hard-coded image/png
2026-08-29 15:52:43 +08:00
drakezhang 506adbe708 Merge remote-tracking branch 'origin/main' into refactor/simplify-dsh-commands 2026-08-29 15:16:31 +08:00
Zhang GH 984d017b0f Merge pull request #156 from Tencent/fix/unify-dsh-plugin-package-id
fix(dsh-plugin): use the published package name as the Cordis id
2026-08-29 15:12:02 +08:00
drakezhang 5df93143c5 refactor(dsh-plugin): simplify browser commands 2026-08-29 14:38:48 +08:00
Zhang GH 7163f2f9b0 Merge pull request #151 from xiyanjun/docs/evaluate-json-ok
docs(skill): clarify bsk evaluate exit-code vs --json .ok semantics
close #133
2026-08-28 22:06:04 +08:00
drakezhang e9f0584af3 ci(extension): publish updates to Microsoft Edge Add-ons 2026-08-28 21:39:53 +08:00
drakezhangandCursor 2997f495db fix(dsh-plugin): use the published package name as the Cordis id
dsh 0.1.0-rc.8 imports and registers the client bundle by export const name, so a leftover dsh-plugin-browserskill id fails to load the 0.1.1 web UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 21:28:06 +08:00
xiyanjun f96bd72c3c docs(skill): clarify bsk evaluate exit-code vs --json .ok semantics
The exit code reflects RPC success only, not JS success. Document that programmatic callers must use --json and check the .ok field to detect JS errors. Addresses the discoverability gap raised in issue #133.
2026-08-28 18:02:38 +08:00
Zhang GH 329c68054b Merge pull request #150 from Tencent/codex/browser-eval-harness
test: 增加可扩展的浏览器能力回归测试框架
2026-08-28 16:41:00 +08:00
drakezhang 5cd43553ed docs(eval): clarify agent-neutral scope 2026-08-28 15:36:38 +08:00
drakezhang 9ad33801c7 test: add extensible browser evaluation harness 2026-08-28 12:16:39 +08:00
Zhang GH 7aaa5a2d01 Merge pull request #147 from Tencent/fix/dsh-plugin-session-lifecycle
fix(dsh-plugin): stabilize skill loading and browser sessions
2026-08-28 12:11:08 +08:00
drakezhang 210b21b637 style(dsh-plugin): format observation 2026-08-28 12:06:47 +08:00
Hannah Liu 48f64ec0df Merge pull request #122 from Tencent/feat/vom-functional-refactor
feat(vom): refactor semantic structure generation for grouped observations | 重构语义结构生成,支持分组式 VOM 观察
2026-08-28 11:24:34 +08:00
“haonan”andCursor c48a53269e style: rustfmt doctor protocol-version test asserts
cargo fmt --check failed on two long assert lines in PR CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 22:50:01 +08:00
“haonan”andCursor 3bbd6ab19f fix: treat protocol version differences as upgrade reminders
Agents were stopping on popup and status copy that looked like a hard incompatibility. Keep the connection usable, reframe exit 5 as an unknown method, and ask users to upgrade instead of aborting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 22:22:30 +08:00
drakezhang 1a70d2200e fix(dsh-plugin): stabilize skill loading and browser sessions
Register bundled browser instructions at agent scope, prioritize foreground commands over thumbnail capture, and unify cancellation and session cleanup.
2026-08-27 19:59:20 +08:00
Zhang GH 554861d6b1 Merge pull request #131 from Tencent/feat/dsh-browser-tools-parity
feat(dsh-plugin): add Browser tool parity and dedicated Harness skill
2026-08-27 16:29:30 +08:00
Ljy-0827 0d7f976335 fix(vom): fix repeat names 2026-08-26 15:28:27 +08:00
Ljy-0827 d1d3eb6a94 fix(vom): bug fix 2026-08-26 15:28:27 +08:00
Ljy-0827 38fd7d3f4c fix(vom): safety policy and rebase bug fix 2026-08-26 15:28:27 +08:00
Ljy-0827 b5062ac19b feat(vom): fix name lost 2026-08-26 15:28:27 +08:00
Ljy-0827 41cb576256 feat(vom): function refactor 2026-08-26 15:28:27 +08:00
Hannah Liu f060a7c074 Merge pull request #142 from Tencent/chore/release-ext-0.1.7
chore(release): extension 0.1.7
2026-08-26 15:19:33 +08:00
Ljy-0827 60b62d5420 chore(release): extension 0.1.7 2026-08-26 15:12:44 +08:00
Hannah Liu 70b851a3e8 Merge pull request #141 from Tencent/chore/release-bsk-0.1.11
chore(release): bsk 0.1.11
2026-08-26 15:06:30 +08:00
Ljy-0827 da3534d066 chore(release): bsk 0.1.11 2026-08-26 14:59:09 +08:00
BB-fat a004291848 feat(dsh-plugin): better-sidebar 集成、会话关停与归档清理、悬浮窗修复 (#130)
* better-sidebar 集成:observation 视图迁入 better-sidebar 载体,按会话作用域展示 bsk 会话
* 会话关停与归档清理:支持会话 dispose 与归档自动清理
* 悬浮窗修复:悬浮 overlay 拖拽/缩放/PiP 弹出等问题修复
* 修复 Document PiP 空白窗口:载体卸载(关 tab、切会话、插件 HMR)时主动 close() PiP 窗口,避免残留空白窗口
2026-08-25 10:33:46 +08:00
cyhanoandDeepSeek Harness 60fbf30e24 fix: use detected media type for screenshots instead of hard-coded image/png
browser_screenshot declared every capture as image/png regardless of the
actual bytes. On Chromium/Edge builds where captureVisibleTab returns JPEG
bytes, the stored attachment reference recorded mediaType "image/png" against
JPEG bytes, so dsh-attachment-local's read-time metadata verification fails
with ATTACHMENT_CORRUPT ("Stored attachment metadata does not match its
reference"), surfacing as code UNKNOWN and triggering endless LLM retries.

- image.ts: sniff the capture's magic bytes (PNG/JPEG/WebP/GIF) and declare
  that real media type to attachments.saveImage, instead of always "image/png".
- tools.ts: assemble the tool result from ref.mediaType (the store's verified
  type) rather than re-declaring "image/png" as const, and relax the output
  schema's const to the accepted media-type enum.
- tests: add a regression case asserting JPEG capture bytes surface as
  image/jpeg.

Co-Authored-By: DeepSeek Harness <noreply@deepseek.com>
2026-08-24 15:31:34 +08:00
drakezhang a4b6a61d29 docs(dsh-plugin): clarify package description 2026-08-22 21:29:33 +08:00
drakezhang cad0b5cbc9 feat(dsh-plugin): add Browser tool parity 2026-08-22 21:15:53 +08:00
204 changed files with 21575 additions and 2844 deletions
+21
View File
@@ -37,6 +37,27 @@ jobs:
- name: Run Rust tests
run: cargo test --workspace --locked
windows-daemon:
name: Windows daemon and update regression tests
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Run Windows named-pipe tests
run: cargo test -p bsk --lib --locked daemon::ipc::windows::tests
- name: Run Windows process liveness tests
run: cargo test -p bsk --lib --locked daemon::lockfile::tests
- name: Run Windows update tests
run: cargo test -p bsk --lib --locked cli::update::tests
- name: Run Windows update process tests
run: cargo test -p bsk --test windows_update --locked
frontend:
name: Frontend lint, typecheck, tests, build
runs-on: ubuntu-latest
+177
View File
@@ -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"
+8
View File
@@ -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
+92
View File
@@ -0,0 +1,92 @@
# Changelog
All notable changes to BrowserSkill will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/).
Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number.
## [Unreleased]
## [0.2.0] - 2026-09-02
### Added
- File transfer: upload and download support across CLI, Extension, and DSH Plugin
- File transfer: drag-and-drop upload (`drop-to-upload`)
- VOM semantic graph, name enrichment, and hover perception modules
- VOM hover probing (opt-in via `observe` parameter)
- DSH Plugin: browser tool parity with CLI commands
- Edge Add-ons automated publishing in CI
- Protocol upgrade reminder when CLI / Extension protocol versions differ
- Browser evaluation harness (`evals/browser/`)
- Unified release script (`scripts/release.mjs`)
### Changed
- **Version scheme**: all three components now share the same semver
- VOM rendering algorithm optimizations
- Leaner SKILL.md agent instructions
- DSH Plugin: simplified browser commands
- Borrow confirmation UX — proactive focus and longer timeout
- PiP window now has a close button
### Fixed
- Screenshot media type detection (was hard-coded to `image/png`)
- DSH Plugin session lifecycle stability
- DSH Plugin Cordis package ID mismatch
- Observation thumbnail media type sniffing
- Upload/download race conditions and layout bypass issues
- VOM repeated name and safety policy issues
---
*Previous releases used independent version numbers per component.*
## CLI 0.1.11 / Extension 0.1.7 / DSH Plugin 0.1.2 — 2026-08-26 ~ 2026-08-29
### Added
- DSH Plugin sidebar integration, session lifecycle, and archive cleanup
- Recorder iframe and OOPIF support
### Changed
- VOM functional refactor
### Fixed
- Recorder safety policy and bug fixes
## CLI 0.1.10 / Extension 0.1.6 — 2026-08-08
### Added
- VOM observation recording and settled-state detection
- Record overlay timer
### Fixed
- Browser keepalive disconnect handling
## CLI 0.1.9 / Extension 0.1.5 — 2026-07-29
### Added
- CLI auto-update mechanism
- Trace v3 protocol and recorder
### Fixed
- MV3 keepalive disconnect
## CLI 0.1.8 / Extension 0.1.4 — 2026-07-22
### Added
- More browser interaction actions
### Fixed
- Windows named-pipe hash-only path issue
## CLI 0.1.7 / Extension 0.1.3 — 2026-07-07
Initial public release pair.
## CLI 0.1.6 — 2026-06-30
### Fixed
- Minor CLI fixes
## CLI 0.1.5 / Extension 0.1.2 — 2026-06-22
First tagged releases.
Generated
+2 -2
View File
@@ -134,7 +134,7 @@ dependencies = [
[[package]]
name = "bsk"
version = "0.1.10"
version = "0.2.0"
dependencies = [
"anyhow",
"base64",
@@ -169,7 +169,7 @@ dependencies = [
[[package]]
name = "bsk-protocol"
version = "0.1.10"
version = "0.2.0"
dependencies = [
"schemars",
"semver",
+2 -2
View File
@@ -7,7 +7,7 @@ members = [
default-members = ["crates/bsk-cli"]
[workspace.package]
version = "0.1.10"
version = "0.2.0"
edition = "2024"
rust-version = "1.85"
license = "MIT"
@@ -43,7 +43,7 @@ rand = "0.8"
uuid = { version = "1", features = ["v4", "serde"] }
# Workspace crates (version required for `cargo publish` of dependents)
bsk-protocol = { version = "0.1.9", path = "crates/bsk-protocol" }
bsk-protocol = { version = "0.2.0", path = "crates/bsk-protocol" }
[profile.release]
strip = "symbols"
+1
View File
@@ -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
+1
View File
@@ -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 无关的浏览器能力测试台
## 许可证
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@browser-skill/extension",
"version": "0.1.6",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
@@ -254,6 +254,69 @@ describe("ChromiumCdp", () => {
expect(cdp.isAttached(7)).toBe(false);
});
it.each([
false,
true,
])("waits for detach before reconnecting a returned tab (detach rejects: %s)", async (rejectDetach) => {
const { api } = fakeApi();
const attached = new Set<number>();
let finishDetach!: () => void;
vi.mocked(api.attach).mockImplementation(async ({ tabId }) => {
if (attached.has(tabId!)) throw new Error("Another debugger is already attached");
attached.add(tabId!);
});
vi.mocked(api.detach).mockImplementation(async ({ tabId }) => {
attached.delete(tabId!);
});
vi.mocked(api.detach).mockImplementationOnce(
({ tabId }) =>
new Promise<void>((resolve, reject) => {
finishDetach = () => {
attached.delete(tabId!);
if (rejectDetach) reject(new Error("tab already detached"));
else resolve();
};
}),
);
const cdp = new ChromiumCdp(api);
cdp.trackSessionTab("aa11", 7);
await cdp.ensureAttached(7);
const returning = cdp.releaseSessionTab("aa11", 7);
await vi.waitFor(() => expect(api.detach).toHaveBeenCalledOnce());
let duplicateDetachFinished = false;
const duplicateDetach = cdp.detach(7).then(() => {
duplicateDetachFinished = true;
});
cdp.trackSessionTab("bb22", 7);
// Register rejection handlers immediately; the pre-fix driver rejects
// both commands while the browser still has the previous attachment.
const pending = Promise.allSettled([
cdp.send(7, "Runtime.evaluate", { expression: "document.title" }),
cdp.send(7, "DOM.getDocument"),
]);
await cdp.send(8, "DOM.getDocument");
const finishedEarly = duplicateDetachFinished;
finishDetach();
await Promise.all([returning, duplicateDetach]);
expect(await pending).toEqual([
{ status: "fulfilled", value: { ok: true } },
{ status: "fulfilled", value: { ok: true } },
]);
expect(finishedEarly).toBe(false);
expect(vi.mocked(api.attach).mock.calls.filter(([target]) => target.tabId === 7)).toHaveLength(
2,
);
expect(cdp.isAttached(7)).toBe(true);
expect(cdp.isAttached(8)).toBe(true);
await cdp.detachSession("bb22");
expect(api.detach).toHaveBeenCalledTimes(2);
expect(cdp.isAttached(7)).toBe(false);
expect(attached.has(7)).toBe(false);
expect(cdp.isAttached(8)).toBe(true);
});
it("detachAll() iterates every cached tab", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
@@ -651,6 +714,155 @@ describe("ChromiumCdp", () => {
expect(cdp.networkEntriesSince(33, 0, 50, 1000).entries[0].url).toBeUndefined();
});
it("releases only the returning session's claim on a tab", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(1);
await cdp.ensureAttached(2);
cdp.trackSessionTab("aa11", 1);
cdp.trackSessionTab("bb22", 1);
cdp.trackSessionTab("aa11", 2);
await cdp.releaseSessionTab("unknown", 1);
await cdp.releaseSessionTab("aa11", 1);
expect(api.detach).not.toHaveBeenCalled();
await cdp.releaseSessionTab("bb22", 1);
await cdp.releaseSessionTab("bb22", 1);
expect(api.detach).toHaveBeenCalledExactlyOnceWith({ tabId: 1 });
expect(cdp.isAttached(2)).toBe(true);
await cdp.detachSession("aa11");
expect(api.detach).toHaveBeenCalledTimes(2);
expect(api.detach).toHaveBeenLastCalledWith({ tabId: 2 });
});
it.each([
false,
true,
])("releases a tab with an attachment in flight (new owner: %s)", async (newOwner) => {
const { api } = fakeApi();
let finishAttach!: () => void;
vi.mocked(api.attach).mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finishAttach = resolve;
}),
);
const cdp = new ChromiumCdp(api);
cdp.trackSessionTab("aa11", 1);
const attaching = cdp.ensureAttached(1);
const releasing = cdp.releaseSessionTab("aa11", 1);
if (newOwner) cdp.trackSessionTab("bb22", 1);
finishAttach();
await Promise.all([attaching, releasing]);
expect(cdp.isAttached(1)).toBe(newOwner);
expect(api.detach).toHaveBeenCalledTimes(newOwner ? 0 : 1);
});
it("stops accepting dialogs on a returned tab even when another session keeps CDP attached", async () => {
const { api, onEvent } = fakeApi();
let controlled = true;
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog: () => controlled });
cdp.trackSessionTab("aa11", 1);
cdp.trackSessionTab("bb22", 1);
await cdp.ensureAttached(1);
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
type: "confirm",
message: "before return",
});
await vi.waitFor(() => expect(cdp.dialogsSince(1, 0)).toHaveLength(1));
vi.mocked(api.sendCommand).mockClear();
controlled = false;
await cdp.releaseSessionTab("aa11", 1);
expect(cdp.isAttached(1)).toBe(true);
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
type: "confirm",
message: "after return",
});
await Promise.resolve();
expect(api.sendCommand).not.toHaveBeenCalled();
expect(cdp.dialogsSince(1, 0)).toHaveLength(1);
controlled = true;
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
type: "alert",
message: "borrowed again",
});
await vi.waitFor(() => expect(cdp.dialogsSince(1, 0)).toHaveLength(2));
expect(api.sendCommand).toHaveBeenCalledExactlyOnceWith(
{ tabId: 1 },
"Page.handleJavaScriptDialog",
{ accept: true },
);
});
it("ignores dialog events after detach, including a pending eligibility check", async () => {
const { api, onEvent } = fakeApi();
let resolveEligibility!: (value: boolean) => void;
const shouldAutoAcceptDialog = vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveEligibility = resolve;
}),
);
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog });
await cdp.ensureAttached(1);
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
await cdp.detach(1);
resolveEligibility(true);
await Promise.resolve();
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
expect(shouldAutoAcceptDialog).toHaveBeenCalledOnce();
expect(api.sendCommand).not.toHaveBeenCalledWith(
expect.anything(),
"Page.handleJavaScriptDialog",
expect.anything(),
);
expect(cdp.dialogsSince(1, 0)).toEqual([]);
});
it("does not accept dialogs when the tab's current scope cannot be determined", async () => {
const { api, onEvent } = fakeApi();
const shouldAutoAcceptDialog = vi.fn(async () => {
throw new Error("tab is gone");
});
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog });
await cdp.ensureAttached(1);
vi.mocked(api.sendCommand).mockClear();
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "confirm" });
await Promise.resolve();
expect(shouldAutoAcceptDialog).toHaveBeenCalledWith(1);
expect(api.sendCommand).not.toHaveBeenCalled();
expect(cdp.dialogsSince(1, 0)).toEqual([]);
});
it("does not restore dialog state when an acceptance completes after detach", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(1);
let finishAccept!: () => void;
vi.mocked(api.sendCommand).mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finishAccept = resolve;
}),
);
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
await cdp.detach(1);
finishAccept();
await Promise.resolve();
expect(cdp.dialogsSince(1, 0)).toEqual([]);
expect(cdp.dialogCursor(1)).toBe(0);
});
it("detachSession only detaches tabs no other session owns", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
@@ -165,6 +165,7 @@ export class ChromiumCdp {
private readonly api: CdpDebuggerApi;
private readonly attachedTabs = new Set<number>();
private readonly attachInFlight = new Map<number, Promise<void>>();
private readonly detachInFlight = new Map<number, Promise<void>>();
private readonly tabOwners = new Map<number, Set<string>>();
private readonly dialogBuffers = new Map<number, JavaScriptDialogInfo[]>();
private readonly dialogSequences = new Map<number, number>();
@@ -182,7 +183,13 @@ export class ChromiumCdp {
private networkSubscription: { dispose(): void } | null = null;
private frameTargetSubscription: { dispose(): void } | null = null;
constructor(api: CdpDebuggerApi = chromeDebuggerApi) {
constructor(
api: CdpDebuggerApi = chromeDebuggerApi,
private readonly options: {
/** CDP observation alone does not authorize dismissing user dialogs. */
shouldAutoAcceptDialog?: (tabId: number) => boolean | Promise<boolean>;
} = {},
) {
this.api = api;
this.bindAutoDetach();
this.bindDialogHandler();
@@ -193,6 +200,10 @@ export class ChromiumCdp {
/** Attach to `tabId` if we haven't already in this driver. */
async ensureAttached(tabId: number): Promise<void> {
// Returning a tab clears the cache before Chrome finishes detaching.
// New observers must wait before opening the next connection to that tab.
const detaching = this.detachInFlight.get(tabId);
if (detaching) await detaching;
if (this.attachedTabs.has(tabId)) return;
const existing = this.attachInFlight.get(tabId);
if (existing) {
@@ -425,6 +436,11 @@ export class ChromiumCdp {
/** Detach if attached; never throws. */
async detach(tabId: number): Promise<void> {
const existing = this.detachInFlight.get(tabId);
if (existing) {
await existing;
return;
}
this.attachInFlight.delete(tabId);
if (!this.attachedTabs.has(tabId)) return;
this.attachedTabs.delete(tabId);
@@ -432,13 +448,19 @@ export class ChromiumCdp {
this.clearConsoleState(tabId);
this.clearNetworkState(tabId);
this.clearFrameState(tabId);
try {
await this.api.detach({ tabId });
} catch (err) {
// Tab may already be gone — Chrome auto-detaches on close. Log
// at debug so production builds aren't noisy.
console.debug("[bsk cdp] detach failed (likely tab already closed)", err);
}
const detach = (async () => {
try {
await this.api.detach({ tabId });
} catch (err) {
// Tab may already be gone — Chrome auto-detaches on close. Log
// at debug so production builds aren't noisy.
console.debug("[bsk cdp] detach failed (likely tab already closed)", err);
}
})().finally(() => {
this.detachInFlight.delete(tabId);
});
this.detachInFlight.set(tabId, detach);
await detach;
}
/** True iff `ensureAttached(tabId)` has succeeded since the last detach. */
@@ -453,6 +475,17 @@ export class ChromiumCdp {
this.tabOwners.set(tabId, owners);
}
/** Release one session's claim, preserving attachments still used by another. */
async releaseSessionTab(sessionId: string, tabId: number): Promise<void> {
const owners = this.tabOwners.get(tabId);
if (!owners?.delete(sessionId) || owners.size > 0) return;
this.tabOwners.delete(tabId);
// An observation may still be attaching when the tab is returned. Wait
// for it so detach cannot miss the attachment or remove a new owner's claim.
await this.attachInFlight.get(tabId)?.catch(() => {});
if (!this.tabOwners.has(tabId)) await this.detach(tabId);
}
/** Subscribe to all CDP events. Returned disposable removes the listener. */
onEvent(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
dispose(): void;
@@ -639,13 +672,23 @@ export class ChromiumCdp {
}
private async onJavaScriptDialogOpening(tabId: number, params: unknown): Promise<void> {
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
const parsed = parseDialogOpeningParams(params);
try {
if (
this.options.shouldAutoAcceptDialog &&
!(await this.options.shouldAutoAcceptDialog(tabId))
) {
return;
}
// The tab may have been returned while its current scope was checked.
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
const handleParams: { accept: boolean; promptText?: string } = { accept: true };
if (parsed.type === "prompt") {
handleParams.promptText = parsed.defaultPrompt ?? "";
}
await this.api.sendCommand({ tabId }, "Page.handleJavaScriptDialog", handleParams);
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
const sequence = (this.dialogSequences.get(tabId) ?? 0) + 1;
this.dialogSequences.set(tabId, sequence);
this.appendDialog(tabId, {
@@ -835,15 +878,9 @@ export class ChromiumCdp {
/** Detach tabs only when no other live session has claimed them. */
async detachSession(sessionId: string): Promise<void> {
const tabsToDetach: number[] = [];
for (const [tabId, owners] of this.tabOwners) {
owners.delete(sessionId);
if (owners.size === 0) {
this.tabOwners.delete(tabId);
tabsToDetach.push(tabId);
}
}
await Promise.all(tabsToDetach.map((tabId) => this.detach(tabId)));
await Promise.all(
Array.from(this.tabOwners.keys(), (tabId) => this.releaseSessionTab(sessionId, tabId)),
);
}
}
@@ -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);
});
@@ -45,10 +45,32 @@ function portHarness() {
describe("RecordFrameAgent", () => {
afterEach(() => {
vi.unstubAllGlobals();
document.documentElement.removeAttribute(RECORD_DOCUMENT_ATTRIBUTE);
document.body.replaceChildren();
});
it("starts when crypto.randomUUID is unavailable", async () => {
const harness = portHarness();
const getRandomValues = vi.fn((bytes: Uint8Array) => {
bytes.fill(0x11);
return bytes;
});
vi.stubGlobal("crypto", { getRandomValues });
vi.stubGlobal("chrome", {
runtime: {
connect: vi.fn(() => harness.port),
sendMessage: vi.fn(),
},
});
const agent = new RecordFrameAgent();
await expect(
agent.start({ type: RECORD_FRAME_START, requestId: "rec-http", startedAtMs: 10 }),
).resolves.toEqual({ ok: true });
expect(getRandomValues).toHaveBeenCalled();
});
it("keeps a failed stop retryable and flushes the final dirty fill", async () => {
const harness = portHarness();
const sendMessage = vi
@@ -1,3 +1,4 @@
import { createRandomUuid } from "@/lib/random-uuid";
import {
isAcceptedRecordStepAck,
RECORD_STEP,
@@ -18,7 +19,7 @@ export class RecordStepDelivery {
constructor(
requestId: string,
send: SendRecordStepMessage = (message) => chrome.runtime.sendMessage(message),
producerId: string = crypto.randomUUID(),
producerId: string = createRandomUuid(),
) {
this.#requestId = requestId;
this.#producerId = producerId;
@@ -1,3 +1,4 @@
import { createRandomUuid } from "@/lib/random-uuid";
import {
markRecordingDocument,
type RecordingDocumentMarker,
@@ -67,7 +68,7 @@ export class RecordFrameAgent {
async #start(
message: RecordFrameStartMessage,
): Promise<{ ok: true } | { ok: false; error: string }> {
const producerId = crypto.randomUUID();
const producerId = createRandomUuid();
const root = await waitForDocumentElement();
if (this.#disposed) return { ok: false, error: "recording document is disposed" };
const marker = markRecordingDocument(producerId, root);
+62 -8
View File
@@ -25,7 +25,7 @@ import { recordFrameCoordinator } from "@/lib/recording/frame-coordinator";
import { attachSessionsLiveFlag } from "@/lib/sessions-live-flag";
import { createDisconnectCleanup } from "@/session-manager/disconnect-cleanup";
import { attachSessionEventHandler } from "@/session-manager/event-handler";
import { SessionManager } from "@/session-manager/manager";
import { isAgentControlledTab, SessionManager } from "@/session-manager/manager";
import {
attachBorrowNotificationButtonHandler,
attachBorrowNotificationClickHandler,
@@ -41,6 +41,8 @@ import {
attachRecordStepListener,
type RecordRuntimeDeps,
} from "@/tools/record";
import { chromeTabsApi } from "@/tools/shared";
import { chromeTabMutationApi } from "@/tools/tabs";
import { detectBrowserMeta } from "@/transport/handshake";
import type { Transport } from "@/transport/transport";
import { WSTransport } from "@/transport/ws-transport";
@@ -49,7 +51,12 @@ export default defineBackground(() => {
const controller = new ConnectionController();
const transport = new WSTransport({ url: __BSK_DAEMON_WS_URL__ });
const sessions = new SessionManager();
const cdp = new ChromiumCdp();
const cdp = new ChromiumCdp(undefined, {
shouldAutoAcceptDialog: async (tabId) => {
const tab = await chrome.tabs.get(tabId);
return sessions.findByWindowId(tab.windowId) !== null;
},
});
const sessionsLive = attachSessionsLiveFlag({ manager: sessions });
let overlayGeneration = 0;
const controlModes = new Map<string, OverlayMode>();
@@ -80,6 +87,24 @@ export default defineBackground(() => {
};
}
/**
* Authoritative overlay state for a specific tab. Agent Window tabs are
* free by default; only tabs explicitly claimed through session startup,
* `tab_create`, or `tab_borrow` receive the control overlay.
*/
function overlayStateForTab(tabId?: number, windowId?: number): OverlayAgentStateMessage {
if (typeof tabId === "number" && typeof windowId === "number") {
const ctx = sessions.findByWindowId(windowId);
if (ctx && isAgentControlledTab(ctx, tabId)) return overlayStateForWindow(windowId);
}
return {
type: OVERLAY_AGENT_STATE,
sessionId: null,
mode: "hidden",
generation: overlayGeneration,
};
}
async function pushOverlayStateToTab(
tabId: number,
state: OverlayAgentStateMessage,
@@ -91,13 +116,18 @@ export default defineBackground(() => {
}
}
async function pushOverlayStateForTab(tabId: number, windowId?: number): Promise<void> {
const state = overlayStateForTab(tabId, windowId);
await pushOverlayStateToTab(tabId, state);
}
async function pushOverlayStateForWindow(windowId: number): Promise<void> {
const state = overlayStateForWindow(windowId);
const tabs = await chrome.tabs.query({ windowId });
await Promise.all(
tabs.map((tab) =>
typeof tab.id === "number" ? pushOverlayStateToTab(tab.id, state) : Promise.resolve(),
),
tabs.map((tab) => {
if (typeof tab.id !== "number") return Promise.resolve();
return pushOverlayStateForTab(tab.id, windowId);
}),
);
}
@@ -136,13 +166,30 @@ export default defineBackground(() => {
if (typeof tab.windowId !== "number") return;
pushOverlayStateForAgentWindow(tab.windowId);
});
// Newly observed tabs are free unless the agent's own creation path has
// already claimed their concrete id. This listener only renders that
// state; it never infers or mutates ownership from event ordering.
chrome.tabs.onCreated.addListener((tab) => {
if (typeof tab.windowId !== "number" || typeof tab.id !== "number") return;
if (!sessions.findByWindowId(tab.windowId)) return;
void pushOverlayStateForTab(tab.id, tab.windowId);
});
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
sessions.forgetClosedTab(tabId, { isWindowClosing: removeInfo.isWindowClosing });
});
// Re-sync the storage.session flag on SW startup so a previous SW's
// stale `true` does not keep waking us on every page load until the
// first mutation (review M4/M5 round 3 m-R3-1).
void sessionsLive.refresh();
const cleanupAfterDisconnect = createDisconnectCleanup({
manager: sessions,
sessionStopDeps: { cdp },
// Same contract as the dispatcher's `tool.session_stop`: without these
// deps the agent-tab cleanup and the window-release path are dead code.
sessionStopDeps: {
cdp,
tabManagement: { tabs: chromeTabMutationApi },
tabsQuery: chromeTabsApi,
},
onSessionsChanged: () => {
void sessionsLive.syncFromManager();
},
@@ -176,6 +223,9 @@ export default defineBackground(() => {
recording: recordDeps,
onSessionsChanged: onOverlaySessionStateChanged,
onBrowserControlResumed,
onAgentTabClaimed: (tabId, windowId) => {
void pushOverlayStateForTab(tabId, windowId);
},
approveBorrow: (ctx) =>
requestBorrowConfirmation(ctx.tabId, {
...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
@@ -309,7 +359,11 @@ export default defineBackground(() => {
}
if (msg.kind === OVERLAY_MSG_READY) {
sendResponse(overlayStateForWindow(sender.tab?.windowId));
// Resolve from explicit tab ownership before sending so free tabs never
// flash the control mask.
if (typeof sender.tab?.id === "number") {
void pushOverlayStateForTab(sender.tab.id, sender.tab.windowId);
}
return false;
}
@@ -122,8 +122,8 @@ describe("App", () => {
expect(screen.queryByText(/^扩展 v/)).toBeNull();
expect(screen.queryByText(/^daemon v/)).toBeNull();
expect(screen.getByTitle("扩展版本").textContent).toBe(EXTENSION_VERSION);
expect(screen.getByTitle("bsk 版本").textContent).toBe(mockDaemonVersion);
expect(screen.getByTitle("扩展版本").textContent).toBe(`Ext ${EXTENSION_VERSION}`);
expect(screen.getByTitle("bsk 版本").textContent).toBe(`CLI ${mockDaemonVersion}`);
expect(screen.getByText("03c3e47f")).toBeTruthy();
const copyButton = screen.getByRole("button", { name: "复制实例 ID" });
@@ -247,6 +247,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", () => {
+6 -5
View File
@@ -79,6 +79,7 @@ export function App() {
}, [copiedTick]);
const isSkewed = statusState === "version_skew";
const connectionLive = statusState === "connected" || isSkewed;
const daemonVersion = snapshot.handshake?.version ?? "—";
const daemonProtocol = snapshot.handshake?.protocol_version ?? "—";
const extensionVersion = snapshot.extensionVersion || "—";
@@ -90,7 +91,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
@@ -206,12 +207,12 @@ export function App() {
</div>
{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>
)}
@@ -266,9 +267,9 @@ export function App() {
data-slot="popup-meta"
>
<div className="flex shrink-0 items-center gap-1">
<span title={t("popup.extensionVersionHint")}>{extensionVersion}</span>
<span title={t("popup.daemonVersionHint")}>CLI {daemonVersion}</span>
<span aria-hidden>/</span>
<span title={t("popup.daemonVersionHint")}>{daemonVersion}</span>
<span title={t("popup.extensionVersionHint")}>Ext {extensionVersion}</span>
</div>
<div className="flex min-w-0 items-center justify-end gap-1">
<span className="shrink-0">{t("popup.instanceTitle")}</span>
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRandomUuid } from "../random-uuid";
describe("createRandomUuid", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("uses crypto.randomUUID when it is available", () => {
const randomUUID = vi.fn(() => "00000000-0000-4000-8000-000000000000");
vi.stubGlobal("crypto", { randomUUID, getRandomValues: vi.fn() });
expect(createRandomUuid()).toBe("00000000-0000-4000-8000-000000000000");
expect(randomUUID).toHaveBeenCalledOnce();
});
it("falls back to getRandomValues and sets UUID v4 bits", () => {
const bytes = new Uint8Array(16).fill(0xff);
const getRandomValues = vi.fn((target: Uint8Array) => {
target.set(bytes);
return target;
});
vi.stubGlobal("crypto", { getRandomValues });
const uuid = createRandomUuid();
expect(uuid).toBe("ffffffff-ffff-4fff-bfff-ffffffffffff");
expect(getRandomValues).toHaveBeenCalledOnce();
});
});
+4 -2
View File
@@ -3,8 +3,10 @@
* between the content-script control overlay and the background SW.
*
* Content script → background:
* - `{ kind: "overlay.ready" }` → background replies with the authoritative
* overlay state for the sender's window.
* - `{ kind: "overlay.ready" }` → background decides the per-tab state first
* (see overlayStateForTab) and pushes the authoritative overlay state for
* that tab. It also proactively pushes on tab create and control-mode
* change, so a tab usually receives state before it ever sends ready.
* - `{ kind: "overlay.interrupt", sessionId }` → background asks the
* daemon (via a `session.user_interrupt` WS event) to cancel
* every inflight + queued tool call for that session with
+26
View File
@@ -0,0 +1,26 @@
/**
* Generate a UUID v4 in browser contexts where randomUUID() is unavailable.
*
* Crypto.randomUUID() is restricted to secure contexts, while
* crypto.getRandomValues() is available to content scripts on ordinary HTTP
* origins. BrowserSkill records many intranet pages served over HTTP, so the
* fallback is required for recording to work on those pages.
*/
export function createRandomUuid(): string {
const cryptoApi = globalThis.crypto;
if (typeof cryptoApi.randomUUID === "function") return cryptoApi.randomUUID();
const bytes = new Uint8Array(16);
cryptoApi.getRandomValues(bytes);
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join("-");
}
@@ -1,3 +1,4 @@
import { createRandomUuid } from "../random-uuid";
import {
isRecordFrameQueryMessage,
RECORD_FRAME_PORT,
@@ -188,7 +189,7 @@ export class RecordFrameCoordinator {
const recording = this.#recordings.get(requestId);
if (!recording) return true;
recording.finishing = true;
const commandId = crypto.randomUUID();
const commandId = createRandomUuid();
const results = await Promise.all(
[...recording.agents.values()].map((agent) => this.#stopAgent(agent, requestId, commandId)),
);
@@ -9,7 +9,7 @@ describe("disconnect session cleanup", () => {
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => nextWindowId++),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
remove,
},
});
@@ -41,7 +41,7 @@ describe("disconnect session cleanup", () => {
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => 100),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
remove: vi.fn(() => removeGate),
},
});
@@ -39,7 +39,7 @@ describe("attachSessionEventHandler", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
await manager.start("aa11");
@@ -73,7 +73,7 @@ describe("attachSessionEventHandler", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const ctx = await manager.start("aa11");
@@ -87,6 +87,7 @@ describe("attachSessionEventHandler", () => {
windowEvents: events.api,
});
manager.forgetClosedTab(7, { isWindowClosing: true });
events.emit(4242);
for (let i = 0; i < 4; i += 1) await Promise.resolve();
@@ -113,7 +114,7 @@ describe("attachSessionEventHandler", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const transport = fakeTransport();
@@ -129,7 +130,7 @@ describe("attachSessionEventHandler", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const transport = fakeTransport();
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { AgentWindowApi, AgentWindowCreateOptions } from "../agent-window";
import { SessionManager } from "../manager";
import { isAgentControlledTab, SessionManager } from "../manager";
function fakeAgentWindow(): AgentWindowApi & {
createMock: ReturnType<typeof vi.fn>;
@@ -13,7 +13,7 @@ function fakeAgentWindow(): AgentWindowApi & {
return id;
});
const removeMock = vi.fn(async (_id: number) => {});
const ensureActiveTabMock = vi.fn(async (_windowId: number, _url: string) => {});
const ensureActiveTabMock = vi.fn(async (_windowId: number, _url: string) => 0);
return {
create: createMock,
remove: removeMock,
@@ -151,6 +151,76 @@ describe("SessionManager", () => {
expect(sm.list()).toEqual([]);
});
describe("explicit tab claims", () => {
it("claims the exact home tab returned by AgentWindowApi", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
const ctx = await sm.start("aa11");
expect(ctx.agentCreatedTabs).toEqual(new Set([0]));
expect(isAgentControlledTab(ctx, 0)).toBe(true);
});
it("keeps every other tab free without classification state", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
const ctx = await sm.start("aa11");
expect(isAgentControlledTab(ctx, 99)).toBe(false);
expect(ctx).not.toHaveProperty("userTabs");
expect(ctx).not.toHaveProperty("pendingAgentTabCount");
});
it("treats a committed borrow as an explicit claim", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
const ctx = await sm.start("aa11");
const reservation = sm.tryReserveBorrow(42, ctx.sessionId);
if ("borrowedBy" in reservation) throw new Error("unexpected borrow conflict");
reservation.commit({ tabId: 42, originalWindowId: 7, originalIndex: 3 });
expect(isAgentControlledTab(ctx, 42)).toBe(true);
});
it("forgets an agent-created tab after Chrome removes it", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(42);
sm.forgetClosedTab(42);
expect(ctx.agentCreatedTabs.has(42)).toBe(false);
expect(isAgentControlledTab(ctx, 0)).toBe(true);
});
it("forgets closed borrows idempotently without affecting other tabs or sessions", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
const a = await sm.start("aa11");
const b = await sm.start("bb22");
a.borrowedTabs.set(42, { tabId: 42, originalWindowId: 7, originalIndex: 0 });
a.borrowedTabs.set(43, { tabId: 43, originalWindowId: 7, originalIndex: 1 });
b.borrowedTabs.set(44, { tabId: 44, originalWindowId: 8, originalIndex: 0 });
sm.forgetClosedTab(42);
sm.forgetClosedTab(42);
sm.forgetClosedTab(999);
expect(isAgentControlledTab(a, 42)).toBe(false);
expect(sm.findBorrowingSession(42, null)).toBeNull();
expect([...a.borrowedTabs.keys()]).toEqual([43]);
expect([...b.borrowedTabs.keys()]).toEqual([44]);
expect(sm.list()).toHaveLength(2);
});
it("prevents an in-flight borrow from reclaiming a closed tab", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
const ctx = await sm.start("aa11");
const reservation = sm.tryReserveBorrow(42, ctx.sessionId);
if ("borrowedBy" in reservation) throw new Error("unexpected borrow conflict");
sm.forgetClosedTab(42);
expect(() =>
reservation.commit({ tabId: 42, originalWindowId: 7, originalIndex: 0 }),
).toThrow(/reservation disappeared/);
reservation.release();
expect(sm.findBorrowingSession(42, null)).toBeNull();
expect(ctx.borrowedTabs.has(42)).toBe(false);
});
});
describe("findBorrowingSession", () => {
it("returns null when no session has borrowed the tab", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
@@ -14,8 +14,11 @@ export interface AgentWindowApi {
* Guarantee the Agent Window has an active, CDP-navigable tab.
* `chrome://` pages (including the New Tab page) reject `Page.navigate`,
* so sessions bootstrap with `about:blank` instead.
*
* Resolves with the id of the activated (or newly created) tab, so callers
* can track the session's home tab without re-querying Chrome.
*/
ensureActiveTab(windowId: number, url: string): Promise<void>;
ensureActiveTab(windowId: number, url: string): Promise<number>;
}
/** Creation hints for a new Agent Window. */
@@ -49,15 +52,19 @@ export const chromeAgentWindowApi: AgentWindowApi = {
// cancellation success while the Agent Window remains open.
await chrome.windows.remove(windowId);
},
async ensureActiveTab(windowId: number, url: string): Promise<void> {
async ensureActiveTab(windowId: number, url: string): Promise<number> {
const tabs = await chrome.tabs.query({ windowId });
const first = tabs.find((t) => typeof t.id === "number");
if (first?.id) {
if (first?.id !== undefined) {
if (!first.active) {
await chrome.tabs.update(first.id, { active: true });
}
return;
return first.id;
}
await chrome.tabs.create({ windowId, url, active: true });
const created = await chrome.tabs.create({ windowId, url, active: true });
if (typeof created?.id !== "number") {
throw new Error("[bh] chrome.tabs.create returned no tab id");
}
return created.id;
},
};
+29 -1
View File
@@ -6,9 +6,20 @@ export interface SessionContext {
agentWindowId: number;
refStore: RefStore;
borrowedTabs: Map<number, BorrowedTab>;
/**
* Tabs explicitly claimed by the agent because it created them. This
* includes the Agent Window's home tab and tabs created by `tool.tab_create`.
* Tabs opened through Chrome UI never enter this set.
*/
agentCreatedTabs: Set<number>;
createdAtMs: number;
}
/** Whether this session has explicitly claimed control of `tabId`. */
export function isAgentControlledTab(ctx: SessionContext, tabId: number): boolean {
return ctx.agentCreatedTabs.has(tabId) || ctx.borrowedTabs.has(tabId);
}
export interface BorrowedTab {
tabId: number;
originalWindowId: number;
@@ -106,6 +117,19 @@ export class SessionManager {
return Array.from(this.sessions.values());
}
/**
* Forget a tab Chrome has removed, including any uncommitted borrow.
* Whole-window closures keep committed borrows until the window-removed
* handler reports which user tabs could not be returned.
*/
forgetClosedTab(tabId: number, { isWindowClosing = false } = {}): void {
this.borrowReservations.delete(tabId);
for (const ctx of this.sessions.values()) {
ctx.agentCreatedTabs.delete(tabId);
if (!isWindowClosing) ctx.borrowedTabs.delete(tabId);
}
}
/**
* Look up whether `tabId` is currently borrowed by some *other*
* session than the one calling. Used by M8 `tab_borrow` to refuse
@@ -179,7 +203,7 @@ export class SessionManager {
const { signal: _signal, ...createOptions } = opts;
windowId = await this.agentWindow.create(AGENT_WINDOW_HOME, createOptions);
throwIfSessionStartAborted(opts.signal);
await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
const homeTabId = await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
throwIfSessionStartAborted(opts.signal);
const ctx: SessionContext = {
@@ -187,6 +211,10 @@ export class SessionManager {
agentWindowId: windowId,
refStore: new RefStore(),
borrowedTabs: new Map(),
// The home tab is the session's first explicit claim. Every other
// tab remains free until `tab_create` or `tab_borrow` identifies it
// by its concrete Chrome tab id.
agentCreatedTabs: new Set([homeTabId]),
createdAtMs: this.now(),
};
this.sessions.set(sessionId, ctx);
@@ -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(
@@ -13,7 +13,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
};
}
@@ -49,13 +49,54 @@ describe("ToolDispatcher", () => {
vi.unstubAllGlobals();
});
it.each([
"tool.snapshot",
"tool.evaluate",
"tool.get_html",
"tool.console",
])("annotates Chrome extension-access failures from %s", async (method) => {
const tab = { id: 7, windowId: 4242, active: true, url: "https://example.test" };
vi.stubGlobal("chrome", {
tabs: {
get: vi.fn(async () => tab),
query: vi.fn(async () => [tab]),
},
});
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(),
ensureActiveTab: vi.fn(),
},
});
await sessions.start("test");
const error = new Error("Cannot access a chrome-extension:// URL of different extension");
const cdp = {
send: vi.fn().mockRejectedValue(error),
ensureConsoleCapture: vi.fn().mockRejectedValue(error),
consoleEntriesSince: vi.fn(),
} as unknown as TestDispatcherCdp;
const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
dispatcher.start();
deliver(makeRequest(method, { session_id: "test", expression: "document.title" }));
await vi.waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0]).toMatchObject({
error: {
code: "cdp_failed",
data: { reason: "cdp_extension_access_denied" },
},
});
dispatcher.stop();
});
it("uses the configured recording runtime for start and stop", async () => {
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
await sessions.start("aa11");
@@ -117,7 +158,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -140,7 +181,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create,
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -158,7 +199,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
await sessions.start("aa11");
@@ -172,6 +213,47 @@ describe("ToolDispatcher", () => {
expect(sent[0]).toEqual({ id: "r-1", result: {} });
});
it("wires the production tab APIs into tool.session_stop so a surviving user tab releases the window", async () => {
// Regression guard for the deps that session_stop reads directly: when
// `tabManagement.tabs` / `tabsQuery` are not injected by the dispatcher,
// the agent-tab cleanup and the release decision silently no-op and the
// Agent Window is closed even though a user tab is still open (issue #57).
const removeTab = vi.fn(async () => {});
const closeWindow = vi.fn(async () => {});
// After cleanup the window still holds one tab (id 99) that is neither
// the home tab nor an agent-created tab — i.e. a genuine user tab.
vi.stubGlobal("chrome", {
tabs: {
remove: removeTab,
query: vi.fn(async () => [{ id: 99, windowId: 4242, active: true }]),
},
});
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove: closeWindow,
ensureActiveTab: vi.fn(async () => 1),
},
});
await sessions.start("aa11");
// Register the exact tab id returned by the agent's creation path.
sessions.get("aa11")?.agentCreatedTabs.add(7);
const dispatcher = new ToolDispatcher({ transport, sessions });
dispatcher.start();
deliver(makeRequest("tool.session_stop", { session_id: "aa11" }));
await flushMicrotasks();
// The agent tab is closed through the real chrome.tabs surface...
expect(removeTab).toHaveBeenCalledWith(7);
// ...and the window is released rather than closed, so the user's tab survives.
expect(closeWindow).not.toHaveBeenCalled();
expect(sessions.has("aa11")).toBe(false);
expect(sent[0]).toEqual({ id: "r-1", result: { window_released: true } });
});
it("routes tool.console through the CDP console buffer", async () => {
vi.stubGlobal("chrome", {
tabs: {
@@ -183,7 +265,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
await sessions.start("aa11");
@@ -246,7 +328,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const ctx = await sessions.start("aa11");
@@ -339,7 +421,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
await sessions.start("aa11");
@@ -375,7 +457,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove,
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
await sessions.start("aa11");
@@ -423,7 +505,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -443,7 +525,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -467,7 +549,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -484,7 +566,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -501,7 +583,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const onSessionsChanged = vi.fn();
@@ -518,16 +600,32 @@ describe("ToolDispatcher", () => {
});
it("invokes onBrowserControlResumed for browser-control tools but not passive reads", async () => {
vi.stubGlobal("chrome", {
tabs: {
create: vi.fn(async (props: chrome.tabs.CreateProperties) => ({
id: 7,
windowId: props.windowId,
url: props.url,
active: props.active,
})),
},
});
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const onBrowserControlResumed = vi.fn();
const dispatcher = new ToolDispatcher({ transport, sessions, onBrowserControlResumed });
const onAgentTabClaimed = vi.fn();
const dispatcher = new ToolDispatcher({
transport,
sessions,
onBrowserControlResumed,
onAgentTabClaimed,
});
dispatcher.start();
deliver(makeRequest("tool.session_start", { session_id: "aa11" }));
@@ -544,6 +642,7 @@ describe("ToolDispatcher", () => {
});
await flushMicrotasks();
expect(onBrowserControlResumed).toHaveBeenCalledWith("aa11");
expect(onAgentTabClaimed).toHaveBeenCalledWith(7, 1);
});
it("reasserts remembered hover before follow-up work and releases only after actions", async () => {
@@ -557,7 +656,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const order: string[] = [];
@@ -639,7 +738,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const cdp = {
@@ -689,6 +788,135 @@ describe("ToolDispatcher", () => {
);
});
it.each([
{ tab_id: 7, code: "not_found" },
{ tab_id: undefined, code: "invalid_params" },
])("keeps hover when tab_return is rejected with $code", async ({ tab_id, code }) => {
const sendMessage = vi.fn(async () => undefined);
vi.stubGlobal("chrome", {
tabs: {
sendMessage,
get: vi.fn(async () => ({ id: 7, windowId: 100, active: true })),
query: vi.fn(async () => [{ id: 7, windowId: 100, active: true }]),
},
});
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 100),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
await sessions.start("aa11");
const cdp = {
send: vi.fn(async <T>() => ({}) as T),
detachSession: vi.fn(async () => {}),
releaseSessionTab: vi.fn(async () => {}),
};
const dispatcher = new ToolDispatcher({
transport,
sessions,
cdp: cdp as unknown as TestDispatcherCdp,
});
const helpers = dispatcher as unknown as {
rememberHover: (sessionId: string, result: unknown) => unknown;
setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise<void>;
withHoverReassert: <T>(
params: { session_id: string; tab_id?: number },
work: () => Promise<T>,
) => Promise<T>;
};
helpers.rememberHover("aa11", { tab_id: 7, x: 10, y: 20 });
await helpers.setHoverBypass("aa11", 7, true);
dispatcher.start();
deliver(makeRequest("tool.tab_return", { session_id: "aa11", tab_id }));
await vi.waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0]).toMatchObject({ error: { code } });
expect(cdp.releaseSessionTab).not.toHaveBeenCalled();
expect(sendMessage).not.toHaveBeenCalledWith(7, expect.objectContaining({ enabled: false }));
await helpers.withHoverReassert({ session_id: "aa11", tab_id: 7 }, async () => ({}));
expect(cdp.send).toHaveBeenCalledExactlyOnceWith(7, "Input.dispatchMouseEvent", {
type: "mouseMoved",
x: 10,
y: 20,
});
dispatcher.stop();
});
it("releases the returned tab's hover before moving it and leaves other tabs alone", async () => {
const sendMessage = vi.fn(async () => undefined);
const move = vi.fn(async () => {
expect(sendMessage).toHaveBeenCalledWith(7, expect.objectContaining({ enabled: false }));
return { id: 7, windowId: 200, index: 4 };
});
vi.stubGlobal("chrome", {
tabs: { sendMessage, move },
windows: { get: vi.fn(async () => ({ id: 200 })) },
});
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 100),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const ctx = await sessions.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const cdp = {
send: vi.fn(async <T>() => ({}) as T),
detachSession: vi.fn(async () => {}),
releaseSessionTab: vi.fn(async () => {}),
};
const dispatcher = new ToolDispatcher({
transport,
sessions,
cdp: cdp as unknown as TestDispatcherCdp,
});
const helpers = dispatcher as unknown as {
rememberHover: (sessionId: string, result: unknown) => unknown;
setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise<void>;
withHoverReassert: <T>(
params: { session_id: string; tab_id?: number },
work: () => Promise<T>,
) => Promise<T>;
};
for (const [sessionId, tabId] of [
["aa11", 7],
["aa11", 8],
["bb22", 9],
] as const) {
helpers.rememberHover(sessionId, { tab_id: tabId, x: 10, y: 20 });
await helpers.setHoverBypass(sessionId, tabId, true);
}
dispatcher.start();
deliver(makeRequest("tool.tab_return", { session_id: "aa11", tab_id: 7 }));
await vi.waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0]).toMatchObject({ result: { tab_id: 7, returned_to_window_id: 200 } });
expect(cdp.releaseSessionTab).toHaveBeenCalledExactlyOnceWith("aa11", 7);
await helpers.withHoverReassert({ session_id: "aa11", tab_id: 7 }, async () => ({}));
expect(cdp.send).not.toHaveBeenCalled();
for (const [sessionId, tabId] of [
["aa11", 8],
["bb22", 9],
] as const) {
expect(sendMessage).not.toHaveBeenCalledWith(
tabId,
expect.objectContaining({ enabled: false }),
);
await helpers.withHoverReassert({ session_id: sessionId, tab_id: tabId }, async () => ({}));
expect(cdp.send).toHaveBeenLastCalledWith(tabId, "Input.dispatchMouseEvent", {
type: "mouseMoved",
x: 10,
y: 20,
});
}
dispatcher.stop();
});
it("transfers hover overlay bypass ownership between sessions", async () => {
vi.stubGlobal("chrome", {
tabs: {
@@ -700,7 +928,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -737,7 +965,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
await sessions.start("aa11");
@@ -789,7 +1017,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -823,7 +1051,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
transport.send = () => {
@@ -845,7 +1073,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 5555),
remove,
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
transport.send = () => {
@@ -873,7 +1101,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(() => createPromise),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -924,7 +1152,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -945,7 +1173,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(() => createPromise),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -20,7 +20,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
};
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import type { RpcError } from "@/transport/types";
import { cdpError, classifyCdpError } from "../errors";
const denied = "Cannot access a chrome-extension:// URL of different extension";
describe("CDP extension access errors", () => {
it("classifies both Chrome Error instances and string failures", () => {
for (const error of [new Error(denied), denied]) {
expect(cdpError(error)).toEqual({
code: "cdp_failed",
message: denied,
data: { reason: "cdp_extension_access_denied" },
});
}
});
it("preserves transfer outcome and cleanup instructions when CDP was denied", () => {
const error: RpcError = {
code: "cdp_failed",
message: denied,
data: {
reason: "transfer_outcome_unknown",
effect_state: "unknown",
cleanup_state: "failed",
},
};
expect(classifyCdpError(error)).toBe(error);
});
it("leaves other error codes and unrelated debugger failures unchanged", () => {
for (const error of [
{ code: "permission_denied", message: denied },
{ code: "cdp_failed", message: "Another debugger is already attached" },
] satisfies RpcError[]) {
expect(classifyCdpError(error)).toBe(error);
}
});
});
@@ -12,7 +12,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
};
}
@@ -13,7 +13,7 @@ function sessions() {
agentWindow: {
create: vi.fn(async () => 100),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
}
@@ -0,0 +1,573 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { CdpRunner } from "@/tools/shared";
import { handleFill } from "../interaction";
interface ScriptParams {
functionDeclaration: string;
arguments?: Array<{ value: unknown }>;
}
async function setup(markup = '<input value="old">') {
document.body.innerHTML = markup;
const element = document.body.firstElementChild as HTMLInputElement;
const manager = new SessionManager({
agentWindow: {
create: async () => 100,
remove: async () => {},
ensureActiveTab: async () => 4,
},
});
const ctx = await manager.start("aa11");
ctx.refStore.set("e1", 12, { tabId: 4 });
const script = vi.fn(async (params: ScriptParams): Promise<unknown> => {
try {
const fn = new Function(`return (${params.functionDeclaration})`)();
return {
result: {
value: await fn.apply(
element,
(params.arguments ?? []).map((arg) => arg.value),
),
},
};
} catch {
return { exceptionDetails: { text: "test page script failed" } };
}
});
// Number/email controls have a browser caret even though their selection
// getters return null. Model that caret instead of assuming it is at the end.
const hiddenCaret = { start: 0, end: 0 };
const moveToEnd = vi.fn(async () => {
if (typeof element.selectionStart === "number") {
element.setSelectionRange(element.value.length, element.value.length);
} else {
hiddenCaret.start = hiddenCaret.end = element.value.length;
}
});
const insert = vi.fn(async (text: string) => {
const focused = document.activeElement as HTMLInputElement;
if (focused.readOnly || focused.disabled) return;
const start = focused.selectionStart ?? hiddenCaret.start;
const end = focused.selectionEnd ?? hiddenCaret.end;
const value = focused.value.slice(0, start) + text + focused.value.slice(end);
focused.value = focused.maxLength >= 0 ? value.slice(0, focused.maxLength) : value;
hiddenCaret.start = hiddenCaret.end = Math.min(start + text.length, focused.value.length);
focused.dispatchEvent(new Event("input", { bubbles: true }));
});
const release = vi.fn(async () => ({}));
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
switch (method) {
case "DOM.describeNode":
return {
node: {
nodeName: element.tagName,
attributes: Array.from(element.attributes).flatMap((attr) => [attr.name, attr.value]),
},
};
case "DOM.scrollIntoViewIfNeeded":
return {};
case "DOM.focus":
element.focus();
return {};
case "DOM.resolveNode":
return { object: { objectId: "fill-target" } };
case "Runtime.callFunctionOn":
return script(params as ScriptParams);
case "Input.insertText":
await insert((params as { text: string }).text);
return {};
case "Input.dispatchKeyEvent":
if ((params as { commands?: string[] }).commands?.includes("moveToEndOfDocument")) {
await moveToEnd();
}
return {};
case "Runtime.releaseObject":
return release();
default:
throw new Error(`unexpected ${method}`);
}
});
return {
element,
hiddenCaret,
moveToEnd,
script,
insert,
release,
send,
fill: (value = "hello", clearBefore = true, signal?: AbortSignal) =>
handleFill(
manager,
{ session_id: "aa11", ref: "e1", value, clear_before: clearBefore },
{
cdp: { send: send as CdpRunner["send"] },
tabsApi: {
get: async (id) => ({ id, windowId: 100, active: true }) as chrome.tabs.Tab,
query: async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab],
},
signal,
},
),
};
}
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = "";
});
describe("fill result verification", () => {
it.each([
"readonly",
"disabled",
])("preserves a %s field before rejecting it", async (attribute) => {
const h = await setup(`<input ${attribute} value="old">`);
expect(await h.fill()).toMatchObject({
code: "invalid_params",
data: { reason: "target_not_fillable" },
});
expect(h.element.value).toBe("old");
expect(h.insert).not.toHaveBeenCalled();
expect(h.release).toHaveBeenCalledOnce();
});
it.each([
"checkbox",
"radio",
"range",
"date",
"file",
])("rejects an unsupported %s input before clearing", async (type) => {
const h = await setup(`<input type="${type}">`);
const before = h.element.value;
expect(await h.fill()).toMatchObject({
code: "invalid_params",
data: { reason: "target_not_fillable" },
});
expect(h.element.value).toBe(before);
expect(h.insert).not.toHaveBeenCalled();
});
it("rejects an overlong value before clearing", async () => {
const h = await setup('<input maxlength="3" value="old">');
expect(await h.fill()).toMatchObject({
code: "invalid_params",
message: /maxlength/,
data: { reason: "fill_value_invalid" },
});
expect(h.element.value).toBe("old");
expect(h.insert).not.toHaveBeenCalled();
});
it.each([
true,
false,
])("classifies a DOM.focus rejection on a disabled=%s target", async (disabled) => {
const h = await setup(`<input ${disabled ? "disabled" : ""} value="old">`);
const send = h.send.getMockImplementation()!;
h.send.mockImplementation(async (tabId, method, params) => {
if (method === "DOM.focus") throw new Error("Element is not focusable");
return send(tabId, method, params);
});
expect(await h.fill()).toMatchObject({
code: disabled ? "invalid_params" : "cdp_failed",
data: { reason: disabled ? "target_not_fillable" : "fill_failed" },
});
expect(h.element.value).toBe("old");
expect(h.insert).not.toHaveBeenCalled();
expect(h.release).toHaveBeenCalledOnce();
});
it("fills a background document without requiring foreground browser focus", async () => {
const h = await setup();
vi.spyOn(document, "hasFocus").mockReturnValue(false);
expect(await h.fill()).toMatchObject({ value_length: 5 });
expect(h.element.value).toBe("hello");
});
it("restores focus once after clearing without typing into another field", async () => {
const h = await setup();
const other = document.createElement("input");
document.body.append(other);
h.element.addEventListener("input", () => other.focus());
expect(await h.fill()).toMatchObject({ value_length: 5 });
expect(h.element.value).toBe("hello");
expect(other.value).toBe("");
expect(h.send.mock.calls.filter(([, method]) => method === "DOM.focus")).toHaveLength(2);
});
it.each([
"focus",
"value",
])("rechecks %s after restoring focus, without retrying again", async (action) => {
const h = await setup();
const other = document.createElement("input");
document.body.append(other);
h.element.addEventListener(
"input",
() => {
other.focus();
h.element.addEventListener("focus", () => {
if (action === "focus") queueMicrotask(() => other.focus());
else h.element.value = "changed";
});
},
{ once: true },
);
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: action === "focus" ? "fill_focus_lost" : "fill_target_changed" },
});
expect(other.value).toBe("");
expect(h.insert).not.toHaveBeenCalled();
expect(h.send.mock.calls.filter(([, method]) => method === "DOM.focus")).toHaveLength(2);
});
it.each([
"remove",
"readonly",
"restore",
"microtask-restore",
])("stops when clearing causes the page to %s the target", async (action) => {
const h = await setup();
h.element.addEventListener("input", () => {
if (action === "remove") h.element.remove();
if (action === "readonly") h.element.readOnly = true;
if (action === "restore") h.element.value = "restored";
if (action === "microtask-restore")
queueMicrotask(() => {
h.element.value = "restored";
});
});
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_target_changed" },
});
expect(h.insert).not.toHaveBeenCalled();
});
it("fails when insertText is acknowledged without changing the value", async () => {
const h = await setup();
h.insert.mockImplementation(async () => {});
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_value_mismatch" },
});
expect(h.release).toHaveBeenCalledOnce();
});
it("detects truncation when maxlength changes after preparation", async () => {
const h = await setup();
h.element.addEventListener(
"input",
() => {
h.element.maxLength = 3;
},
{ once: true },
);
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_value_mismatch" },
});
expect(h.element.value).toBe("hel");
});
it.each([
"input",
"change",
"microtask-change",
])("detects a value reverted by the %s handler", async (event) => {
const h = await setup();
h.element.addEventListener(event === "microtask-change" ? "change" : event, () => {
if (event === "microtask-change")
queueMicrotask(() => {
h.element.value = "";
});
else h.element.value = "";
});
expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /expected value/ });
});
it("compares content rather than length without including field values in the error", async () => {
const h = await setup('<input type="password">');
h.element.addEventListener("change", () => {
h.element.value = "other-secret";
});
const result = await h.fill("given-secret");
expect(result).toMatchObject({ code: "cdp_failed", message: /expected value/ });
expect(JSON.stringify(result)).not.toMatch(/given-secret|other-secret/);
});
it("does not report success for a target replaced by a change handler", async () => {
const h = await setup();
h.element.addEventListener("change", () => h.element.replaceWith(h.element.cloneNode()));
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_target_changed" },
});
});
it.each([
1, 2, 3, 4, 5,
])("rejects script exceptions at phase %s and releases the target", async (phase) => {
const h = await setup();
vi.spyOn(document, "hasFocus").mockReturnValue(false);
const run = h.script.getMockImplementation()!;
let count = 0;
h.script.mockImplementation(async (params) =>
++count === phase ? { exceptionDetails: { text: "page secret" } } : run(params),
);
const result = await h.fill();
expect(result).toMatchObject({ code: "cdp_failed", data: { reason: "fill_failed" } });
expect(JSON.stringify(result)).not.toContain("page secret");
if (phase <= 3) expect(h.insert).not.toHaveBeenCalled();
expect(h.release).toHaveBeenCalledOnce();
});
it.each([1, 2, 3, 5])("rejects missing results at phase %s", async (phase) => {
const h = await setup();
vi.spyOn(document, "hasFocus").mockReturnValue(false);
const run = h.script.getMockImplementation()!;
let count = 0;
h.script.mockImplementation(async (params) =>
++count === phase ? { result: { type: "undefined" } } : run(params),
);
expect(await h.fill()).toMatchObject({ code: "cdp_failed" });
if (phase <= 3) expect(h.insert).not.toHaveBeenCalled();
});
it.each(["input", "change"])("checks state after nested microtasks from %s", async (event) => {
const h = await setup();
h.element.addEventListener(event, () => {
queueMicrotask(() =>
queueMicrotask(() => {
h.element.value = "reverted";
}),
);
});
expect(await h.fill()).toMatchObject({ code: "cdp_failed" });
if (event === "input") expect(h.insert).not.toHaveBeenCalled();
});
it.each([
"<input>",
'<input type="password">',
"<textarea></textarea>",
])("verifies successful text in %s", async (markup) => {
const h = await setup(markup);
expect(await h.fill("你好🙂")).toMatchObject({ value_length: 4 });
expect(h.element.value).toBe("你好🙂");
expect(h.release).toHaveBeenCalledOnce();
});
it("verifies append against the old value plus the requested text", async () => {
const h = await setup();
h.element.focus();
h.element.setSelectionRange(3, 3);
expect(await h.fill("!", false)).toMatchObject({ value_length: 4 });
expect(h.element.value).toBe("old!");
});
it.each([
["<textarea></textarea>", "a\r\nb", "a\nb"],
["<input>", "a\nb", "a b"],
["<input>", "a\r\nb", "a b"],
[
'<input type="email" multiple>',
" a@example.com , b@example.com ",
"a@example.com,b@example.com",
],
])("accepts native normalization for %s", async (markup, requested, normalized) => {
if (markup.startsWith("<textarea")) {
// Happy DOM does not implement Chrome's textarea line-ending normalization.
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!;
vi.spyOn(HTMLTextAreaElement.prototype, "value", "set").mockImplementation(function (
this: HTMLTextAreaElement,
value,
) {
setter.call(this, value.replace(/\r\n?/g, "\n"));
});
}
const h = await setup(markup);
h.insert.mockImplementation(async () => {
h.element.value = normalized;
});
expect(await h.fill(requested)).toMatchObject({ value_length: normalized.length });
});
it("does not count an editable padding break as an extra typed character", async () => {
const h = await setup('<div contenteditable="true" tabindex="0"></div>');
// These two layout/editability properties are missing in Happy DOM.
let innerText = "";
Object.defineProperty(h.element, "isContentEditable", { get: () => true });
Object.defineProperty(h.element, "innerText", { get: () => innerText });
h.insert.mockImplementation(async () => {
h.element.innerHTML = "one<div><br></div>";
innerText = "one\n\n";
});
expect(await h.fill("one\n")).toMatchObject({ value_length: 4 });
});
it.each([
[0, 0],
[1, 1],
[0, 3],
])("appends after moving selection %s:%s to the end", async (start, end) => {
const h = await setup();
h.element.focus();
h.element.setSelectionRange(start, end);
expect(await h.fill("🙂", false)).toMatchObject({ value_length: 5 });
expect(h.element.value).toBe("old🙂");
});
it.each(
["number", "email"].flatMap((type) =>
[
[0, 0],
[1, 1],
[0, 2],
].map(([start, end]) => ({ type, start, end })),
),
)("appends to $type from selection $start:$end without using its unsupported API", async ({
type,
start,
end,
}) => {
const h = await setup(`<input type="${type}" value="12">`);
Object.assign(h.hiddenCaret, { start, end });
vi.spyOn(h.element, "selectionStart", "get").mockReturnValue(null);
vi.spyOn(h.element, "selectionEnd", "get").mockReturnValue(null);
const selection = vi.spyOn(h.element, "setSelectionRange").mockImplementation(() => {
throw new DOMException("unsupported", "InvalidStateError");
});
expect(await h.fill("3", false)).toMatchObject({ value_length: 3 });
expect(h.element.value).toBe("123");
expect(selection).not.toHaveBeenCalled();
});
it.each([
"number",
"email",
])("does not report successful %s append when the caret command is ignored", async (type) => {
const h = await setup(`<input type="${type}" value="12">`);
vi.spyOn(h.element, "selectionStart", "get").mockReturnValue(null);
vi.spyOn(h.element, "selectionEnd", "get").mockReturnValue(null);
h.moveToEnd.mockResolvedValueOnce(undefined);
expect(await h.fill("3", false)).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_value_mismatch" },
});
expect(h.element.value).toBe("312");
expect(h.insert).toHaveBeenCalledOnce();
});
it("rejects invalid number text before clearing", async () => {
const h = await setup('<input type="number" value="12">');
expect(await h.fill("abc")).toMatchObject({
code: "invalid_params",
data: { reason: "fill_value_invalid" },
});
expect(h.element.value).toBe("12");
expect(h.insert).not.toHaveBeenCalled();
});
it("reports page formatting as unconfirmed without retrying the write", async () => {
const h = await setup();
h.element.addEventListener("change", () => {
h.element.value = h.element.value.toUpperCase();
});
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_value_mismatch" },
});
expect(h.element.value).toBe("HELLO");
expect(h.insert).toHaveBeenCalledOnce();
});
it.each([
["focus", false],
["focus", true],
["value", false],
["value", true],
["cancel", false],
["cancel", true],
["throw", false],
["throw", true],
] as const)("stops when positioning triggers %s (clear=%s) and releases the key", async (action, clearBefore) => {
const h = await setup();
vi.spyOn(document, "hasFocus").mockReturnValue(false);
const controller = new AbortController();
const other = document.createElement("input");
document.body.append(other);
const send = h.send.getMockImplementation()!;
h.send.mockImplementation(async (tabId, method, params) => {
const reply = await send(tabId, method, params);
if (
method === "Input.dispatchKeyEvent" &&
(params as { type: string }).type === "rawKeyDown"
) {
if (action === "focus") other.focus();
if (action === "value") h.element.value = "changed";
if (action === "cancel") controller.abort();
if (action === "throw") throw new Error("dispatch failed");
}
return reply;
});
const result = await h.fill("!", clearBefore, controller.signal);
expect(result).toMatchObject({ code: action === "cancel" ? "cancelled" : "cdp_failed" });
expect(h.insert).not.toHaveBeenCalled();
expect(other.value).toBe("");
expect(h.send).toHaveBeenCalledWith(
4,
"Input.dispatchKeyEvent",
expect.objectContaining({ type: "keyUp" }),
);
expect(h.release).toHaveBeenCalledOnce();
});
it.each([true, false])("verifies an empty request with clear_before=%s", async (clearBefore) => {
const h = await setup();
expect(await h.fill("", clearBefore)).toMatchObject({ value_length: clearBefore ? 0 : 3 });
expect(h.element.value).toBe(clearBefore ? "" : "old");
expect(h.insert).not.toHaveBeenCalled();
});
it("cancels after insertion without sending change events", async () => {
const h = await setup();
const controller = new AbortController();
h.insert.mockImplementation(async () => controller.abort());
expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" });
expect(h.script).toHaveBeenCalledTimes(2);
expect(h.release).toHaveBeenCalledOnce();
});
it("cancels after resolving the remote object without clearing the field", async () => {
const h = await setup();
const controller = new AbortController();
const send = h.send.getMockImplementation()!;
h.send.mockImplementation(async (tabId, method, params) => {
const result = await send(tabId, method, params);
if (method === "DOM.resolveNode") controller.abort();
return result;
});
expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" });
expect(h.element.value).toBe("old");
expect(h.script).not.toHaveBeenCalled();
expect(h.release).toHaveBeenCalledOnce();
});
it("honors cancellation during verification", async () => {
const h = await setup();
const controller = new AbortController();
h.element.addEventListener("change", () => controller.abort());
expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" });
expect(h.release).toHaveBeenCalledOnce();
});
it("keeps the verified outcome when releasing the remote object fails", async () => {
const h = await setup();
h.release.mockRejectedValueOnce(new Error("target closed"));
expect(await h.fill()).toMatchObject({ value_length: 5 });
});
});
@@ -21,7 +21,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
};
}
@@ -540,6 +540,21 @@ describe("handleHover", () => {
});
});
function successfulFillScript(params: unknown) {
const script = params as { arguments?: Array<{ value: unknown }>; functionDeclaration: string };
const args = script.arguments ?? [];
return {
result: {
value:
args.length === 2
? { before: "", expected: args[0].value }
: script.functionDeclaration.startsWith("function(expected)")
? { connected: true, matches: true, valueLength: String(args[0].value).length }
: "ready",
},
};
}
describe("handleFill", () => {
it("returns not_found for unknown ref", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
@@ -586,7 +601,8 @@ describe("handleFill", () => {
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.focus": () => ({}),
"DOM.resolveNode": () => ({ object: { objectId: "obj-1" } }),
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }),
"Runtime.callFunctionOn": successfulFillScript,
"Runtime.releaseObject": () => ({}),
"Input.insertText": () => ({}),
});
const res = await handleFill(
@@ -600,13 +616,12 @@ describe("handleFill", () => {
expect(res.used_ref).toBe("e1");
const insert = fake.sent.find((c) => c.method === "Input.insertText");
expect(insert?.params).toEqual({ text: "hello" });
// clear_before defaults to true → callFunctionOn invoked once to
// clear, once to fire input/change after typing.
// Foreground replacement needs no extra caret-positioning round trip.
const callFns = fake.sent.filter((c) => c.method === "Runtime.callFunctionOn");
expect(callFns.length).toBeGreaterThanOrEqual(2);
expect(callFns).toHaveLength(4);
});
it("clear_before=false skips the wipe call", async () => {
it("passes clear_before=false to preparation and verifies the result", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 1, { tabId: 4 });
@@ -618,12 +633,12 @@ describe("handleFill", () => {
"DOM.focus": () => ({}),
"DOM.resolveNode": () => ({ object: { objectId: "obj-2" } }),
"Runtime.callFunctionOn": (p) => {
const fn = (p as { functionDeclaration?: string }).functionDeclaration ?? "";
// No "this.value = ''" clearing on a no-clear path; only the
// post-input dispatchEvent.
expect(fn).not.toMatch(/this\.value\s*=\s*''/);
return { result: { type: "undefined" } };
const args = (p as { arguments?: Array<{ value: unknown }> }).arguments ?? [];
if (args.length === 2) expect(args[1].value).toBe(false);
return successfulFillScript(p);
},
"Input.dispatchKeyEvent": () => ({}),
"Runtime.releaseObject": () => ({}),
"Input.insertText": () => ({}),
});
const res = await handleFill(
@@ -650,7 +665,8 @@ describe("handleFill", () => {
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.focus": () => ({}),
"DOM.resolveNode": () => ({ object: { objectId: "obj-3" } }),
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }),
"Runtime.callFunctionOn": successfulFillScript,
"Runtime.releaseObject": () => ({}),
"Input.insertText": () => ({}),
});
const res = await handleFill(
@@ -0,0 +1,266 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { BrowserNavigationApi } from "../browser-navigation";
import { handleNavigate, handleReload } from "../navigation";
import type { CdpRunner } from "../shared";
const denied = "Cannot access a chrome-extension:// URL of different extension";
const url = "https://example.test/recovered";
function event<T extends (...args: never[]) => void>() {
const listeners = new Set<T>();
return {
listeners,
addListener: (listener: T) => listeners.add(listener),
removeListener: (listener: T) => listeners.delete(listener),
fire: (...args: Parameters<T>) => {
for (const listener of [...listeners]) listener(...args);
},
};
}
async function fixture() {
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => 100),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 4),
},
});
await manager.start("test");
type Listener = Parameters<BrowserNavigationApi["onCommitted"]["addListener"]>[0];
const events = {
onCommitted: event<Listener>(),
onDOMContentLoaded: event<Listener>(),
onCompleted: event<Listener>(),
onErrorOccurred: event<Listener>(),
};
const cdpEvents = event<Parameters<NonNullable<CdpRunner["onEvent"]>>[0]>();
const state = { blocked: true, destinationBlocked: false, emitIdle: true };
const send = vi.fn(async (_tabId: number, method: string) => {
if (state.blocked) throw new Error(denied);
if (method === "Page.getFrameTree") {
return { frameTree: { frame: { id: "main", loaderId: "new-loader" } } };
}
if (method === "Runtime.evaluate") return { result: { value: "complete" } };
if (method === "Page.setLifecycleEventsEnabled" && state.emitIdle) {
cdpEvents.fire({ tabId: 4 }, "Page.lifecycleEvent", {
frameId: "main",
loaderId: "new-loader",
name: "networkIdle",
});
}
if (method === "Page.navigate") return { frameId: "main", loaderId: "new-loader" };
return {};
});
const cdp: CdpRunner = {
send: send as CdpRunner["send"],
onEvent: (listener) => {
cdpEvents.addListener(listener);
return { dispose: () => cdpEvents.removeListener(listener) };
},
};
const main = { tabId: 4, frameId: 0, documentId: "new-document" };
const navigate = async () => {
state.blocked = state.destinationBlocked;
events.onCommitted.fire(main);
events.onDOMContentLoaded.fire(main);
events.onCompleted.fire(main);
};
const browserNavigation = { ...events, update: vi.fn(navigate), reload: vi.fn(navigate) };
const tab = { id: 4, windowId: 100, active: true, url } as chrome.tabs.Tab;
const tabsApi = { get: vi.fn(async () => tab), query: vi.fn(async () => [tab]) };
const deps = { cdp, tabsApi, browserNavigation, defaultTimeoutMs: 1000 };
const expectCleanedUp = () => {
for (const entry of Object.values(events)) expect(entry.listeners.size).toBe(0);
expect(cdpEvents.listeners.size).toBe(0);
};
return { manager, deps, state, send, events, main, cdpEvents, expectCleanedUp };
}
describe("navigation after Chrome denies extension-frame access", () => {
it.each([
"load",
"commit",
"domcontentloaded",
"networkidle",
] as const)("recovers the same tab with the requested %s checkpoint", async (waitUntil) => {
const f = await fixture();
const result = await handleNavigate(
f.manager,
{
session_id: "test",
url,
wait_until: waitUntil,
},
f.deps,
);
expect(result).toMatchObject({ tab_id: 4, final_url: url, reached: waitUntil });
expect(f.deps.browserNavigation.update).toHaveBeenCalledExactlyOnceWith(4, { url });
expect(f.send.mock.calls.some(([, method]) => method === "Page.navigate")).toBe(false);
expect(f.send).toHaveBeenCalledWith(4, "Page.enable", {});
f.expectCleanedUp();
});
it("recovers reload through tabs.reload and preserves hard reload", async () => {
const f = await fixture();
const result = await handleReload(f.manager, { session_id: "test", hard: true }, f.deps);
expect(result).toMatchObject({ tab_id: 4, reached: "load" });
expect(f.deps.browserNavigation.reload).toHaveBeenCalledExactlyOnceWith(4, {
bypassCache: true,
});
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
f.expectCleanedUp();
});
it("reports the restriction when reload leaves the conflicting frame present", async () => {
const f = await fixture();
f.state.destinationBlocked = true;
const result = await handleReload(f.manager, { session_id: "test" }, f.deps);
expect(result).toMatchObject({
code: "cdp_failed",
data: { reason: "cdp_extension_access_denied" },
});
expect(f.deps.browserNavigation.reload).toHaveBeenCalledOnce();
f.expectCleanedUp();
});
it("does not fall back for unrelated debugger failures", async () => {
const f = await fixture();
f.send.mockRejectedValue(new Error("Another debugger is already attached"));
const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps);
expect(result).toMatchObject({
code: "cdp_failed",
message: "Another debugger is already attached",
});
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
f.expectCleanedUp();
});
it("does not replay navigation when a dispatched CDP command fails", async () => {
const f = await fixture();
f.state.blocked = false;
const send = f.send.getMockImplementation()!;
f.send.mockImplementation(async (tabId, method) => {
if (method === "Page.navigate") throw new Error(denied);
return send(tabId, method);
});
const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps);
expect(result).toMatchObject({
code: "cdp_failed",
data: { reason: "cdp_extension_access_denied" },
});
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
f.expectCleanedUp();
});
it("keeps the Agent Window guard in front of the recovery", async () => {
const f = await fixture();
f.deps.tabsApi.get.mockResolvedValue({
id: 4,
windowId: 200,
active: true,
url,
} as chrome.tabs.Tab);
const result = await handleNavigate(f.manager, { session_id: "test", tab_id: 4, url }, f.deps);
expect(result).toMatchObject({ code: "permission_denied" });
expect(f.send).not.toHaveBeenCalled();
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
});
it("ignores old-document, subframe, and other-tab completion events", async () => {
const f = await fixture();
f.deps.browserNavigation.update.mockImplementation(async () => {});
let settled = false;
const work = handleNavigate(f.manager, { session_id: "test", url }, f.deps).then((result) => {
settled = true;
return result;
});
await vi.waitFor(() => expect(f.deps.browserNavigation.update).toHaveBeenCalledOnce());
f.events.onCompleted.fire(f.main);
f.events.onCommitted.fire({ ...f.main, tabId: 9 });
f.events.onCommitted.fire({ ...f.main, frameId: 1 });
f.events.onCompleted.fire(f.main);
await Promise.resolve();
expect(settled).toBe(false);
f.events.onCommitted.fire(f.main);
f.events.onCompleted.fire({ ...f.main, documentId: "old-document" });
await Promise.resolve();
expect(settled).toBe(false);
f.state.blocked = false;
f.events.onCompleted.fire(f.main);
expect(await work).toMatchObject({ reached: "load" });
f.expectCleanedUp();
});
it("does not infer network idle from a browser load event", async () => {
const f = await fixture();
f.state.emitIdle = false;
const result = await handleNavigate(
f.manager,
{
session_id: "test",
url,
wait_until: "networkidle",
timeout_ms: 10,
},
f.deps,
);
expect(result).toMatchObject({ reached: "timeout" });
f.expectCleanedUp();
});
it("cleans up browser listeners on timeout", async () => {
const f = await fixture();
f.deps.browserNavigation.update.mockImplementation(async () => {});
const result = await handleNavigate(
f.manager,
{ session_id: "test", url, timeout_ms: 10 },
f.deps,
);
expect(result).toMatchObject({ reached: "timeout" });
f.expectCleanedUp();
});
it("does not navigate an already-cancelled request", async () => {
const f = await fixture();
const result = await handleNavigate(
f.manager,
{ session_id: "test", url },
{
...f.deps,
signal: AbortSignal.abort(),
},
);
expect(result).toMatchObject({ code: "cancelled" });
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
f.expectCleanedUp();
});
it("cleans up browser listeners when cancelled after dispatch", async () => {
const f = await fixture();
const ac = new AbortController();
f.deps.browserNavigation.update.mockImplementation(async () => {
ac.abort();
});
const result = await handleNavigate(
f.manager,
{ session_id: "test", url },
{ ...f.deps, signal: ac.signal },
);
expect(result).toMatchObject({ code: "cancelled" });
expect(f.deps.browserNavigation.update).toHaveBeenCalledOnce();
f.expectCleanedUp();
});
it("reports browser navigation errors and releases listeners", async () => {
const f = await fixture();
f.deps.browserNavigation.update.mockImplementation(async () => {
f.events.onErrorOccurred.fire({ ...f.main, error: "net::ERR_NAME_NOT_RESOLVED" });
});
const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps);
expect(result).toMatchObject({ code: "cdp_failed", message: "net::ERR_NAME_NOT_RESOLVED" });
f.expectCleanedUp();
});
});
@@ -21,7 +21,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
};
}
@@ -12,7 +12,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
};
}
@@ -28,7 +28,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
};
}
@@ -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",
@@ -1,8 +1,26 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import { handleSessionStop } from "../session";
import type { ChromeTabsApi } from "../shared";
import { type AgentOverlayResetApi, type ChromeWindowsApi, type TabMutationApi } from "../tabs";
/** Build a read-only query api over a FakeState. */
function makeQuery(state: FakeState): ChromeTabsApi {
return {
get: vi.fn(async (id) => {
const t = state.tabs.get(id);
if (!t) throw new Error(`tab ${id} not found`);
return t;
}),
query: vi.fn(async (q: chrome.tabs.QueryInfo) => {
const w = q.windowId;
return Array.from(state.tabs.values()).filter(
(t) => typeof w !== "number" || t.windowId === w,
);
}),
};
}
function fakeAgentWindow(ids: number[]) {
let i = 0;
const create = vi.fn(async () => {
@@ -11,7 +29,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
});
const remove = vi.fn(async () => {});
const ensureActiveTab = vi.fn(async () => {});
const ensureActiveTab = vi.fn(async () => 0);
return { create, remove, ensureActiveTab };
}
@@ -30,7 +48,9 @@ function makeApis(
} {
const tabs: TabMutationApi = {
create: vi.fn(),
remove: vi.fn(async () => {}),
remove: vi.fn(async (id: number) => {
state.tabs.delete(id);
}),
update: vi.fn(async (_id, _p) => undefined),
get: vi.fn(async (id) => {
const t = state.tabs.get(id);
@@ -237,11 +257,15 @@ describe("handleSessionStop with auto-return", () => {
moves: [],
};
const { tabs, windows } = makeApis(state, { moveThrowsFor: new Set([1]) });
const cdp = {
detachSession: vi.fn(async () => {}),
releaseSessionTab: vi.fn(async () => {}),
};
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{ tabManagement: { tabs, windows } },
{ cdp, tabManagement: { tabs, windows } },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.return_failures?.map((f) => f.tab_id)).toEqual([1]);
@@ -249,6 +273,8 @@ describe("handleSessionStop with auto-return", () => {
expect(sm.has("aa11")).toBe(true);
expect(ctx.borrowedTabs.has(1)).toBe(true);
expect(ctx.borrowedTabs.has(2)).toBe(false);
expect(cdp.releaseSessionTab).toHaveBeenCalledExactlyOnceWith("aa11", 2);
expect(cdp.detachSession).not.toHaveBeenCalled();
expect(aw.remove).not.toHaveBeenCalled();
});
@@ -267,4 +293,275 @@ describe("handleSessionStop with auto-return", () => {
await handleSessionStop(sm, { session_id: "aa11" }, { tabManagement: { tabs, windows } });
expect(ctx.refStore.isEmpty()).toBe(true);
});
it.each([
"before teardown",
"during its return",
"after its return failed",
"before its turn in the return loop",
])("finishes stopping when a borrowed tab closes %s", async (timing) => {
const aw = fakeAgentWindow([100]);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(1, { tabId: 1, originalWindowId: 200, originalIndex: 0 });
ctx.borrowedTabs.set(2, { tabId: 2, originalWindowId: 200, originalIndex: 1 });
const state: FakeState = {
tabs: new Map([
[1, { id: 1, windowId: 100 } as chrome.tabs.Tab],
[2, { id: 2, windowId: 100 } as chrome.tabs.Tab],
]),
windowsClosed: new Set(),
moves: [],
};
const { tabs, windows } = makeApis(state);
const cdp = { detachSession: vi.fn(async () => {}) };
const closeTab = (tabId: number) => {
state.tabs.delete(tabId);
sm.forgetClosedTab(tabId);
};
const baseMove = tabs.move;
tabs.move = vi.fn(async (tabId, props) => {
if (tabId === 1 && timing === "during its return") closeTab(1);
if (tabId === 1 && timing === "after its return failed") {
throw new Error("simulated move failure");
}
if (tabId === 2 && timing === "after its return failed") closeTab(1);
if (tabId === 1 && timing === "before its turn in the return loop") closeTab(2);
if (!state.tabs.has(tabId)) throw new Error(`tab ${tabId} not found`);
return baseMove(tabId, props);
});
if (timing === "before teardown") closeTab(1);
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{
cdp,
tabManagement: {
tabs,
windows,
agentOverlayReset: { resetAgentOverlays: vi.fn(async () => {}) },
},
tabsQuery: makeQuery(state),
},
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
const closedTabId = timing === "before its turn in the return loop" ? 2 : 1;
expect(res.returned_tab_ids).toEqual([closedTabId === 1 ? 2 : 1]);
expect(res.return_failures).toBeUndefined();
expect(ctx.borrowedTabs.size).toBe(0);
expect(sm.has("aa11")).toBe(false);
expect(cdp.detachSession).toHaveBeenCalledWith("aa11");
expect(aw.remove).toHaveBeenCalledWith(100);
if (timing === "before teardown" || timing === "before its turn in the return loop") {
expect(tabs.move).not.toHaveBeenCalledWith(closedTabId, expect.anything());
}
});
});
describe("handleSessionStop window release (issue #57)", () => {
const agentWindowId = 100;
it("releases the window when user-created tabs remain", async () => {
const aw = fakeAgentWindow([agentWindowId]);
// ensureActiveTab returns the home tab id (10). We override the fake to
// return a known id.
aw.ensureActiveTab = vi.fn(async () => 10);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(11);
ctx.agentCreatedTabs.add(12);
// user tab 99 (created via Chrome UI, NOT in agentCreatedTabs)
const state: FakeState = {
tabs: new Map([
[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab],
[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab],
[12, { id: 12, windowId: agentWindowId } as chrome.tabs.Tab],
[99, { id: 99, windowId: agentWindowId } as chrome.tabs.Tab],
]),
windowsClosed: new Set(),
moves: [],
};
const { tabs, windows } = makeApis(state);
const query = makeQuery(state);
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{ tabManagement: { tabs, windows }, tabsQuery: query },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.window_released).toBe(true);
// agent tabs + home removed, user tab 99 kept
expect((tabs.remove as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0]).sort()).toEqual([
10, 11, 12,
]);
expect(state.tabs.has(99)).toBe(true);
// window released (dropOnly), not closed
expect(aw.remove).not.toHaveBeenCalled();
expect(sm.has("aa11")).toBe(false);
});
it("closes the window (not release) when no user tabs remain", async () => {
const aw = fakeAgentWindow([agentWindowId]);
aw.ensureActiveTab = vi.fn(async () => 10);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(11);
const state: FakeState = {
tabs: new Map([
[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab],
[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab],
]),
windowsClosed: new Set(),
moves: [],
};
const { tabs, windows } = makeApis(state);
const query = makeQuery(state);
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{ tabManagement: { tabs, windows }, tabsQuery: query },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.window_released).toBeFalsy();
expect(aw.remove).toHaveBeenCalledWith(agentWindowId);
expect(sm.has("aa11")).toBe(false);
});
it("is non-fatal when an agent tab is already gone", async () => {
const aw = fakeAgentWindow([agentWindowId]);
aw.ensureActiveTab = vi.fn(async () => 10);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(11); // no longer in state → remove throws
const state: FakeState = {
tabs: new Map([[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab]]),
windowsClosed: new Set(),
moves: [],
};
const { tabs, windows } = makeApis(state);
(tabs.remove as ReturnType<typeof vi.fn>).mockImplementation(async (id: number) => {
if (id === 11) throw new Error("tab 11 already closed");
state.tabs.delete(id); // home (10) removed normally
});
const query = makeQuery(state);
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{ tabManagement: { tabs, windows }, tabsQuery: query },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
// user tab 10? no — 10 is home, removed. window would be empty → close.
expect(res.window_released).toBeFalsy();
expect(aw.remove).toHaveBeenCalled();
});
it("degrades to close-window when tabsQuery is missing", async () => {
const aw = fakeAgentWindow([agentWindowId]);
aw.ensureActiveTab = vi.fn(async () => 10);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(11);
const state: FakeState = {
tabs: new Map([
[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab],
[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab],
]),
windowsClosed: new Set(),
moves: [],
};
const { tabs, windows } = makeApis(state);
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{ tabManagement: { tabs, windows } }, // no tabsQuery
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
// No query available → conservatively close the window.
expect(res.window_released).toBeFalsy();
expect(aw.remove).toHaveBeenCalledWith(agentWindowId);
});
it("degrades to close-window when tabsQuery.query throws", async () => {
const aw = fakeAgentWindow([agentWindowId]);
aw.ensureActiveTab = vi.fn(async () => 10);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(11);
const state: FakeState = {
tabs: new Map([[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab]]),
windowsClosed: new Set(),
moves: [],
};
const { tabs, windows } = makeApis(state);
const query = makeQuery(state);
(query.query as ReturnType<typeof vi.fn>).mockImplementation(async () => {
throw new Error("query failed");
});
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{ tabManagement: { tabs, windows }, tabsQuery: query },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.window_released).toBeFalsy();
expect(aw.remove).toHaveBeenCalled();
});
it("tracks agentCreatedTabs across tab_create / tab_close", async () => {
const aw = fakeAgentWindow([agentWindowId]);
aw.ensureActiveTab = vi.fn(async () => 10);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
// Simulate handleTabCreate adding, then handleTabClose removing.
ctx.agentCreatedTabs.add(11);
expect(ctx.agentCreatedTabs.has(11)).toBe(true);
ctx.agentCreatedTabs.delete(11); // as handleTabClose does on success
expect(ctx.agentCreatedTabs.has(11)).toBe(false);
// Unknown id delete is a safe no-op (user-closed tab).
expect(() => ctx.agentCreatedTabs.delete(999)).not.toThrow();
});
it("closes the window (not release) when an agent tab fails to close", async () => {
// Regression: a leaked agent tab (still in agentCreatedTabs, but
// still present because Step 4 remove() threw) must NOT be mistaken
// for a user tab. Otherwise the window would be released (dropOnly)
// and the agent tab would leak (issue #57 regression).
const aw = fakeAgentWindow([agentWindowId]);
aw.ensureActiveTab = vi.fn(async () => 10);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(11); // Step 4 remove() will throw for this tab
const state: FakeState = {
tabs: new Map([
[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab],
[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab],
]),
windowsClosed: new Set(),
moves: [],
};
const { tabs, windows } = makeApis(state);
(tabs.remove as ReturnType<typeof vi.fn>).mockImplementation(async (id: number) => {
if (id === 11) throw new Error("tab 11 failed to close");
state.tabs.delete(id); // home (10) removed normally; 11 lingers
});
const query = makeQuery(state);
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{ tabManagement: { tabs, windows }, tabsQuery: query },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
// The leaked agent tab must NOT keep the window open.
expect(res.window_released).toBeFalsy();
expect(aw.remove).toHaveBeenCalledWith(agentWindowId);
expect(sm.has("aa11")).toBe(false);
});
});
@@ -12,7 +12,7 @@ function fakeAgentWindow() {
return {
create: vi.fn(async () => 100),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
};
}
@@ -11,7 +11,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
},
remove: async () => {},
ensureActiveTab: async () => {},
ensureActiveTab: async () => 1,
};
}
+181 -1
View File
@@ -1,5 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { type CdpDebuggerApi, ChromiumCdp } from "@/browser-driver/chromium-cdp";
import { SessionManager } from "@/session-manager/manager";
import { handleHover } from "../interaction";
import {
type AgentOverlayResetApi,
type ChromeWindowsApi,
@@ -23,7 +25,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
};
}
@@ -221,6 +223,7 @@ describe("handleTabCreate", () => {
active: true,
});
expect(res).toMatchObject({ tab_id: 10, window_id: 100 });
expect(sm.get("aa11")?.agentCreatedTabs.has(10)).toBe(true);
});
it("forwards explicit url + index + active=false", async () => {
@@ -256,6 +259,41 @@ describe("handleTabCreate", () => {
const res = await handleTabCreate(sm, { session_id: "aa11", index: -1 }, { tabs: api });
expect(res).toMatchObject({ code: "invalid_params" });
});
it("does not claim any tab when chrome.tabs.create fails", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const state: FakeTabState = { tabs: new Map(), nextTabId: 10, windowsClosed: new Set() };
const { api, spies } = makeTabMutationApi(state);
spies.create.mockRejectedValueOnce(new Error("chrome.tabs.create failed"));
const res = await handleTabCreate(sm, { session_id: "aa11" }, { tabs: api });
expect(res).toMatchObject({ code: "protocol_error" });
expect(sm.get("aa11")?.agentCreatedTabs).toEqual(new Set([1]));
});
it("claims the returned id even when a user tab appears during tab creation", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
const state: FakeTabState = { tabs: new Map(), nextTabId: 10, windowsClosed: new Set() };
const { api, spies } = makeTabMutationApi(state);
let resolveCreate!: (tab: chrome.tabs.Tab) => void;
const createResult = new Promise<chrome.tabs.Tab>((resolve) => {
resolveCreate = resolve;
});
spies.create.mockImplementationOnce(() => createResult);
const pending = handleTabCreate(sm, { session_id: "aa11" }, { tabs: api });
// A user can create an unrelated same-window tab while Chrome is still
// resolving the agent request. There is no pending slot for it to steal.
expect(ctx.agentCreatedTabs.has(99)).toBe(false);
resolveCreate({ id: 10, windowId: 100, active: true } as chrome.tabs.Tab);
await expect(pending).resolves.toMatchObject({ tab_id: 10 });
expect(ctx.agentCreatedTabs.has(10)).toBe(true);
expect(ctx.agentCreatedTabs.has(99)).toBe(false);
});
});
describe("handleTabClose", () => {
@@ -511,6 +549,148 @@ describe("handleTabBorrow", () => {
});
describe("handleTabReturn", () => {
it.each([
"not_found",
"cdp_failed",
])("detaches after selector resolution fails with %s", async (code) => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api } = makeTabMutationApi(state);
const { api: windows } = makeWindowsApi(state);
const debuggerApi = {
attach: vi.fn(async () => {}),
detach: vi.fn(async () => {}),
sendCommand: vi.fn(async (_target: unknown, method: string) => {
if (method === "DOM.getDocument") return { root: { nodeId: 1 } };
if (method === "DOM.querySelector") {
if (code === "cdp_failed") throw new Error("selector query failed");
return { nodeId: 0 };
}
return {};
}),
onEvent: { addListener: vi.fn(), removeListener: vi.fn() },
onDetach: { addListener: vi.fn(), removeListener: vi.fn() },
};
const cdp = new ChromiumCdp(debuggerApi as unknown as CdpDebuggerApi);
const hover = await handleHover(
sm,
{ session_id: "aa11", tab_id: 7, selector: "#missing" },
{
cdp,
tabsApi: { get: api.get, query: vi.fn(async () => []) },
},
);
expect(hover).toMatchObject({ code });
expect(cdp.isAttached(7)).toBe(true);
const returned = await handleTabReturn(
sm,
{ session_id: "aa11", tab_id: 7 },
{
tabs: api,
windows,
cdp,
agentOverlayReset: { resetAgentOverlays: vi.fn(async () => {}) },
},
);
expect(returned).toMatchObject({ returned_to_window_id: 200 });
expect(ctx.borrowedTabs.has(7)).toBe(false);
expect(cdp.isAttached(7)).toBe(false);
expect(debuggerApi.detach).toHaveBeenCalledExactlyOnceWith({ tabId: 7 });
cdp.dispose();
});
it("honours cancellation while preparing a validated return", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api, spies } = makeTabMutationApi(state);
const { api: windows } = makeWindowsApi(state);
const controller = new AbortController();
const beforeReturn = vi.fn(async () => {
controller.abort();
});
const result = await handleTabReturn(
sm,
{ session_id: "aa11", tab_id: 7 },
{
tabs: api,
windows,
signal: controller.signal,
beforeReturn,
},
);
expect(result).toMatchObject({ code: "cancelled" });
expect(beforeReturn).toHaveBeenCalledExactlyOnceWith("aa11", 7);
expect(spies.move).not.toHaveBeenCalled();
expect(ctx.borrowedTabs.has(7)).toBe(true);
});
it.each([
"original",
"fallback",
"cleanup_failed",
"failed",
"cancelled",
])("releases CDP only after a successful return (%s)", async (mode) => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api, spies } = makeTabMutationApi(state);
const { api: windowsApi } = makeWindowsApi(state, { lastFocused: 500 });
const success = mode !== "failed" && mode !== "cancelled";
if (mode === "fallback") spies.move.mockRejectedValueOnce(new Error("original move failed"));
if (mode === "failed") spies.move.mockRejectedValue(new Error("move failed"));
let windowAtRelease: number | undefined;
const cdp = {
releaseSessionTab: vi.fn(async () => {
windowAtRelease = state.tabs.get(7)?.windowId;
if (mode === "cleanup_failed") throw new Error("debugger release failed");
}),
};
const controller = new AbortController();
if (mode === "cancelled") controller.abort();
const result = await handleTabReturn(
sm,
{ session_id: "aa11", tab_id: 7 },
{
tabs: api,
windows: windowsApi,
cdp,
signal: controller.signal,
agentOverlayReset: { resetAgentOverlays: vi.fn(async () => {}) },
},
);
expect("code" in result).toBe(!success);
expect(ctx.borrowedTabs.has(7)).toBe(!success);
expect(cdp.releaseSessionTab).toHaveBeenCalledTimes(success ? 1 : 0);
if (success) {
expect(cdp.releaseSessionTab).toHaveBeenCalledWith("aa11", 7);
expect(windowAtRelease).toBe(mode === "fallback" ? 500 : 200);
expect(spies.move).toHaveBeenCalledTimes(mode === "fallback" ? 2 : 1);
}
});
it("returns the tab to its original window/index and clears borrowedTabs", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
@@ -12,7 +12,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
};
}
@@ -11,7 +11,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
});
const remove = vi.fn(async () => {});
const ensureActiveTab = vi.fn(async () => {});
const ensureActiveTab = vi.fn(async () => 1);
return { create, remove, ensureActiveTab };
}
@@ -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
@@ -0,0 +1,110 @@
import type { RpcError, WaitUntil } from "@/transport/types";
import { cdpError } from "./errors";
interface NavigationEvent {
tabId: number;
frameId: number;
documentId?: string;
error?: string;
}
interface NavigationEvents {
addListener(listener: (details: NavigationEvent) => void): void;
removeListener(listener: (details: NavigationEvent) => void): void;
}
/** Normal browser navigation remains available when chrome.debugger is denied. */
export interface BrowserNavigationApi {
update(tabId: number, props: { url: string }): Promise<unknown>;
reload(tabId: number, props: { bypassCache: boolean }): Promise<void>;
onCommitted: NavigationEvents;
onDOMContentLoaded: NavigationEvents;
onCompleted: NavigationEvents;
onErrorOccurred: NavigationEvents;
}
export const chromeBrowserNavigationApi: BrowserNavigationApi = {
update: (tabId, props) => chrome.tabs.update(tabId, props),
reload: (tabId, props) => chrome.tabs.reload(tabId, props),
get onCommitted() {
return chrome.webNavigation.onCommitted;
},
get onDOMContentLoaded() {
return chrome.webNavigation.onDOMContentLoaded;
},
get onCompleted() {
return chrome.webNavigation.onCompleted;
},
get onErrorOccurred() {
return chrome.webNavigation.onErrorOccurred;
},
};
export type NavigationOutcome =
| { reached: "match" | "timeout" | "cancelled"; lastLifecycle?: string }
| { reached: "failed"; error: RpcError };
/** Subscribe before dispatch and ignore the departing document's load events. */
export async function navigateWithBrowserApi(
api: BrowserNavigationApi,
tabId: number,
action: () => Promise<unknown>,
waitUntil: Exclude<WaitUntil, "networkidle">,
timeoutMs: number,
signal?: AbortSignal,
): Promise<NavigationOutcome> {
if (signal?.aborted) return { reached: "cancelled" };
if (timeoutMs <= 0) return { reached: "timeout" };
let committed = false;
let documentId: string | undefined;
let lastLifecycle: string | undefined;
let finish!: (outcome: NavigationOutcome) => void;
const outcome = new Promise<NavigationOutcome>((resolve) => {
finish = resolve;
});
const isMainFrame = (details: NavigationEvent) =>
details.tabId === tabId && details.frameId === 0;
const onCommitted = (details: NavigationEvent) => {
if (!isMainFrame(details)) return;
committed = true;
documentId = details.documentId;
lastLifecycle = "commit";
if (waitUntil === "commit") finish({ reached: "match", lastLifecycle });
};
const onDOMContentLoaded = (details: NavigationEvent) => {
if (!isMainFrame(details) || !committed || (documentId && details.documentId !== documentId))
return;
lastLifecycle = "DOMContentLoaded";
if (waitUntil === "domcontentloaded") finish({ reached: "match", lastLifecycle });
};
const onCompleted = (details: NavigationEvent) => {
if (!isMainFrame(details) || !committed || (documentId && details.documentId !== documentId))
return;
finish({ reached: "match", lastLifecycle: "load" });
};
const onError = (details: NavigationEvent) => {
if (!isMainFrame(details)) return;
finish({ reached: "failed", error: cdpError(details.error ?? "browser navigation failed") });
};
const onAbort = () => finish({ reached: "cancelled", lastLifecycle });
const subscriptions = [
[api.onCommitted, onCommitted],
[api.onDOMContentLoaded, onDOMContentLoaded],
[api.onCompleted, onCompleted],
[api.onErrorOccurred, onError],
] as const;
for (const [event, listener] of subscriptions) event.addListener(listener);
signal?.addEventListener("abort", onAbort, { once: true });
const timer = setTimeout(() => finish({ reached: "timeout", lastLifecycle }), timeoutMs);
try {
if (signal?.aborted) return { reached: "cancelled" };
await action();
return await outcome;
} catch (error) {
return { reached: "failed", error: cdpError(error) };
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
for (const [event, listener] of subscriptions) event.removeListener(listener);
}
}
+38 -7
View File
@@ -36,6 +36,7 @@ import { isRequestFrame } from "@/transport/types";
import { handleConsole } from "./console";
import { handleDownload } from "./download";
import { type EmulateCdpRunner, handleEmulate } from "./emulate";
import { classifyCdpError } from "./errors";
import { handleEvaluate } from "./evaluate";
import { handleRequestHelp } from "./human-loop";
import { handleClick, handleFill, handleHover, handlePress, handleSelect } from "./interaction";
@@ -69,6 +70,7 @@ import {
import { chromeTabsApi, lookupSession, resolveTargetTab } from "./shared";
import {
type BorrowConfirmationApprover,
chromeTabMutationApi,
handleTabBorrow,
handleTabClose,
handleTabCreate,
@@ -118,6 +120,8 @@ export interface DispatcherDeps {
onSessionsChanged?: () => void;
/** Invoked before a tool that dispatches page input or mutates browser state is forwarded. */
onBrowserControlResumed?: (sessionId: string) => void;
/** Invoked after a tab is explicitly claimed so its overlay can be refreshed immediately. */
onAgentTabClaimed?: (tabId: number, windowId: number) => void;
/** User approval for `tool.tab_borrow` (overlay in content script). */
approveBorrow?: BorrowConfirmationApprover;
/** i18n notification copy for `tool.request_help` (resolved per-call). */
@@ -147,6 +151,7 @@ export class ToolDispatcher {
private readonly recording?: RecordRuntimeDeps;
private readonly onSessionsChanged?: () => void;
private readonly onBrowserControlResumed?: (sessionId: string) => void;
private readonly onAgentTabClaimed?: (tabId: number, windowId: number) => void;
private readonly approveBorrow?: BorrowConfirmationApprover;
private readonly helpNotificationCopy?: () => { title: string; body: string };
private subscription: { dispose(): void } | null = null;
@@ -167,6 +172,7 @@ export class ToolDispatcher {
this.recording = deps.recording;
this.onSessionsChanged = deps.onSessionsChanged;
this.onBrowserControlResumed = deps.onBrowserControlResumed;
this.onAgentTabClaimed = deps.onAgentTabClaimed;
this.approveBorrow = deps.approveBorrow;
this.helpNotificationCopy = deps.helpNotificationCopy;
}
@@ -234,7 +240,7 @@ export class ToolDispatcher {
if (sessionId) this.onBrowserControlResumed?.(sessionId);
const result = await this.invoke(req, ac.signal);
if (isRpcError(result)) {
body = { id: req.id, error: result };
body = { id: req.id, error: classifyCdpError(result) };
} else {
body = { id: req.id, result };
if (req.method === "tool.session_start") {
@@ -302,13 +308,25 @@ export class ToolDispatcher {
await this.releaseHoverLatch((req.params as SessionStopParams).session_id);
return handleSessionStop(this.sessions, req.params as SessionStopParams, {
cdp: this.cdp,
// Must be wired in production: the agent-tab cleanup and the
// window-release decision (issue #57) read these deps directly
// and silently no-op when they are absent.
tabManagement: { tabs: chromeTabMutationApi },
tabsQuery: chromeTabsApi,
signal,
});
}
case "tool.tab_list":
return handleTabList(this.sessions, req.params as TabListParams, chromeTabsApi, signal);
case "tool.tab_create":
return handleTabCreate(this.sessions, req.params as TabCreateParams, { signal });
case "tool.tab_create": {
const result = await handleTabCreate(this.sessions, req.params as TabCreateParams, {
signal,
});
if (!isRpcError(result)) {
this.onAgentTabClaimed?.(result.tab_id, result.window_id);
}
return result;
}
case "tool.tab_close":
return this.withHoverReleaseForRequest(
req.params as TabCloseParams,
@@ -317,13 +335,22 @@ export class ToolDispatcher {
);
case "tool.tab_select":
return handleTabSelect(this.sessions, req.params as TabSelectParams, { signal });
case "tool.tab_borrow":
return handleTabBorrow(this.sessions, req.params as TabBorrowParams, {
case "tool.tab_borrow": {
const result = await handleTabBorrow(this.sessions, req.params as TabBorrowParams, {
signal,
approveBorrow: this.approveBorrow,
});
if (!isRpcError(result)) {
this.onAgentTabClaimed?.(result.tab_id, result.agent_window_id);
}
return result;
}
case "tool.tab_return":
return handleTabReturn(this.sessions, req.params as TabReturnParams, { signal });
return handleTabReturn(this.sessions, req.params as TabReturnParams, {
signal,
cdp: this.cdp,
beforeReturn: (sessionId, tabId) => this.releaseHoverLatch(sessionId, tabId),
});
case "tool.window_resize":
return handleWindowResize(
this.sessions,
@@ -387,7 +414,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,
+28
View File
@@ -13,6 +13,34 @@ import type {
export type { RpcErrorData, RpcErrorReason };
/** Chrome checks the whole tab's frame tree, including other extensions' frames. */
export function isCdpExtensionAccessDenied(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes("Cannot access a chrome-extension:// URL of different extension");
}
/** Preserve more specific reasons, especially file-transfer outcome/cleanup details. */
export function classifyCdpError(error: RpcError): RpcError {
if (
error.code !== "cdp_failed" ||
error.data?.reason ||
!isCdpExtensionAccessDenied(error.message)
) {
return error;
}
return {
...error,
data: { ...error.data, reason: "cdp_extension_access_denied" },
};
}
export function cdpError(error: unknown): RpcError {
return classifyCdpError({
code: "cdp_failed",
message: error instanceof Error ? error.message : String(error),
});
}
export interface TransferErrorOptions {
effectState: TransferEffectState;
phase: string;
+282 -42
View File
@@ -183,6 +183,8 @@ export async function resolveBackendNode(
}
// selector path
try {
// Selector lookup itself attaches CDP, even when no element is found.
cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
const doc = await cdp.send<{ root?: { nodeId?: number } }>(target.tabId, "DOM.getDocument", {
depth: 0,
});
@@ -607,6 +609,53 @@ type SelectMutationResult =
// tool.fill
// ---------------------------------------------------------------------------
type FillFailureReason =
| "target_not_fillable"
| "fill_value_invalid"
| "fill_target_changed"
| "fill_focus_lost"
| "fill_value_mismatch"
| "fill_failed";
type FillPreparation =
| { before: string; expected: string }
| { reason: FillFailureReason; error: string };
type FillReadiness = "ready" | "background" | "fill_target_changed" | "fill_focus_lost";
function fillError(reason: FillFailureReason, message: string): RpcError {
return rpcError(
reason === "target_not_fillable" || reason === "fill_value_invalid"
? "invalid_params"
: "cdp_failed",
reason,
message,
);
}
interface FillScriptReply<T> {
result?: { value?: T };
exceptionDetails?: unknown;
}
const FILL_EDITABLE_FUNCTION = `function() {
const tag = this.tagName.toLowerCase();
const supported = tag === 'input'
? ['text', 'search', 'tel', 'url', 'email', 'password', 'number'].includes(this.type)
: tag === 'textarea' || this.isContentEditable;
return this.isConnected && supported && !this.readOnly && !this.matches(':disabled');
}`;
// Chrome renders a trailing editable newline with an empty <div><br></div>.
// innerText includes the padding break; it is not an extra typed character.
const FILL_VALUE_FUNCTION = `function() {
if (!this.isContentEditable) return this.value;
const value = this.innerText;
const tail = this.lastChild;
const padding = tail && tail.nodeName === 'DIV' && tail.childNodes.length === 1 && tail.firstChild.nodeName === 'BR';
return padding && value.endsWith('\\n\\n') ? value.slice(0, -1) : value;
}`;
export async function handleFill(
manager: SessionManager,
params: FillParams,
@@ -658,12 +707,8 @@ export async function handleFill(
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
return fillError("fill_failed", err instanceof Error ? err.message : String(err));
}
if (throwIfAborted(deps.signal)) {
@@ -676,57 +721,252 @@ export async function handleFill(
const clearBefore = params.clear_before ?? true;
try {
if (clearBefore) {
// Clear input/textarea value or wipe contenteditable innerText,
// then fire `input` so frameworks observe the empty state.
await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", {
objectId,
functionDeclaration: `function() {
if (this.isContentEditable) { this.textContent = ''; }
else {
const proto = this instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
if (descriptor && descriptor.set) descriptor.set.call(this, '');
else this.value = '';
}
this.dispatchEvent(new Event('input', { bubbles: true }));
}`,
returnByValue: true,
});
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
try {
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
} catch (error) {
// Chrome rejects DOM.focus for disabled controls, including inherited
// fieldset state. Classify the live target before treating this as a
// browser failure; the normal path needs no extra round trip.
const editable = await nodeCdp.send<FillScriptReply<boolean>>(
target.tabId,
"Runtime.callFunctionOn",
{ objectId, functionDeclaration: FILL_EDITABLE_FUNCTION, returnByValue: true },
);
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
if (!editable.exceptionDetails && editable.result?.value === false) {
return fillError(
"target_not_fillable",
"fill target is not editable or its input type is unsupported",
);
}
throw error;
}
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
// Check editability before clearing. Keep the expected result tied to
// this object, including the existing value on the append path.
const prepared = await nodeCdp.send<FillScriptReply<FillPreparation>>(
target.tabId,
"Runtime.callFunctionOn",
{
objectId,
functionDeclaration: `function(value, clearBefore) {
const tag = this.tagName.toLowerCase();
const native = tag === 'input' || tag === 'textarea';
if (!(${FILL_EDITABLE_FUNCTION}).call(this)) return { reason: 'target_not_fillable', error: 'fill target is not editable or its input type is unsupported' };
if (this.getRootNode().activeElement !== this) return { reason: 'fill_focus_lost', error: 'fill target does not have focus' };
const before = clearBefore ? '' : (${FILL_VALUE_FUNCTION}).call(this);
let expected = before + value;
if (native) {
// Use the browser's own value sanitization (e.g. textarea
// line endings) without changing the live control.
const normalizer = this.ownerDocument.createElement(tag);
if (tag === 'input') {
normalizer.type = this.type;
normalizer.multiple = this.multiple;
}
// insertText treats line breaks in a single-line input as spaces.
if (tag === 'input') expected = expected.replace(/\\r\\n|\\r|\\n/g, ' ');
normalizer.value = expected;
if (expected !== '' && normalizer.value === '' && this.type === 'number') {
return { reason: 'fill_value_invalid', error: 'fill value is not valid for a number input' };
}
expected = normalizer.value;
if (this.type !== 'number' && this.maxLength >= 0 && expected.length > this.maxLength) {
return { reason: 'fill_value_invalid', error: 'fill value exceeds the target maxlength' };
}
} else {
expected = expected.replace(/\\r\\n?/g, '\\n');
}
if (clearBefore) {
if (native) {
const proto = tag === 'textarea' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(proto, 'value').set.call(this, '');
} else {
this.textContent = '';
}
this.dispatchEvent(new Event('input', { bubbles: true }));
}
return { before, expected };
}`,
arguments: [{ value: params.value }, { value: clearBefore }],
returnByValue: true,
},
);
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
if (prepared.exceptionDetails) {
return fillError("fill_failed", "fill preparation script failed");
}
const preparation = prepared.result?.value;
if (preparation && "error" in preparation) {
return fillError(preparation.reason, preparation.error);
}
if (typeof preparation?.before !== "string" || typeof preparation?.expected !== "string") {
return fillError("fill_failed", "fill preparation returned an unexpected result");
}
// A separate call runs after the clearing event's microtasks drain.
// DOM focus works in background tabs. Use document.hasFocus() only to
// detect deferred focus events, never to reject background input.
const checkReady = async (): Promise<FillReadiness | RpcError> => {
const reply = await nodeCdp.send<FillScriptReply<FillReadiness>>(
target.tabId,
"Runtime.callFunctionOn",
{
objectId,
functionDeclaration: `function(before) {
if (!(${FILL_EDITABLE_FUNCTION}).call(this) ||
(${FILL_VALUE_FUNCTION}).call(this) !== before) return 'fill_target_changed';
if (this.getRootNode().activeElement !== this) return 'fill_focus_lost';
return this.ownerDocument.hasFocus() ? 'ready' : 'background';
}`,
arguments: [{ value: preparation.before }],
returnByValue: true,
},
);
const state = reply.result?.value;
if (
reply.exceptionDetails ||
(state !== "ready" &&
state !== "background" &&
state !== "fill_target_changed" &&
state !== "fill_focus_lost")
) {
return fillError(
"fill_failed",
"fill readiness script failed or returned an unexpected result",
);
}
return state;
};
let ready = await checkReady();
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
// Restore focus once only if clearing left this same target editable
// and unchanged. Recheck after focus handlers have run.
if (clearBefore && ready === "fill_focus_lost") {
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
ready = await checkReady();
}
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
if (typeof ready !== "string") return ready;
if (ready !== "ready" && ready !== "background") {
return fillError(ready, "fill target changed or lost focus before typing");
}
if (params.value !== "" && (!clearBefore || ready === "background")) {
// Native editing commands also support number/email inputs, whose
// selection APIs cannot set a caret, and multiline contenteditables.
// Also do this for background replacement: CDP input focuses the
// renderer, delivering deferred focus events before it inserts text.
// Use a command-only event so this does not invoke an End shortcut.
try {
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
type: "rawKeyDown",
commands: ["moveToEndOfDocument"],
});
} finally {
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", { type: "keyUp" });
}
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
// Focus and key handlers can change the target or move focus too.
ready = await checkReady();
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
if (typeof ready !== "string") return ready;
if (ready !== "ready" && ready !== "background") {
return fillError(ready, "fill target changed or lost focus while positioning the caret");
}
}
// CDP `Input.insertText` handles IME / multi-byte input out of the
// box, much more reliably than per-key `dispatchKeyEvent`.
await deps.cdp.send(target.tabId, "Input.insertText", { text: params.value });
if (params.value !== "") {
await deps.cdp.send(target.tabId, "Input.insertText", { text: params.value });
}
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
// Fire `input` + `change` so React / Vue controlled inputs commit.
await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", {
// Keep the existing notifications, then read in a separate call so
// nested microtasks queued by the page cannot race the verification.
const notified = await nodeCdp.send<FillScriptReply<unknown>>(
target.tabId,
"Runtime.callFunctionOn",
{
objectId,
functionDeclaration: `function() {
this.dispatchEvent(new Event('input', { bubbles: true }));
this.dispatchEvent(new Event('change', { bubbles: true }));
}`,
returnByValue: true,
},
);
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
if (notified.exceptionDetails) {
return fillError("fill_failed", "fill notification script failed");
}
const verified = await nodeCdp.send<
FillScriptReply<{ connected: boolean; matches: boolean; valueLength: number }>
>(target.tabId, "Runtime.callFunctionOn", {
objectId,
functionDeclaration: `function() {
this.dispatchEvent(new Event('input', { bubbles: true }));
this.dispatchEvent(new Event('change', { bubbles: true }));
}`,
functionDeclaration: `function(expected) {
const value = (${FILL_VALUE_FUNCTION}).call(this);
return { connected: this.isConnected, matches: value === expected, valueLength: value.length };
}`,
arguments: [{ value: preparation.expected }],
returnByValue: true,
});
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
const verification = verified.result?.value;
if (
verified.exceptionDetails ||
typeof verification?.connected !== "boolean" ||
typeof verification?.matches !== "boolean" ||
typeof verification?.valueLength !== "number"
) {
return fillError(
"fill_failed",
"fill verification script failed or returned an unexpected result",
);
}
if (!verification.connected) {
return fillError("fill_target_changed", "fill target was removed or replaced during input");
}
if (!verification.matches) {
return fillError(
"fill_value_mismatch",
"fill could not verify the expected value; observe the page before deciding whether to retry",
);
}
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId,
used_ref: node.usedRef,
used_selector: node.usedSelector,
value_length: verification.valueLength,
});
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
return fillError("fill_failed", err instanceof Error ? err.message : String(err));
} finally {
await nodeCdp.send(target.tabId, "Runtime.releaseObject", { objectId }).catch(() => undefined);
}
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId,
used_ref: node.usedRef,
used_selector: node.usedSelector,
value_length: params.value.length,
});
}
// ---------------------------------------------------------------------------
+116 -10
View File
@@ -28,7 +28,13 @@ import type {
RpcError,
WaitUntil,
} from "@/transport/types";
import {
type BrowserNavigationApi,
chromeBrowserNavigationApi,
navigateWithBrowserApi,
} from "./browser-navigation";
import { attachDialogs, markDialogCursor } from "./dialogs";
import { cdpError, isCdpExtensionAccessDenied } from "./errors";
import {
type CdpRunner,
type ChromeTabsApi,
@@ -42,6 +48,7 @@ import {
export interface NavigationDeps {
cdp: CdpRunner;
tabsApi: ChromeTabsApi;
browserNavigation?: BrowserNavigationApi;
/** Optional AbortSignal — M7 abort hook (M10.2 will wire the full chain). */
signal?: AbortSignal;
/** Override default timeout when the caller omits `timeout_ms`. */
@@ -479,6 +486,81 @@ async function readTabUrl(api: ChromeTabsApi, tabId: number): Promise<string | u
}
}
/** Recover only a failed preflight; never replay an already-dispatched navigation. */
async function recoverBrowserNavigation(
deps: NavigationDeps,
tabId: number,
action: (api: BrowserNavigationApi) => Promise<unknown>,
waitUntil: WaitUntil,
timeoutMs: number,
): Promise<ReloadResult | RpcError> {
const abort = linkedAbortSignal(deps.signal);
const deadline = Date.now() + timeoutMs;
const api = deps.browserNavigation ?? chromeBrowserNavigationApi;
try {
let outcome = await navigateWithBrowserApi(
api,
tabId,
() => action(api),
waitUntil === "networkidle" ? "commit" : waitUntil,
timeoutMs,
abort.signal,
);
if (outcome.reached === "failed") return outcome.error;
if (outcome.reached === "cancelled" || abort.signal.aborted) {
return { code: "cancelled", message: "navigation recovery aborted" };
}
if (outcome.reached === "match") {
// A completed reload is not a recovery if the restricted frame remains.
await deps.cdp.send(tabId, "Page.enable", {});
if (abort.signal.aborted)
return { code: "cancelled", message: "navigation recovery aborted" };
if (waitUntil === "networkidle") {
// webNavigation cannot prove network idle. Subscribe on the new
// document before enabling CDP lifecycle events after its commit.
const frame = await readMainFrameInfo(deps.cdp, tabId);
if (!frame.frameId) return cdpError("no main frame after navigation");
const remaining = deadline - Date.now();
if (remaining <= 0) {
outcome = { reached: "timeout", lastLifecycle: "commit" };
} else {
const wait = startLifecycleWait(
deps.cdp,
tabId,
frame.frameId,
"networkIdle",
remaining,
abort.signal,
{ loaderId: frame.loaderId ?? undefined },
);
await deps.cdp.send(tabId, "Page.setLifecycleEventsEnabled", { enabled: true });
outcome = await wait.promise;
}
}
}
if (outcome.reached === "cancelled" || abort.signal.aborted) {
return { code: "cancelled", message: "navigation recovery aborted" };
}
return {
tab_id: tabId,
final_url: await readTabUrl(deps.tabsApi, tabId),
reached: outcome.reached === "match" ? waitUntil : "timeout",
...(outcome.reached === "timeout"
? {
error_text: `timed out waiting for ${waitUntil} during navigation recovery after ${timeoutMs}ms`,
}
: {}),
};
} catch (error) {
return abort.signal.aborted
? { code: "cancelled", message: "navigation recovery aborted" }
: cdpError(error);
} finally {
abort.abort();
abort.cleanup();
}
}
// ---------------------------------------------------------------------------
// tool.navigate
// ---------------------------------------------------------------------------
@@ -505,7 +587,20 @@ export async function handleNavigate(
try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await ensureCdpReady(deps.cdp, target.tabId);
try {
await ensureCdpReady(deps.cdp, target.tabId);
} catch (error) {
if (!isCdpExtensionAccessDenied(error)) throw error;
const recovered = await recoverBrowserNavigation(
deps,
target.tabId,
(api) => api.update(target.tabId, { url: params.url }),
waitUntil,
timeoutMs,
);
if (isRpcError(recovered)) return recovered;
return attachDialogs(deps.cdp, target.tabId, dialogCursor, { ...recovered, url: params.url });
}
const expected = cdpLifecycleName(waitUntil);
const beforeReadyState = await probeMainFrameReadyState(deps.cdp, target.tabId);
const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId);
@@ -574,10 +669,7 @@ export async function handleNavigate(
}`,
});
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
return cdpError(err);
}
}
@@ -727,7 +819,24 @@ export async function handleReload(
try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await ensureCdpReady(deps.cdp, target.tabId);
try {
await ensureCdpReady(deps.cdp, target.tabId);
} catch (error) {
if (!isCdpExtensionAccessDenied(error)) throw error;
const previousUrl = await readTabUrl(deps.tabsApi, target.tabId);
const recovered = await recoverBrowserNavigation(
deps,
target.tabId,
(api) => api.reload(target.tabId, { bypassCache: ignoreCache }),
waitUntil,
timeoutMs,
);
if (isRpcError(recovered)) return recovered;
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
...recovered,
previous_url: previousUrl,
});
}
const previousUrl = await readTabUrl(deps.tabsApi, target.tabId);
const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId);
const expected = cdpLifecycleName(waitUntil);
@@ -776,10 +885,7 @@ export async function handleReload(
}`,
});
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
return cdpError(err);
}
}
File diff suppressed because it is too large Load Diff
+94 -8
View File
@@ -2,6 +2,7 @@ import { type SessionManager, SessionStartCleanupError } from "@/session-manager
import type { RpcError } from "@/transport/types";
import { rpcError } from "./errors";
import { clearRecordingForSession } from "./record";
import type { CdpRunner, ChromeTabsApi } from "./shared";
import { isRpcError } from "./shared";
import { returnBorrowedTab, type TabManagementDeps } from "./tabs";
@@ -73,11 +74,17 @@ export interface SessionStopResult {
/** Tab ids whose return path failed; those entries remain borrowed so
* shutdown can be retried without closing the Agent Window. */
return_failures?: Array<{ tab_id: number; code: string; message: string }>;
/**
* True when the Agent Window was *released* to the user (instead of being
* closed) because it still contained user-created tabs after the agent's own
* tabs were closed. Single-session scope.
*/
window_released?: boolean;
}
export interface SessionStopDeps {
signal?: AbortSignal;
cdp?: {
cdp?: Pick<CdpRunner, "releaseSessionTab"> & {
detachSession(sessionId: string): Promise<void>;
};
/**
@@ -87,6 +94,13 @@ export interface SessionStopDeps {
* without a real browser.
*/
tabManagement?: TabManagementDeps;
/**
* Read-only Chrome tabs API used to *query* remaining tabs before deciding
* whether to release the window. Kept separate from `tabManagement.tabs`
* (a `TabMutationApi` that has no `query`) so the mutation interface stays
* pure. Tests can inject a fake `ChromeTabsApi` too.
*/
tabsQuery?: ChromeTabsApi;
}
/**
@@ -152,8 +166,10 @@ export async function handleSessionStart(
* cleanly (review parity with M6).
* 3. Detach CDP sessions the extension still holds for this
* session (no-op if M6/M7 didn't attach to any tab).
* 4. Close the Agent Window. SessionManager.stop() removes the
* Chrome window and forgets the context.
* 4. Close the agent-created tabs and the home tab (design §3.2).
* 5. If any tab remains — those are user-created tabs — release the
* window to the user (dropOnly) instead of closing it, so user
* tabs survive. Otherwise close the window as before.
*
* Failures in step 1 keep the Agent Window open: a failed borrowed tab
* may still be there, so closing the window would risk losing user
@@ -188,15 +204,19 @@ export async function handleSessionStop(
const returnFailures: SessionStopResult["return_failures"] = [];
const borrowedIds = Array.from(ctx.borrowedTabs.keys());
for (const tabId of borrowedIds) {
// A close event may have removed this borrow while another tab returned.
if (!ctx.borrowedTabs.has(tabId)) continue;
try {
const tabManagement = {
...(deps.tabManagement ?? {}),
cdp: deps.cdp ?? deps.tabManagement?.cdp,
signal: deps.signal,
isAgentWindowId:
deps.tabManagement?.isAgentWindowId ??
((windowId: number) => manager.findByWindowId(windowId) !== null),
};
const outcome = await returnBorrowedTab(ctx, tabId, tabManagement);
if (!ctx.borrowedTabs.has(tabId)) continue;
if (typeof outcome === "object" && "code" in outcome) {
console.warn(`[bsk session_stop] auto-return failed for tab ${tabId}`, outcome);
returnFailures?.push({
@@ -209,6 +229,7 @@ export async function handleSessionStop(
ctx.borrowedTabs.delete(tabId);
}
} catch (err) {
if (!ctx.borrowedTabs.has(tabId)) continue;
const message = err instanceof Error ? err.message : String(err);
console.warn(`[bsk session_stop] auto-return threw for tab ${tabId}: ${message}`);
returnFailures?.push({
@@ -219,8 +240,15 @@ export async function handleSessionStop(
}
}
// A previously failed tab may have closed during a later return. Only
// confirmed close events remove borrows; ordinary move failures stay retryable.
const pendingReturnFailures = returnFailures.filter((failure) =>
ctx.borrowedTabs.has(failure.tab_id),
);
if (deps.signal?.aborted) {
const nonCancelFailures = returnFailures?.filter((failure) => failure.code !== "cancelled");
const nonCancelFailures = pendingReturnFailures.filter(
(failure) => failure.code !== "cancelled",
);
if (nonCancelFailures && nonCancelFailures.length > 0) {
return {
...(returnedTabIds.length > 0 ? { returned_tab_ids: returnedTabIds } : {}),
@@ -232,8 +260,8 @@ export async function handleSessionStop(
const result: SessionStopResult = {};
if (returnedTabIds.length > 0) result.returned_tab_ids = returnedTabIds;
if (returnFailures && returnFailures.length > 0) {
result.return_failures = returnFailures;
if (pendingReturnFailures.length > 0) {
result.return_failures = pendingReturnFailures;
// A failed return means at least one borrowed user tab may still be
// inside the Agent Window. Keep the session/window alive so the user
// can retry `bsk session stop` or explicitly `bsk tab return` after the
@@ -253,8 +281,66 @@ export async function handleSessionStop(
return { code: "cancelled", message: "session_stop aborted before window close" };
}
// Step 4: close the Agent Window and drop the context.
await manager.stop(params.session_id);
// Step 4: close every tab explicitly created by the agent, including the
// home tab. `tabsApi` is a
// TabMutationApi (remove only); `queryApi` is a separate read-only
// ChromeTabsApi. If neither is injected, we conservatively fall back to
// closing the window (see Step 5).
const tabsApi = deps.tabManagement?.tabs;
const queryApi = deps.tabsQuery;
if (tabsApi) {
// Close each agent-created tab that still exists.
const agentCreatedTabIds = Array.from(ctx.agentCreatedTabs);
for (const tabId of agentCreatedTabIds) {
try {
await tabsApi.remove(tabId);
ctx.agentCreatedTabs.delete(tabId);
} catch (err) {
// Tab may already be gone (closed by the user). Non-fatal.
console.warn(`[bsk session_stop] failed to close agent tab ${tabId}`, err);
}
}
}
// Step 5: decide whether to release (keep) the window or close it.
let shouldRelease = false;
if (queryApi) {
try {
const liveWindowTabs = await queryApi.query({ windowId: ctx.agentWindowId });
// Only genuine *user* tabs count toward keeping the window open.
// An agent tab that failed to close in Step 4 may still be present
// here; if we counted it as a reason to release (dropOnly), the
// window would be kept and that agent tab would leak (issue #57
// regression). Exclude any id still tracked in agentCreatedTabs.
const userTabs = liveWindowTabs.filter((t) => {
if (t.id === undefined) return false;
return !ctx.agentCreatedTabs.has(t.id);
});
const leakedAgentTabs = liveWindowTabs.filter(
(t) => t.id !== undefined && ctx.agentCreatedTabs.has(t.id),
);
if (leakedAgentTabs.length > 0) {
console.warn(
`[bsk session_stop] ${leakedAgentTabs.length} agent tab(s) failed to close; forcing window close instead of release`,
leakedAgentTabs.map((t) => t.id),
);
}
shouldRelease = userTabs.length > 0;
} catch {
// Query failed (e.g. window already gone) — conservatively close it.
shouldRelease = false;
}
}
if (shouldRelease) {
// Keep the window + its user tabs; only drop the session binding.
await manager.stop(params.session_id, { dropOnly: true });
result.window_released = true;
} else {
// Window is empty (or we couldn't verify state) — close it.
await manager.stop(params.session_id);
}
return result;
}
+1
View File
@@ -57,6 +57,7 @@ export interface CdpRunner {
getFrameGraph?(tabId: number): Promise<CdpFrameGraph>;
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
trackSessionTab?(sessionId: string, tabId: number): void;
releaseSessionTab?(sessionId: string, tabId: number): Promise<void>;
onEvent?(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
dispose(): void;
};
+46 -29
View File
@@ -10,7 +10,7 @@ import {
import type { SessionContext, SessionManager } from "@/session-manager/manager";
import type { RpcError } from "@/transport/types";
import { rpcError } from "./errors";
import { isRpcError, lookupSession } from "./shared";
import { type CdpRunner, isRpcError, lookupSession } from "./shared";
export type TabScope = "user" | "agent" | "all";
@@ -287,6 +287,10 @@ export interface TabManagementDeps {
approveBorrow?: BorrowConfirmationApprover;
/** Clears Agent-scoped overlays after a borrowed tab is returned. */
agentOverlayReset?: AgentOverlayResetApi;
/** Releases this session's CDP claim after a borrowed tab is returned. */
cdp?: Pick<CdpRunner, "releaseSessionTab">;
/** Runs after tab_return validation, before moving the borrowed tab. */
beforeReturn?: (sessionId: string, tabId: number) => Promise<void>;
/**
* Reports whether `windowId` is any live session's Agent Window.
* `tab_return`'s fallback window picker uses this to avoid moving a
@@ -369,6 +373,7 @@ function buildCreateProps(
* Returns the created tab on success, or an `RpcError` on failure.
*/
async function createTabAndCleanup(
ctx: SessionContext,
deps: TabManagementDeps,
createProps: chrome.tabs.CreateProperties,
): Promise<CreatedChromeTab | RpcError> {
@@ -381,25 +386,28 @@ async function createTabAndCleanup(
message: err instanceof Error ? err.message : String(err),
};
}
if (aborted(deps.signal, "tab_create")) {
// We already opened the tab; close it on abort so we don't leak.
if (typeof tab.id === "number") {
try {
await getTabsApi(deps).remove(tab.id);
} catch (cleanupErr) {
return rpcError(
"protocol_error",
"cleanup_failed",
`tab_create aborted but cleanup of tab ${tab.id} failed: ${describeError(cleanupErr)}`,
{ resource_type: "tab", resource_id: tab.id },
);
}
}
return { code: "cancelled", message: "tab_create aborted" };
}
if (typeof tab.id !== "number") {
return { code: "protocol_error", message: "chrome.tabs.create returned no tab id" };
}
// Claim the concrete id returned by Chrome. Ownership never depends on
// matching this request to an asynchronous onCreated event.
ctx.agentCreatedTabs.add(tab.id);
if (aborted(deps.signal, "tab_create")) {
try {
await getTabsApi(deps).remove(tab.id);
ctx.agentCreatedTabs.delete(tab.id);
} catch (cleanupErr) {
// Keep the claim when cleanup fails so session_stop can retry instead
// of releasing an agent-owned tab to the user.
return rpcError(
"protocol_error",
"cleanup_failed",
`tab_create aborted but cleanup of tab ${tab.id} failed: ${describeError(cleanupErr)}`,
{ resource_type: "tab", resource_id: tab.id },
);
}
return { code: "cancelled", message: "tab_create aborted" };
}
return { ...tab, id: tab.id };
}
@@ -426,7 +434,7 @@ export async function handleTabCreate(
const paramErr = validateTabCreateParams(params);
if (paramErr) return paramErr;
const tab = await createTabAndCleanup(deps, buildCreateProps(ctx, params));
const tab = await createTabAndCleanup(ctx, deps, buildCreateProps(ctx, params));
if (isRpcError(tab)) return tab;
return {
@@ -526,6 +534,9 @@ export async function handleTabClose(
}
try {
await getTabsApi(deps).remove(params.tab_id);
// Keep the tracking set accurate so session_stop won't try to close a
// tab that's already gone (design §3.1).
ctx.agentCreatedTabs.delete(params.tab_id);
} catch (err) {
return {
code: "protocol_error",
@@ -753,13 +764,6 @@ async function executeBorrowCore(
);
if (moveErr) return moveErr;
// Best-effort activation — failure is non-fatal.
try {
await p.tabsApi.update(p.tabId, { active: true });
} catch (err) {
console.debug("[bsk tab_borrow] activate after move failed", err);
}
return { originalWindowId, originalIndex };
}
@@ -823,6 +827,12 @@ export async function handleTabBorrow(
message: err instanceof Error ? err.message : String(err),
};
}
// Activate after commit so overlay/event observers see the tab as claimed.
try {
await tabsApi.update(params.tab_id, { active: true });
} catch (err) {
console.debug("[bsk tab_borrow] activate after move failed", err);
}
return {
tab_id: params.tab_id,
original_window_id: coreResult.originalWindowId,
@@ -939,16 +949,22 @@ async function cleanupUnusedFallbackWindow(
}
}
function resetAgentOverlaysInReturnedTab(
async function releaseReturnedTabState(
ctx: SessionContext,
tabId: number,
deps: TabManagementDeps,
): void {
): Promise<void> {
void getAgentOverlayResetApi(deps)
.resetAgentOverlays(tabId, ctx.sessionId)
.catch((err) => {
console.debug("[bsk tab_return] agent overlay reset failed", err);
});
try {
await deps.cdp?.releaseSessionTab?.(ctx.sessionId, tabId);
} catch (err) {
// A successful move must not be retried because debugger cleanup failed.
console.debug("[bsk tab_return] CDP release failed", err);
}
}
/**
@@ -1022,7 +1038,7 @@ export async function returnBorrowedTab(
});
const movedTab = Array.isArray(moved) ? moved[0] : moved;
const finalIndex = typeof movedTab?.index === "number" ? movedTab.index : targetIndex;
resetAgentOverlaysInReturnedTab(ctx, tabId, deps);
await releaseReturnedTabState(ctx, tabId, deps);
return {
tabId,
toWindowId: targetWindowId,
@@ -1064,7 +1080,7 @@ export async function returnBorrowedTab(
});
const movedTab = Array.isArray(moved) ? moved[0] : moved;
const finalIndex = typeof movedTab?.index === "number" ? movedTab.index : target.index;
resetAgentOverlaysInReturnedTab(ctx, tabId, deps);
await releaseReturnedTabState(ctx, tabId, deps);
return {
tabId,
toWindowId: target.windowId,
@@ -1110,6 +1126,7 @@ export async function handleTabReturn(
message: `tab_return: tab ${params.tab_id} is not borrowed by this session`,
};
}
await deps.beforeReturn?.(ctx.sessionId, params.tab_id);
const outcome = await returnBorrowedTab(ctx, params.tab_id, {
...deps,
isAgentWindowId:
@@ -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")],
@@ -215,21 +215,29 @@ function makeCdp(snapshot: unknown) {
if (method === "Runtime.evaluate") {
return {
result: {
value: {
controls: [
deepSerializedValue: {
type: "array",
value: [
{
state: "filled",
sensitive: true,
defaultValue: "hunter2",
value: "hunter2",
placeholder: "secret",
type: "array",
value: [
{ type: "node", value: { backendNodeId: 13 } },
{
type: "string",
value: JSON.stringify({
state: "filled",
sensitive: true,
placeholder: "secret",
}),
},
],
},
],
childFrames: [],
},
},
};
}
if (method === "Runtime.releaseObjectGroup") return {};
throw new Error(`unexpected ${method}`);
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
};
@@ -251,11 +259,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());
});
@@ -274,8 +281,11 @@ describe("captureViewModel", () => {
expect(cdp.send).toHaveBeenCalledWith(
4,
"Runtime.evaluate",
expect.objectContaining({ returnByValue: true }),
expect.objectContaining({
serializationOptions: expect.objectContaining({ serialization: "deep" }),
}),
);
expect(cdp.send).toHaveBeenCalledWith(4, "Runtime.releaseObjectGroup", expect.anything());
expect(cdp.send).not.toHaveBeenCalledWith(4, "DOM.resolveNode", expect.anything());
expect(cdp.send).not.toHaveBeenCalledWith(4, "Runtime.callFunctionOn", expect.anything());
});
@@ -319,9 +329,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 +389,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 +434,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 +695,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) => {
@@ -0,0 +1,270 @@
import { describe, expect, it, vi } from "vitest";
import { type CdpRunner, cdpRunnerForTarget } from "../../shared";
import { captureViewModel } from "../capture";
interface Control {
id: number;
value: string;
type?: string;
}
function snapshot(groups: Control[][]) {
const strings: string[] = [];
const str = (value: string) => {
let index = strings.indexOf(value);
if (index < 0) index = strings.push(value) - 1;
return index;
};
const documents = groups.map((controls, groupIndex) => ({
frameId: `frame-${groupIndex}`,
nodes: {
parentIndex: [-1, 0, ...controls.map(() => 1)],
nodeName: [str("html"), str("body"), ...controls.map(() => str("input"))],
backendNodeId: [8000 + groupIndex * 2, 8001 + groupIndex * 2, ...controls.map((c) => c.id)],
attributes: [
[],
[],
...controls.map((c) => [
str("type"),
str(c.type ?? "text"),
str("placeholder"),
str("snapshot placeholder"),
]),
],
inputValue: {
index: controls.map((_, i) => i + 2),
value: controls.map((c) => str(c.value)),
},
contentDocumentIndex: { index: [] as number[], value: [] as number[] },
},
}));
const root = documents[0].nodes;
for (let i = 1; i < documents.length; i++) {
root.contentDocumentIndex.index.push(root.backendNodeId.length);
root.contentDocumentIndex.value.push(i);
root.parentIndex.push(1);
root.nodeName.push(str("iframe"));
root.backendNodeId.push(9000 + i);
root.attributes.push([]);
}
return { strings, documents };
}
function state(value: string) {
return {
value,
defaultValue: "",
placeholder: "live placeholder",
state: value ? "filled" : "empty",
sensitive: false,
};
}
function reply(entries: Array<[number, unknown]>) {
return {
result: {
deepSerializedValue: {
type: "array",
value: entries.map(([backendNodeId, fields]) => ({
type: "array",
value: [
{ type: "node", value: { backendNodeId } },
{ type: "string", value: JSON.stringify(fields) },
],
})),
},
},
};
}
function fakeCdp(groups: Control[][], evaluate: (params: Record<string, unknown>) => unknown) {
const snap = snapshot(groups);
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
if (method === "DOMSnapshot.captureSnapshot") return snap;
if (method === "Runtime.evaluate") return evaluate(params as Record<string, unknown>);
return {};
});
return { send, cdp: { send: send as CdpRunner["send"] } };
}
describe("form state identity", () => {
it("matches reordered controls by identity and preserves controls missing from the batch", async () => {
// The snapshot includes a shadow input. The runtime query skips it,
// encounters a new input, and sees the remaining inputs in a new order.
const { cdp } = fakeCdp(
[
[
{ id: 10, value: "" },
{ id: 11, value: "old email" },
{ id: 12, value: "old city" },
],
],
() =>
reply([
[99, state("new control")],
[12, state("上海")],
[11, state("alice@example.com")],
]),
);
const captured = await captureViewModel(cdp, 7);
expect(captured.nodes.find((n) => n.backendNodeId === 10)).toMatchObject({
formValue: "",
formState: "empty",
formPlaceholder: "snapshot placeholder",
});
expect(captured.nodes.find((n) => n.backendNodeId === 11)).toMatchObject({
formValue: "alice@example.com",
});
expect(captured.nodes.find((n) => n.backendNodeId === 12)).toMatchObject({ formValue: "上海" });
expect(captured.nodes.some((n) => n.backendNodeId === 99)).toBe(false);
});
it("does not shift state between frames when one frame is absent from the runtime batch", async () => {
const { cdp } = fakeCdp(
[
[],
[{ id: 11, value: "inaccessible frame" }],
[{ id: 21, value: "old second" }],
[{ id: 31, value: "old third" }],
],
() =>
reply([
[31, state("third frame")],
[21, state("second frame")],
]),
);
const captured = await captureViewModel(cdp, 7);
const controls = [...captured.iframeNodes.values()].flat();
expect(controls.find((n) => n.backendNodeId === 11)?.formValue).toBe("inaccessible frame");
expect(controls.find((n) => n.backendNodeId === 21)?.formValue).toBe("second frame");
expect(controls.find((n) => n.backendNodeId === 31)?.formValue).toBe("third frame");
});
it("keeps identical backend ids in different OOPIF targets separate", async () => {
const sendToTarget = vi.fn(async (target, method) => {
if (method === "DOMSnapshot.captureSnapshot") return snapshot([[{ id: 11, value: "old" }]]);
if (method === "Runtime.evaluate") return reply([[11, state(target.sessionId)]]);
return {};
});
const send = vi.fn(async () => {
throw new Error("unexpected top-level target");
});
const cdp = { send, sendToTarget } as unknown as CdpRunner;
for (const sessionId of ["left-frame", "right-frame"]) {
const captured = await captureViewModel(cdpRunnerForTarget(cdp, { tabId: 7, sessionId }), 7);
expect(captured.nodes.find((n) => n.backendNodeId === 11)?.formValue).toBe(sessionId);
expect(sendToTarget).toHaveBeenCalledWith(
{ tabId: 7, sessionId },
"Runtime.releaseObjectGroup",
expect.anything(),
);
}
expect(send).not.toHaveBeenCalled();
});
it.each([
"unsupported",
"legacy",
"malformed",
"invalid-state",
])("preserves snapshot data when enrichment is %s", async (mode) => {
const { cdp, send } = fakeCdp([[{ id: 11, value: "snapshot value" }]], () => {
if (mode === "unsupported") throw new Error("serialization unsupported");
if (mode === "legacy") return { result: { value: { controls: [state("wrong value")] } } };
const result = reply([
[11, mode === "invalid-state" ? { value: "wrong value" } : state("wrong value")],
]);
if (mode === "malformed")
result.result.deepSerializedValue.value[0].value[1].value = "not json";
return result;
});
const captured = await captureViewModel(cdp, 7);
expect(captured.nodes.find((n) => n.backendNodeId === 11)).toMatchObject({
formValue: "snapshot value",
formPlaceholder: "snapshot placeholder",
});
expect(send).toHaveBeenCalledWith(7, "Runtime.releaseObjectGroup", expect.anything());
});
it("releases the batch's remote objects when capture is cancelled", async () => {
const controller = new AbortController();
const { cdp, send } = fakeCdp([[{ id: 11, value: "old" }]], () => {
controller.abort();
return reply([[11, state("new")]]);
});
await expect(captureViewModel(cdp, 7, { signal: controller.signal })).rejects.toMatchObject({
name: "AbortError",
});
expect(send).toHaveBeenCalledWith(7, "Runtime.releaseObjectGroup", expect.anything());
});
it("skips entries without node identity while still applying valid entries", async () => {
const { cdp } = fakeCdp(
[
[
{ id: 11, value: "snapshot value" },
{ id: 12, value: "old value" },
],
],
() => {
const result = reply([
[11, state("wrong value")],
[12, state("live value")],
]);
const element = result.result.deepSerializedValue.value[0].value[0];
delete (element.value as { backendNodeId?: number }).backendNodeId;
return result;
},
);
const captured = await captureViewModel(cdp, 7);
expect(captured.nodes.find((n) => n.backendNodeId === 11)?.formValue).toBe("snapshot value");
expect(captured.nodes.find((n) => n.backendNodeId === 12)?.formValue).toBe("live value");
});
it.each([
"password",
"text",
])("redacts a sensitive %s control after identity matching", async (type) => {
const { cdp } = fakeCdp([[{ id: 11, value: "secret", type }]], () =>
reply([[11, { ...state("secret"), sensitive: true }]]),
);
const captured = await captureViewModel(cdp, 7);
const control = captured.nodes.find((n) => n.backendNodeId === 11);
expect(control?.formState).toBe("filled");
expect(control?.formValue).toBeUndefined();
expect(control?.formDefaultValue).toBeUndefined();
expect(control?.attrs.value).toBeUndefined();
});
it("keeps one bounded batch without per-control CDP queries", async () => {
const controls = Array.from({ length: 251 }, (_, i) => ({ id: i + 1, value: "old" }));
const elements = controls.map((c) => ({
backendNodeId: c.id,
tagName: "INPUT",
type: "text",
value: `live ${c.id}`,
defaultValue: "",
placeholder: "",
}));
const { cdp, send } = fakeCdp([controls], (params) => {
const doc = {
querySelectorAll: (selector: string) =>
selector === "input,textarea,select" ? elements : [],
};
const entries = new Function("document", `return ${params.expression}`)(doc) as Array<
[{ backendNodeId: number }, string]
>;
expect(entries).toHaveLength(250);
return reply(entries.map(([element, json]) => [element.backendNodeId, JSON.parse(json)]));
});
const captured = await captureViewModel(cdp, 7);
expect(captured.nodes.find((n) => n.backendNodeId === 250)?.formValue).toBe("live 250");
expect(captured.nodes.find((n) => n.backendNodeId === 251)?.formValue).toBe("old");
expect(send.mock.calls.filter(([, method]) => method === "Runtime.evaluate")).toHaveLength(1);
expect(
send.mock.calls.filter(([, method]) => method === "Runtime.releaseObjectGroup"),
).toHaveLength(1);
expect(send).not.toHaveBeenCalledWith(7, "DOM.resolveNode", expect.anything());
expect(send).not.toHaveBeenCalledWith(7, "Runtime.callFunctionOn", expect.anything());
});
});
@@ -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);
});
});
+164 -124
View File
@@ -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;
}
@@ -186,9 +190,9 @@ interface CapturedFormState {
sensitive?: boolean;
}
interface CapturedFrameFormState {
controls: CapturedFormState[];
childFrames: CapturedFrameFormState[];
interface DeepSerializedValue {
type: string;
value?: unknown;
}
function formStateBatchExpression(maxControls: number): string {
@@ -211,43 +215,63 @@ function formStateBatchExpression(maxControls: number): string {
...(sensitive ? {} : { value: rawValue, defaultValue }),
};
};
const controls = [];
const collect = (doc) => {
const controls = [];
for (const el of Array.from(doc.querySelectorAll(controlSelector))) {
if (remaining <= 0) break;
controls.push(controlState(el));
// Deep serialization supplies the node's backend id. Keep the
// state as JSON so decoding needs no general-purpose V8 deserializer.
controls.push([el, JSON.stringify(controlState(el))]);
remaining -= 1;
}
const childFrames = [];
for (const frame of Array.from(doc.querySelectorAll("iframe"))) {
if (remaining <= 0) break;
let childDoc = null;
try { childDoc = frame.contentDocument; } catch { childDoc = null; }
childFrames.push(childDoc && remaining > 0 ? collect(childDoc) : { controls: [], childFrames: [] });
if (childDoc) collect(childDoc);
}
return { controls, childFrames };
};
return collect(document);
collect(document);
return controls;
})()`;
}
function flattenFrameFormStates(
frame: CapturedFrameFormState | undefined,
out: CapturedFormState[][] = [],
): CapturedFormState[][] {
if (!frame) return out;
out.push(Array.isArray(frame.controls) ? frame.controls : []);
for (const child of Array.isArray(frame.childFrames) ? frame.childFrames : []) {
flattenFrameFormStates(child, out);
function formStatesByBackendId(result: RuntimeEvaluateReply): Map<number, CapturedFormState> {
const states = new Map<number, CapturedFormState>();
const serialized = result.result?.deepSerializedValue;
if (serialized?.type !== "array" || !Array.isArray(serialized.value)) return states;
for (const entry of serialized.value as DeepSerializedValue[]) {
if (entry?.type !== "array" || !Array.isArray(entry.value)) continue;
const [element, json] = entry.value as DeepSerializedValue[];
if (element?.type !== "node" || json?.type !== "string" || typeof json.value !== "string") {
continue;
}
const backendNodeId = (element.value as { backendNodeId?: number } | undefined)?.backendNodeId;
if (typeof backendNodeId !== "number" || !Number.isSafeInteger(backendNodeId)) continue;
try {
const state = JSON.parse(json.value) as CapturedFormState | null;
if (
!state ||
!["empty", "filled", "default"].includes(state.state ?? "") ||
typeof state.sensitive !== "boolean" ||
typeof state.placeholder !== "string" ||
(!state.sensitive &&
(typeof state.value !== "string" || typeof state.defaultValue !== "string"))
) {
continue;
}
states.set(backendNodeId, state);
} catch {
// A malformed entry must not overwrite the snapshot's own state.
}
}
return out;
return states;
}
function applyFormStates(nodes: CapturedNode[], states: CapturedFormState[]): void {
let index = 0;
function applyFormStates(nodes: CapturedNode[], states: Map<number, CapturedFormState>): void {
for (const node of nodes) {
if (!isFormControlTag(node.tag)) continue;
const state = states[index];
index += 1;
const state = states.get(node.backendNodeId);
if (!state) continue;
node.formState = state.state;
node.formPlaceholder = state.placeholder ?? "";
@@ -272,26 +296,37 @@ async function enrichFormControlStates(
nodes.some((node) => isFormControlTag(node.tag)),
);
if (!hasControls) return;
const objectGroup = `bsk-vom-forms-${crypto.randomUUID()}`;
try {
throwIfAborted(signal);
const result = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
expression: formStateBatchExpression(MAX_FORM_ENRICH_CONTROLS),
returnByValue: true,
objectGroup,
serializationOptions: {
serialization: "deep",
maxDepth: 3,
additionalParameters: { maxNodeDepth: 0, includeShadowTree: "none" },
},
});
throwIfAborted(signal);
const frameStates = flattenFrameFormStates(runtimeValue<CapturedFrameFormState>(result));
for (let i = 0; i < frameNodeGroups.length; i += 1) {
applyFormStates(frameNodeGroups[i], frameStates[i] ?? []);
// Backend ids are scoped to this capture's CDP target. OOPIF targets
// use their own captureViewModel call and never share this lookup.
const states = formStatesByBackendId(result);
for (const nodes of frameNodeGroups) {
applyFormStates(nodes, states);
}
} catch (error) {
if (isAbortError(error)) throw error;
// Best-effort enrichment. DOMSnapshot/AX data still carries the nodes.
} finally {
await cdp.send(tabId, "Runtime.releaseObjectGroup", { objectGroup }).catch(() => undefined);
}
}
interface RuntimeEvaluateReply {
result?: {
value?: unknown;
deepSerializedValue?: DeepSerializedValue;
};
}
@@ -361,7 +396,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 +504,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 +651,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 +694,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 +847,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 +893,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 +914,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 +956,7 @@ function parseDocumentNodes(
position,
pointerEvents,
cursor,
rendered,
textContent,
...(formValue !== undefined ? { formValue } : {}),
...(tag === "input" || tag === "textarea"
@@ -1020,7 +1061,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 +1095,6 @@ export async function captureViewModel(
nodes: [],
viewport,
iframeNodes: new Map(),
surfaceProbes: [],
excludedBackendNodeIds: new Set(),
};
}
@@ -1077,11 +1123,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 +1143,6 @@ export async function captureViewModel(
frameOwnerBackendNodeIds: frameParsed.frameOwnerBackendNodeIds,
frameParentIds: frameParsed.frameParentIds,
...(topContext.frameId ? { rootFrameId: topContext.frameId } : {}),
surfaceProbes,
excludedBackendNodeIds,
};
}
+24 -22
View File
@@ -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}`;
}
+11
View File
@@ -26,11 +26,17 @@ export type RpcErrorReason =
| "ref_not_found"
| "selector_not_found"
| "target_not_fillable"
| "fill_value_invalid"
| "fill_target_changed"
| "fill_focus_lost"
| "fill_value_mismatch"
| "fill_failed"
| "target_not_select"
| "option_not_found"
| "single_select_value_count"
| "tab_not_active"
| "restricted_tab_url"
| "cdp_extension_access_denied"
| "borrow_conflict"
| "screenshot_capture_failed"
| "file_input_probe_failed"
@@ -333,9 +339,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;
@@ -0,0 +1,119 @@
# Visual observation design fixtures
These are synthetic, site-independent inputs for the visual observation architecture in
[the design](../../../../docs/design/visual-observation.md). They contain no original page
data, account identifiers, external fonts or production selectors. They are not a production
discovery implementation.
Use [the acceptance checklist](../../../../docs/design/visual-observation-acceptance.md)
for each PR's steps and success criteria. No separate report is required.
Historical probe success does not certify future production modules.
## Browser contract probe
Requires Node.js with built-in WebSocket support (Node 22+) and an installed Chrome. No extra
packages, user profile, extension, or login are required.
From the repository root:
```sh
node apps/extension/test-fixtures/visual-observation/probe.mjs /tmp/bsk-visual-evidence
```
On platforms other than macOS, set `CHROME_PATH` to the installed Chrome executable. The probe
starts its own headless Chrome with an ephemeral profile and a loopback fixture server. It
removes the profile and stops Chrome/server on completion or failure. `report.json`, raw CDP
replies and PNGs are written to the explicitly supplied directory; do not commit them.
The probe verifies CSS exposure versus visibility, snapshot units, DPR raster dimensions,
CSS zoom, frame content origins, child scrolling, nested same-target geometry, a confirmed
OOPIF target, and navigation/document.open identity behavior. It records page-scale emulation
separately; that is **not** browser UI zoom. It does not certify production VOM behavior, arbitrary
occlusion, exact clip-path geometry, or all browser versions.
## Minimal end-to-end reproduction
```sh
python3 -m http.server 4177 --bind 127.0.0.1 \
--directory apps/extension/test-fixtures/visual-observation
bsk session start --width 1440 --height 1100
bsk navigate http://127.0.0.1:4177/table.html --session <session>
bsk observe --session <session>
bsk screenshot --session <session> --out /tmp/bsk-table.png
bsk session stop <session>
```
Always stop the session and the local server. Use a foreground Agent Window and confirm the
painted grid is visible before interpreting the observation. `load`, AX toolbar appearance,
and background screenshots alone do not prove a canvas has drawn.
`table.html` embeds `grid.html` through `localhost` from a `127.0.0.1` parent. The grid has DOM
toolbar controls and one unlabeled Canvas with three synthetic columns and six painted rows.
Main baseline: `Page action`, `Insert record`, and `Filter` are referenceable; the painted
`Canvas column A/B/C` and `Canvas row 1..6` have no textual observation representation or visual
ref, despite an untruncated result. This is a **known gap**, not a golden expectation that future
implementations must preserve.
Future acceptance: retain the DOM controls and add a screenshot-only visual ref for the visible
grid, with correct frame projection. Do not invent AX cell text. Validate the corresponding
screenshot, then move/remove/resize and re-observe according to the freshness contract.
## Discovery policy cases
At a 1440×1100 window and without scrolling:
| Canvas ID | Expected candidate in the new design | Reason |
|---|---|---|
| plain | yes | No name required |
| named | yes | Name does not establish complete pixel semantics |
| aria-hidden | yes | Accessibility exclusion is not visual exclusion |
| inert | yes | Inertness is not visual hiding |
| pointer-none | yes | Screenshot observation does not require pointer interaction |
| visibility-override | yes | Child restores computed visibility |
| opacity-parent | no | Ancestor makes subtree transparent |
| display-none | no | No layout box |
| visibility-hidden | no | Computed visibility suppresses this element |
| zero | no | Empty geometry |
| hidden-overridden | yes | CSS display overrides normal hidden presentation |
| partial-clip | yes, 60×30 crop | Supported overflow intersection |
| full-clip | no | Fully outside ancestor clip |
| axis-clip | yes, x clipped only | overflow-x:clip and overflow-y:visible remain independent |
| border-clip | yes, clipped to client box | Ancestor border box is not its scrollport |
| border-canvas | yes, border-box region | Observation and live screenshot resolver must use the same box kind |
| fallback | yes | AX fallback is not full pixel coverage |
| stack-a / stack-b | one output entry after selection | Same document/parent/exact crop; not proof of a logical object |
`checkVisibility()` is used in the probe only for CSS suppression evidence; it does not establish
viewport intersection, ancestor overflow clipping, or absence of occlusion. `frame.html` supplies
known local coordinates, bordered owners, nested frames and scrollable content for independent
geometry verification. The fixture IDs must never enter production discovery code.
## Renderer operation counts
After installing the repository's normal dependencies:
```sh
node apps/extension/test-fixtures/visual-observation/measure-render.mjs /tmp/bsk-render-evidence
```
This instruments a temporary copy of the current renderer, runs the installed Vitest, and writes
the source hash and loop counters to `render-counts.json`. It checks instrumentation sites and
fails explicitly when source structure changes; update the diagnostic deliberately then. It never
modifies production sources and never asserts that today's quadratic costs must persist.
The wide fixture contains N headings followed by N equally named buttons in a common context.
The deep fixture contains 2,000/4,000 transparent wrappers. Counters cover render-stack visits,
ordered DOM context candidates, and same-container sibling visits. They are not a complete CPU
profile or an assertion about the complexity of all VOM stages.
Use these existing-renderer counts only as a before/after regression baseline. Improving the
historical algorithm is not a prerequisite for Canvas work. New Canvas stages need their own
workload models and measurements; do not copy this renderer's algorithms or use these counts
as evidence that visual discovery, clipping, selection, or screenshot execution is efficient.
## Review boundary
Commit only these synthetic fixtures, diagnostic tools, and reviewed design/evidence summaries.
Keep local browser profiles, original internal-site screenshots/HTML/observations, generated
captures, temporary source instrumentation and build outputs outside the changeset.
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Frame geometry contract</title>
<style>
body { margin: 0; width: 600px; height: 900px; }
canvas { position: absolute; left: 17px; top: 23px; width: 120px; height: 40px; }
iframe { position: absolute; left: 30px; top: 90px; width: 180px; height: 100px; border: 7px solid #555; }
</style>
<canvas id="frame-canvas" width="120" height="40"></canvas>
<script>
const context = document.querySelector("canvas").getContext("2d");
context.fillStyle = "#15803d";
context.fillRect(0, 0, 120, 40);
context.fillStyle = "white";
context.fillText("FRAME PIXELS", 4, 24);
if (new URLSearchParams(location.search).has("nested")) {
const child = document.createElement("iframe");
child.title = "nested same-target frame";
child.src = "/frame.html";
document.body.append(child);
}
window.fixtureReady = true;
</script>
</html>
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Canvas grid</title>
<style>
body { margin: 0; font: 16px sans-serif; }
nav { height: 40px; }
canvas { display: block; width: 780px; height: 360px; }
</style>
<nav><button type="button">Insert record</button> <button type="button">Filter</button></nav>
<canvas width="1560" height="720"></canvas>
<script>
const context = document.querySelector("canvas").getContext("2d");
context.scale(2, 2);
context.fillStyle = "white";
context.fillRect(0, 0, 780, 360);
context.strokeStyle = "#777";
for (let x = 0; x <= 780; x += 260) { context.beginPath(); context.moveTo(x, 0); context.lineTo(x, 350); context.stroke(); }
for (let y = 0; y <= 350; y += 50) { context.beginPath(); context.moveTo(0, y); context.lineTo(780, y); context.stroke(); }
context.fillStyle = "#111";
context.font = "16px sans-serif";
["Canvas column A", "Canvas column B", "Canvas column C"].forEach((label, i) => context.fillText(label, i * 260 + 12, 30));
for (let row = 1; row <= 6; row++) context.fillText(`Canvas row ${row}`, 12, row * 50 + 30);
window.fixtureReady = true;
</script>
</html>
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Visual observation contract fixture</title>
<style>
body { margin: 0; min-height: 1800px; font: 14px sans-serif; }
#cases { display: grid; grid-template-columns: repeat(4, 180px); gap: 12px; padding: 20px; }
.case { width: 160px; height: 70px; }
canvas { width: 120px; height: 40px; }
.clip { width: 60px; height: 30px; overflow: hidden; }
.axis-clip { width: 60px; height: 20px; overflow-x: clip; overflow-y: visible; }
#same, #cross { position: absolute; top: 600px; width: 300px; height: 220px; border: 10px solid #555; }
#same { left: 40px; }
#cross { left: 400px; }
</style>
<h1>Canvas contract</h1>
<button type="button" id="control">Ordinary DOM control</button>
<div id="cases">
<div class="case"><canvas id="plain"></canvas></div>
<div class="case"><canvas id="named" aria-label="Named chart"></canvas></div>
<div class="case" aria-hidden="true"><canvas id="aria-hidden"></canvas></div>
<div class="case" inert><canvas id="inert"></canvas></div>
<div class="case"><canvas id="pointer-none" style="pointer-events:none"></canvas></div>
<div class="case" style="visibility:hidden"><canvas id="visibility-override" style="visibility:visible"></canvas></div>
<div class="case" style="opacity:0"><canvas id="opacity-parent"></canvas></div>
<div class="case"><canvas id="display-none" style="display:none"></canvas></div>
<div class="case"><canvas id="visibility-hidden" style="visibility:hidden"></canvas></div>
<div class="case"><canvas id="zero" style="width:0"></canvas></div>
<div class="case"><canvas id="hidden-overridden" hidden style="display:block"></canvas></div>
<div class="case"><div class="clip"><canvas id="partial-clip"></canvas></div></div>
<div class="case"><div class="clip"><canvas id="full-clip" style="margin-left:80px"></canvas></div></div>
<div class="case"><canvas id="fallback">Accessible fallback is not pixel content</canvas></div>
<div class="case"><div class="axis-clip"><canvas id="axis-clip"></canvas></div></div>
<div class="case"><div id="border-clip-owner" style="width:60px;height:30px;border:5px solid black;padding:4px;overflow:hidden"><canvas id="border-clip"></canvas></div></div>
<div class="case"><canvas id="border-canvas" style="border:5px solid black"></canvas></div>
<div class="case" style="position:relative"><canvas id="stack-a"></canvas><canvas id="stack-b" style="position:absolute;left:0;top:0"></canvas></div>
</div>
<iframe id="same" title="same target"></iframe>
<iframe id="cross" title="cross site"></iframe>
<script>
for (const canvas of document.querySelectorAll("canvas")) {
canvas.width = 120;
canvas.height = 40;
const context = canvas.getContext("2d");
context.fillStyle = "#1d4ed8";
context.fillRect(0, 0, 120, 40);
context.fillStyle = "white";
context.fillText("PIXEL TABLE 42", 5, 24);
}
if (!new URLSearchParams(location.search).has("noFrames")) {
document.querySelector("#same").src = "/frame.html?nested=1";
document.querySelector("#cross").src = `${location.protocol}//localhost:${location.port}/frame.html`;
}
window.fixtureReady = true;
</script>
</html>
@@ -0,0 +1,86 @@
// Diagnostic, not a golden test requiring the current quadratic cost to persist.
// Instrument a temporary copy; never modify the production renderer.
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../..");
const output = process.argv[2];
if (!output || !path.isAbsolute(output))
throw new Error("Pass an absolute evidence output directory");
const require = createRequire(path.join(root, "apps/extension/package.json"));
const vitestDirectory = path.dirname(require.resolve("vitest/package.json"));
const temporary = await mkdtemp(path.join(os.tmpdir(), "bsk-render-measure-"));
await mkdir(output, { recursive: true });
try {
const source = await readFile(path.join(root, "packages/vom/src/render.ts"), "utf8");
let instrumented = source;
for (const [needle, field] of [
["const { node, depth } = stack.pop() as { node: VomNode; depth: number };", "renderVisits"],
["for (const candidate of orderedCandidates) {", "contextCandidates"],
["for (const sibling of siblings) {", "siblingVisits"],
]) {
assert.equal(instrumented.split(needle).length, 2, `Update measurement site: ${field}`);
instrumented = instrumented.replace(needle, `${needle} designCounts.${field}++;`);
}
instrumented = `export const designCounts = {renderVisits:0,contextCandidates:0,siblingVisits:0};\n${instrumented}`;
await writeFile(path.join(temporary, "render.ts"), instrumented);
for (const file of ["layers.ts", "types.ts"])
await copyFile(path.join(root, "packages/vom/src", file), path.join(temporary, file));
const reportPath = path.join(output, "render-counts.json");
await writeFile(
path.join(temporary, "measure.test.ts"),
`
import {writeFileSync} from 'node:fs';
import {test,expect} from ${JSON.stringify(path.join(vitestDirectory, "dist/index.js"))};
import {renderVom,designCounts} from './render';
test('measure operation counts',()=>{
const rows=[];
const make=(id,extra={})=>({id,parentId:1,backendNodeId:id,tag:'div',role:'generic',rect:{x:0,y:0,w:100,h:20},paintOrder:0,position:'static',pointerEvents:'auto',contextScopeId:'root',...extra});
const run=(shape,n,nodes)=>{
Object.assign(designCounts,{renderVisits:0,contextCandidates:0,siblingVisits:0});
const result=renderVom({viewport:{width:1440,height:1000},nodes},{maxTokens:Infinity});
rows.push({shape,n,counts:{...designCounts},refs:result.refs.length});
};
for(const n of [100,200,400]){
const nodes=[make(1,{parentId:null,role:'RootWebArea',name:'Doc'})];
for(let i=0;i<n;i++)nodes.push(make(i+2,{role:'heading',name:'Region '+i,domParentId:1,domAncestorIds:[1]}));
for(let i=0;i<n;i++)nodes.push(make(n+i+2,{tag:'button',role:'button',name:'View',domParentId:1,domAncestorIds:[1]}));
run('wide',n,nodes);
}
for(const n of [2000,4000]){
const nodes=[make(1,{parentId:null,role:'RootWebArea',name:'Doc'})];
for(let i=2;i<=n;i++)nodes.push(make(i,{parentId:i-1}));
nodes.push(make(n+1,{parentId:n,role:'button',tag:'button',name:'Unique control'}));
run('deep',n,nodes);
}
expect(rows.at(-1).refs).toBe(1);
writeFileSync(${JSON.stringify(reportPath)},JSON.stringify({sourceSha256:${JSON.stringify(createHash("sha256").update(source).digest("hex"))},rows},null,2));
});
`,
);
const config = path.join(temporary, "vitest.config.mjs");
await writeFile(
config,
`export default {test:{environment:'node',include:[${JSON.stringify(path.join(temporary, "measure.test.ts"))}]}};`,
);
const result = spawnSync(
process.execPath,
[path.join(vitestDirectory, "vitest.mjs"), "run", "--config", config],
{ cwd: root, encoding: "utf8", timeout: 60000 },
);
await writeFile(
path.join(output, "render-measure.log"),
`${result.stdout ?? ""}\n${result.stderr ?? ""}`,
);
if (result.error) throw result.error;
assert.equal(result.status, 0, `Measurement failed; see ${output}/render-measure.log`);
process.stdout.write(`${reportPath}\n`);
} finally {
await rm(temporary, { recursive: true, force: true });
}
@@ -0,0 +1,533 @@
// Standalone browser contract probe. No extension, credentials or user profile.
// Raw replies and screenshots are written only to the caller-supplied directory.
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const fixtureDirectory = path.dirname(fileURLToPath(import.meta.url));
const output = process.argv[2];
if (!output || !path.isAbsolute(output))
throw new Error("Pass an absolute evidence output directory");
const chromePath =
process.env.CHROME_PATH ?? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const styles = [
"position",
"pointer-events",
"cursor",
"visibility",
"opacity",
"overflow-x",
"overflow-y",
"display",
];
const checks = [];
function check(name, condition) {
checks.push({ name, passed: Boolean(condition) });
assert.ok(condition, name);
}
class Cdp {
nextId = 0;
pending = new Map();
events = [];
counts = {};
async connect(url) {
this.socket = new WebSocket(url);
this.socket.addEventListener("message", ({ data }) => {
const reply = JSON.parse(data);
if (!reply.id) {
this.events.push(reply);
return;
}
const task = this.pending.get(reply.id);
if (!task) return;
this.pending.delete(reply.id);
clearTimeout(task.timer);
if (reply.error) task.reject(new Error(JSON.stringify(reply.error)));
else task.resolve(reply.result);
});
await new Promise((resolve, reject) => {
this.socket.addEventListener("open", resolve, { once: true });
this.socket.addEventListener("error", reject, { once: true });
});
}
send(method, params = {}, sessionId) {
const id = ++this.nextId;
this.counts[method] = (this.counts[method] ?? 0) + 1;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`CDP timeout: ${method}`));
}, 10000);
this.pending.set(id, { resolve, reject, timer });
this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
});
}
close() {
for (const task of this.pending.values()) {
clearTimeout(task.timer);
task.reject(new Error("CDP closed"));
}
this.pending.clear();
this.socket?.close();
}
}
function decodedCanvases(snapshot) {
return snapshot.documents.flatMap((document) => {
const layoutByNode = new Map(document.layout.nodeIndex.map((node, index) => [node, index]));
return document.nodes.nodeName.flatMap((name, index) => {
if (snapshot.strings[name] !== "CANVAS") return [];
const attrs = document.nodes.attributes[index];
const attributes = {};
for (let i = 0; i < attrs.length; i += 2)
attributes[snapshot.strings[attrs[i]]] = snapshot.strings[attrs[i + 1]];
const layoutIndex = layoutByNode.get(index) ?? -1;
return [
{
id: attributes.id,
backendNodeId: document.nodes.backendNodeId[index],
frameId: snapshot.strings[document.frameId],
attributes,
bounds: layoutIndex < 0 ? null : document.layout.bounds[layoutIndex],
styles:
layoutIndex < 0
? null
: Object.fromEntries(
styles.map((style, i) => [
style,
snapshot.strings[document.layout.styles[layoutIndex][i]],
]),
),
},
];
});
});
}
await mkdir(output, { recursive: true });
const profile = await mkdtemp(path.join(os.tmpdir(), "bsk-visual-probe-"));
const server = http.createServer(async (request, response) => {
const name = new URL(request.url, "http://fixture").pathname;
if (!["/index.html", "/frame.html"].includes(name)) {
response.writeHead(404).end();
return;
}
try {
response.setHeader("Content-Type", "text/html; charset=utf-8");
response.end(await readFile(path.join(fixtureDirectory, name.slice(1))));
} catch {
response.writeHead(500).end();
}
});
const cdp = new Cdp();
let chrome;
let report = {};
try {
server.listen(0, "127.0.0.1");
await once(server, "listening");
const port = server.address().port;
chrome = spawn(
chromePath,
[
"--headless=new",
`--user-data-dir=${profile}`,
"--remote-debugging-port=0",
"--no-first-run",
"--no-default-browser-check",
"--site-per-process",
"--window-size=1440,1100",
"about:blank",
],
{ stdio: "ignore" },
);
chrome.on("error", (error) => {
report.launchError = error.message;
});
let activePort;
for (let attempt = 0; attempt < 100; attempt++) {
try {
activePort = (await readFile(path.join(profile, "DevToolsActivePort"), "utf8"))
.trim()
.split("\n");
break;
} catch {
await pause(100);
}
}
if (!activePort)
throw new Error(report.launchError ?? "Chrome did not expose DevToolsActivePort");
await cdp.connect(`ws://127.0.0.1:${activePort[0]}${activePort[1]}`);
report.browser = await cdp.send("Browser.getVersion");
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
const { sessionId: root } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
const send = (method, params = {}) => cdp.send(method, params, root);
const evaluate = async (expression, session = root, contextId) => {
const result = await cdp.send(
"Runtime.evaluate",
{ expression, returnByValue: true, awaitPromise: true, ...(contextId ? { contextId } : {}) },
session,
);
if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails));
return result.result.value;
};
await send("Page.enable");
await send("Runtime.enable");
async function navigate(url) {
await send("Page.navigate", { url });
for (let i = 0; i < 100; i++) {
if (await evaluate("document.readyState === 'complete' && window.fixtureReady === true"))
return;
await pause(50);
}
throw new Error("Fixture did not become ready");
}
const url = `http://127.0.0.1:${port}/index.html`;
await navigate(`${url}?noFrames=1`);
async function capture(label) {
const metrics = await send("Page.getLayoutMetrics");
const snapshot = await send("DOMSnapshot.captureSnapshot", {
computedStyles: styles,
includePaintOrder: true,
includeDOMRects: true,
});
const ax = await send("Accessibility.getFullAXTree");
const runtime = await evaluate(`Array.from(document.querySelectorAll('canvas'), el => {
const r=el.getBoundingClientRect(), s=getComputedStyle(el);
return {id:el.id,rect:{x:r.x,y:r.y,w:r.width,h:r.height},visibility:s.visibility,display:s.display,
checkVisibility:el.checkVisibility({opacityProperty:true,visibilityProperty:true}),
dpr:devicePixelRatio,scrollX,scrollY}; })`);
const result = { metrics, snapshot, ax, runtime, canvases: decodedCanvases(snapshot) };
await writeFile(path.join(output, `${label}.json`), JSON.stringify(result, null, 2));
return result;
}
const baseline = await capture("root-baseline");
const rootImage = await send("Page.captureScreenshot", { format: "png" });
await writeFile(path.join(output, "root.png"), Buffer.from(rootImage.data, "base64"));
const runtime = Object.fromEntries(baseline.runtime.map((node) => [node.id, node]));
for (const id of [
"plain",
"named",
"aria-hidden",
"inert",
"pointer-none",
"visibility-override",
"hidden-overridden",
"fallback",
])
check(`CSS visible: ${id}`, runtime[id].checkVisibility);
for (const id of ["opacity-parent", "display-none", "visibility-hidden"])
check(`CSS suppressed: ${id}`, !runtime[id].checkVisibility);
check("painted text is absent from AX", !JSON.stringify(baseline.ax).includes("PIXEL TABLE 42"));
report.css = baseline.runtime;
const bordered = baseline.canvases.find((node) => node.id === "border-canvas");
report.canvasBox = await send("DOM.getBoxModel", { backendNodeId: bordered.backendNodeId });
check(
"Canvas snapshot uses border box",
bordered.bounds[2] === 130 &&
report.canvasBox.model.border[2] - report.canvasBox.model.border[0] === 130 &&
report.canvasBox.model.content[2] - report.canvasBox.model.content[0] === 120,
);
const rootDocument = baseline.snapshot.documents[0];
const ownerNode = rootDocument.nodes.attributes.findIndex((attrs) =>
attrs.some(
(value, index) => index % 2 === 1 && baseline.snapshot.strings[value] === "border-clip-owner",
),
);
const ownerLayout = rootDocument.layout.nodeIndex.indexOf(ownerNode);
report.clipClientBox = {
snapshot: rootDocument.layout.clientRects[ownerLayout],
runtime: await evaluate(
"(() => {const e=document.getElementById('border-clip-owner');return [e.clientLeft,e.clientTop,e.clientWidth,e.clientHeight]})()",
),
};
check(
"snapshot client rect contains local client offsets and size",
JSON.stringify(report.clipClientBox.snapshot) ===
JSON.stringify(report.clipClientBox.runtime) &&
JSON.stringify(report.clipClientBox.runtime) === "[5,5,68,38]",
);
report.clips = await evaluate(`['partial-clip','full-clip','axis-clip'].map(id=>{
const el=document.getElementById(id),p=el.parentElement,s=getComputedStyle(p);
const a=el.getBoundingClientRect(),b=p.getBoundingClientRect();
return {id,overflowX:s.overflowX,overflowY:s.overflowY,
child:{x:a.x,y:a.y,w:a.width,h:a.height},parent:{x:b.x,y:b.y,w:b.width,h:b.height}};
})`);
const [partial, full, axis] = report.clips;
check(
"partial overflow crop dimensions",
partial.parent.w === 60 &&
partial.parent.h === 30 &&
partial.child.x === partial.parent.x &&
partial.child.y === partial.parent.y,
);
check(
"fully clipped candidate lies beyond ancestor",
full.child.x >= full.parent.x + full.parent.w,
);
check(
"single-axis clip remains independent in computed style",
axis.overflowX === "clip" && axis.overflowY === "visible" && axis.child.h > axis.parent.h,
);
const samples = [];
for (let i = 0; i < 12; i++)
samples.push(
await evaluate(
"JSON.stringify(document.querySelector('#plain').getBoundingClientRect().toJSON())",
),
);
check("static rect stable over 12 reads", new Set(samples).size === 1);
await evaluate("document.querySelector('#plain').style.transform='translateX(0.5px)'");
report.halfPixelMovement = await evaluate(
"document.querySelector('#plain').getBoundingClientRect().x",
);
await evaluate("document.querySelector('#plain').style.transform=''");
report.raster = [];
for (const dpr of [1, 1.5, 2]) {
await send("Emulation.setDeviceMetricsOverride", {
width: 1000,
height: 900,
deviceScaleFactor: dpr,
mobile: false,
});
const state = await capture(`dpr-${dpr}`);
const image = await send("Page.captureScreenshot", {
format: "png",
clip: { x: 20, y: 100, width: 120, height: 40, scale: 1 },
});
const bytes = Buffer.from(image.data, "base64");
const dimensions = { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
report.raster.push({
dpr,
dimensions,
metrics: state.metrics,
plain: state.canvases.find((node) => node.id === "plain"),
});
check(
`screenshot raster DPR ${dpr}`,
dimensions.width === 120 * dpr && dimensions.height === 40 * dpr,
);
}
await send("Emulation.clearDeviceMetricsOverride");
await evaluate("document.body.style.zoom='1.25'");
const cssZoom = await capture("css-zoom-1.25");
check(
"CSS zoom is reflected in snapshot geometry",
cssZoom.canvases.find((n) => n.id === "plain").bounds[2] === 150,
);
report.cssZoom = {
runtime: cssZoom.runtime[0],
snapshot: cssZoom.canvases.find((n) => n.id === "plain"),
};
await evaluate("document.body.style.zoom=''");
await send("Emulation.setPageScaleFactor", { pageScaleFactor: 1.5 });
report.pageScale = (await capture("page-scale-1.5")).metrics;
await send("Emulation.setPageScaleFactor", { pageScaleFactor: 1 });
await evaluate("scrollTo(0,100)");
const scrolled = await capture("scroll-100");
check(
"snapshot document bounds stable under scroll",
JSON.stringify(scrolled.canvases.find((n) => n.id === "plain").bounds) ===
JSON.stringify(baseline.canvases.find((n) => n.id === "plain").bounds),
);
check(
"runtime viewport y follows scroll",
scrolled.runtime[0].rect.y === baseline.runtime[0].rect.y - 100,
);
await navigate(url);
await pause(200);
const targets = await cdp.send("Target.getTargets");
const iframeTarget = targets.targetInfos.find(
(target) => target.type === "iframe" && target.url.startsWith(`http://localhost:${port}/`),
);
check("cross-site frame is an actual OOPIF target", iframeTarget);
const { sessionId: child } = await cdp.send("Target.attachToTarget", {
targetId: iframeTarget.targetId,
flatten: true,
});
report.frames = {};
for (const [name, session] of [
["root", root],
["oopif", child],
]) {
const tree = await cdp.send("Page.getFrameTree", {}, session);
const snapshot = await cdp.send(
"DOMSnapshot.captureSnapshot",
{ computedStyles: styles, includeDOMRects: true },
session,
);
const frameRows = [];
const stack = [tree.frameTree];
while (stack.length) {
const node = stack.pop();
const world = await cdp.send(
"Page.createIsolatedWorld",
{ frameId: node.frame.id, worldName: "bsk-design-probe" },
session,
);
const local = await evaluate(
"({url:location.pathname,innerWidth,innerHeight,scrollX,scrollY,canvas:document.querySelector('canvas').getBoundingClientRect().toJSON()})",
session,
world.executionContextId,
);
let owner;
if (node.frame.parentId) {
// All owners in this fixture live in the root target; child content can be OOPIF.
const address = await send("DOM.getFrameOwner", { frameId: node.frame.id });
owner = await send("DOM.getBoxModel", { backendNodeId: address.backendNodeId });
}
frameRows.push({ frame: node.frame, local, owner });
stack.push(...(node.childFrames ?? []));
}
report.frames[name] = { tree, snapshot, canvases: decodedCanvases(snapshot), frameRows };
}
check(
"same-target snapshot includes nested documents",
report.frames.root.snapshot.documents.length === 3,
);
check("oopif snapshot isolated from root", report.frames.oopif.snapshot.documents.length === 1);
const sameRow = report.frames.root.frameRows.find(
(row) => row.owner && row.local.innerWidth === 300,
);
const nestedRow = report.frames.root.frameRows.find(
(row) => row.owner && row.local.innerWidth === 180,
);
check(
"same frame content origin includes border",
sameRow.owner.model.content[0] === 50 && sameRow.owner.model.content[1] === 610,
);
check(
"nested owner quad is already target-root relative",
nestedRow.owner.model.content[0] === 87 && nestedRow.owner.model.content[1] === 707,
);
const crossRow = report.frames.oopif.frameRows[0];
check(
"OOPIF owner content origin",
crossRow.owner.model.content[0] === 410 && crossRow.owner.model.content[1] === 610,
);
const sameFrame = sameRow.frame.id;
const world = await send("Page.createIsolatedWorld", {
frameId: sameFrame,
worldName: "bsk-design-probe",
});
await evaluate("scrollTo(0,10)", root, world.executionContextId);
const frameScroll = await send("DOMSnapshot.captureSnapshot", {
computedStyles: styles,
includeDOMRects: true,
});
report.frameScroll = {
documents: frameScroll.documents.map((d) => ({
frameId: frameScroll.strings[d.frameId],
scrollOffsetY: d.scrollOffsetY,
})),
canvases: decodedCanvases(frameScroll),
local: await evaluate(
"document.querySelector('canvas').getBoundingClientRect().toJSON()",
root,
world.executionContextId,
),
};
check("frame scroll is local", report.frameScroll.local.y === 13);
check(
"frame snapshot stays document-relative after child scroll",
report.frameScroll.canvases.find((n) => n.frameId === sameFrame).bounds[1] === 23,
);
await evaluate("scrollTo(0,0)", root, world.executionContextId);
await evaluate(
"document.querySelector('#same').style.transformOrigin='0 0';document.querySelector('#same').style.transform='scale(1.25)'",
);
const sameOwner = await send("DOM.getFrameOwner", { frameId: sameFrame });
report.transformedOwner = await send("DOM.getBoxModel", {
backendNodeId: sameOwner.backendNodeId,
});
check(
"scaled frame owner content width",
report.transformedOwner.model.content[2] - report.transformedOwner.model.content[0] === 375,
);
await evaluate("document.querySelector('#same').style.transform=''");
const screenshot = await send("Page.captureScreenshot", { format: "png" });
await writeFile(path.join(output, "frames.png"), Buffer.from(screenshot.data, "base64"));
const before = (await send("Page.getFrameTree")).frameTree.frame;
const oldDocument = (await send("DOM.getDocument")).root.backendNodeId;
await evaluate("history.replaceState(null,'','#same-document')");
const sameDocument = (await send("Page.getFrameTree")).frameTree.frame;
const sameDocumentBackend = (await send("DOM.getDocument")).root.backendNodeId;
await navigate(`${url}?noFrames=1&navigation=2`);
const after = (await send("Page.getFrameTree")).frameTree.frame;
const newDocument = (await send("DOM.getDocument")).root.backendNodeId;
report.identity = { before, sameDocument, after, oldDocument, sameDocumentBackend, newDocument };
check("same-document navigation preserves loader", before.loaderId === sameDocument.loaderId);
check("cross-document navigation changes loader", before.loaderId !== after.loaderId);
check("frame id survives document replacement", before.id === after.id);
check(
"document backend identity changes on navigation",
oldDocument !== newDocument && oldDocument === sameDocumentBackend,
);
const documentTree = await send("DOM.getDocument");
report.identity.htmlBeforeOpen = documentTree.root.children.find(
(node) => node.nodeType === 1,
).backendNodeId;
const anchorNode = await send("DOM.querySelector", {
nodeId: documentTree.root.nodeId,
selector: "canvas",
});
const anchor = await send("DOM.describeNode", { nodeId: anchorNode.nodeId });
await evaluate(
"document.open(); document.write('<!doctype html><title>Replacement</title><canvas></canvas>'); document.close()",
);
report.identity.documentOpen = (await send("Page.getFrameTree")).frameTree.frame;
const afterOpenTree = await send("DOM.getDocument");
report.identity.documentOpenBackend = afterOpenTree.root.backendNodeId;
report.identity.htmlAfterOpen = afterOpenTree.root.children.find(
(node) => node.nodeType === 1,
).backendNodeId;
check(
"document.open replaces documentElement identity",
report.identity.htmlBeforeOpen !== report.identity.htmlAfterOpen,
);
try {
const object = await send("DOM.resolveNode", { backendNodeId: anchor.node.backendNodeId });
const connected = await send("Runtime.callFunctionOn", {
objectId: object.object.objectId,
functionDeclaration: "function(){return this.isConnected}",
returnByValue: true,
});
report.identity.oldAnchorConnected = connected.result.value;
} catch {
report.identity.oldAnchorConnected = false;
}
check(
"document.open invalidates old anchor even if document identifiers survive",
report.identity.oldAnchorConnected === false,
);
report.counts = cdp.counts;
report.checks = checks;
process.stdout.write(
`${JSON.stringify({ browser: report.browser.product, checks: checks.length, output })}\n`,
);
} catch (error) {
report.error = error.stack;
report.checks = checks;
process.exitCode = 1;
process.stderr.write(`${error.stack}\n`);
} finally {
await writeFile(path.join(output, "report.json"), JSON.stringify(report, null, 2));
cdp.close();
if (chrome && chrome.exitCode === null) {
const exited = once(chrome, "exit");
chrome.kill("SIGTERM");
await Promise.race([exited, pause(2000)]);
if (chrome.exitCode === null) {
chrome.kill("SIGKILL");
await exited;
}
}
await new Promise((resolve) => server.close(resolve));
await rm(profile, { recursive: true, force: true });
}
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Embedded Canvas table reproduction</title>
<style>
body { font: 16px sans-serif; }
iframe { display: block; width: 800px; height: 440px; border: 8px solid #ccc; }
</style>
<h1>Embedded table</h1>
<button type="button">Page action</button>
<iframe title="Table application"></iframe>
<script>
document.querySelector("iframe").src = `http://localhost:${location.port}/grid.html`;
</script>
</html>
+1
View File
@@ -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/**",
+132 -310
View File
@@ -1,362 +1,184 @@
---
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
upload download 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>`)
| Command | Summary |
|---------|---------|
| `bsk tab list` | List tabs (`--scope user\|agent\|all`, default `all`) |
| `bsk tab create` | New tab in Agent Window (`--url`, `--no-active`, `--index`) |
| `bsk tab close <tab-id>` | Close an agent tab |
| `bsk tab select <tab-id>` | Focus an agent tab |
| `bsk tab borrow <tab-id>` | Move a user tab into the Agent Window |
| `bsk tab return <tab-id>` | Return a borrowed tab to its original window |
### Observation (require `--session` unless noted)
| Command | Summary |
|---------|---------|
| `bsk snapshot` | First-choice static page understanding: accessibility tree with `@eN` element refs |
| `bsk observe` | Semantic VOM observation with bounded perception probes for conditional surfaces |
| `bsk get-html` | Raw HTML dump after snapshot is insufficient (high token cost) |
| `bsk screenshot` | PNG capture after snapshot is insufficient: full visible tab, or `--ref @eN` to crop to one element (`--out` path optional) |
### Console & network debugging (read-only; require `--session`)
| Command | Summary |
|---------|---------|
| `bsk console` | Buffered page console messages, JS exceptions, and browser log entries (`--include-stack` for stack traces) |
| `bsk network` | Buffered network responses (status, method, URL, MIME/resource type) and failures (`net::ERR_*` reason) |
Both capture from the moment the tab is attached and read a bounded per-tab buffer: `--since <seq>` pages from a cursor (`next_since` in the result), `--limit` (default 50, max 200), `--max-text-chars` (default 1000, max 4096), `--tab-id` to target a non-active tab. Both are strictly read-only — they never intercept or modify traffic, and request/response headers, bodies, and timings are not captured.
### Navigation
| Command | Summary |
|---------|---------|
| `bsk navigate <url>` | Go to URL in agent tab (`--wait-until`, `--timeout`) |
| `bsk navigate-back` | History back one step |
| `bsk navigate-forward` | History forward one step |
| `bsk reload` | Reload current tab (`--hard` bypass cache) |
(`bsk navigate back` / `bsk navigate forward` are equivalent subcommands.)
### Interaction
| Command | Summary |
|---------|---------|
| `bsk click <ref-or-selector>` | Click element (`--button`, `--click-count`, `--modifiers`) |
| `bsk hover <ref-or-selector>` | Move the mouse to an element and wait for hover UI to settle (`--settle`, `--modifiers`) |
| `bsk fill <ref-or-selector> --value <text>` | Clear and type into input |
| `bsk select <ref-or-selector> --value <v>` | Set `<select>` option(s) by `value` (repeat `--value` for multi-select) |
| `bsk press <key>` | Key/combo (`Enter`, `Ctrl+A`, …; optional `--ref` to focus first) |
### File transfer (require `--session`)
| Command | Summary |
|---------|---------|
| `bsk upload <ref-or-selector> --file <path>` | Attach a local file through a file-input trigger; use `--mode drop` for a reliably identified attachment-receiving area (`--file` is repeatable) |
| `bsk download <ref-or-selector> --out <path>` | Click one download trigger and copy the single completed file to an exact local path (`--overwrite` is opt-in) |
The agent/harness decides whether a file transfer is appropriate and which local path belongs to the task. Treat upload as disclosure of that file to the current website, and download as accepting website-controlled bytes onto the local filesystem. Use only paths that are necessary for the user's bounded goal.
BrowserSkill enforces the mechanical boundary: files are staged under a session-scoped opaque transfer, only daemon-minted capabilities reach the extension, upload/download still obey Agent Window tab checks, and transfers are chunk/size bounded. The default upload mode intercepts one chooser transaction, locates the file input activated in the resolved target's document, and assigns only the staged paths. `--mode drop` does not click: it delivers the same staged files through one native browser drop bound to the explicitly resolved attachment target. These mechanisms never fall back to each other automatically. Download uniquely correlates one exact-target browser intent with one Chrome download in either event order, routes it through a daemon-minted relative directory, and lets the daemon validate and import it. Upload staging remains available for a later form submission and is removed when the session ends. Downloads cannot overwrite an existing destination unless `--overwrite` is explicit. BrowserSkill does not inspect file content or decide whether its meaning is sensitive.
Choose the upload mechanism explicitly:
- Use the default input mode for an upload button, file-input label, or “upload from computer” action.
- Use `--mode drop` only when the attachment can be reliably attributed to the target business editor: an explicit drop zone, chat composer, email editor, or form attachment area. Do not target page whitespace, a generic container, or an area whose attachment ownership is ambiguous.
- If input mode returns `reason=file_input_not_activated` with `effect_state=none`, re-observe. When a reliable attachment-receiving area is present, attempt drop once against that area; otherwise use `request-help` when available.
- Never switch mechanisms or repeat an upload when `effect_state=unknown` or `committed`.
A successful drop command means Chrome dispatched the native file drop to the resolved zone; it does not prove that the site's application logic accepted the attachment. Observe the page once after the command, and do not repeat the drop when the attachment is already present.
Do not use `request-help` merely because a native file chooser or browser download is involved; try these commands first. For transfer failures, use the structured error instead of retrying blindly:
- `reason=file_input_not_activated` means the requested click did not activate exactly one `<input type="file">`; the page may use a non-input picker such as `window.showOpenFilePicker()`. Follow the explicit mechanism-selection rule above. If no reliable attachment-receiving area exists, call `request-help` when available and tell the user the exact original local path to choose. The staged daemon path is internal and must not be shown to the user.
- `reason=file_input_probe_failed` means BrowserSkill could not safely establish the browser-side upload transaction. Do not repeat the same action; use `request-help` when available.
- `reason=set_file_input_failed` means BrowserSkill found the activated file input but Chrome rejected the staged path or assignment. Check the extension's file-URL access permission; otherwise use `request-help`.
- `reason=upload_mechanism_unsupported` means the browser cannot perform a native file drop. Do not retry the same drop; use a standard file-input trigger or `request-help`.
- `reason=file_drop_target_unavailable` means the specified point is no longer owned by the resolved attachment target. When `effect_state=none`, re-observe and select the attachment-receiving editor or drop zone itself; do not guess another target.
- `reason=file_drop_failed` means the native drop command failed. Do not retry when `effect_state=unknown`; first observe whether the page already received the file.
- `reason=download_capture_failed` means BrowserSkill could not attribute exactly one completed download to the requested target. Do not retry blindly or accept an unrelated browser download; use `request-help` when available.
- `effect_state=none` means BrowserSkill confirmed that no file-transfer effect was committed. Follow the accompanying reason; a corrected target or explicit human fallback may be attempted.
- `effect_state=unknown` means the browser may already have attached or created the file. Do not repeat the transfer. Observe the page if that can establish the result; otherwise stop and report the uncertainty.
- `effect_state=committed` means the browser-side effect occurred even if later completion or cleanup failed. Do not repeat it; continue only after verifying the resulting page/download state.
If `request-help` returns `outcome="disabled"`, do not retry it. Stop gracefully and report the transfer mechanism that requires human intervention.
### Scripting & timing
| Command | Summary |
|---------|---------|
| `bsk evaluate <expression>` | Run JS in agent tab (see red lines); JS throw → stderr, **exit 0** |
| `bsk wait-for-navigation` | Block until load/DOM idle/etc. (`--wait-until`, `--timeout`) |
| `bsk wait-ms <duration>` | Sleep (`500ms`, `2s`, `1m`; **no** `--session`) |
### Ask the human for help — `bsk request-help`
When a step needs a human (captcha, login, OTP) or you want the user to
confirm an important action, pause and ask:
bsk request-help --session <id> --prompt "Solve the captcha, then click Done only after the site accepts it" \
--title "Captcha required" --target @e7 --target "#submit" --timeout 5m
- `--prompt` (required): what the user should do.
- `--title` (optional): custom title for the overlay panel. When omitted,
the extension shows its default localized title.
- `--target` (repeatable): a snapshot ref (`@e7`) or CSS selector
(`#submit`) to scroll to and flash-highlight. **Strongly recommended** —
whenever the prompt refers to a concrete element (a button to click, a
field to fill, a checkbox to toggle), pass its `@eN` ref / selector so the
user is guided straight to the right spot instead of hunting for it. For
interaction scenarios, always include the relevant target(s); reserve a
prompt with no `--target` for cases where there is genuinely no specific
element to point at (e.g. "wait for the page to finish loading").
- `--timeout` (default `5m`): how long to wait.
- `--completion-criteria` (optional): JSON success detector. Use it only
when there is a concrete post-help success signal, e.g.
`{"any":[{"url_contains":"/dashboard"},{"selector_exists":"[data-testid='account-menu']"}],"stable_for_ms":1000}`.
The target tab is brought to the foreground; the page stays interactive
while the agent control mask is hidden. The call blocks until the user
explicitly acts, the timeout expires, cancellation arrives, or explicit
completion criteria match. Page reloads, SPA route changes, and captcha
refreshes do not return control by themselves. The result `outcome` is one of:
- `continued` — the user finished and clicked Done / return control (treat as confirm).
- `cancelled` — the user clicked Cancel (treat as reject/abort).
- `timed_out` — nobody acted within the timeout.
- `completed` — the explicit `--completion-criteria` matched while the user had control.
- `navigated` — deprecated legacy outcome. Do not rely on navigation as a completion signal.
`note` carries any text the user typed back. `resolved_targets` reports
which refs/selectors matched a live element.
`request-help` does not refresh the page model after the user returns
control. After a `continued` or `completed` result, issue a separate
observation tool call (usually `bsk snapshot --session <id>`) before using
new refs or reasoning about the post-help page state.
#### Disabling request-help (unattended mode)
Set `BSK_REQUEST_HELP=off` on unattended servers: `bsk request-help` then
returns immediately with `outcome="disabled"` (no overlay, no waiting,
exit 0). Any other value keeps it enabled. If you get `disabled`, do not
retry — complete the task autonomously or stop gracefully.
### Recording — `bsk record`
Capture the user's own actions in the Agent Window to a **trace bundle**, for later LLM-driven automation. New CLI builds request **trace v3** (page observations + action chain); older extensions may still return **trace v2** (actions only), which the CLI exports as a single `trace.json` without `states/`.
```bash
bsk record start --browser <instance-id-or-label> \
[--url https://…] [--purpose "publish a wiki doc"] \
[--max-page-tokens 3000] [--redact-values] \
[--output trace]
# `--url` is optional; default https://example.com/ when omitted (must be http(s)).
# Blocks until the user clicks Finish in the recording panel, then writes:
# trace/trace.json — action chain (+ state index when v3)
# trace/states/ — v3 only: one `sN.txt` observe snapshot per settled page state
bsk record stop [--output trace] # terminal fallback if the browser panel is unavailable
```text
bsk fill <ref> --value <text> bsk select <ref> --value <option-value>
bsk screenshot --out <path> bsk emulate --device <preset-id>
bsk upload <ref> --file <path> bsk download <ref> --out <path>
```
- **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 …`)
## File transfer
| 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 |
`upload` and `download` stage files through the daemon; the agent never touches browser-internal
paths. Treat upload as disclosure to the website, download as accepting website-controlled bytes.
Human errors print `error:` + `hint:` on stderr; `--json` includes `code`, `message`, `hint`, `exit_code`.
Upload has two independent mechanisms — choose explicitly, never rely on automatic fallback:
### When to run diagnostics
- **Default (input mode):** for upload buttons, file-input labels, or "upload from computer"
actions. The command clicks the target and intercepts the native file chooser.
- **`--mode drop`:** for reliably identified attachment-receiving areas — an explicit drop zone,
chat composer, email editor, or form attachment area. Do not target page whitespace, generic
containers, or areas whose attachment ownership is ambiguous.
| 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>` |
Decision sequence when uploading:
Always **`bsk session stop <id>`** in a `finally`-style path so the Agent Window closes and borrowed tabs return.
1. Try input mode (the default).
2. If it returns `reason=file_input_not_activated` with `effect_state=none`, re-observe. When a
reliable attachment target exists, try `--mode drop` once against that target.
3. Otherwise fall back to `request-help`.
4. **Never** switch mechanisms or repeat when `effect_state` is `unknown` or `committed` — the
browser may already have applied the file.
## Red lines
A successful drop means Chrome dispatched the native file-drop event; it does not prove the site
accepted the attachment. Observe the page once after the command.
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.
Download default-refuses to overwrite; pass `--overwrite` when replacing an existing file is
intended. Read `bsk upload --help` and `bsk download --help` for all flags and error details.
---
## Recover without wandering
**More detail for any command:** `bsk <cmd> --help`
- 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.
- Fill result unconfirmed (`fill_value_mismatch`): observe the field first; the page may have
formatted the value. Continue if the visible result satisfies the user's intent. Otherwise correct
the remaining difference; do not blindly repeat fill or immediately request human help. For other
fill errors, follow the returned hint and inspect current state before retrying.
- 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.
The CLI's current help and error hints are authoritative for flags, parameters, and recovery
details.
+18 -11
View File
@@ -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
+7
View File
@@ -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 {
+111 -1
View File
@@ -43,10 +43,16 @@ pub mod reason {
pub const REF_NOT_FOUND: &str = "ref_not_found";
pub const SELECTOR_NOT_FOUND: &str = "selector_not_found";
pub const TARGET_NOT_FILLABLE: &str = "target_not_fillable";
pub const FILL_VALUE_INVALID: &str = "fill_value_invalid";
pub const FILL_TARGET_CHANGED: &str = "fill_target_changed";
pub const FILL_FOCUS_LOST: &str = "fill_focus_lost";
pub const FILL_VALUE_MISMATCH: &str = "fill_value_mismatch";
pub const FILL_FAILED: &str = "fill_failed";
pub const TARGET_NOT_SELECT: &str = "target_not_select";
pub const OPTION_NOT_FOUND: &str = "option_not_found";
pub const SINGLE_SELECT_VALUE_COUNT: &str = "single_select_value_count";
pub const TAB_NOT_ACTIVE: &str = "tab_not_active";
pub const CDP_EXTENSION_ACCESS_DENIED: &str = "cdp_extension_access_denied";
pub const BORROW_CONFLICT: &str = "borrow_conflict";
pub const SCREENSHOT_CAPTURE_FAILED: &str = "screenshot_capture_failed";
pub const FILE_INPUT_PROBE_FAILED: &str = "file_input_probe_failed";
@@ -206,6 +212,13 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
return base;
};
match (code, reason) {
(ErrorCode::CdpFailed, reason::CDP_EXTENSION_ACCESS_DENIED) => RenderInfo {
summary: "Chrome blocked CDP access to another extension's content in this tab",
hint: Some(
"a web page can contain a restricted extension frame; disable the conflicting extension and reload, or use `bsk navigate <url>` to leave this page; reconnecting BrowserSkill alone does not remove the restriction",
),
exit_code: base.exit_code,
},
(_, reason::TRANSFER_OUTCOME_UNKNOWN) => RenderInfo {
summary: "the file transfer outcome could not be confirmed",
hint: Some(
@@ -247,7 +260,42 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
(ErrorCode::InvalidParams, reason::TARGET_NOT_FILLABLE) => RenderInfo {
summary: "target element is not fillable",
hint: Some(
"choose an input, textarea, or contenteditable element from the latest snapshot",
"observe the page and choose an enabled, editable text input, textarea, or contenteditable; use the appropriate interaction for other control types",
),
exit_code: base.exit_code,
},
(ErrorCode::InvalidParams, reason::FILL_VALUE_INVALID) => RenderInfo {
summary: "the requested text does not fit the field constraints",
hint: Some(
"check the field's input type and maxlength; use a compatible value without silently truncating or changing the user's intended data",
),
exit_code: base.exit_code,
},
(ErrorCode::CdpFailed, reason::FILL_TARGET_CHANGED) => RenderInfo {
summary: "the fill target changed during the action",
hint: Some(
"observe the current page and field value; if the intended result is missing, use a fresh editable target before retrying",
),
exit_code: base.exit_code,
},
(ErrorCode::CdpFailed, reason::FILL_FOCUS_LOST) => RenderInfo {
summary: "the page moved focus away from the fill target",
hint: Some(
"observe the page for a dialog or rerender and resolve it before retrying; fill manages field focus and does not require bringing the browser to the foreground",
),
exit_code: base.exit_code,
},
(ErrorCode::CdpFailed, reason::FILL_VALUE_MISMATCH) => RenderInfo {
summary: "the fill result could not be confirmed",
hint: Some(
"observe the field before retrying; the page may have formatted the value. Continue if the visible result satisfies the user's intent; otherwise correct the remaining difference. Do not blindly repeat fill or immediately request human help",
),
exit_code: base.exit_code,
},
(ErrorCode::CdpFailed, reason::FILL_FAILED) => RenderInfo {
summary: "fill encountered a browser or page-script error",
hint: Some(
"observe the page before retrying because the field may already have changed; retry only if the intended result is missing and the target is still available",
),
exit_code: base.exit_code,
},
@@ -442,6 +490,56 @@ mod tests {
ErrorCode::NoBrowserConnected,
];
#[test]
fn fill_reasons_provide_recovery_without_changing_exit_codes() {
for (code, reason, hint_fragment) in [
(
ErrorCode::InvalidParams,
reason::TARGET_NOT_FILLABLE,
"enabled, editable",
),
(
ErrorCode::InvalidParams,
reason::FILL_VALUE_INVALID,
"maxlength",
),
(
ErrorCode::CdpFailed,
reason::FILL_TARGET_CHANGED,
"fresh editable target",
),
(
ErrorCode::CdpFailed,
reason::FILL_FOCUS_LOST,
"does not require bringing the browser",
),
(
ErrorCode::CdpFailed,
reason::FILL_VALUE_MISMATCH,
"Continue if the visible result satisfies",
),
(
ErrorCode::CdpFailed,
reason::FILL_FAILED,
"may already have changed",
),
] {
let data = serde_json::json!({ "reason": reason });
let info = info_for_error(code, Some(&data));
assert!(info.hint.unwrap().contains(hint_fragment), "{reason}");
assert_eq!(info.exit_code, info_for(code).exit_code);
}
let mismatch = serde_json::json!({ "reason": reason::FILL_VALUE_MISMATCH });
let info = info_for_error(ErrorCode::CdpFailed, Some(&mismatch));
assert_eq!(info.summary, "the fill result could not be confirmed");
assert!(info.hint.unwrap().contains("Do not blindly repeat fill"));
let unknown = serde_json::json!({ "reason": "future_fill_reason" });
assert_eq!(
info_for_error(ErrorCode::CdpFailed, Some(&unknown)),
info_for(ErrorCode::CdpFailed)
);
}
#[test]
fn element_not_visible_overrides_permission_denied_copy() {
let data = serde_json::json!({ "reason": reason::ELEMENT_NOT_VISIBLE });
@@ -454,6 +552,18 @@ mod tests {
assert!(!info.summary.contains("sandbox"));
}
#[test]
fn extension_access_denied_explains_the_frame_restriction_and_navigation_recovery() {
let data = serde_json::json!({ "reason": reason::CDP_EXTENSION_ACCESS_DENIED });
let info = info_for_error(ErrorCode::CdpFailed, Some(&data));
assert!(info.summary.contains("another extension"));
let hint = info.hint.unwrap();
assert!(hint.contains("restricted extension frame"));
assert!(hint.contains("bsk navigate <url>"));
assert!(hint.contains("disable the conflicting extension"));
assert_eq!(info.exit_code, 3);
}
#[test]
fn session_busy_overrides_timeout_copy() {
let data = serde_json::json!({ "reason": reason::SESSION_BUSY });
+2 -2
View File
@@ -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),
+238 -73
View File
@@ -6,9 +6,7 @@ use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
use std::process::{Command, Stdio};
mod windows;
use anyhow::{Context, Result, bail};
use clap::Args;
@@ -363,7 +361,8 @@ fn install_candidate_with_client(
crate::daemon::start::run_stop().context("stop bsk daemon before update")?;
}
let action = replace_binary_for_update(&target, &binary, daemon_was_running)?;
let restart_args = daemon_was_running.then(StartArgs::default);
let action = replace_binary_for_update(&target, &binary, restart_args.as_ref())?;
if daemon_was_running && matches!(action, InstallAction::Replaced) {
crate::daemon::start::run_start(StartArgs::default())
@@ -394,8 +393,8 @@ pub(crate) fn download_candidate_binary(
/// Daemon-side install: download, verify, and replace the executable at
/// `target` (on Windows: stage the replacement next to it). Unlike the
/// CLI path this never stops or starts the daemon — the daemon drives
/// its own restart once the binary is replaced.
/// CLI path this never stops the daemon. On Windows the helper starts
/// its replacement after exit; elsewhere the daemon drives its own restart.
///
/// `target` must be captured *before* any replacement happens: on Linux
/// `std::env::current_exe` starts returning a ` (deleted)`-suffixed
@@ -403,10 +402,11 @@ pub(crate) fn download_candidate_binary(
pub(crate) fn self_install_candidate(
candidate: &UpdateCandidate,
target: &Path,
restart_args: &StartArgs,
) -> Result<InstallAction> {
let client = update_http_client(ARCHIVE_FETCH_TIMEOUT)?;
let binary = download_candidate_binary(candidate, &client)?;
replace_binary_at_path(target, &binary)
replace_binary_for_update(target, &binary, Some(restart_args))
}
/// Outcome of one daemon auto-update step (see [`auto_update_step`]).
@@ -423,8 +423,8 @@ pub(crate) enum AutoUpdateOutcome {
/// The new binary replaced the old one; the daemon should now
/// restart into it.
Replaced { latest: String },
/// Windows staged the replacement next to the running binary; a
/// daemon/terminal restart finishes it.
/// Windows started a helper that will replace the executable and
/// restart the daemon once the current process exits.
Staged { latest: String },
}
@@ -668,6 +668,7 @@ fn render_report(format: Format, report: &UpdateReport) -> Result<()> {
}
if matches!(report.install_action, Some("staged")) {
println!("the detached update helper will apply the replacement after exit");
println!("if it fails, see the *.update-*.log file next to the executable");
}
}
Format::Json => {
@@ -688,22 +689,22 @@ pub fn extract_bsk_binary(archive_bytes: &[u8], kind: ArchiveKind) -> Result<Vec
}
pub fn replace_binary_at_path(target: &Path, binary: &[u8]) -> Result<InstallAction> {
replace_binary_for_update(target, binary, false)
replace_binary_for_update(target, binary, None)
}
fn replace_binary_for_update(
target: &Path,
binary: &[u8],
restart_daemon: bool,
restart_args: Option<&StartArgs>,
) -> Result<InstallAction> {
#[cfg(windows)]
{
stage_windows_replacement(target, binary, restart_daemon)
stage_windows_replacement(target, binary, restart_args)
}
#[cfg(not(windows))]
{
let _ = restart_daemon;
let _ = restart_args;
replace_binary_atomically(target, binary)
}
}
@@ -749,33 +750,69 @@ fn replace_binary_atomically(target: &Path, binary: &[u8]) -> Result<InstallActi
fn stage_windows_replacement(
target: &Path,
binary: &[u8],
restart_daemon: bool,
restart_args: Option<&StartArgs>,
) -> Result<InstallAction> {
let paths = staged_replacement_paths(target, std::process::id())?;
std::fs::write(&paths.binary_path, binary)
.with_context(|| format!("write {}", paths.binary_path.display()))?;
std::fs::write(
&paths.script_path,
windows_replacement_script(target, &paths.binary_path, restart_daemon),
)
.with_context(|| format!("write {}", paths.script_path.display()))?;
// The helper owns replacement/restart after this process exits. Do not
// report Staged until it has actually begun executing the script.
let _child = spawn_windows_replacement(target, binary, restart_args, 120)?;
Ok(InstallAction::Staged)
}
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let command = format!("\"{}\"", paths.script_path.display());
if let Err(err) = Command::new("cmd.exe")
.args(["/D", "/S", "/C"])
.arg(command)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(CREATE_NO_WINDOW)
.spawn()
{
#[cfg(windows)]
fn spawn_windows_replacement(
target: &Path,
binary: &[u8],
restart_args: Option<&StartArgs>,
attempts: u32,
) -> Result<windows::Helper> {
let paths = staged_replacement_paths(target, std::process::id())?;
let ready_path = paths.script_path.with_extension("ready");
let log_path = paths.script_path.with_extension("log");
let result = (|| {
// A previous failed attempt by this process must not acknowledge a
// new helper. All paths are private to this target and process id.
match std::fs::remove_file(&ready_path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}
std::fs::write(&paths.binary_path, binary)
.with_context(|| format!("write {}", paths.binary_path.display()))?;
std::fs::write(
&paths.script_path,
windows_replacement_script(restart_args, attempts),
)
.with_context(|| format!("write {}", paths.script_path.display()))?;
let mut child = windows::spawn(
&paths.script_path,
&paths.binary_path,
target,
&ready_path,
&log_path,
)
.context("launch detached Windows update helper")?;
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
if std::fs::remove_file(&ready_path).is_ok() {
return Ok(child);
}
if let Some(status) = child.try_wait()? {
bail!("Windows update helper exited before becoming ready: {status}");
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
bail!("Windows update helper did not become ready within 5 seconds");
}
std::thread::sleep(Duration::from_millis(20));
}
})();
if result.is_err() {
let _ = std::fs::remove_file(&paths.binary_path);
let _ = std::fs::remove_file(&paths.script_path);
return Err(err).context("launch detached Windows update helper");
let _ = std::fs::remove_file(&ready_path);
}
Ok(InstallAction::Staged)
result.with_context(|| format!("Windows update helper failed; see {}", log_path.display()))
}
pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplacementPaths> {
@@ -792,25 +829,43 @@ pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplace
}
#[cfg(any(windows, test))]
fn windows_replacement_script(target: &Path, staged_binary: &Path, restart_daemon: bool) -> String {
let target = target.display().to_string().replace('%', "%%");
let staged_binary = staged_binary.display().to_string().replace('%', "%%");
let restart = if restart_daemon {
format!("start \"\" /B \"{target}\" daemon start >nul 2>nul\r\n")
} else {
String::new()
};
fn windows_replacement_script(restart_args: Option<&StartArgs>, attempts: u32) -> String {
// Only fixed switches and numeric values go into the ASCII script. Paths
// stay in Unicode environment variables, including %, ! and shell symbols.
let restart = restart_args.map_or_else(String::new, |args| {
format!(
"\"%BSK_UPDATE_TARGET%\" daemon start --port {} --session-idle {}ms --daemon-idle {}ms\r\nif errorlevel 1 goto failed_restart\r\n",
args.resolved_port(),
args.resolved_session_idle().as_millis(),
args.resolved_daemon_idle().as_millis(),
)
});
format!(
"@echo off\r\n\
setlocal\r\n\
setlocal DisableDelayedExpansion\r\n\
> \"%BSK_UPDATE_READY%\" echo ready\r\n\
if errorlevel 1 exit /b 1\r\n\
set /a attempts=0\r\n\
:retry\r\n\
move /Y \"{staged_binary}\" \"{target}\" >nul 2>nul\r\n\
if errorlevel 1 (\r\n\
timeout /t 1 /nobreak >nul\r\n\
goto retry\r\n\
)\r\n\
move /Y \"%BSK_UPDATE_SOURCE%\" \"%BSK_UPDATE_TARGET%\" >nul\r\n\
if not errorlevel 1 goto replaced\r\n\
set /a attempts+=1\r\n\
if %attempts% geq {attempts} goto failed_replace\r\n\
if not exist \"%BSK_UPDATE_SOURCE%\" goto failed_replace\r\n\
ping -n 2 127.0.0.1 >nul\r\n\
goto retry\r\n\
:failed_replace\r\n\
echo Failed to replace the executable after %attempts% attempts.\r\n\
goto failed\r\n\
:replaced\r\n\
{restart}\
del /F /Q \"%~f0\" >nul 2>nul\r\n"
del /F /Q \"%BSK_UPDATE_LOG%\" >nul 2>nul\r\n\
(del /F /Q \"%BSK_UPDATE_SCRIPT%\" >nul 2>nul & exit 0)\r\n\
:failed_restart\r\n\
echo Replaced the executable but failed to restart the daemon.\r\n\
:failed\r\n\
del /F /Q \"%BSK_UPDATE_SOURCE%\" >nul 2>nul\r\n\
(del /F /Q \"%BSK_UPDATE_SCRIPT%\" >nul 2>nul & exit 1)\r\n"
)
}
@@ -964,6 +1019,7 @@ mod tests {
assert_eq!(extracted, b"windows binary");
}
#[cfg(not(windows))]
#[test]
fn replaces_binary_at_path() {
let tmp = tempfile::TempDir::new().unwrap();
@@ -998,29 +1054,139 @@ mod tests {
}
#[test]
fn windows_replacement_script_retries_restarts_and_cleans_itself() {
let script = windows_replacement_script(
Path::new(r"C:\Program Files\bsk.exe"),
Path::new(r"C:\Program Files\bsk.exe.update-42"),
true,
fn windows_replacement_script_preserves_restart_config() {
let args = StartArgs {
port: Some(54321),
session_idle: Some(Duration::from_millis(1234)),
daemon_idle: Some(Duration::from_secs(75)),
..Default::default()
};
let script = windows_replacement_script(Some(&args), 120);
assert!(script.is_ascii());
assert!(
script
.contains("daemon start --port 54321 --session-idle 1234ms --daemon-idle 75000ms")
);
assert!(script.contains(":retry"));
assert!(script.contains("move /Y"));
assert!(script.contains(r#"start "" /B "C:\Program Files\bsk.exe" daemon start"#));
assert!(script.contains(r#"del /F /Q "%~f0""#));
assert!(!windows_replacement_script(None, 120).contains("daemon start"));
}
#[test]
fn windows_replacement_script_omits_restart_when_daemon_was_not_running() {
let script = windows_replacement_script(
Path::new(r"C:\bsk.exe"),
Path::new(r"C:\bsk.exe.update-42"),
false,
);
#[cfg(windows)]
fn wait_for_helper(mut child: windows::Helper) -> std::process::ExitStatus {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
if let Some(status) = child.try_wait().unwrap() {
return status;
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
panic!("Windows update helper did not exit");
}
std::thread::sleep(Duration::from_millis(20));
}
}
assert!(!script.contains("daemon start"));
assert!(script.contains(r#"del /F /Q "%~f0""#));
#[cfg(windows)]
#[test]
fn windows_helper_replaces_in_unicode_spaces_and_shell_symbol_paths() {
let tmp = tempfile::TempDir::new().unwrap();
for name in [
"ascii",
"with space",
"中文目录",
"literal %PATH% ! & (folder)",
] {
let dir = tmp.path().join(name);
std::fs::create_dir(&dir).unwrap();
let target = dir.join("bsk.exe");
std::fs::write(&target, b"old binary").unwrap();
let child = spawn_windows_replacement(&target, b"new binary", None, 5).unwrap();
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
let status = wait_for_helper(child);
assert!(
status.success(),
"{name}: {:?}",
std::fs::read_to_string(paths.script_path.with_extension("log"))
);
assert_eq!(std::fs::read(&target).unwrap(), b"new binary", "{name}");
// Successful updates leave no helper, staged binary, ready file or log.
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "{name}");
}
}
#[cfg(windows)]
#[test]
fn windows_helper_waits_for_unlock_then_replaces() {
use std::os::windows::fs::OpenOptionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("bsk.exe");
std::fs::write(&target, b"old binary").unwrap();
// Deny delete sharing, like a running Windows executable.
let lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(1)
.open(&target)
.unwrap();
let mut child = spawn_windows_replacement(&target, b"new binary", None, 5).unwrap();
std::thread::sleep(Duration::from_millis(150));
assert!(child.try_wait().unwrap().is_none());
assert_eq!(std::fs::read(&target).unwrap(), b"old binary");
drop(lock);
assert!(wait_for_helper(child).success());
assert_eq!(std::fs::read(&target).unwrap(), b"new binary");
}
#[cfg(windows)]
#[test]
fn windows_helper_bounds_retries_and_preserves_old_binary_on_failure() {
use std::os::windows::fs::OpenOptionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("bsk.exe");
std::fs::write(&target, b"old binary").unwrap();
let _lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(1)
.open(&target)
.unwrap();
let child = spawn_windows_replacement(&target, b"new binary", None, 2).unwrap();
assert!(!wait_for_helper(child).success());
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"old binary");
assert!(!paths.binary_path.exists());
assert!(!paths.script_path.exists());
let log_bytes = std::fs::read(paths.script_path.with_extension("log")).unwrap();
let log = String::from_utf8_lossy(&log_bytes);
assert!(
log.contains("Failed to replace the executable after 2 attempts."),
"{log}"
);
}
#[cfg(windows)]
#[test]
fn windows_helper_reports_restart_failure_without_losing_replacement() {
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("bsk.exe");
std::fs::write(&target, b"old binary").unwrap();
// Not a valid PE executable: replacement succeeds but restart fails.
let child = spawn_windows_replacement(
&target,
b"invalid executable",
Some(&StartArgs::default()),
2,
)
.unwrap();
assert!(!wait_for_helper(child).success());
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
let bytes = std::fs::read(paths.script_path.with_extension("log")).unwrap();
let log = String::from_utf8_lossy(&bytes);
assert!(
log.contains("Replaced the executable but failed to restart the daemon."),
"{log}"
);
assert_eq!(std::fs::read(&target).unwrap(), b"invalid executable");
assert!(!paths.script_path.exists());
assert!(!paths.binary_path.exists());
}
#[test]
@@ -1279,10 +1445,9 @@ mod tests {
}
#[test]
fn auto_update_step_staged_outcome_requests_no_restart() {
// The Windows shape: the replacement is staged next to the
// running binary, so the outcome must not ask for the immediate
// self-restart that `Replaced` triggers.
fn auto_update_step_reports_staged_handoff() {
// The helper, rather than this process, starts the new executable.
// The daemon must exit to let that staged handoff finish.
let candidate = test_candidate();
let outcome =
auto_update_step(Some(&candidate), true, 0, |_| Ok(InstallAction::Staged)).unwrap();
+203
View File
@@ -0,0 +1,203 @@
//! Launch the update helper with only its stdio handles inherited.
//!
//! std::process::Command on Windows also inherits other inheritable handles.
//! A daemon's listening socket must not survive in the helper: it would keep
//! the port occupied after the daemon exits and prevent its replacement starting.
use std::ffi::OsStr;
use std::fs::File;
use std::io;
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::os::windows::process::ExitStatusExt;
use std::path::Path;
use std::process::ExitStatus;
use windows_sys::Win32::Foundation::{
HANDLE, HANDLE_FLAG_INHERIT, SetHandleInformation, WAIT_OBJECT_0, WAIT_TIMEOUT,
};
use windows_sys::Win32::System::Threading::{
CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, CreateProcessW,
DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetExitCodeProcess,
InitializeProcThreadAttributeList, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROCESS_INFORMATION,
STARTF_USESTDHANDLES, STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute,
WaitForSingleObject,
};
pub(super) struct Helper(OwnedHandle);
impl Helper {
pub(super) fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
// SAFETY: the owned process handle remains valid for both calls.
match unsafe { WaitForSingleObject(self.0.as_raw_handle(), 0) } {
WAIT_OBJECT_0 => {
let mut code = 0;
if unsafe { GetExitCodeProcess(self.0.as_raw_handle(), &mut code) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(Some(ExitStatus::from_raw(code)))
}
WAIT_TIMEOUT => Ok(None),
_ => Err(io::Error::last_os_error()),
}
}
pub(super) fn kill(&mut self) -> io::Result<()> {
// SAFETY: this handle belongs to the helper we created.
if unsafe { TerminateProcess(self.0.as_raw_handle(), 1) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
pub(super) fn wait(&mut self) -> io::Result<()> {
// Only used after kill(); do not leave a live helper on startup failure.
if unsafe { WaitForSingleObject(self.0.as_raw_handle(), 5000) } != WAIT_OBJECT_0 {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"update helper did not exit",
));
}
Ok(())
}
}
// The opaque attribute list needs pointer-aligned storage and explicit teardown.
struct AttributeList(Vec<usize>);
impl Drop for AttributeList {
fn drop(&mut self) {
// SAFETY: this wrapper is constructed only after successful initialization.
unsafe { DeleteProcThreadAttributeList(self.0.as_mut_ptr().cast()) };
}
}
pub(super) fn spawn(
script: &Path,
source: &Path,
target: &Path,
ready: &Path,
log_path: &Path,
) -> io::Result<Helper> {
let root = std::env::var_os("SystemRoot")
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "SystemRoot is not set"))?;
let application = wide(Path::new(&root).join("System32/cmd.exe").as_os_str());
// /S /C strips one outer pair of quotes. Environment expansion keeps paths
// Unicode and avoids CRT escaping, batch-file encoding, and CALL expansion.
let mut command = wide(OsStr::new(
"cmd.exe /D /V:OFF /S /C \"\"%BSK_UPDATE_SCRIPT%\"\"",
));
let overrides = [
("BSK_UPDATE_SCRIPT", script.as_os_str()),
("BSK_UPDATE_SOURCE", source.as_os_str()),
("BSK_UPDATE_TARGET", target.as_os_str()),
("BSK_UPDATE_READY", ready.as_os_str()),
("BSK_UPDATE_LOG", log_path.as_os_str()),
];
let mut env: Vec<_> = std::env::vars_os()
.filter(|(key, _)| {
let key = key.to_string_lossy();
!overrides
.iter()
.any(|(name, _)| key.eq_ignore_ascii_case(name))
&& !key.eq_ignore_ascii_case(crate::daemon::start::DAEMONIZED_ENV)
&& !key.eq_ignore_ascii_case(crate::daemon::start::DAEMON_REPLACEMENT_WAIT_ENV)
})
.collect();
env.extend(
overrides
.into_iter()
.map(|(key, value)| (key.into(), value.to_owned())),
);
env.sort_by_key(|(key, _)| key.to_string_lossy().to_uppercase());
let mut environment = Vec::new();
for (key, value) in env {
environment.extend(key.encode_wide());
environment.push(b'=' as u16);
environment.extend(value.encode_wide());
environment.push(0);
}
environment.push(0);
let input = File::open("NUL")?;
let log = File::create(log_path)?;
let handles: [HANDLE; 2] = [input.as_raw_handle(), log.as_raw_handle()];
for &handle in &handles {
// SAFETY: these are dedicated helper stdio handles, held until spawn
// finishes. The explicit handle list excludes every other parent handle.
if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) } == 0 {
return Err(io::Error::last_os_error());
}
}
let mut size = 0;
// SAFETY: the first call queries the allocation size, as required by Win32.
unsafe { InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut size) };
if size == 0 {
return Err(io::Error::last_os_error());
}
let mut storage = vec![0usize; size.div_ceil(std::mem::size_of::<usize>())];
// SAFETY: storage has the requested size and pointer alignment.
if unsafe { InitializeProcThreadAttributeList(storage.as_mut_ptr().cast(), 1, 0, &mut size) }
== 0
{
return Err(io::Error::last_os_error());
}
let mut attributes = AttributeList(storage);
// SAFETY: the handle array and attribute storage outlive CreateProcessW.
if unsafe {
UpdateProcThreadAttribute(
attributes.0.as_mut_ptr().cast(),
0,
PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize,
handles.as_ptr().cast(),
std::mem::size_of_val(&handles),
std::ptr::null_mut(),
std::ptr::null(),
)
} == 0
{
return Err(io::Error::last_os_error());
}
// SAFETY: these Win32 structs permit zero initialization; required fields
// are populated below before the process is created.
let mut startup: STARTUPINFOEXW = unsafe { std::mem::zeroed() };
startup.StartupInfo.cb = std::mem::size_of::<STARTUPINFOEXW>() as u32;
startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
startup.StartupInfo.hStdInput = handles[0];
startup.StartupInfo.hStdOutput = handles[1];
startup.StartupInfo.hStdError = handles[1];
startup.lpAttributeList = attributes.0.as_mut_ptr().cast();
let mut process: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
// SAFETY: strings are NUL-terminated UTF-16, the environment is double-NUL
// terminated, and all buffers/handles remain alive for the duration of spawn.
if unsafe {
CreateProcessW(
application.as_ptr(),
command.as_mut_ptr(),
std::ptr::null(),
std::ptr::null(),
1,
CREATE_NO_WINDOW
| CREATE_NEW_PROCESS_GROUP
| CREATE_UNICODE_ENVIRONMENT
| EXTENDED_STARTUPINFO_PRESENT,
environment.as_ptr().cast(),
std::ptr::null(),
&startup.StartupInfo,
&mut process,
)
} == 0
{
return Err(io::Error::last_os_error());
}
// SAFETY: successful CreateProcessW transfers these two handles to us.
let _thread = unsafe { OwnedHandle::from_raw_handle(process.hThread) };
Ok(Helper(unsafe {
OwnedHandle::from_raw_handle(process.hProcess)
}))
}
fn wide(value: &OsStr) -> Vec<u16> {
value.encode_wide().chain(Some(0)).collect()
}
+134
View File
@@ -1399,6 +1399,28 @@ mod windows {
connected = pipe.connect() => {
match connected {
Ok(()) => {
// Keep the connected instance alive until its replacement
// exists. Otherwise a fast handler can close the last
// instance and make new clients fail with NotFound.
loop {
match ServerOptions::new()
.access_inbound(true)
.access_outbound(true)
.create(&listener.pipe_name)
{
Ok(next) => {
listener.first = Some(next);
break;
}
Err(err) => {
warn!(?err, "create next named-pipe instance failed");
tokio::select! {
_ = &mut shutdown => return,
_ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
}
}
}
}
on_open();
let handler = handler.clone();
let on_act = on_activity.clone();
@@ -1467,6 +1489,118 @@ mod windows {
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use std::time::Duration;
use tokio::net::windows::named_pipe::ClientOptions;
use tokio::sync::oneshot;
fn isolated_listener() -> NamedPipeListener {
let pipe_name = format!(r"\\.\pipe\bsk-test-{}", uuid::Uuid::new_v4());
let first = ServerOptions::new()
.first_pipe_instance(true)
.create(&pipe_name)
.unwrap();
NamedPipeListener {
pipe_name,
first: Some(first),
}
}
#[tokio::test]
async fn next_instance_exists_before_connection_is_handed_off() {
let listener = isolated_listener();
let name = listener.pipe_name.clone();
let probe_name = name.clone();
let (probe_tx, probe_rx) = oneshot::channel();
let probe_tx = Mutex::new(Some(probe_tx));
let (stop_tx, stop_rx) = oneshot::channel();
let server = tokio::spawn(serve(
listener,
crate::daemon::ipc::default_ping_handler(),
move || {
if let Some(tx) = probe_tx.lock().unwrap().take() {
// This callback runs before the connection task can finish.
// A second client must already have an instance to open.
let result = ClientOptions::new().open(&probe_name).map(drop);
let _ = tx.send(result);
}
},
|| {},
|| {},
async {
let _ = stop_rx.await;
},
));
let client = ClientOptions::new().open(&name).unwrap();
let result = tokio::time::timeout(Duration::from_secs(5), probe_rx).await;
drop(client);
let _ = stop_tx.send(());
server.await.unwrap();
result
.expect("accept callback ran")
.unwrap()
.expect("next pipe instance is ready");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn rapid_disconnects_and_concurrent_rpc_connections() {
let listener = isolated_listener();
let name = listener.pipe_name.clone();
let (stop_tx, stop_rx) = oneshot::channel();
let server = tokio::spawn(serve(
listener,
crate::daemon::ipc::default_ping_handler(),
|| {},
|| {},
|| {},
async {
let _ = stop_rx.await;
},
));
let exercise = async {
// Include clients that close without sending any request.
for _ in 0..64 {
drop(
crate::ipc_client::Client::connect_path(name.clone().into())
.await
.unwrap(),
);
}
let mut clients = tokio::task::JoinSet::new();
for _ in 0..8 {
let name = name.clone();
clients.spawn(async move {
for _ in 0..32 {
let mut client =
crate::ipc_client::Client::connect_path(name.clone().into())
.await
.unwrap();
let reply: bsk_protocol::PingResult = client
.call(
bsk_protocol::Method::SystemPing,
&serde_json::json!({}),
Duration::from_secs(5),
)
.await
.unwrap()
.unwrap();
assert!(reply.pong);
}
});
}
while let Some(result) = clients.join_next().await {
result.unwrap();
}
};
let result = tokio::time::timeout(Duration::from_secs(30), exercise).await;
let _ = stop_tx.send(());
server.await.unwrap();
result.expect("connection exercise completed");
}
}
}
#[cfg(windows)]
+44 -4
View File
@@ -90,17 +90,21 @@ pub fn pid_alive(pid: u32) -> bool {
#[cfg(windows)]
{
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::Foundation::{CloseHandle, WAIT_TIMEOUT};
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject,
};
unsafe {
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
let handle = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid);
if handle.is_null() {
return false;
}
// An exited process can still be opened while another handle
// keeps its kernel object alive. Poll its termination signal;
// unlike checking STILL_ACTIVE, this also handles exit code 259.
let alive = WaitForSingleObject(handle, 0) == WAIT_TIMEOUT;
CloseHandle(handle);
true
alive
}
}
@@ -110,3 +114,39 @@ pub fn pid_alive(pid: u32) -> bool {
false
}
}
#[cfg(all(test, windows))]
mod tests {
use super::pid_alive;
use std::os::windows::process::CommandExt;
use std::process::Command;
#[test]
fn current_process_is_alive() {
assert!(pid_alive(std::process::id()));
}
#[test]
fn invalid_process_is_not_alive() {
assert!(!pid_alive(0));
assert!(!pid_alive(u32::MAX));
}
#[test]
fn exited_process_with_retained_handle_is_not_alive() {
for exit_code in [0, 259] {
let mut child = Command::new("cmd.exe")
.args(["/D", "/C", &format!("exit {exit_code}")])
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
.spawn()
.expect("spawn short-lived process");
assert_eq!(child.wait().unwrap().code(), Some(exit_code));
// Keep Child (and its process handle) alive during the probe.
assert!(
!pid_alive(child.id()),
"exited process with code {exit_code}"
);
drop(child);
}
}
}
+14 -9
View File
@@ -232,8 +232,8 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
let session_idle_task = spawn_session_idle_reaper(Arc::clone(&state));
let browser_liveness_task = spawn_browser_liveness_reaper(Arc::clone(&state));
// Fired by the update check task after a successful auto-update:
// the replacement daemon has already been spawned, so this
// process should shut down and let it take over.
// the replacement daemon (or Windows update helper) is ready, so
// this process should shut down and let it take over.
let restart_notify = Arc::new(tokio::sync::Notify::new());
let update_check_task =
spawn_update_check_task(Arc::clone(&state), Arc::clone(&restart_notify));
@@ -539,8 +539,8 @@ pub(crate) fn spawn_session_idle_reaper(state: Arc<DaemonState>) -> tokio::task:
/// task spawns the replacement daemon (see
/// [`DAEMON_REPLACEMENT_WAIT_ENV`]) and fires `restart` so this process
/// shuts down and the new version takes over; on Windows the
/// replacement can only be staged, so it just logs that a restart is
/// needed.
/// helper waits for this process to exit, replaces the binary, and starts
/// the new daemon with the same configuration.
pub(crate) fn spawn_update_check_task(
state: Arc<DaemonState>,
restart: Arc<tokio::sync::Notify>,
@@ -605,7 +605,11 @@ pub(crate) fn spawn_update_check_task(
|candidate| {
let target =
exe_path.as_deref().context("current executable unknown")?;
update::self_install_candidate(candidate, target)
update::self_install_candidate(
candidate,
target,
&restart_start_args(&state.config),
)
},
)
})
@@ -635,10 +639,11 @@ pub(crate) fn spawn_update_check_task(
sessions,
"auto-update postponed: agent session(s) active; will retry on the next tick"
),
update::AutoUpdateOutcome::Staged { latest } => warn!(
%latest,
"auto-update staged the new binary but cannot replace the running daemon in place; restart the daemon (or terminal) to finish the upgrade"
),
update::AutoUpdateOutcome::Staged { latest } => {
info!(%latest, "update helper ready; exiting so it can replace and restart the daemon");
restart.notify_one();
return;
}
update::AutoUpdateOutcome::Replaced { latest } => {
info!(
current = env!("CARGO_PKG_VERSION"),
+2
View File
@@ -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
+343
View File
@@ -0,0 +1,343 @@
//! Exercise self-update with a real executable and a local release server.
#![cfg(windows)]
use std::fs;
use std::io::{Cursor, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::thread;
use std::time::{Duration, Instant};
use bsk::daemon::info::DaemonInfo;
use sha2::{Digest, Sha256};
const MARKER: &[u8] = b"windows-update-regression-fixture";
struct ReleaseServer {
url: String,
requests: Arc<AtomicUsize>,
stop: Arc<AtomicBool>,
worker: Option<thread::JoinHandle<()>>,
}
impl ReleaseServer {
fn new(binary: &[u8]) -> Self {
let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new()));
archive
.start_file(
"bsk.exe",
zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored),
)
.unwrap();
archive.write_all(binary).unwrap();
let archive = archive.finish().unwrap().into_inner();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let manifest = serde_json::to_vec(&serde_json::json!({
"version": "999.0.0",
"assets": {"windows-x64": {
"url": format!("{url}/bsk.zip"),
"sha256": Sha256::digest(&archive).iter().map(|byte| format!("{byte:02x}")).collect::<String>(),
}},
}))
.unwrap();
let stop = Arc::new(AtomicBool::new(false));
let worker_stop = Arc::clone(&stop);
let requests = Arc::new(AtomicUsize::new(0));
let worker_requests = Arc::clone(&requests);
let worker = thread::spawn(move || {
while !worker_stop.load(Ordering::SeqCst) {
let (mut stream, _) = match listener.accept() {
Ok(connection) => connection,
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
continue;
}
Err(err) => panic!("accept release request: {err}"),
};
// Windows accept() inherits the listener's nonblocking mode.
// Keep accept polling for shutdown, but read/write each request
// in blocking mode with the bounded timeouts below.
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut request = Vec::new();
let mut chunk = [0; 1024];
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
let len = stream.read(&mut chunk).unwrap();
if len == 0 {
break;
}
request.extend_from_slice(&chunk[..len]);
}
let body = if request.starts_with(b"GET /bsk.zip ") {
worker_requests.fetch_add(1, Ordering::SeqCst);
&archive
} else {
&manifest
};
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.unwrap();
stream.write_all(body).unwrap();
}
});
Self {
url: format!("{url}/version.json"),
requests,
stop,
worker: Some(worker),
}
}
}
impl Drop for ReleaseServer {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
let _ = self.worker.take().unwrap().join();
}
}
#[test]
fn release_server_waits_for_delayed_and_fragmented_request_headers() {
let server = ReleaseServer::new(b"test binary");
let address = server
.url
.strip_prefix("http://")
.unwrap()
.strip_suffix("/version.json")
.unwrap();
let mut stream = TcpStream::connect(address).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(5)))
.unwrap();
// Give accept() time to run before any bytes arrive, then split the
// headers across writes. Both reads must wait rather than return WouldBlock.
thread::sleep(Duration::from_millis(100));
stream.write_all(b"GET /version.json HTTP/1.1\r\n").unwrap();
thread::sleep(Duration::from_millis(100));
stream.write_all(b"Host: localhost\r\n\r\n").unwrap();
let mut response = String::new();
stream.read_to_string(&mut response).unwrap();
assert!(response.starts_with("HTTP/1.1 200 OK\r\n"), "{response}");
let (_, body) = response.split_once("\r\n\r\n").unwrap();
let manifest: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(manifest["version"], "999.0.0");
}
struct Fixture {
_tmp: tempfile::TempDir,
exe: PathBuf,
home: PathBuf,
binary: Vec<u8>,
server: ReleaseServer,
daemon: Option<Child>,
}
impl Fixture {
fn new() -> Self {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("中文 space %PATH% ! & (update)");
fs::create_dir(&dir).unwrap();
let exe = dir.join("bsk.exe");
let home = tmp.path().join("home");
fs::create_dir(&home).unwrap();
fs::copy(env!("CARGO_BIN_EXE_bsk"), &exe).unwrap();
// A PE overlay distinguishes the replacement without requiring a
// second build or changing the executable's behavior/version.
let mut binary = fs::read(&exe).unwrap();
binary.extend_from_slice(MARKER);
let server = ReleaseServer::new(&binary);
Self {
_tmp: tmp,
exe,
home,
binary,
server,
daemon: None,
}
}
fn command(&self) -> Command {
let mut cmd = Command::new(&self.exe);
cmd.env("BSK_HOME", &self.home)
.env("BSK_UPDATE_MANIFEST_URL", &self.server.url)
.env("BSK_AUTO_UPDATE", "off")
.env("RUST_LOG", "info")
.env("NO_PROXY", "127.0.0.1,localhost")
.env_remove("BSK_DAEMONIZED")
.env_remove("BSK_DAEMON_REPLACES_PID")
.stdin(Stdio::null())
.creation_flags(0x0800_0000);
cmd
}
fn wait_for(&self, description: &str, mut check: impl FnMut() -> bool) {
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if check() {
return;
}
thread::sleep(Duration::from_millis(50));
}
let diagnostics: Vec<_> = fs::read_dir(self.exe.parent().unwrap())
.unwrap()
.flatten()
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "log"))
.map(|entry| {
(
entry.path(),
String::from_utf8_lossy(&fs::read(entry.path()).unwrap_or_default())
.into_owned(),
)
})
.collect();
let home_logs: Vec<_> = fs::read_dir(&self.home)
.unwrap()
.flatten()
.filter(|entry| entry.file_name().to_string_lossy().contains("log"))
.map(|entry| {
(
entry.path(),
String::from_utf8_lossy(&fs::read(entry.path()).unwrap_or_default())
.into_owned(),
)
})
.collect();
panic!(
"timed out waiting for {description}; helper logs: {diagnostics:?}; daemon logs: {home_logs:?}"
);
}
fn info(&self) -> Option<DaemonInfo> {
serde_json::from_slice(&fs::read(self.home.join("daemon.json")).ok()?).ok()
}
fn updated(&self) -> bool {
// A locked executable may briefly reject reads during replacement.
fs::read(&self.exe).is_ok_and(|binary| binary == self.binary)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
// Stop only the daemon belonging to this isolated BSK_HOME. Also reap
// the foreground process on assertion failures to avoid leaking locks.
let _ = self.command().args(["daemon", "stop"]).output();
if let Some(child) = self.daemon.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
}
}
#[test]
fn manual_update_replaces_the_running_executable_after_cli_exit() {
let fixture = Fixture::new();
let out = fixture
.command()
.args(["--json", "update", "--yes"])
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(report["status"], "staged");
fixture.wait_for("CLI replacement and helper cleanup", || {
fixture.updated() && fs::read_dir(fixture.exe.parent().unwrap()).unwrap().count() == 1
});
assert!(
fixture.info().is_none(),
"an update must not start a previously absent daemon"
);
assert_eq!(fixture.server.requests.load(Ordering::SeqCst), 1);
}
#[test]
fn automatic_update_exits_old_daemon_and_restarts_on_the_same_port() {
let mut fixture = Fixture::new();
let port = unused_port();
let log = fs::File::create(fixture.home.join("foreground.log")).unwrap();
let child = fixture
.command()
.env("BSK_AUTO_UPDATE", "on")
// Exercise the environment inherited by a normally detached daemon.
.env("BSK_DAEMONIZED", "1")
.args([
"daemon",
"start",
"--port",
&port.to_string(),
"--session-idle",
"1234ms",
"--daemon-idle",
"30s",
])
.stdout(Stdio::from(log.try_clone().unwrap()))
.stderr(Stdio::from(log))
.spawn()
.unwrap();
let old_pid = child.id();
fixture.daemon = Some(child);
fixture.wait_for("replacement daemon", || {
fixture.updated()
&& fixture
.info()
.is_some_and(|info| info.pid != old_pid && info.ws_port == port)
});
assert!(
fixture
.daemon
.as_mut()
.unwrap()
.try_wait()
.unwrap()
.is_some(),
"old daemon must exit"
);
fixture.wait_for("helper cleanup", || {
fs::read_dir(fixture.exe.parent().unwrap()).unwrap().count() == 1
});
let status = fixture
.command()
.args(["--json", "status"])
.output()
.unwrap();
assert!(
status.status.success(),
"{}",
String::from_utf8_lossy(&status.stderr)
);
assert_eq!(
fixture.server.requests.load(Ordering::SeqCst),
1,
"the replacement must not stage another update immediately"
);
}
fn unused_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
@@ -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",
+54 -2
View File
@@ -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);
}
}
@@ -0,0 +1,184 @@
# Visual observation:验收清单
新 agent 先读 [架构与 PR 划分](visual-observation.md),再按本清单验收当前 PR;无需聊天记录,也无需另写报告、逐项编号或填写模板。历史结果见 [PR 0 证据](visual-observation-evidence.md),不能代替本次执行。
## 怎么开始
- 查看分支、HEAD 和工作区改动,确认当前做哪个 PR。PR 0 基于 main `3c5f838`;后续模块对照其前置基线,最终回归对照 main。注意未跟踪文件也属于改动。
- 当前仅有设计、合成 fixture 和诊断脚本;未来模块尚未实现。只验收当前阶段及受影响的已有功能,不要求提前完成后续 PR。
- 测试放在模块附近,调用实际生产代码。未接线模块可用人工输入和 CDP stub;实现 PR 写明运行命令即可,不强制新建指定目录、命名或诊断 API。
- 浏览器验收前核实 CLI 版本,并重新 build、确认扩展 reload。PATH 中的 bsk 曾经比仓库版本旧,不能只凭版本号认定运行了目标代码。
- 不 commit/push。截图、原始页面内容和一次性实验放临时目录;只把长期需要的设计、fixture 和测试留在仓库。
## 现有验证入口
从仓库根目录运行,命令成功退出且相关断言通过才算成功:
```sh
pnpm vom:test
pnpm --filter @browser-skill/extension test src/tools/__tests__/frame-geometry.test.ts src/tools/__tests__/element-geometry.test.ts src/tools/__tests__/observation.test.ts src/tools/__tests__/snapshot-ref.test.ts src/tools/__tests__/interaction.test.ts src/tools/vom src/session-manager/__tests__/ref-store.test.ts
pnpm exec biome check apps/extension/test-fixtures/visual-observation
VISUAL_EVIDENCE="$(mktemp -d /tmp/bsk-visual-acceptance.XXXXXX)"
node apps/extension/test-fixtures/visual-observation/probe.mjs "$VISUAL_EVIDENCE/browser-contract"
node apps/extension/test-fixtures/visual-observation/measure-render.mjs "$VISUAL_EVIDENCE/performance"
```
probe 需要 Node 22+ 和 Chrome,配置见 [fixture 说明](../../apps/extension/test-fixtures/visual-observation/README.md)。它验证浏览器事实,不代表后续生产模块已经通过。measure-render 用于旧 renderer 对照,不要求修复历史复杂度。
实现 PR 还需运行所属模块的新测试、`pnpm --filter @browser-skill/extension compile`、`pnpm ext:test` 和 `pnpm ext:build`。仅修改文档时检查链接和内容即可,不必重跑浏览器。已有失败应说明,不在范围外顺手修复。
## 每个 PR 怎么算完成
### PR 0:设计与复现
只有文档、合成 fixture 和诊断变动;架构、边界及后续 PR 的完成条件明确。现有测试通过;probe 的 checks 非空且全部通过(本版 39 项);measure-render 产生五组 wide/deep 结果。
按下方浏览器步骤,在 main 重现“表格已绘制,但 observe 没有视觉入口”。合成页面和原始 iWiki 分别确认。原始页面未加载、无权限或缺入口时说明阻塞,不能算已复现。入库内容不含内部页面地址、标识或截图。
### PR 1:几何与身份
数值误差:纯函数输入允许 1e-6 CSS px;真实浏览器每条边允许 0.25 CSS px。坐标类型不能混传;动态 frame 归属需运行时检查。
| 怎么测 | 什么算成功 |
|---|---|
| top document bounds=(30,50,120,40),document scroll=(10,20),viewport=800×600 | top border region=(20,30,120,40);不受 backing store 尺寸影响 |
| 使用 frame.html:local Canvas=(17,23,120,40),same owner content origin=(50,610);nested origin=(87,707);OOPIF origin=(410,610) | top origins 分别=(67,633)、(104,730)、(427,633);不能重复给 nested 加父偏移;owner border 不能代替 content |
| child scrollY=10;same frame owner origin 不变 | Canvas top y=623,local y=13;snapshot document y=23;scroll 只扣一次 |
| same iframe 以自身左上角为原点 scale(1.25),原 border origin=(40,600),border=10 | owner content origin=(52.5,612.5),Canvas top=(73.75,641.25),尺寸150×50 |
| Canvas content 120×40,border 每边5;overflow owner CSS width60/height30/border5/padding4 | Canvas region 宽130高50,live 验证同 box;owner clientRect=(5,5,68,38),不能使用78×48 border范围作为clip |
| DPR=1/1.5/2,CSS Canvas120×40;UI zoom与CSS zoom分别处理 | DPR不直接除掉 CSS rect;raster 预测与实际图片分别记录;不以layout/css宽度比作为万能DPR |
| 分别构造缺失 owner、失效 frame、旋转、透视、mask、clip-path、活动pinch、变换的overflow祖先 | 前两项 geometry-unavailable,后六类 geometry-unsupported;无伪造精确 region ref、无按border猜偏移 |
| 同一操作并发请求同一frame 20次,再开启第二次操作 | 第一次每个metrics来源、owner quad、frame投影最多构建/请求一次;第二次重新获取;异常promise不泄漏到其他操作 |
### PR 2:采集与共享事实
| 怎么测 | 什么算成功 |
|---|---|
| target A/B 各含 backendNodeId=7,frame/document不同 | 两个lookup结果独立;AX join、overlay排除和父子查找不串target |
| 4个frame分属2个target,其中一个target有3份document;均成功采集 | 每target恰好一次DOMSnapshot;每frame恰好一次AX批次;失败重试不混入成功请求数;无按frame重复snapshot |
| 采集中模拟frame detach或loader/documentElement更换 | 丢弃受影响document的混合事实并标记原因;其他完整document可保留;不得把新AX接到旧DOM |
| 输入10,000深链、10,000兄弟、孤儿及A→B→A环 | 无栈溢出/无限循环;孤儿/环有明确不完整状态;不生成完整祖先证据;context推导每有效节点至多一次 |
| opacity0祖先;visibility:hidden祖先+visible子;aria-hidden/inert祖先 | 分别得到视觉透明、子可见、语义/交互状态独立;不折叠为同一个excluded |
| 一个frame采集失败,其他frame有语义节点;全部成功但无Canvas | 前者partial且保留其他语义;后者empty而非unavailable;错误原因可追踪到frame及阶段 |
| 注入CDP调用记录和hover探针;纯计算中途取消 | 所有静态DOM/AX捕获先于hover输入;取消后下一工作块不继续处理,未执行的输入/截图为0;取消不变成普通空结果 |
### PR 3:视觉发现
| 怎么测 | 什么算成功 |
|---|---|
| index.html?noFrames=1 的plain、named、aria-hidden、inert、pointer-none、visibility-override、hidden-overridden、fallback | 八个均为候选;无label不影响发现,有AX/fallback不抑制候选 |
| opacity-parent、display-none、visibility-hidden、zero、full-clip | 五个均无候选,原因分别能追踪到视觉/几何规则;不能把aria-hidden列为原因 |
| partial-clip、axis-clip、border-clip、border-canvas | crop尺寸分别60×30、60×40、64×34、130×50;边界来自client box/同种Canvas box |
| 同frame中删除语义parent,再保持DOM/几何不变 | 候选仍存在、地址不变;语义context可变为null;frame owner本身无法解析时不是该用例 |
| 连续两次输入同一只读Facts;对Facts启用深冻结 | 候选内容一致,无输入mutation,无CDP调用;去重前stack-a/b仍是两个候选 |
| 普通候选部分越出viewport;完全越界;overlay-owned Canvas;unsupported clip | 分别交集区域、无候选、排除、明确unsupported状态;不把未知状态伪装成empty |
### PR 4:选择与预算
预算按每个输出行的 `ceil(line.length/4)` 求和(UTF-16 length),包括头部和截断提示。H 为完整协议头成本,V 为固定行 `[visual] Canvas regions present; use screenshot` 的成本。
| 怎么测 | 什么算成功 |
|---|---|
| 同context:无名1000×800候选A,有名10×10候选B,K=1 | 选A;名称不能压倒区域面积 |
| 同document/parent且crop完全相同的A/B;另一个parent的C;crop右边相差0.01的D;相邻tile E | A/B仅输出一个代表;C/D/E独立保留;不输出layers=N作为业务解释 |
| 面积相同的候选随机打乱20次,地址/位置保持不变 | 排序相同;tie-break顺序固定为frameId、top y、left x、backendNodeId(同一观察内);去重代表取该顺序最小者;字符串按代码单元字典序、数值按数值升序 |
| 50,001个不同key,最后一个为最大Canvas,K=1 | 最后最大者仍被选中;dedup key峰值≤50,000;标记dedup-degraded;未因容量耗尽停扫描 |
| 100,000候选、K=1/32/512;另测K=0 | 选中数量≤K、heap峰值≤K、exact-key之外的模糊pair比较为0;K=0不产生ref候选;结果与独立全排序小规模oracle一致 |
| 带blocking layer的纯输入,候选context在L1、背景、未知各一个 | L1优先,背景不标为物理不可见,未知不伪装L1;同层再按面积及固定 tie-break 排序;不修改Facts |
| 预算H-1 | 新observe规划返回insufficient-budget,工具映射invalid_params并给minimum=H;无新文本/ref提交;snapshot旧路径不改变 |
| 有候选,预算H、H+V-1、H+V | 前两个输出合法协议头、无悬空ref、truncated=true;H+V能保留固定视觉提示;全部账本≤预算 |
| 没有显式maxTokens,候选足够多 | 视觉附加部分cost≤2048;最终视觉条目≤512;不是对整个旧语义输出强加2048 |
| 标签含200个Unicode code point、引号、换行、代理对emoji | 标签先截至160 code point再JSON escape,按最终行核算;无断开的代理对,无换行注入额外条目;写不下的行不产生ref |
| finite预算取H+V、64、128、256、2048(小于H时拒绝),混合长短条目 | 每次总账本≤budget;输出决定可重复;已知omitted才给精确数,候选/去重组/上游未知数量不混加 |
### PR 5:类型化 ref
| 怎么测 | 什么算成功 |
|---|---|
| 注册DOM和visual-region两个判别目标 | kind决定操作资格;类型测试不能构造visual+interact矛盾状态;不额外接受调用者capability数组 |
| 同名ref置于两个session/tab;输入@e1与e1 | canonical形式可解析;不同session/tab不串目标;未知目标为既有not_found类错误 |
| 对visual ref调用click/fill/hover/select/get_html/人工帮助DOM定位入口 | 全部按主设计拒绝;CDP输入、DOM HTML读取、DOM高亮等该工具副作用次数为0;不能偷偷降为anchor操作 |
| loader变化、target重attach、documentElement替换;另测history.replaceState不改身份 | 前三者拒绝旧目标;最后不单凭URL变化拒绝;实际anchor仍需连接 |
| document.open保留loader/document backend但替换html和Canvas | 旧ref失效;只比较loader的实现必须被此测试击败 |
| 第二次observe重新绑定e1;第一次ref字符串再次输入 | 明确验证“最新观察映射”而非声称能识别旧字符串;新映射仅来自成功提交;旧DOM工具回归通过 |
### PR 6:截图执行
首次 clipScale 为 `min(1, 2048/(max(w,h)*r), sqrt(4000000/(w*h*r*r)))`;r 来自实际 metrics 适配,最终仍检查真实 PNG 尺寸。
| 怎么测 | 什么算成功 |
|---|---|
| visual ref解析成功并截图;DOM ref另测 | visual路径scrollIntoView次数0;DOM路径保持BASE策略;visual实时box种类与观察一致 |
| 同anchor四边分别移动0.249、0.25、0.251 CSS px;分别改尺寸/裁剪策略 | 前两种仅数值误差可接受,0.251拒绝;document/裁剪策略变化不受容差豁免;不靠old∩live返回小片段 |
| 保持身份/几何,重绘Canvas颜色;另移除Canvas | 重绘允许取得当前图像;移除拒绝;不承诺像素快照或把颜色改变误报为stale |
| css4096×2048,rasterEstimate2;css4000×4000,rasterEstimate1 | 首次clipScale分别0.25、0.5(不放大,取edge/area/1最小值);实际成功尺寸每边≤2048、面积≤4,000,000 |
| mock第一次图片3000×3000,第二次2000×2000;再测两次均3000×3000 | 第一组重试一次并成功,第二组恰好两次后pixel-budget-exceeded;不能无限重试或返回超限图 |
| 空data、非法PNG头、0尺寸、负/非有限几何 | 明确失败,不能猜测width/height;此项不要求将PNG头检查宣称为完整解码校验 |
| capture前取消;overlay抑制后CDP失败;采集时出现dialog | 取消前不再capture;成功/失败/取消均恢复overlay;dialog机制保持既有契约;无额外输入 |
### PR 7:observe 接线
| 怎么测 | 什么算成功 |
|---|---|
| 固定SemanticScene,无视觉候选;普通预算,与BASE replay同一输入 | 语义文本、ref相对顺序、role/name一致;不因为新通道重跑semantic pipeline |
| 同SemanticScene加两个视觉候选,大预算 | 原DOM ref顺序不变,视觉ref在其后;每个输出ref均可在registry找到同目标,registry无未输出新ref |
| DOM文本多到耗尽除预留外的预算 | 固定视觉提示可见(预算允许时);truncated=true;不是DOM stopped后无条件跳过视觉提示 |
| 视觉capture unavailable但语义成功 | 语义结果保留,观察状态能区分empty/unavailable/truncated;debug reason准确且无原始DOM泄漏 |
| snapshot、recording调用、hover probe on/off与BASE对比 | snapshot不新增visual;recording不隐式启用发现;probe默认off,on时仍在静态采集后;相应既有测试通过 |
| blocking layer混合DOM/visual、重复候选、不同预算 | 输出层策略与选择模块的活跃层策略一致;头、摘要、条目、occlusion/omitted行均纳入账本;无悬空ref |
### PR 8:整体验收
复跑上述模块测试、下方性能对照和浏览器场景,确认原始 iWiki 的缺口已解决。检查没有站点特判、重复的几何/预算权威实现或遗留的替代路径;没有顺带扩展到 OCR、逻辑画布聚合或旧算法重写。不能只凭合成页面通过宣布原始问题解决。
## 性能怎么验
在相关模块实现时检查,不拖到最后统一补。旧路径和新增 Canvas 成本分开看:
- **旧路径**:同输入对照 main/前置基线,已有 render/context/sibling 工作量、语义构建次数和 DOM 操作请求不增加;保留 #165 的回归。不要求消除旧算法的历史二次项。
- **无 Canvas**:允许必要检测与共享几何成本,但不构建视觉专用索引、不发视觉专用 CDP 请求、不重复跑语义链路。
- **新增树处理**:deep/wide 节点数取 1k、10k、100k;每节点 context 至多推导一次,每条边每个索引 pass 至多访问一次,pass 数不随规模增加。无递归溢出、每候选完整祖先遍历或全祖先数组复制。
- **选择**:候选数取 1k、10k、100k,K 取 1、32、512;堆峰值不超过 K,去重 key 不超过 50,000,无模糊两两比较。比较计数符合具体算法推导的上界;小规模结果与独立全排序对照一致,容量耗尽后仍能选中最后的大图。
- **请求与取消**:每 target 一次 snapshot、每 frame 一次 AX 批次;单次操作内几何请求共享,不跨操作复用 live cache。几何最大并发 4,新增计算每块最多 256 项并实际让出事件循环,取消后不再启动下一块。
- **接线后耗时**:同机器、浏览器和固定内容,分别测无 Canvas 页与合成表格页。各预热 3 次,再基线/新版本交替各 20 次,比较中位数和 p95;旧阶段与新增成本分开。旧阶段中位数增加超过 max(基线 10%, 2ms) 时复测,两轮均超过且可归因于改动则未通过;噪声无法排除则说明尚未确认。操作数仍须满足前述条件,不能用耗时阈值代替它们。
计数可以由测试插桩取得,不必为验收增加生产接口。性能结果只需说明旧路径是否回退、新增路径的规模与成本,不需要另填表格。
## 浏览器怎么验
### 合成表格(PR 0 复现;PR 7/8 验证修复)
1. 核实 `bsk --version`、`bsk browsers`、`bsk status`;运行 `pnpm ext:build`,确认用户已 reload `apps/extension/dist/chrome-mv3`。
2. 单独启动服务器:`python3 -m http.server 4177 --bind 127.0.0.1 --directory apps/extension/test-fixtures/visual-observation`。
3. `bsk session start --width 1440 --height 1100`,使用返回的 session id 打开下列页面。尖括号替换为实际值,每条命令传同一 session。
```sh
bsk navigate http://127.0.0.1:4177/table.html --session <session> --json
bsk observe --session <session> --json
bsk screenshot --session <session> --out <临时目录>/table.png --json
```
4. 把窗口放前台,实际看到三列六行后再采集 observe。load 完成、AX 工具栏出现或背景占位截图都不能证明表格已经绘制。
5. main 应有 Page action、Insert record、Filter 三个 DOM 控件,truncated=false,但无表格视觉入口。这是 PR 0 的缺口复现。
6. 新实现应保留三个控件,并增加 visual ref。用本次返回的 ref 执行 `bsk screenshot --ref <ref> --session <session> --out <临时目录>/crop.png`,实际查看图片:包含 Canvas column A/B/C 和六行,裁剪对应 780×360 CSS px 的表格区域,不能错到工具栏或 iframe 边框。PNG 满足每边 2048、总像素 4,000,000 的上限。无需生成单元格语义。
### 几何与动态场景
PR 1 验证实际生产几何模块;PR 6 验证截图执行;PR 8 复验完整链路。未接 observe 时可注入人工 facts/ref,但必须调用实际模块,不能以独立 probe 代替实现测试。
- 必测缩放集合:DPR 1/1.5/2 × top/frame;真实 UI zoom 100%/90% × top/frame;CSS zoom 1.25 的 top Canvas;frame scale 1.25 的 same/nested/OOPIF。按 PR 1 的坐标预期核对。OOPIF 需确认实际 iframe target,跨 origin 本身不够。
- UI zoom 要通过浏览器菜单或原生快捷键确认,不能以 CSS zoom 或 pinch emulation 替代。旋转、mask、clip-path、活动 pinch、变换 overflow 按不支持策略降级,不输出猜测的精确 ref。
- 使用 `index.html?noFrames=1` 的 `#plain`,每个实验从 reload 后的新 ref 开始:平移 1px、宽度改为 121px → 旧 ref 截图要求重新观察;移除节点或 document.open → 旧目标失效;仅重绘颜色 → 截图成功且是新颜色。不要在重新 observe 后拿重用的 eN 字符串声称验证了旧 ref。
- visual ref 的 click/fill/hover/select/get_html/DOM 高亮拒绝测试只在合成页或自动测试运行,确认拒绝前没有派发输入、读 HTML 或高亮副作用。
### 原始 iWiki(PR 0 与 PR 8)
入口与就绪说明在本机临时文件 `/private/tmp/bsk-visual-design/private-reproduction.md`。文件丢失或登录失效时向用户取得入口/权限,说明这一项未完成;继续独立的合成验证。
保持只读。前台实际看到表格后采集 observe 和截图:PR 0 确认缺口;PR 8 确认外围 DOM 控件仍在、新 visual ref 能截到实际表格。不要编辑单元格,不要求原页面永久维持历史的 63 个 ref。私有内容不入库。
结束后停止自己的 session、服务器及临时 Chrome,恢复修改过的 zoom/emulation,不关闭其他任务资源。
## 做完怎么回复
直接告诉用户:验了哪个 PR、跑了什么、是否符合上述成功条件;若有失败或未做的检查,说明具体原因。必要时附关键测试输出或截图路径。无需单独报告文件、hash 清单、状态统计或全量日志归档;没运行、被跳过或受阻的项目不要写成通过。
+235
View File
@@ -0,0 +1,235 @@
# Visual observation:PR 0 验证结果与架构决策
日期:2026-09-08。生产基线:main `3c5f838`。分支:`codex/visual-observation-design`。
本次只新增设计、合成 fixture 和诊断工具。没有生产算法变更,没有 commit/push。
总体设计及后续 PR 依赖见 [架构与实施计划](visual-observation.md)。
本文是历史证据;新会话请按 [验收清单](visual-observation-acceptance.md) 执行相关检查并说明结果,不继承本文的通过结论。
## 1. 环境与证据等级
| 环境 | 实际版本/说明 |
|---|---|
| PATH 中的 bsk | 0.1.7,未用于有效页面基线 |
| 仓库已有 target/debug/bsk | 0.2.0;`update --check` 报告发布最新版也是 0.2.0,没有安装更新 |
| daemon / connected extension | 0.2.0,protocol 1.1 |
| 页面基线 Chrome | 151.0.0.0,由 bsk browsers 报告 |
| 生产扩展构建 | `pnpm ext:build`,用户 reload 当前 main 基线构建后采集 |
| 独立协议实验 Chrome | 152.0.7977.76,临时无登录 profile;与用户浏览器不是同一个运行实例 |
| 基线测试 | VOM 47 tests / 2 files;相关扩展 217 tests / 14 files;全部通过 |
页面结果是实际 bsk + reload 扩展的结果。协议实验是独立 Chrome 的真实 CDP 响应。
临时 replay 是将合成响应送入 main adapter 的诊断,不代表另一条真实浏览器完整运行。
不同证据不混用,版本号本身不证明构建 commit。
所有内部页面原文、地址、iframe 标识、截图和用户信息仅保存在本机临时目录,不进入仓库。
本文仅保存匿名化事实,仓库 fixture 完全独立构造。
## 2. 原始问题已确认:表格加载完成后仍有观察缺口
有效就绪条件:前台可见页面已经绘制出网格、列标题和六行表格。不能使用以下替代条件:
- `document.readyState === complete`。
- AX 已有嵌入应用的工具栏。
- 页面出现 iframe。
- 背景截图仍为站点加载 logo。
本次初始后台窗口恰好出现“AX 已有工具栏,图像仍为加载占位”的情况。这些样本已排除。
在前台绘制完成后重新取得匹配的截图和 observe,结果为:
| 事实 | 结果 |
|---|---|
| 实际截图 | 可见列标题、网格与六行表格 |
| main observe | 63 个 ref,含外围和嵌入应用工具栏 |
| truncated | false |
| 表格列内容 | 没有相应文本表示 |
| visual ref | 没有 |
| 直接打开已观察到的嵌入应用 | 发现一个无 aria-label 的 Canvas,CSS 尺寸 1450×835、backing store 2900×1670,DPR 2 |
直接打开嵌入应用用于验证其渲染介质;其宽度变化是独立 tab 布局,不能拿这组坐标当父页面中的 crop。
父页面的截图/VOM 对照与直接嵌入页的 DOM 证据分别记录。
结论:主要能力缺口是视觉内容没有观察入口;不属于这次有效样本的文本预算耗尽。
新架构不能承诺从 Canvas 自动恢复单元格语义;正确结果是保留工具栏,并提供可截图的区域入口。
## 3. 合成最小复现
`table.html` → 跨站点 `grid.html`:一个 iframe、DOM 工具栏、一个无名 Canvas。
Canvas 绘制三列、六行,所有文字与原页面无关。
main 实际输出的关键部分:
```text
@view 1440x923
@e1 button "Page action"
Iframe "Table application"
navigation
@e2 button "Insert record"
@e3 button "Filter"
```
`ref_count=3`,`truncated=false`。截图已人工确认包含 `Canvas column A/B/C` 和六行。
这些像素文字既没有文本表示,也没有 visual ref。该最小复现已覆盖原场景的关键结构,
无需站点权限或生产选择器。
## 4. CSS 与语义/交互分离
真实浏览器实验确认:
- aria-hidden 祖先、inert 祖先、pointer-events:none 不会使 Canvas 自动视觉消失。
- `visibility:hidden` 祖先下的 `visibility:visible` 子 Canvas 可以恢复可见。
- `hidden` 元素被 `display:block` 覆盖时仍可绘制。
- opacity:0 祖先会使子树透明;不能仅检查 Canvas 自己的 opacity。
- display:none 无 layout bounds;visibility:hidden 可以仍有 bounds。
- AX fallback 文本不包含绘制的 `PIXEL TABLE 42`,不能用 fallback 存在作为发现去重条件。
决定:事实层分别保存 computed visibility、透明继承、语义暴露和交互属性。
视觉发现不读取 semantic keep/drop 结果;祖先索引只能传播符合属性语义的状态。
`checkVisibility()` 的实验作用仅限 CSS suppression,不用它证明 overflow、viewport 或遮挡结果。
常规 overflow 由独立几何规则负责;任意遮挡不进入首版精确能力范围。
## 5. 单位、frame 投影与截图
### 5.1 DPR 与截图像素是不同契约
独立 Chrome 上对相同 120×40 CSS clip 的结果:
| Emulated DPR | DOMSnapshot Canvas w×h | Screenshot w×h | layoutViewport/cssLayoutViewport 宽度比 |
|---|---|---|---|
| 1 | 120×40 | 120×40 | 1 |
| 1.5 | 120×40 | 180×60 | 1 |
| 2 | 120×40 | 240×80 | 1 |
因此该比值不能用作通用截图 DPR。原始 DOMSnapshot 单位与截图 raster scale 必须分别适配,
输出 PNG 尺寸是最终预算事实。不能把本组 DPR emulation 结果泛化为所有浏览器缩放组合。
### 5.2 三种缩放分别记账
- CSS `zoom:1.25`:120px 宽 Canvas 的布局 bounds 为 150px,运行时几何同步变化。
- Emulation.setPageScaleFactor(1.5):记录为 page-scale/pinch 类证据,不充当 UI zoom。
- 用户 Chrome 原生 UI 缩放到 90%:界面明确显示 90%,DPR 从 2 变为约 1.8,innerWidth 从 1440 变为 1600,visualViewport.scale 仍为 1;Canvas CSS 宽约 120.0000076,截图仍是 2880px 宽。main VOM 视口为 1600×1025。已恢复 100%。
现有 bsk 的 Meta+- CDP 输入未改变浏览器缩放,所以该无效果尝试不算缩放验证;
有效样本来自原生浏览器 UI 操作。真实 UI zoom 仅验证了上述点,不能宣称完整 zoom 矩阵已通过。
决定:使用 CSS 视口空间作为发现/截图区域公共契约;精确单位 adapter 的实现还必须在 PR 1
跑计划内组合回归,不用一个比例同时解决所有协议字段。
### 5.3 Frame 空间与 main 已知偏移
fixture local Canvas 原点为 (17,23)。
| 场景 | 实测 owner content 原点(target root) | 正确 top Canvas 原点 | main parser replay |
|---|---|---|---|
| 同 target frame,10px border | (50,610) | (67,633) | (57,623) |
| 上述 frame 内 nested,另有 7px border | (87,707) | (104,730) | (87,713) |
| OOPIF,10px border | (410,610) | (427,633) | 本表不混入非完整 replay 的 OOPIF 结果 |
根 target 的 DOMSnapshot 含三份 document(top/same/nested);OOPIF 在单独 target 中只返回自己的 document。
probe 通过实际 `TargetInfo.type=iframe` 验证 OOPIF,而非仅根据 origin 推断进程隔离。
子 frame scrollY=10 后,runtime local y 从 23 变为 13,snapshot document y 仍为 23。
owner scale(1.25) 后 content quad 宽度变为 375,child innerWidth 仍为 300。
决定:frame-local 原始矩形必须先减该 document 的滚动,再经过一次权威投影;同 target 的
owner quad 已是 target-root 坐标,不能重复逐层加偏移。禁止 border-box 替代 content origin。
Box 种类也必须进入契约:带 5px border 的 120px Canvas,其 snapshot/border quad 宽 130,
content quad 宽 120。视觉入口采用 border-box,实时验证同种 box;iframe 投影则采用 content quad。
带 border/padding 的 overflow owner,snapshot.clientRects 对应 `[clientLeft,clientTop,clientWidth,clientHeight]`,
本 fixture 为 `[5,5,68,38]`。裁剪边界应从 client box 派生,不能直接拿 ancestor border bounds。
单轴 `overflow-x:clip;overflow-y:visible` 的 computed style 保持分轴,必须分轴传播。
CSS zoom 1.25 后,上述 owner 的 bounds 变为 `[745,455.625,97,59.5]`,clientRects 仍为
`[5,5,68,38]`,offsetRects 为 `[596,365,78,48]`。直接按 border-width 比例推导 client box
会混入不同空间与整数取整。首版将有 CSS zoom/DOM transform 的 overflow 祖先组合明确降级,
不承诺精确 crop;普通未变换 overflow 及 iframe content-quad 投影保留支持。
## 6. Document 与 ref 时效
真实 CDP 实验:
| 操作 | frameId | loaderId | document backend | anchor |
|---|---|---|---|---|
| history.replaceState | 不变 | 不变 | 不变 | 仍属于当前树 |
| 跨 document 导航 | 不变 | 变化 | 变化 | 旧目标不可继续视为当前内容 |
| document.open/write/close | 不变 | 不变 | 本次实验不变 | 旧 Canvas 失去连接,documentElement 被替换 |
决定:身份取 target attachment epoch + frameId + loaderId + documentElement backend identity;
工具执行时仍检查 anchor.isConnected 及其所属 documentElement。不能仅凭 loaderId 判断有效。
DOMSnapshot 可提供根元素 backend identity,frame graph 需要保留 loaderId;当前 main graph
没有该字段,这是后续数据契约的明确修改点。
12 次静态几何采样完全相同;0.5px 移动可被检测;真实 90% 缩放引入约 1e-5 CSS px 的
数值误差。首版采用最大边误差 0.25 CSS px 作为保守工程阈值,超出要求重新 observe。
这不是大规模统计得到的通用稳定性保证;改变阈值需更新其测试及行为文档。
既有 eN 被下一次观察重用后,单靠旧字符串不能检测它来自上一代。内部 generation 不消除
该协议限制。本次不改变协议,只验证当前存储目标的身份/几何。Canvas 可以在身份与几何
不变时重绘;截图始终表达执行时图像,不承诺观察时像素快照。
## 7. 既有路径回归基线:用于防止负优化
诊断复制当前 renderer 到临时目录,在三个明确循环点加计数;生产文件未改变。
复现命令见 [fixture README](../../apps/extension/test-fixtures/visual-observation/README.md)。
| 输入 | Render stack visits | DOM context candidates | Same-container sibling visits |
|---|---:|---:|---:|
| 100 headings + 100 View buttons | 201 | 10,000 | 15,050 |
| 200 headings + 200 View buttons | 401 | 40,000 | 60,100 |
| 400 headings + 400 View buttons | 801 | 160,000 | 240,200 |
| 2,000 transparent wrappers + control | 2,001 | 0 | 0 |
| 4,000 transparent wrappers + control | 4,001 | 0 | 0 |
显式栈访问线性并通过深树输入;本组宽树上下文计数呈二次增长。这里记录的是现有路径的
特征,供后续同输入比较是否发生回退,不是本次要求修复的问题,也不是 Canvas 新算法的模板。
计数不是完整 CPU profile,更不能替代 Canvas 发现、裁剪、选择和截图各自的性能测试。
处理边界:
1. 既有语义、DOM 操作和截图路径对照 main 不发生性能回退;不把历史算法优化列为本次前置依赖。
2. Canvas 新路径按自己的规模和访问模式论证算法;发现、裁剪、去重、选择和截图分别验收。
3. 精确 crop key 去重的选择来自首版的覆盖等价契约和新增成本分析,与 #165 采用什么算法无关。
4. PR 7/8 分别报告既有路径回归、无 Canvas 页面的额外成本、有 Canvas 页面的新增成本。
新功能有合理的额外成本不等于旧算法负优化;重复执行旧工作则属于需要消除的回退。
## 8. 锁定首版策略与退化
| 决策 | 首版契约 | 理由/验收 |
|---|---|---|
| 去重 | 同 document + 同直接 parent + 四条 crop 边完全相等 | 无模糊聚类;误差导致重复比误合并更可控 |
| 索引容量 | 50,000 keys;耗尽后其余候选继续未去重选择 | 内存有界,不因早期小图耗尽而停止后续发现 |
| K | min(预算推导容量, 512) | 所有行仍逐条核算可变成本 |
| 默认视觉预算 | 未给总 maxTokens 时 2048 estimated tokens | 有显式 maxTokens 时全部纳入同一预算 |
| 摘要预留 | 固定提示行按实际估算器成本预留 | 不用不可验证的固定 token 常量表示某段文本 |
| 几何时效 | 最大边差 > 0.25 CSS px 即重新观察 | 身份和裁剪策略变化独立拒绝 |
| 图像 | max edge 2048、max pixels 4,000,000、最多两次 capture | 成功必须以实际尺寸满足后置条件 |
| 外部几何并发 | 4 | 单操作缓存;操作结束释放 |
| 新增纯计算取消 | 每 256 项形成工作块并让出事件循环 | 不仅是轮询无法及时更新的 signal |
这些数值是可审查的首版资源政策,不是声称已证明用户体验最优的测量结果。
参数可以在对应模块 PR 中经新的证据调整,但必须一并更新契约及测试。
进一步收敛:首版精确区域支持轴向矩形、普通 overflow 链、正向轴对齐平移/缩放与 frame。
旋转、透视、clip-path、mask、活动 pinch-zoom 进入明确的 geometry-unsupported 降级,
不给看似精确的 region ref,提示现有视口截图途径。不会把包围矩形偷换成精确区域。
## 9. 已执行的检查与下一阶段门槛
已执行:
- main VOM tests:47 passed。
- main 相关 extension tests:217 passed,含 frame/element geometry、observation、interaction、ref-store、VOM。
- `pnpm ext:build` 成功,用户 reload 后采集有效页面基线。
- 独立 Chrome browser contract probe:39 个断言通过;可复现命令见 fixture README。
- main 合成 capture replay、显式栈/上下文操作计数。
- 实际 Chrome UI 90% zoom 样本,恢复 100%。
- 原始表格与合成嵌入式表格均人工查看截图,并与未截断 observe 对照。
PR 0 交付的是已明确边界的设计和基线,不是未来功能已经通过:新的 discovery、typed ref、
freshness resolver、pixel planner 和 renderer 接线尚未实现。后续每个 PR 必须验证自己的
生产实现,不能把本次浏览器事实验证替代实现验收。
人工证据不设置默认 CI 对内网站点的访问依赖。合成 probe 可按需执行;页面原文不进仓库。
后续 PR 的职责和验收保持在主设计中,本文负责记录证据、决策及没有作出的保证。

Some files were not shown because too many files have changed in this diff Show More