mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2438b35fc1 | ||
|
|
5fc59b6053 | ||
|
|
5fa6b49e91 | ||
|
|
b2d2aed754 | ||
|
|
ad1e44a72b | ||
|
|
1a86b0aa0e | ||
|
|
27dba3c57f |
@@ -38,7 +38,7 @@ jobs:
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
windows-daemon:
|
||||
name: Windows daemon, installer and cancellation regression tests
|
||||
name: Windows daemon and update regression tests
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
@@ -55,16 +55,8 @@ jobs:
|
||||
- name: Run Windows update tests
|
||||
run: cargo test -p bsk --lib --locked cli::update::tests
|
||||
|
||||
- name: Run Windows update and cancellation process tests
|
||||
run: cargo test -p bsk --test windows_update --test windows_parent_cancel --locked
|
||||
|
||||
- name: Run installer tests (Windows PowerShell 5.1)
|
||||
shell: powershell
|
||||
run: ./scripts/install-windows.test.ps1
|
||||
|
||||
- name: Run installer tests (PowerShell 7)
|
||||
shell: pwsh
|
||||
run: ./scripts/install-windows.test.ps1
|
||||
- name: Run Windows update process tests
|
||||
run: cargo test -p bsk --test windows_update --locked
|
||||
|
||||
frontend:
|
||||
name: Frontend lint, typecheck, tests, build
|
||||
|
||||
+1
-18
@@ -5,24 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number.
|
||||
|
||||
## [0.2.1] - 2026-09-09
|
||||
|
||||
### Changed
|
||||
- DSH Plugin: clarified installation, configuration, profile restart, and upgrade instructions
|
||||
|
||||
### Fixed
|
||||
- CLI: reliable Windows self-updates, including executable replacement and daemon restart
|
||||
- CLI: Windows named-pipe connection continuity and stale daemon process detection
|
||||
- Windows installer: PowerShell 5.1 compatibility, path quoting, and staged executable replacement
|
||||
- CLI / DSH Plugin: cancellation propagation and upload/download cleanup when the parent process exits
|
||||
- Extension: tab ownership and session cleanup when agent tabs fail to open or borrowed tabs are returned or closed
|
||||
- Extension: navigation recovery when other extensions restrict Chrome DevTools Protocol access
|
||||
- Extension: background input filling, automatic append behavior, and verification of field values before reporting success
|
||||
- Extension: match observed form state to the correct element after page changes
|
||||
- Extension: UUID generation fallback for recording and overlays on insecure origins
|
||||
- Browser locale matching for regional language variants
|
||||
- DSH Plugin: observation state synchronization, thumbnail capture lifecycle, and stale preview cleanup
|
||||
- DSH Plugin: removed the obsolete client runtime host dependency
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.0] - 2026-09-02
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -134,7 +134,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bsk"
|
||||
version = "0.2.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -169,7 +169,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bsk-protocol"
|
||||
version = "0.2.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"schemars",
|
||||
"semver",
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ members = [
|
||||
default-members = ["crates/bsk-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.1"
|
||||
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.2.1", path = "crates/bsk-protocol" }
|
||||
bsk-protocol = { version = "0.2.0", path = "crates/bsk-protocol" }
|
||||
|
||||
[profile.release]
|
||||
strip = "symbols"
|
||||
|
||||
@@ -147,26 +147,20 @@ Start a new Agent session and write a prompt that needs the browser, for example
|
||||
Using [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`)?
|
||||
BrowserSkill ships a first-class dsh plugin on npm as
|
||||
[`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin).
|
||||
It gives the agent native `browser_*` tools and a live view of its browser sessions
|
||||
in the Web UI. The plugin runs `bsk` on the agent's behalf.
|
||||
It injects native `browser_*` tools (no shelling out to `bsk`) and a live Web UI
|
||||
overlay of each Agent Window.
|
||||
|
||||
Install the `bsk` CLI and connect the browser extension first. Then add the plugin
|
||||
to a dsh profile and start it (replace `web` with your profile name):
|
||||
Add it to a dsh profile, then start that profile:
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin
|
||||
dsh --profile web
|
||||
```
|
||||
|
||||
The plugin includes the `browser-skill` skill, so `bsk install-skill` is not needed
|
||||
for dsh. Installed plugins do not update automatically. To upgrade this plugin:
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web update @wxg-prc-cpg/browser-skill-dsh-plugin --latest
|
||||
```
|
||||
|
||||
Restart the profile after upgrading. See the
|
||||
[plugin README](packages/dsh-plugin-browserskill/README.md) for usage and configuration.
|
||||
The plugin carries its own copy of the skill, so `bsk install-skill` is not needed
|
||||
for dsh — but the `bsk` CLI and the browser extension are still prerequisites. See
|
||||
the [plugin README](packages/dsh-plugin-browserskill/README.md) for the tool list,
|
||||
configuration, and the observation overlay.
|
||||
|
||||
## How It Works
|
||||
|
||||
|
||||
+3
-9
@@ -125,22 +125,16 @@ bsk install-skill
|
||||
|
||||
## DeepSeek Harness 插件
|
||||
|
||||
在用 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)?BrowserSkill 提供了官方 dsh 插件,已发布到 npm:[`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin)。它为 Agent 提供原生 `browser_*` 工具,由插件代为调用 `bsk`,并在 Web UI 中实时展示浏览器会话。
|
||||
在用 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)?BrowserSkill 提供了官方 dsh 插件,已发布到 npm:[`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin)。它会注入原生 `browser_*` 工具(无需再通过 Shell 调用 `bsk`),并在 Web UI 中实时观察每个 Agent Window。
|
||||
|
||||
先安装 `bsk` CLI 并连接浏览器扩展,再将插件装进 dsh profile 并启动(将 `web` 替换为你的 profile 名称):
|
||||
把它装进某个 dsh profile,然后启动该 profile:
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin
|
||||
dsh --profile web
|
||||
```
|
||||
|
||||
插件自带 `browser-skill` skill,所以在 dsh 下无需执行 `bsk install-skill`。已安装的插件不会自动更新;升级此插件请运行:
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web update @wxg-prc-cpg/browser-skill-dsh-plugin --latest
|
||||
```
|
||||
|
||||
升级后重启该 profile。用法与配置见[插件 README](packages/dsh-plugin-browserskill/README.md)。
|
||||
插件自带 skill,所以在 dsh 下无需执行 `bsk install-skill`;但 `bsk` CLI 和浏览器扩展仍是前置条件。工具清单、配置项与观察浮层见[插件 README](packages/dsh-plugin-browserskill/README.md)。
|
||||
|
||||
## 工作原理
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@browser-skill/extension",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -29,24 +29,6 @@ function fakeApi() {
|
||||
}
|
||||
|
||||
describe("ChromiumCdp", () => {
|
||||
it("changes attachment identity only across real debugger attachments", async () => {
|
||||
const { api, onEvent, onDetach } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
expect(cdp.getAttachmentId(4)).toBeUndefined();
|
||||
await cdp.ensureAttached(4);
|
||||
const first = cdp.getAttachmentId(4);
|
||||
expect(first).toBeTruthy();
|
||||
await cdp.ensureAttached(4);
|
||||
onEvent.fire({ tabId: 4 }, "Target.attachedToTarget", { sessionId: "child" });
|
||||
expect(cdp.getAttachmentId(4)).toBe(first);
|
||||
onDetach.fire({ tabId: 4 }, "target_closed");
|
||||
expect(cdp.getAttachmentId(4)).toBeUndefined();
|
||||
await cdp.ensureAttached(4);
|
||||
expect(cdp.getAttachmentId(4)).not.toBe(first);
|
||||
await cdp.detach(4);
|
||||
expect(cdp.getAttachmentId(4)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("discovers multiple iframe targets and recursively routes nested OOPIF commands", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
|
||||
@@ -164,7 +164,6 @@ async function settleBeforeDeadline(promises: Promise<void>[], deadline: number)
|
||||
export class ChromiumCdp {
|
||||
private readonly api: CdpDebuggerApi;
|
||||
private readonly attachedTabs = new Set<number>();
|
||||
private readonly attachmentIds = new Map<number, string>();
|
||||
private readonly attachInFlight = new Map<number, Promise<void>>();
|
||||
private readonly detachInFlight = new Map<number, Promise<void>>();
|
||||
private readonly tabOwners = new Map<number, Set<string>>();
|
||||
@@ -199,11 +198,6 @@ export class ChromiumCdp {
|
||||
this.bindFrameTargetHandler();
|
||||
}
|
||||
|
||||
/** Identity of this actual debugger attachment, independent of frame topology. */
|
||||
getAttachmentId(tabId: number): string | undefined {
|
||||
return this.attachmentIds.get(tabId);
|
||||
}
|
||||
|
||||
/** Attach to `tabId` if we haven't already in this driver. */
|
||||
async ensureAttached(tabId: number): Promise<void> {
|
||||
// Returning a tab clears the cache before Chrome finishes detaching.
|
||||
@@ -223,7 +217,6 @@ export class ChromiumCdp {
|
||||
await this.enableConsoleDomains(tabId);
|
||||
await this.enableNetworkDomainBestEffort(tabId);
|
||||
this.attachedTabs.add(tabId);
|
||||
this.attachmentIds.set(tabId, crypto.randomUUID());
|
||||
await this.enableFrameDiscovery({ tabId }).catch((err) => {
|
||||
console.debug("[bsk cdp] frame discovery unavailable", { tabId, err });
|
||||
});
|
||||
@@ -451,7 +444,6 @@ export class ChromiumCdp {
|
||||
this.attachInFlight.delete(tabId);
|
||||
if (!this.attachedTabs.has(tabId)) return;
|
||||
this.attachedTabs.delete(tabId);
|
||||
this.attachmentIds.delete(tabId);
|
||||
this.clearDialogState(tabId);
|
||||
this.clearConsoleState(tabId);
|
||||
this.clearNetworkState(tabId);
|
||||
@@ -510,7 +502,6 @@ export class ChromiumCdp {
|
||||
this.attachInFlight.clear();
|
||||
this.tabOwners.clear();
|
||||
this.attachedTabs.clear();
|
||||
this.attachmentIds.clear();
|
||||
this.dialogBuffers.clear();
|
||||
this.dialogSequences.clear();
|
||||
this.consoleBuffers.clear();
|
||||
@@ -857,7 +848,6 @@ export class ChromiumCdp {
|
||||
const listener = (source: chrome.debugger.Debuggee, _reason: string) => {
|
||||
if (typeof source.tabId === "number") {
|
||||
this.attachedTabs.delete(source.tabId);
|
||||
this.attachmentIds.delete(source.tabId);
|
||||
this.attachInFlight.delete(source.tabId);
|
||||
this.tabOwners.delete(source.tabId);
|
||||
this.clearDialogState(source.tabId);
|
||||
|
||||
@@ -436,8 +436,6 @@ describe("file transfer tools", () => {
|
||||
if (method === "DOM.getContentQuads") {
|
||||
return { quads: [[10, 20, 210, 20, 210, 120, 10, 120]] };
|
||||
}
|
||||
if (method === "Runtime.evaluate")
|
||||
return { result: { value: { width: 400, height: 300 } } };
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 400, clientHeight: 300 } };
|
||||
}
|
||||
|
||||
@@ -13,138 +13,7 @@ import {
|
||||
import { GeometryContext } from "../geometry/frame-context";
|
||||
import type { CdpRunner } from "../shared";
|
||||
|
||||
function scrollbarDriver(
|
||||
visible = { width: 185, height: 89 },
|
||||
frameSize: unknown = { width: 200, height: 100 },
|
||||
) {
|
||||
const send = vi.fn(async (target: { sessionId?: string }, method: string) => {
|
||||
if (method === "Runtime.evaluate") return { result: { value: frameSize } };
|
||||
if (method === "Page.getLayoutMetrics")
|
||||
return {
|
||||
cssLayoutViewport: {
|
||||
clientWidth: target.sessionId ? visible.width : 800,
|
||||
clientHeight: target.sessionId ? visible.height : 600,
|
||||
},
|
||||
};
|
||||
if (method === "DOM.getBoxModel")
|
||||
return {
|
||||
model: {
|
||||
content: target.sessionId
|
||||
? [0, 0, 200, 0, 200, 100, 0, 100]
|
||||
: [100, 100, 500, 100, 500, 300, 100, 300],
|
||||
},
|
||||
};
|
||||
if (method === "DOM.getContentQuads")
|
||||
return { quads: [[180, 80, 230, 80, 230, 120, 180, 120]] };
|
||||
throw new Error(`unexpected ${method}`);
|
||||
});
|
||||
const cdp: CdpRunner = {
|
||||
send: (tabId, method) => send({ tabId } as never, method) as never,
|
||||
sendToTarget: send as CdpRunner["sendToTarget"],
|
||||
getFrameGraph: async () => ({
|
||||
rootFrameId: "main",
|
||||
frames: [
|
||||
{ frameId: "main", target: { tabId: 4 } },
|
||||
{
|
||||
frameId: "child",
|
||||
parentFrameId: "main",
|
||||
ownerBackendNodeId: 10,
|
||||
target: { tabId: 4, sessionId: "child" },
|
||||
},
|
||||
{
|
||||
frameId: "same-child",
|
||||
parentFrameId: "child",
|
||||
ownerBackendNodeId: 20,
|
||||
target: { tabId: 4, sessionId: "child" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
return { cdp, send };
|
||||
}
|
||||
|
||||
describe("frame geometry projection", () => {
|
||||
it.each([
|
||||
{ width: 200, height: 100 },
|
||||
{ width: 185, height: 100 },
|
||||
{ width: 200, height: 89 },
|
||||
{ width: 185, height: 89 },
|
||||
])("preserves scale and clips scrollbar strips for visible viewport $width × $height", async (visible) => {
|
||||
const { cdp, send } = scrollbarDriver(visible);
|
||||
const geometry = await resolveNodeGeometry(cdp, 4, {
|
||||
target: { tabId: 4, sessionId: "child" },
|
||||
frameId: "child",
|
||||
backendNodeId: 101,
|
||||
});
|
||||
expect(geometry).toMatchObject({
|
||||
topBounds: {
|
||||
x: 460,
|
||||
y: 260,
|
||||
width: (visible.width - 180) * 2,
|
||||
height: (visible.height - 80) * 2,
|
||||
},
|
||||
actionPoint: { x: 460 + visible.width - 180, y: 260 + visible.height - 80 },
|
||||
targetActionPoint: { x: (180 + visible.width) / 2, y: (80 + visible.height) / 2 },
|
||||
});
|
||||
expect(send.mock.calls.filter(([, method]) => method === "Runtime.evaluate")).toHaveLength(1);
|
||||
expect(send.mock.calls.filter(([, method]) => method === "Page.getLayoutMetrics")).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("shares the full viewport read across same-target frames, and refreshes it next operation", async () => {
|
||||
const { cdp, send } = scrollbarDriver();
|
||||
const context = new GeometryContext(cdp, 4);
|
||||
await Promise.all([context.targetProjection("child"), context.targetProjection("same-child")]);
|
||||
expect(send.mock.calls.filter(([, method]) => method === "Runtime.evaluate")).toHaveLength(1);
|
||||
await new GeometryContext(cdp, 4).targetProjection("child");
|
||||
expect(send.mock.calls.filter(([, method]) => method === "Runtime.evaluate")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
{},
|
||||
{ width: 0, height: 100 },
|
||||
{ width: 200, height: NaN },
|
||||
])("rejects unavailable full viewport dimensions: %j", async (size) => {
|
||||
const { cdp } = scrollbarDriver(undefined, size);
|
||||
expect(await new GeometryContext(cdp, 4).targetProjection("child")).toBeNull();
|
||||
});
|
||||
|
||||
it("clips an inner OOPIF against an intermediate OOPIF's occupied scrollbar strips", async () => {
|
||||
const { cdp } = scrollbarDriver();
|
||||
const outer = cdp.sendToTarget!;
|
||||
cdp.sendToTarget = async (target, method, params) => {
|
||||
if (method === "DOM.getBoxModel")
|
||||
return { model: { content: [150, 60, 250, 60, 250, 120, 150, 120] } } as never;
|
||||
if (target.sessionId !== "inner") return outer(target, method, params);
|
||||
if (method === "Runtime.evaluate")
|
||||
return { result: { value: { width: 100, height: 60 } } } as never;
|
||||
if (method === "Page.getLayoutMetrics")
|
||||
return { cssLayoutViewport: { clientWidth: 90, clientHeight: 50 } } as never;
|
||||
if (method === "DOM.getContentQuads")
|
||||
return { quads: [[0, 0, 100, 0, 100, 60, 0, 60]] } as never;
|
||||
throw new Error(`unexpected ${method}`);
|
||||
};
|
||||
const graph = await cdp.getFrameGraph!(4);
|
||||
graph.frames[2] = {
|
||||
frameId: "inner",
|
||||
parentFrameId: "child",
|
||||
ownerBackendNodeId: 20,
|
||||
target: { tabId: 4, sessionId: "inner" },
|
||||
};
|
||||
cdp.getFrameGraph = async () => graph;
|
||||
expect(
|
||||
await resolveNodeGeometry(cdp, 4, {
|
||||
target: { tabId: 4, sessionId: "inner" },
|
||||
frameId: "inner",
|
||||
backendNodeId: 101,
|
||||
}),
|
||||
).toMatchObject({
|
||||
topBounds: { x: 400, y: 220, width: 70, height: 58 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps region bounds separate from polygon area", () => {
|
||||
const region = [
|
||||
rectPolygon({ x: 0, y: 0, w: 10, h: 10 }),
|
||||
@@ -208,8 +77,6 @@ describe("frame geometry projection", () => {
|
||||
const cdp: CdpRunner = {
|
||||
send: send as CdpRunner["send"],
|
||||
sendToTarget: vi.fn(async (_target, method) => {
|
||||
if (method === "Runtime.evaluate")
|
||||
return { result: { value: { width: 200, height: 100 } } };
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
||||
}
|
||||
@@ -268,8 +135,6 @@ describe("frame geometry projection", () => {
|
||||
throw new Error(`unexpected root command ${method}`);
|
||||
}) as CdpRunner["send"],
|
||||
sendToTarget: vi.fn(async (_target, method) => {
|
||||
if (method === "Runtime.evaluate")
|
||||
return { result: { value: { width: 200, height: 100 } } };
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
||||
}
|
||||
@@ -331,8 +196,6 @@ describe("frame geometry projection", () => {
|
||||
}) as CdpRunner["send"],
|
||||
sendToTarget: vi.fn(async (_target, method) => {
|
||||
if (method === "DOM.scrollIntoViewIfNeeded") return {};
|
||||
if (method === "Runtime.evaluate")
|
||||
return { result: { value: { width: 200, height: 100 } } };
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
||||
}
|
||||
|
||||
@@ -5,10 +5,9 @@ import { rectPolygon } from "../geometry";
|
||||
import {
|
||||
projectSnapshotRect,
|
||||
screenshotPageRect,
|
||||
snapshotCoordinates,
|
||||
snapshotViewportRect,
|
||||
} from "../geometry/coordinate-types";
|
||||
import { GeometryContext, snapshotLayoutScale } from "../geometry/frame-context";
|
||||
import { GeometryContext } from "../geometry/frame-context";
|
||||
import type { CdpRunner } from "../shared";
|
||||
|
||||
const target = { tabId: 4 };
|
||||
@@ -23,11 +22,7 @@ const graph: CdpFrameGraph = {
|
||||
function driver() {
|
||||
const send = vi.fn(async (_tab: number, method: string): Promise<object> => {
|
||||
if (method === "Page.getLayoutMetrics")
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 1000 },
|
||||
};
|
||||
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 1000 } };
|
||||
if (method === "DOM.getBoxModel")
|
||||
return { model: { content: [52.5, 612.5, 427.5, 612.5, 427.5, 862.5, 52.5, 862.5] } };
|
||||
if (method === "DOM.resolveNode") return { object: { objectId: "owner" } };
|
||||
@@ -40,63 +35,6 @@ function driver() {
|
||||
}
|
||||
|
||||
describe("measurement geometry context", () => {
|
||||
it.each([0.8, 1, 1.25, 2])("normalizes snapshot layout units at scale %s", (scale) => {
|
||||
const measured = snapshotLayoutScale({
|
||||
visualViewport: { clientWidth: 997.25, zoom: 1.1 },
|
||||
cssVisualViewport: { clientWidth: 997.25 / scale, zoom: 1.1 },
|
||||
// Rounded layout dimensions must not supply the scale.
|
||||
layoutViewport: { clientWidth: 997 },
|
||||
cssLayoutViewport: { clientWidth: Math.floor(997.25 / scale) },
|
||||
});
|
||||
expect(measured).toBeCloseTo(scale, 12);
|
||||
const coordinates = snapshotCoordinates(
|
||||
{ scrollOffsetX: 80 * scale, scrollOffsetY: 200 * scale },
|
||||
measured,
|
||||
{ x: 999, y: 999 },
|
||||
);
|
||||
expect(
|
||||
snapshotViewportRect(
|
||||
[160 * scale, 320 * scale, 120 * scale, 40 * scale],
|
||||
{ target },
|
||||
coordinates,
|
||||
)?.rect,
|
||||
).toEqual({ x: 80, y: 120, width: 120, height: 40 });
|
||||
});
|
||||
|
||||
it("uses CSS root scroll only for missing snapshot offsets, without scaling it twice", () => {
|
||||
const coordinates = snapshotCoordinates({ scrollOffsetX: 80 }, 2, { x: 999, y: 100 });
|
||||
expect(snapshotViewportRect([200, 800, 240, 80], { target }, coordinates)?.rect).toEqual({
|
||||
x: 60,
|
||||
y: 300,
|
||||
width: 120,
|
||||
height: 40,
|
||||
});
|
||||
expect(snapshotCoordinates({ scrollOffsetX: 0 }, 2)).toBeNull();
|
||||
expect(
|
||||
snapshotCoordinates({ scrollOffsetX: NaN, scrollOffsetY: 0 }, 2, { x: 0, y: 0 }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not infer snapshot units from missing, invalid, or CSS-only metrics", () => {
|
||||
expect(snapshotLayoutScale({ cssLayoutViewport: { clientWidth: 800 } })).toBeNull();
|
||||
for (const raw of [0, -1, NaN, Infinity]) {
|
||||
expect(
|
||||
snapshotLayoutScale({
|
||||
visualViewport: { clientWidth: raw },
|
||||
cssVisualViewport: { clientWidth: 800 },
|
||||
}),
|
||||
).toBeNull();
|
||||
}
|
||||
expect(
|
||||
snapshotLayoutScale({
|
||||
visualViewport: { clientWidth: 0, clientHeight: 600 },
|
||||
cssVisualViewport: { clientWidth: 0, clientHeight: 300 },
|
||||
}),
|
||||
).toBe(2);
|
||||
expect(snapshotCoordinates({ scrollOffsetX: 0, scrollOffsetY: 0 }, null)).toBeNull();
|
||||
expect(snapshotViewportRect([10, 20, 120, 40], { target }, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("shares in-flight frame measurements, but never shares them with the next context", async () => {
|
||||
const cdp = driver();
|
||||
const context = new GeometryContext(cdp, 4);
|
||||
@@ -123,20 +61,14 @@ describe("measurement geometry context", () => {
|
||||
height: 1000,
|
||||
});
|
||||
if (projection.status !== "available") throw new Error("expected available projection");
|
||||
const input = snapshotViewportRect([17, 23, 120, 40], source, {
|
||||
layoutUnitsPerCssPixel: 1,
|
||||
scrollCss: { x: 0, y: 0 },
|
||||
});
|
||||
const input = snapshotViewportRect([17, 23, 120, 40], source, { x: 0, y: 0 });
|
||||
expect(projectSnapshotRect(input!, projection.projection)).toEqual({
|
||||
x: 73.75,
|
||||
y: 641.25,
|
||||
width: 150,
|
||||
height: 50,
|
||||
});
|
||||
const scrolled = snapshotViewportRect([17, 23, 120, 40], source, {
|
||||
layoutUnitsPerCssPixel: 1,
|
||||
scrollCss: { x: 0, y: 10 },
|
||||
});
|
||||
const scrolled = snapshotViewportRect([17, 23, 120, 40], source, { x: 0, y: 10 });
|
||||
expect(projectSnapshotRect(scrolled!, projection.projection)?.y).toBe(628.75);
|
||||
await context.snapshotProjection(source, 10, [], { width: 1000, height: 1000 });
|
||||
expect(cdp.calls.mock.calls.filter(([, method]) => method === "DOM.resolveNode")).toHaveLength(
|
||||
@@ -147,101 +79,11 @@ describe("measurement geometry context", () => {
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("falls back only for missing or invalid batch entries and does not reuse sizes across contexts", async () => {
|
||||
const cdp = driver();
|
||||
const original = cdp.send;
|
||||
const calls: string[] = [];
|
||||
cdp.send = async (tabId, method, params) => {
|
||||
calls.push(method);
|
||||
if (method === "Runtime.evaluate")
|
||||
return {
|
||||
result: {
|
||||
deepSerializedValue: {
|
||||
type: "array",
|
||||
value: [
|
||||
{
|
||||
type: "array",
|
||||
value: [
|
||||
{ type: "node", value: { backendNodeId: 10 } },
|
||||
{ type: "string", value: '{"width":300,"height":200}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "array",
|
||||
value: [
|
||||
{ type: "node", value: { backendNodeId: 11 } },
|
||||
{ type: "string", value: '{"width":0,"height":200}' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
if (method === "Runtime.releaseObjectGroup") return {} as never;
|
||||
return original(tabId, method, params);
|
||||
};
|
||||
const context = new GeometryContext(cdp, 4);
|
||||
context.registerSnapshotOwners(target, new Set([10, 11]));
|
||||
const values = await Promise.all(
|
||||
[10, 11].map((id) =>
|
||||
context.snapshotProjection({ target, frameId: String(id) }, id, [], {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(values.every((value) => value.status === "available")).toBe(true);
|
||||
expect(calls.filter((method) => method === "DOM.resolveNode")).toHaveLength(1);
|
||||
expect(calls.filter((method) => method === "Runtime.evaluate")).toHaveLength(1);
|
||||
await new GeometryContext(cdp, 4).snapshotProjection({ target, frameId: "child" }, 10, [], {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
});
|
||||
expect(calls.filter((method) => method === "DOM.resolveNode")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("waits for batch object cleanup and propagates cancellation", async () => {
|
||||
const cdp = driver();
|
||||
const original = cdp.send;
|
||||
const controller = new AbortController();
|
||||
let release!: () => void;
|
||||
let cleanup!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
cleanup = resolve;
|
||||
});
|
||||
cdp.send = async (tabId, method, params) => {
|
||||
if (method === "Runtime.evaluate") return {} as never;
|
||||
if (method === "Runtime.releaseObjectGroup") {
|
||||
cleanup();
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return {} as never;
|
||||
}
|
||||
return original(tabId, method, params);
|
||||
};
|
||||
const context = new GeometryContext(cdp, 4, undefined, controller.signal);
|
||||
context.registerSnapshotOwners(target, new Set([10, 11]));
|
||||
let settled = false;
|
||||
const operation = context
|
||||
.snapshotProjection({ target, frameId: "child" }, 10, [], { width: 1000, height: 1000 })
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
const rejected = expect(operation).rejects.toMatchObject({ name: "AbortError" });
|
||||
await started;
|
||||
controller.abort();
|
||||
expect(settled).toBe(false);
|
||||
release();
|
||||
await rejected;
|
||||
expect(cdp.calls.mock.calls.some(([, method]) => method === "DOM.resolveNode")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects owner mismatches and invalid numeric bounds", () => {
|
||||
const input = snapshotViewportRect(
|
||||
[30, 50, 120, 40],
|
||||
{ target, frameId: "main" },
|
||||
{ layoutUnitsPerCssPixel: 1, scrollCss: { x: 10, y: 20 } },
|
||||
{ x: 10, y: 20 },
|
||||
)!;
|
||||
const projection = {
|
||||
source: { target, frameId: "main" },
|
||||
@@ -262,20 +104,8 @@ describe("measurement geometry context", () => {
|
||||
source: { target: { tabId: 5 }, frameId: "main" },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
snapshotViewportRect(
|
||||
[0, 0, NaN, 40],
|
||||
{ target },
|
||||
{ layoutUnitsPerCssPixel: 1, scrollCss: { x: 0, y: 0 } },
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
snapshotViewportRect(
|
||||
[0, 0, 10, 40],
|
||||
{ target },
|
||||
{ layoutUnitsPerCssPixel: 1, scrollCss: { x: Infinity, y: 0 } },
|
||||
),
|
||||
).toBeNull();
|
||||
expect(snapshotViewportRect([0, 0, NaN, 40], { target }, { x: 0, y: 0 })).toBeNull();
|
||||
expect(snapshotViewportRect([0, 0, 10, 40], { target }, { x: Infinity, y: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not turn a border quad or a failed owner read into a content projection", async () => {
|
||||
@@ -308,7 +138,7 @@ describe("measurement geometry context", () => {
|
||||
const input = snapshotViewportRect(
|
||||
[0, 0, 300, 200],
|
||||
{ target, frameId: "nested" },
|
||||
{ layoutUnitsPerCssPixel: 1, scrollCss: { x: 0, y: 0 } },
|
||||
{ x: 0, y: 0 },
|
||||
);
|
||||
expect(projectSnapshotRect(input!, projection.projection)).toEqual({
|
||||
x: 60,
|
||||
@@ -358,11 +188,7 @@ describe("measurement geometry context", () => {
|
||||
peak = Math.max(peak, active);
|
||||
await new Promise<void>((resolve) => release.push(resolve));
|
||||
active--;
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 100, clientHeight: 100 },
|
||||
};
|
||||
return { cssLayoutViewport: { clientWidth: 100, clientHeight: 100 } };
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const context = new GeometryContext(
|
||||
|
||||
@@ -433,7 +433,6 @@ describe("handleRequestHelp", () => {
|
||||
if (method === "DOM.getContentQuads") {
|
||||
return { quads: [[10, 20, 110, 20, 110, 60, 10, 60]] };
|
||||
}
|
||||
if (method === "Runtime.evaluate") return { result: { value: { width: 200, height: 100 } } };
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
||||
}
|
||||
|
||||
@@ -201,7 +201,6 @@ describe("handleClick", () => {
|
||||
if (method === "DOM.getContentQuads") {
|
||||
return { quads: [[10, 20, 110, 20, 110, 60, 10, 60]] };
|
||||
}
|
||||
if (method === "Runtime.evaluate") return { result: { value: { width: 200, height: 100 } } };
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
||||
}
|
||||
@@ -221,7 +220,6 @@ describe("handleClick", () => {
|
||||
{ sessionId: "child-session", method: "DOM.scrollIntoViewIfNeeded" },
|
||||
{ sessionId: "child-session", method: "DOM.getContentQuads" },
|
||||
{ sessionId: "child-session", method: "Page.getLayoutMetrics" },
|
||||
{ sessionId: "child-session", method: "Runtime.evaluate" },
|
||||
]);
|
||||
expect(fake.sent.filter((call) => call.method === "Input.dispatchMouseEvent")).toHaveLength(3);
|
||||
});
|
||||
|
||||
@@ -93,11 +93,7 @@ function makeFakeCdp(handlers: Record<string, (params?: object) => unknown>) {
|
||||
sent.push({ method, params });
|
||||
const handler = handlers[method];
|
||||
if (!handler && method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 },
|
||||
};
|
||||
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
|
||||
}
|
||||
if (!handler) throw new Error(`unexpected CDP call ${method}`);
|
||||
return handler(params);
|
||||
@@ -685,59 +681,6 @@ describe("buildFrameVomScene", () => {
|
||||
expect(nestedInput?.parentId).toBe(result.nodes.find((item) => item.backendNodeId === 210)?.id);
|
||||
});
|
||||
|
||||
it("excludes an overlay only in its owning document when target backend ids collide", () => {
|
||||
const owner = (id: number): CapturedNode => ({
|
||||
backendNodeId: id,
|
||||
parentBackendNodeId: null,
|
||||
frameId: "main",
|
||||
tag: "iframe",
|
||||
attrs: {},
|
||||
rect: { x: id * 10, y: 0, w: 200, h: 100 },
|
||||
paintOrder: 0,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
});
|
||||
const owners = [owner(10), owner(20)];
|
||||
const result = buildFrameVomScene(
|
||||
[
|
||||
{
|
||||
frameId: "main",
|
||||
contextScopeId: "main",
|
||||
target: { tabId: 7 },
|
||||
domNodes: owners,
|
||||
axNodes: [],
|
||||
},
|
||||
...["left", "right"].map((frameId, i) => ({
|
||||
frameId,
|
||||
contextScopeId: frameId,
|
||||
parentFrameId: "main",
|
||||
ownerBackendNodeId: owners[i].backendNodeId,
|
||||
target: { tabId: 7, sessionId: frameId },
|
||||
excludedBackendNodeIds: new Set(i === 0 ? [7] : []),
|
||||
domNodes: [],
|
||||
axNodes: [
|
||||
{
|
||||
nodeId: "button",
|
||||
frameId,
|
||||
backendDOMNodeId: 7,
|
||||
role: { type: "role", value: "button" },
|
||||
name: { type: "computedString", value: frameId + " action" },
|
||||
},
|
||||
],
|
||||
})),
|
||||
],
|
||||
{
|
||||
nodes: owners,
|
||||
viewport: { width: 800, height: 600 },
|
||||
excludedBackendNodeIds: new Set(),
|
||||
},
|
||||
);
|
||||
const rendered = renderVom(result);
|
||||
expect(rendered.text).not.toContain("left action");
|
||||
expect(rendered.text).toContain("right action");
|
||||
expect(rendered.refs.find((ref) => ref.backendNodeId === 7)?.frameId).toBe("right");
|
||||
});
|
||||
|
||||
it("does not place a child document at the page root when its frame boundary is unresolved", () => {
|
||||
const childNode: CapturedNode = {
|
||||
backendNodeId: 101,
|
||||
@@ -2587,11 +2530,7 @@ describe("handleSnapshot", () => {
|
||||
if (method === "Accessibility.enable") return {};
|
||||
if (method === "Accessibility.getFullAXTree") return { nodes };
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||
};
|
||||
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 } };
|
||||
}
|
||||
if (method === "DOMSnapshot.enable") return {};
|
||||
if (method === "DOMSnapshot.captureSnapshot") throw new Error("snapshot unsupported");
|
||||
@@ -2678,11 +2617,7 @@ describe("handleSnapshot", () => {
|
||||
if (method === "Accessibility.enable") return {};
|
||||
if (method === "Accessibility.getFullAXTree") return { nodes: [root, button] };
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||
};
|
||||
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 } };
|
||||
}
|
||||
if (method === "DOMSnapshot.enable") return {};
|
||||
if (method === "DOMSnapshot.captureSnapshot") {
|
||||
@@ -2690,8 +2625,6 @@ describe("handleSnapshot", () => {
|
||||
strings,
|
||||
documents: [
|
||||
{
|
||||
scrollOffsetX: 0,
|
||||
scrollOffsetY: 0,
|
||||
frameId: "root",
|
||||
nodes: {
|
||||
parentIndex: [-1, 0],
|
||||
@@ -2776,11 +2709,7 @@ describe("handleSnapshot", () => {
|
||||
if (method === "Accessibility.enable") return {};
|
||||
if (method === "Accessibility.getFullAXTree") return { nodes: [root, button] };
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||
};
|
||||
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 } };
|
||||
}
|
||||
if (method === "DOMSnapshot.enable") return {};
|
||||
if (method === "DOMSnapshot.captureSnapshot") {
|
||||
@@ -2788,8 +2717,6 @@ describe("handleSnapshot", () => {
|
||||
strings,
|
||||
documents: [
|
||||
{
|
||||
scrollOffsetX: 0,
|
||||
scrollOffsetY: 0,
|
||||
frameId: "root",
|
||||
nodes: {
|
||||
parentIndex: [-1, 0],
|
||||
@@ -2877,11 +2804,7 @@ describe("handleSnapshot", () => {
|
||||
if (method === "Accessibility.enable") return {};
|
||||
if (method === "Accessibility.getFullAXTree") return { nodes: [root, button] };
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||
};
|
||||
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 } };
|
||||
}
|
||||
if (method === "DOMSnapshot.enable") return {};
|
||||
if (method === "DOMSnapshot.captureSnapshot") {
|
||||
@@ -2889,8 +2812,6 @@ describe("handleSnapshot", () => {
|
||||
strings,
|
||||
documents: [
|
||||
{
|
||||
scrollOffsetX: 0,
|
||||
scrollOffsetY: 0,
|
||||
frameId: "root",
|
||||
nodes: {
|
||||
parentIndex: [-1, 0],
|
||||
@@ -3049,11 +2970,7 @@ describe("handleSnapshot", () => {
|
||||
};
|
||||
}
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 },
|
||||
};
|
||||
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } };
|
||||
}
|
||||
if (method === "DOMSnapshot.enable") return {};
|
||||
if (method === "DOMSnapshot.captureSnapshot") {
|
||||
@@ -3160,8 +3077,6 @@ describe("handleSnapshot", () => {
|
||||
strings: S,
|
||||
documents: [
|
||||
{
|
||||
scrollOffsetX: 0,
|
||||
scrollOffsetY: 0,
|
||||
frameId: "root",
|
||||
nodes: {
|
||||
parentIndex: [-1, 0, 1, 2],
|
||||
@@ -3220,8 +3135,6 @@ describe("handleSnapshot", () => {
|
||||
strings: S,
|
||||
documents: [
|
||||
{
|
||||
scrollOffsetX: 0,
|
||||
scrollOffsetY: 0,
|
||||
frameId: "root",
|
||||
nodes: {
|
||||
parentIndex: [-1, 0, 1, 1, 1, 1],
|
||||
@@ -3272,8 +3185,6 @@ describe("handleSnapshot", () => {
|
||||
}
|
||||
|
||||
const VP_METRICS = {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||
};
|
||||
|
||||
@@ -3287,8 +3198,6 @@ describe("handleSnapshot", () => {
|
||||
strings,
|
||||
documents: [
|
||||
{
|
||||
scrollOffsetX: 0,
|
||||
scrollOffsetY: 0,
|
||||
frameId: "main",
|
||||
nodes: {
|
||||
parentIndex: [-1, 0, 1],
|
||||
@@ -3309,8 +3218,6 @@ describe("handleSnapshot", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
scrollOffsetX: 0,
|
||||
scrollOffsetY: 0,
|
||||
frameId: "child",
|
||||
nodes: {
|
||||
parentIndex: [-1, 0, 1],
|
||||
@@ -3380,13 +3287,8 @@ describe("handleSnapshot", () => {
|
||||
throw new Error(`unexpected root CDP method ${method}`);
|
||||
});
|
||||
const sendToTarget = vi.fn(async (_target, method: string) => {
|
||||
if (method === "Runtime.evaluate") return { result: { value: { width: 400, height: 300 } } };
|
||||
if (method === "Page.getLayoutMetrics" && ownerGeometry !== "unavailable")
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 400, clientHeight: 300 },
|
||||
};
|
||||
return { cssLayoutViewport: { clientWidth: 400, clientHeight: 300 } };
|
||||
if (method === "Accessibility.enable" || method === "DOMSnapshot.enable") return {};
|
||||
if (method === "DOMSnapshot.captureSnapshot")
|
||||
return { ...snapshot, documents: [snapshot.documents[1]] };
|
||||
@@ -3429,8 +3331,6 @@ describe("handleSnapshot", () => {
|
||||
strings: ["body", "a", "href", "//outside.test/docs", "https://page.test/start"],
|
||||
documents: [
|
||||
{
|
||||
scrollOffsetX: 0,
|
||||
scrollOffsetY: 0,
|
||||
frameId: "main",
|
||||
...(hasSnapshotUrl ? { documentURL: 4 } : {}),
|
||||
nodes: {
|
||||
@@ -3552,8 +3452,6 @@ describe("handleSnapshot", () => {
|
||||
strings,
|
||||
documents: [
|
||||
{
|
||||
scrollOffsetX: 0,
|
||||
scrollOffsetY: 0,
|
||||
frameId: "root",
|
||||
nodes: {
|
||||
parentIndex: [-1, 0, 1, 1, 3],
|
||||
@@ -3696,74 +3594,20 @@ describe("handleSnapshot", () => {
|
||||
const result = await captureVomObservation(makeFrameAwareDeps().cdp, 4, "https://example.com", {
|
||||
maxTokens,
|
||||
});
|
||||
expect(result.text.match(/@warning/g)).toHaveLength(2);
|
||||
expect(result.text).toContain("document identity unverified");
|
||||
expect(result.text).toContain("geometry incomplete");
|
||||
expect(result.text.match(/@warning/g)).toHaveLength(1);
|
||||
expect(result.text).toContain("iframe geometry incomplete");
|
||||
expect(result.text.startsWith("@vom 1\n")).toBe(true);
|
||||
if (maxTokens === undefined) {
|
||||
expect(result.truncated).toBe(false);
|
||||
expect(result.text).toContain("Frame action");
|
||||
expect(result.matchNodes.find((node) => node.backendNodeId === 22)).toMatchObject({
|
||||
rect: null,
|
||||
localRect: null,
|
||||
localRect: { x: 20, y: 30, w: 120, h: 40 },
|
||||
});
|
||||
}
|
||||
if (maxTokens === 100) expect(Math.ceil(result.text.length / 4)).toBeLessThanOrEqual(maxTokens);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["DOMSnapshot.captureSnapshot", "dom"],
|
||||
["Accessibility.getFullAXTree", "ax"],
|
||||
])("reports partial capture when child %s fails", async (failedMethod, stage) => {
|
||||
const { cdp } = makeFrameAwareDeps("available");
|
||||
const send = cdp.sendToTarget;
|
||||
cdp.sendToTarget = async (target, method, params) => {
|
||||
if (method === failedMethod) throw new Error("unavailable child data");
|
||||
return send(target, method, params);
|
||||
};
|
||||
for (const maxTokens of [undefined, 100, 0]) {
|
||||
const result = await captureVomObservation(cdp, 4, "https://example.com", { maxTokens });
|
||||
expect(result.text).toContain(`@warning observation incomplete: some ${stage} data`);
|
||||
expect(result.text.match(/@warning/g)).toHaveLength(2);
|
||||
expect(result.text).toContain("document identity unverified");
|
||||
expect(result.text.startsWith("@vom 1\n")).toBe(true);
|
||||
if (maxTokens === undefined) {
|
||||
expect(result.frames.map((frame) => frame.frameId)).toEqual(["main", "child"]);
|
||||
expect(result.truncated).toBe(false);
|
||||
}
|
||||
if (maxTokens === 100)
|
||||
expect(Math.ceil(result.text.length / 4)).toBeLessThanOrEqual(maxTokens);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports omitted child ownership without exposing capture internals", async () => {
|
||||
const { cdp } = makeFrameAwareDeps("available");
|
||||
const send = cdp.send;
|
||||
cdp.send = async (tabId, method, params) => {
|
||||
const result = await send(tabId, method, params);
|
||||
if (method === "DOMSnapshot.captureSnapshot") {
|
||||
const child = await cdp.sendToTarget({ tabId, sessionId: "child-session" }, method, params);
|
||||
(result as { documents: unknown[] }).documents.push(
|
||||
...(child as { documents: unknown[] }).documents,
|
||||
);
|
||||
}
|
||||
return result as never;
|
||||
};
|
||||
const result = await captureVomObservation(cdp, 4, "https://example.com");
|
||||
expect(result.frames.map((frame) => frame.frameId)).toEqual(["main"]);
|
||||
expect(result.text).toContain("observation incomplete: some ownership data");
|
||||
expect(result.text).not.toContain("Frame action");
|
||||
expect(result).not.toHaveProperty("issues");
|
||||
});
|
||||
|
||||
it("reports unavailable form state while retaining the observed document", async () => {
|
||||
const { cdp } = makeOverlayDeps([], loginSnapshotReply(), VP_METRICS);
|
||||
const result = await captureVomObservation(cdp, 4, "https://example.com");
|
||||
expect(result.text).toContain("observation incomplete: some forms data");
|
||||
expect(result.frames).toHaveLength(1);
|
||||
expect(result.text).not.toContain("unexpected CDP method");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"available",
|
||||
"clipped",
|
||||
@@ -3773,8 +3617,7 @@ describe("handleSnapshot", () => {
|
||||
4,
|
||||
"https://example.com",
|
||||
);
|
||||
expect(result.text).not.toContain("iframe geometry incomplete");
|
||||
expect(result.text).toContain("document identity unverified");
|
||||
expect(result.text).not.toContain("@warning");
|
||||
const node = result.matchNodes.find((item) => item.backendNodeId === 22);
|
||||
if (ownerGeometry === "clipped") expect(node?.rect).toBeNull();
|
||||
else expect(node?.rect).toEqual({ x: 120, y: 130, w: 120, h: 40 });
|
||||
@@ -3796,7 +3639,7 @@ describe("handleSnapshot", () => {
|
||||
expect(result.matchNodes.find((node) => node.backendNodeId === 22)).toMatchObject({
|
||||
frameId: "child",
|
||||
tag: "button",
|
||||
localRect: null,
|
||||
localRect: { x: 20, y: 30, w: 120, h: 40 },
|
||||
});
|
||||
expect(result.refs.find((ref) => ref.backendNodeId === 22)).toMatchObject({
|
||||
frameId: "child",
|
||||
@@ -4140,8 +3983,6 @@ describe("handleSnapshot", () => {
|
||||
strings: S,
|
||||
documents: [
|
||||
{
|
||||
scrollOffsetX: 0,
|
||||
scrollOffsetY: 0,
|
||||
frameId: "root",
|
||||
nodes: {
|
||||
parentIndex: [-1, 0, 1, 0, 3, 4, 5],
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CdpFrame, CdpFrameGraph, CdpTarget } from "@/browser-driver/frame-graph";
|
||||
import { resolveNodeGeometry } from "../frame-geometry";
|
||||
import type { CdpRunner } from "../shared";
|
||||
import { captureObservationFacts } from "../vom/capture-coordinator";
|
||||
|
||||
type Send = <T = Record<string, unknown>>(
|
||||
method: string,
|
||||
params?: object,
|
||||
sessionId?: string,
|
||||
) => Promise<T>;
|
||||
type Tree = { frame: { id: string; parentId?: string; name?: string }; childFrames?: Tree[] };
|
||||
type Rect = { x: number; y: number; w: number; h: number };
|
||||
type Oracle = {
|
||||
probes: Record<string, Rect>;
|
||||
viewport: { width: number; height: number };
|
||||
owners: Record<string, { x: number; y: number; scale: number }>;
|
||||
};
|
||||
|
||||
function clip(rect: Rect, viewport: Oracle["viewport"]): Rect | null {
|
||||
const x = Math.max(0, rect.x),
|
||||
y = Math.max(0, rect.y);
|
||||
const right = Math.min(viewport.width, rect.x + rect.w);
|
||||
const bottom = Math.min(viewport.height, rect.y + rect.h);
|
||||
return right > x && bottom > y ? { x, y, w: right - x, h: bottom - y } : null;
|
||||
}
|
||||
|
||||
// The independent oracle uses DOM border boxes and this fixture's axis-aligned
|
||||
// iframe transforms. No production snapshot conversion/projection builds expectations.
|
||||
const oracleExpression = `(() => {
|
||||
const probes = {};
|
||||
for (const node of document.querySelectorAll('[data-geometry-probe]')) {
|
||||
const box = node.getBoundingClientRect();
|
||||
probes[node.id] = { x: box.x, y: box.y, w: box.width, h: box.height };
|
||||
}
|
||||
const owners = {};
|
||||
for (const frame of document.querySelectorAll('iframe')) {
|
||||
const rect = frame.getBoundingClientRect();
|
||||
const style = getComputedStyle(frame);
|
||||
const scale = rect.width / frame.offsetWidth;
|
||||
owners[frame.name] = {
|
||||
x: rect.x + (frame.clientLeft + parseFloat(style.paddingLeft)) * scale,
|
||||
y: rect.y + (frame.clientTop + parseFloat(style.paddingTop)) * scale,
|
||||
scale,
|
||||
};
|
||||
}
|
||||
return { probes, viewport: { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight }, owners };
|
||||
})()`;
|
||||
|
||||
describe.skipIf(!process.env.BSK_GEOMETRY_CHROME)("real DOMSnapshot coordinate contract", () => {
|
||||
it.each(
|
||||
[
|
||||
{ deviceScale: 1, zoom: 1 },
|
||||
{ deviceScale: 0.8, zoom: 1 },
|
||||
{ deviceScale: 1, zoom: 1.25 },
|
||||
{ deviceScale: 2, zoom: 1 },
|
||||
{ deviceScale: 2, zoom: 0.8 },
|
||||
]
|
||||
.flatMap((configuration) => [
|
||||
{ ...configuration, fixture: "snapshot-coordinates", scrollbars: "none" },
|
||||
{ ...configuration, fixture: "oopif-scrollbars", scrollbars: "both" },
|
||||
])
|
||||
.concat(
|
||||
["vertical", "horizontal", "none"].map((scrollbars) => ({
|
||||
deviceScale: 1,
|
||||
zoom: 1,
|
||||
fixture: "oopif-scrollbars",
|
||||
scrollbars,
|
||||
})),
|
||||
),
|
||||
)("$fixture: device scale $deviceScale, zoom $zoom, scrollbars $scrollbars", async (configuration) => {
|
||||
const evalRoot = new URL("../../../../../evals/browser/", import.meta.url);
|
||||
const { createEvalServer } = await import(new URL("lib/server.mjs", evalRoot).href);
|
||||
const { withChrome } = await import(
|
||||
new URL("cases/regression/snapshot-coordinates/chrome.mjs", evalRoot).href
|
||||
);
|
||||
const server = createEvalServer();
|
||||
const { baseUrl } = await server.start();
|
||||
try {
|
||||
await withChrome(
|
||||
{ executable: process.env.BSK_GEOMETRY_CHROME, ...configuration },
|
||||
async (send: Send) => {
|
||||
const { targetId } = await send<{ targetId: string }>("Target.createTarget", {
|
||||
url: "about:blank",
|
||||
});
|
||||
const { sessionId: rootSession } = await send<{ sessionId: string }>(
|
||||
"Target.attachToTarget",
|
||||
{ targetId, flatten: true },
|
||||
);
|
||||
await send(
|
||||
"Page.navigate",
|
||||
{
|
||||
url: `${baseUrl}/${configuration.fixture}?run=coordinates&scrollbars=${configuration.scrollbars}`,
|
||||
},
|
||||
rootSession,
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
server
|
||||
.snapshot("coordinates")
|
||||
.events.some(
|
||||
(event: { type: string; data: { root?: boolean } }) =>
|
||||
event.type === "geometry.ready" && event.data.root,
|
||||
),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const targets = await send<{ targetInfos: { targetId: string; type: string }[] }>(
|
||||
"Target.getTargets",
|
||||
);
|
||||
const sessions = [rootSession];
|
||||
for (const target of targets.targetInfos.filter((target) => target.type === "iframe")) {
|
||||
const { sessionId } = await send<{ sessionId: string }>("Target.attachToTarget", {
|
||||
targetId: target.targetId,
|
||||
flatten: true,
|
||||
});
|
||||
sessions.push(sessionId);
|
||||
}
|
||||
expect(sessions.length).toBe(configuration.fixture === "oopif-scrollbars" ? 3 : 2);
|
||||
const frames: CdpFrame[] = [];
|
||||
const names = new Map<string, string>();
|
||||
const sessionFor = (target: CdpTarget) => target.sessionId ?? rootSession;
|
||||
for (const sessionId of sessions) {
|
||||
const { frameTree } = await send<{ frameTree: Tree }>(
|
||||
"Page.getFrameTree",
|
||||
{},
|
||||
sessionId,
|
||||
);
|
||||
const target: CdpTarget = {
|
||||
tabId: 1,
|
||||
...(sessionId === rootSession ? {} : { sessionId }),
|
||||
};
|
||||
const visit = (tree: Tree, parentFrameId = tree.frame.parentId) => {
|
||||
frames.push({ frameId: tree.frame.id, parentFrameId, target });
|
||||
names.set(tree.frame.id, tree.frame.name ?? "");
|
||||
for (const child of tree.childFrames ?? []) visit(child, tree.frame.id);
|
||||
};
|
||||
visit(frameTree);
|
||||
}
|
||||
expect(frames).toHaveLength(configuration.fixture === "oopif-scrollbars" ? 3 : 5);
|
||||
for (const frame of frames) {
|
||||
if (!frame.parentFrameId) continue;
|
||||
const parent = frames.find((item) => item.frameId === frame.parentFrameId)!;
|
||||
const owner = await send<{ backendNodeId: number }>(
|
||||
"DOM.getFrameOwner",
|
||||
{ frameId: frame.frameId },
|
||||
sessionFor(parent.target),
|
||||
);
|
||||
frame.ownerBackendNodeId = owner.backendNodeId;
|
||||
}
|
||||
const graph: CdpFrameGraph = { rootFrameId: frames[0].frameId, frames };
|
||||
const calls: string[] = [];
|
||||
const cdp: CdpRunner = {
|
||||
send: (tabId, method, params) => {
|
||||
calls.push(`${tabId}:${method}`);
|
||||
return send(method, params, rootSession);
|
||||
},
|
||||
sendToTarget: (target, method, params) => {
|
||||
calls.push(`${target.sessionId ?? target.tabId}:${method}`);
|
||||
if (
|
||||
method === "Runtime.evaluate" &&
|
||||
(params as { expression?: string })?.expression?.includes("window.innerWidth")
|
||||
)
|
||||
calls.push(`${target.sessionId ?? target.tabId}:viewport-size`);
|
||||
return send(method, params, sessionFor(target));
|
||||
},
|
||||
getFrameGraph: async () => graph,
|
||||
getAttachmentId: () => rootSession,
|
||||
};
|
||||
const oracles = new Map<string, Oracle>();
|
||||
for (const frame of frames) {
|
||||
const session = sessionFor(frame.target);
|
||||
const { executionContextId } = await send<{ executionContextId: number }>(
|
||||
"Page.createIsolatedWorld",
|
||||
{ frameId: frame.frameId, worldName: "coordinate-oracle" },
|
||||
session,
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const ready = await send<{ result: { value: boolean } }>(
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
expression: 'document.documentElement.dataset.geometryReady === "true"',
|
||||
contextId: executionContextId,
|
||||
returnByValue: true,
|
||||
},
|
||||
session,
|
||||
);
|
||||
return ready.result.value;
|
||||
},
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
// Establish scroll after cross-process target creation/layout settles.
|
||||
const scroll = frame.parentFrameId
|
||||
? names.get(frame.frameId) === "nested"
|
||||
? [10, 20]
|
||||
: [40, 100]
|
||||
: [80, 240];
|
||||
await send(
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
expression: `(async () => { scrollTo(${scroll.join(",")}); await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); })()`,
|
||||
contextId: executionContextId,
|
||||
awaitPromise: true,
|
||||
},
|
||||
session,
|
||||
);
|
||||
const reply = await send<{ result: { value: Oracle } }>(
|
||||
"Runtime.evaluate",
|
||||
{ expression: oracleExpression, contextId: executionContextId, returnByValue: true },
|
||||
session,
|
||||
);
|
||||
oracles.set(frame.frameId, reply.result.value);
|
||||
}
|
||||
const childViewport = await send<{
|
||||
result: {
|
||||
value: { width: number; height: number; clientWidth: number; clientHeight: number };
|
||||
};
|
||||
}>(
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
expression:
|
||||
"({ width: innerWidth, height: innerHeight, clientWidth: document.documentElement.clientWidth, clientHeight: document.documentElement.clientHeight })",
|
||||
returnByValue: true,
|
||||
},
|
||||
sessions[1],
|
||||
);
|
||||
for (const dimension of ["Width", "Height"] as const) {
|
||||
const occupied =
|
||||
childViewport.result.value[dimension === "Width" ? "width" : "height"] -
|
||||
childViewport.result.value[`client${dimension}`];
|
||||
if (
|
||||
configuration.scrollbars === "both" ||
|
||||
configuration.scrollbars === (dimension === "Width" ? "vertical" : "horizontal")
|
||||
)
|
||||
expect(occupied).toBeGreaterThan(0);
|
||||
else expect(occupied).toBe(0);
|
||||
}
|
||||
const facts = await captureObservationFacts(cdp, 1);
|
||||
expect(facts.issues).toEqual([]);
|
||||
expect(calls.filter((call) => call.endsWith(":Page.getLayoutMetrics"))).toHaveLength(
|
||||
sessions.length,
|
||||
);
|
||||
// Identity verification also uses Runtime.evaluate; count the full viewport reads separately.
|
||||
const viewportReadsBeforeLive = calls.filter((call) => call.endsWith(":viewport-size"));
|
||||
expect(new Set(viewportReadsBeforeLive).size).toBe(viewportReadsBeforeLive.length);
|
||||
expect(viewportReadsBeforeLive.length).toBeLessThanOrEqual(sessions.length - 1);
|
||||
for (const frame of frames) {
|
||||
for (const [id, local] of Object.entries(oracles.get(frame.frameId)!.probes)) {
|
||||
const node = facts.documents
|
||||
.find((doc) => doc.frame.frameId === frame.frameId)
|
||||
?.domNodes.find((node) => node.attrs.id === id);
|
||||
expect(node, `missing ${id} in ${names.get(frame.frameId) || "root"}`).toBeDefined();
|
||||
let top = clip(local, oracles.get(frame.frameId)!.viewport);
|
||||
let current = frame;
|
||||
while (top && current.parentFrameId) {
|
||||
const parentOracle = oracles.get(current.parentFrameId)!;
|
||||
const owner = parentOracle.owners[names.get(current.frameId)!];
|
||||
top = clip(
|
||||
{
|
||||
x: owner.x + top.x * owner.scale,
|
||||
y: owner.y + top.y * owner.scale,
|
||||
w: top.w * owner.scale,
|
||||
h: top.h * owner.scale,
|
||||
},
|
||||
parentOracle.viewport,
|
||||
);
|
||||
current = frames.find((item) => item.frameId === current.parentFrameId)!;
|
||||
}
|
||||
for (const key of ["x", "y", "w", "h"] as const)
|
||||
expect(
|
||||
Math.abs(node!.localRect![key] - local[key]),
|
||||
`${names.get(frame.frameId)} ${id} local ${key}`,
|
||||
).toBeLessThan(2);
|
||||
if (!top) {
|
||||
expect(node!.rect, `${id} must be clipped out`).toBeNull();
|
||||
if (configuration.fixture === "oopif-scrollbars")
|
||||
expect(
|
||||
await resolveNodeGeometry(cdp, 1, {
|
||||
target: frame.target,
|
||||
frameId: frame.frameId,
|
||||
backendNodeId: node!.backendNodeId,
|
||||
}),
|
||||
).toMatchObject({ code: "permission_denied" });
|
||||
continue;
|
||||
}
|
||||
expect(node!.rect).not.toBeNull();
|
||||
for (const key of ["x", "y", "w", "h"] as const)
|
||||
expect(
|
||||
Math.abs(node!.rect![key] - top[key]),
|
||||
`${names.get(frame.frameId)} ${id} top ${key}`,
|
||||
).toBeLessThan(2);
|
||||
if (configuration.fixture !== "oopif-scrollbars") continue;
|
||||
const live = await resolveNodeGeometry(cdp, 1, {
|
||||
target: frame.target,
|
||||
frameId: frame.frameId,
|
||||
backendNodeId: node!.backendNodeId,
|
||||
});
|
||||
if ("code" in live) throw new Error(live.message);
|
||||
for (const [key, liveKey] of [
|
||||
["x", "x"],
|
||||
["y", "y"],
|
||||
["w", "width"],
|
||||
["h", "height"],
|
||||
] as const)
|
||||
expect(
|
||||
Math.abs(live.topBounds[liveKey] - top[key]),
|
||||
`${id} live ${key}`,
|
||||
).toBeLessThan(2);
|
||||
const before = server.snapshot("coordinates").events.length;
|
||||
for (const type of ["mousePressed", "mouseReleased"])
|
||||
await send(
|
||||
"Input.dispatchMouseEvent",
|
||||
{ type, ...live.actionPoint, button: "left", clickCount: 1 },
|
||||
rootSession,
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
server
|
||||
.snapshot("coordinates")
|
||||
.events.slice(before)
|
||||
.some(
|
||||
(event: { type: string; path: string; data: { probe?: string } }) =>
|
||||
event.type === "geometry.clicked" &&
|
||||
event.data.probe === id &&
|
||||
event.path ===
|
||||
(frame.parentFrameId
|
||||
? names.get(frame.frameId) === "nested"
|
||||
? "/oopif-scrollbars/nested"
|
||||
: "/oopif-scrollbars/frame"
|
||||
: "/oopif-scrollbars"),
|
||||
),
|
||||
{ timeout: 3000 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
}
|
||||
const metrics = await send<{
|
||||
cssVisualViewport: { zoom: number };
|
||||
visualViewport: { clientWidth: number };
|
||||
}>("Page.getLayoutMetrics", {}, rootSession);
|
||||
expect(metrics.cssVisualViewport.zoom).toBeCloseTo(configuration.zoom, 4);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -161,8 +161,7 @@ export async function resolveNodeGeometry(
|
||||
return geometryError(`could not resolve frame geometry for ${address.frameId}`);
|
||||
topVisibleRegions = projectRegionToViewport(localRegion, projection);
|
||||
if (address.target.sessionId) {
|
||||
const localViewport = await context.viewport(address.target);
|
||||
if (!localViewport) return geometryError("could not resolve target viewport geometry");
|
||||
const localViewport = projection.edges[0]?.sourceViewport ?? projection.topViewport;
|
||||
const localVisibleRegions = localRegion
|
||||
.map((polygon) =>
|
||||
clipPolygon(
|
||||
|
||||
@@ -25,12 +25,6 @@ export interface UnavailableFrameProjection {
|
||||
ownerBackendNodeId: number;
|
||||
}
|
||||
|
||||
export interface UnavailableSnapshotCoordinates {
|
||||
status: "unavailable";
|
||||
source: CoordinateOwner;
|
||||
reason: "snapshot-coordinates-unavailable";
|
||||
}
|
||||
|
||||
export type SnapshotProjectionResult =
|
||||
| { status: "available"; projection: SnapshotProjection }
|
||||
| UnavailableFrameProjection;
|
||||
@@ -38,74 +32,25 @@ export type SnapshotProjectionResult =
|
||||
/** Descendants inherit the failed boundary without attempting another owner read. */
|
||||
export type FrameProjectionIssue =
|
||||
| UnavailableFrameProjection
|
||||
| UnavailableSnapshotCoordinates
|
||||
| {
|
||||
status: "blocked";
|
||||
source: CoordinateOwner;
|
||||
cause: UnavailableFrameProjection | UnavailableSnapshotCoordinates;
|
||||
};
|
||||
| { status: "blocked"; source: CoordinateOwner; cause: UnavailableFrameProjection };
|
||||
|
||||
export type FrameProjectionState = SnapshotProjectionResult | FrameProjectionIssue;
|
||||
|
||||
export interface SnapshotCoordinates {
|
||||
layoutUnitsPerCssPixel: number;
|
||||
scrollCss: { x: number; y: number };
|
||||
}
|
||||
|
||||
/** Snapshot scroll offsets share the raw layout units of its bounds. Only a
|
||||
* target's root document may fall back to that target's CSS layout viewport. */
|
||||
export function snapshotCoordinates(
|
||||
document: { scrollOffsetX?: number; scrollOffsetY?: number },
|
||||
layoutUnitsPerCssPixel: number | null,
|
||||
rootScrollCss?: { x?: number; y?: number },
|
||||
): SnapshotCoordinates | null {
|
||||
if (
|
||||
layoutUnitsPerCssPixel === null ||
|
||||
!Number.isFinite(layoutUnitsPerCssPixel) ||
|
||||
layoutUnitsPerCssPixel <= 0
|
||||
)
|
||||
return null;
|
||||
const x =
|
||||
document.scrollOffsetX === undefined
|
||||
? rootScrollCss?.x
|
||||
: document.scrollOffsetX / layoutUnitsPerCssPixel;
|
||||
const y =
|
||||
document.scrollOffsetY === undefined
|
||||
? rootScrollCss?.y
|
||||
: document.scrollOffsetY / layoutUnitsPerCssPixel;
|
||||
if (x === undefined || y === undefined || !Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||||
return { layoutUnitsPerCssPixel, scrollCss: { x, y } };
|
||||
}
|
||||
|
||||
/** Convert raw document-relative Blink layout units exactly once, before any
|
||||
* CSS viewport clipping or frame projection. This scale is not screenshot zoom. */
|
||||
/** DOMSnapshot bounds are document-relative CSS pixels, independent of raster DPR. */
|
||||
export function snapshotViewportRect(
|
||||
bounds: number[],
|
||||
owner: CoordinateOwner,
|
||||
coordinates: SnapshotCoordinates | null,
|
||||
scroll: { x: number; y: number },
|
||||
): FrameViewportRect | null {
|
||||
if (!coordinates || bounds.length < 4 || !bounds.slice(0, 4).every(Number.isFinite)) return null;
|
||||
const { layoutUnitsPerCssPixel: scale, scrollCss: scroll } = coordinates;
|
||||
if (bounds.length < 4 || !bounds.slice(0, 4).every(Number.isFinite)) return null;
|
||||
const [x, y, width, height] = bounds;
|
||||
if (
|
||||
!Number.isFinite(scale) ||
|
||||
scale <= 0 ||
|
||||
width <= 0 ||
|
||||
height <= 0 ||
|
||||
!Number.isFinite(scroll.x) ||
|
||||
!Number.isFinite(scroll.y)
|
||||
) {
|
||||
if (width <= 0 || height <= 0 || !Number.isFinite(scroll.x) || !Number.isFinite(scroll.y)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
space: "frame-viewport-css",
|
||||
owner,
|
||||
rect: {
|
||||
x: x / scale - scroll.x,
|
||||
y: y / scale - scroll.y,
|
||||
width: width / scale,
|
||||
height: height / scale,
|
||||
},
|
||||
rect: { x: x - scroll.x, y: y - scroll.y, width, height },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,19 +9,15 @@ import {
|
||||
type Polygon,
|
||||
type ProjectiveEdge,
|
||||
parseCdpQuad,
|
||||
projectPolygon,
|
||||
type Quad,
|
||||
type Size,
|
||||
viewportPolygon,
|
||||
} from "../geometry";
|
||||
import { type CdpRunner, sendToCdpTarget } from "../shared";
|
||||
import type { CoordinateOwner, CssViewport, SnapshotProjectionResult } from "./coordinate-types";
|
||||
|
||||
import { readSnapshotOwnerSizes } from "./snapshot-owner-sizes";
|
||||
|
||||
export interface LayoutMetrics {
|
||||
cssVisualViewport?: { zoom?: number; clientWidth?: number; clientHeight?: number };
|
||||
visualViewport?: { zoom?: number; clientWidth?: number; clientHeight?: number };
|
||||
cssVisualViewport?: { zoom?: number };
|
||||
visualViewport?: { zoom?: number };
|
||||
cssLayoutViewport?: {
|
||||
clientWidth?: number;
|
||||
clientHeight?: number;
|
||||
@@ -31,29 +27,6 @@ export interface LayoutMetrics {
|
||||
layoutViewport?: { clientWidth?: number; clientHeight?: number; pageX?: number; pageY?: number };
|
||||
}
|
||||
|
||||
/** Chromium emits these paired floating-point dimensions before/after dividing
|
||||
* by LayoutZoomFactor. Integer layout viewport dimensions lose precision at
|
||||
* fractional zoom. Neither visualViewport.scale nor .zoom is this conversion.
|
||||
* See InspectorPageAgent::getLayoutMetrics in Chromium. */
|
||||
export function snapshotLayoutScale(metrics: LayoutMetrics): number | null {
|
||||
for (const dimension of ["clientWidth", "clientHeight"] as const) {
|
||||
const raw = metrics.visualViewport?.[dimension];
|
||||
const css = metrics.cssVisualViewport?.[dimension];
|
||||
if (
|
||||
raw !== undefined &&
|
||||
css !== undefined &&
|
||||
Number.isFinite(raw) &&
|
||||
Number.isFinite(css) &&
|
||||
raw > 0 &&
|
||||
css > 0
|
||||
) {
|
||||
const scale = raw / css;
|
||||
if (Number.isFinite(scale) && scale > 0) return scale;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The legacy field is a compatibility fallback, never a source of raster DPR. */
|
||||
export function cssViewport(metrics: LayoutMetrics): CssViewport {
|
||||
const value = metrics.cssLayoutViewport ?? metrics.layoutViewport;
|
||||
@@ -69,11 +42,9 @@ export function cssViewport(metrics: LayoutMetrics): CssViewport {
|
||||
/** One read-only measurement phase. Discard after scrolling or any later operation. */
|
||||
export class GeometryContext {
|
||||
private readonly metrics = new Map<string, Promise<LayoutMetrics>>();
|
||||
private readonly frameViewports = new Map<string, Promise<Size | null>>();
|
||||
private readonly owners = new Map<string, Promise<Quad | null>>();
|
||||
private readonly projections = new Map<string, Promise<GeometryProjection | null>>();
|
||||
private readonly snapshotOwners = new Map<string, Promise<{ quad: Quad; size: Size } | null>>();
|
||||
private readonly sizeBatches = new Map<string, () => Promise<Map<number, Size>>>();
|
||||
private active = 0;
|
||||
private readonly waiting: Array<() => void> = [];
|
||||
private graphPromise?: Promise<CdpFrameGraph | null>;
|
||||
@@ -160,35 +131,6 @@ export class GeometryContext {
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The full OOPIF viewport maps to its owner's content quad. Layout metrics
|
||||
* exclude occupied scrollbars and remain the clipping boundary, not the scale. */
|
||||
private frameViewport(target: CdpTarget): Promise<Size | null> {
|
||||
const key = cdpTargetKey(target);
|
||||
let promise = this.frameViewports.get(key);
|
||||
if (!promise) {
|
||||
promise = this.request<{ result?: { value?: Size }; exceptionDetails?: unknown }>(
|
||||
target,
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
expression: "({ width: window.innerWidth, height: window.innerHeight })",
|
||||
returnByValue: true,
|
||||
},
|
||||
).then(({ result, exceptionDetails }) => {
|
||||
const size = result?.value;
|
||||
return !exceptionDetails &&
|
||||
size &&
|
||||
Number.isFinite(size.width) &&
|
||||
Number.isFinite(size.height) &&
|
||||
size.width > 0 &&
|
||||
size.height > 0
|
||||
? size
|
||||
: null;
|
||||
});
|
||||
this.frameViewports.set(key, promise);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
|
||||
ownerContent(target: CdpTarget, backendNodeId: number): Promise<Quad | null> {
|
||||
const key = `${cdpTargetKey(target)}:${backendNodeId}`;
|
||||
let promise = this.owners.get(key);
|
||||
@@ -244,34 +186,12 @@ export class GeometryContext {
|
||||
: { status: "unavailable", source, ownerBackendNodeId };
|
||||
}
|
||||
|
||||
/** Register one target's snapshot owners; the batch starts only when a
|
||||
* projection actually needs a size. Single owners keep the cheaper old path. */
|
||||
registerSnapshotOwners(target: CdpTarget, owners: ReadonlySet<number>): void {
|
||||
const key = cdpTargetKey(target);
|
||||
if (owners.size < 2 || this.sizeBatches.has(key)) return;
|
||||
let pending: Promise<Map<number, Size>> | undefined;
|
||||
this.sizeBatches.set(key, () => {
|
||||
pending ??= readSnapshotOwnerSizes(
|
||||
(method, params, cleanup) => this.request(target, method, params, cleanup),
|
||||
owners,
|
||||
).catch((error) => {
|
||||
if (error && typeof error === "object" && "name" in error && error.name === "AbortError")
|
||||
throw error;
|
||||
return new Map<number, Size>();
|
||||
});
|
||||
return pending;
|
||||
});
|
||||
}
|
||||
|
||||
private async snapshotOwner(
|
||||
target: CdpTarget,
|
||||
backendNodeId: number,
|
||||
): Promise<{ quad: Quad; size: Size } | null> {
|
||||
const quad = await this.ownerContent(target, backendNodeId);
|
||||
if (!quad) return null;
|
||||
const batchSize = (await this.sizeBatches.get(cdpTargetKey(target))?.())?.get(backendNodeId);
|
||||
if (this.signal?.aborted) throw new DOMException("geometry aborted", "AbortError");
|
||||
if (batchSize) return { quad, size: batchSize };
|
||||
const resolved = await this.request<{ object?: { objectId?: string } }>(
|
||||
target,
|
||||
"DOM.resolveNode",
|
||||
@@ -374,19 +294,12 @@ export class GeometryContext {
|
||||
const parent = frames.get(root.parentFrameId);
|
||||
if (!parent || root.ownerBackendNodeId === undefined) return null;
|
||||
const destinationQuad = await this.ownerContent(parent.target, root.ownerBackendNodeId);
|
||||
if (!destinationQuad) return null;
|
||||
const source = await this.frameViewport(root.target);
|
||||
const visible = await this.viewport(root.target);
|
||||
const source = await this.viewport(root.target);
|
||||
const parentRoot = this.targetRoot(frames, parent);
|
||||
if (!source || !visible || !parentRoot) return null;
|
||||
if (!destinationQuad || !source || !parentRoot) return null;
|
||||
const destinationClips = await this.clips(frames, parent, parentRoot);
|
||||
if (!destinationClips) return null;
|
||||
const edge = { sourceViewport: source, destinationQuad, destinationClips };
|
||||
// Keep scrollbar strips clipped at every target boundary, including
|
||||
// intermediate OOPIFs. Project the clip with the same full-viewport scale.
|
||||
if (source.width !== visible.width || source.height !== visible.height)
|
||||
destinationClips.push(projectPolygon(viewportPolygon(visible), edge));
|
||||
edges.push(edge);
|
||||
edges.push({ sourceViewport: source, destinationQuad, destinationClips });
|
||||
root = parentRoot;
|
||||
}
|
||||
const topViewport = await this.viewport(root.target);
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { Size } from "../geometry";
|
||||
|
||||
interface SerializedValue {
|
||||
type: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
type Send = <T>(method: string, params: object, cleanup?: boolean) => Promise<T>;
|
||||
|
||||
/** Batch only accessible frame owners. Missing/shadow/cross-origin owners keep
|
||||
* the existing node-scoped read; no page-wide style columns are requested. */
|
||||
export async function readSnapshotOwnerSizes(
|
||||
send: Send,
|
||||
owners: ReadonlySet<number>,
|
||||
): Promise<Map<number, Size>> {
|
||||
const sizes = new Map<number, Size>();
|
||||
const objectGroup = `bsk-frame-sizes-${crypto.randomUUID()}`;
|
||||
try {
|
||||
const reply = await send<{ result?: { deepSerializedValue?: SerializedValue } }>(
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
expression: `(() => {
|
||||
const result = [];
|
||||
const documents = [document];
|
||||
for (let i = 0; i < documents.length && result.length < ${owners.size}; i++) {
|
||||
for (const owner of documents[i].querySelectorAll('iframe,frame')) {
|
||||
if (result.length >= ${owners.size}) break;
|
||||
if (!owner.isConnected) continue;
|
||||
const style = getComputedStyle(owner);
|
||||
result.push([owner, JSON.stringify({
|
||||
width: owner.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight),
|
||||
height: owner.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
|
||||
})]);
|
||||
try { if (owner.contentDocument) documents.push(owner.contentDocument); } catch {}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
})()`,
|
||||
objectGroup,
|
||||
serializationOptions: {
|
||||
serialization: "deep",
|
||||
maxDepth: 3,
|
||||
additionalParameters: { maxNodeDepth: 0, includeShadowTree: "none" },
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = reply.result?.deepSerializedValue;
|
||||
if (result?.type !== "array" || !Array.isArray(result.value)) return sizes;
|
||||
for (const entry of result.value as SerializedValue[]) {
|
||||
if (entry.type !== "array" || !Array.isArray(entry.value)) continue;
|
||||
const [node, data] = entry.value as SerializedValue[];
|
||||
if (node?.type !== "node" || data?.type !== "string" || typeof data.value !== "string")
|
||||
continue;
|
||||
const id = (node.value as { backendNodeId?: number } | undefined)?.backendNodeId;
|
||||
if (id === undefined || !owners.has(id)) continue;
|
||||
try {
|
||||
const size = JSON.parse(data.value) as Size;
|
||||
if (
|
||||
size &&
|
||||
Number.isFinite(size.width) &&
|
||||
Number.isFinite(size.height) &&
|
||||
size.width > 0 &&
|
||||
size.height > 0
|
||||
)
|
||||
sizes.set(id, size);
|
||||
} catch {
|
||||
/* An invalid entry uses the existing individual read. */
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await send("Runtime.releaseObjectGroup", { objectGroup }, true).catch(() => {});
|
||||
}
|
||||
return sizes;
|
||||
}
|
||||
@@ -926,41 +926,19 @@ export async function captureVomObservation(
|
||||
);
|
||||
// Reserve space using the renderer's character-based token estimate. Like VOM
|
||||
// headers, this integrity notice remains visible even under a tiny token budget.
|
||||
const notices: string[] = [];
|
||||
if (facts.issues.some((issue) => issue.stage === "geometry"))
|
||||
notices.push(
|
||||
"@warning geometry incomplete: some page or frame content has no top-level coordinates.",
|
||||
);
|
||||
const incompleteStages = ["dom", "ax", "forms", "ownership"].filter((stage) =>
|
||||
facts.issues.some((issue) => issue.stage === stage),
|
||||
);
|
||||
if (incompleteStages.length)
|
||||
notices.push(
|
||||
`@warning observation incomplete: some ${incompleteStages.join(", ")} data is unavailable or omitted.`,
|
||||
);
|
||||
if (
|
||||
facts.issues.some(
|
||||
(issue) => issue.stage === "identity" && issue.reason !== "identity-unverified",
|
||||
)
|
||||
)
|
||||
notices.push(
|
||||
"@warning observation incomplete: some documents were omitted because their identity changed or could not be revalidated.",
|
||||
);
|
||||
if (facts.issues.some((issue) => issue.reason === "identity-unverified"))
|
||||
notices.push(
|
||||
"@warning document identity unverified: some retained documents could not be checked for changes during capture.",
|
||||
);
|
||||
const captureNotice = notices.join("\n");
|
||||
const geometryNotice = facts.issues.some((issue) => issue.stage === "geometry")
|
||||
? "@warning iframe geometry incomplete: some frame content has no top-level coordinates.\n"
|
||||
: "";
|
||||
const rendered = renderVom(decoratedScene, {
|
||||
maxDepth: options.maxDepth,
|
||||
maxTokens:
|
||||
!captureNotice || options.maxTokens === undefined
|
||||
!geometryNotice || options.maxTokens === undefined
|
||||
? options.maxTokens
|
||||
: Math.max(0, options.maxTokens - Math.ceil((captureNotice.length + 1) / 4)),
|
||||
: Math.max(0, options.maxTokens - Math.ceil(geometryNotice.length / 4)),
|
||||
redactValues: options.redactValues,
|
||||
activeRegionPolicy: options.activeRegionPolicy,
|
||||
});
|
||||
if (captureNotice) rendered.text += `\n${captureNotice}`;
|
||||
if (geometryNotice) rendered.text += `\n${geometryNotice.trimEnd()}`;
|
||||
throwIfAborted(options.signal, "observation");
|
||||
return projectRecordSafeObservation({
|
||||
rootFrameId: captured.rootFrameId ?? normalizedDocuments[0]?.frameId ?? "root",
|
||||
|
||||
@@ -55,7 +55,6 @@ export interface CdpRunner {
|
||||
sendToTarget?<T = unknown>(target: CdpTarget, method: string, params?: object): Promise<T>;
|
||||
detach?(tabId: number): Promise<void>;
|
||||
getFrameGraph?(tabId: number): Promise<CdpFrameGraph>;
|
||||
getAttachmentId?(tabId: number): string | undefined;
|
||||
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
|
||||
trackSessionTab?(sessionId: string, tabId: number): void;
|
||||
releaseSessionTab?(sessionId: string, tabId: number): Promise<void>;
|
||||
|
||||
@@ -2,22 +2,13 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { CdpFrame, CdpTarget } from "@/browser-driver/frame-graph";
|
||||
import { cdpTargetKey } from "@/browser-driver/frame-graph";
|
||||
import { OVERLAY_HOST_MARKER_ATTR } from "@/lib/overlay-bridge";
|
||||
import { captureVomObservation } from "../../observation";
|
||||
import type { CdpRunner } from "../../shared";
|
||||
import { captureObservationFacts, semanticCapture } from "../capture-coordinator";
|
||||
import { buildSemanticGraph } from "../semantic-graph/build";
|
||||
import { REQUESTED_STYLES, type SnapshotReply } from "../snapshot";
|
||||
import { REQUESTED_STYLES } from "../snapshot";
|
||||
|
||||
function fixture(
|
||||
options: {
|
||||
frames?: CdpFrame[];
|
||||
after?: Record<string, { element?: number; missing?: boolean; unreadable?: boolean }>;
|
||||
fail?: string;
|
||||
missingIdentity?: boolean;
|
||||
omitDocument?: string;
|
||||
attachmentChanged?: boolean;
|
||||
overlay?: string;
|
||||
} = {},
|
||||
options: { frames?: CdpFrame[]; fail?: string; omitDocument?: string; overlay?: string } = {},
|
||||
) {
|
||||
const frames = options.frames ?? [
|
||||
{ frameId: "main", target: { tabId: 4 } },
|
||||
@@ -43,10 +34,7 @@ function fixture(
|
||||
const elements = new Map(
|
||||
frames.map((frame, i) => [frame.frameId, frame.target.sessionId ? 1 : i * 10 + 1]),
|
||||
);
|
||||
const contexts = new Map(frames.map((frame, i) => [i + 1, frame.frameId]));
|
||||
const logs: Array<{ target: CdpTarget; method: string; params: Record<string, unknown> }> = [];
|
||||
let snapshots = 0;
|
||||
const targetCount = new Set(frames.map((frame) => cdpTargetKey(frame.target))).size;
|
||||
const send = vi.fn(
|
||||
async <T>(target: CdpTarget, method: string, params: object = {}): Promise<T> => {
|
||||
logs.push({ target, method, params: params as Record<string, unknown> });
|
||||
@@ -54,35 +42,9 @@ function fixture(
|
||||
if (options.fail === `${target.sessionId ?? "main"}:${method}`)
|
||||
throw new Error("fixture failure");
|
||||
let result: unknown = {};
|
||||
if (method === "Page.createIsolatedWorld")
|
||||
result = {
|
||||
executionContextId: frames.findIndex((frame) => frame.frameId === args.frameId) + 1,
|
||||
};
|
||||
if (method === "Runtime.evaluate" && args.contextId) {
|
||||
const id = contexts.get(Number(args.contextId))!;
|
||||
const change = options.after?.[id];
|
||||
result =
|
||||
options.missingIdentity || change?.unreadable
|
||||
? {}
|
||||
: {
|
||||
result: {
|
||||
deepSerializedValue: change?.missing
|
||||
? { type: "null" }
|
||||
: {
|
||||
type: "node",
|
||||
value: { backendNodeId: change?.element ?? elements.get(id) },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "Page.getLayoutMetrics")
|
||||
result = {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 },
|
||||
};
|
||||
result = { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } };
|
||||
if (method === "DOMSnapshot.captureSnapshot") {
|
||||
snapshots++;
|
||||
expect((params as { computedStyles: unknown }).computedStyles).toEqual(REQUESTED_STYLES);
|
||||
result = {
|
||||
strings: [
|
||||
@@ -104,39 +66,14 @@ function fixture(
|
||||
)
|
||||
.map((frame) => {
|
||||
const element = elements.get(frame.frameId)!;
|
||||
const localFrames = frames.filter(
|
||||
(f) =>
|
||||
cdpTargetKey(f.target) === cdpTargetKey(target) &&
|
||||
f.frameId !== options.omitDocument,
|
||||
);
|
||||
const children = localFrames.filter((f) => f.parentFrameId === frame.frameId);
|
||||
|
||||
return {
|
||||
scrollOffsetX: 0,
|
||||
scrollOffsetY: 0,
|
||||
frameId: frame.frameId,
|
||||
nodes: {
|
||||
backendNodeId: [
|
||||
element - 1,
|
||||
element + 10000,
|
||||
element,
|
||||
element + 1,
|
||||
...children.map((f) => f.ownerBackendNodeId!),
|
||||
],
|
||||
nodeName: [0, 1, 1, 2, ...children.map(() => 2)],
|
||||
nodeType: [9, 10, 1, 1, ...children.map(() => 1)],
|
||||
parentIndex: [-1, 0, 0, 2, ...children.map(() => 2)],
|
||||
attributes: [
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
frame.frameId === options.overlay ? [3, 4] : [],
|
||||
...children.map(() => []),
|
||||
],
|
||||
contentDocumentIndex: {
|
||||
index: children.map((_, i) => 4 + i),
|
||||
value: children.map((f) => localFrames.indexOf(f)),
|
||||
},
|
||||
backendNodeId: [element - 1, element + 10000, element, element + 1],
|
||||
nodeName: [0, 1, 1, 2],
|
||||
nodeType: [9, 10, 1, 1],
|
||||
parentIndex: [-1, 0, 0, 2],
|
||||
attributes: [[], [], [], frame.frameId === options.overlay ? [3, 4] : []],
|
||||
},
|
||||
layout: {
|
||||
nodeIndex: [2, 3],
|
||||
@@ -171,12 +108,6 @@ function fixture(
|
||||
},
|
||||
);
|
||||
const cdp: CdpRunner = {
|
||||
getAttachmentId: () =>
|
||||
options.missingIdentity
|
||||
? undefined
|
||||
: options.attachmentChanged && snapshots === targetCount
|
||||
? "new-attachment"
|
||||
: "attachment",
|
||||
getFrameGraph: async () => ({ rootFrameId: frames[0].frameId, frames }),
|
||||
send: (tabId, method, params) =>
|
||||
(send as NonNullable<CdpRunner["sendToTarget"]>)({ tabId }, method, params),
|
||||
@@ -186,44 +117,12 @@ function fixture(
|
||||
}
|
||||
|
||||
describe("captureObservationFacts", () => {
|
||||
it("normalizes snapshot-owned root scroll instead of mixing in stale CSS scroll", async () => {
|
||||
const { cdp } = fixture({ frames: [{ frameId: "main", target: { tabId: 4 } }] });
|
||||
const original = cdp.sendToTarget!;
|
||||
cdp.sendToTarget = async (target, method, params) => {
|
||||
if (method === "Page.getLayoutMetrics")
|
||||
return {
|
||||
visualViewport: { clientWidth: 2000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 999, pageY: 999 },
|
||||
} as never;
|
||||
const reply = await original(target, method, params);
|
||||
if (method === "DOMSnapshot.captureSnapshot") {
|
||||
const doc = (reply as SnapshotReply).documents![0];
|
||||
doc.scrollOffsetX = 40;
|
||||
doc.scrollOffsetY = 200;
|
||||
doc.layout!.bounds![1] = [200, 800, 240, 80];
|
||||
}
|
||||
return reply as never;
|
||||
};
|
||||
cdp.send = (tabId, method, params) => cdp.sendToTarget!({ tabId }, method, params);
|
||||
const facts = await captureObservationFacts(cdp, 4);
|
||||
expect(facts.documents[0].domNodes.find((node) => node.backendNodeId === 2)).toMatchObject({
|
||||
localRect: { x: 80, y: 300, w: 120, h: 40 },
|
||||
rect: { x: 80, y: 300, w: 120, h: 40 },
|
||||
rendered: true,
|
||||
});
|
||||
expect(facts.issues).toEqual([]);
|
||||
});
|
||||
|
||||
it("collects each target once and scopes equal backend IDs", async () => {
|
||||
const { cdp, logs } = fixture();
|
||||
const facts = await captureObservationFacts(cdp, 4);
|
||||
expect(logs.filter((call) => call.method === "DOMSnapshot.captureSnapshot")).toHaveLength(2);
|
||||
expect(logs.filter((call) => call.method === "Accessibility.enable")).toHaveLength(2);
|
||||
expect(logs.filter((call) => call.method === "Accessibility.getFullAXTree")).toHaveLength(4);
|
||||
expect(facts.documents.every((doc) => doc.identity)).toBe(true);
|
||||
expect(logs.filter((call) => call.method === "DOM.describeNode")).toHaveLength(0);
|
||||
expect(logs.filter((call) => call.method === "Page.createIsolatedWorld")).toHaveLength(4);
|
||||
const main = facts.documents.find((doc) => doc.frame.frameId === "main")!;
|
||||
const remote = facts.documents.find((doc) => doc.frame.frameId === "remote")!;
|
||||
expect(main.index.nodes.get(2)).not.toBe(remote.index.nodes.get(2));
|
||||
@@ -234,219 +133,6 @@ describe("captureObservationFacts", () => {
|
||||
expect(facts.finishedAt).toBeGreaterThanOrEqual(facts.startedAt);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ element: 99 },
|
||||
{ missing: true },
|
||||
])("isolates changed child identity %j and dependent descendants", async (change) => {
|
||||
const { cdp } = fixture({ after: { same: change } });
|
||||
const facts = await captureObservationFacts(cdp, 4);
|
||||
expect(facts.documents.map((doc) => doc.frame.frameId)).toEqual(["main", "remote"]);
|
||||
expect(facts.issues).toContainEqual(
|
||||
expect.objectContaining({ frameId: "same", reason: "document-changed" }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ after: { main: { element: 99 } } },
|
||||
{ attachmentChanged: true },
|
||||
])("rejects a root replacement or reattachment %j", async (options) => {
|
||||
await expect(captureObservationFacts(fixture(options).cdp, 4)).rejects.toThrow(
|
||||
"document identity",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"main",
|
||||
"same",
|
||||
])("does not publish a previously identified %s document after identity reads fail", async (frameId) => {
|
||||
const { cdp } = fixture({ after: { [frameId]: { unreadable: true } } });
|
||||
if (frameId === "main")
|
||||
await expect(captureObservationFacts(cdp, 4)).rejects.toThrow("document identity");
|
||||
else {
|
||||
const facts = await captureObservationFacts(cdp, 4);
|
||||
expect(facts.documents.map((doc) => doc.frame.frameId)).toEqual(["main", "remote"]);
|
||||
expect(facts.issues).toContainEqual(
|
||||
expect.objectContaining({ frameId, reason: "identity-unavailable" }),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("validates newly discovered snapshot documents without a pre-snapshot identity read", async () => {
|
||||
const { cdp, logs } = fixture();
|
||||
const graph = await cdp.getFrameGraph!(4);
|
||||
cdp.getFrameGraph = async () => ({ ...graph, frames: graph.frames.slice(0, 1) });
|
||||
const facts = await captureObservationFacts(cdp, 4);
|
||||
expect(facts.documents.map((doc) => doc.frame.frameId)).toEqual(["main", "same", "nested"]);
|
||||
expect(facts.documents.every((doc) => doc.identity)).toBe(true);
|
||||
expect(logs.filter((call) => call.method === "Page.createIsolatedWorld")).toHaveLength(3);
|
||||
expect(logs.findIndex((call) => call.method === "Page.createIsolatedWorld")).toBeGreaterThan(
|
||||
logs.findIndex((call) => call.method === "Accessibility.getFullAXTree"),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a snapshot root that no longer matches the current frame root", async () => {
|
||||
const { cdp } = fixture();
|
||||
const original = cdp.sendToTarget!;
|
||||
cdp.sendToTarget = async (target, method, params) => {
|
||||
const result = await original(target, method, params);
|
||||
if (method === "DOMSnapshot.captureSnapshot" && !target.sessionId) {
|
||||
const snapshot = result as {
|
||||
documents: { frameId: string; nodes: { backendNodeId: number[] } }[];
|
||||
};
|
||||
snapshot.documents[0].nodes.backendNodeId[2] = 999;
|
||||
}
|
||||
return result as never;
|
||||
};
|
||||
cdp.send = (tabId, method, params) => cdp.sendToTarget!({ tabId }, method, params);
|
||||
await expect(captureObservationFacts(cdp, 4)).rejects.toThrow("document identity");
|
||||
});
|
||||
|
||||
it("starts after-identity reads only after every target finishes and propagates cancellation during identity cleanup", async () => {
|
||||
const { cdp, logs } = fixture();
|
||||
const controller = new AbortController();
|
||||
const original = cdp.sendToTarget!;
|
||||
let identityCleanup = 0;
|
||||
cdp.sendToTarget = async (target, method, params) => {
|
||||
if (method === "Page.createIsolatedWorld") {
|
||||
expect(logs.filter((call) => call.method === "Accessibility.getFullAXTree")).toHaveLength(
|
||||
4,
|
||||
);
|
||||
}
|
||||
const result = await original(target, method, params);
|
||||
if (
|
||||
method === "Runtime.releaseObjectGroup" &&
|
||||
String((params as { objectGroup?: string })?.objectGroup).startsWith(
|
||||
"bsk-document-identity-",
|
||||
) &&
|
||||
target.sessionId === "remote"
|
||||
) {
|
||||
if (++identityCleanup === 1) controller.abort();
|
||||
}
|
||||
return result as never;
|
||||
};
|
||||
cdp.send = (tabId, method, params) => cdp.sendToTarget!({ tabId }, method, params);
|
||||
await expect(captureObservationFacts(cdp, 4, controller.signal)).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
});
|
||||
expect(identityCleanup).toBe(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
1, 10, 100,
|
||||
])("bounds identity reads for %i same-target documents after snapshot and AX collection", async (count) => {
|
||||
const frames: CdpFrame[] = Array.from({ length: count }, (_, i) => ({
|
||||
frameId: `frame-${i}`,
|
||||
target: { tabId: 4 },
|
||||
...(i ? { parentFrameId: "frame-0", ownerBackendNodeId: 90000 + i } : {}),
|
||||
}));
|
||||
const { cdp, logs } = fixture({ frames });
|
||||
const original = cdp.sendToTarget!;
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
let released = 0;
|
||||
cdp.sendToTarget = async (target, method, params) => {
|
||||
if (method === "Page.createIsolatedWorld") {
|
||||
peak = Math.max(peak, ++active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
if (method === "DOMSnapshot.captureSnapshot") {
|
||||
expect(active).toBe(0);
|
||||
expect(released).toBe(0);
|
||||
}
|
||||
if (method === "Page.createIsolatedWorld") {
|
||||
expect(logs.filter((call) => call.method === "Accessibility.getFullAXTree")).toHaveLength(
|
||||
count,
|
||||
);
|
||||
}
|
||||
const result = await original(target, method, params);
|
||||
if (
|
||||
method === "Runtime.releaseObjectGroup" &&
|
||||
String((params as { objectGroup?: string })?.objectGroup).startsWith(
|
||||
"bsk-document-identity-",
|
||||
)
|
||||
) {
|
||||
active--;
|
||||
released++;
|
||||
}
|
||||
return result as never;
|
||||
};
|
||||
cdp.send = (tabId, method, params) => cdp.sendToTarget!({ tabId }, method, params);
|
||||
const facts = await captureObservationFacts(cdp, 4);
|
||||
expect(facts.documents.every((doc) => doc.identity)).toBe(true);
|
||||
expect(peak).toBe(Math.min(count, 4));
|
||||
expect(active).toBe(0);
|
||||
expect(released).toBe(count);
|
||||
for (const method of [
|
||||
"Page.createIsolatedWorld",
|
||||
"Runtime.evaluate",
|
||||
"Runtime.releaseObjectGroup",
|
||||
])
|
||||
expect(logs.filter((call) => call.method === method)).toHaveLength(count);
|
||||
expect(logs.filter((call) => call.method === "DOMSnapshot.captureSnapshot")).toHaveLength(1);
|
||||
expect(logs.filter((call) => call.method === "Page.getFrameTree")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("joins identity cleanup on cancellation without starting queued frames", async () => {
|
||||
const frames: CdpFrame[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
frameId: `frame-${i}`,
|
||||
target: { tabId: 4 },
|
||||
...(i ? { parentFrameId: "frame-0", ownerBackendNodeId: 90000 + i } : {}),
|
||||
}));
|
||||
const { cdp, logs } = fixture({ frames });
|
||||
const original = cdp.sendToTarget!;
|
||||
const controller = new AbortController();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let ready!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
ready = resolve;
|
||||
});
|
||||
let held = 0;
|
||||
cdp.sendToTarget = async (target, method, params) => {
|
||||
const result = await original(target, method, params);
|
||||
if (
|
||||
method === "Runtime.releaseObjectGroup" &&
|
||||
String((params as { objectGroup?: string })?.objectGroup).startsWith(
|
||||
"bsk-document-identity-",
|
||||
)
|
||||
) {
|
||||
if (++held === 4) ready();
|
||||
await gate;
|
||||
}
|
||||
return result as never;
|
||||
};
|
||||
cdp.send = (tabId, method, params) => cdp.sendToTarget!({ tabId }, method, params);
|
||||
let settled = false;
|
||||
const pending = captureObservationFacts(cdp, 4, controller.signal).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
const rejected = expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
await blocked;
|
||||
controller.abort();
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
release();
|
||||
await rejected;
|
||||
expect(held).toBe(4);
|
||||
expect(logs.filter((call) => call.method === "Page.createIsolatedWorld")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ after: { same: { element: 99 } } }, "omitted", false],
|
||||
[{ after: { same: { unreadable: true } } }, "omitted", false],
|
||||
[{ missingIdentity: true }, "document identity unverified", true],
|
||||
[{}, "", true],
|
||||
] as const)("publishes identity integrity without silently losing content %j", async (options, notice, retained) => {
|
||||
const { cdp } = fixture(options);
|
||||
const result = await captureVomObservation(cdp, 4, "https://example.com");
|
||||
expect(result.frames.some((frame) => frame.frameId === "same")).toBe(retained);
|
||||
if (notice) expect(result.text).toContain(notice);
|
||||
else expect(result.text).not.toContain("identity");
|
||||
if (!retained) expect(result.text).not.toContain("document identity unverified");
|
||||
});
|
||||
|
||||
it("does not retry failed or missing documents and retains valid AX-only semantics", async () => {
|
||||
const { cdp, logs } = fixture({
|
||||
fail: "remote:DOMSnapshot.captureSnapshot",
|
||||
@@ -465,12 +151,6 @@ describe("captureObservationFacts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps missing identity explicit without manufacturing a verified document", async () => {
|
||||
const facts = await captureObservationFacts(fixture({ missingIdentity: true }).cdp, 4);
|
||||
expect(facts.documents.every((doc) => !doc.identity)).toBe(true);
|
||||
expect(facts.issues.filter((issue) => issue.reason === "identity-unverified")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("excludes overlay AX in its own document without excluding equal IDs elsewhere", async () => {
|
||||
const facts = await captureObservationFacts(fixture({ overlay: "remote" }).cdp, 4);
|
||||
const { documents } = semanticCapture(facts);
|
||||
@@ -559,7 +239,9 @@ describe("captureObservationFacts", () => {
|
||||
await vi.waitFor(() => {
|
||||
if (mode === "worker-abort")
|
||||
expect(
|
||||
calls.some((call) => call.target === "s3" && call.method === "Accessibility.enable"),
|
||||
calls.some(
|
||||
(call) => call.target === "s3" && call.method === "Accessibility.getFullAXTree",
|
||||
),
|
||||
).toBe(true);
|
||||
else
|
||||
expect(
|
||||
@@ -626,8 +308,6 @@ function childSnapshot(frameId: string, backendNodeId: number) {
|
||||
strings,
|
||||
documents: [
|
||||
{
|
||||
scrollOffsetX: 0,
|
||||
scrollOffsetY: 0,
|
||||
frameId,
|
||||
nodes: {
|
||||
parentIndex: [-1, 0],
|
||||
@@ -655,13 +335,8 @@ function childSnapshot(frameId: string, backendNodeId: number) {
|
||||
describe("OOPIF capture", () => {
|
||||
it("captures and positions multiple OOPIF documents missing from the root snapshot", async () => {
|
||||
const sendToTarget = vi.fn(async (target, method) => {
|
||||
if (method === "Runtime.evaluate") return { result: { value: { width: 300, height: 200 } } };
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 300, clientHeight: 200, pageX: 0, pageY: 0 },
|
||||
};
|
||||
return { cssLayoutViewport: { clientWidth: 300, clientHeight: 200, pageX: 0, pageY: 0 } };
|
||||
}
|
||||
if (method === "DOMSnapshot.enable" || method === "Accessibility.enable") return {};
|
||||
if (method === "DOMSnapshot.captureSnapshot") {
|
||||
@@ -683,11 +358,7 @@ describe("OOPIF capture", () => {
|
||||
return { model: { content: [x, 100, x + 300, 100, x + 300, 300, x, 300] } };
|
||||
}
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 },
|
||||
};
|
||||
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } };
|
||||
}
|
||||
throw new Error(`unexpected root ${method}`);
|
||||
}) as CdpRunner["send"],
|
||||
@@ -765,11 +436,7 @@ describe("OOPIF capture", () => {
|
||||
};
|
||||
const reply = async (_target: unknown, method: string) => {
|
||||
if (method === "Page.getLayoutMetrics")
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 300, clientHeight: 200 },
|
||||
};
|
||||
return { cssLayoutViewport: { clientWidth: 300, clientHeight: 200 } };
|
||||
if (method === "DOMSnapshot.captureSnapshot")
|
||||
return typeof _target === "number" ? childSnapshot("main", 11) : snapshot;
|
||||
if (method === "DOM.getBoxModel")
|
||||
@@ -827,11 +494,7 @@ describe("OOPIF capture", () => {
|
||||
if (method === "Accessibility.getFullAXTree") return { nodes: [] };
|
||||
if (method === "DOM.getBoxModel") throw new Error("owner geometry unavailable");
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 },
|
||||
};
|
||||
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } };
|
||||
}
|
||||
throw new Error(`unexpected root ${method}`);
|
||||
}) as CdpRunner["send"],
|
||||
@@ -874,7 +537,7 @@ describe("OOPIF capture", () => {
|
||||
expect.objectContaining({ backendDOMNodeId: 101, frameId: "child" }),
|
||||
]);
|
||||
expect(childDocument?.domNodes.find((node) => node.backendNodeId === 101)).toEqual(
|
||||
expect.objectContaining({ rect: null, localRect: null, rendered: true }),
|
||||
expect.objectContaining({ rect: null, localRect: { x: 10, y: 20, w: 100, h: 40 } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -884,8 +547,6 @@ function siblingCaptureFixture(
|
||||
beforeReply: (method: string, params: Record<string, unknown>) => Promise<void> = async () => {},
|
||||
) {
|
||||
const document = (id: number, owners: number[], childIndexes: number[]) => ({
|
||||
scrollOffsetX: 0,
|
||||
scrollOffsetY: 0,
|
||||
frameId: `frame-${id}`,
|
||||
nodes: {
|
||||
parentIndex: [-1, ...owners.map(() => 0)],
|
||||
@@ -917,11 +578,7 @@ function siblingCaptureFixture(
|
||||
await beforeReply(method, args);
|
||||
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
||||
if (method === "Page.getLayoutMetrics")
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 },
|
||||
};
|
||||
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } };
|
||||
if (method === "DOM.getBoxModel")
|
||||
return { model: { content: [0, 0, 200, 0, 200, 100, 0, 100] } };
|
||||
if (method === "DOM.resolveNode") return { object: { objectId: String(args.backendNodeId) } };
|
||||
@@ -1125,11 +782,7 @@ describe("snapshot document provenance", () => {
|
||||
(method === "DOMSnapshot.captureSnapshot"
|
||||
? { ...f.snapshot, documents: [f.snapshot.documents[1]] }
|
||||
: method === "Page.getLayoutMetrics"
|
||||
? {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 200, clientHeight: 100 },
|
||||
}
|
||||
? { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } }
|
||||
: {}) as T,
|
||||
};
|
||||
const facts = await captureObservationFacts(cdp, 4);
|
||||
@@ -1145,44 +798,6 @@ describe("snapshot document provenance", () => {
|
||||
});
|
||||
|
||||
describe("sibling frame measurement scheduling", () => {
|
||||
it("batches same-target owner sizes without changing projected results", async () => {
|
||||
const fixture = siblingCaptureFixture();
|
||||
const original = fixture.cdp.send;
|
||||
const methods: string[] = [];
|
||||
fixture.cdp.send = async (tabId, method, params) => {
|
||||
methods.push(method);
|
||||
if (method === "Runtime.evaluate")
|
||||
return {
|
||||
result: {
|
||||
deepSerializedValue: {
|
||||
type: "array",
|
||||
value: [100, 101, 102, 103, 104, 105, 200].map((backendNodeId) => ({
|
||||
type: "array",
|
||||
value: [
|
||||
{ type: "node", value: { backendNodeId } },
|
||||
{ type: "string", value: JSON.stringify({ width: 200, height: 100 }) },
|
||||
],
|
||||
})),
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
return original(tabId, method, params);
|
||||
};
|
||||
const captured = await captureObservationFacts(fixture.cdp, 4);
|
||||
expect(captured.issues.filter((issue) => issue.stage === "geometry")).toEqual([]);
|
||||
expect(captured.documents.map((doc) => doc.frame.frameId)).toEqual(
|
||||
Array.from({ length: 8 }, (_, i) => `frame-${i}`),
|
||||
);
|
||||
expect(
|
||||
captured.documents.find((doc) => doc.frame.frameId === "frame-7")?.domNodes[0].rect,
|
||||
).toEqual({ x: 0, y: 0, w: 200, h: 100 });
|
||||
expect(methods.filter((method) => method === "DOM.getBoxModel")).toHaveLength(7);
|
||||
expect(methods.filter((method) => method === "Runtime.evaluate")).toHaveLength(1);
|
||||
expect(methods.filter((method) => method === "Runtime.releaseObjectGroup")).toHaveLength(1);
|
||||
expect(methods).not.toContain("DOM.resolveNode");
|
||||
expect(methods).not.toContain("Runtime.callFunctionOn");
|
||||
});
|
||||
|
||||
it("bounds concurrent reads, fills free slots and preserves breadth-first output despite reordered replies", async () => {
|
||||
const pending = new Map<number, () => void>();
|
||||
const fixture = siblingCaptureFixture(async (method, params) => {
|
||||
@@ -1297,69 +912,3 @@ describe("sibling frame measurement scheduling", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AX frame scheduling", () => {
|
||||
it.each([
|
||||
false,
|
||||
true,
|
||||
])("shares four workers across frames and drains cancellation=%s", async (cancel) => {
|
||||
const f = siblingCaptureFixture();
|
||||
const original = f.cdp.send;
|
||||
const pending = new Map<string, () => void>();
|
||||
let active = 0,
|
||||
peak = 0;
|
||||
f.cdp.send = async (tabId, method, params) => {
|
||||
if (method !== "Accessibility.getFullAXTree") return original(tabId, method, params);
|
||||
const frameId = (params as { frameId: string }).frameId;
|
||||
active++;
|
||||
peak = Math.max(peak, active);
|
||||
try {
|
||||
await new Promise<void>((resolve) => pending.set(frameId, resolve));
|
||||
if (frameId === "frame-2") throw new Error("one AX frame unavailable");
|
||||
return { nodes: [{ nodeId: frameId, frameId }] } as never;
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
};
|
||||
const controller = new AbortController();
|
||||
let settled = false;
|
||||
const capture = captureObservationFacts(f.cdp, 4, controller.signal);
|
||||
const outcome = capture.then(
|
||||
(facts) => ({ facts, error: undefined }),
|
||||
(error) => ({ facts: undefined, error }),
|
||||
);
|
||||
void outcome.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await vi.waitFor(() => expect(pending.size).toBe(4));
|
||||
if (cancel) {
|
||||
controller.abort();
|
||||
pending.get("frame-3")!();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(settled).toBe(false);
|
||||
expect(pending.size).toBe(4);
|
||||
for (const release of pending.values()) release();
|
||||
expect((await outcome).error).toMatchObject({ name: "AbortError" });
|
||||
} else {
|
||||
for (let i = 3; i < 7; i++) {
|
||||
pending.get(`frame-${i}`)!();
|
||||
await vi.waitFor(() => expect(pending.has(`frame-${i + 1}`)).toBe(true));
|
||||
}
|
||||
for (const release of pending.values()) release();
|
||||
const { facts, error } = await outcome;
|
||||
expect(error).toBeUndefined();
|
||||
expect(facts?.documents.map((doc) => doc.frame.frameId)).toEqual(
|
||||
Array.from({ length: 8 }, (_, i) => `frame-${i}`),
|
||||
);
|
||||
expect(facts?.documents.find((doc) => doc.frame.frameId === "frame-2")?.axNodes).toEqual([]);
|
||||
expect(facts?.issues).toContainEqual(
|
||||
expect.objectContaining({ stage: "ax", frameId: "frame-2" }),
|
||||
);
|
||||
expect(facts?.documents.find((doc) => doc.frame.frameId === "frame-7")?.axNodes).toHaveLength(
|
||||
1,
|
||||
);
|
||||
}
|
||||
expect(peak).toBe(4);
|
||||
expect(active).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -215,11 +215,7 @@ function makeCdp(snapshot: unknown) {
|
||||
if (method === "DOMSnapshot.enable") return {};
|
||||
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||
};
|
||||
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 } };
|
||||
}
|
||||
if (method === "Runtime.evaluate") {
|
||||
return {
|
||||
@@ -281,8 +277,6 @@ describe("snapshot capture and hover", () => {
|
||||
if (method === "DOMSnapshot.captureSnapshot") return hoverTriggerSnapshotReply();
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||
};
|
||||
}
|
||||
@@ -346,8 +340,6 @@ describe("snapshot capture and hover", () => {
|
||||
if (method === "DOMSnapshot.captureSnapshot") return twoHoverTriggerSnapshotReply();
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||
};
|
||||
}
|
||||
@@ -396,8 +388,6 @@ describe("snapshot capture and hover", () => {
|
||||
if (method === "DOMSnapshot.captureSnapshot") return nestedHoverTriggerSnapshotReply();
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||
};
|
||||
}
|
||||
@@ -743,8 +733,6 @@ describe("snapshot capture and hover", () => {
|
||||
if (method === "DOMSnapshot.captureSnapshot") return fakeSnapshotReply();
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 200 },
|
||||
};
|
||||
}
|
||||
@@ -757,7 +745,7 @@ describe("snapshot capture and hover", () => {
|
||||
expect(div?.rect).toMatchObject({ y: 0, h: 600 });
|
||||
});
|
||||
|
||||
it("normalizes raw snapshot bounds before viewport clipping", async () => {
|
||||
it("keeps CSS snapshot bounds independent of legacy metrics ratio", async () => {
|
||||
const S = ["html", "body", "div", "position", "fixed", "static", "pointer-events", "auto"];
|
||||
const i = (s: string) => S.indexOf(s);
|
||||
const snapshot = {
|
||||
@@ -792,8 +780,6 @@ describe("snapshot capture and hover", () => {
|
||||
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 2000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||
layoutViewport: { clientWidth: 2000, clientHeight: 1600 },
|
||||
};
|
||||
@@ -811,8 +797,8 @@ describe("snapshot capture and hover", () => {
|
||||
expect(nodes.find((n) => n.backendNodeId === 12)?.localRect).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 1000,
|
||||
h: 800,
|
||||
w: 2000,
|
||||
h: 1600,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -870,8 +856,6 @@ describe("snapshot capture and hover", () => {
|
||||
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||
};
|
||||
}
|
||||
@@ -962,8 +946,6 @@ describe("snapshot capture and hover", () => {
|
||||
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||
};
|
||||
}
|
||||
@@ -1053,8 +1035,6 @@ describe("snapshot capture and hover", () => {
|
||||
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||
};
|
||||
}
|
||||
@@ -1117,8 +1097,6 @@ describe("snapshot capture and hover", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
scrollOffsetX: 0,
|
||||
scrollOffsetY: 0,
|
||||
frameId: "child",
|
||||
nodes: {
|
||||
parentIndex: [-1, 0, 1],
|
||||
@@ -1141,8 +1119,6 @@ describe("snapshot capture and hover", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
scrollOffsetX: 0,
|
||||
scrollOffsetY: 0,
|
||||
frameId: "nested",
|
||||
nodes: {
|
||||
parentIndex: [-1, 0],
|
||||
@@ -1170,8 +1146,6 @@ describe("snapshot capture and hover", () => {
|
||||
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||
};
|
||||
}
|
||||
@@ -1251,8 +1225,6 @@ describe("snapshot capture and hover", () => {
|
||||
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 2000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 100 },
|
||||
layoutViewport: { clientWidth: 2000, clientHeight: 1600 },
|
||||
};
|
||||
@@ -1261,7 +1233,7 @@ describe("snapshot capture and hover", () => {
|
||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||
};
|
||||
const { nodes } = semanticCapture(await captureObservationFacts(cdp, 4)).captured;
|
||||
expect(nodes.find((n) => n.backendNodeId === 12)?.rect?.y).toBe(400 / 2 - 100);
|
||||
expect(nodes.find((n) => n.backendNodeId === 12)?.rect?.y).toBe(400 - 100);
|
||||
});
|
||||
|
||||
it("collectOverlayExcludedBackendIds walks the pierced overlay host subtree", async () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { OVERLAY_HOST_MARKER_ATTR } from "@/lib/overlay-bridge";
|
||||
import { createCaptureCheckpoint } from "../capture-abort";
|
||||
import { buildDocumentIndex, type DecodedNode } from "../facts";
|
||||
import { decodeDocument, REQUESTED_STYLES } from "../snapshot";
|
||||
|
||||
@@ -77,7 +76,7 @@ describe("document facts", () => {
|
||||
);
|
||||
expect(nodes[0].attrs).toEqual({ "aria-hidden": "true", inert: "" });
|
||||
expect(nodes[0].layout).toEqual({
|
||||
boundsSpace: "snapshot-document-layout",
|
||||
boundsSpace: "snapshot-document-css",
|
||||
bounds: [10, 20, 120, 40],
|
||||
styles: {
|
||||
position: "static",
|
||||
@@ -91,23 +90,7 @@ describe("document facts", () => {
|
||||
expect(nodes[0]).not.toHaveProperty("excluded");
|
||||
});
|
||||
|
||||
it("keeps short blocks synchronous and checks cancellation after yielding", async () => {
|
||||
const clock = vi.spyOn(performance, "now").mockReturnValue(0);
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const checkpoint = createCaptureCheckpoint(controller.signal);
|
||||
for (let i = 0; i < 20; i++) expect(checkpoint()).toBeUndefined();
|
||||
clock.mockReturnValue(9);
|
||||
const pending = checkpoint();
|
||||
expect(pending).toBeInstanceOf(Promise);
|
||||
controller.abort();
|
||||
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("checks cancellation at bounded indexing blocks", async () => {
|
||||
it("yields during large inputs so cancellation interrupts indexing", async () => {
|
||||
const controller = new AbortController();
|
||||
const input = Array.from({ length: 100_000 }, (_, i) => node(i, i ? i - 1 : null));
|
||||
let reads = 0;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { type CdpRunner, cdpRunnerForTarget } from "../../shared";
|
||||
import { enrichFormControlStates } from "../form-capture";
|
||||
import { decodeDocument } from "../snapshot";
|
||||
import { captureObservationFacts } from "../capture-coordinator";
|
||||
|
||||
interface Control {
|
||||
id: number;
|
||||
@@ -78,32 +77,21 @@ function reply(entries: Array<[number, unknown]>) {
|
||||
};
|
||||
}
|
||||
|
||||
async function fakeCdp(
|
||||
groups: Control[][],
|
||||
evaluate: (params: Record<string, unknown>) => unknown,
|
||||
) {
|
||||
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 {};
|
||||
});
|
||||
const nodes = await decodeControls(snap);
|
||||
return { send, cdp: { send: send as CdpRunner["send"] }, nodes };
|
||||
}
|
||||
|
||||
async function decodeControls(snap: ReturnType<typeof snapshot>) {
|
||||
return Promise.all(
|
||||
snap.documents.map(async (doc) =>
|
||||
(await decodeDocument(doc, snap.strings)).nodes.map((node) => ({ ...node, rect: null })),
|
||||
),
|
||||
);
|
||||
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, nodes } = await fakeCdp(
|
||||
const { cdp } = fakeCdp(
|
||||
[
|
||||
[
|
||||
{ id: 10, value: "" },
|
||||
@@ -118,21 +106,23 @@ describe("form state identity", () => {
|
||||
[11, state("alice@example.com")],
|
||||
]),
|
||||
);
|
||||
await enrichFormControlStates(cdp, 7, nodes);
|
||||
expect(nodes[0].find((n) => n.backendNodeId === 10)).toMatchObject({
|
||||
const captured = await captureObservationFacts(cdp, 7);
|
||||
expect(captured.documents[0].domNodes.find((n) => n.backendNodeId === 10)).toMatchObject({
|
||||
formValue: "",
|
||||
formState: "empty",
|
||||
formPlaceholder: "snapshot placeholder",
|
||||
});
|
||||
expect(nodes[0].find((n) => n.backendNodeId === 11)).toMatchObject({
|
||||
expect(captured.documents[0].domNodes.find((n) => n.backendNodeId === 11)).toMatchObject({
|
||||
formValue: "alice@example.com",
|
||||
});
|
||||
expect(nodes[0].find((n) => n.backendNodeId === 12)).toMatchObject({ formValue: "上海" });
|
||||
expect(nodes[0].some((n) => n.backendNodeId === 99)).toBe(false);
|
||||
expect(captured.documents[0].domNodes.find((n) => n.backendNodeId === 12)).toMatchObject({
|
||||
formValue: "上海",
|
||||
});
|
||||
expect(captured.documents[0].domNodes.some((n) => n.backendNodeId === 99)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not shift state between frames when one frame is absent from the runtime batch", async () => {
|
||||
const { cdp, nodes } = await fakeCdp(
|
||||
const { cdp } = fakeCdp(
|
||||
[
|
||||
[],
|
||||
[{ id: 11, value: "inaccessible frame" }],
|
||||
@@ -145,8 +135,10 @@ describe("form state identity", () => {
|
||||
[21, state("second frame")],
|
||||
]),
|
||||
);
|
||||
await enrichFormControlStates(cdp, 7, nodes);
|
||||
const controls = nodes.slice(1).flat();
|
||||
const captured = await captureObservationFacts(cdp, 7);
|
||||
const controls = captured.documents
|
||||
.filter((doc) => doc.frame.parentFrameId)
|
||||
.flatMap((doc) => doc.domNodes);
|
||||
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");
|
||||
@@ -154,6 +146,7 @@ describe("form state identity", () => {
|
||||
|
||||
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 {};
|
||||
});
|
||||
@@ -162,9 +155,13 @@ describe("form state identity", () => {
|
||||
});
|
||||
const cdp = { send, sendToTarget } as unknown as CdpRunner;
|
||||
for (const sessionId of ["left-frame", "right-frame"]) {
|
||||
const nodes = await decodeControls(snapshot([[{ id: 11, value: "old" }]]));
|
||||
await enrichFormControlStates(cdpRunnerForTarget(cdp, { tabId: 7, sessionId }), 7, nodes);
|
||||
expect(nodes[0].find((n) => n.backendNodeId === 11)?.formValue).toBe(sessionId);
|
||||
const captured = await captureObservationFacts(
|
||||
cdpRunnerForTarget(cdp, { tabId: 7, sessionId }),
|
||||
7,
|
||||
);
|
||||
expect(captured.documents[0].domNodes.find((n) => n.backendNodeId === 11)?.formValue).toBe(
|
||||
sessionId,
|
||||
);
|
||||
expect(sendToTarget).toHaveBeenCalledWith(
|
||||
{ tabId: 7, sessionId },
|
||||
"Runtime.releaseObjectGroup",
|
||||
@@ -180,7 +177,7 @@ describe("form state identity", () => {
|
||||
"malformed",
|
||||
"invalid-state",
|
||||
])("preserves snapshot data when enrichment is %s", async (mode) => {
|
||||
const { cdp, send, nodes } = await fakeCdp([[{ id: 11, value: "snapshot value" }]], () => {
|
||||
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([
|
||||
@@ -190,8 +187,8 @@ describe("form state identity", () => {
|
||||
result.result.deepSerializedValue.value[0].value[1].value = "not json";
|
||||
return result;
|
||||
});
|
||||
expect(await enrichFormControlStates(cdp, 7, nodes)).toBe(mode !== "unsupported");
|
||||
expect(nodes[0].find((n) => n.backendNodeId === 11)).toMatchObject({
|
||||
const captured = await captureObservationFacts(cdp, 7);
|
||||
expect(captured.documents[0].domNodes.find((n) => n.backendNodeId === 11)).toMatchObject({
|
||||
formValue: "snapshot value",
|
||||
formPlaceholder: "snapshot placeholder",
|
||||
});
|
||||
@@ -200,34 +197,18 @@ describe("form state identity", () => {
|
||||
|
||||
it("releases the batch's remote objects when capture is cancelled", async () => {
|
||||
const controller = new AbortController();
|
||||
const { cdp, send, nodes } = await fakeCdp([[{ id: 11, value: "old" }]], () => {
|
||||
const { cdp, send } = fakeCdp([[{ id: 11, value: "old" }]], () => {
|
||||
controller.abort();
|
||||
return reply([[11, state("new")]]);
|
||||
});
|
||||
await expect(enrichFormControlStates(cdp, 7, nodes, controller.signal)).rejects.toMatchObject({
|
||||
await expect(captureObservationFacts(cdp, 7, controller.signal)).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
});
|
||||
expect(send).toHaveBeenCalledWith(7, "Runtime.releaseObjectGroup", expect.anything());
|
||||
});
|
||||
|
||||
it("does not report success when cancelled during object cleanup", async () => {
|
||||
const controller = new AbortController();
|
||||
const nodes = await decodeControls(snapshot([[{ id: 11, value: "old" }]]));
|
||||
const cdp: CdpRunner = {
|
||||
send: vi.fn(async (_tabId, method) => {
|
||||
if (method === "Runtime.evaluate") return reply([[11, state("new")]]);
|
||||
if (method === "Runtime.releaseObjectGroup") controller.abort();
|
||||
return {};
|
||||
}) as CdpRunner["send"],
|
||||
};
|
||||
await expect(enrichFormControlStates(cdp, 7, nodes, controller.signal)).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
});
|
||||
expect(cdp.send).toHaveBeenCalledWith(7, "Runtime.releaseObjectGroup", expect.anything());
|
||||
});
|
||||
|
||||
it("skips entries without node identity while still applying valid entries", async () => {
|
||||
const { cdp, nodes } = await fakeCdp(
|
||||
const { cdp } = fakeCdp(
|
||||
[
|
||||
[
|
||||
{ id: 11, value: "snapshot value" },
|
||||
@@ -244,20 +225,24 @@ describe("form state identity", () => {
|
||||
return result;
|
||||
},
|
||||
);
|
||||
await enrichFormControlStates(cdp, 7, nodes);
|
||||
expect(nodes[0].find((n) => n.backendNodeId === 11)?.formValue).toBe("snapshot value");
|
||||
expect(nodes[0].find((n) => n.backendNodeId === 12)?.formValue).toBe("live value");
|
||||
const captured = await captureObservationFacts(cdp, 7);
|
||||
expect(captured.documents[0].domNodes.find((n) => n.backendNodeId === 11)?.formValue).toBe(
|
||||
"snapshot value",
|
||||
);
|
||||
expect(captured.documents[0].domNodes.find((n) => n.backendNodeId === 12)?.formValue).toBe(
|
||||
"live value",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"password",
|
||||
"text",
|
||||
])("redacts a sensitive %s control after identity matching", async (type) => {
|
||||
const { cdp, nodes } = await fakeCdp([[{ id: 11, value: "secret", type }]], () =>
|
||||
const { cdp } = fakeCdp([[{ id: 11, value: "secret", type }]], () =>
|
||||
reply([[11, { ...state("secret"), sensitive: true }]]),
|
||||
);
|
||||
await enrichFormControlStates(cdp, 7, nodes);
|
||||
const control = nodes[0].find((n) => n.backendNodeId === 11);
|
||||
const captured = await captureObservationFacts(cdp, 7);
|
||||
const control = captured.documents[0].domNodes.find((n) => n.backendNodeId === 11);
|
||||
expect(control?.formState).toBe("filled");
|
||||
expect(control?.formValue).toBeUndefined();
|
||||
expect(control?.formDefaultValue).toBeUndefined();
|
||||
@@ -274,7 +259,7 @@ describe("form state identity", () => {
|
||||
defaultValue: "",
|
||||
placeholder: "",
|
||||
}));
|
||||
const { cdp, send, nodes } = await fakeCdp([controls], (params) => {
|
||||
const { cdp, send } = fakeCdp([controls], (params) => {
|
||||
const doc = {
|
||||
querySelectorAll: (selector: string) =>
|
||||
selector === "input,textarea,select" ? elements : [],
|
||||
@@ -285,9 +270,13 @@ describe("form state identity", () => {
|
||||
expect(entries).toHaveLength(250);
|
||||
return reply(entries.map(([element, json]) => [element.backendNodeId, JSON.parse(json)]));
|
||||
});
|
||||
await enrichFormControlStates(cdp, 7, nodes);
|
||||
expect(nodes[0].find((n) => n.backendNodeId === 250)?.formValue).toBe("live 250");
|
||||
expect(nodes[0].find((n) => n.backendNodeId === 251)?.formValue).toBe("old");
|
||||
const captured = await captureObservationFacts(cdp, 7);
|
||||
expect(captured.documents[0].domNodes.find((n) => n.backendNodeId === 250)?.formValue).toBe(
|
||||
"live 250",
|
||||
);
|
||||
expect(captured.documents[0].domNodes.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"),
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
function captureAbortError(): Error {
|
||||
const error = new Error("observation aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
(error as { name?: string }).name === "AbortError"
|
||||
);
|
||||
}
|
||||
|
||||
export function throwIfAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) throw captureAbortError();
|
||||
}
|
||||
|
||||
/** Check at bounded work blocks; yield only after a processing slice is spent.
|
||||
* Each caller owns its deadline, so unrelated captures do not share state. */
|
||||
export function createCaptureCheckpoint(signal?: AbortSignal): () => Promise<void> | undefined {
|
||||
throwIfAborted(signal);
|
||||
let deadline = performance.now() + 8;
|
||||
return () => {
|
||||
throwIfAborted(signal);
|
||||
if (performance.now() < deadline) return;
|
||||
return new Promise<void>((resolve) => {
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.onmessage = () => {
|
||||
channel.port1.close();
|
||||
channel.port2.close();
|
||||
resolve();
|
||||
};
|
||||
channel.port2.postMessage(null);
|
||||
}).then(() => {
|
||||
throwIfAborted(signal);
|
||||
deadline = performance.now() + 8;
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -7,13 +7,14 @@ import {
|
||||
import { cssViewport, GeometryContext } from "../geometry/frame-context";
|
||||
import { type CdpRunner, cdpRunnerForTarget } from "../shared";
|
||||
import { collectOverlayExcludedBackendIds } from "./capture";
|
||||
import type { CapturedSceneInput } from "./facts";
|
||||
import {
|
||||
isAbortError as isCaptureAbort,
|
||||
throwIfAborted as throwCaptureAborted,
|
||||
} from "./capture-abort";
|
||||
import { verifyDocumentIdentity } from "./document-identity";
|
||||
import type { CapturedSceneInput, DocumentIdentity } from "./facts";
|
||||
import { buildDocumentIndex, type CaptureIssue, type ObservationFacts } from "./facts";
|
||||
buildDocumentIndex,
|
||||
type CaptureIssue,
|
||||
isCaptureAbort,
|
||||
type ObservationFacts,
|
||||
throwCaptureAborted,
|
||||
} from "./facts";
|
||||
import { enrichFormControlStates } from "./form-capture";
|
||||
import {
|
||||
buildFrameDocuments,
|
||||
@@ -21,14 +22,13 @@ import {
|
||||
type FrameDocument,
|
||||
type FrameOwnedAxNode,
|
||||
} from "./frame-document";
|
||||
import { type NormalizedFrameDocument, normalizeSnapshot } from "./normalize";
|
||||
import { type NormalizedDocument, normalizeSnapshot } from "./normalize";
|
||||
import { describeSnapshotFrames, REQUESTED_STYLES, type SnapshotReply } from "./snapshot";
|
||||
|
||||
interface TargetBatch<T extends FrameOwnedAxNode> {
|
||||
target: CdpTarget;
|
||||
frames: CdpFrame[];
|
||||
snapshotAttachmentId?: string;
|
||||
documents: NormalizedFrameDocument[];
|
||||
documents: NormalizedDocument[];
|
||||
ax: FrameAxBatch<T>[];
|
||||
fallbackExcluded: Set<number>;
|
||||
snapshotFrameIds?: Set<string>;
|
||||
@@ -37,7 +37,7 @@ interface TargetBatch<T extends FrameOwnedAxNode> {
|
||||
|
||||
/** A fixed worker pool scoped to one capture; each worker issues at most one
|
||||
* collection request at a time. Geometry has its own measurement-phase bound. */
|
||||
async function collectTasks<T>(
|
||||
async function collectTargets<T>(
|
||||
items: readonly T[],
|
||||
collect: (item: T) => Promise<void>,
|
||||
signal?: AbortSignal,
|
||||
@@ -114,8 +114,7 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
||||
const unavailable = (batch: TargetBatch<T>, stage: CaptureIssue["stage"], frameId?: string) =>
|
||||
issues.push({ target: batch.target, frameId, stage, reason: "capture-unavailable" });
|
||||
let firstFailure: unknown;
|
||||
const axReady = new Set<TargetBatch<T>>();
|
||||
await collectTasks(
|
||||
await collectTargets(
|
||||
batches,
|
||||
async (batch) => {
|
||||
const scoped = cdpRunnerForTarget(cdp, batch.target);
|
||||
@@ -123,7 +122,6 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
||||
throwCaptureAborted(signal);
|
||||
await scoped.send(tabId, "DOMSnapshot.enable", {});
|
||||
throwCaptureAborted(signal);
|
||||
batch.snapshotAttachmentId = cdp.getAttachmentId?.(tabId);
|
||||
const snapshot = await scoped.send<SnapshotReply>(tabId, "DOMSnapshot.captureSnapshot", {
|
||||
computedStyles: REQUESTED_STYLES,
|
||||
includePaintOrder: true,
|
||||
@@ -150,7 +148,7 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
||||
const formsAvailable = await enrichFormControlStates(
|
||||
scoped,
|
||||
tabId,
|
||||
batch.documents.map((doc) => doc.nodes),
|
||||
batch.documents.map((doc) => doc.domNodes),
|
||||
signal,
|
||||
);
|
||||
throwCaptureAborted(signal);
|
||||
@@ -183,48 +181,26 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
||||
unavailable(batch, "ax");
|
||||
return;
|
||||
}
|
||||
axReady.add(batch);
|
||||
},
|
||||
signal,
|
||||
);
|
||||
|
||||
// Schedule AX by frame across all ready targets, rather than nesting pools.
|
||||
// Keep result slots in source order even when CDP replies arrive out of order.
|
||||
const axTasks: { batch: TargetBatch<T>; frame: CdpFrame; result?: FrameAxBatch<T> }[] =
|
||||
batches.flatMap((batch) =>
|
||||
axReady.has(batch) ? batch.frames.map((frame) => ({ batch, frame })) : [],
|
||||
);
|
||||
await collectTasks(
|
||||
axTasks,
|
||||
async (task) => {
|
||||
const { batch, frame } = task;
|
||||
try {
|
||||
throwCaptureAborted(signal);
|
||||
const result = await cdpRunnerForTarget(cdp, batch.target).send<{ nodes?: T[] }>(
|
||||
tabId,
|
||||
"Accessibility.getFullAXTree",
|
||||
frame.frameId === "root" ? {} : { frameId: frame.frameId },
|
||||
);
|
||||
throwCaptureAborted(signal);
|
||||
task.result = { frame, nodes: result.nodes ?? [] };
|
||||
} catch (error) {
|
||||
throwCaptureAborted(signal);
|
||||
if (isCaptureAbort(error)) throw error;
|
||||
firstFailure ??= error;
|
||||
unavailable(batch, "ax", frame.frameId);
|
||||
for (const frame of batch.frames) {
|
||||
try {
|
||||
throwCaptureAborted(signal);
|
||||
const result = await scoped.send<{ nodes?: T[] }>(
|
||||
tabId,
|
||||
"Accessibility.getFullAXTree",
|
||||
frame.frameId === "root" ? {} : { frameId: frame.frameId },
|
||||
);
|
||||
throwCaptureAborted(signal);
|
||||
batch.ax.push({ frame, nodes: result.nodes ?? [] });
|
||||
} catch (error) {
|
||||
throwCaptureAborted(signal);
|
||||
if (isCaptureAbort(error)) throw error;
|
||||
firstFailure ??= error;
|
||||
unavailable(batch, "ax", frame.frameId);
|
||||
}
|
||||
}
|
||||
},
|
||||
signal,
|
||||
);
|
||||
for (const task of axTasks) if (task.result) task.batch.ax.push(task.result);
|
||||
|
||||
let viewport = { width: 0, height: 0 };
|
||||
try {
|
||||
const measured = cssViewport(await geometry.layoutMetrics({ tabId }));
|
||||
viewport = { width: measured.width, height: measured.height };
|
||||
} catch (error) {
|
||||
if (isCaptureAbort(error)) throw error;
|
||||
}
|
||||
|
||||
// A snapshot claim outranks an old graph hint. Conflicting actual claims
|
||||
// cannot be resolved by completion order or by overwriting a frameId map.
|
||||
@@ -246,56 +222,6 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
||||
!claims.has(frame.frameId) || claims.get(frame.frameId) === cdpTargetKey(batch.target),
|
||||
),
|
||||
);
|
||||
const rootBatch = batches.find((batch) => !batch.target.sessionId);
|
||||
const rootFrameId =
|
||||
rootBatch?.rootFrameId ?? graph?.rootFrameId ?? rootBatch?.frames[0]?.frameId ?? "root";
|
||||
const ownershipInvalid = new Set(invalid);
|
||||
const identities = new Map<string, DocumentIdentity>();
|
||||
const selected = new Map(currentFrames.map((frame) => [frame.frameId, frame]));
|
||||
const checks = batches.flatMap((batch) => {
|
||||
const docs = new Map(batch.documents.map((doc) => [doc.frame.frameId, doc]));
|
||||
return batch.frames
|
||||
.filter(
|
||||
(frame) =>
|
||||
!invalid.has(frame.frameId) &&
|
||||
cdpTargetKey(selected.get(frame.frameId)!.target) === cdpTargetKey(batch.target),
|
||||
)
|
||||
.map((frame) => ({ batch, frame, doc: docs.get(frame.frameId) }));
|
||||
});
|
||||
await collectTasks(
|
||||
checks,
|
||||
async ({ batch, frame, doc }) => {
|
||||
throwCaptureAborted(signal);
|
||||
if (!batch.snapshotAttachmentId || doc?.documentElementBackendNodeId === undefined) {
|
||||
issues.push({
|
||||
target: frame.target,
|
||||
frameId: frame.frameId,
|
||||
stage: "identity",
|
||||
reason: "identity-unverified",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const identity: DocumentIdentity = {
|
||||
attachmentId: batch.snapshotAttachmentId,
|
||||
target: frame.target,
|
||||
frameId: frame.frameId,
|
||||
documentElementBackendNodeId: doc.documentElementBackendNodeId,
|
||||
};
|
||||
const verified = await verifyDocumentIdentity(cdp, identity, signal);
|
||||
const status = cdp.getAttachmentId?.(tabId) === identity.attachmentId ? verified : "changed";
|
||||
if (status === "current") identities.set(frame.frameId, identity);
|
||||
else {
|
||||
invalid.add(frame.frameId);
|
||||
issues.push({
|
||||
target: frame.target,
|
||||
frameId: frame.frameId,
|
||||
stage: "identity",
|
||||
reason: status === "changed" ? "document-changed" : "identity-unavailable",
|
||||
});
|
||||
}
|
||||
},
|
||||
signal,
|
||||
);
|
||||
const children = new Map<string, string[]>();
|
||||
for (const frame of currentFrames) {
|
||||
if (!frame.parentFrameId) continue;
|
||||
@@ -313,7 +239,7 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
||||
}
|
||||
}
|
||||
for (const frame of currentFrames) {
|
||||
if (ownershipInvalid.has(frame.frameId))
|
||||
if (invalid.has(frame.frameId))
|
||||
issues.push({
|
||||
target: frame.target,
|
||||
frameId: frame.frameId,
|
||||
@@ -321,12 +247,10 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
||||
reason: "frame-ownership-unresolved",
|
||||
});
|
||||
}
|
||||
if (invalid.has(rootFrameId))
|
||||
throw new Error(
|
||||
ownershipInvalid.has(rootFrameId)
|
||||
? "observation root document ownership is ambiguous"
|
||||
: "observation document identity changed or could not be verified; observe again",
|
||||
);
|
||||
const rootBatch = batches.find((batch) => !batch.target.sessionId);
|
||||
const rootFrameId =
|
||||
rootBatch?.rootFrameId ?? graph?.rootFrameId ?? rootBatch?.frames[0]?.frameId ?? "root";
|
||||
if (invalid.has(rootFrameId)) throw new Error("observation root document ownership is ambiguous");
|
||||
const frames = currentFrames.filter((frame) => !invalid.has(frame.frameId));
|
||||
const frameById = new Map(frames.map((frame) => [frame.frameId, frame]));
|
||||
const belongs = (frame: CdpFrame) => {
|
||||
@@ -346,10 +270,7 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
||||
{
|
||||
nodes: [],
|
||||
rootFrameId,
|
||||
frameNodes: new Map([...decoded].map(([id, doc]) => [id, doc.nodes])),
|
||||
frameExcludedBackendNodeIds: new Map(
|
||||
[...decoded].map(([id, doc]) => [id, doc.index.excludedBackendNodeIds]),
|
||||
),
|
||||
frameNodes: new Map([...decoded].map(([id, doc]) => [id, doc.domNodes])),
|
||||
},
|
||||
signal,
|
||||
(frame) =>
|
||||
@@ -360,21 +281,21 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
||||
reason: "frame-ownership-unresolved",
|
||||
}),
|
||||
);
|
||||
let viewport = { width: 0, height: 0 };
|
||||
try {
|
||||
const measured = cssViewport(await geometry.layoutMetrics({ tabId }));
|
||||
viewport = { width: measured.width, height: measured.height };
|
||||
} catch (error) {
|
||||
if (isCaptureAbort(error)) throw error;
|
||||
}
|
||||
const facts: ObservationFacts<T>["documents"][number][] = [];
|
||||
for (const document of documents) {
|
||||
const doc = decoded.get(document.frameId);
|
||||
const index = doc?.index ?? (await buildDocumentIndex([], signal));
|
||||
const fallback = groups.get(cdpTargetKey(document.target))?.fallbackExcluded;
|
||||
const {
|
||||
domNodes,
|
||||
axNodes,
|
||||
contextScopeId: _scope,
|
||||
excludedBackendNodeIds: _excluded,
|
||||
...frame
|
||||
} = document;
|
||||
const { domNodes, axNodes, contextScopeId: _scope, ...frame } = document;
|
||||
facts.push({
|
||||
frame,
|
||||
identity: identities.get(document.frameId),
|
||||
index: fallback?.size ? { ...index, excludedBackendNodeIds: fallback } : index,
|
||||
domNodes,
|
||||
axNodes,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { Rect } from "@browser-skill/vom";
|
||||
|
||||
export interface CapturedNode {
|
||||
backendNodeId: number;
|
||||
parentBackendNodeId: number | null;
|
||||
frameId?: string;
|
||||
/** Owning iframe backend node id; `null` for the top-level document. */
|
||||
ownerFrameBackendNodeId?: number | null;
|
||||
tag: string;
|
||||
attrs: Record<string, string>;
|
||||
/** Top-level viewport-relative CSS px, clipped to the owning frame viewport. */
|
||||
rect: Rect | null;
|
||||
/** Frame-local viewport-relative CSS px before top-level projection. */
|
||||
localRect?: Rect | null;
|
||||
paintOrder: number;
|
||||
position: string;
|
||||
pointerEvents: string;
|
||||
/**
|
||||
* computed `cursor`. `cursor: pointer` is the strongest CDP-free signal
|
||||
* that a non-semantic element (a `<div>`/`<span>` with a click handler)
|
||||
* is actually an interactive control — used by the adapter to surface
|
||||
* custom buttons/checkboxes the AX tree drops as `generic`. Optional like
|
||||
* `textContent`: the live parser always sets it, hand-built fixtures may not.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
* Whether the live DOM snapshot provides a painted, non-hidden box for this
|
||||
* node. Semantic resolution uses this only for DOM fallback nodes; AX-backed
|
||||
* nodes remain authoritative even when they are outside the viewport.
|
||||
*/
|
||||
rendered?: boolean;
|
||||
textContent?: string;
|
||||
formState?: "empty" | "filled" | "default";
|
||||
formValue?: string;
|
||||
formDefaultValue?: string;
|
||||
formPlaceholder?: string;
|
||||
}
|
||||
@@ -3,15 +3,14 @@
|
||||
import { evaluateHoverTrigger } from "@/lib/hover-trigger-policy";
|
||||
import { OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge";
|
||||
import type { CdpRunner } from "../shared";
|
||||
import type { CapturedNode } from "./capture-types";
|
||||
import type { CapturedSurfaceProbe } from "./facts";
|
||||
import type { CapturedNode, CapturedSurfaceProbe } from "./facts";
|
||||
|
||||
export type {
|
||||
CapturedNode,
|
||||
CapturedSurfaceProbe,
|
||||
} from "./facts";
|
||||
|
||||
import { isAbortError, throwIfAborted } from "./capture-abort";
|
||||
import { isCaptureAbort as isAbortError, throwCaptureAborted as throwIfAborted } from "./facts";
|
||||
import { clearHover, ProbeBudget, waitForHover } from "./hover-perception";
|
||||
|
||||
interface RuntimeEvaluateReply {
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { CdpRunner } from "../shared";
|
||||
import { sendToCdpTarget } from "../shared";
|
||||
import { isAbortError, throwIfAborted } from "./capture-abort";
|
||||
import type { DocumentIdentity } from "./facts";
|
||||
|
||||
/** Compare the snapshot root with the current root in that exact frame.
|
||||
* Deep serialization supplies the backend ID without a separate describeNode. */
|
||||
export async function verifyDocumentIdentity(
|
||||
cdp: CdpRunner,
|
||||
identity: DocumentIdentity,
|
||||
signal?: AbortSignal,
|
||||
): Promise<"current" | "changed" | "unavailable"> {
|
||||
const attached = () => cdp.getAttachmentId?.(identity.target.tabId) === identity.attachmentId;
|
||||
throwIfAborted(signal);
|
||||
if (!attached()) return "changed";
|
||||
const objectGroup = `bsk-document-identity-${crypto.randomUUID()}`;
|
||||
const send = <T>(method: string, params: object) => {
|
||||
throwIfAborted(signal);
|
||||
return sendToCdpTarget<T>(cdp, identity.target, method, params);
|
||||
};
|
||||
try {
|
||||
const world = await send<{ executionContextId: number }>("Page.createIsolatedWorld", {
|
||||
frameId: identity.frameId,
|
||||
worldName: "bsk-document-identity",
|
||||
});
|
||||
const reply = await send<{
|
||||
result?: { deepSerializedValue?: { type: string; value?: { backendNodeId?: number } } };
|
||||
}>("Runtime.evaluate", {
|
||||
expression: "document.documentElement",
|
||||
contextId: world.executionContextId,
|
||||
objectGroup,
|
||||
serializationOptions: {
|
||||
serialization: "deep",
|
||||
additionalParameters: { maxNodeDepth: 0, includeShadowTree: "none" },
|
||||
},
|
||||
});
|
||||
if (!attached()) return "changed";
|
||||
const root = reply.result?.deepSerializedValue;
|
||||
if (root?.type === "null") return "changed";
|
||||
const id = root?.type === "node" ? root.value?.backendNodeId : undefined;
|
||||
if (id === undefined) return "unavailable";
|
||||
return id === identity.documentElementBackendNodeId ? "current" : "changed";
|
||||
} catch (error) {
|
||||
throwIfAborted(signal);
|
||||
if (isAbortError(error)) throw error;
|
||||
return attached() ? "unavailable" : "changed";
|
||||
} finally {
|
||||
await sendToCdpTarget(cdp, identity.target, "Runtime.releaseObjectGroup", {
|
||||
objectGroup,
|
||||
}).catch(() => {});
|
||||
throwIfAborted(signal);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,64 @@
|
||||
import type { Viewport } from "@browser-skill/vom";
|
||||
import type { Rect, Viewport } from "@browser-skill/vom";
|
||||
import type { CdpFrame, CdpTarget } from "@/browser-driver/frame-graph";
|
||||
import { isOverlayHostNode } from "@/lib/overlay-bridge";
|
||||
import type { FrameProjectionIssue } from "../geometry/coordinate-types";
|
||||
import { createCaptureCheckpoint } from "./capture-abort";
|
||||
import type { CapturedNode } from "./capture-types";
|
||||
import type { FrameOwnedAxNode } from "./frame-document";
|
||||
|
||||
/** Bounds retain their raw snapshot document layout units until normalization. */
|
||||
export interface CapturedNode {
|
||||
backendNodeId: number;
|
||||
parentBackendNodeId: number | null;
|
||||
frameId?: string;
|
||||
/** Owning iframe backend node id; `null` for the top-level document. */
|
||||
ownerFrameBackendNodeId?: number | null;
|
||||
tag: string;
|
||||
attrs: Record<string, string>;
|
||||
/** Top-level viewport-relative CSS px, clipped to the owning frame viewport. */
|
||||
rect: Rect | null;
|
||||
/** Frame-local viewport-relative CSS px before top-level projection. */
|
||||
localRect?: Rect | null;
|
||||
paintOrder: number;
|
||||
position: string;
|
||||
pointerEvents: string;
|
||||
/**
|
||||
* computed `cursor`. `cursor: pointer` is the strongest CDP-free signal
|
||||
* that a non-semantic element (a `<div>`/`<span>` with a click handler)
|
||||
* is actually an interactive control — used by the adapter to surface
|
||||
* custom buttons/checkboxes the AX tree drops as `generic`. Optional like
|
||||
* `textContent`: the live parser always sets it, hand-built fixtures may not.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
* Whether the live DOM snapshot provides a painted, non-hidden box for this
|
||||
* node. Semantic resolution uses this only for DOM fallback nodes; AX-backed
|
||||
* nodes remain authoritative even when they are outside the viewport.
|
||||
*/
|
||||
rendered?: boolean;
|
||||
textContent?: string;
|
||||
formState?: "empty" | "filled" | "default";
|
||||
formValue?: string;
|
||||
formDefaultValue?: string;
|
||||
formPlaceholder?: string;
|
||||
}
|
||||
|
||||
export interface CapturedSurfaceProbe {
|
||||
triggerBackendNodeId: number;
|
||||
triggerPoint?: { x: number; y: number };
|
||||
triggerAction: "hover" | "focus" | string;
|
||||
subItems: string[];
|
||||
confidence?: "high" | "medium" | "low";
|
||||
}
|
||||
|
||||
/** Narrow input shared by the existing semantic scene/hover consumers. */
|
||||
export interface CapturedSceneInput {
|
||||
nodes: CapturedNode[];
|
||||
viewport: Viewport;
|
||||
rootFrameId?: string;
|
||||
excludedBackendNodeIds: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
/** Bounds retain their snapshot document CSS units until normalization. */
|
||||
export interface SnapshotLayout {
|
||||
readonly boundsSpace: "snapshot-document-layout";
|
||||
readonly boundsSpace: "snapshot-document-css";
|
||||
bounds?: number[];
|
||||
styles: Readonly<Record<string, string>>;
|
||||
}
|
||||
@@ -32,40 +82,78 @@ export interface DecodedDocument {
|
||||
nodes: DecodedNode[];
|
||||
}
|
||||
|
||||
export interface CaptureIssue {
|
||||
/** Owner failure or inherited blockage; absent for other geometry failures. */
|
||||
projectionIssue?: FrameProjectionIssue;
|
||||
target: CdpTarget;
|
||||
frameId?: string;
|
||||
stage: "dom" | "ax" | "ownership" | "geometry" | "forms";
|
||||
reason: "capture-unavailable" | "frame-ownership-unresolved" | "geometry-unavailable";
|
||||
}
|
||||
|
||||
export interface DocumentFacts<T extends FrameOwnedAxNode> {
|
||||
readonly frame: CdpFrame;
|
||||
readonly index: DocumentIndex;
|
||||
readonly domNodes: CapturedNode[];
|
||||
readonly axNodes: T[];
|
||||
}
|
||||
|
||||
export interface ObservationFacts<T extends FrameOwnedAxNode> {
|
||||
readonly rootFrameId: string;
|
||||
readonly viewport: Viewport;
|
||||
readonly documents: readonly DocumentFacts<T>[];
|
||||
readonly issues: readonly CaptureIssue[];
|
||||
readonly startedAt: number;
|
||||
readonly finishedAt: number;
|
||||
}
|
||||
|
||||
export function throwCaptureAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) throw new DOMException("observation aborted", "AbortError");
|
||||
}
|
||||
|
||||
export function isCaptureAbort(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === "AbortError";
|
||||
}
|
||||
|
||||
/** Called only at bounded block boundaries, not once per node. */
|
||||
export async function captureCheckpoint(signal?: AbortSignal): Promise<void> {
|
||||
throwCaptureAborted(signal);
|
||||
await new Promise<void>((resolve) => {
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.onmessage = () => {
|
||||
channel.port1.close();
|
||||
channel.port2.close();
|
||||
resolve();
|
||||
};
|
||||
channel.port2.postMessage(null);
|
||||
});
|
||||
throwCaptureAborted(signal);
|
||||
}
|
||||
|
||||
/** All snapshot nodes participate, including document and shadow roots. This
|
||||
* preserves overlay propagation when the semantic adapter omits those nodes. */
|
||||
export async function buildDocumentIndex<T extends DecodedNode>(
|
||||
input: readonly T[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<DocumentIndex<T>> {
|
||||
const checkpoint = createCaptureCheckpoint(signal);
|
||||
const nodes = new Map<number, T>();
|
||||
const overlayByNode = new Map<number, boolean>();
|
||||
const excludedBackendNodeIds = new Set<number>();
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
if (i % 256 === 0) {
|
||||
const pending = checkpoint();
|
||||
if (pending) await pending;
|
||||
}
|
||||
if (i % 256 === 0) await captureCheckpoint(signal);
|
||||
const node = input[i];
|
||||
nodes.set(node.backendNodeId, node);
|
||||
}
|
||||
let work = 0;
|
||||
for (const node of input) {
|
||||
if (work++ % 256 === 0) {
|
||||
const pending = checkpoint();
|
||||
if (pending) await pending;
|
||||
}
|
||||
if (work++ % 256 === 0) await captureCheckpoint(signal);
|
||||
if (overlayByNode.has(node.backendNodeId)) continue;
|
||||
const path: DecodedNode[] = [];
|
||||
const visiting = new Set<number>();
|
||||
let current: DecodedNode | undefined = node;
|
||||
let overlay = false;
|
||||
while (current && !overlayByNode.has(current.backendNodeId)) {
|
||||
if (work++ % 256 === 0) {
|
||||
const pending = checkpoint();
|
||||
if (pending) await pending;
|
||||
}
|
||||
if (work++ % 256 === 0) await captureCheckpoint(signal);
|
||||
if (visiting.has(current.backendNodeId)) {
|
||||
// Only an overlay inside the cycle may mark the cycle and its descendants.
|
||||
overlay = path
|
||||
@@ -84,10 +172,7 @@ export async function buildDocumentIndex<T extends DecodedNode>(
|
||||
if (current && overlayByNode.has(current.backendNodeId))
|
||||
overlay = overlayByNode.get(current.backendNodeId)!;
|
||||
for (let i = path.length - 1; i >= 0; i--) {
|
||||
if (work++ % 256 === 0) {
|
||||
const pending = checkpoint();
|
||||
if (pending) await pending;
|
||||
}
|
||||
if (work++ % 256 === 0) await captureCheckpoint(signal);
|
||||
const item = path[i];
|
||||
overlay = overlay || isOverlayHostNode(item.tag, Object.keys(item.attrs));
|
||||
overlayByNode.set(item.backendNodeId, overlay);
|
||||
@@ -96,60 +181,3 @@ export async function buildDocumentIndex<T extends DecodedNode>(
|
||||
}
|
||||
return { nodes, excludedBackendNodeIds };
|
||||
}
|
||||
|
||||
export interface DocumentIdentity {
|
||||
attachmentId: string;
|
||||
target: CdpTarget;
|
||||
frameId: string;
|
||||
documentElementBackendNodeId: number;
|
||||
}
|
||||
|
||||
/** Narrow input shared by the existing semantic scene/hover consumers. */
|
||||
export interface CapturedSceneInput {
|
||||
nodes: CapturedNode[];
|
||||
viewport: Viewport;
|
||||
rootFrameId?: string;
|
||||
excludedBackendNodeIds: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
export interface CaptureIssue {
|
||||
/** Owner failure or inherited blockage; absent for other geometry failures. */
|
||||
projectionIssue?: FrameProjectionIssue;
|
||||
target: CdpTarget;
|
||||
frameId?: string;
|
||||
stage: "dom" | "ax" | "identity" | "ownership" | "geometry" | "forms";
|
||||
reason:
|
||||
| "document-changed"
|
||||
| "identity-unavailable"
|
||||
| "identity-unverified"
|
||||
| "capture-unavailable"
|
||||
| "frame-ownership-unresolved"
|
||||
| "geometry-unavailable";
|
||||
}
|
||||
|
||||
export interface DocumentFacts<T extends FrameOwnedAxNode> {
|
||||
readonly frame: CdpFrame;
|
||||
readonly identity?: DocumentIdentity;
|
||||
readonly index: DocumentIndex;
|
||||
readonly domNodes: CapturedNode[];
|
||||
readonly axNodes: T[];
|
||||
}
|
||||
|
||||
export interface ObservationFacts<T extends FrameOwnedAxNode> {
|
||||
readonly rootFrameId: string;
|
||||
readonly viewport: Viewport;
|
||||
readonly documents: readonly DocumentFacts<T>[];
|
||||
readonly issues: readonly CaptureIssue[];
|
||||
readonly startedAt: number;
|
||||
readonly finishedAt: number;
|
||||
}
|
||||
|
||||
export interface CapturedSurfaceProbe {
|
||||
triggerBackendNodeId: number;
|
||||
triggerPoint?: { x: number; y: number };
|
||||
triggerAction: "hover" | "focus" | string;
|
||||
subItems: string[];
|
||||
confidence?: "high" | "medium" | "low";
|
||||
}
|
||||
|
||||
export type { CapturedNode } from "./capture-types";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CdpRunner } from "../shared";
|
||||
import { isAbortError, throwIfAborted } from "./capture-abort";
|
||||
import type { CapturedNode } from "./capture-types";
|
||||
import type { CapturedNode } from "./facts";
|
||||
import { isCaptureAbort as isAbortError, throwCaptureAborted as throwIfAborted } from "./facts";
|
||||
|
||||
interface RuntimeEvaluateReply {
|
||||
result?: { deepSerializedValue?: DeepSerializedValue };
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
type CdpTarget,
|
||||
cdpTargetKey,
|
||||
} from "@/browser-driver/frame-graph";
|
||||
import { createCaptureCheckpoint } from "./capture-abort";
|
||||
import type { CapturedNode } from "./capture-types";
|
||||
import type { CapturedNode } from "./facts";
|
||||
import { captureCheckpoint } from "./facts";
|
||||
|
||||
export interface FrameOwnedAxNode {
|
||||
nodeId: string;
|
||||
@@ -32,7 +32,6 @@ export interface FrameDomInput {
|
||||
nodes: CapturedNode[];
|
||||
rootFrameId?: string;
|
||||
frameNodes?: ReadonlyMap<string, CapturedNode[]>;
|
||||
frameExcludedBackendNodeIds?: ReadonlyMap<string, ReadonlySet<number>>;
|
||||
}
|
||||
|
||||
interface Ownership {
|
||||
@@ -73,7 +72,6 @@ export async function buildFrameDocuments<T extends FrameOwnedAxNode>(
|
||||
signal?: AbortSignal,
|
||||
unresolved?: (frame: CdpFrame) => void,
|
||||
): Promise<FrameDocument<T>[]> {
|
||||
const checkpoint = createCaptureCheckpoint(signal);
|
||||
let work = 0;
|
||||
const frames = frameList(graph, batches);
|
||||
const frameById = new Map(frames.map((frame) => [frame.frameId, frame]));
|
||||
@@ -83,10 +81,7 @@ export async function buildFrameDocuments<T extends FrameOwnedAxNode>(
|
||||
const backendOwner = new Map<string, string>();
|
||||
for (const frame of frames) {
|
||||
for (const node of domNodesForFrame(frame.frameId)) {
|
||||
if (work++ % 256 === 0) {
|
||||
const pending = checkpoint();
|
||||
if (pending) await pending;
|
||||
}
|
||||
if (work++ % 256 === 0) await captureCheckpoint(signal);
|
||||
backendOwner.set(targetBackendKey(frame.target, node.backendNodeId), frame.frameId);
|
||||
}
|
||||
}
|
||||
@@ -95,24 +90,18 @@ export async function buildFrameDocuments<T extends FrameOwnedAxNode>(
|
||||
for (const batch of batches) {
|
||||
const nodeById = new Map<string, T>();
|
||||
for (const node of batch.nodes) {
|
||||
if (work++ % 256 === 0) {
|
||||
const pending = checkpoint();
|
||||
if (pending) await pending;
|
||||
}
|
||||
if (work++ % 256 === 0) await captureCheckpoint(signal);
|
||||
nodeById.set(node.nodeId, node);
|
||||
}
|
||||
let reported = false;
|
||||
const ownershipByNodeId = new Map<string, Ownership>();
|
||||
for (const node of batch.nodes) {
|
||||
const resolveOwnership = async (node: T): Promise<Ownership> => {
|
||||
const path: T[] = [];
|
||||
const visiting = new Set<string>();
|
||||
let current: T | undefined = node;
|
||||
let ownership: Ownership = { frameId: batch.frame.frameId, strength: 1 };
|
||||
while (current) {
|
||||
if (work++ % 256 === 0) {
|
||||
const pending = checkpoint();
|
||||
if (pending) await pending;
|
||||
}
|
||||
if (work++ % 256 === 0) await captureCheckpoint(signal);
|
||||
const cached = ownershipByNodeId.get(current.nodeId);
|
||||
if (cached) {
|
||||
ownership = cached;
|
||||
@@ -148,14 +137,17 @@ export async function buildFrameDocuments<T extends FrameOwnedAxNode>(
|
||||
current = current.parentId ? nodeById.get(current.parentId) : undefined;
|
||||
}
|
||||
for (let i = path.length - 1; i >= 0; i--) {
|
||||
if (work++ % 256 === 0) {
|
||||
const pending = checkpoint();
|
||||
if (pending) await pending;
|
||||
}
|
||||
if (work++ % 256 === 0) await captureCheckpoint(signal);
|
||||
ownership = { frameId: ownership.frameId, strength: Math.min(2, ownership.strength) };
|
||||
ownershipByNodeId.set(path[i].nodeId, ownership);
|
||||
}
|
||||
if (!ownershipByNodeId.has(node.nodeId)) ownershipByNodeId.set(node.nodeId, ownership);
|
||||
return ownership;
|
||||
};
|
||||
|
||||
for (const node of batch.nodes) {
|
||||
if (work++ % 256 === 0) await captureCheckpoint(signal);
|
||||
const ownership = await resolveOwnership(node);
|
||||
if (!ownership.frameId) continue;
|
||||
const key = targetNodeKey(batch.frame.target, node.nodeId);
|
||||
const existing = candidates.get(key);
|
||||
@@ -170,10 +162,7 @@ export async function buildFrameDocuments<T extends FrameOwnedAxNode>(
|
||||
);
|
||||
const axNodesByFrame = new Map<string, T[]>();
|
||||
for (const candidate of candidates.values()) {
|
||||
if (work++ % 256 === 0) {
|
||||
const pending = checkpoint();
|
||||
if (pending) await pending;
|
||||
}
|
||||
if (work++ % 256 === 0) await captureCheckpoint(signal);
|
||||
const { node, target, ownership } = candidate;
|
||||
const parentFrameId = node.parentId
|
||||
? ownershipByTargetNode.get(targetNodeKey(target, node.parentId))
|
||||
@@ -199,6 +188,5 @@ export async function buildFrameDocuments<T extends FrameOwnedAxNode>(
|
||||
contextScopeId: frame.frameId,
|
||||
axNodes: axNodesByFrame.get(frame.frameId) ?? [],
|
||||
domNodes: domNodesForFrame(frame.frameId),
|
||||
excludedBackendNodeIds: captured.frameExcludedBackendNodeIds?.get(frame.frameId),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -4,21 +4,19 @@ import { type GeometryProjection, projectRectToViewport } from "../geometry";
|
||||
import {
|
||||
type FrameProjectionState,
|
||||
projectSnapshotRect,
|
||||
type SnapshotCoordinates,
|
||||
snapshotCoordinates,
|
||||
snapshotViewportRect,
|
||||
} from "../geometry/coordinate-types";
|
||||
import { cssViewport, GeometryContext } from "../geometry/frame-context";
|
||||
import type { CapturedNode } from "./facts";
|
||||
import {
|
||||
GeometryContext,
|
||||
type LayoutMetrics,
|
||||
snapshotLayoutScale,
|
||||
} from "../geometry/frame-context";
|
||||
import {
|
||||
createCaptureCheckpoint,
|
||||
isAbortError as isCaptureAbort,
|
||||
throwIfAborted as throwCaptureAborted,
|
||||
} from "./capture-abort";
|
||||
import { buildDocumentIndex, type CaptureIssue, type DocumentIndex, type NodeFacts } from "./facts";
|
||||
buildDocumentIndex,
|
||||
type CaptureIssue,
|
||||
captureCheckpoint,
|
||||
type DocumentIndex,
|
||||
isCaptureAbort,
|
||||
type NodeFacts,
|
||||
throwCaptureAborted,
|
||||
} from "./facts";
|
||||
import {
|
||||
decodeDocument,
|
||||
type SnapshotDocument,
|
||||
@@ -26,90 +24,10 @@ import {
|
||||
snapshotFrameId,
|
||||
} from "./snapshot";
|
||||
|
||||
export interface FrameContext {
|
||||
frameId?: string;
|
||||
ownerFrameBackendNodeId: number | null;
|
||||
projection: FrameProjectionState | null;
|
||||
targetProjection?: GeometryProjection | null;
|
||||
target: CdpTarget;
|
||||
coordinates: SnapshotCoordinates | null;
|
||||
}
|
||||
|
||||
export interface NormalizedDocument {
|
||||
nodes: NodeFacts[];
|
||||
index: DocumentIndex;
|
||||
documentElementBackendNodeId?: number;
|
||||
}
|
||||
|
||||
/** Interpret one document using a supplied projection. No live reads or frame scheduling. */
|
||||
export async function normalizeDocument(
|
||||
doc: SnapshotDocument,
|
||||
strings: string[],
|
||||
context: FrameContext,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NormalizedDocument> {
|
||||
const decoded = await decodeDocument(doc, strings, signal);
|
||||
const checkpoint = createCaptureCheckpoint(signal);
|
||||
const nodes: NodeFacts[] = [];
|
||||
for (let i = 0; i < decoded.nodes.length; i++) {
|
||||
if (i % 256 === 0) {
|
||||
const pending = checkpoint();
|
||||
if (pending) await pending;
|
||||
}
|
||||
const node = decoded.nodes[i];
|
||||
const bounds = node.layout?.bounds ?? [];
|
||||
const input = snapshotViewportRect(
|
||||
bounds,
|
||||
{ target: context.target, frameId: context.frameId },
|
||||
context.coordinates,
|
||||
);
|
||||
const local = input?.rect;
|
||||
let rect =
|
||||
input && context.projection?.status === "available"
|
||||
? projectSnapshotRect(input, context.projection.projection)
|
||||
: null;
|
||||
if (rect && context.targetProjection !== undefined)
|
||||
rect = context.targetProjection
|
||||
? projectRectToViewport(
|
||||
{ x: rect.x, y: rect.y, w: rect.width, h: rect.height },
|
||||
context.targetProjection,
|
||||
)
|
||||
: null;
|
||||
const visibility = node.layout?.styles.visibility || "visible";
|
||||
const opacity = node.layout?.styles.opacity || "1";
|
||||
nodes.push({
|
||||
...node,
|
||||
...(context.frameId ? { frameId: context.frameId } : {}),
|
||||
ownerFrameBackendNodeId: context.ownerFrameBackendNodeId,
|
||||
localRect: local ? { x: local.x, y: local.y, w: local.width, h: local.height } : null,
|
||||
rect: rect ? { x: rect.x, y: rect.y, w: rect.width, h: rect.height } : null,
|
||||
rendered:
|
||||
bounds.length >= 4 &&
|
||||
bounds.slice(0, 4).every(Number.isFinite) &&
|
||||
bounds[2] > 0 &&
|
||||
bounds[3] > 0 &&
|
||||
visibility !== "hidden" &&
|
||||
visibility !== "collapse" &&
|
||||
(Number.parseFloat(opacity) || 0) > 0,
|
||||
});
|
||||
}
|
||||
const index = await buildDocumentIndex(nodes, signal);
|
||||
return {
|
||||
nodes: nodes.filter(
|
||||
(node) => !node.tag.startsWith("#") && !index.excludedBackendNodeIds.has(node.backendNodeId),
|
||||
),
|
||||
index,
|
||||
documentElementBackendNodeId: nodes.find(
|
||||
(node) =>
|
||||
node.nodeType === 1 &&
|
||||
node.parentBackendNodeId !== null &&
|
||||
index.nodes.get(node.parentBackendNodeId)?.nodeType === 9,
|
||||
)?.backendNodeId,
|
||||
};
|
||||
}
|
||||
|
||||
export interface NormalizedFrameDocument extends NormalizedDocument {
|
||||
frame: CdpFrame;
|
||||
domNodes: CapturedNode[];
|
||||
}
|
||||
|
||||
/** One target's snapshot. Source ownership is checked before interpreting any
|
||||
@@ -122,17 +40,9 @@ export async function normalizeSnapshot(
|
||||
issues: CaptureIssue[],
|
||||
signal?: AbortSignal,
|
||||
rootFrameId?: string,
|
||||
): Promise<NormalizedFrameDocument[]> {
|
||||
): Promise<NormalizedDocument[]> {
|
||||
const strings = snapshot.strings ?? [];
|
||||
const raw = snapshot.documents ?? [];
|
||||
const ownerIds = new Set<number>();
|
||||
for (const document of raw) {
|
||||
for (const index of document.nodes?.contentDocumentIndex?.index ?? []) {
|
||||
const id = document.nodes?.backendNodeId?.[index];
|
||||
if (id !== undefined) ownerIds.add(id);
|
||||
}
|
||||
}
|
||||
geometry.registerSnapshotOwners(target, ownerIds);
|
||||
const frameById = new Map(frames.map((frame) => [frame.frameId, frame]));
|
||||
const sources = new Map<string, SnapshotDocument>();
|
||||
for (const doc of raw) {
|
||||
@@ -148,20 +58,17 @@ export async function normalizeSnapshot(
|
||||
}
|
||||
sources.set(id, doc);
|
||||
}
|
||||
let metrics: LayoutMetrics = {};
|
||||
let viewport: Viewport = { width: 0, height: 0 };
|
||||
let scrollX = 0,
|
||||
scrollY = 0;
|
||||
try {
|
||||
metrics = await geometry.layoutMetrics(target);
|
||||
const measured = cssViewport(await geometry.layoutMetrics(target));
|
||||
viewport = { width: measured.width, height: measured.height };
|
||||
scrollX = measured.scrollX;
|
||||
scrollY = measured.scrollY;
|
||||
} catch (error) {
|
||||
if (isCaptureAbort(error)) throw error;
|
||||
}
|
||||
const viewport: Viewport = {
|
||||
width: metrics.cssLayoutViewport?.clientWidth ?? 0,
|
||||
height: metrics.cssLayoutViewport?.clientHeight ?? 0,
|
||||
};
|
||||
const layoutUnitsPerCssPixel = snapshotLayoutScale(metrics);
|
||||
const viewportAvailable = [viewport.width, viewport.height].every(
|
||||
(size) => Number.isFinite(size) && size > 0,
|
||||
);
|
||||
|
||||
const children = new Map<string, CdpFrame[]>();
|
||||
const pending: CdpFrame[] = [];
|
||||
@@ -174,7 +81,7 @@ export async function normalizeSnapshot(
|
||||
}
|
||||
}
|
||||
const projections = new Map<string, FrameProjectionState | null>();
|
||||
const result: NormalizedFrameDocument[] = [];
|
||||
const result: NormalizedDocument[] = [];
|
||||
let targetProjection: GeometryProjection | null = null;
|
||||
const targetRoot = rootFrameId ? frameById.get(rootFrameId) : undefined;
|
||||
if (target.sessionId && targetRoot) {
|
||||
@@ -186,7 +93,6 @@ export async function normalizeSnapshot(
|
||||
if (isCaptureAbort(error)) throw error;
|
||||
}
|
||||
}
|
||||
const checkpoint = createCaptureCheckpoint(signal);
|
||||
const visited = new Set<string>();
|
||||
const remaining = frames[Symbol.iterator]();
|
||||
for (let cursor = 0; visited.size < frames.length; cursor++) {
|
||||
@@ -196,10 +102,7 @@ export async function normalizeSnapshot(
|
||||
if (next.done) break;
|
||||
pending.push(next.value);
|
||||
}
|
||||
if (cursor % 256 === 0) {
|
||||
const pending = checkpoint();
|
||||
if (pending) await pending;
|
||||
}
|
||||
if (cursor % 256 === 0) await captureCheckpoint(signal);
|
||||
const frame = pending[cursor];
|
||||
if (visited.has(frame.frameId)) continue;
|
||||
visited.add(frame.frameId);
|
||||
@@ -207,24 +110,12 @@ export async function normalizeSnapshot(
|
||||
const doc = sources.get(frame.frameId);
|
||||
if (!doc) continue;
|
||||
const source = { target, frameId: frame.frameId };
|
||||
const coordinates = snapshotCoordinates(
|
||||
doc,
|
||||
layoutUnitsPerCssPixel,
|
||||
const state: FrameProjectionState | null =
|
||||
frame.frameId === rootFrameId
|
||||
? { x: metrics.cssLayoutViewport?.pageX, y: metrics.cssLayoutViewport?.pageY }
|
||||
: undefined,
|
||||
);
|
||||
let state: FrameProjectionState | null =
|
||||
frame.frameId === rootFrameId
|
||||
? viewportAvailable && coordinates
|
||||
? {
|
||||
status: "available",
|
||||
projection: {
|
||||
source,
|
||||
geometry: { sourceClips: [], edges: [], topViewport: viewport },
|
||||
},
|
||||
}
|
||||
: { status: "unavailable", source, reason: "snapshot-coordinates-unavailable" }
|
||||
? {
|
||||
status: "available",
|
||||
projection: { source, geometry: { sourceClips: [], edges: [], topViewport: viewport } },
|
||||
}
|
||||
: (projections.get(frame.frameId) ?? null);
|
||||
if (!state && frame.frameId !== rootFrameId)
|
||||
issues.push({
|
||||
@@ -233,8 +124,6 @@ export async function normalizeSnapshot(
|
||||
stage: "ownership",
|
||||
reason: "frame-ownership-unresolved",
|
||||
});
|
||||
if (!coordinates && (!state || state.status === "available"))
|
||||
state = { status: "unavailable", source, reason: "snapshot-coordinates-unavailable" };
|
||||
projections.set(frame.frameId, state);
|
||||
const projection = state?.status === "available" ? state.projection : null;
|
||||
if (!projection || (target.sessionId && !targetProjection))
|
||||
@@ -279,14 +168,6 @@ export async function normalizeSnapshot(
|
||||
projections.set(child.frameId, null);
|
||||
continue;
|
||||
}
|
||||
if (!snapshotCoordinates(sources.get(child.frameId)!, layoutUnitsPerCssPixel)) {
|
||||
projections.set(child.frameId, {
|
||||
status: "unavailable",
|
||||
source: childSource,
|
||||
reason: "snapshot-coordinates-unavailable",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
projections.set(
|
||||
child.frameId,
|
||||
@@ -306,20 +187,47 @@ export async function normalizeSnapshot(
|
||||
// Join active reads and their cleanup before returning cancellation.
|
||||
if (failure) throw failure;
|
||||
throwCaptureAborted(signal);
|
||||
const normalized = await normalizeDocument(
|
||||
doc,
|
||||
strings,
|
||||
{
|
||||
const decoded = await decodeDocument(doc, strings, signal);
|
||||
const nodes: NodeFacts[] = [];
|
||||
for (let i = 0; i < decoded.nodes.length; i++) {
|
||||
if (i % 256 === 0) await captureCheckpoint(signal);
|
||||
const node = decoded.nodes[i];
|
||||
const input = snapshotViewportRect(node.layout?.bounds ?? [], source, {
|
||||
x: doc.scrollOffsetX ?? (frame.frameId === rootFrameId ? scrollX : 0),
|
||||
y: doc.scrollOffsetY ?? (frame.frameId === rootFrameId ? scrollY : 0),
|
||||
});
|
||||
const local = input?.rect;
|
||||
let rect = input && projection ? projectSnapshotRect(input, projection) : null;
|
||||
if (rect && target.sessionId)
|
||||
rect = targetProjection
|
||||
? projectRectToViewport(
|
||||
{ x: rect.x, y: rect.y, w: rect.width, h: rect.height },
|
||||
targetProjection,
|
||||
)
|
||||
: null;
|
||||
const styles = node.layout?.styles;
|
||||
const visibility = styles?.visibility || "visible";
|
||||
const opacity = styles?.opacity || "1";
|
||||
nodes.push({
|
||||
...node,
|
||||
frameId: frame.frameId,
|
||||
ownerFrameBackendNodeId: frame.ownerBackendNodeId ?? null,
|
||||
projection: state,
|
||||
target,
|
||||
...(target.sessionId ? { targetProjection } : {}),
|
||||
coordinates,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
result.push({ ...normalized, frame });
|
||||
localRect: local ? { x: local.x, y: local.y, w: local.width, h: local.height } : null,
|
||||
rect: rect ? { x: rect.x, y: rect.y, w: rect.width, h: rect.height } : null,
|
||||
rendered:
|
||||
!!local &&
|
||||
visibility !== "hidden" &&
|
||||
visibility !== "collapse" &&
|
||||
(Number.parseFloat(opacity) || 0) > 0,
|
||||
});
|
||||
}
|
||||
const index = await buildDocumentIndex(nodes, signal);
|
||||
const domNodes = nodes.filter((node) => !node.tag.startsWith("#"));
|
||||
result.push({
|
||||
index,
|
||||
frame,
|
||||
domNodes,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type CdpFrame, type CdpTarget, cdpTargetKey } from "@/browser-driver/frame-graph";
|
||||
import { createCaptureCheckpoint } from "./capture-abort";
|
||||
import type { CapturedNode } from "./capture-types";
|
||||
import type { DecodedDocument, DecodedNode } from "./facts";
|
||||
import type { CapturedNode } from "./facts";
|
||||
import { captureCheckpoint, type DecodedDocument, type DecodedNode } from "./facts";
|
||||
export const REQUESTED_STYLES = [
|
||||
"position",
|
||||
"pointer-events",
|
||||
@@ -88,7 +87,7 @@ function str(strings: string[], idx: number | undefined): string {
|
||||
return strings[idx] ?? "";
|
||||
}
|
||||
|
||||
export function sparseIndexMap(sparse: SparseArray | undefined): Map<number, number> {
|
||||
function sparseIndexMap(sparse: SparseArray | undefined): Map<number, number> {
|
||||
const out = new Map<number, number>();
|
||||
if (!sparse?.index || !sparse.value) return out;
|
||||
for (let i = 0; i < sparse.index.length; i++) {
|
||||
@@ -104,7 +103,6 @@ export async function decodeDocument(
|
||||
strings: string[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<DecodedDocument> {
|
||||
const checkpoint = createCaptureCheckpoint(signal);
|
||||
const dn = doc.nodes;
|
||||
const dl = doc.layout;
|
||||
if (!dn?.backendNodeId) {
|
||||
@@ -114,10 +112,7 @@ export async function decodeDocument(
|
||||
const count = dn.backendNodeId.length;
|
||||
const layoutByNode = new Map<number, number>();
|
||||
for (let i = 0; i < (dl?.nodeIndex?.length ?? 0); i++) {
|
||||
if (i % 256 === 0) {
|
||||
const pending = checkpoint();
|
||||
if (pending) await pending;
|
||||
}
|
||||
if (i % 256 === 0) await captureCheckpoint(signal);
|
||||
layoutByNode.set(dl!.nodeIndex![i], i);
|
||||
}
|
||||
|
||||
@@ -132,10 +127,7 @@ export async function decodeDocument(
|
||||
const nodeTextContent = new Map<number, string[]>();
|
||||
if (dn.nodeValue) {
|
||||
for (let n = 0; n < count; n++) {
|
||||
if (n % 256 === 0) {
|
||||
const pending = checkpoint();
|
||||
if (pending) await pending;
|
||||
}
|
||||
if (n % 256 === 0) await captureCheckpoint(signal);
|
||||
const nvIdx = dn.nodeValue[n] ?? -1;
|
||||
if (nvIdx < 0) continue;
|
||||
const text = str(strings, nvIdx).trim();
|
||||
@@ -152,10 +144,7 @@ export async function decodeDocument(
|
||||
// Decode fields without assigning a coordinate projection or semantic policy.
|
||||
const nodes: DecodedNode[] = [];
|
||||
for (let n = 0; n < count; n++) {
|
||||
if (n % 256 === 0) {
|
||||
const pending = checkpoint();
|
||||
if (pending) await pending;
|
||||
}
|
||||
if (n % 256 === 0) await captureCheckpoint(signal);
|
||||
const backendNodeId = dn.backendNodeId[n];
|
||||
const parentIdx = dn.parentIndex?.[n] ?? -1;
|
||||
const parentBackendNodeId = parentIdx >= 0 ? (dn.backendNodeId[parentIdx] ?? null) : null;
|
||||
@@ -175,7 +164,7 @@ export async function decodeDocument(
|
||||
li === undefined
|
||||
? undefined
|
||||
: {
|
||||
boundsSpace: "snapshot-document-layout" as const,
|
||||
boundsSpace: "snapshot-document-css" as const,
|
||||
bounds: dl?.bounds?.[li],
|
||||
styles,
|
||||
};
|
||||
|
||||
@@ -73,14 +73,6 @@ where
|
||||
P: Serialize + Send + 'static,
|
||||
R: DeserializeOwned + Send + 'static,
|
||||
{
|
||||
let parent_cancel = stdin_cancellation();
|
||||
if parent_cancel.as_ref().is_some_and(|rx| *rx.borrow()) {
|
||||
return Err(CliError::from_rpc(RpcError {
|
||||
code: ErrorCode::Cancelled,
|
||||
message: "parent closed the cancellation pipe".into(),
|
||||
data: Some(serde_json::json!({"reason": "cancelled_before_dispatch"})),
|
||||
}));
|
||||
}
|
||||
let rpc_id: RpcId = format!("{}-{}", rpc_id_prefix, random_short_id());
|
||||
let mut client = IpcClient::connect(sock.clone()).await?;
|
||||
let rpc_id_for_call = rpc_id.clone();
|
||||
@@ -96,16 +88,16 @@ where
|
||||
let outcome = tokio::select! {
|
||||
biased;
|
||||
res = &mut main_fut => res?,
|
||||
sig = wait_for_cancel(parent_cancel) => {
|
||||
sig.context("wait for cancellation").map_err(CliError::Local)?;
|
||||
debug!(rpc_id = %rpc_id_for_cancel, "forwarding cancel to daemon");
|
||||
sig = wait_for_sigint() => {
|
||||
sig.context("install SIGINT handler").map_err(CliError::Local)?;
|
||||
debug!(rpc_id = %rpc_id_for_cancel, "SIGINT: forwarding cancel to daemon");
|
||||
let _ = send_cancel(&sock, &rpc_id_for_cancel).await;
|
||||
match tokio::time::timeout(CANCEL_RESPONSE_GRACE, &mut main_fut).await {
|
||||
Ok(res) => res?,
|
||||
Err(_) => {
|
||||
debug!(
|
||||
rpc_id = %rpc_id_for_cancel,
|
||||
"cancel grace elapsed; synthesising cancelled error"
|
||||
"SIGINT: cancel grace elapsed; synthesising cancelled error"
|
||||
);
|
||||
return Err(CliError::from_rpc(RpcError {
|
||||
code: ErrorCode::Cancelled,
|
||||
@@ -120,46 +112,6 @@ where
|
||||
outcome.map_err(CliError::from_rpc)
|
||||
}
|
||||
|
||||
/// Release caller-owned staging even after cancellation. This deliberately
|
||||
/// exposes only transfer.release, not a general bypass for business RPCs.
|
||||
/// The entire batch, including connection setup, shares one bounded budget.
|
||||
pub fn release_transfers<'a>(
|
||||
sock: &Path,
|
||||
transfer_ids: impl IntoIterator<Item = &'a str>,
|
||||
) -> Result<(), CliError> {
|
||||
use bsk_protocol::tools::{TransferIdParams, TransferReleaseResult};
|
||||
const CLEANUP_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
let ids: Vec<_> = transfer_ids.into_iter().collect();
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("build runtime for transfer cleanup")?;
|
||||
rt.block_on(async {
|
||||
tokio::time::timeout(CLEANUP_TIMEOUT, async {
|
||||
let mut client = IpcClient::connect(sock).await?;
|
||||
for id in ids {
|
||||
client
|
||||
.call::<_, TransferReleaseResult>(
|
||||
"transfer-release",
|
||||
Method::TransferRelease,
|
||||
Some(TransferIdParams {
|
||||
transfer_id: id.to_string(),
|
||||
}),
|
||||
CLEANUP_TIMEOUT,
|
||||
)
|
||||
.await?
|
||||
.map_err(CliError::from_rpc)?;
|
||||
}
|
||||
Ok::<_, CliError>(())
|
||||
})
|
||||
.await
|
||||
.context("transfer cleanup exceeded its total time budget")?
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a `cancel { rpc_id }` frame over a fresh connection so it
|
||||
/// lands on the daemon while the original call is still parked.
|
||||
///
|
||||
@@ -194,57 +146,8 @@ fn random_short_id() -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Opt-in control pipe for parent processes that cannot send Ctrl-C (Node on
|
||||
/// Windows). One reader survives the short-lived runtimes used by multi-RPC
|
||||
/// commands such as upload. Ordinary CLI stdin and terminal Ctrl-C are unchanged.
|
||||
fn stdin_cancellation() -> Option<tokio::sync::watch::Receiver<bool>> {
|
||||
if std::env::var("BSK_CANCEL_ON_STDIN_CLOSE").as_deref() != Ok("1") {
|
||||
return None;
|
||||
}
|
||||
static CANCEL: std::sync::OnceLock<tokio::sync::watch::Receiver<bool>> =
|
||||
std::sync::OnceLock::new();
|
||||
Some(
|
||||
CANCEL
|
||||
.get_or_init(|| {
|
||||
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||
std::thread::spawn(move || {
|
||||
use std::io::Read;
|
||||
let mut stdin = std::io::stdin().lock();
|
||||
let mut buf = [0; 64];
|
||||
loop {
|
||||
match stdin.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
let _ = tx.send(true);
|
||||
});
|
||||
rx
|
||||
})
|
||||
.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn wait_for_cancel(
|
||||
parent_cancel: Option<tokio::sync::watch::Receiver<bool>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let parent_closed = async move {
|
||||
match parent_cancel {
|
||||
Some(mut rx) => {
|
||||
let _ = rx.wait_for(|closed| *closed).await;
|
||||
}
|
||||
None => std::future::pending::<()>().await,
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
result = wait_for_sigint() => result,
|
||||
_ = parent_closed => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for terminal Ctrl-C.
|
||||
/// Wait for either Ctrl-C or process SIGTERM. Returning here means
|
||||
/// the user has asked us to stop.
|
||||
async fn wait_for_sigint() -> anyhow::Result<()> {
|
||||
tokio::signal::ctrl_c().await.context("listen for SIGINT")?;
|
||||
Ok(())
|
||||
|
||||
@@ -9,7 +9,8 @@ use anyhow::Context;
|
||||
use base64::Engine;
|
||||
use bsk_protocol::Method;
|
||||
use bsk_protocol::tools::{
|
||||
DownloadParams, DownloadResult, TransferChunkParams, TransferChunkResult,
|
||||
DownloadParams, DownloadResult, TransferChunkParams, TransferChunkResult, TransferIdParams,
|
||||
TransferReleaseResult,
|
||||
};
|
||||
use clap::Args;
|
||||
use uuid::Uuid;
|
||||
@@ -69,7 +70,13 @@ pub fn dispatch(args: DownloadArgs, format: Format) -> Result<(), CliError> {
|
||||
CliError::Local(anyhow::anyhow!("daemon returned no download transfer id"))
|
||||
})?;
|
||||
let write_result = write_transfer(&info.sock_path, &transfer_id, &args.out, args.overwrite);
|
||||
let _ = crate::cli::business_rpc::release_transfers(&info.sock_path, [transfer_id.as_str()]);
|
||||
let _: Result<TransferReleaseResult, CliError> = crate::cli::business_rpc::call(
|
||||
info.sock_path,
|
||||
"transfer-release",
|
||||
Method::TransferRelease,
|
||||
Some(TransferIdParams { transfer_id }),
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
write_result?;
|
||||
match format {
|
||||
Format::Json => {
|
||||
|
||||
@@ -10,7 +10,8 @@ use base64::Engine;
|
||||
use bsk_protocol::Method;
|
||||
use bsk_protocol::tools::{
|
||||
TransferBeginParams, TransferBeginResult, TransferChunkParams, TransferChunkResult,
|
||||
TransferIdParams, TransferReadyResult, UploadFile, UploadMode, UploadParams, UploadResult,
|
||||
TransferIdParams, TransferReadyResult, TransferReleaseResult, UploadFile, UploadMode,
|
||||
UploadParams, UploadResult,
|
||||
};
|
||||
use clap::{Args, ValueEnum};
|
||||
|
||||
@@ -68,12 +69,14 @@ pub fn dispatch(args: UploadArgs, format: Format) -> Result<(), CliError> {
|
||||
let (ref_, selector) = split_target(args.target, args.ref_, args.selector)?;
|
||||
let mut staged = Vec::new();
|
||||
for path in &args.files {
|
||||
if let Err(err) = stage_file(&info.sock_path, &args.session, path, &mut staged) {
|
||||
let _ = crate::cli::business_rpc::release_transfers(
|
||||
&info.sock_path,
|
||||
staged.iter().map(|(id, _)| id.as_str()),
|
||||
);
|
||||
return Err(err);
|
||||
match stage_file(&info.sock_path, &args.session, path) {
|
||||
Ok(file) => staged.push(file),
|
||||
Err(err) => {
|
||||
for (id, _) in &staged {
|
||||
let _ = release(&info.sock_path, id);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
let params = UploadParams {
|
||||
@@ -99,17 +102,6 @@ pub fn dispatch(args: UploadArgs, format: Format) -> Result<(), CliError> {
|
||||
Some(params),
|
||||
ipc_timeout(args.timeout),
|
||||
);
|
||||
// The helper can reject cancellation before handing any request to the
|
||||
// daemon. In that case staging still belongs to this CLI.
|
||||
if result.as_ref().is_err_and(|err| {
|
||||
err.data()
|
||||
.is_some_and(|data| data["reason"] == "cancelled_before_dispatch")
|
||||
}) {
|
||||
let _ = crate::cli::business_rpc::release_transfers(
|
||||
&info.sock_path,
|
||||
staged.iter().map(|(id, _)| id.as_str()),
|
||||
);
|
||||
}
|
||||
// Once tool.upload is dispatched, staging ownership belongs to the
|
||||
// session. A transport timeout cannot prove that Chrome did not attach
|
||||
// the file, so releasing here could invalidate a late successful attach.
|
||||
@@ -126,12 +118,7 @@ pub fn dispatch(args: UploadArgs, format: Format) -> Result<(), CliError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stage_file(
|
||||
sock: &Path,
|
||||
session: &str,
|
||||
path: &PathBuf,
|
||||
staged: &mut Vec<(String, String)>,
|
||||
) -> Result<(), CliError> {
|
||||
fn stage_file(sock: &Path, session: &str, path: &PathBuf) -> Result<(String, String), CliError> {
|
||||
let mut file = File::open(path)
|
||||
.with_context(|| format!("open upload file {}", path.display()))
|
||||
.map_err(CliError::Local)?;
|
||||
@@ -162,39 +149,55 @@ fn stage_file(
|
||||
}),
|
||||
Duration::from_secs(10),
|
||||
)?;
|
||||
// Record ownership immediately so one error path releases both completed
|
||||
// files and the current partially staged file.
|
||||
staged.push((begin.transfer_id.clone(), name));
|
||||
let mut offset = 0u64;
|
||||
let mut buf = vec![0u8; begin.chunk_size as usize];
|
||||
loop {
|
||||
let n = file.read(&mut buf).map_err(|e| CliError::Local(e.into()))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
let staged = (|| {
|
||||
let mut offset = 0u64;
|
||||
let mut buf = vec![0u8; begin.chunk_size as usize];
|
||||
loop {
|
||||
let n = file.read(&mut buf).map_err(|e| CliError::Local(e.into()))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
let reply: TransferChunkResult = crate::cli::business_rpc::call(
|
||||
sock.to_path_buf(),
|
||||
"transfer-chunk",
|
||||
Method::TransferChunk,
|
||||
Some(TransferChunkParams {
|
||||
transfer_id: begin.transfer_id.clone(),
|
||||
offset,
|
||||
data_base64: base64::engine::general_purpose::STANDARD.encode(&buf[..n]),
|
||||
}),
|
||||
Duration::from_secs(30),
|
||||
)?;
|
||||
offset = reply.next_offset;
|
||||
}
|
||||
let reply: TransferChunkResult = crate::cli::business_rpc::call(
|
||||
let _: TransferReadyResult = crate::cli::business_rpc::call(
|
||||
sock.to_path_buf(),
|
||||
"transfer-chunk",
|
||||
Method::TransferChunk,
|
||||
Some(TransferChunkParams {
|
||||
"transfer-finish",
|
||||
Method::TransferFinish,
|
||||
Some(TransferIdParams {
|
||||
transfer_id: begin.transfer_id.clone(),
|
||||
offset,
|
||||
data_base64: base64::engine::general_purpose::STANDARD.encode(&buf[..n]),
|
||||
}),
|
||||
Duration::from_secs(30),
|
||||
Duration::from_secs(10),
|
||||
)?;
|
||||
offset = reply.next_offset;
|
||||
Ok::<_, CliError>(())
|
||||
})();
|
||||
if let Err(err) = staged {
|
||||
let _ = release(sock, &begin.transfer_id);
|
||||
return Err(err);
|
||||
}
|
||||
let _: TransferReadyResult = crate::cli::business_rpc::call(
|
||||
Ok((begin.transfer_id, name))
|
||||
}
|
||||
|
||||
fn release(sock: &Path, id: &str) -> Result<TransferReleaseResult, CliError> {
|
||||
crate::cli::business_rpc::call(
|
||||
sock.to_path_buf(),
|
||||
"transfer-finish",
|
||||
Method::TransferFinish,
|
||||
"transfer-release",
|
||||
Method::TransferRelease,
|
||||
Some(TransferIdParams {
|
||||
transfer_id: begin.transfer_id.clone(),
|
||||
transfer_id: id.to_string(),
|
||||
}),
|
||||
Duration::from_secs(10),
|
||||
)?;
|
||||
Ok(())
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
}
|
||||
|
||||
fn ipc_timeout(timeout_ms: u32) -> Duration {
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
//! Real Windows CLI cancellation, including caller-owned transfer cleanup.
|
||||
#![cfg(windows)]
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bsk::daemon::{file_transfer::TransferRegistry, info::DaemonInfo};
|
||||
use bsk_protocol::tools::{TransferBeginParams, TransferIdParams};
|
||||
use bsk_protocol::{ErrorCode, Frame, Method, ResponseBody, ResponseFrame, RpcError};
|
||||
use serde_json::json;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::windows::named_pipe::ServerOptions;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::{Notify, watch};
|
||||
|
||||
fn response<T: serde::Serialize>(result: Result<T, RpcError>) -> ResponseBody {
|
||||
match result {
|
||||
Ok(value) => ResponseBody::Ok(serde_json::to_value(value).unwrap()),
|
||||
Err(err) => ResponseBody::Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_close_cancels_business_but_releases_resources_with_a_bounded_budget() {
|
||||
// Resolve the production registry's root before starting any runtime threads.
|
||||
let registry_home = tempfile::tempdir().unwrap();
|
||||
let original_home = std::env::var_os("BSK_HOME");
|
||||
unsafe {
|
||||
std::env::set_var("BSK_HOME", registry_home.path());
|
||||
}
|
||||
let transfers = Arc::new(TransferRegistry::new().unwrap());
|
||||
unsafe {
|
||||
match original_home {
|
||||
Some(value) => std::env::set_var("BSK_HOME", value),
|
||||
None => std::env::remove_var("BSK_HOME"),
|
||||
}
|
||||
}
|
||||
transfers.initialize().unwrap();
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(exercise(
|
||||
transfers,
|
||||
®istry_home.path().join("run/transfers"),
|
||||
));
|
||||
}
|
||||
|
||||
async fn exercise(transfers: Arc<TransferRegistry>, staging_root: &Path) {
|
||||
for mode in [
|
||||
"wait",
|
||||
"upload",
|
||||
"upload-multi",
|
||||
"upload-before-dispatch",
|
||||
"download",
|
||||
"upload-multi-stall",
|
||||
"ordinary",
|
||||
] {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let pipe_name = format!(r"\\.\pipe\bsk-parent-cancel-{}", uuid::Uuid::new_v4());
|
||||
let info = DaemonInfo::now(std::process::id(), (&pipe_name).into(), 0, "0.2.0");
|
||||
std::fs::write(
|
||||
home.path().join("daemon.json"),
|
||||
serde_json::to_vec(&info).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut pipe = ServerOptions::new()
|
||||
.first_pipe_instance(true)
|
||||
.create(&pipe_name)
|
||||
.unwrap();
|
||||
let registered = Arc::new(Notify::new());
|
||||
let pending = Arc::new(Mutex::new(None::<String>));
|
||||
let ids = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let released = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let received = Arc::new(Mutex::new(Vec::<Method>::new()));
|
||||
let download_id = if mode == "download" {
|
||||
let staging = transfers.begin_download("fixture").unwrap();
|
||||
let browser_dir = home.path().join("BrowserSkill").join(&staging.transfer_id);
|
||||
std::fs::create_dir_all(&browser_dir).unwrap();
|
||||
let file = browser_dir.join("download.txt");
|
||||
std::fs::write(&file, b"download fixture").unwrap();
|
||||
transfers
|
||||
.import_download(&staging.transfer_id, &file)
|
||||
.unwrap();
|
||||
ids.lock().unwrap().push(staging.transfer_id.clone());
|
||||
Some(staging.transfer_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (cancel, cancelled) = watch::channel(false);
|
||||
let ready = Arc::clone(®istered);
|
||||
let name = pipe_name.clone();
|
||||
let registry = Arc::clone(&transfers);
|
||||
let server_ids = Arc::clone(&ids);
|
||||
let server_released = Arc::clone(&released);
|
||||
let server_received = Arc::clone(&received);
|
||||
let server = tokio::spawn(async move {
|
||||
let mut connections = tokio::task::JoinSet::new();
|
||||
loop {
|
||||
pipe.connect().await.unwrap();
|
||||
let next = ServerOptions::new().create(&name).unwrap();
|
||||
let connection = std::mem::replace(&mut pipe, next);
|
||||
let ready = Arc::clone(&ready);
|
||||
let pending = Arc::clone(&pending);
|
||||
let cancel = cancel.clone();
|
||||
let mut cancelled = cancelled.clone();
|
||||
let name = name.clone();
|
||||
let registry = Arc::clone(®istry);
|
||||
let ids = Arc::clone(&server_ids);
|
||||
let released = Arc::clone(&server_released);
|
||||
let received = Arc::clone(&server_received);
|
||||
let download_id = download_id.clone();
|
||||
connections.spawn(async move {
|
||||
let (read, mut write) = tokio::io::split(connection);
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut line = String::new();
|
||||
while reader.read_line(&mut line).await.unwrap_or(0) > 0 {
|
||||
let Frame::Request(req) = serde_json::from_str::<Frame>(&line).unwrap()
|
||||
else {
|
||||
panic!("expected request");
|
||||
};
|
||||
line.clear();
|
||||
received.lock().unwrap().push(req.method.clone());
|
||||
let params = req.params.clone().unwrap_or_default();
|
||||
let body = match req.method {
|
||||
Method::SystemStatus => ResponseBody::Ok(json!({
|
||||
"daemon_version": "0.2.0", "protocol_version": "1.1",
|
||||
"pid": std::process::id(), "uptime_secs": 0,
|
||||
"ws_port": 0, "sock_path": name,
|
||||
"browsers": [], "sessions": [], "version_skew_browsers": []
|
||||
})),
|
||||
Method::TransferBegin => {
|
||||
let begin = registry
|
||||
.begin_upload(serde_json::from_value(params).unwrap())
|
||||
.unwrap();
|
||||
ids.lock().unwrap().push(begin.transfer_id.clone());
|
||||
response(Ok(begin))
|
||||
}
|
||||
Method::ToolDownload => ResponseBody::Ok(json!({
|
||||
"tab_id": 1, "suggested_filename": "download.txt",
|
||||
"byte_size": 16, "transfer_id": download_id,
|
||||
})),
|
||||
Method::ToolWaitMs if mode == "ordinary" => {
|
||||
ResponseBody::Ok(json!({"waited_ms": 1}))
|
||||
}
|
||||
Method::TransferChunk
|
||||
if mode == "upload-before-dispatch"
|
||||
|| (mode.contains("multi")
|
||||
&& ids.lock().unwrap().len() == 1) =>
|
||||
{
|
||||
response(
|
||||
registry.write_chunk(serde_json::from_value(params).unwrap()),
|
||||
)
|
||||
}
|
||||
Method::TransferFinish if mode != "upload-before-dispatch" => response(
|
||||
registry.finish_upload(serde_json::from_value(params).unwrap()),
|
||||
),
|
||||
Method::ToolWaitMs
|
||||
| Method::TransferChunk
|
||||
| Method::TransferRead
|
||||
| Method::TransferFinish => {
|
||||
*pending.lock().unwrap() = Some(req.id.clone());
|
||||
ready.notify_one();
|
||||
cancelled.wait_for(|value| *value).await.unwrap();
|
||||
if mode == "upload-before-dispatch" {
|
||||
// Finish won the race with cancellation, but tool.upload must
|
||||
// not be dispatched and the CLI still owns the staging.
|
||||
response(
|
||||
registry
|
||||
.finish_upload(serde_json::from_value(params).unwrap()),
|
||||
)
|
||||
} else {
|
||||
ResponseBody::Err(RpcError {
|
||||
code: ErrorCode::Cancelled,
|
||||
message: "parent cancellation reached daemon".into(),
|
||||
data: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
Method::Cancel => {
|
||||
let target = params["rpc_id"].as_str().unwrap();
|
||||
assert_eq!(pending.lock().unwrap().as_deref(), Some(target));
|
||||
cancel.send(true).unwrap();
|
||||
ResponseBody::Ok(json!({"cancelled": true}))
|
||||
}
|
||||
Method::TransferRelease => {
|
||||
let params: TransferIdParams =
|
||||
serde_json::from_value(params).unwrap();
|
||||
released.lock().unwrap().push(params.transfer_id.clone());
|
||||
if mode.contains("stall") {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
response(Ok(registry.release(params)))
|
||||
}
|
||||
other => panic!("unexpected request after cancellation: {other:?}"),
|
||||
};
|
||||
let response = Frame::Response(ResponseFrame { id: req.id, body });
|
||||
let mut bytes = serde_json::to_vec(&response).unwrap();
|
||||
bytes.push(b'\n');
|
||||
if write.write_all(&bytes).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_bsk"));
|
||||
cmd.env("BSK_HOME", home.path())
|
||||
.env("BSK_AUTO_UPDATE", "off")
|
||||
.env_remove("BSK_CANCEL_ON_STDIN_CLOSE")
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.creation_flags(0x0800_0000)
|
||||
.kill_on_drop(true);
|
||||
if mode.starts_with("upload") {
|
||||
let source = home.path().join("upload.txt");
|
||||
// Declare 300 MiB without allocating or transferring that much data.
|
||||
std::fs::File::create(&source)
|
||||
.unwrap()
|
||||
.set_len(if mode == "upload-before-dispatch" {
|
||||
16
|
||||
} else {
|
||||
300 * 1024 * 1024
|
||||
})
|
||||
.unwrap();
|
||||
cmd.args(["upload", "--session", "fixture", "--selector", "input"]);
|
||||
if mode.contains("multi") {
|
||||
let first = home.path().join("first.txt");
|
||||
std::fs::write(&first, b"first file").unwrap();
|
||||
cmd.arg("--file").arg(first);
|
||||
}
|
||||
cmd.arg("--file").arg(source);
|
||||
} else if mode == "download" {
|
||||
let out = home.path().join("out.txt");
|
||||
std::fs::write(&out, b"existing output").unwrap();
|
||||
cmd.args([
|
||||
"download",
|
||||
"--session",
|
||||
"fixture",
|
||||
"--selector",
|
||||
"a",
|
||||
"--overwrite",
|
||||
"--out",
|
||||
])
|
||||
.arg(out);
|
||||
} else {
|
||||
cmd.args(["wait-ms", if mode == "ordinary" { "1ms" } else { "60s" }]);
|
||||
}
|
||||
cmd.arg("--json");
|
||||
if mode == "ordinary" {
|
||||
cmd.stdin(std::process::Stdio::null());
|
||||
} else {
|
||||
cmd.env("BSK_CANCEL_ON_STDIN_CLOSE", "1")
|
||||
.stdin(std::process::Stdio::piped());
|
||||
}
|
||||
let mut child = cmd.spawn().unwrap();
|
||||
if mode != "ordinary" {
|
||||
tokio::time::timeout(Duration::from_secs(10), registered.notified())
|
||||
.await
|
||||
.expect("CLI registered business RPC");
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.write_all(b"still connected")
|
||||
.await
|
||||
.unwrap();
|
||||
drop(child.stdin.take());
|
||||
}
|
||||
let start = Instant::now();
|
||||
let output = tokio::time::timeout(Duration::from_secs(8), child.wait_with_output())
|
||||
.await
|
||||
.expect("CLI must settle promptly, including cleanup")
|
||||
.unwrap();
|
||||
server.abort();
|
||||
let _ = server.await;
|
||||
assert_eq!(
|
||||
output.status.code(),
|
||||
Some(if mode == "ordinary" { 0 } else { 2 }),
|
||||
"{mode}: stdout={} stderr={}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
if mode != "ordinary" {
|
||||
let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(body["code"], "cancelled");
|
||||
if mode != "upload-before-dispatch" {
|
||||
assert_eq!(body["message"], "parent cancellation reached daemon");
|
||||
}
|
||||
let received = received.lock().unwrap();
|
||||
let cancel = received.iter().position(|m| *m == Method::Cancel).unwrap();
|
||||
assert!(
|
||||
received[cancel + 1..]
|
||||
.iter()
|
||||
.all(|m| *m == Method::TransferRelease),
|
||||
"only cleanup may follow cancellation: {received:?}"
|
||||
);
|
||||
}
|
||||
let ids = ids.lock().unwrap();
|
||||
if mode.contains("stall") {
|
||||
assert_eq!(released.lock().unwrap().len(), 1);
|
||||
assert!(start.elapsed() >= Duration::from_secs(4));
|
||||
// The CLI cannot force an unresponsive daemon to clean up.
|
||||
transfers.release_session("fixture");
|
||||
} else {
|
||||
assert_eq!(
|
||||
*released.lock().unwrap(),
|
||||
*ids,
|
||||
"{mode}: every allocation must be released"
|
||||
);
|
||||
for id in ids.iter() {
|
||||
assert!(!staging_root.join(id).exists(), "staging leaked: {id}");
|
||||
}
|
||||
if mode.starts_with("upload") {
|
||||
let retry = transfers
|
||||
.begin_upload(TransferBeginParams {
|
||||
session_id: "fixture".into(),
|
||||
name: "retry.txt".into(),
|
||||
byte_size: 300 * 1024 * 1024,
|
||||
})
|
||||
.expect("same session must have room for another 300 MiB upload");
|
||||
transfers.release(TransferIdParams {
|
||||
transfer_id: retry.transfer_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
if mode == "download" {
|
||||
assert_eq!(
|
||||
std::fs::read(home.path().join("out.txt")).unwrap(),
|
||||
b"existing output"
|
||||
);
|
||||
assert!(
|
||||
!std::fs::read_dir(home.path())
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.any(|entry| entry.file_name().to_string_lossy().ends_with(".part"))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
# OOPIF occupied-scrollbar regression
|
||||
|
||||
An iframe's content quad includes its child viewport's scrollbar space, while
|
||||
`Page.getLayoutMetrics().cssLayoutViewport` excludes that space. Mapping the latter
|
||||
onto the whole quad stretches positions and sizes. The full target-local viewport
|
||||
must determine scale; the visible viewport must still clip content at every OOPIF
|
||||
boundary and constrain target-local action points.
|
||||
|
||||
This fixture alternates loopback hostnames across two nested OOPIFs. It includes
|
||||
scrolling, borders, padding, scaled iframe owners, a partially clipped button and
|
||||
a fully clipped button. Custom scrollbars occupy different widths and heights.
|
||||
The `scrollbars` query parameter accepts `both` (default), `vertical`, `horizontal`
|
||||
or `none`.
|
||||
|
||||
Run the numerical regression with Node 22+ and a local Chrome executable:
|
||||
|
||||
```sh
|
||||
BSK_GEOMETRY_CHROME=/path/to/chrome pnpm --filter @browser-skill/extension exec vitest run \
|
||||
src/tools/__tests__/snapshot-coordinates.browser.test.ts
|
||||
```
|
||||
|
||||
The existing browser runner is shared with the snapshot-unit regression. The test:
|
||||
|
||||
- Verifies actual OOPIF targets and occupied scrollbar dimensions.
|
||||
- Compares snapshot and live geometry with independent DOM rectangles and clipping.
|
||||
- Dispatches real root-target clicks and verifies the intended frame/button received them.
|
||||
- Rejects fully clipped controls and checks snapshot measurement reuse per target.
|
||||
- Covers five device-scale/browser-zoom combinations plus single-axis and no-scrollbar cases.
|
||||
|
||||
The runner creates and removes isolated browser profiles. It is opt-in and skipped
|
||||
in normal unit runs when `BSK_GEOMETRY_CHROME` is unset.
|
||||
|
||||
Run the CLI fixture smoke with a connected test extension:
|
||||
|
||||
```sh
|
||||
BSK_AUTO_UPDATE=off pnpm eval:browser smoke --case oopif-scrollbars --bsk ./target/debug/bsk
|
||||
```
|
||||
|
||||
The smoke assertions verify both nested frames have occupied scrollbars, that the
|
||||
marker is observed and that the session is closed. Numeric geometry and actual
|
||||
click assertions are exercised by the browser test above, not by smoke alone.
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"$schema": "../../../schemas/case.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "oopif-scrollbars",
|
||||
"title": "Nested OOPIFs with occupied scrollbars and clipped controls",
|
||||
"suite": "regression",
|
||||
"tags": ["geometry", "iframe", "scroll"],
|
||||
"fixture": { "startPath": "/oopif-scrollbars" },
|
||||
"prompts": {
|
||||
"en": "Open {url}, observe the page and its frames, report the OOPIF-SCROLLBARS marker, then close the session.",
|
||||
"zh-CN": "打开 {url},观察页面及其 iframe,报告 OOPIF-SCROLLBARS 标记,然后关闭会话。"
|
||||
},
|
||||
"coverage": ["session.start", "session.stop", "page.navigate", "inspect.observe"],
|
||||
"assertions": {
|
||||
"site": [
|
||||
{
|
||||
"label": "nested frame fixture settled",
|
||||
"type": "geometry.ready",
|
||||
"where": { "data.root": true },
|
||||
"minCount": 1
|
||||
},
|
||||
{
|
||||
"label": "both child frames have occupied scrollbars",
|
||||
"type": "geometry.scrollbars",
|
||||
"where": { "data.vertical": true, "data.horizontal": true },
|
||||
"minCount": 2
|
||||
}
|
||||
],
|
||||
"response": [{ "label": "observed the fixture marker", "includes": "OOPIF-SCROLLBARS" }],
|
||||
"adapter": [{ "label": "browser session was closed", "key": "sessionStopped" }]
|
||||
},
|
||||
"smoke": {
|
||||
"steps": [
|
||||
{ "action": "navigate" },
|
||||
{ "action": "wait-site-event", "type": "geometry.ready", "where": { "root": true } },
|
||||
{ "action": "observe" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { page, withRun } from "../../../lib/fixtures.mjs";
|
||||
|
||||
export default {
|
||||
id: "oopif-scrollbars",
|
||||
routes: ["/oopif-scrollbars", "/oopif-scrollbars/frame", "/oopif-scrollbars/nested"],
|
||||
render({ pathname, runId, query }) {
|
||||
const root = pathname === "/oopif-scrollbars";
|
||||
const nested = pathname.endsWith("/nested");
|
||||
const mode = query.get("scrollbars") ?? "both";
|
||||
return page({
|
||||
title: "OOPIF scrollbar geometry regression",
|
||||
body: `
|
||||
<style>
|
||||
main { margin: 0; width: auto; }
|
||||
body { width: 2000px; height: 2400px; }
|
||||
html { overflow-anchor: none; }
|
||||
${!root ? `html { overflow-x: ${mode === "vertical" || mode === "none" ? "hidden" : "scroll"}; overflow-y: ${mode === "horizontal" || mode === "none" ? "hidden" : "scroll"}; } html::-webkit-scrollbar { width: 17px; height: 11px; }` : ""}
|
||||
button { position: absolute; padding: 0; border: 0; border-radius: 0; }
|
||||
#probe { left: ${root ? 180 : nested ? 30 : 100}px; top: ${root ? 360 : nested ? 40 : 160}px; width: 60px; height: 20px; }
|
||||
#edge { position: fixed; right: -20px; bottom: -15px; width: 80px; height: 60px; }
|
||||
#outside { position: fixed; left: calc(100% + 2px); top: calc(100% + 2px); width: 10px; height: 10px; }
|
||||
iframe { position: absolute; box-sizing: content-box; width: 420px; height: 320px;
|
||||
left: 340px; top: 400px; border: 6px solid black; padding: 8px; transform: scale(1.2); transform-origin: 0 0; }
|
||||
#nested { left: 150px; top: 210px; width: 200px; height: 120px; border-width: 4px; padding: 6px; transform: scale(0.8); }
|
||||
</style>
|
||||
<button id="probe" data-geometry-probe>OOPIF-SCROLLBARS</button>
|
||||
${!root ? '<button id="edge" data-geometry-probe>EDGE</button><button id="outside" data-geometry-probe>OUTSIDE</button>' : ""}
|
||||
${!nested ? `<iframe id="${root ? "cross" : "nested"}" name="${root ? "cross" : "nested"}" title="${root ? "outer" : "nested"} cross-site frame"></iframe>` : ""}
|
||||
`,
|
||||
script: `
|
||||
history.scrollRestoration = "manual";
|
||||
const child = document.querySelector("iframe");
|
||||
if (child) {
|
||||
const url = new URL(${JSON.stringify(withRun(root ? "/oopif-scrollbars/frame" : "/oopif-scrollbars/nested", runId, { scrollbars: mode }))}, location.href);
|
||||
url.hostname = location.hostname === "127.0.0.1" ? "localhost" : "127.0.0.1";
|
||||
child.src = url.href;
|
||||
}
|
||||
document.addEventListener("click", event => {
|
||||
const probe = event.target.closest("[data-geometry-probe]");
|
||||
if (probe) browserEval.send("geometry.clicked", { probe: probe.id });
|
||||
});
|
||||
window.addEventListener("load", () => {
|
||||
scrollTo(${root ? "80, 240" : nested ? "10, 20" : "40, 100"});
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
document.documentElement.dataset.geometryReady = "true";
|
||||
if (!${root}) browserEval.send("geometry.scrollbars", {
|
||||
vertical: innerWidth > document.documentElement.clientWidth,
|
||||
horizontal: innerHeight > document.documentElement.clientHeight,
|
||||
});
|
||||
browserEval.send("geometry.ready", { root: ${root} });
|
||||
}));
|
||||
});
|
||||
`,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
# DOMSnapshot layout-unit regression (#195)
|
||||
|
||||
Source: https://github.com/Tencent/BrowserSkill/pull/195
|
||||
|
||||
DOMSnapshot bounds and document scroll offsets retain Blink layout units. Treating them as CSS
|
||||
pixels changes both positions and sizes under device scaling/browser zoom, before iframe
|
||||
projection or viewport clipping even starts.
|
||||
|
||||
The fixture contains a scrolled root, a scrolled same-process iframe, an OOPIF served from the
|
||||
other loopback hostname, and a nested iframe in each. Owners have borders, padding and CSS
|
||||
transforms. The geometry test compares the production capture's local and top-level rectangles
|
||||
against independent DOM border boxes, with at most 2 CSS pixels of layout rounding. It also
|
||||
checks that each target reads layout metrics only once. No credentials or personal tabs are used.
|
||||
|
||||
Run the geometric assertions with Node 22+ and a local Chrome executable:
|
||||
|
||||
```sh
|
||||
BSK_GEOMETRY_CHROME=/path/to/chrome pnpm --filter @browser-skill/extension exec vitest run \
|
||||
src/tools/__tests__/snapshot-coordinates.browser.test.ts
|
||||
```
|
||||
|
||||
The test launches and cleans up its own headless Chrome profiles. It explicitly verifies the
|
||||
requested browser zoom and OOPIF topology, so unsupported setups fail rather than silently
|
||||
testing a different configuration. It is skipped in ordinary unit runs unless the executable
|
||||
is specified. No browser download or new package dependency is required.
|
||||
|
||||
The corpus smoke case checks fixture readiness and observable semantics through the real CLI:
|
||||
|
||||
```sh
|
||||
BSK_AUTO_UPDATE=off pnpm eval:browser smoke --case snapshot-coordinates --bsk ./target/debug/bsk
|
||||
```
|
||||
|
||||
Smoke alone does **not** certify coordinate accuracy; CLI observations do not expose raw boxes.
|
||||
Use the geometric test above for the regression's numeric assertions.
|
||||
|
||||
## Related scrollbar regression
|
||||
|
||||
The unit-conversion fixture keeps non-occupying OOPIF scrollbars by default. The same
|
||||
browser test also runs the dedicated [OOPIF scrollbar fixture](../oopif-scrollbars/README.md),
|
||||
which covers occupied scrollbars, two OOPIF boundaries, clipping and real clicks.
|
||||
Append `&classic-scrollbars` to this fixture's URL to enable its normal OOPIF scrollbars.
|
||||
@@ -1,99 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// This regression owns its browser/profile. It never attaches to a user's Chrome.
|
||||
export async function withChrome({ executable, deviceScale, zoom }, run) {
|
||||
const profile = await mkdtemp(join(tmpdir(), "bsk-snapshot-coordinates-"));
|
||||
let chrome;
|
||||
let socket;
|
||||
const pending = new Map();
|
||||
try {
|
||||
await mkdir(join(profile, "Default"));
|
||||
await writeFile(
|
||||
join(profile, "Default", "Preferences"),
|
||||
JSON.stringify({
|
||||
partition: { default_zoom_level: { x: Math.log(zoom) / Math.log(1.2) } },
|
||||
}),
|
||||
);
|
||||
chrome = spawn(
|
||||
executable,
|
||||
[
|
||||
"--headless=new",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-extensions",
|
||||
"--site-per-process",
|
||||
"--remote-debugging-port=0",
|
||||
"--window-size=1600,1200",
|
||||
`--force-device-scale-factor=${deviceScale}`,
|
||||
`--user-data-dir=${profile}`,
|
||||
"about:blank",
|
||||
],
|
||||
{ stdio: ["ignore", "ignore", "pipe"] },
|
||||
);
|
||||
const endpoint = await new Promise((resolve, reject) => {
|
||||
let output = "";
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error(`Chrome startup timed out: ${output}`)),
|
||||
15_000,
|
||||
);
|
||||
const fail = (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
};
|
||||
chrome.once("error", fail);
|
||||
chrome.once("exit", (code) => fail(new Error(`Chrome exited (${code}): ${output}`)));
|
||||
chrome.stderr.on("data", (chunk) => {
|
||||
output += chunk;
|
||||
const match = output.match(/DevTools listening on (ws:\/\/\S+)/);
|
||||
if (match) {
|
||||
clearTimeout(timeout);
|
||||
resolve(match[1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
socket = new WebSocket(endpoint);
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.addEventListener("open", resolve, { once: true });
|
||||
socket.addEventListener("error", reject, { once: true });
|
||||
});
|
||||
socket.addEventListener("message", ({ data }) => {
|
||||
const reply = JSON.parse(data);
|
||||
const request = pending.get(reply.id);
|
||||
if (!request) return;
|
||||
clearTimeout(request.timeout);
|
||||
pending.delete(reply.id);
|
||||
if (reply.error) request.reject(new Error(JSON.stringify(reply.error)));
|
||||
else request.resolve(reply.result);
|
||||
});
|
||||
let sequence = 0;
|
||||
const send = (method, params = {}, sessionId) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const id = ++sequence;
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(`CDP timed out: ${method}`));
|
||||
}, 10_000);
|
||||
pending.set(id, { resolve, reject, timeout });
|
||||
socket.send(JSON.stringify({ id, method, params, sessionId }));
|
||||
});
|
||||
await run(send);
|
||||
} finally {
|
||||
for (const request of pending.values()) {
|
||||
clearTimeout(request.timeout);
|
||||
request.reject(new Error("regression browser closed"));
|
||||
}
|
||||
socket?.close();
|
||||
if (chrome && chrome.exitCode === null && chrome.signalCode === null) {
|
||||
const exited = once(chrome, "exit");
|
||||
const timeout = setTimeout(() => chrome.kill("SIGKILL"), 5_000);
|
||||
chrome.kill();
|
||||
await exited;
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
await rm(profile, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"$schema": "../../../schemas/case.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "snapshot-coordinates",
|
||||
"title": "Scrolled and nested frame coordinate fixture",
|
||||
"suite": "regression",
|
||||
"tags": ["geometry", "iframe", "scroll"],
|
||||
"fixture": { "startPath": "/snapshot-coordinates" },
|
||||
"prompts": {
|
||||
"en": "Open {url}, observe the page and its frames, report the GEOMETRY marker, then close the session.",
|
||||
"zh-CN": "打开 {url},观察页面及其 iframe,报告 GEOMETRY 标记,然后关闭会话。"
|
||||
},
|
||||
"coverage": ["session.start", "session.stop", "page.navigate", "inspect.observe"],
|
||||
"assertions": {
|
||||
"site": [
|
||||
{
|
||||
"label": "root fixture scrolled and settled",
|
||||
"type": "geometry.ready",
|
||||
"where": { "data.root": true },
|
||||
"minCount": 1
|
||||
}
|
||||
],
|
||||
"response": [{ "label": "observed the fixture marker", "includes": "GEOMETRY-195" }],
|
||||
"adapter": [{ "label": "browser session was closed", "key": "sessionStopped" }]
|
||||
},
|
||||
"smoke": {
|
||||
"steps": [
|
||||
{ "action": "navigate" },
|
||||
{ "action": "wait-site-event", "type": "geometry.ready", "where": { "root": true } },
|
||||
{ "action": "observe" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { page, withRun } from "../../../lib/fixtures.mjs";
|
||||
|
||||
export default {
|
||||
id: "snapshot-coordinates",
|
||||
routes: ["/snapshot-coordinates", "/snapshot-coordinates/frame", "/snapshot-coordinates/nested"],
|
||||
render({ pathname, runId, query }) {
|
||||
const root = pathname === "/snapshot-coordinates";
|
||||
const nested = pathname.endsWith("/nested");
|
||||
return page({
|
||||
title: "Snapshot coordinate regression",
|
||||
body: `
|
||||
<style>
|
||||
main { margin: 0; width: auto; }
|
||||
body { width: 2000px; height: 2400px; }
|
||||
html { overflow-anchor: none; }
|
||||
${query.get("scrollbars") === "none" ? "html { scrollbar-width: none; }" : ""}
|
||||
button { position: absolute; box-sizing: border-box; padding: 0; border: 2px solid black; border-radius: 0; }
|
||||
#probe { left: ${root ? 180 : 100}px; top: ${root ? 360 : 160}px; width: 120px; height: 40px; }
|
||||
iframe { position: absolute; box-sizing: content-box; width: 320px; height: 220px;
|
||||
border: 6px solid black; padding: 8px; transform: scale(1.25); transform-origin: 0 0; }
|
||||
#same { left: 340px; top: 400px; }
|
||||
#cross { left: 820px; top: 400px; }
|
||||
#nested { left: 150px; top: 210px; width: 130px; height: 70px; border-width: 2px; padding: 4px; transform: scale(0.8); }
|
||||
${nested ? "#probe { left: 30px; top: 40px; width: 60px; height: 20px; }" : ""}
|
||||
</style>
|
||||
<button id="probe" data-geometry-probe>GEOMETRY-195</button>
|
||||
${root ? `<iframe id="same" name="same" title="same-process frame" src="${withRun("/snapshot-coordinates/frame", runId)}"></iframe><iframe id="cross" name="cross" title="cross-site frame"></iframe>` : ""}
|
||||
${!root && !nested ? `<iframe id="nested" name="nested" title="nested frame" src="${withRun("/snapshot-coordinates/nested", runId)}"></iframe>` : ""}
|
||||
`,
|
||||
script: `
|
||||
history.scrollRestoration = "manual";
|
||||
const cross = document.querySelector("#cross");
|
||||
if (cross) {
|
||||
const url = new URL(${JSON.stringify(withRun("/snapshot-coordinates/frame", runId))}, location.href);
|
||||
url.hostname = location.hostname === "127.0.0.1" ? "localhost" : "127.0.0.1";
|
||||
// Isolate unit conversion from the pre-existing OOPIF scrollbar-width
|
||||
// projection issue. ?classic-scrollbars retains that separate reproduction.
|
||||
if (!new URLSearchParams(location.search).has("classic-scrollbars")) url.searchParams.set("scrollbars", "none");
|
||||
cross.src = url.href;
|
||||
}
|
||||
window.addEventListener("load", () => {
|
||||
scrollTo(${root ? "80, 240" : nested ? "10, 20" : "40, 100"});
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
window.geometryReady = true;
|
||||
document.documentElement.dataset.geometryReady = "true";
|
||||
browserEval.send("geometry.ready", { root: ${root} });
|
||||
}));
|
||||
});
|
||||
`,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -41,23 +41,17 @@ test("case manifests are discovered, ordered, and grouped into suites", () => {
|
||||
"diagnostics",
|
||||
"mobile-emulation",
|
||||
"generated-form",
|
||||
"oopif-scrollbars",
|
||||
"snapshot-coordinates",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(repositorySummary(cases, fixtureRegistry).suites, {
|
||||
core: 6,
|
||||
matrix: 1,
|
||||
regression: 2,
|
||||
});
|
||||
assert.deepEqual(repositorySummary(cases, fixtureRegistry).suites, { core: 6, matrix: 1 });
|
||||
});
|
||||
|
||||
test("repository validation links every case to a fixture and valid workflow evidence", () => {
|
||||
assert.deepEqual(validateRepositoryCases(cases, fixtureRegistry), []);
|
||||
const summary = repositorySummary(cases, fixtureRegistry);
|
||||
assert.equal(summary.cases, 9);
|
||||
assert.equal(summary.fixtureModules, 10);
|
||||
assert.equal(summary.fixtureRoutes, 18);
|
||||
assert.equal(summary.cases, 7);
|
||||
assert.equal(summary.fixtureModules, 8);
|
||||
assert.equal(summary.fixtureRoutes, 12);
|
||||
});
|
||||
|
||||
test("manifest validation rejects unknown operations and incomplete workflow steps", () => {
|
||||
|
||||
+15
-41
@@ -21,7 +21,6 @@ $ErrorActionPreference = "Stop"
|
||||
|
||||
$Repo = if ($env:BSK_REPO) { $env:BSK_REPO } else { "Tencent/BrowserSkill" }
|
||||
$InstallDir = if ($env:BSK_INSTALL_DIR) { $env:BSK_INSTALL_DIR } else { Join-Path $HOME ".local\bin" }
|
||||
$InstallDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($InstallDir)
|
||||
$GitHub = "https://github.com/${Repo}"
|
||||
|
||||
function Write-Log {
|
||||
@@ -65,7 +64,7 @@ function Get-PlatformTriple {
|
||||
function Add-ToUserPath {
|
||||
param([string]$Dir)
|
||||
|
||||
$currentUserPath = @([Environment]::GetEnvironmentVariable("PATH", "User") -split ";" | Where-Object { $_ })
|
||||
$currentUserPath = [Environment]::GetEnvironmentVariable("PATH", "User") -split ";" | Where-Object { $_ }
|
||||
|
||||
if ($currentUserPath -contains $Dir) {
|
||||
Write-Log "$Dir is already in your user PATH"
|
||||
@@ -91,59 +90,30 @@ function Add-ToSessionPath {
|
||||
# ── Git Bash (bash environment) PATH helper ──────────────────────────────────
|
||||
|
||||
function Add-ToBashProfile {
|
||||
param([string]$Dir, [string]$BashRc = (Join-Path $HOME ".bashrc"))
|
||||
param([string]$Dir)
|
||||
|
||||
# Convert Windows path (e.g. C:\Users\foo\.local\bin) to Git-Bash Unix-style (/c/Users/foo/.local/bin)
|
||||
$unixPath = $Dir -replace '\\', '/'
|
||||
if ($unixPath -match '^([A-Z]):(.*)$') {
|
||||
$unixPath = '/' + $matches[1].ToLower() + $matches[2]
|
||||
}
|
||||
# Single-quote the literal directory; only the existing PATH is expanded.
|
||||
$shellQuote = "'" + [char]34 + "'" + [char]34 + "'"
|
||||
$quotedPath = "'" + $unixPath.Replace("'", $shellQuote) + "'"
|
||||
$exportLine = "export PATH=${quotedPath}:`"`$PATH`" # bsk CLI"
|
||||
$bashRc = Join-Path $HOME ".bashrc"
|
||||
$exportLine = "export PATH=""${unixPath}:`$PATH"" # bsk CLI"
|
||||
|
||||
if (Test-Path -LiteralPath $BashRc) {
|
||||
$content = [System.IO.File]::ReadAllText($BashRc)
|
||||
if ($content.Contains($exportLine)) {
|
||||
if (Test-Path $bashRc) {
|
||||
$content = Get-Content $bashRc -Raw -ErrorAction SilentlyContinue
|
||||
if ($content -match [regex]::Escape($unixPath)) {
|
||||
Write-Log "$unixPath is already in ~/.bashrc"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
# Explicit BOM-less UTF-8 also works in Windows PowerShell 5.1.
|
||||
[System.IO.File]::AppendAllText($BashRc, "`n$exportLine`n", (New-Object System.Text.UTF8Encoding($false)))
|
||||
Add-Content $bashRc "`n$exportLine" -Encoding ASCII
|
||||
Write-Log "added ${unixPath} to ~/.bashrc"
|
||||
}
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Stage on the destination volume before stopping the daemon. Never truncate
|
||||
# the installed executable: failed replacement must leave it usable.
|
||||
function Install-Binary {
|
||||
param([string]$Source, [string]$Target)
|
||||
|
||||
$staged = "$Target.install-$([Guid]::NewGuid().ToString('N'))"
|
||||
try {
|
||||
[System.IO.File]::Copy($Source, $staged)
|
||||
if ([System.IO.File]::Exists($Target)) {
|
||||
# Use the downloaded CLI: older versions have broken Windows
|
||||
# liveness checks. Stop verifies daemon identity before terminating it.
|
||||
& $Source daemon stop
|
||||
if ($LASTEXITCODE -ne 0) { throw "could not stop bsk daemon; existing installation was not replaced" }
|
||||
# PowerShell 5.1 converts $null to an empty path for string parameters.
|
||||
[System.IO.File]::Replace($staged, $Target, [NullString]::Value)
|
||||
Write-Log "daemon will restart automatically on the next browser command"
|
||||
}
|
||||
else {
|
||||
[System.IO.File]::Move($staged, $Target)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ([System.IO.File]::Exists($staged)) { [System.IO.File]::Delete($staged) }
|
||||
}
|
||||
}
|
||||
|
||||
function Main {
|
||||
$platform = Get-PlatformTriple
|
||||
|
||||
@@ -214,7 +184,7 @@ function Main {
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
}
|
||||
|
||||
Install-Binary -Source (Join-Path $tempDir "bsk.exe") -Target (Join-Path $InstallDir "bsk.exe")
|
||||
Copy-Item -Path (Join-Path $tempDir "bsk.exe") -Destination (Join-Path $InstallDir "bsk.exe") -Force
|
||||
|
||||
Write-Log "installed bsk to $InstallDir\bsk.exe"
|
||||
|
||||
@@ -229,8 +199,12 @@ function Main {
|
||||
|
||||
# Verify
|
||||
$bskPath = Join-Path $InstallDir "bsk.exe"
|
||||
& $bskPath --version
|
||||
if ($LASTEXITCODE -ne 0) { throw "installed bsk failed verification" }
|
||||
if (Get-Command bsk -ErrorAction SilentlyContinue) {
|
||||
& bsk --version
|
||||
}
|
||||
else {
|
||||
Write-Log "verify install: & ""$bskPath"" --version"
|
||||
}
|
||||
|
||||
Write-Log "done"
|
||||
Write-Host ""
|
||||
|
||||
@@ -1,51 +1,13 @@
|
||||
# BrowserSkill for DeepSeek Harness
|
||||
# dsh-plugin-browserskill
|
||||
|
||||
[](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin)
|
||||
npm: [`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin)
|
||||
|
||||
Use [BrowserSkill](https://github.com/Tencent/BrowserSkill) in
|
||||
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`) to browse
|
||||
websites, fill forms, and capture screenshots through native `browser_*` tools.
|
||||
Browser tasks run in Agent Windows, with a live view in the dsh Web UI.
|
||||
A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) tool plugin that exposes
|
||||
[BrowserSkill](https://github.com/Tencent/BrowserSkill) (`bsk`) browser automation to the model.
|
||||
|
||||
## Installation
|
||||
|
||||
Before installing the plugin:
|
||||
|
||||
- Install [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) and
|
||||
[pnpm](https://pnpm.io/installation), which dsh uses to manage plugins.
|
||||
- Install the `bsk` CLI and connect the BrowserSkill extension in Chrome or Edge.
|
||||
Follow the [BrowserSkill setup guide](https://github.com/Tencent/BrowserSkill#quick-start).
|
||||
- Make sure `bsk` is on the `PATH` used to start dsh, or set `bskPath` in the plugin configuration.
|
||||
|
||||
Install the plugin into the `web` profile, then start it:
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin
|
||||
dsh --profile web
|
||||
```
|
||||
|
||||
Replace `web` with your profile name if you use a different profile. The plugin
|
||||
includes the `browser-skill` skill; no separate `bsk install-skill` step is needed.
|
||||
|
||||
In a conversation, try:
|
||||
|
||||
```text
|
||||
/browser-skill open example.com and summarize the page.
|
||||
```
|
||||
|
||||
By default, the browser tools become available when the skill is invoked.
|
||||
|
||||
## Updating
|
||||
|
||||
Installed plugins do not update automatically. To upgrade this plugin to npm's
|
||||
`latest` version, including versions outside the profile's saved dependency range:
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web update @wxg-prc-cpg/browser-skill-dsh-plugin --latest
|
||||
```
|
||||
|
||||
Restart that dsh profile after upgrading. This command updates the plugin; update
|
||||
the `bsk` CLI and browser extension separately when a release requires it.
|
||||
Each action maps to one `bsk <cmd> --json` invocation: the plugin spawns the bsk CLI, parses its
|
||||
structured JSON output, and returns a canonical typed value. The bsk daemon, browser, and browser
|
||||
extension keep owning the actual browser control — this package is a thin, well-typed bridge.
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -58,7 +20,23 @@ the `bsk` CLI and browser extension separately when a release requires it.
|
||||
| `browser_tabs` | `list`, `create`, `select`, `close`, `borrow`, `return` | Manage Agent Window tabs and temporarily borrow user tabs. |
|
||||
| `browser_assist` | `resize`, `emulate`, `request-help` | Resize or emulate the browser and pause for human-only steps. |
|
||||
|
||||
Arbitrary page-script evaluation and interaction recording are not supported.
|
||||
`bsk evaluate` and `bsk record` are intentionally not exposed by this plugin: arbitrary page
|
||||
evaluation is a higher-risk capability, while recording is long-running and needs a dedicated
|
||||
DeepSeek Harness lifecycle before it can be added safely.
|
||||
|
||||
## Agent skill (progressive disclosure)
|
||||
|
||||
Beyond the tools, the plugin publishes the **`browser-skill` agent skill** through the harness's
|
||||
official skill seam (`ctx.skills.register`): the catalog entry (name + routing description) is
|
||||
resident in `<available_skills>`, and the body is loaded only when the model invokes the `skill`
|
||||
tool. Its single source is the DSH-specific `skill/SKILL.md`, which documents only structured
|
||||
`browser_*` calls and their plugin semantics. The repository-root CLI skill is intentionally not
|
||||
concatenated: its command examples belong to a different execution interface and would bypass
|
||||
the plugin's ownership, live observation UI, cancellation, and cleanup path if followed directly.
|
||||
The build rejects internal CLI-name leakage, command-line code blocks, unknown browser tools, and
|
||||
missing supported browser tools before embedding the Markdown. Registration and every pre-step
|
||||
catalog snapshot are pure in-memory reads (no disk/process/daemon); compositions without the skill
|
||||
seam degrade silently.
|
||||
|
||||
## Multi-session model
|
||||
|
||||
@@ -77,73 +55,136 @@ an explicit `session` argument naming a foreign or unknown id is rejected, the `
|
||||
`browser_session` shows plugin-created sessions only (no daemon-wide view), and stop/unload cleanup
|
||||
can never touch a session owned by another program.
|
||||
|
||||
## Configuration
|
||||
## Installation
|
||||
|
||||
After installing the plugin, edit your profile's `cordis.patch.yml`. For the `web`
|
||||
profile, the default location is `~/.dsh/profiles/web/cordis.patch.yml`. If you set
|
||||
`DSH_HOME`, use `$DSH_HOME/profiles/web/cordis.patch.yml` instead. Replace `web` with
|
||||
your profile name as needed.
|
||||
The plugin follows the standard dsh bundle layout (`dsh.bundle` manifest + `cordis.patch.yml`):
|
||||
|
||||
If the file contains only comments and `[]`, keep the comments and replace `[]`
|
||||
with the YAML below. If it already contains patch entries, add this entry to the
|
||||
existing list or edit its existing `id: browserskill` entry. Keep a single
|
||||
top-level YAML list. This overrides the plugin registered by the installed bundle:
|
||||
|
||||
```yaml
|
||||
- id: browserskill
|
||||
config:
|
||||
bskPath: bsk
|
||||
defaultTimeoutMs: 120000
|
||||
maxSessions: 5
|
||||
observationEnabled: true
|
||||
thumbnailIntervalMs: 1500
|
||||
idleIntervalMs: 8000
|
||||
lazyTools: true
|
||||
```sh
|
||||
dsh plugin --profile <name> add @wxg-prc-cpg/browser-skill-dsh-plugin
|
||||
dsh --profile <name>
|
||||
```
|
||||
|
||||
Change `bskPath` to the full path of your CLI binary if it is not on dsh's `PATH`.
|
||||
A patch replaces the entry's entire `config` object, so keep all overrides you need
|
||||
together in that object.
|
||||
Prerequisite: the `bsk` CLI must be installed and on `PATH`, and the BrowserSkill browser extension
|
||||
(Chrome or Edge) must be connected — see the
|
||||
[BrowserSkill README](https://github.com/Tencent/BrowserSkill). When bsk is missing, tool calls fail
|
||||
with install guidance instead of a bare spawn error.
|
||||
|
||||
Configuration changes follow `dsh.profile.patchReload` in the profile's
|
||||
`package.json`: `live` (the default for `web`) applies changes when you save the
|
||||
patch file; `startup` requires restarting the profile. Restart after upgrading
|
||||
the plugin in either case.
|
||||
## Configuration
|
||||
|
||||
All fields are optional; omitted fields use the defaults below:
|
||||
All fields are optional and validated through the plugin's Schemastery `Config`:
|
||||
|
||||
| Option | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `bskPath` | `bsk` | Path to the CLI binary. |
|
||||
| `defaultTimeoutMs` | `120000` | Default command timeout in milliseconds. |
|
||||
| `maxSessions` | `5` | Maximum concurrent sessions started by this plugin. |
|
||||
| `observationEnabled` | `true` | Enable live browser observation. |
|
||||
| `thumbnailIntervalMs` | `1500` | Screenshot interval for active sessions, in milliseconds. |
|
||||
| `idleIntervalMs` | `8000` | Screenshot interval for idle sessions and the recent-activity window, in milliseconds. |
|
||||
| `lazyTools` | `true` | Reveal the browser tools when the skill is invoked. Set `false` to register them at startup. |
|
||||
```yaml
|
||||
# cordis.patch.yml override example
|
||||
- insert:
|
||||
- id: browserskill
|
||||
name: "@wxg-prc-cpg/browser-skill-dsh-plugin"
|
||||
config:
|
||||
bskPath: bsk # path to the bsk binary (default: resolve from PATH)
|
||||
defaultTimeoutMs: 120000
|
||||
maxSessions: 5
|
||||
# observationEnabled: true # live PiP/overlay observation (below)
|
||||
# thumbnailIntervalMs: 1500 # frame cadence while a session is active
|
||||
# idleIntervalMs: 8000 # idle cadence / recent-activity window
|
||||
# lazyTools: true # reveal browser_* tools only after the skill is invoked
|
||||
```
|
||||
|
||||
With `lazyTools: true`, only the skill's catalog entry is initially advertised to
|
||||
the model. The six `browser_*` tool schemas are added to the system prompt after
|
||||
the `browser-skill` skill is successfully invoked, either by the model or through
|
||||
`/browser-skill`. Set `lazyTools: false` to make the tools available immediately.
|
||||
- **`lazyTools` (default `true`)** — the final progressive-disclosure stage: the six
|
||||
`browser_*` tool schemas stay OUT of the system prompt (zero schema tokens) until the
|
||||
`browser-skill` skill is actually invoked — the skill catalog entry is the only
|
||||
advertisement. One successful invocation (model tool call, or a `/browser-skill` user
|
||||
gesture) registers the whole suite for the rest of the process; repeated invocations are
|
||||
no-ops, and sessions resumed with a past invocation in their durable log reveal the suite
|
||||
on entry. Set `false` for the legacy always-on registration.
|
||||
|
||||
## Live browser view
|
||||
## Observation overlay (PiP mini-window)
|
||||
|
||||
The dsh Web UI shows the plugin's browser sessions in a floating panel. If your
|
||||
profile provides the `dsh-better-sidebar` integration, the view appears in a
|
||||
**Browser Skill** sidebar tab instead.
|
||||
When the plugin runs inside the dsh Web UI, an **observation overlay** floats over the app
|
||||
(registered into the `shell.overlay` seat): a breathing thumbnail per owned session plus its
|
||||
current action and elapsed time. The card docks at the top-right of the content area (clear of
|
||||
the composer and the shell's header controls) and wears the **BrowserSkill product family's own
|
||||
look**: the overlay reuses `@browser-skill/ui` components (`Button`, `cn`) and its oklch design
|
||||
tokens (`--card`, `--primary`, `--destructive`, `--ring`, …), status dots spec'd after the
|
||||
extension popup's connection indicator, and Remix icons. The BSK utility sheet is compiled
|
||||
scoped under the `.bsk-obs` root class (`scripts/build-client-css.mjs`), so the overlay looks
|
||||
like the BrowserSkill extension without leaking a single selector into the host shell — and the
|
||||
shell's theme cannot bleed back in.
|
||||
|
||||
- See the current action, elapsed time, and recent screenshot for each session.
|
||||
- Select a session to focus on it. The sidebar view follows the current conversation.
|
||||
- Use **Interrupt** to cancel the current browser command. The agent may continue
|
||||
with another action afterward.
|
||||
- Drag or resize the floating panel, or use **Pop out** to open a Picture-in-Picture
|
||||
window in browsers that support it.
|
||||
- Periodic screenshots are requested while a browser observation view is visible.
|
||||
Configure the active and idle intervals with the options above.
|
||||
- **Lifecycle**: hidden while the plugin owns no sessions; appears on the first
|
||||
`browser_session` start action; disappears when all sessions stop (or the plugin unloads).
|
||||
- **Focus view**: status row (green/idle/red dot + session + action + mm:ss), the latest page
|
||||
frame (while viewed, refreshes every ~1.5s while active, ~8s when idle; the last good frame stays on
|
||||
stage while the next one loads, and is kept on errors so the card does not flash),
|
||||
and a compact icon toolbar (Interrupt + Pop out, hover for the label).
|
||||
- **Interrupt**: one click kills the in-flight bsk command of the focus session (same semantics
|
||||
as the chat Stop button — the current action fails, the agent run may continue). Strip items
|
||||
carry their own hover interrupt button.
|
||||
- **Multi-session strip**: every session gets a tile (mini frame + id + status dot); focus
|
||||
auto-follows the most recently active session; clicking a tile pins focus (pin badge, click
|
||||
again to release); errored sessions get a red edge without stealing focus; sessions the daemon
|
||||
lost are greyed out; prolonged daemon/browser outage shows "browser unavailable" and greys
|
||||
the interrupt button until captures recover.
|
||||
- **Drag & resize**: drag the header to move the card; drag any of the four
|
||||
corners to resize (min 240×180, max 80% of the viewport; no visible grip).
|
||||
Both are remembered for the page lifetime.
|
||||
- **Pop out (PiP)**: upgrades the card into a native Document PiP window (requires a user
|
||||
gesture, per browser rules), sized from the current card; closing the PiP falls back to the
|
||||
in-page card with state intact. Browsers without Document PiP simply hide the button.
|
||||
- **Screenshot demand**: the client requests periodic screenshots while an observation view is
|
||||
visible, using the PiP document's visibility when popped out. Hidden or collapsed views can
|
||||
keep subscribing to state without requesting screenshots. The first screenshot subscriber
|
||||
starts capture scheduling; the last one's departure cancels timers and skips queued captures.
|
||||
An already running capture may finish. Demand applies to the service's owned sessions, not just
|
||||
the selected session.
|
||||
- **Wire**: `GET /bsk-observation/events` is the live synchronization channel (SSE). Every connection,
|
||||
including reconnects, starts with `{ type: "snapshot", sessions, available }`, followed by
|
||||
`upsert`, `remove`, `reset`, and `availability` events on the same stream. Replace local state
|
||||
with each snapshot before applying subsequent events. Use `?thumbnails=0` for state-only
|
||||
subscriptions or `?thumbnails=1` to request screenshots; omitting the parameter defaults to
|
||||
requesting screenshots for compatibility. Closing the stream releases its screenshot demand.
|
||||
`GET /bsk-observation/state` remains a one-time `{ sessions, available }` read and does not
|
||||
request screenshots. It has no ordering guarantee relative to a separate SSE connection;
|
||||
live clients should initialize and resynchronize from the stream's snapshot instead.
|
||||
The host also serves `POST /bsk-observation/interrupt`, `POST /bsk-observation/stop`, and
|
||||
`GET /bsk-observation/thumbnail/<attachmentId>` through the dsh `webServer` route seam
|
||||
(dsh 0.1's Typert Remote pipeline is closed to out-of-tree packages). All commands for one
|
||||
session — tool calls and frame captures alike — run through a per-session FIFO, because the
|
||||
daemon accepts only one unfinished command per session.
|
||||
- **Programmatic subscription**: `ObservationService.subscribe(listener, { thumbnails: false })`
|
||||
subscribes to state without requesting screenshots. On an active service, the listener receives
|
||||
the initial `snapshot` synchronously, before `subscribe` returns, then receives subsequent
|
||||
changes. `thumbnails` defaults to `true`, so callers should choose it explicitly. The returned
|
||||
unsubscribe function is safe to call repeatedly and releases this subscription's screenshot
|
||||
demand. Subscribing after service disposal is a no-op.
|
||||
- **Trust model**: these routes expose live screenshots (and an interrupt write), so they
|
||||
replicate the browser-trust fence dsh applies to its own `/api` routes: the request Host
|
||||
must be a loopback authority (`localhost`, `127.0.0.0/8`, `[::1]`), a present Origin must
|
||||
match the Host, `sec-fetch-site: cross-site` is refused, and POST requires an
|
||||
`application/json` body (cross-site simple requests can never satisfy that). The channel is
|
||||
therefore built for **loopback-only serving** — binding the dsh web server to `0.0.0.0` and
|
||||
reaching it through a LAN address will (deliberately) fail the fence; do not put these
|
||||
routes behind a non-loopback reverse proxy without adding your own authentication.
|
||||
- Configure with `observationEnabled` / `thumbnailIntervalMs` / `idleIntervalMs`.
|
||||
|
||||
The observation endpoints require a loopback address such as `localhost` or
|
||||
`127.0.0.1`. Access through a LAN hostname or non-loopback reverse proxy is not supported.
|
||||
## Behavior notes
|
||||
|
||||
- **Cancellation**: aborting a tool call (`exec.signal`) kills the underlying bsk child process,
|
||||
matching BrowserSkill's cooperative tool-cancellation model.
|
||||
- **UI cards**: calls render as terminal cards (command line as title, output as the completed
|
||||
card). Screenshots additionally attach the image itself when the host mounts an attachment store
|
||||
and the active model route declares image input; otherwise the PNG path is returned.
|
||||
- **Web UI toolview (browser half)**: the package is dual-face. `dsh.client` (platform `web`) ships
|
||||
`lib/client.cjs`, which registers a keyed `tool.call.toolview` view for `browser_inspect`. The
|
||||
custom view keeps a terminal block for every inspect action and, when a screenshot result carries
|
||||
an image block, resolves the durable attachment through the client session's authorized
|
||||
`readAttachment` RPC and renders it with the shared `MessageImage` thumbnail/lightbox atoms.
|
||||
The other five tools keep the stock terminal card. The bundle follows the dsh client
|
||||
contract: a CJS closure factory handed to `window.__ModuleLoader__.load`, platform modules
|
||||
(`react`, `dsh-client-ui-*`) external, everything else inlined, CSS Modules compiled by
|
||||
lightningcss.
|
||||
- **Errors**: non-zero bsk exits surface the CLI's JSON error envelope (`code`, `message`, `hint`)
|
||||
so the model gets the daemon's actionable guidance.
|
||||
- **Long-running work** (e.g. `bsk record`) is not backgrounded via `ctx.jobs` yet — tracked as a
|
||||
follow-up.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -154,47 +195,22 @@ pnpm --filter @wxg-prc-cpg/browser-skill-dsh-plugin test # unit tests mock b
|
||||
pnpm --filter @wxg-prc-cpg/browser-skill-dsh-plugin build # tsdown -> lib/
|
||||
```
|
||||
|
||||
See the [development notes](https://github.com/Tencent/BrowserSkill/blob/main/packages/dsh-plugin-browserskill/docs/development.md)
|
||||
for skill registration, tool results, and observation APIs in the current source.
|
||||
|
||||
## Publishing
|
||||
|
||||
The [Release dsh plugin workflow](https://github.com/Tencent/BrowserSkill/blob/main/.github/workflows/release-dsh-plugin.yml)
|
||||
publishes the package and this README to npm. Pushing a `dsh-plugin-vX.Y.Z` tag
|
||||
triggers it; ordinary commits to `main` do not.
|
||||
The GitHub Actions workflow **Release dsh plugin** publishes this package to npm
|
||||
as `@wxg-prc-cpg/browser-skill-dsh-plugin`. The Cordis plugin id, client
|
||||
bundle registration, and `cordis.patch.yml` name are the same specifier so
|
||||
dsh can import and materialize the plugin without a ModuleLoader id mismatch.
|
||||
|
||||
1. Commit this README and any other changes intended for the release.
|
||||
2. From the repository root, run the
|
||||
[release script](https://github.com/Tencent/BrowserSkill/blob/main/scripts/release.mjs)
|
||||
with a new stable version, replacing `<version>` below. The script updates the
|
||||
CLI, extension, and DSH plugin versions, commits the version changes, and creates
|
||||
their release tags:
|
||||
Trigger it by pushing a tag that matches `package.json`'s `version`:
|
||||
|
||||
```sh
|
||||
node scripts/release.mjs <version>
|
||||
```
|
||||
```sh
|
||||
git tag dsh-plugin-v0.1.2
|
||||
git push origin dsh-plugin-v0.1.2
|
||||
```
|
||||
|
||||
3. Push the version commit to the release branch, then push the DSH plugin tag
|
||||
created by the script, using the same `<version>`:
|
||||
|
||||
```sh
|
||||
git push origin HEAD
|
||||
git push origin dsh-plugin-v<version>
|
||||
```
|
||||
|
||||
This publishes the DSH plugin. Push the CLI and extension tags separately when
|
||||
those components are ready for release.
|
||||
|
||||
The workflow checks the version, runs typechecks and tests, builds the package,
|
||||
and publishes it to npm.
|
||||
|
||||
You can also run the workflow manually from GitHub Actions on the intended release
|
||||
ref. Both triggers require an unpublished version and the `NPM_TOKEN` secret in the
|
||||
`npm-publish` GitHub Environment.
|
||||
|
||||
npm updates the package README only when a new version is published, including
|
||||
for documentation-only changes. Published versions cannot be overwritten. See
|
||||
[npm's README update rules](https://docs.npmjs.com/about-package-readme-files/).
|
||||
Or run the workflow from the Actions tab (`workflow_dispatch`). The job reads the
|
||||
`NPM_TOKEN` secret from the `npm-publish` GitHub Environment.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
# DSH plugin development notes
|
||||
|
||||
For installation, usage, and configuration, see the [plugin README](../README.md).
|
||||
These notes describe the current source on this branch; a published npm version may
|
||||
not include later changes.
|
||||
|
||||
## Tool execution
|
||||
|
||||
The plugin runs the `bsk` CLI and parses its JSON output. The daemon and browser
|
||||
extension perform the browser operations. The published package name is also the
|
||||
Cordis plugin id, client bundle registration id, and `cordis.patch.yml` plugin name.
|
||||
Keep these identifiers aligned so dsh can load both halves of the plugin.
|
||||
|
||||
## Agent skill
|
||||
|
||||
Beyond the tools, the plugin publishes the **`browser-skill` agent skill** through the harness's
|
||||
official skill seam (`ctx.skills.register`): the catalog entry (name + routing description) is
|
||||
resident in `<available_skills>`, and the body is loaded only when the model invokes the `skill`
|
||||
tool. Its single source is the DSH-specific `skill/SKILL.md`, which documents only structured
|
||||
`browser_*` calls and their plugin semantics. The repository-root CLI skill is intentionally not
|
||||
concatenated: its command examples belong to a different execution interface and would bypass
|
||||
the plugin's ownership, live observation UI, cancellation, and cleanup path if followed directly.
|
||||
The build rejects internal CLI-name leakage, command-line code blocks, unknown browser tools, and
|
||||
missing supported browser tools before embedding the Markdown. Registration and every pre-step
|
||||
catalog snapshot are pure in-memory reads (no disk/process/daemon); compositions without the skill
|
||||
seam degrade silently.
|
||||
|
||||
With `lazyTools: true`, the six tool schemas stay out of the system prompt until
|
||||
the skill is successfully invoked. Registration happens once and lasts until the
|
||||
plugin is unloaded. Repeated invocations do not register duplicate tools. Entering
|
||||
a resumed conversation whose history contains a successful skill invocation also
|
||||
registers the tools. Setting `lazyTools: false` registers them at plugin startup.
|
||||
|
||||
## Observation subscriptions and routes
|
||||
|
||||
- **Screenshot demand**: the client requests periodic screenshots while an observation view is
|
||||
visible, using the PiP document's visibility when popped out. Hidden or collapsed views can
|
||||
keep subscribing to state without requesting screenshots. The first screenshot subscriber
|
||||
starts capture scheduling; the last one's departure cancels timers and skips queued captures.
|
||||
An already running capture may finish. Demand applies to the service's owned sessions, not just
|
||||
the selected session.
|
||||
- **Wire**: `GET /bsk-observation/events` is the live synchronization channel (SSE). Every connection,
|
||||
including reconnects, starts with `{ type: "snapshot", sessions, available }`, followed by
|
||||
`upsert`, `remove`, `reset`, and `availability` events on the same stream. Replace local state
|
||||
with each snapshot before applying subsequent events. Use `?thumbnails=0` for state-only
|
||||
subscriptions or `?thumbnails=1` to request screenshots; omitting the parameter defaults to
|
||||
requesting screenshots for compatibility. Closing the stream releases its screenshot demand.
|
||||
`GET /bsk-observation/state` remains a one-time `{ sessions, available }` read and does not
|
||||
request screenshots. It has no ordering guarantee relative to a separate SSE connection;
|
||||
live clients should initialize and resynchronize from the stream's snapshot instead.
|
||||
The host also serves `POST /bsk-observation/interrupt`, `POST /bsk-observation/stop`, and
|
||||
`GET /bsk-observation/thumbnail/<attachmentId>` through the dsh `webServer` route seam
|
||||
(dsh 0.1's Typert Remote pipeline is closed to out-of-tree packages). All commands for one
|
||||
session — tool calls and frame captures alike — run through a per-session FIFO, because the
|
||||
daemon accepts only one unfinished command per session.
|
||||
- **Programmatic subscription**: `ObservationService.subscribe(listener, { thumbnails: false })`
|
||||
subscribes to state without requesting screenshots. On an active service, the listener receives
|
||||
the initial `snapshot` synchronously, before `subscribe` returns, then receives subsequent
|
||||
changes. `thumbnails` defaults to `true`, so callers should choose it explicitly. The returned
|
||||
unsubscribe function is safe to call repeatedly and releases this subscription's screenshot
|
||||
demand. Subscribing after service disposal is a no-op.
|
||||
- **Trust model**: these routes expose live screenshots (and an interrupt write), so they
|
||||
replicate the browser-trust fence dsh applies to its own `/api` routes: the request Host
|
||||
must be a loopback authority (`localhost`, `127.0.0.0/8`, `[::1]`), a present Origin must
|
||||
match the Host, `sec-fetch-site: cross-site` is refused, and POST requires an
|
||||
`application/json` body (cross-site simple requests can never satisfy that). The channel is
|
||||
therefore built for **loopback-only serving** — binding the dsh web server to `0.0.0.0` and
|
||||
reaching it through a LAN address will (deliberately) fail the fence; do not put these
|
||||
routes behind a non-loopback reverse proxy without adding your own authentication.
|
||||
|
||||
## Tool results and cancellation
|
||||
|
||||
- **Cancellation**: aborting a tool call (`exec.signal`) kills the underlying bsk child process,
|
||||
matching BrowserSkill's cooperative tool-cancellation model.
|
||||
- **UI cards**: calls render as terminal cards (command line as title, output as the completed
|
||||
card). Screenshots additionally attach the image itself when the host mounts an attachment store
|
||||
and the active model route declares image input; otherwise the PNG path is returned.
|
||||
- **Web UI toolview (browser half)**: the package is dual-face. `dsh.client` (platform `web`) ships
|
||||
`lib/client.cjs`, which registers a keyed `tool.call.toolview` view for `browser_inspect`. The
|
||||
custom view keeps a terminal block for every inspect action and, when a screenshot result carries
|
||||
an image block, resolves the durable attachment through the client session's authorized
|
||||
`readAttachment` RPC and renders it with the shared `MessageImage` thumbnail/lightbox atoms.
|
||||
The other five tools keep the stock terminal card. The bundle follows the dsh client
|
||||
contract: a CJS closure factory handed to `window.__ModuleLoader__.load`, platform modules
|
||||
(`react`, `dsh-client-ui-*`) external, everything else inlined, CSS Modules compiled by
|
||||
lightningcss.
|
||||
- **Errors**: non-zero bsk exits surface the CLI's JSON error envelope (`code`, `message`, `hint`)
|
||||
so the model gets the daemon's actionable guidance.
|
||||
- **Long-running work** (e.g. `bsk record`) is not backgrounded via `ctx.jobs` yet — tracked as a
|
||||
follow-up.
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@wxg-prc-cpg/browser-skill-dsh-plugin",
|
||||
"description": "DeepSeek Harness tool plugin that exposes BrowserSkill browser automation (browser_* tools) to the model",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* map the CLI's JSON error envelope onto a thrown `BskError`.
|
||||
*/
|
||||
|
||||
import { type ChildProcess, type SpawnOptionsWithoutStdio, spawn } from "node:child_process";
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
|
||||
/** Shape of the JSON error envelope `bsk --json` prints on failure. */
|
||||
export interface BskErrorBody {
|
||||
@@ -53,11 +53,7 @@ export interface BskRunOptions {
|
||||
}
|
||||
|
||||
/** Minimal spawn signature so tests can substitute a fake child process. */
|
||||
export type SpawnImpl = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: SpawnOptionsWithoutStdio,
|
||||
) => ChildProcess;
|
||||
export type SpawnImpl = (command: string, args: string[]) => ChildProcess;
|
||||
|
||||
export interface BskRunner {
|
||||
/** Run `bsk <args...> --json` and collect its output. */
|
||||
@@ -68,66 +64,29 @@ export interface BskRunner {
|
||||
killFor(tag: string): number;
|
||||
}
|
||||
|
||||
// Business RPCs translate Ctrl-C / opt-in stdin EOF into cancel(rpc_id).
|
||||
// Allow reconciliation before hard-killing an old or unresponsive CLI.
|
||||
// Business RPCs translate SIGINT into the daemon's cancel(rpc_id) protocol.
|
||||
// Give that bounded reconciliation path time to settle before the hard kill.
|
||||
const KILL_GRACE_MS = 3000;
|
||||
// Windows IPC may spend 5s connecting, 2s cancelling, 2s settling,
|
||||
// and up to 5s releasing the entire batch of caller-owned transfers.
|
||||
const WINDOWS_KILL_GRACE_MS = 15_000;
|
||||
const SESSION_BUSY_RETRY_DELAY_MS = 100;
|
||||
|
||||
export function createBskRunner(bskPath: string, spawnImpl: SpawnImpl = spawn): BskRunner {
|
||||
const live = new Map<ChildProcess, string | undefined>();
|
||||
const windows = process.platform === "win32";
|
||||
const cancelling = new Set<ChildProcess>();
|
||||
|
||||
function killChild(child: ChildProcess): void {
|
||||
if (child.exitCode !== null || child.signalCode !== null || cancelling.has(child)) return;
|
||||
cancelling.add(child);
|
||||
// Node kills Windows children outright for SIGINT. EOF asks the CLI to
|
||||
// send its existing cancel RPC and wait for browser reconciliation.
|
||||
if (windows && child.stdin) child.stdin.end();
|
||||
else child.kill("SIGINT");
|
||||
const force = setTimeout(
|
||||
() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
},
|
||||
windows ? WINDOWS_KILL_GRACE_MS : KILL_GRACE_MS,
|
||||
);
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill("SIGINT");
|
||||
const force = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
}, KILL_GRACE_MS);
|
||||
force.unref();
|
||||
child.once("close", () => {
|
||||
clearTimeout(force);
|
||||
cancelling.delete(child);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
run(args, options = {}) {
|
||||
if (options.signal?.aborted) {
|
||||
return Promise.resolve({
|
||||
code: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
aborted: true,
|
||||
});
|
||||
}
|
||||
return new Promise<BskRunResult>((resolve, reject) => {
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = spawnImpl(
|
||||
bskPath,
|
||||
[...args, "--json"],
|
||||
windows
|
||||
? {
|
||||
windowsHide: true,
|
||||
env: { ...process.env, BSK_CANCEL_ON_STDIN_CLOSE: "1" },
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
// A child exiting while cancellation closes stdin may report EPIPE.
|
||||
// Its close/error event remains the authority for the run result.
|
||||
child.stdin?.on("error", () => {});
|
||||
child = spawnImpl(bskPath, [...args, "--json"]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type BskRunResult,
|
||||
createBskRunner,
|
||||
@@ -93,77 +92,6 @@ describe("createBskRunner", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Windows parent cancellation", () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")!;
|
||||
beforeEach(() => Object.defineProperty(process, "platform", { value: "win32" }));
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", originalPlatform);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("opts in to stdin cancellation and waits for CLI reconciliation", async () => {
|
||||
const child = Object.assign(new FakeChild(), { stdin: new PassThrough() });
|
||||
const spawn = vi.fn(() => child as unknown as ChildProcess);
|
||||
const runner = createBskRunner("bsk", spawn);
|
||||
const abort = new AbortController();
|
||||
const result = runner.run(["snapshot"], { signal: abort.signal });
|
||||
expect(spawn.mock.calls[0]).toEqual([
|
||||
"bsk",
|
||||
["snapshot", "--json"],
|
||||
{
|
||||
windowsHide: true,
|
||||
env: { ...process.env, BSK_CANCEL_ON_STDIN_CLOSE: "1" },
|
||||
},
|
||||
]);
|
||||
abort.abort();
|
||||
expect(child.stdin.writableEnded).toBe(true);
|
||||
expect(child.killedWith).toEqual([]);
|
||||
// Closing an already-exited child's pipe must not crash the host.
|
||||
child.stdin.emit("error", Object.assign(new Error("closed"), { code: "EPIPE" }));
|
||||
child.finish(2, '{"code":"cancelled"}');
|
||||
expect(await result).toMatchObject({ code: 2, aborted: true });
|
||||
});
|
||||
|
||||
it("bounds cancellation of an old or unresponsive CLI", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = Object.assign(new FakeChild(), { stdin: new PassThrough() });
|
||||
const runner = createBskRunner("bsk", fakeSpawn([child]));
|
||||
const result = runner.run(["snapshot"], { timeoutMs: 5 });
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
expect(child.stdin.writableEnded).toBe(true);
|
||||
expect(child.killedWith).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(child.killedWith).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(child.killedWith).toEqual(["SIGKILL"]);
|
||||
expect(await result).toMatchObject({ timedOut: true });
|
||||
});
|
||||
|
||||
it("cancels only the requested tag and clears the fallback after exit", async () => {
|
||||
vi.useFakeTimers();
|
||||
const a = Object.assign(new FakeChild(), { stdin: new PassThrough() });
|
||||
const b = Object.assign(new FakeChild(), { stdin: new PassThrough() });
|
||||
const runner = createBskRunner("bsk", fakeSpawn([a, b]));
|
||||
const first = runner.run(["snapshot"], { tag: "a" });
|
||||
const second = runner.run(["snapshot"], { tag: "b" });
|
||||
expect(runner.killFor("a")).toBe(1);
|
||||
expect(a.stdin.writableEnded).toBe(true);
|
||||
expect(b.stdin.writableEnded).toBe(false);
|
||||
a.finish(2);
|
||||
b.finish(0);
|
||||
await Promise.all([first, second]);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("does not start work for an already-aborted call", async () => {
|
||||
const spawn = vi.fn();
|
||||
const runner = createBskRunner("bsk", spawn);
|
||||
const signal = AbortSignal.abort();
|
||||
expect(await runner.run(["snapshot"], { signal })).toMatchObject({ aborted: true });
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBskJson", () => {
|
||||
const base: BskRunResult = { code: 0, stdout: "", stderr: "", timedOut: false, aborted: false };
|
||||
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
#Requires -Version 5.1
|
||||
param([string]$BskPath = (Join-Path $PSScriptRoot "../target/debug/bsk.exe"))
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Load definitions, never Main: no downloads or changes to the real user PATH.
|
||||
$installer = Join-Path $PSScriptRoot "../install.ps1"
|
||||
$tokens = $null
|
||||
$errors = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile($installer, [ref]$tokens, [ref]$errors)
|
||||
if ($errors.Count) { throw ($errors | Out-String) }
|
||||
foreach ($statement in $ast.EndBlock.Statements) {
|
||||
if ($statement -is [System.Management.Automation.Language.FunctionDefinitionAst]) {
|
||||
$definition = $statement.Extent.Text
|
||||
if ($statement.Name -eq "Add-ToUserPath") {
|
||||
$definition = $definition.Replace('[Environment]::GetEnvironmentVariable("PATH", "User")', '$script:UserPath')
|
||||
$definition = $definition.Replace('[Environment]::SetEnvironmentVariable("PATH", $newUserPath, "User")', '$script:UserPath = $newUserPath')
|
||||
}
|
||||
Invoke-Expression $definition
|
||||
}
|
||||
}
|
||||
function Assert-Equal($Actual, $Expected) {
|
||||
if ($Actual -cne $Expected) { throw "expected [$Expected], got [$Actual]" }
|
||||
}
|
||||
function Assert-Fails([scriptblock]$Action) {
|
||||
$failed = $false
|
||||
try { & $Action } catch { $failed = $true }
|
||||
if (-not $failed) { throw "expected failure" }
|
||||
}
|
||||
|
||||
$dir = 'C:\Users\Alice\.local\bin'
|
||||
foreach ($existing in @('', 'C:\WindowsApps', 'C:\A;C:\B')) {
|
||||
$script:UserPath = $existing
|
||||
Add-ToUserPath $dir
|
||||
$expected = if ($existing) { "$existing;$dir" } else { $dir }
|
||||
Assert-Equal $script:UserPath $expected
|
||||
Add-ToUserPath $dir
|
||||
Assert-Equal $script:UserPath $expected
|
||||
}
|
||||
|
||||
$root = Join-Path ([IO.Path]::GetTempPath()) ("bsk-install-test-" + [Guid]::NewGuid().ToString('N'))
|
||||
[IO.Directory]::CreateDirectory($root) | Out-Null
|
||||
$oldBskHome = $env:BSK_HOME
|
||||
$oldAutoUpdate = $env:BSK_AUTO_UPDATE
|
||||
$daemon = $null
|
||||
try {
|
||||
$utf8 = New-Object System.Text.UTF8Encoding($false)
|
||||
$bashRc = Join-Path $root ".bashrc"
|
||||
$existing = "# existing 中文 configuration" + [char]10
|
||||
[IO.File]::WriteAllText($bashRc, $existing, $utf8)
|
||||
$special = 'C:\Users\张三 space $HOME $(echo unsafe) ! & [x] O''Brien\.local\bin'
|
||||
Add-ToBashProfile $special $bashRc
|
||||
$first = [IO.File]::ReadAllText($bashRc)
|
||||
if (-not $first.StartsWith($existing)) { throw "existing bashrc content changed" }
|
||||
if (-not $first.Contains('张三')) { throw "Unicode path was lost" }
|
||||
Add-ToBashProfile $special $bashRc
|
||||
Assert-Equal ([IO.File]::ReadAllText($bashRc)) $first
|
||||
$bytes = [IO.File]::ReadAllBytes($bashRc)
|
||||
if ($bytes[0] -eq 0xEF) { throw "unexpected UTF-8 BOM" }
|
||||
|
||||
$bash = [IO.Path]::GetFullPath((Join-Path (Split-Path (Get-Command git).Source -Parent) "../bin/bash.exe"))
|
||||
if (-not (Test-Path -LiteralPath $bash)) { throw "Git Bash is required for quoting regression" }
|
||||
$env:BSK_TEST_RC = $bashRc
|
||||
$probe = Join-Path $root 'probe.sh'
|
||||
[IO.File]::WriteAllText($probe, 'source "$BSK_TEST_RC"; printf "%s\n" "${PATH%%:*}"', $utf8)
|
||||
$actual = & $bash --noprofile --norc $probe
|
||||
if ($LASTEXITCODE -ne 0) { throw "Git Bash failed" }
|
||||
Assert-Equal $actual ('/c' + $special.Substring(2).Replace('\', '/'))
|
||||
|
||||
$env:BSK_HOME = Join-Path $root "home"
|
||||
$env:BSK_AUTO_UPDATE = "off"
|
||||
[IO.Directory]::CreateDirectory($env:BSK_HOME) | Out-Null
|
||||
$sourceDir = Join-Path $root "download"
|
||||
[IO.Directory]::CreateDirectory($sourceDir) | Out-Null
|
||||
$source = Join-Path $sourceDir "bsk.exe"
|
||||
[IO.File]::Copy((Resolve-Path -LiteralPath $BskPath).Path, $source)
|
||||
$targetDir = Join-Path $root "中文 space [x] & install"
|
||||
[IO.Directory]::CreateDirectory($targetDir) | Out-Null
|
||||
$target = Join-Path $targetDir "bsk.exe"
|
||||
Install-Binary $source $target
|
||||
Assert-Equal (Get-FileHash -LiteralPath $target).Hash (Get-FileHash -LiteralPath $source).Hash
|
||||
|
||||
$daemon = Start-Process -FilePath $target -ArgumentList @('daemon', 'start', '--foreground', '--port', '0') -WindowStyle Hidden -PassThru
|
||||
$infoPath = Join-Path $env:BSK_HOME "daemon.json"
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(15)
|
||||
while (-not [IO.File]::Exists($infoPath)) {
|
||||
if ($daemon.HasExited -or [DateTime]::UtcNow -gt $deadline) { throw "daemon failed to start" }
|
||||
Start-Sleep -Milliseconds 50
|
||||
}
|
||||
# PE overlays distinguish old/new executables without another build.
|
||||
$stream = [IO.File]::OpenWrite($source)
|
||||
try {
|
||||
$stream.Seek(0, [IO.SeekOrigin]::End) | Out-Null
|
||||
$marker = $utf8.GetBytes("installer replacement regression")
|
||||
$stream.Write($marker, 0, $marker.Length)
|
||||
} finally { $stream.Dispose() }
|
||||
Install-Binary $source $target
|
||||
if (-not $daemon.WaitForExit(5000)) { throw "old daemon still running" }
|
||||
Assert-Equal (Get-FileHash -LiteralPath $target).Hash (Get-FileHash -LiteralPath $source).Hash
|
||||
& $target --version
|
||||
if ($LASTEXITCODE -ne 0) { throw "replacement is not executable" }
|
||||
|
||||
# Refuse replacement when daemon identity cannot be verified.
|
||||
[IO.File]::WriteAllText($infoPath, 'invalid daemon metadata')
|
||||
$before = (Get-FileHash -LiteralPath $target).Hash
|
||||
Assert-Fails { Install-Binary $source $target }
|
||||
Assert-Equal (Get-FileHash -LiteralPath $target).Hash $before
|
||||
[IO.File]::Delete($infoPath)
|
||||
|
||||
# A remaining lock must fail without truncation or staging debris.
|
||||
$lock = [IO.File]::Open($target, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None)
|
||||
try { Assert-Fails { Install-Binary $source $target } } finally { $lock.Dispose() }
|
||||
Assert-Equal (Get-FileHash -LiteralPath $target).Hash $before
|
||||
if (@(Get-ChildItem -LiteralPath $targetDir -Filter "*.install-*").Count) { throw "staging files leaked" }
|
||||
Write-Host "Windows installer regressions passed ($($PSVersionTable.PSVersion))"
|
||||
}
|
||||
catch {
|
||||
Write-Host ($_ | Out-String)
|
||||
Write-Host $_.ScriptStackTrace
|
||||
throw
|
||||
}
|
||||
finally {
|
||||
if ($daemon -and -not $daemon.HasExited) {
|
||||
Stop-Process -Id $daemon.Id -Force
|
||||
$daemon.WaitForExit(5000) | Out-Null
|
||||
}
|
||||
$env:BSK_HOME = $oldBskHome
|
||||
$env:BSK_AUTO_UPDATE = $oldAutoUpdate
|
||||
Remove-Item Env:BSK_TEST_RC -ErrorAction SilentlyContinue
|
||||
# Resolve and verify before recursive cleanup; only this fixture is removed.
|
||||
$resolvedRoot = [IO.Path]::GetFullPath($root)
|
||||
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
|
||||
if (-not $resolvedRoot.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) -or
|
||||
-not ([IO.Path]::GetFileName($resolvedRoot)).StartsWith('bsk-install-test-')) { throw "invalid cleanup path" }
|
||||
Remove-Item -LiteralPath $resolvedRoot -Recurse -Force
|
||||
}
|
||||
+3
-9
@@ -174,22 +174,16 @@ function updateChangelog(version) {
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const escapedVer = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const versionHeading = new RegExp(`^## \\[${escapedVer}\\](?:\\s|$)`, "m");
|
||||
|
||||
// First: fill in any date-placeholder line for this version (e.g. "## [0.2.0] - 2026-09-XX")
|
||||
const versionPlaceholder = new RegExp(`^## \\[${escapedVer}\\]\\s*-\\s*\\d{4}-\\d{2}-XX`, "m");
|
||||
if (versionPlaceholder.test(content)) {
|
||||
content = content.replace(versionPlaceholder, `## [${version}] - ${today}`);
|
||||
} else if (!versionHeading.test(content)) {
|
||||
// Preserve prepared release entries; new versions go before the release history.
|
||||
const heading = `## [${version}] - ${today}`;
|
||||
} else {
|
||||
// Otherwise: insert a new section after [Unreleased]
|
||||
const unreleased = /^## \[Unreleased\]/m;
|
||||
if (unreleased.test(content)) {
|
||||
content = content.replace(unreleased, `## [Unreleased]\n\n${heading}`);
|
||||
} else if (/^## /m.test(content)) {
|
||||
content = content.replace(/^## /m, `${heading}\n\n## `);
|
||||
} else {
|
||||
content = `${content.trimEnd()}\n\n${heading}\n`;
|
||||
content = content.replace(unreleased, `## [Unreleased]\n\n## [${version}] - ${today}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user