Compare commits

...
Author SHA1 Message Date
Ljy-0827 435a6bb9be feat(vom): canvas design and fixtures 2026-09-08 16:49:43 +08:00
Zhang GH 3c5f838c56 Merge pull request #192 from Tencent/fix/windows-update-reliability
fix(update): make Windows self-updates reliable
2026-09-07 20:37:32 +08:00
iuyo5678 9337bd557b test(update): use blocking connections in Windows release server 2026-09-07 20:27:04 +08:00
iuyo5678 162a31ae92 fix(update): make Windows self-updates reliable 2026-09-07 20:04:15 +08:00
Zhang GH 2f84234a31 Merge pull request #191 from Tencent/fix/windows-ipc-process-liveness
fix(windows): preserve pipe continuity and verify process liveness
2026-09-07 19:06:38 +08:00
iuyo5678 72c10aab75 fix(windows): preserve pipe continuity and verify process liveness 2026-09-07 18:25:32 +08:00
Zhang GH 1eeb55af02 Merge pull request #190 from Tencent/fix/dsh-observation-sync-and-capture
fix(dsh): synchronize observation state and manage thumbnail capture
2026-09-07 17:46:31 +08:00
drakezhang 4992a54864 docs(dsh): clarify observation subscription and snapshot contracts 2026-09-07 17:38:35 +08:00
drakezhang 8b195eb0df test(extension): return tab id from navigation recovery mock 2026-09-07 17:26:01 +08:00
drakezhang eae4db1729 Merge main for navigation recovery test compatibility 2026-09-07 17:25:22 +08:00
drakezhang e961395be9 fix(dsh): synchronize observation state and manage thumbnail capture 2026-09-07 16:24:04 +08:00
Zhang GH a6d1d02acc Merge pull request #179 from Tencent/fix/cdp-extension-frame-recovery
fix: recover navigation from cross-extension CDP restrictions
2026-09-07 16:05:46 +08:00
Zhang GH 5ebe609c40 Merge pull request #189 from Tencent/fix/verify-fill-before-success
fix(extension): verify fill results before reporting success
2026-09-07 15:59:21 +08:00
drakezhang fd3ce202a1 fix: recover navigation from cross-extension CDP restrictions
Classify Chrome extension-frame access failures and provide an actionable CLI hint. Recover navigate and reload only when their initial CDP setup is denied, using browser navigation events and verifying that CDP access returns.

Preserve normal navigation, Agent Window checks, transfer error details, and network-idle semantics. Never replay an already-dispatched navigation.
2026-09-03 18:21:21 +08:00
37 changed files with 4421 additions and 230 deletions
+21
View File
@@ -37,6 +37,27 @@ jobs:
- name: Run Rust tests - name: Run Rust tests
run: cargo test --workspace --locked run: cargo test --workspace --locked
windows-daemon:
name: Windows daemon and update regression tests
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Run Windows named-pipe tests
run: cargo test -p bsk --lib --locked daemon::ipc::windows::tests
- name: Run Windows process liveness tests
run: cargo test -p bsk --lib --locked daemon::lockfile::tests
- name: Run Windows update tests
run: cargo test -p bsk --lib --locked cli::update::tests
- name: Run Windows update process tests
run: cargo test -p bsk --test windows_update --locked
frontend: frontend:
name: Frontend lint, typecheck, tests, build name: Frontend lint, typecheck, tests, build
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -49,6 +49,47 @@ describe("ToolDispatcher", () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it.each([
"tool.snapshot",
"tool.evaluate",
"tool.get_html",
"tool.console",
])("annotates Chrome extension-access failures from %s", async (method) => {
const tab = { id: 7, windowId: 4242, active: true, url: "https://example.test" };
vi.stubGlobal("chrome", {
tabs: {
get: vi.fn(async () => tab),
query: vi.fn(async () => [tab]),
},
});
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(),
ensureActiveTab: vi.fn(),
},
});
await sessions.start("test");
const error = new Error("Cannot access a chrome-extension:// URL of different extension");
const cdp = {
send: vi.fn().mockRejectedValue(error),
ensureConsoleCapture: vi.fn().mockRejectedValue(error),
consoleEntriesSince: vi.fn(),
} as unknown as TestDispatcherCdp;
const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
dispatcher.start();
deliver(makeRequest(method, { session_id: "test", expression: "document.title" }));
await vi.waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0]).toMatchObject({
error: {
code: "cdp_failed",
data: { reason: "cdp_extension_access_denied" },
},
});
dispatcher.stop();
});
it("uses the configured recording runtime for start and stop", async () => { it("uses the configured recording runtime for start and stop", async () => {
const { transport, sent, deliver } = fakeTransport(); const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({ const sessions = new SessionManager({
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import type { RpcError } from "@/transport/types";
import { cdpError, classifyCdpError } from "../errors";
const denied = "Cannot access a chrome-extension:// URL of different extension";
describe("CDP extension access errors", () => {
it("classifies both Chrome Error instances and string failures", () => {
for (const error of [new Error(denied), denied]) {
expect(cdpError(error)).toEqual({
code: "cdp_failed",
message: denied,
data: { reason: "cdp_extension_access_denied" },
});
}
});
it("preserves transfer outcome and cleanup instructions when CDP was denied", () => {
const error: RpcError = {
code: "cdp_failed",
message: denied,
data: {
reason: "transfer_outcome_unknown",
effect_state: "unknown",
cleanup_state: "failed",
},
};
expect(classifyCdpError(error)).toBe(error);
});
it("leaves other error codes and unrelated debugger failures unchanged", () => {
for (const error of [
{ code: "permission_denied", message: denied },
{ code: "cdp_failed", message: "Another debugger is already attached" },
] satisfies RpcError[]) {
expect(classifyCdpError(error)).toBe(error);
}
});
});
@@ -0,0 +1,266 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { BrowserNavigationApi } from "../browser-navigation";
import { handleNavigate, handleReload } from "../navigation";
import type { CdpRunner } from "../shared";
const denied = "Cannot access a chrome-extension:// URL of different extension";
const url = "https://example.test/recovered";
function event<T extends (...args: never[]) => void>() {
const listeners = new Set<T>();
return {
listeners,
addListener: (listener: T) => listeners.add(listener),
removeListener: (listener: T) => listeners.delete(listener),
fire: (...args: Parameters<T>) => {
for (const listener of [...listeners]) listener(...args);
},
};
}
async function fixture() {
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => 100),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 4),
},
});
await manager.start("test");
type Listener = Parameters<BrowserNavigationApi["onCommitted"]["addListener"]>[0];
const events = {
onCommitted: event<Listener>(),
onDOMContentLoaded: event<Listener>(),
onCompleted: event<Listener>(),
onErrorOccurred: event<Listener>(),
};
const cdpEvents = event<Parameters<NonNullable<CdpRunner["onEvent"]>>[0]>();
const state = { blocked: true, destinationBlocked: false, emitIdle: true };
const send = vi.fn(async (_tabId: number, method: string) => {
if (state.blocked) throw new Error(denied);
if (method === "Page.getFrameTree") {
return { frameTree: { frame: { id: "main", loaderId: "new-loader" } } };
}
if (method === "Runtime.evaluate") return { result: { value: "complete" } };
if (method === "Page.setLifecycleEventsEnabled" && state.emitIdle) {
cdpEvents.fire({ tabId: 4 }, "Page.lifecycleEvent", {
frameId: "main",
loaderId: "new-loader",
name: "networkIdle",
});
}
if (method === "Page.navigate") return { frameId: "main", loaderId: "new-loader" };
return {};
});
const cdp: CdpRunner = {
send: send as CdpRunner["send"],
onEvent: (listener) => {
cdpEvents.addListener(listener);
return { dispose: () => cdpEvents.removeListener(listener) };
},
};
const main = { tabId: 4, frameId: 0, documentId: "new-document" };
const navigate = async () => {
state.blocked = state.destinationBlocked;
events.onCommitted.fire(main);
events.onDOMContentLoaded.fire(main);
events.onCompleted.fire(main);
};
const browserNavigation = { ...events, update: vi.fn(navigate), reload: vi.fn(navigate) };
const tab = { id: 4, windowId: 100, active: true, url } as chrome.tabs.Tab;
const tabsApi = { get: vi.fn(async () => tab), query: vi.fn(async () => [tab]) };
const deps = { cdp, tabsApi, browserNavigation, defaultTimeoutMs: 1000 };
const expectCleanedUp = () => {
for (const entry of Object.values(events)) expect(entry.listeners.size).toBe(0);
expect(cdpEvents.listeners.size).toBe(0);
};
return { manager, deps, state, send, events, main, cdpEvents, expectCleanedUp };
}
describe("navigation after Chrome denies extension-frame access", () => {
it.each([
"load",
"commit",
"domcontentloaded",
"networkidle",
] as const)("recovers the same tab with the requested %s checkpoint", async (waitUntil) => {
const f = await fixture();
const result = await handleNavigate(
f.manager,
{
session_id: "test",
url,
wait_until: waitUntil,
},
f.deps,
);
expect(result).toMatchObject({ tab_id: 4, final_url: url, reached: waitUntil });
expect(f.deps.browserNavigation.update).toHaveBeenCalledExactlyOnceWith(4, { url });
expect(f.send.mock.calls.some(([, method]) => method === "Page.navigate")).toBe(false);
expect(f.send).toHaveBeenCalledWith(4, "Page.enable", {});
f.expectCleanedUp();
});
it("recovers reload through tabs.reload and preserves hard reload", async () => {
const f = await fixture();
const result = await handleReload(f.manager, { session_id: "test", hard: true }, f.deps);
expect(result).toMatchObject({ tab_id: 4, reached: "load" });
expect(f.deps.browserNavigation.reload).toHaveBeenCalledExactlyOnceWith(4, {
bypassCache: true,
});
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
f.expectCleanedUp();
});
it("reports the restriction when reload leaves the conflicting frame present", async () => {
const f = await fixture();
f.state.destinationBlocked = true;
const result = await handleReload(f.manager, { session_id: "test" }, f.deps);
expect(result).toMatchObject({
code: "cdp_failed",
data: { reason: "cdp_extension_access_denied" },
});
expect(f.deps.browserNavigation.reload).toHaveBeenCalledOnce();
f.expectCleanedUp();
});
it("does not fall back for unrelated debugger failures", async () => {
const f = await fixture();
f.send.mockRejectedValue(new Error("Another debugger is already attached"));
const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps);
expect(result).toMatchObject({
code: "cdp_failed",
message: "Another debugger is already attached",
});
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
f.expectCleanedUp();
});
it("does not replay navigation when a dispatched CDP command fails", async () => {
const f = await fixture();
f.state.blocked = false;
const send = f.send.getMockImplementation()!;
f.send.mockImplementation(async (tabId, method) => {
if (method === "Page.navigate") throw new Error(denied);
return send(tabId, method);
});
const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps);
expect(result).toMatchObject({
code: "cdp_failed",
data: { reason: "cdp_extension_access_denied" },
});
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
f.expectCleanedUp();
});
it("keeps the Agent Window guard in front of the recovery", async () => {
const f = await fixture();
f.deps.tabsApi.get.mockResolvedValue({
id: 4,
windowId: 200,
active: true,
url,
} as chrome.tabs.Tab);
const result = await handleNavigate(f.manager, { session_id: "test", tab_id: 4, url }, f.deps);
expect(result).toMatchObject({ code: "permission_denied" });
expect(f.send).not.toHaveBeenCalled();
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
});
it("ignores old-document, subframe, and other-tab completion events", async () => {
const f = await fixture();
f.deps.browserNavigation.update.mockImplementation(async () => {});
let settled = false;
const work = handleNavigate(f.manager, { session_id: "test", url }, f.deps).then((result) => {
settled = true;
return result;
});
await vi.waitFor(() => expect(f.deps.browserNavigation.update).toHaveBeenCalledOnce());
f.events.onCompleted.fire(f.main);
f.events.onCommitted.fire({ ...f.main, tabId: 9 });
f.events.onCommitted.fire({ ...f.main, frameId: 1 });
f.events.onCompleted.fire(f.main);
await Promise.resolve();
expect(settled).toBe(false);
f.events.onCommitted.fire(f.main);
f.events.onCompleted.fire({ ...f.main, documentId: "old-document" });
await Promise.resolve();
expect(settled).toBe(false);
f.state.blocked = false;
f.events.onCompleted.fire(f.main);
expect(await work).toMatchObject({ reached: "load" });
f.expectCleanedUp();
});
it("does not infer network idle from a browser load event", async () => {
const f = await fixture();
f.state.emitIdle = false;
const result = await handleNavigate(
f.manager,
{
session_id: "test",
url,
wait_until: "networkidle",
timeout_ms: 10,
},
f.deps,
);
expect(result).toMatchObject({ reached: "timeout" });
f.expectCleanedUp();
});
it("cleans up browser listeners on timeout", async () => {
const f = await fixture();
f.deps.browserNavigation.update.mockImplementation(async () => {});
const result = await handleNavigate(
f.manager,
{ session_id: "test", url, timeout_ms: 10 },
f.deps,
);
expect(result).toMatchObject({ reached: "timeout" });
f.expectCleanedUp();
});
it("does not navigate an already-cancelled request", async () => {
const f = await fixture();
const result = await handleNavigate(
f.manager,
{ session_id: "test", url },
{
...f.deps,
signal: AbortSignal.abort(),
},
);
expect(result).toMatchObject({ code: "cancelled" });
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
f.expectCleanedUp();
});
it("cleans up browser listeners when cancelled after dispatch", async () => {
const f = await fixture();
const ac = new AbortController();
f.deps.browserNavigation.update.mockImplementation(async () => {
ac.abort();
});
const result = await handleNavigate(
f.manager,
{ session_id: "test", url },
{ ...f.deps, signal: ac.signal },
);
expect(result).toMatchObject({ code: "cancelled" });
expect(f.deps.browserNavigation.update).toHaveBeenCalledOnce();
f.expectCleanedUp();
});
it("reports browser navigation errors and releases listeners", async () => {
const f = await fixture();
f.deps.browserNavigation.update.mockImplementation(async () => {
f.events.onErrorOccurred.fire({ ...f.main, error: "net::ERR_NAME_NOT_RESOLVED" });
});
const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps);
expect(result).toMatchObject({ code: "cdp_failed", message: "net::ERR_NAME_NOT_RESOLVED" });
f.expectCleanedUp();
});
});
@@ -0,0 +1,110 @@
import type { RpcError, WaitUntil } from "@/transport/types";
import { cdpError } from "./errors";
interface NavigationEvent {
tabId: number;
frameId: number;
documentId?: string;
error?: string;
}
interface NavigationEvents {
addListener(listener: (details: NavigationEvent) => void): void;
removeListener(listener: (details: NavigationEvent) => void): void;
}
/** Normal browser navigation remains available when chrome.debugger is denied. */
export interface BrowserNavigationApi {
update(tabId: number, props: { url: string }): Promise<unknown>;
reload(tabId: number, props: { bypassCache: boolean }): Promise<void>;
onCommitted: NavigationEvents;
onDOMContentLoaded: NavigationEvents;
onCompleted: NavigationEvents;
onErrorOccurred: NavigationEvents;
}
export const chromeBrowserNavigationApi: BrowserNavigationApi = {
update: (tabId, props) => chrome.tabs.update(tabId, props),
reload: (tabId, props) => chrome.tabs.reload(tabId, props),
get onCommitted() {
return chrome.webNavigation.onCommitted;
},
get onDOMContentLoaded() {
return chrome.webNavigation.onDOMContentLoaded;
},
get onCompleted() {
return chrome.webNavigation.onCompleted;
},
get onErrorOccurred() {
return chrome.webNavigation.onErrorOccurred;
},
};
export type NavigationOutcome =
| { reached: "match" | "timeout" | "cancelled"; lastLifecycle?: string }
| { reached: "failed"; error: RpcError };
/** Subscribe before dispatch and ignore the departing document's load events. */
export async function navigateWithBrowserApi(
api: BrowserNavigationApi,
tabId: number,
action: () => Promise<unknown>,
waitUntil: Exclude<WaitUntil, "networkidle">,
timeoutMs: number,
signal?: AbortSignal,
): Promise<NavigationOutcome> {
if (signal?.aborted) return { reached: "cancelled" };
if (timeoutMs <= 0) return { reached: "timeout" };
let committed = false;
let documentId: string | undefined;
let lastLifecycle: string | undefined;
let finish!: (outcome: NavigationOutcome) => void;
const outcome = new Promise<NavigationOutcome>((resolve) => {
finish = resolve;
});
const isMainFrame = (details: NavigationEvent) =>
details.tabId === tabId && details.frameId === 0;
const onCommitted = (details: NavigationEvent) => {
if (!isMainFrame(details)) return;
committed = true;
documentId = details.documentId;
lastLifecycle = "commit";
if (waitUntil === "commit") finish({ reached: "match", lastLifecycle });
};
const onDOMContentLoaded = (details: NavigationEvent) => {
if (!isMainFrame(details) || !committed || (documentId && details.documentId !== documentId))
return;
lastLifecycle = "DOMContentLoaded";
if (waitUntil === "domcontentloaded") finish({ reached: "match", lastLifecycle });
};
const onCompleted = (details: NavigationEvent) => {
if (!isMainFrame(details) || !committed || (documentId && details.documentId !== documentId))
return;
finish({ reached: "match", lastLifecycle: "load" });
};
const onError = (details: NavigationEvent) => {
if (!isMainFrame(details)) return;
finish({ reached: "failed", error: cdpError(details.error ?? "browser navigation failed") });
};
const onAbort = () => finish({ reached: "cancelled", lastLifecycle });
const subscriptions = [
[api.onCommitted, onCommitted],
[api.onDOMContentLoaded, onDOMContentLoaded],
[api.onCompleted, onCompleted],
[api.onErrorOccurred, onError],
] as const;
for (const [event, listener] of subscriptions) event.addListener(listener);
signal?.addEventListener("abort", onAbort, { once: true });
const timer = setTimeout(() => finish({ reached: "timeout", lastLifecycle }), timeoutMs);
try {
if (signal?.aborted) return { reached: "cancelled" };
await action();
return await outcome;
} catch (error) {
return { reached: "failed", error: cdpError(error) };
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
for (const [event, listener] of subscriptions) event.removeListener(listener);
}
}
+2 -1
View File
@@ -36,6 +36,7 @@ import { isRequestFrame } from "@/transport/types";
import { handleConsole } from "./console"; import { handleConsole } from "./console";
import { handleDownload } from "./download"; import { handleDownload } from "./download";
import { type EmulateCdpRunner, handleEmulate } from "./emulate"; import { type EmulateCdpRunner, handleEmulate } from "./emulate";
import { classifyCdpError } from "./errors";
import { handleEvaluate } from "./evaluate"; import { handleEvaluate } from "./evaluate";
import { handleRequestHelp } from "./human-loop"; import { handleRequestHelp } from "./human-loop";
import { handleClick, handleFill, handleHover, handlePress, handleSelect } from "./interaction"; import { handleClick, handleFill, handleHover, handlePress, handleSelect } from "./interaction";
@@ -239,7 +240,7 @@ export class ToolDispatcher {
if (sessionId) this.onBrowserControlResumed?.(sessionId); if (sessionId) this.onBrowserControlResumed?.(sessionId);
const result = await this.invoke(req, ac.signal); const result = await this.invoke(req, ac.signal);
if (isRpcError(result)) { if (isRpcError(result)) {
body = { id: req.id, error: result }; body = { id: req.id, error: classifyCdpError(result) };
} else { } else {
body = { id: req.id, result }; body = { id: req.id, result };
if (req.method === "tool.session_start") { if (req.method === "tool.session_start") {
+28
View File
@@ -13,6 +13,34 @@ import type {
export type { RpcErrorData, RpcErrorReason }; export type { RpcErrorData, RpcErrorReason };
/** Chrome checks the whole tab's frame tree, including other extensions' frames. */
export function isCdpExtensionAccessDenied(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes("Cannot access a chrome-extension:// URL of different extension");
}
/** Preserve more specific reasons, especially file-transfer outcome/cleanup details. */
export function classifyCdpError(error: RpcError): RpcError {
if (
error.code !== "cdp_failed" ||
error.data?.reason ||
!isCdpExtensionAccessDenied(error.message)
) {
return error;
}
return {
...error,
data: { ...error.data, reason: "cdp_extension_access_denied" },
};
}
export function cdpError(error: unknown): RpcError {
return classifyCdpError({
code: "cdp_failed",
message: error instanceof Error ? error.message : String(error),
});
}
export interface TransferErrorOptions { export interface TransferErrorOptions {
effectState: TransferEffectState; effectState: TransferEffectState;
phase: string; phase: string;
+116 -10
View File
@@ -28,7 +28,13 @@ import type {
RpcError, RpcError,
WaitUntil, WaitUntil,
} from "@/transport/types"; } from "@/transport/types";
import {
type BrowserNavigationApi,
chromeBrowserNavigationApi,
navigateWithBrowserApi,
} from "./browser-navigation";
import { attachDialogs, markDialogCursor } from "./dialogs"; import { attachDialogs, markDialogCursor } from "./dialogs";
import { cdpError, isCdpExtensionAccessDenied } from "./errors";
import { import {
type CdpRunner, type CdpRunner,
type ChromeTabsApi, type ChromeTabsApi,
@@ -42,6 +48,7 @@ import {
export interface NavigationDeps { export interface NavigationDeps {
cdp: CdpRunner; cdp: CdpRunner;
tabsApi: ChromeTabsApi; tabsApi: ChromeTabsApi;
browserNavigation?: BrowserNavigationApi;
/** Optional AbortSignal — M7 abort hook (M10.2 will wire the full chain). */ /** Optional AbortSignal — M7 abort hook (M10.2 will wire the full chain). */
signal?: AbortSignal; signal?: AbortSignal;
/** Override default timeout when the caller omits `timeout_ms`. */ /** Override default timeout when the caller omits `timeout_ms`. */
@@ -479,6 +486,81 @@ async function readTabUrl(api: ChromeTabsApi, tabId: number): Promise<string | u
} }
} }
/** Recover only a failed preflight; never replay an already-dispatched navigation. */
async function recoverBrowserNavigation(
deps: NavigationDeps,
tabId: number,
action: (api: BrowserNavigationApi) => Promise<unknown>,
waitUntil: WaitUntil,
timeoutMs: number,
): Promise<ReloadResult | RpcError> {
const abort = linkedAbortSignal(deps.signal);
const deadline = Date.now() + timeoutMs;
const api = deps.browserNavigation ?? chromeBrowserNavigationApi;
try {
let outcome = await navigateWithBrowserApi(
api,
tabId,
() => action(api),
waitUntil === "networkidle" ? "commit" : waitUntil,
timeoutMs,
abort.signal,
);
if (outcome.reached === "failed") return outcome.error;
if (outcome.reached === "cancelled" || abort.signal.aborted) {
return { code: "cancelled", message: "navigation recovery aborted" };
}
if (outcome.reached === "match") {
// A completed reload is not a recovery if the restricted frame remains.
await deps.cdp.send(tabId, "Page.enable", {});
if (abort.signal.aborted)
return { code: "cancelled", message: "navigation recovery aborted" };
if (waitUntil === "networkidle") {
// webNavigation cannot prove network idle. Subscribe on the new
// document before enabling CDP lifecycle events after its commit.
const frame = await readMainFrameInfo(deps.cdp, tabId);
if (!frame.frameId) return cdpError("no main frame after navigation");
const remaining = deadline - Date.now();
if (remaining <= 0) {
outcome = { reached: "timeout", lastLifecycle: "commit" };
} else {
const wait = startLifecycleWait(
deps.cdp,
tabId,
frame.frameId,
"networkIdle",
remaining,
abort.signal,
{ loaderId: frame.loaderId ?? undefined },
);
await deps.cdp.send(tabId, "Page.setLifecycleEventsEnabled", { enabled: true });
outcome = await wait.promise;
}
}
}
if (outcome.reached === "cancelled" || abort.signal.aborted) {
return { code: "cancelled", message: "navigation recovery aborted" };
}
return {
tab_id: tabId,
final_url: await readTabUrl(deps.tabsApi, tabId),
reached: outcome.reached === "match" ? waitUntil : "timeout",
...(outcome.reached === "timeout"
? {
error_text: `timed out waiting for ${waitUntil} during navigation recovery after ${timeoutMs}ms`,
}
: {}),
};
} catch (error) {
return abort.signal.aborted
? { code: "cancelled", message: "navigation recovery aborted" }
: cdpError(error);
} finally {
abort.abort();
abort.cleanup();
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// tool.navigate // tool.navigate
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -505,7 +587,20 @@ export async function handleNavigate(
try { try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await ensureCdpReady(deps.cdp, target.tabId); try {
await ensureCdpReady(deps.cdp, target.tabId);
} catch (error) {
if (!isCdpExtensionAccessDenied(error)) throw error;
const recovered = await recoverBrowserNavigation(
deps,
target.tabId,
(api) => api.update(target.tabId, { url: params.url }),
waitUntil,
timeoutMs,
);
if (isRpcError(recovered)) return recovered;
return attachDialogs(deps.cdp, target.tabId, dialogCursor, { ...recovered, url: params.url });
}
const expected = cdpLifecycleName(waitUntil); const expected = cdpLifecycleName(waitUntil);
const beforeReadyState = await probeMainFrameReadyState(deps.cdp, target.tabId); const beforeReadyState = await probeMainFrameReadyState(deps.cdp, target.tabId);
const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId); const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId);
@@ -574,10 +669,7 @@ export async function handleNavigate(
}`, }`,
}); });
} catch (err) { } catch (err) {
return { return cdpError(err);
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
} }
} }
@@ -727,7 +819,24 @@ export async function handleReload(
try { try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await ensureCdpReady(deps.cdp, target.tabId); try {
await ensureCdpReady(deps.cdp, target.tabId);
} catch (error) {
if (!isCdpExtensionAccessDenied(error)) throw error;
const previousUrl = await readTabUrl(deps.tabsApi, target.tabId);
const recovered = await recoverBrowserNavigation(
deps,
target.tabId,
(api) => api.reload(target.tabId, { bypassCache: ignoreCache }),
waitUntil,
timeoutMs,
);
if (isRpcError(recovered)) return recovered;
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
...recovered,
previous_url: previousUrl,
});
}
const previousUrl = await readTabUrl(deps.tabsApi, target.tabId); const previousUrl = await readTabUrl(deps.tabsApi, target.tabId);
const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId); const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId);
const expected = cdpLifecycleName(waitUntil); const expected = cdpLifecycleName(waitUntil);
@@ -776,10 +885,7 @@ export async function handleReload(
}`, }`,
}); });
} catch (err) { } catch (err) {
return { return cdpError(err);
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
} }
} }
+1
View File
@@ -36,6 +36,7 @@ export type RpcErrorReason =
| "single_select_value_count" | "single_select_value_count"
| "tab_not_active" | "tab_not_active"
| "restricted_tab_url" | "restricted_tab_url"
| "cdp_extension_access_denied"
| "borrow_conflict" | "borrow_conflict"
| "screenshot_capture_failed" | "screenshot_capture_failed"
| "file_input_probe_failed" | "file_input_probe_failed"
@@ -0,0 +1,119 @@
# Visual observation design fixtures
These are synthetic, site-independent inputs for the visual observation architecture in
[the design](../../../../docs/design/visual-observation.md). They contain no original page
data, account identifiers, external fonts or production selectors. They are not a production
discovery implementation.
Use [the acceptance checklist](../../../../docs/design/visual-observation-acceptance.md)
for each PR's steps and success criteria. No separate report is required.
Historical probe success does not certify future production modules.
## Browser contract probe
Requires Node.js with built-in WebSocket support (Node 22+) and an installed Chrome. No extra
packages, user profile, extension, or login are required.
From the repository root:
```sh
node apps/extension/test-fixtures/visual-observation/probe.mjs /tmp/bsk-visual-evidence
```
On platforms other than macOS, set `CHROME_PATH` to the installed Chrome executable. The probe
starts its own headless Chrome with an ephemeral profile and a loopback fixture server. It
removes the profile and stops Chrome/server on completion or failure. `report.json`, raw CDP
replies and PNGs are written to the explicitly supplied directory; do not commit them.
The probe verifies CSS exposure versus visibility, snapshot units, DPR raster dimensions,
CSS zoom, frame content origins, child scrolling, nested same-target geometry, a confirmed
OOPIF target, and navigation/document.open identity behavior. It records page-scale emulation
separately; that is **not** browser UI zoom. It does not certify production VOM behavior, arbitrary
occlusion, exact clip-path geometry, or all browser versions.
## Minimal end-to-end reproduction
```sh
python3 -m http.server 4177 --bind 127.0.0.1 \
--directory apps/extension/test-fixtures/visual-observation
bsk session start --width 1440 --height 1100
bsk navigate http://127.0.0.1:4177/table.html --session <session>
bsk observe --session <session>
bsk screenshot --session <session> --out /tmp/bsk-table.png
bsk session stop <session>
```
Always stop the session and the local server. Use a foreground Agent Window and confirm the
painted grid is visible before interpreting the observation. `load`, AX toolbar appearance,
and background screenshots alone do not prove a canvas has drawn.
`table.html` embeds `grid.html` through `localhost` from a `127.0.0.1` parent. The grid has DOM
toolbar controls and one unlabeled Canvas with three synthetic columns and six painted rows.
Main baseline: `Page action`, `Insert record`, and `Filter` are referenceable; the painted
`Canvas column A/B/C` and `Canvas row 1..6` have no textual observation representation or visual
ref, despite an untruncated result. This is a **known gap**, not a golden expectation that future
implementations must preserve.
Future acceptance: retain the DOM controls and add a screenshot-only visual ref for the visible
grid, with correct frame projection. Do not invent AX cell text. Validate the corresponding
screenshot, then move/remove/resize and re-observe according to the freshness contract.
## Discovery policy cases
At a 1440×1100 window and without scrolling:
| Canvas ID | Expected candidate in the new design | Reason |
|---|---|---|
| plain | yes | No name required |
| named | yes | Name does not establish complete pixel semantics |
| aria-hidden | yes | Accessibility exclusion is not visual exclusion |
| inert | yes | Inertness is not visual hiding |
| pointer-none | yes | Screenshot observation does not require pointer interaction |
| visibility-override | yes | Child restores computed visibility |
| opacity-parent | no | Ancestor makes subtree transparent |
| display-none | no | No layout box |
| visibility-hidden | no | Computed visibility suppresses this element |
| zero | no | Empty geometry |
| hidden-overridden | yes | CSS display overrides normal hidden presentation |
| partial-clip | yes, 60×30 crop | Supported overflow intersection |
| full-clip | no | Fully outside ancestor clip |
| axis-clip | yes, x clipped only | overflow-x:clip and overflow-y:visible remain independent |
| border-clip | yes, clipped to client box | Ancestor border box is not its scrollport |
| border-canvas | yes, border-box region | Observation and live screenshot resolver must use the same box kind |
| fallback | yes | AX fallback is not full pixel coverage |
| stack-a / stack-b | one output entry after selection | Same document/parent/exact crop; not proof of a logical object |
`checkVisibility()` is used in the probe only for CSS suppression evidence; it does not establish
viewport intersection, ancestor overflow clipping, or absence of occlusion. `frame.html` supplies
known local coordinates, bordered owners, nested frames and scrollable content for independent
geometry verification. The fixture IDs must never enter production discovery code.
## Renderer operation counts
After installing the repository's normal dependencies:
```sh
node apps/extension/test-fixtures/visual-observation/measure-render.mjs /tmp/bsk-render-evidence
```
This instruments a temporary copy of the current renderer, runs the installed Vitest, and writes
the source hash and loop counters to `render-counts.json`. It checks instrumentation sites and
fails explicitly when source structure changes; update the diagnostic deliberately then. It never
modifies production sources and never asserts that today's quadratic costs must persist.
The wide fixture contains N headings followed by N equally named buttons in a common context.
The deep fixture contains 2,000/4,000 transparent wrappers. Counters cover render-stack visits,
ordered DOM context candidates, and same-container sibling visits. They are not a complete CPU
profile or an assertion about the complexity of all VOM stages.
Use these existing-renderer counts only as a before/after regression baseline. Improving the
historical algorithm is not a prerequisite for Canvas work. New Canvas stages need their own
workload models and measurements; do not copy this renderer's algorithms or use these counts
as evidence that visual discovery, clipping, selection, or screenshot execution is efficient.
## Review boundary
Commit only these synthetic fixtures, diagnostic tools, and reviewed design/evidence summaries.
Keep local browser profiles, original internal-site screenshots/HTML/observations, generated
captures, temporary source instrumentation and build outputs outside the changeset.
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Frame geometry contract</title>
<style>
body { margin: 0; width: 600px; height: 900px; }
canvas { position: absolute; left: 17px; top: 23px; width: 120px; height: 40px; }
iframe { position: absolute; left: 30px; top: 90px; width: 180px; height: 100px; border: 7px solid #555; }
</style>
<canvas id="frame-canvas" width="120" height="40"></canvas>
<script>
const context = document.querySelector("canvas").getContext("2d");
context.fillStyle = "#15803d";
context.fillRect(0, 0, 120, 40);
context.fillStyle = "white";
context.fillText("FRAME PIXELS", 4, 24);
if (new URLSearchParams(location.search).has("nested")) {
const child = document.createElement("iframe");
child.title = "nested same-target frame";
child.src = "/frame.html";
document.body.append(child);
}
window.fixtureReady = true;
</script>
</html>
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Canvas grid</title>
<style>
body { margin: 0; font: 16px sans-serif; }
nav { height: 40px; }
canvas { display: block; width: 780px; height: 360px; }
</style>
<nav><button type="button">Insert record</button> <button type="button">Filter</button></nav>
<canvas width="1560" height="720"></canvas>
<script>
const context = document.querySelector("canvas").getContext("2d");
context.scale(2, 2);
context.fillStyle = "white";
context.fillRect(0, 0, 780, 360);
context.strokeStyle = "#777";
for (let x = 0; x <= 780; x += 260) { context.beginPath(); context.moveTo(x, 0); context.lineTo(x, 350); context.stroke(); }
for (let y = 0; y <= 350; y += 50) { context.beginPath(); context.moveTo(0, y); context.lineTo(780, y); context.stroke(); }
context.fillStyle = "#111";
context.font = "16px sans-serif";
["Canvas column A", "Canvas column B", "Canvas column C"].forEach((label, i) => context.fillText(label, i * 260 + 12, 30));
for (let row = 1; row <= 6; row++) context.fillText(`Canvas row ${row}`, 12, row * 50 + 30);
window.fixtureReady = true;
</script>
</html>
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Visual observation contract fixture</title>
<style>
body { margin: 0; min-height: 1800px; font: 14px sans-serif; }
#cases { display: grid; grid-template-columns: repeat(4, 180px); gap: 12px; padding: 20px; }
.case { width: 160px; height: 70px; }
canvas { width: 120px; height: 40px; }
.clip { width: 60px; height: 30px; overflow: hidden; }
.axis-clip { width: 60px; height: 20px; overflow-x: clip; overflow-y: visible; }
#same, #cross { position: absolute; top: 600px; width: 300px; height: 220px; border: 10px solid #555; }
#same { left: 40px; }
#cross { left: 400px; }
</style>
<h1>Canvas contract</h1>
<button type="button" id="control">Ordinary DOM control</button>
<div id="cases">
<div class="case"><canvas id="plain"></canvas></div>
<div class="case"><canvas id="named" aria-label="Named chart"></canvas></div>
<div class="case" aria-hidden="true"><canvas id="aria-hidden"></canvas></div>
<div class="case" inert><canvas id="inert"></canvas></div>
<div class="case"><canvas id="pointer-none" style="pointer-events:none"></canvas></div>
<div class="case" style="visibility:hidden"><canvas id="visibility-override" style="visibility:visible"></canvas></div>
<div class="case" style="opacity:0"><canvas id="opacity-parent"></canvas></div>
<div class="case"><canvas id="display-none" style="display:none"></canvas></div>
<div class="case"><canvas id="visibility-hidden" style="visibility:hidden"></canvas></div>
<div class="case"><canvas id="zero" style="width:0"></canvas></div>
<div class="case"><canvas id="hidden-overridden" hidden style="display:block"></canvas></div>
<div class="case"><div class="clip"><canvas id="partial-clip"></canvas></div></div>
<div class="case"><div class="clip"><canvas id="full-clip" style="margin-left:80px"></canvas></div></div>
<div class="case"><canvas id="fallback">Accessible fallback is not pixel content</canvas></div>
<div class="case"><div class="axis-clip"><canvas id="axis-clip"></canvas></div></div>
<div class="case"><div id="border-clip-owner" style="width:60px;height:30px;border:5px solid black;padding:4px;overflow:hidden"><canvas id="border-clip"></canvas></div></div>
<div class="case"><canvas id="border-canvas" style="border:5px solid black"></canvas></div>
<div class="case" style="position:relative"><canvas id="stack-a"></canvas><canvas id="stack-b" style="position:absolute;left:0;top:0"></canvas></div>
</div>
<iframe id="same" title="same target"></iframe>
<iframe id="cross" title="cross site"></iframe>
<script>
for (const canvas of document.querySelectorAll("canvas")) {
canvas.width = 120;
canvas.height = 40;
const context = canvas.getContext("2d");
context.fillStyle = "#1d4ed8";
context.fillRect(0, 0, 120, 40);
context.fillStyle = "white";
context.fillText("PIXEL TABLE 42", 5, 24);
}
if (!new URLSearchParams(location.search).has("noFrames")) {
document.querySelector("#same").src = "/frame.html?nested=1";
document.querySelector("#cross").src = `${location.protocol}//localhost:${location.port}/frame.html`;
}
window.fixtureReady = true;
</script>
</html>
@@ -0,0 +1,86 @@
// Diagnostic, not a golden test requiring the current quadratic cost to persist.
// Instrument a temporary copy; never modify the production renderer.
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../..");
const output = process.argv[2];
if (!output || !path.isAbsolute(output))
throw new Error("Pass an absolute evidence output directory");
const require = createRequire(path.join(root, "apps/extension/package.json"));
const vitestDirectory = path.dirname(require.resolve("vitest/package.json"));
const temporary = await mkdtemp(path.join(os.tmpdir(), "bsk-render-measure-"));
await mkdir(output, { recursive: true });
try {
const source = await readFile(path.join(root, "packages/vom/src/render.ts"), "utf8");
let instrumented = source;
for (const [needle, field] of [
["const { node, depth } = stack.pop() as { node: VomNode; depth: number };", "renderVisits"],
["for (const candidate of orderedCandidates) {", "contextCandidates"],
["for (const sibling of siblings) {", "siblingVisits"],
]) {
assert.equal(instrumented.split(needle).length, 2, `Update measurement site: ${field}`);
instrumented = instrumented.replace(needle, `${needle} designCounts.${field}++;`);
}
instrumented = `export const designCounts = {renderVisits:0,contextCandidates:0,siblingVisits:0};\n${instrumented}`;
await writeFile(path.join(temporary, "render.ts"), instrumented);
for (const file of ["layers.ts", "types.ts"])
await copyFile(path.join(root, "packages/vom/src", file), path.join(temporary, file));
const reportPath = path.join(output, "render-counts.json");
await writeFile(
path.join(temporary, "measure.test.ts"),
`
import {writeFileSync} from 'node:fs';
import {test,expect} from ${JSON.stringify(path.join(vitestDirectory, "dist/index.js"))};
import {renderVom,designCounts} from './render';
test('measure operation counts',()=>{
const rows=[];
const make=(id,extra={})=>({id,parentId:1,backendNodeId:id,tag:'div',role:'generic',rect:{x:0,y:0,w:100,h:20},paintOrder:0,position:'static',pointerEvents:'auto',contextScopeId:'root',...extra});
const run=(shape,n,nodes)=>{
Object.assign(designCounts,{renderVisits:0,contextCandidates:0,siblingVisits:0});
const result=renderVom({viewport:{width:1440,height:1000},nodes},{maxTokens:Infinity});
rows.push({shape,n,counts:{...designCounts},refs:result.refs.length});
};
for(const n of [100,200,400]){
const nodes=[make(1,{parentId:null,role:'RootWebArea',name:'Doc'})];
for(let i=0;i<n;i++)nodes.push(make(i+2,{role:'heading',name:'Region '+i,domParentId:1,domAncestorIds:[1]}));
for(let i=0;i<n;i++)nodes.push(make(n+i+2,{tag:'button',role:'button',name:'View',domParentId:1,domAncestorIds:[1]}));
run('wide',n,nodes);
}
for(const n of [2000,4000]){
const nodes=[make(1,{parentId:null,role:'RootWebArea',name:'Doc'})];
for(let i=2;i<=n;i++)nodes.push(make(i,{parentId:i-1}));
nodes.push(make(n+1,{parentId:n,role:'button',tag:'button',name:'Unique control'}));
run('deep',n,nodes);
}
expect(rows.at(-1).refs).toBe(1);
writeFileSync(${JSON.stringify(reportPath)},JSON.stringify({sourceSha256:${JSON.stringify(createHash("sha256").update(source).digest("hex"))},rows},null,2));
});
`,
);
const config = path.join(temporary, "vitest.config.mjs");
await writeFile(
config,
`export default {test:{environment:'node',include:[${JSON.stringify(path.join(temporary, "measure.test.ts"))}]}};`,
);
const result = spawnSync(
process.execPath,
[path.join(vitestDirectory, "vitest.mjs"), "run", "--config", config],
{ cwd: root, encoding: "utf8", timeout: 60000 },
);
await writeFile(
path.join(output, "render-measure.log"),
`${result.stdout ?? ""}\n${result.stderr ?? ""}`,
);
if (result.error) throw result.error;
assert.equal(result.status, 0, `Measurement failed; see ${output}/render-measure.log`);
process.stdout.write(`${reportPath}\n`);
} finally {
await rm(temporary, { recursive: true, force: true });
}
@@ -0,0 +1,533 @@
// Standalone browser contract probe. No extension, credentials or user profile.
// Raw replies and screenshots are written only to the caller-supplied directory.
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const fixtureDirectory = path.dirname(fileURLToPath(import.meta.url));
const output = process.argv[2];
if (!output || !path.isAbsolute(output))
throw new Error("Pass an absolute evidence output directory");
const chromePath =
process.env.CHROME_PATH ?? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const styles = [
"position",
"pointer-events",
"cursor",
"visibility",
"opacity",
"overflow-x",
"overflow-y",
"display",
];
const checks = [];
function check(name, condition) {
checks.push({ name, passed: Boolean(condition) });
assert.ok(condition, name);
}
class Cdp {
nextId = 0;
pending = new Map();
events = [];
counts = {};
async connect(url) {
this.socket = new WebSocket(url);
this.socket.addEventListener("message", ({ data }) => {
const reply = JSON.parse(data);
if (!reply.id) {
this.events.push(reply);
return;
}
const task = this.pending.get(reply.id);
if (!task) return;
this.pending.delete(reply.id);
clearTimeout(task.timer);
if (reply.error) task.reject(new Error(JSON.stringify(reply.error)));
else task.resolve(reply.result);
});
await new Promise((resolve, reject) => {
this.socket.addEventListener("open", resolve, { once: true });
this.socket.addEventListener("error", reject, { once: true });
});
}
send(method, params = {}, sessionId) {
const id = ++this.nextId;
this.counts[method] = (this.counts[method] ?? 0) + 1;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`CDP timeout: ${method}`));
}, 10000);
this.pending.set(id, { resolve, reject, timer });
this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
});
}
close() {
for (const task of this.pending.values()) {
clearTimeout(task.timer);
task.reject(new Error("CDP closed"));
}
this.pending.clear();
this.socket?.close();
}
}
function decodedCanvases(snapshot) {
return snapshot.documents.flatMap((document) => {
const layoutByNode = new Map(document.layout.nodeIndex.map((node, index) => [node, index]));
return document.nodes.nodeName.flatMap((name, index) => {
if (snapshot.strings[name] !== "CANVAS") return [];
const attrs = document.nodes.attributes[index];
const attributes = {};
for (let i = 0; i < attrs.length; i += 2)
attributes[snapshot.strings[attrs[i]]] = snapshot.strings[attrs[i + 1]];
const layoutIndex = layoutByNode.get(index) ?? -1;
return [
{
id: attributes.id,
backendNodeId: document.nodes.backendNodeId[index],
frameId: snapshot.strings[document.frameId],
attributes,
bounds: layoutIndex < 0 ? null : document.layout.bounds[layoutIndex],
styles:
layoutIndex < 0
? null
: Object.fromEntries(
styles.map((style, i) => [
style,
snapshot.strings[document.layout.styles[layoutIndex][i]],
]),
),
},
];
});
});
}
await mkdir(output, { recursive: true });
const profile = await mkdtemp(path.join(os.tmpdir(), "bsk-visual-probe-"));
const server = http.createServer(async (request, response) => {
const name = new URL(request.url, "http://fixture").pathname;
if (!["/index.html", "/frame.html"].includes(name)) {
response.writeHead(404).end();
return;
}
try {
response.setHeader("Content-Type", "text/html; charset=utf-8");
response.end(await readFile(path.join(fixtureDirectory, name.slice(1))));
} catch {
response.writeHead(500).end();
}
});
const cdp = new Cdp();
let chrome;
let report = {};
try {
server.listen(0, "127.0.0.1");
await once(server, "listening");
const port = server.address().port;
chrome = spawn(
chromePath,
[
"--headless=new",
`--user-data-dir=${profile}`,
"--remote-debugging-port=0",
"--no-first-run",
"--no-default-browser-check",
"--site-per-process",
"--window-size=1440,1100",
"about:blank",
],
{ stdio: "ignore" },
);
chrome.on("error", (error) => {
report.launchError = error.message;
});
let activePort;
for (let attempt = 0; attempt < 100; attempt++) {
try {
activePort = (await readFile(path.join(profile, "DevToolsActivePort"), "utf8"))
.trim()
.split("\n");
break;
} catch {
await pause(100);
}
}
if (!activePort)
throw new Error(report.launchError ?? "Chrome did not expose DevToolsActivePort");
await cdp.connect(`ws://127.0.0.1:${activePort[0]}${activePort[1]}`);
report.browser = await cdp.send("Browser.getVersion");
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
const { sessionId: root } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
const send = (method, params = {}) => cdp.send(method, params, root);
const evaluate = async (expression, session = root, contextId) => {
const result = await cdp.send(
"Runtime.evaluate",
{ expression, returnByValue: true, awaitPromise: true, ...(contextId ? { contextId } : {}) },
session,
);
if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails));
return result.result.value;
};
await send("Page.enable");
await send("Runtime.enable");
async function navigate(url) {
await send("Page.navigate", { url });
for (let i = 0; i < 100; i++) {
if (await evaluate("document.readyState === 'complete' && window.fixtureReady === true"))
return;
await pause(50);
}
throw new Error("Fixture did not become ready");
}
const url = `http://127.0.0.1:${port}/index.html`;
await navigate(`${url}?noFrames=1`);
async function capture(label) {
const metrics = await send("Page.getLayoutMetrics");
const snapshot = await send("DOMSnapshot.captureSnapshot", {
computedStyles: styles,
includePaintOrder: true,
includeDOMRects: true,
});
const ax = await send("Accessibility.getFullAXTree");
const runtime = await evaluate(`Array.from(document.querySelectorAll('canvas'), el => {
const r=el.getBoundingClientRect(), s=getComputedStyle(el);
return {id:el.id,rect:{x:r.x,y:r.y,w:r.width,h:r.height},visibility:s.visibility,display:s.display,
checkVisibility:el.checkVisibility({opacityProperty:true,visibilityProperty:true}),
dpr:devicePixelRatio,scrollX,scrollY}; })`);
const result = { metrics, snapshot, ax, runtime, canvases: decodedCanvases(snapshot) };
await writeFile(path.join(output, `${label}.json`), JSON.stringify(result, null, 2));
return result;
}
const baseline = await capture("root-baseline");
const rootImage = await send("Page.captureScreenshot", { format: "png" });
await writeFile(path.join(output, "root.png"), Buffer.from(rootImage.data, "base64"));
const runtime = Object.fromEntries(baseline.runtime.map((node) => [node.id, node]));
for (const id of [
"plain",
"named",
"aria-hidden",
"inert",
"pointer-none",
"visibility-override",
"hidden-overridden",
"fallback",
])
check(`CSS visible: ${id}`, runtime[id].checkVisibility);
for (const id of ["opacity-parent", "display-none", "visibility-hidden"])
check(`CSS suppressed: ${id}`, !runtime[id].checkVisibility);
check("painted text is absent from AX", !JSON.stringify(baseline.ax).includes("PIXEL TABLE 42"));
report.css = baseline.runtime;
const bordered = baseline.canvases.find((node) => node.id === "border-canvas");
report.canvasBox = await send("DOM.getBoxModel", { backendNodeId: bordered.backendNodeId });
check(
"Canvas snapshot uses border box",
bordered.bounds[2] === 130 &&
report.canvasBox.model.border[2] - report.canvasBox.model.border[0] === 130 &&
report.canvasBox.model.content[2] - report.canvasBox.model.content[0] === 120,
);
const rootDocument = baseline.snapshot.documents[0];
const ownerNode = rootDocument.nodes.attributes.findIndex((attrs) =>
attrs.some(
(value, index) => index % 2 === 1 && baseline.snapshot.strings[value] === "border-clip-owner",
),
);
const ownerLayout = rootDocument.layout.nodeIndex.indexOf(ownerNode);
report.clipClientBox = {
snapshot: rootDocument.layout.clientRects[ownerLayout],
runtime: await evaluate(
"(() => {const e=document.getElementById('border-clip-owner');return [e.clientLeft,e.clientTop,e.clientWidth,e.clientHeight]})()",
),
};
check(
"snapshot client rect contains local client offsets and size",
JSON.stringify(report.clipClientBox.snapshot) ===
JSON.stringify(report.clipClientBox.runtime) &&
JSON.stringify(report.clipClientBox.runtime) === "[5,5,68,38]",
);
report.clips = await evaluate(`['partial-clip','full-clip','axis-clip'].map(id=>{
const el=document.getElementById(id),p=el.parentElement,s=getComputedStyle(p);
const a=el.getBoundingClientRect(),b=p.getBoundingClientRect();
return {id,overflowX:s.overflowX,overflowY:s.overflowY,
child:{x:a.x,y:a.y,w:a.width,h:a.height},parent:{x:b.x,y:b.y,w:b.width,h:b.height}};
})`);
const [partial, full, axis] = report.clips;
check(
"partial overflow crop dimensions",
partial.parent.w === 60 &&
partial.parent.h === 30 &&
partial.child.x === partial.parent.x &&
partial.child.y === partial.parent.y,
);
check(
"fully clipped candidate lies beyond ancestor",
full.child.x >= full.parent.x + full.parent.w,
);
check(
"single-axis clip remains independent in computed style",
axis.overflowX === "clip" && axis.overflowY === "visible" && axis.child.h > axis.parent.h,
);
const samples = [];
for (let i = 0; i < 12; i++)
samples.push(
await evaluate(
"JSON.stringify(document.querySelector('#plain').getBoundingClientRect().toJSON())",
),
);
check("static rect stable over 12 reads", new Set(samples).size === 1);
await evaluate("document.querySelector('#plain').style.transform='translateX(0.5px)'");
report.halfPixelMovement = await evaluate(
"document.querySelector('#plain').getBoundingClientRect().x",
);
await evaluate("document.querySelector('#plain').style.transform=''");
report.raster = [];
for (const dpr of [1, 1.5, 2]) {
await send("Emulation.setDeviceMetricsOverride", {
width: 1000,
height: 900,
deviceScaleFactor: dpr,
mobile: false,
});
const state = await capture(`dpr-${dpr}`);
const image = await send("Page.captureScreenshot", {
format: "png",
clip: { x: 20, y: 100, width: 120, height: 40, scale: 1 },
});
const bytes = Buffer.from(image.data, "base64");
const dimensions = { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
report.raster.push({
dpr,
dimensions,
metrics: state.metrics,
plain: state.canvases.find((node) => node.id === "plain"),
});
check(
`screenshot raster DPR ${dpr}`,
dimensions.width === 120 * dpr && dimensions.height === 40 * dpr,
);
}
await send("Emulation.clearDeviceMetricsOverride");
await evaluate("document.body.style.zoom='1.25'");
const cssZoom = await capture("css-zoom-1.25");
check(
"CSS zoom is reflected in snapshot geometry",
cssZoom.canvases.find((n) => n.id === "plain").bounds[2] === 150,
);
report.cssZoom = {
runtime: cssZoom.runtime[0],
snapshot: cssZoom.canvases.find((n) => n.id === "plain"),
};
await evaluate("document.body.style.zoom=''");
await send("Emulation.setPageScaleFactor", { pageScaleFactor: 1.5 });
report.pageScale = (await capture("page-scale-1.5")).metrics;
await send("Emulation.setPageScaleFactor", { pageScaleFactor: 1 });
await evaluate("scrollTo(0,100)");
const scrolled = await capture("scroll-100");
check(
"snapshot document bounds stable under scroll",
JSON.stringify(scrolled.canvases.find((n) => n.id === "plain").bounds) ===
JSON.stringify(baseline.canvases.find((n) => n.id === "plain").bounds),
);
check(
"runtime viewport y follows scroll",
scrolled.runtime[0].rect.y === baseline.runtime[0].rect.y - 100,
);
await navigate(url);
await pause(200);
const targets = await cdp.send("Target.getTargets");
const iframeTarget = targets.targetInfos.find(
(target) => target.type === "iframe" && target.url.startsWith(`http://localhost:${port}/`),
);
check("cross-site frame is an actual OOPIF target", iframeTarget);
const { sessionId: child } = await cdp.send("Target.attachToTarget", {
targetId: iframeTarget.targetId,
flatten: true,
});
report.frames = {};
for (const [name, session] of [
["root", root],
["oopif", child],
]) {
const tree = await cdp.send("Page.getFrameTree", {}, session);
const snapshot = await cdp.send(
"DOMSnapshot.captureSnapshot",
{ computedStyles: styles, includeDOMRects: true },
session,
);
const frameRows = [];
const stack = [tree.frameTree];
while (stack.length) {
const node = stack.pop();
const world = await cdp.send(
"Page.createIsolatedWorld",
{ frameId: node.frame.id, worldName: "bsk-design-probe" },
session,
);
const local = await evaluate(
"({url:location.pathname,innerWidth,innerHeight,scrollX,scrollY,canvas:document.querySelector('canvas').getBoundingClientRect().toJSON()})",
session,
world.executionContextId,
);
let owner;
if (node.frame.parentId) {
// All owners in this fixture live in the root target; child content can be OOPIF.
const address = await send("DOM.getFrameOwner", { frameId: node.frame.id });
owner = await send("DOM.getBoxModel", { backendNodeId: address.backendNodeId });
}
frameRows.push({ frame: node.frame, local, owner });
stack.push(...(node.childFrames ?? []));
}
report.frames[name] = { tree, snapshot, canvases: decodedCanvases(snapshot), frameRows };
}
check(
"same-target snapshot includes nested documents",
report.frames.root.snapshot.documents.length === 3,
);
check("oopif snapshot isolated from root", report.frames.oopif.snapshot.documents.length === 1);
const sameRow = report.frames.root.frameRows.find(
(row) => row.owner && row.local.innerWidth === 300,
);
const nestedRow = report.frames.root.frameRows.find(
(row) => row.owner && row.local.innerWidth === 180,
);
check(
"same frame content origin includes border",
sameRow.owner.model.content[0] === 50 && sameRow.owner.model.content[1] === 610,
);
check(
"nested owner quad is already target-root relative",
nestedRow.owner.model.content[0] === 87 && nestedRow.owner.model.content[1] === 707,
);
const crossRow = report.frames.oopif.frameRows[0];
check(
"OOPIF owner content origin",
crossRow.owner.model.content[0] === 410 && crossRow.owner.model.content[1] === 610,
);
const sameFrame = sameRow.frame.id;
const world = await send("Page.createIsolatedWorld", {
frameId: sameFrame,
worldName: "bsk-design-probe",
});
await evaluate("scrollTo(0,10)", root, world.executionContextId);
const frameScroll = await send("DOMSnapshot.captureSnapshot", {
computedStyles: styles,
includeDOMRects: true,
});
report.frameScroll = {
documents: frameScroll.documents.map((d) => ({
frameId: frameScroll.strings[d.frameId],
scrollOffsetY: d.scrollOffsetY,
})),
canvases: decodedCanvases(frameScroll),
local: await evaluate(
"document.querySelector('canvas').getBoundingClientRect().toJSON()",
root,
world.executionContextId,
),
};
check("frame scroll is local", report.frameScroll.local.y === 13);
check(
"frame snapshot stays document-relative after child scroll",
report.frameScroll.canvases.find((n) => n.frameId === sameFrame).bounds[1] === 23,
);
await evaluate("scrollTo(0,0)", root, world.executionContextId);
await evaluate(
"document.querySelector('#same').style.transformOrigin='0 0';document.querySelector('#same').style.transform='scale(1.25)'",
);
const sameOwner = await send("DOM.getFrameOwner", { frameId: sameFrame });
report.transformedOwner = await send("DOM.getBoxModel", {
backendNodeId: sameOwner.backendNodeId,
});
check(
"scaled frame owner content width",
report.transformedOwner.model.content[2] - report.transformedOwner.model.content[0] === 375,
);
await evaluate("document.querySelector('#same').style.transform=''");
const screenshot = await send("Page.captureScreenshot", { format: "png" });
await writeFile(path.join(output, "frames.png"), Buffer.from(screenshot.data, "base64"));
const before = (await send("Page.getFrameTree")).frameTree.frame;
const oldDocument = (await send("DOM.getDocument")).root.backendNodeId;
await evaluate("history.replaceState(null,'','#same-document')");
const sameDocument = (await send("Page.getFrameTree")).frameTree.frame;
const sameDocumentBackend = (await send("DOM.getDocument")).root.backendNodeId;
await navigate(`${url}?noFrames=1&navigation=2`);
const after = (await send("Page.getFrameTree")).frameTree.frame;
const newDocument = (await send("DOM.getDocument")).root.backendNodeId;
report.identity = { before, sameDocument, after, oldDocument, sameDocumentBackend, newDocument };
check("same-document navigation preserves loader", before.loaderId === sameDocument.loaderId);
check("cross-document navigation changes loader", before.loaderId !== after.loaderId);
check("frame id survives document replacement", before.id === after.id);
check(
"document backend identity changes on navigation",
oldDocument !== newDocument && oldDocument === sameDocumentBackend,
);
const documentTree = await send("DOM.getDocument");
report.identity.htmlBeforeOpen = documentTree.root.children.find(
(node) => node.nodeType === 1,
).backendNodeId;
const anchorNode = await send("DOM.querySelector", {
nodeId: documentTree.root.nodeId,
selector: "canvas",
});
const anchor = await send("DOM.describeNode", { nodeId: anchorNode.nodeId });
await evaluate(
"document.open(); document.write('<!doctype html><title>Replacement</title><canvas></canvas>'); document.close()",
);
report.identity.documentOpen = (await send("Page.getFrameTree")).frameTree.frame;
const afterOpenTree = await send("DOM.getDocument");
report.identity.documentOpenBackend = afterOpenTree.root.backendNodeId;
report.identity.htmlAfterOpen = afterOpenTree.root.children.find(
(node) => node.nodeType === 1,
).backendNodeId;
check(
"document.open replaces documentElement identity",
report.identity.htmlBeforeOpen !== report.identity.htmlAfterOpen,
);
try {
const object = await send("DOM.resolveNode", { backendNodeId: anchor.node.backendNodeId });
const connected = await send("Runtime.callFunctionOn", {
objectId: object.object.objectId,
functionDeclaration: "function(){return this.isConnected}",
returnByValue: true,
});
report.identity.oldAnchorConnected = connected.result.value;
} catch {
report.identity.oldAnchorConnected = false;
}
check(
"document.open invalidates old anchor even if document identifiers survive",
report.identity.oldAnchorConnected === false,
);
report.counts = cdp.counts;
report.checks = checks;
process.stdout.write(
`${JSON.stringify({ browser: report.browser.product, checks: checks.length, output })}\n`,
);
} catch (error) {
report.error = error.stack;
report.checks = checks;
process.exitCode = 1;
process.stderr.write(`${error.stack}\n`);
} finally {
await writeFile(path.join(output, "report.json"), JSON.stringify(report, null, 2));
cdp.close();
if (chrome && chrome.exitCode === null) {
const exited = once(chrome, "exit");
chrome.kill("SIGTERM");
await Promise.race([exited, pause(2000)]);
if (chrome.exitCode === null) {
chrome.kill("SIGKILL");
await exited;
}
}
await new Promise((resolve) => server.close(resolve));
await rm(profile, { recursive: true, force: true });
}
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Embedded Canvas table reproduction</title>
<style>
body { font: 16px sans-serif; }
iframe { display: block; width: 800px; height: 440px; border: 8px solid #ccc; }
</style>
<h1>Embedded table</h1>
<button type="button">Page action</button>
<iframe title="Table application"></iframe>
<script>
document.querySelector("iframe").src = `http://localhost:${location.port}/grid.html`;
</script>
</html>
+20
View File
@@ -52,6 +52,7 @@ pub mod reason {
pub const OPTION_NOT_FOUND: &str = "option_not_found"; pub const OPTION_NOT_FOUND: &str = "option_not_found";
pub const SINGLE_SELECT_VALUE_COUNT: &str = "single_select_value_count"; pub const SINGLE_SELECT_VALUE_COUNT: &str = "single_select_value_count";
pub const TAB_NOT_ACTIVE: &str = "tab_not_active"; pub const TAB_NOT_ACTIVE: &str = "tab_not_active";
pub const CDP_EXTENSION_ACCESS_DENIED: &str = "cdp_extension_access_denied";
pub const BORROW_CONFLICT: &str = "borrow_conflict"; pub const BORROW_CONFLICT: &str = "borrow_conflict";
pub const SCREENSHOT_CAPTURE_FAILED: &str = "screenshot_capture_failed"; pub const SCREENSHOT_CAPTURE_FAILED: &str = "screenshot_capture_failed";
pub const FILE_INPUT_PROBE_FAILED: &str = "file_input_probe_failed"; pub const FILE_INPUT_PROBE_FAILED: &str = "file_input_probe_failed";
@@ -211,6 +212,13 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
return base; return base;
}; };
match (code, reason) { match (code, reason) {
(ErrorCode::CdpFailed, reason::CDP_EXTENSION_ACCESS_DENIED) => RenderInfo {
summary: "Chrome blocked CDP access to another extension's content in this tab",
hint: Some(
"a web page can contain a restricted extension frame; disable the conflicting extension and reload, or use `bsk navigate <url>` to leave this page; reconnecting BrowserSkill alone does not remove the restriction",
),
exit_code: base.exit_code,
},
(_, reason::TRANSFER_OUTCOME_UNKNOWN) => RenderInfo { (_, reason::TRANSFER_OUTCOME_UNKNOWN) => RenderInfo {
summary: "the file transfer outcome could not be confirmed", summary: "the file transfer outcome could not be confirmed",
hint: Some( hint: Some(
@@ -544,6 +552,18 @@ mod tests {
assert!(!info.summary.contains("sandbox")); assert!(!info.summary.contains("sandbox"));
} }
#[test]
fn extension_access_denied_explains_the_frame_restriction_and_navigation_recovery() {
let data = serde_json::json!({ "reason": reason::CDP_EXTENSION_ACCESS_DENIED });
let info = info_for_error(ErrorCode::CdpFailed, Some(&data));
assert!(info.summary.contains("another extension"));
let hint = info.hint.unwrap();
assert!(hint.contains("restricted extension frame"));
assert!(hint.contains("bsk navigate <url>"));
assert!(hint.contains("disable the conflicting extension"));
assert_eq!(info.exit_code, 3);
}
#[test] #[test]
fn session_busy_overrides_timeout_copy() { fn session_busy_overrides_timeout_copy() {
let data = serde_json::json!({ "reason": reason::SESSION_BUSY }); let data = serde_json::json!({ "reason": reason::SESSION_BUSY });
+238 -76
View File
@@ -6,9 +6,7 @@ use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
#[cfg(windows)] #[cfg(windows)]
use std::os::windows::process::CommandExt; mod windows;
#[cfg(windows)]
use std::process::{Command, Stdio};
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use clap::Args; use clap::Args;
@@ -363,7 +361,8 @@ fn install_candidate_with_client(
crate::daemon::start::run_stop().context("stop bsk daemon before update")?; crate::daemon::start::run_stop().context("stop bsk daemon before update")?;
} }
let action = replace_binary_for_update(&target, &binary, daemon_was_running)?; let restart_args = daemon_was_running.then(StartArgs::default);
let action = replace_binary_for_update(&target, &binary, restart_args.as_ref())?;
if daemon_was_running && matches!(action, InstallAction::Replaced) { if daemon_was_running && matches!(action, InstallAction::Replaced) {
crate::daemon::start::run_start(StartArgs::default()) crate::daemon::start::run_start(StartArgs::default())
@@ -394,8 +393,8 @@ pub(crate) fn download_candidate_binary(
/// Daemon-side install: download, verify, and replace the executable at /// Daemon-side install: download, verify, and replace the executable at
/// `target` (on Windows: stage the replacement next to it). Unlike the /// `target` (on Windows: stage the replacement next to it). Unlike the
/// CLI path this never stops or starts the daemon — the daemon drives /// CLI path this never stops the daemon. On Windows the helper starts
/// its own restart once the binary is replaced. /// its replacement after exit; elsewhere the daemon drives its own restart.
/// ///
/// `target` must be captured *before* any replacement happens: on Linux /// `target` must be captured *before* any replacement happens: on Linux
/// `std::env::current_exe` starts returning a ` (deleted)`-suffixed /// `std::env::current_exe` starts returning a ` (deleted)`-suffixed
@@ -403,10 +402,11 @@ pub(crate) fn download_candidate_binary(
pub(crate) fn self_install_candidate( pub(crate) fn self_install_candidate(
candidate: &UpdateCandidate, candidate: &UpdateCandidate,
target: &Path, target: &Path,
restart_args: &StartArgs,
) -> Result<InstallAction> { ) -> Result<InstallAction> {
let client = update_http_client(ARCHIVE_FETCH_TIMEOUT)?; let client = update_http_client(ARCHIVE_FETCH_TIMEOUT)?;
let binary = download_candidate_binary(candidate, &client)?; let binary = download_candidate_binary(candidate, &client)?;
replace_binary_at_path(target, &binary) replace_binary_for_update(target, &binary, Some(restart_args))
} }
/// Outcome of one daemon auto-update step (see [`auto_update_step`]). /// Outcome of one daemon auto-update step (see [`auto_update_step`]).
@@ -423,8 +423,8 @@ pub(crate) enum AutoUpdateOutcome {
/// The new binary replaced the old one; the daemon should now /// The new binary replaced the old one; the daemon should now
/// restart into it. /// restart into it.
Replaced { latest: String }, Replaced { latest: String },
/// Windows staged the replacement next to the running binary; a /// Windows started a helper that will replace the executable and
/// daemon/terminal restart finishes it. /// restart the daemon once the current process exits.
Staged { latest: String }, Staged { latest: String },
} }
@@ -668,6 +668,7 @@ fn render_report(format: Format, report: &UpdateReport) -> Result<()> {
} }
if matches!(report.install_action, Some("staged")) { if matches!(report.install_action, Some("staged")) {
println!("the detached update helper will apply the replacement after exit"); println!("the detached update helper will apply the replacement after exit");
println!("if it fails, see the *.update-*.log file next to the executable");
} }
} }
Format::Json => { Format::Json => {
@@ -688,22 +689,22 @@ pub fn extract_bsk_binary(archive_bytes: &[u8], kind: ArchiveKind) -> Result<Vec
} }
pub fn replace_binary_at_path(target: &Path, binary: &[u8]) -> Result<InstallAction> { pub fn replace_binary_at_path(target: &Path, binary: &[u8]) -> Result<InstallAction> {
replace_binary_for_update(target, binary, false) replace_binary_for_update(target, binary, None)
} }
fn replace_binary_for_update( fn replace_binary_for_update(
target: &Path, target: &Path,
binary: &[u8], binary: &[u8],
restart_daemon: bool, restart_args: Option<&StartArgs>,
) -> Result<InstallAction> { ) -> Result<InstallAction> {
#[cfg(windows)] #[cfg(windows)]
{ {
stage_windows_replacement(target, binary, restart_daemon) stage_windows_replacement(target, binary, restart_args)
} }
#[cfg(not(windows))] #[cfg(not(windows))]
{ {
let _ = restart_daemon; let _ = restart_args;
replace_binary_atomically(target, binary) replace_binary_atomically(target, binary)
} }
} }
@@ -749,34 +750,69 @@ fn replace_binary_atomically(target: &Path, binary: &[u8]) -> Result<InstallActi
fn stage_windows_replacement( fn stage_windows_replacement(
target: &Path, target: &Path,
binary: &[u8], binary: &[u8],
restart_daemon: bool, restart_args: Option<&StartArgs>,
) -> Result<InstallAction> { ) -> Result<InstallAction> {
let paths = staged_replacement_paths(target, std::process::id())?; // The helper owns replacement/restart after this process exits. Do not
std::fs::write(&paths.binary_path, binary) // report Staged until it has actually begun executing the script.
.with_context(|| format!("write {}", paths.binary_path.display()))?; let _child = spawn_windows_replacement(target, binary, restart_args, 120)?;
std::fs::write( Ok(InstallAction::Staged)
&paths.script_path, }
windows_replacement_script(target, &paths.binary_path, restart_daemon),
)
.with_context(|| format!("write {}", paths.script_path.display()))?;
const CREATE_NO_WINDOW: u32 = 0x0800_0000; #[cfg(windows)]
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; fn spawn_windows_replacement(
let command = format!("\"{}\"", paths.script_path.display()); target: &Path,
if let Err(err) = Command::new("cmd.exe") binary: &[u8],
.args(["/D", "/S", "/C"]) restart_args: Option<&StartArgs>,
.arg(command) attempts: u32,
.stdin(Stdio::null()) ) -> Result<windows::Helper> {
.stdout(Stdio::null()) let paths = staged_replacement_paths(target, std::process::id())?;
.stderr(Stdio::null()) let ready_path = paths.script_path.with_extension("ready");
.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP) let log_path = paths.script_path.with_extension("log");
.spawn() let result = (|| {
{ // A previous failed attempt by this process must not acknowledge a
// new helper. All paths are private to this target and process id.
match std::fs::remove_file(&ready_path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}
std::fs::write(&paths.binary_path, binary)
.with_context(|| format!("write {}", paths.binary_path.display()))?;
std::fs::write(
&paths.script_path,
windows_replacement_script(restart_args, attempts),
)
.with_context(|| format!("write {}", paths.script_path.display()))?;
let mut child = windows::spawn(
&paths.script_path,
&paths.binary_path,
target,
&ready_path,
&log_path,
)
.context("launch detached Windows update helper")?;
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
if std::fs::remove_file(&ready_path).is_ok() {
return Ok(child);
}
if let Some(status) = child.try_wait()? {
bail!("Windows update helper exited before becoming ready: {status}");
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
bail!("Windows update helper did not become ready within 5 seconds");
}
std::thread::sleep(Duration::from_millis(20));
}
})();
if result.is_err() {
let _ = std::fs::remove_file(&paths.binary_path); let _ = std::fs::remove_file(&paths.binary_path);
let _ = std::fs::remove_file(&paths.script_path); let _ = std::fs::remove_file(&paths.script_path);
return Err(err).context("launch detached Windows update helper"); let _ = std::fs::remove_file(&ready_path);
} }
Ok(InstallAction::Staged) result.with_context(|| format!("Windows update helper failed; see {}", log_path.display()))
} }
pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplacementPaths> { pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplacementPaths> {
@@ -793,25 +829,43 @@ pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplace
} }
#[cfg(any(windows, test))] #[cfg(any(windows, test))]
fn windows_replacement_script(target: &Path, staged_binary: &Path, restart_daemon: bool) -> String { fn windows_replacement_script(restart_args: Option<&StartArgs>, attempts: u32) -> String {
let target = target.display().to_string().replace('%', "%%"); // Only fixed switches and numeric values go into the ASCII script. Paths
let staged_binary = staged_binary.display().to_string().replace('%', "%%"); // stay in Unicode environment variables, including %, ! and shell symbols.
let restart = if restart_daemon { let restart = restart_args.map_or_else(String::new, |args| {
format!("start \"\" /B \"{target}\" daemon start >nul 2>nul\r\n") format!(
} else { "\"%BSK_UPDATE_TARGET%\" daemon start --port {} --session-idle {}ms --daemon-idle {}ms\r\nif errorlevel 1 goto failed_restart\r\n",
String::new() args.resolved_port(),
}; args.resolved_session_idle().as_millis(),
args.resolved_daemon_idle().as_millis(),
)
});
format!( format!(
"@echo off\r\n\ "@echo off\r\n\
setlocal\r\n\ setlocal DisableDelayedExpansion\r\n\
> \"%BSK_UPDATE_READY%\" echo ready\r\n\
if errorlevel 1 exit /b 1\r\n\
set /a attempts=0\r\n\
:retry\r\n\ :retry\r\n\
move /Y \"{staged_binary}\" \"{target}\" >nul 2>nul\r\n\ move /Y \"%BSK_UPDATE_SOURCE%\" \"%BSK_UPDATE_TARGET%\" >nul\r\n\
if errorlevel 1 (\r\n\ if not errorlevel 1 goto replaced\r\n\
ping -n 2 127.0.0.1 >nul\r\n\ set /a attempts+=1\r\n\
goto retry\r\n\ if %attempts% geq {attempts} goto failed_replace\r\n\
)\r\n\ if not exist \"%BSK_UPDATE_SOURCE%\" goto failed_replace\r\n\
ping -n 2 127.0.0.1 >nul\r\n\
goto retry\r\n\
:failed_replace\r\n\
echo Failed to replace the executable after %attempts% attempts.\r\n\
goto failed\r\n\
:replaced\r\n\
{restart}\ {restart}\
del /F /Q \"%~f0\" >nul 2>nul\r\n" del /F /Q \"%BSK_UPDATE_LOG%\" >nul 2>nul\r\n\
(del /F /Q \"%BSK_UPDATE_SCRIPT%\" >nul 2>nul & exit 0)\r\n\
:failed_restart\r\n\
echo Replaced the executable but failed to restart the daemon.\r\n\
:failed\r\n\
del /F /Q \"%BSK_UPDATE_SOURCE%\" >nul 2>nul\r\n\
(del /F /Q \"%BSK_UPDATE_SCRIPT%\" >nul 2>nul & exit 1)\r\n"
) )
} }
@@ -965,6 +1019,7 @@ mod tests {
assert_eq!(extracted, b"windows binary"); assert_eq!(extracted, b"windows binary");
} }
#[cfg(not(windows))]
#[test] #[test]
fn replaces_binary_at_path() { fn replaces_binary_at_path() {
let tmp = tempfile::TempDir::new().unwrap(); let tmp = tempfile::TempDir::new().unwrap();
@@ -999,31 +1054,139 @@ mod tests {
} }
#[test] #[test]
fn windows_replacement_script_retries_restarts_and_cleans_itself() { fn windows_replacement_script_preserves_restart_config() {
let script = windows_replacement_script( let args = StartArgs {
Path::new(r"C:\Program Files\bsk.exe"), port: Some(54321),
Path::new(r"C:\Program Files\bsk.exe.update-42"), session_idle: Some(Duration::from_millis(1234)),
true, daemon_idle: Some(Duration::from_secs(75)),
..Default::default()
};
let script = windows_replacement_script(Some(&args), 120);
assert!(script.is_ascii());
assert!(
script
.contains("daemon start --port 54321 --session-idle 1234ms --daemon-idle 75000ms")
); );
assert!(!windows_replacement_script(None, 120).contains("daemon start"));
assert!(script.contains(":retry"));
assert!(script.contains("move /Y"));
assert!(script.contains("ping -n 2 127.0.0.1"));
assert!(!script.contains("timeout"));
assert!(script.contains(r#"start "" /B "C:\Program Files\bsk.exe" daemon start"#));
assert!(script.contains(r#"del /F /Q "%~f0""#));
} }
#[test] #[cfg(windows)]
fn windows_replacement_script_omits_restart_when_daemon_was_not_running() { fn wait_for_helper(mut child: windows::Helper) -> std::process::ExitStatus {
let script = windows_replacement_script( let deadline = std::time::Instant::now() + Duration::from_secs(10);
Path::new(r"C:\bsk.exe"), loop {
Path::new(r"C:\bsk.exe.update-42"), if let Some(status) = child.try_wait().unwrap() {
false, return status;
); }
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
panic!("Windows update helper did not exit");
}
std::thread::sleep(Duration::from_millis(20));
}
}
assert!(!script.contains("daemon start")); #[cfg(windows)]
assert!(script.contains(r#"del /F /Q "%~f0""#)); #[test]
fn windows_helper_replaces_in_unicode_spaces_and_shell_symbol_paths() {
let tmp = tempfile::TempDir::new().unwrap();
for name in [
"ascii",
"with space",
"中文目录",
"literal %PATH% ! & (folder)",
] {
let dir = tmp.path().join(name);
std::fs::create_dir(&dir).unwrap();
let target = dir.join("bsk.exe");
std::fs::write(&target, b"old binary").unwrap();
let child = spawn_windows_replacement(&target, b"new binary", None, 5).unwrap();
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
let status = wait_for_helper(child);
assert!(
status.success(),
"{name}: {:?}",
std::fs::read_to_string(paths.script_path.with_extension("log"))
);
assert_eq!(std::fs::read(&target).unwrap(), b"new binary", "{name}");
// Successful updates leave no helper, staged binary, ready file or log.
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "{name}");
}
}
#[cfg(windows)]
#[test]
fn windows_helper_waits_for_unlock_then_replaces() {
use std::os::windows::fs::OpenOptionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("bsk.exe");
std::fs::write(&target, b"old binary").unwrap();
// Deny delete sharing, like a running Windows executable.
let lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(1)
.open(&target)
.unwrap();
let mut child = spawn_windows_replacement(&target, b"new binary", None, 5).unwrap();
std::thread::sleep(Duration::from_millis(150));
assert!(child.try_wait().unwrap().is_none());
assert_eq!(std::fs::read(&target).unwrap(), b"old binary");
drop(lock);
assert!(wait_for_helper(child).success());
assert_eq!(std::fs::read(&target).unwrap(), b"new binary");
}
#[cfg(windows)]
#[test]
fn windows_helper_bounds_retries_and_preserves_old_binary_on_failure() {
use std::os::windows::fs::OpenOptionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("bsk.exe");
std::fs::write(&target, b"old binary").unwrap();
let _lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(1)
.open(&target)
.unwrap();
let child = spawn_windows_replacement(&target, b"new binary", None, 2).unwrap();
assert!(!wait_for_helper(child).success());
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"old binary");
assert!(!paths.binary_path.exists());
assert!(!paths.script_path.exists());
let log_bytes = std::fs::read(paths.script_path.with_extension("log")).unwrap();
let log = String::from_utf8_lossy(&log_bytes);
assert!(
log.contains("Failed to replace the executable after 2 attempts."),
"{log}"
);
}
#[cfg(windows)]
#[test]
fn windows_helper_reports_restart_failure_without_losing_replacement() {
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("bsk.exe");
std::fs::write(&target, b"old binary").unwrap();
// Not a valid PE executable: replacement succeeds but restart fails.
let child = spawn_windows_replacement(
&target,
b"invalid executable",
Some(&StartArgs::default()),
2,
)
.unwrap();
assert!(!wait_for_helper(child).success());
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
let bytes = std::fs::read(paths.script_path.with_extension("log")).unwrap();
let log = String::from_utf8_lossy(&bytes);
assert!(
log.contains("Replaced the executable but failed to restart the daemon."),
"{log}"
);
assert_eq!(std::fs::read(&target).unwrap(), b"invalid executable");
assert!(!paths.script_path.exists());
assert!(!paths.binary_path.exists());
} }
#[test] #[test]
@@ -1282,10 +1445,9 @@ mod tests {
} }
#[test] #[test]
fn auto_update_step_staged_outcome_requests_no_restart() { fn auto_update_step_reports_staged_handoff() {
// The Windows shape: the replacement is staged next to the // The helper, rather than this process, starts the new executable.
// running binary, so the outcome must not ask for the immediate // The daemon must exit to let that staged handoff finish.
// self-restart that `Replaced` triggers.
let candidate = test_candidate(); let candidate = test_candidate();
let outcome = let outcome =
auto_update_step(Some(&candidate), true, 0, |_| Ok(InstallAction::Staged)).unwrap(); auto_update_step(Some(&candidate), true, 0, |_| Ok(InstallAction::Staged)).unwrap();
+203
View File
@@ -0,0 +1,203 @@
//! Launch the update helper with only its stdio handles inherited.
//!
//! std::process::Command on Windows also inherits other inheritable handles.
//! A daemon's listening socket must not survive in the helper: it would keep
//! the port occupied after the daemon exits and prevent its replacement starting.
use std::ffi::OsStr;
use std::fs::File;
use std::io;
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::os::windows::process::ExitStatusExt;
use std::path::Path;
use std::process::ExitStatus;
use windows_sys::Win32::Foundation::{
HANDLE, HANDLE_FLAG_INHERIT, SetHandleInformation, WAIT_OBJECT_0, WAIT_TIMEOUT,
};
use windows_sys::Win32::System::Threading::{
CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, CreateProcessW,
DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetExitCodeProcess,
InitializeProcThreadAttributeList, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROCESS_INFORMATION,
STARTF_USESTDHANDLES, STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute,
WaitForSingleObject,
};
pub(super) struct Helper(OwnedHandle);
impl Helper {
pub(super) fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
// SAFETY: the owned process handle remains valid for both calls.
match unsafe { WaitForSingleObject(self.0.as_raw_handle(), 0) } {
WAIT_OBJECT_0 => {
let mut code = 0;
if unsafe { GetExitCodeProcess(self.0.as_raw_handle(), &mut code) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(Some(ExitStatus::from_raw(code)))
}
WAIT_TIMEOUT => Ok(None),
_ => Err(io::Error::last_os_error()),
}
}
pub(super) fn kill(&mut self) -> io::Result<()> {
// SAFETY: this handle belongs to the helper we created.
if unsafe { TerminateProcess(self.0.as_raw_handle(), 1) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
pub(super) fn wait(&mut self) -> io::Result<()> {
// Only used after kill(); do not leave a live helper on startup failure.
if unsafe { WaitForSingleObject(self.0.as_raw_handle(), 5000) } != WAIT_OBJECT_0 {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"update helper did not exit",
));
}
Ok(())
}
}
// The opaque attribute list needs pointer-aligned storage and explicit teardown.
struct AttributeList(Vec<usize>);
impl Drop for AttributeList {
fn drop(&mut self) {
// SAFETY: this wrapper is constructed only after successful initialization.
unsafe { DeleteProcThreadAttributeList(self.0.as_mut_ptr().cast()) };
}
}
pub(super) fn spawn(
script: &Path,
source: &Path,
target: &Path,
ready: &Path,
log_path: &Path,
) -> io::Result<Helper> {
let root = std::env::var_os("SystemRoot")
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "SystemRoot is not set"))?;
let application = wide(Path::new(&root).join("System32/cmd.exe").as_os_str());
// /S /C strips one outer pair of quotes. Environment expansion keeps paths
// Unicode and avoids CRT escaping, batch-file encoding, and CALL expansion.
let mut command = wide(OsStr::new(
"cmd.exe /D /V:OFF /S /C \"\"%BSK_UPDATE_SCRIPT%\"\"",
));
let overrides = [
("BSK_UPDATE_SCRIPT", script.as_os_str()),
("BSK_UPDATE_SOURCE", source.as_os_str()),
("BSK_UPDATE_TARGET", target.as_os_str()),
("BSK_UPDATE_READY", ready.as_os_str()),
("BSK_UPDATE_LOG", log_path.as_os_str()),
];
let mut env: Vec<_> = std::env::vars_os()
.filter(|(key, _)| {
let key = key.to_string_lossy();
!overrides
.iter()
.any(|(name, _)| key.eq_ignore_ascii_case(name))
&& !key.eq_ignore_ascii_case(crate::daemon::start::DAEMONIZED_ENV)
&& !key.eq_ignore_ascii_case(crate::daemon::start::DAEMON_REPLACEMENT_WAIT_ENV)
})
.collect();
env.extend(
overrides
.into_iter()
.map(|(key, value)| (key.into(), value.to_owned())),
);
env.sort_by_key(|(key, _)| key.to_string_lossy().to_uppercase());
let mut environment = Vec::new();
for (key, value) in env {
environment.extend(key.encode_wide());
environment.push(b'=' as u16);
environment.extend(value.encode_wide());
environment.push(0);
}
environment.push(0);
let input = File::open("NUL")?;
let log = File::create(log_path)?;
let handles: [HANDLE; 2] = [input.as_raw_handle(), log.as_raw_handle()];
for &handle in &handles {
// SAFETY: these are dedicated helper stdio handles, held until spawn
// finishes. The explicit handle list excludes every other parent handle.
if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) } == 0 {
return Err(io::Error::last_os_error());
}
}
let mut size = 0;
// SAFETY: the first call queries the allocation size, as required by Win32.
unsafe { InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut size) };
if size == 0 {
return Err(io::Error::last_os_error());
}
let mut storage = vec![0usize; size.div_ceil(std::mem::size_of::<usize>())];
// SAFETY: storage has the requested size and pointer alignment.
if unsafe { InitializeProcThreadAttributeList(storage.as_mut_ptr().cast(), 1, 0, &mut size) }
== 0
{
return Err(io::Error::last_os_error());
}
let mut attributes = AttributeList(storage);
// SAFETY: the handle array and attribute storage outlive CreateProcessW.
if unsafe {
UpdateProcThreadAttribute(
attributes.0.as_mut_ptr().cast(),
0,
PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize,
handles.as_ptr().cast(),
std::mem::size_of_val(&handles),
std::ptr::null_mut(),
std::ptr::null(),
)
} == 0
{
return Err(io::Error::last_os_error());
}
// SAFETY: these Win32 structs permit zero initialization; required fields
// are populated below before the process is created.
let mut startup: STARTUPINFOEXW = unsafe { std::mem::zeroed() };
startup.StartupInfo.cb = std::mem::size_of::<STARTUPINFOEXW>() as u32;
startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
startup.StartupInfo.hStdInput = handles[0];
startup.StartupInfo.hStdOutput = handles[1];
startup.StartupInfo.hStdError = handles[1];
startup.lpAttributeList = attributes.0.as_mut_ptr().cast();
let mut process: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
// SAFETY: strings are NUL-terminated UTF-16, the environment is double-NUL
// terminated, and all buffers/handles remain alive for the duration of spawn.
if unsafe {
CreateProcessW(
application.as_ptr(),
command.as_mut_ptr(),
std::ptr::null(),
std::ptr::null(),
1,
CREATE_NO_WINDOW
| CREATE_NEW_PROCESS_GROUP
| CREATE_UNICODE_ENVIRONMENT
| EXTENDED_STARTUPINFO_PRESENT,
environment.as_ptr().cast(),
std::ptr::null(),
&startup.StartupInfo,
&mut process,
)
} == 0
{
return Err(io::Error::last_os_error());
}
// SAFETY: successful CreateProcessW transfers these two handles to us.
let _thread = unsafe { OwnedHandle::from_raw_handle(process.hThread) };
Ok(Helper(unsafe {
OwnedHandle::from_raw_handle(process.hProcess)
}))
}
fn wide(value: &OsStr) -> Vec<u16> {
value.encode_wide().chain(Some(0)).collect()
}
+134
View File
@@ -1399,6 +1399,28 @@ mod windows {
connected = pipe.connect() => { connected = pipe.connect() => {
match connected { match connected {
Ok(()) => { Ok(()) => {
// Keep the connected instance alive until its replacement
// exists. Otherwise a fast handler can close the last
// instance and make new clients fail with NotFound.
loop {
match ServerOptions::new()
.access_inbound(true)
.access_outbound(true)
.create(&listener.pipe_name)
{
Ok(next) => {
listener.first = Some(next);
break;
}
Err(err) => {
warn!(?err, "create next named-pipe instance failed");
tokio::select! {
_ = &mut shutdown => return,
_ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
}
}
}
}
on_open(); on_open();
let handler = handler.clone(); let handler = handler.clone();
let on_act = on_activity.clone(); let on_act = on_activity.clone();
@@ -1467,6 +1489,118 @@ mod windows {
} }
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use std::time::Duration;
use tokio::net::windows::named_pipe::ClientOptions;
use tokio::sync::oneshot;
fn isolated_listener() -> NamedPipeListener {
let pipe_name = format!(r"\\.\pipe\bsk-test-{}", uuid::Uuid::new_v4());
let first = ServerOptions::new()
.first_pipe_instance(true)
.create(&pipe_name)
.unwrap();
NamedPipeListener {
pipe_name,
first: Some(first),
}
}
#[tokio::test]
async fn next_instance_exists_before_connection_is_handed_off() {
let listener = isolated_listener();
let name = listener.pipe_name.clone();
let probe_name = name.clone();
let (probe_tx, probe_rx) = oneshot::channel();
let probe_tx = Mutex::new(Some(probe_tx));
let (stop_tx, stop_rx) = oneshot::channel();
let server = tokio::spawn(serve(
listener,
crate::daemon::ipc::default_ping_handler(),
move || {
if let Some(tx) = probe_tx.lock().unwrap().take() {
// This callback runs before the connection task can finish.
// A second client must already have an instance to open.
let result = ClientOptions::new().open(&probe_name).map(drop);
let _ = tx.send(result);
}
},
|| {},
|| {},
async {
let _ = stop_rx.await;
},
));
let client = ClientOptions::new().open(&name).unwrap();
let result = tokio::time::timeout(Duration::from_secs(5), probe_rx).await;
drop(client);
let _ = stop_tx.send(());
server.await.unwrap();
result
.expect("accept callback ran")
.unwrap()
.expect("next pipe instance is ready");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn rapid_disconnects_and_concurrent_rpc_connections() {
let listener = isolated_listener();
let name = listener.pipe_name.clone();
let (stop_tx, stop_rx) = oneshot::channel();
let server = tokio::spawn(serve(
listener,
crate::daemon::ipc::default_ping_handler(),
|| {},
|| {},
|| {},
async {
let _ = stop_rx.await;
},
));
let exercise = async {
// Include clients that close without sending any request.
for _ in 0..64 {
drop(
crate::ipc_client::Client::connect_path(name.clone().into())
.await
.unwrap(),
);
}
let mut clients = tokio::task::JoinSet::new();
for _ in 0..8 {
let name = name.clone();
clients.spawn(async move {
for _ in 0..32 {
let mut client =
crate::ipc_client::Client::connect_path(name.clone().into())
.await
.unwrap();
let reply: bsk_protocol::PingResult = client
.call(
bsk_protocol::Method::SystemPing,
&serde_json::json!({}),
Duration::from_secs(5),
)
.await
.unwrap()
.unwrap();
assert!(reply.pong);
}
});
}
while let Some(result) = clients.join_next().await {
result.unwrap();
}
};
let result = tokio::time::timeout(Duration::from_secs(30), exercise).await;
let _ = stop_tx.send(());
server.await.unwrap();
result.expect("connection exercise completed");
}
}
} }
#[cfg(windows)] #[cfg(windows)]
+44 -4
View File
@@ -90,17 +90,21 @@ pub fn pid_alive(pid: u32) -> bool {
#[cfg(windows)] #[cfg(windows)]
{ {
use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Foundation::{CloseHandle, WAIT_TIMEOUT};
use windows_sys::Win32::System::Threading::{ use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject,
}; };
unsafe { unsafe {
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); let handle = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid);
if handle.is_null() { if handle.is_null() {
return false; return false;
} }
// An exited process can still be opened while another handle
// keeps its kernel object alive. Poll its termination signal;
// unlike checking STILL_ACTIVE, this also handles exit code 259.
let alive = WaitForSingleObject(handle, 0) == WAIT_TIMEOUT;
CloseHandle(handle); CloseHandle(handle);
true alive
} }
} }
@@ -110,3 +114,39 @@ pub fn pid_alive(pid: u32) -> bool {
false false
} }
} }
#[cfg(all(test, windows))]
mod tests {
use super::pid_alive;
use std::os::windows::process::CommandExt;
use std::process::Command;
#[test]
fn current_process_is_alive() {
assert!(pid_alive(std::process::id()));
}
#[test]
fn invalid_process_is_not_alive() {
assert!(!pid_alive(0));
assert!(!pid_alive(u32::MAX));
}
#[test]
fn exited_process_with_retained_handle_is_not_alive() {
for exit_code in [0, 259] {
let mut child = Command::new("cmd.exe")
.args(["/D", "/C", &format!("exit {exit_code}")])
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
.spawn()
.expect("spawn short-lived process");
assert_eq!(child.wait().unwrap().code(), Some(exit_code));
// Keep Child (and its process handle) alive during the probe.
assert!(
!pid_alive(child.id()),
"exited process with code {exit_code}"
);
drop(child);
}
}
}
+14 -9
View File
@@ -232,8 +232,8 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
let session_idle_task = spawn_session_idle_reaper(Arc::clone(&state)); let session_idle_task = spawn_session_idle_reaper(Arc::clone(&state));
let browser_liveness_task = spawn_browser_liveness_reaper(Arc::clone(&state)); let browser_liveness_task = spawn_browser_liveness_reaper(Arc::clone(&state));
// Fired by the update check task after a successful auto-update: // Fired by the update check task after a successful auto-update:
// the replacement daemon has already been spawned, so this // the replacement daemon (or Windows update helper) is ready, so
// process should shut down and let it take over. // this process should shut down and let it take over.
let restart_notify = Arc::new(tokio::sync::Notify::new()); let restart_notify = Arc::new(tokio::sync::Notify::new());
let update_check_task = let update_check_task =
spawn_update_check_task(Arc::clone(&state), Arc::clone(&restart_notify)); spawn_update_check_task(Arc::clone(&state), Arc::clone(&restart_notify));
@@ -539,8 +539,8 @@ pub(crate) fn spawn_session_idle_reaper(state: Arc<DaemonState>) -> tokio::task:
/// task spawns the replacement daemon (see /// task spawns the replacement daemon (see
/// [`DAEMON_REPLACEMENT_WAIT_ENV`]) and fires `restart` so this process /// [`DAEMON_REPLACEMENT_WAIT_ENV`]) and fires `restart` so this process
/// shuts down and the new version takes over; on Windows the /// shuts down and the new version takes over; on Windows the
/// replacement can only be staged, so it just logs that a restart is /// helper waits for this process to exit, replaces the binary, and starts
/// needed. /// the new daemon with the same configuration.
pub(crate) fn spawn_update_check_task( pub(crate) fn spawn_update_check_task(
state: Arc<DaemonState>, state: Arc<DaemonState>,
restart: Arc<tokio::sync::Notify>, restart: Arc<tokio::sync::Notify>,
@@ -605,7 +605,11 @@ pub(crate) fn spawn_update_check_task(
|candidate| { |candidate| {
let target = let target =
exe_path.as_deref().context("current executable unknown")?; exe_path.as_deref().context("current executable unknown")?;
update::self_install_candidate(candidate, target) update::self_install_candidate(
candidate,
target,
&restart_start_args(&state.config),
)
}, },
) )
}) })
@@ -635,10 +639,11 @@ pub(crate) fn spawn_update_check_task(
sessions, sessions,
"auto-update postponed: agent session(s) active; will retry on the next tick" "auto-update postponed: agent session(s) active; will retry on the next tick"
), ),
update::AutoUpdateOutcome::Staged { latest } => warn!( update::AutoUpdateOutcome::Staged { latest } => {
%latest, info!(%latest, "update helper ready; exiting so it can replace and restart the daemon");
"auto-update staged the new binary but cannot replace the running daemon in place; restart the daemon (or terminal) to finish the upgrade" restart.notify_one();
), return;
}
update::AutoUpdateOutcome::Replaced { latest } => { update::AutoUpdateOutcome::Replaced { latest } => {
info!( info!(
current = env!("CARGO_PKG_VERSION"), current = env!("CARGO_PKG_VERSION"),
+343
View File
@@ -0,0 +1,343 @@
//! Exercise self-update with a real executable and a local release server.
#![cfg(windows)]
use std::fs;
use std::io::{Cursor, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::thread;
use std::time::{Duration, Instant};
use bsk::daemon::info::DaemonInfo;
use sha2::{Digest, Sha256};
const MARKER: &[u8] = b"windows-update-regression-fixture";
struct ReleaseServer {
url: String,
requests: Arc<AtomicUsize>,
stop: Arc<AtomicBool>,
worker: Option<thread::JoinHandle<()>>,
}
impl ReleaseServer {
fn new(binary: &[u8]) -> Self {
let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new()));
archive
.start_file(
"bsk.exe",
zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored),
)
.unwrap();
archive.write_all(binary).unwrap();
let archive = archive.finish().unwrap().into_inner();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let manifest = serde_json::to_vec(&serde_json::json!({
"version": "999.0.0",
"assets": {"windows-x64": {
"url": format!("{url}/bsk.zip"),
"sha256": Sha256::digest(&archive).iter().map(|byte| format!("{byte:02x}")).collect::<String>(),
}},
}))
.unwrap();
let stop = Arc::new(AtomicBool::new(false));
let worker_stop = Arc::clone(&stop);
let requests = Arc::new(AtomicUsize::new(0));
let worker_requests = Arc::clone(&requests);
let worker = thread::spawn(move || {
while !worker_stop.load(Ordering::SeqCst) {
let (mut stream, _) = match listener.accept() {
Ok(connection) => connection,
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
continue;
}
Err(err) => panic!("accept release request: {err}"),
};
// Windows accept() inherits the listener's nonblocking mode.
// Keep accept polling for shutdown, but read/write each request
// in blocking mode with the bounded timeouts below.
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut request = Vec::new();
let mut chunk = [0; 1024];
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
let len = stream.read(&mut chunk).unwrap();
if len == 0 {
break;
}
request.extend_from_slice(&chunk[..len]);
}
let body = if request.starts_with(b"GET /bsk.zip ") {
worker_requests.fetch_add(1, Ordering::SeqCst);
&archive
} else {
&manifest
};
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.unwrap();
stream.write_all(body).unwrap();
}
});
Self {
url: format!("{url}/version.json"),
requests,
stop,
worker: Some(worker),
}
}
}
impl Drop for ReleaseServer {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
let _ = self.worker.take().unwrap().join();
}
}
#[test]
fn release_server_waits_for_delayed_and_fragmented_request_headers() {
let server = ReleaseServer::new(b"test binary");
let address = server
.url
.strip_prefix("http://")
.unwrap()
.strip_suffix("/version.json")
.unwrap();
let mut stream = TcpStream::connect(address).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(5)))
.unwrap();
// Give accept() time to run before any bytes arrive, then split the
// headers across writes. Both reads must wait rather than return WouldBlock.
thread::sleep(Duration::from_millis(100));
stream.write_all(b"GET /version.json HTTP/1.1\r\n").unwrap();
thread::sleep(Duration::from_millis(100));
stream.write_all(b"Host: localhost\r\n\r\n").unwrap();
let mut response = String::new();
stream.read_to_string(&mut response).unwrap();
assert!(response.starts_with("HTTP/1.1 200 OK\r\n"), "{response}");
let (_, body) = response.split_once("\r\n\r\n").unwrap();
let manifest: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(manifest["version"], "999.0.0");
}
struct Fixture {
_tmp: tempfile::TempDir,
exe: PathBuf,
home: PathBuf,
binary: Vec<u8>,
server: ReleaseServer,
daemon: Option<Child>,
}
impl Fixture {
fn new() -> Self {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("中文 space %PATH% ! & (update)");
fs::create_dir(&dir).unwrap();
let exe = dir.join("bsk.exe");
let home = tmp.path().join("home");
fs::create_dir(&home).unwrap();
fs::copy(env!("CARGO_BIN_EXE_bsk"), &exe).unwrap();
// A PE overlay distinguishes the replacement without requiring a
// second build or changing the executable's behavior/version.
let mut binary = fs::read(&exe).unwrap();
binary.extend_from_slice(MARKER);
let server = ReleaseServer::new(&binary);
Self {
_tmp: tmp,
exe,
home,
binary,
server,
daemon: None,
}
}
fn command(&self) -> Command {
let mut cmd = Command::new(&self.exe);
cmd.env("BSK_HOME", &self.home)
.env("BSK_UPDATE_MANIFEST_URL", &self.server.url)
.env("BSK_AUTO_UPDATE", "off")
.env("RUST_LOG", "info")
.env("NO_PROXY", "127.0.0.1,localhost")
.env_remove("BSK_DAEMONIZED")
.env_remove("BSK_DAEMON_REPLACES_PID")
.stdin(Stdio::null())
.creation_flags(0x0800_0000);
cmd
}
fn wait_for(&self, description: &str, mut check: impl FnMut() -> bool) {
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if check() {
return;
}
thread::sleep(Duration::from_millis(50));
}
let diagnostics: Vec<_> = fs::read_dir(self.exe.parent().unwrap())
.unwrap()
.flatten()
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "log"))
.map(|entry| {
(
entry.path(),
String::from_utf8_lossy(&fs::read(entry.path()).unwrap_or_default())
.into_owned(),
)
})
.collect();
let home_logs: Vec<_> = fs::read_dir(&self.home)
.unwrap()
.flatten()
.filter(|entry| entry.file_name().to_string_lossy().contains("log"))
.map(|entry| {
(
entry.path(),
String::from_utf8_lossy(&fs::read(entry.path()).unwrap_or_default())
.into_owned(),
)
})
.collect();
panic!(
"timed out waiting for {description}; helper logs: {diagnostics:?}; daemon logs: {home_logs:?}"
);
}
fn info(&self) -> Option<DaemonInfo> {
serde_json::from_slice(&fs::read(self.home.join("daemon.json")).ok()?).ok()
}
fn updated(&self) -> bool {
// A locked executable may briefly reject reads during replacement.
fs::read(&self.exe).is_ok_and(|binary| binary == self.binary)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
// Stop only the daemon belonging to this isolated BSK_HOME. Also reap
// the foreground process on assertion failures to avoid leaking locks.
let _ = self.command().args(["daemon", "stop"]).output();
if let Some(child) = self.daemon.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
}
}
#[test]
fn manual_update_replaces_the_running_executable_after_cli_exit() {
let fixture = Fixture::new();
let out = fixture
.command()
.args(["--json", "update", "--yes"])
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(report["status"], "staged");
fixture.wait_for("CLI replacement and helper cleanup", || {
fixture.updated() && fs::read_dir(fixture.exe.parent().unwrap()).unwrap().count() == 1
});
assert!(
fixture.info().is_none(),
"an update must not start a previously absent daemon"
);
assert_eq!(fixture.server.requests.load(Ordering::SeqCst), 1);
}
#[test]
fn automatic_update_exits_old_daemon_and_restarts_on_the_same_port() {
let mut fixture = Fixture::new();
let port = unused_port();
let log = fs::File::create(fixture.home.join("foreground.log")).unwrap();
let child = fixture
.command()
.env("BSK_AUTO_UPDATE", "on")
// Exercise the environment inherited by a normally detached daemon.
.env("BSK_DAEMONIZED", "1")
.args([
"daemon",
"start",
"--port",
&port.to_string(),
"--session-idle",
"1234ms",
"--daemon-idle",
"30s",
])
.stdout(Stdio::from(log.try_clone().unwrap()))
.stderr(Stdio::from(log))
.spawn()
.unwrap();
let old_pid = child.id();
fixture.daemon = Some(child);
fixture.wait_for("replacement daemon", || {
fixture.updated()
&& fixture
.info()
.is_some_and(|info| info.pid != old_pid && info.ws_port == port)
});
assert!(
fixture
.daemon
.as_mut()
.unwrap()
.try_wait()
.unwrap()
.is_some(),
"old daemon must exit"
);
fixture.wait_for("helper cleanup", || {
fs::read_dir(fixture.exe.parent().unwrap()).unwrap().count() == 1
});
let status = fixture
.command()
.args(["--json", "status"])
.output()
.unwrap();
assert!(
status.status.success(),
"{}",
String::from_utf8_lossy(&status.stderr)
);
assert_eq!(
fixture.server.requests.load(Ordering::SeqCst),
1,
"the replacement must not stage another update immediately"
);
}
fn unused_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
@@ -0,0 +1,184 @@
# Visual observation:验收清单
新 agent 先读 [架构与 PR 划分](visual-observation.md),再按本清单验收当前 PR;无需聊天记录,也无需另写报告、逐项编号或填写模板。历史结果见 [PR 0 证据](visual-observation-evidence.md),不能代替本次执行。
## 怎么开始
- 查看分支、HEAD 和工作区改动,确认当前做哪个 PR。PR 0 基于 main `3c5f838`;后续模块对照其前置基线,最终回归对照 main。注意未跟踪文件也属于改动。
- 当前仅有设计、合成 fixture 和诊断脚本;未来模块尚未实现。只验收当前阶段及受影响的已有功能,不要求提前完成后续 PR。
- 测试放在模块附近,调用实际生产代码。未接线模块可用人工输入和 CDP stub;实现 PR 写明运行命令即可,不强制新建指定目录、命名或诊断 API。
- 浏览器验收前核实 CLI 版本,并重新 build、确认扩展 reload。PATH 中的 bsk 曾经比仓库版本旧,不能只凭版本号认定运行了目标代码。
- 不 commit/push。截图、原始页面内容和一次性实验放临时目录;只把长期需要的设计、fixture 和测试留在仓库。
## 现有验证入口
从仓库根目录运行,命令成功退出且相关断言通过才算成功:
```sh
pnpm vom:test
pnpm --filter @browser-skill/extension test src/tools/__tests__/frame-geometry.test.ts src/tools/__tests__/element-geometry.test.ts src/tools/__tests__/observation.test.ts src/tools/__tests__/snapshot-ref.test.ts src/tools/__tests__/interaction.test.ts src/tools/vom src/session-manager/__tests__/ref-store.test.ts
pnpm exec biome check apps/extension/test-fixtures/visual-observation
VISUAL_EVIDENCE="$(mktemp -d /tmp/bsk-visual-acceptance.XXXXXX)"
node apps/extension/test-fixtures/visual-observation/probe.mjs "$VISUAL_EVIDENCE/browser-contract"
node apps/extension/test-fixtures/visual-observation/measure-render.mjs "$VISUAL_EVIDENCE/performance"
```
probe 需要 Node 22+ 和 Chrome,配置见 [fixture 说明](../../apps/extension/test-fixtures/visual-observation/README.md)。它验证浏览器事实,不代表后续生产模块已经通过。measure-render 用于旧 renderer 对照,不要求修复历史复杂度。
实现 PR 还需运行所属模块的新测试、`pnpm --filter @browser-skill/extension compile`、`pnpm ext:test` 和 `pnpm ext:build`。仅修改文档时检查链接和内容即可,不必重跑浏览器。已有失败应说明,不在范围外顺手修复。
## 每个 PR 怎么算完成
### PR 0:设计与复现
只有文档、合成 fixture 和诊断变动;架构、边界及后续 PR 的完成条件明确。现有测试通过;probe 的 checks 非空且全部通过(本版 39 项);measure-render 产生五组 wide/deep 结果。
按下方浏览器步骤,在 main 重现“表格已绘制,但 observe 没有视觉入口”。合成页面和原始 iWiki 分别确认。原始页面未加载、无权限或缺入口时说明阻塞,不能算已复现。入库内容不含内部页面地址、标识或截图。
### PR 1:几何与身份
数值误差:纯函数输入允许 1e-6 CSS px;真实浏览器每条边允许 0.25 CSS px。坐标类型不能混传;动态 frame 归属需运行时检查。
| 怎么测 | 什么算成功 |
|---|---|
| top document bounds=(30,50,120,40),document scroll=(10,20),viewport=800×600 | top border region=(20,30,120,40);不受 backing store 尺寸影响 |
| 使用 frame.html:local Canvas=(17,23,120,40),same owner content origin=(50,610);nested origin=(87,707);OOPIF origin=(410,610) | top origins 分别=(67,633)、(104,730)、(427,633);不能重复给 nested 加父偏移;owner border 不能代替 content |
| child scrollY=10;same frame owner origin 不变 | Canvas top y=623,local y=13;snapshot document y=23;scroll 只扣一次 |
| same iframe 以自身左上角为原点 scale(1.25),原 border origin=(40,600),border=10 | owner content origin=(52.5,612.5),Canvas top=(73.75,641.25),尺寸150×50 |
| Canvas content 120×40,border 每边5;overflow owner CSS width60/height30/border5/padding4 | Canvas region 宽130高50,live 验证同 box;owner clientRect=(5,5,68,38),不能使用78×48 border范围作为clip |
| DPR=1/1.5/2,CSS Canvas120×40;UI zoom与CSS zoom分别处理 | DPR不直接除掉 CSS rect;raster 预测与实际图片分别记录;不以layout/css宽度比作为万能DPR |
| 分别构造缺失 owner、失效 frame、旋转、透视、mask、clip-path、活动pinch、变换的overflow祖先 | 前两项 geometry-unavailable,后六类 geometry-unsupported;无伪造精确 region ref、无按border猜偏移 |
| 同一操作并发请求同一frame 20次,再开启第二次操作 | 第一次每个metrics来源、owner quad、frame投影最多构建/请求一次;第二次重新获取;异常promise不泄漏到其他操作 |
### PR 2:采集与共享事实
| 怎么测 | 什么算成功 |
|---|---|
| target A/B 各含 backendNodeId=7,frame/document不同 | 两个lookup结果独立;AX join、overlay排除和父子查找不串target |
| 4个frame分属2个target,其中一个target有3份document;均成功采集 | 每target恰好一次DOMSnapshot;每frame恰好一次AX批次;失败重试不混入成功请求数;无按frame重复snapshot |
| 采集中模拟frame detach或loader/documentElement更换 | 丢弃受影响document的混合事实并标记原因;其他完整document可保留;不得把新AX接到旧DOM |
| 输入10,000深链、10,000兄弟、孤儿及A→B→A环 | 无栈溢出/无限循环;孤儿/环有明确不完整状态;不生成完整祖先证据;context推导每有效节点至多一次 |
| opacity0祖先;visibility:hidden祖先+visible子;aria-hidden/inert祖先 | 分别得到视觉透明、子可见、语义/交互状态独立;不折叠为同一个excluded |
| 一个frame采集失败,其他frame有语义节点;全部成功但无Canvas | 前者partial且保留其他语义;后者empty而非unavailable;错误原因可追踪到frame及阶段 |
| 注入CDP调用记录和hover探针;纯计算中途取消 | 所有静态DOM/AX捕获先于hover输入;取消后下一工作块不继续处理,未执行的输入/截图为0;取消不变成普通空结果 |
### PR 3:视觉发现
| 怎么测 | 什么算成功 |
|---|---|
| index.html?noFrames=1 的plain、named、aria-hidden、inert、pointer-none、visibility-override、hidden-overridden、fallback | 八个均为候选;无label不影响发现,有AX/fallback不抑制候选 |
| opacity-parent、display-none、visibility-hidden、zero、full-clip | 五个均无候选,原因分别能追踪到视觉/几何规则;不能把aria-hidden列为原因 |
| partial-clip、axis-clip、border-clip、border-canvas | crop尺寸分别60×30、60×40、64×34、130×50;边界来自client box/同种Canvas box |
| 同frame中删除语义parent,再保持DOM/几何不变 | 候选仍存在、地址不变;语义context可变为null;frame owner本身无法解析时不是该用例 |
| 连续两次输入同一只读Facts;对Facts启用深冻结 | 候选内容一致,无输入mutation,无CDP调用;去重前stack-a/b仍是两个候选 |
| 普通候选部分越出viewport;完全越界;overlay-owned Canvas;unsupported clip | 分别交集区域、无候选、排除、明确unsupported状态;不把未知状态伪装成empty |
### PR 4:选择与预算
预算按每个输出行的 `ceil(line.length/4)` 求和(UTF-16 length),包括头部和截断提示。H 为完整协议头成本,V 为固定行 `[visual] Canvas regions present; use screenshot` 的成本。
| 怎么测 | 什么算成功 |
|---|---|
| 同context:无名1000×800候选A,有名10×10候选B,K=1 | 选A;名称不能压倒区域面积 |
| 同document/parent且crop完全相同的A/B;另一个parent的C;crop右边相差0.01的D;相邻tile E | A/B仅输出一个代表;C/D/E独立保留;不输出layers=N作为业务解释 |
| 面积相同的候选随机打乱20次,地址/位置保持不变 | 排序相同;tie-break顺序固定为frameId、top y、left x、backendNodeId(同一观察内);去重代表取该顺序最小者;字符串按代码单元字典序、数值按数值升序 |
| 50,001个不同key,最后一个为最大Canvas,K=1 | 最后最大者仍被选中;dedup key峰值≤50,000;标记dedup-degraded;未因容量耗尽停扫描 |
| 100,000候选、K=1/32/512;另测K=0 | 选中数量≤K、heap峰值≤K、exact-key之外的模糊pair比较为0;K=0不产生ref候选;结果与独立全排序小规模oracle一致 |
| 带blocking layer的纯输入,候选context在L1、背景、未知各一个 | L1优先,背景不标为物理不可见,未知不伪装L1;同层再按面积及固定 tie-break 排序;不修改Facts |
| 预算H-1 | 新observe规划返回insufficient-budget,工具映射invalid_params并给minimum=H;无新文本/ref提交;snapshot旧路径不改变 |
| 有候选,预算H、H+V-1、H+V | 前两个输出合法协议头、无悬空ref、truncated=true;H+V能保留固定视觉提示;全部账本≤预算 |
| 没有显式maxTokens,候选足够多 | 视觉附加部分cost≤2048;最终视觉条目≤512;不是对整个旧语义输出强加2048 |
| 标签含200个Unicode code point、引号、换行、代理对emoji | 标签先截至160 code point再JSON escape,按最终行核算;无断开的代理对,无换行注入额外条目;写不下的行不产生ref |
| finite预算取H+V、64、128、256、2048(小于H时拒绝),混合长短条目 | 每次总账本≤budget;输出决定可重复;已知omitted才给精确数,候选/去重组/上游未知数量不混加 |
### PR 5:类型化 ref
| 怎么测 | 什么算成功 |
|---|---|
| 注册DOM和visual-region两个判别目标 | kind决定操作资格;类型测试不能构造visual+interact矛盾状态;不额外接受调用者capability数组 |
| 同名ref置于两个session/tab;输入@e1与e1 | canonical形式可解析;不同session/tab不串目标;未知目标为既有not_found类错误 |
| 对visual ref调用click/fill/hover/select/get_html/人工帮助DOM定位入口 | 全部按主设计拒绝;CDP输入、DOM HTML读取、DOM高亮等该工具副作用次数为0;不能偷偷降为anchor操作 |
| loader变化、target重attach、documentElement替换;另测history.replaceState不改身份 | 前三者拒绝旧目标;最后不单凭URL变化拒绝;实际anchor仍需连接 |
| document.open保留loader/document backend但替换html和Canvas | 旧ref失效;只比较loader的实现必须被此测试击败 |
| 第二次observe重新绑定e1;第一次ref字符串再次输入 | 明确验证“最新观察映射”而非声称能识别旧字符串;新映射仅来自成功提交;旧DOM工具回归通过 |
### PR 6:截图执行
首次 clipScale 为 `min(1, 2048/(max(w,h)*r), sqrt(4000000/(w*h*r*r)))`;r 来自实际 metrics 适配,最终仍检查真实 PNG 尺寸。
| 怎么测 | 什么算成功 |
|---|---|
| visual ref解析成功并截图;DOM ref另测 | visual路径scrollIntoView次数0;DOM路径保持BASE策略;visual实时box种类与观察一致 |
| 同anchor四边分别移动0.249、0.25、0.251 CSS px;分别改尺寸/裁剪策略 | 前两种仅数值误差可接受,0.251拒绝;document/裁剪策略变化不受容差豁免;不靠old∩live返回小片段 |
| 保持身份/几何,重绘Canvas颜色;另移除Canvas | 重绘允许取得当前图像;移除拒绝;不承诺像素快照或把颜色改变误报为stale |
| css4096×2048,rasterEstimate2;css4000×4000,rasterEstimate1 | 首次clipScale分别0.25、0.5(不放大,取edge/area/1最小值);实际成功尺寸每边≤2048、面积≤4,000,000 |
| mock第一次图片3000×3000,第二次2000×2000;再测两次均3000×3000 | 第一组重试一次并成功,第二组恰好两次后pixel-budget-exceeded;不能无限重试或返回超限图 |
| 空data、非法PNG头、0尺寸、负/非有限几何 | 明确失败,不能猜测width/height;此项不要求将PNG头检查宣称为完整解码校验 |
| capture前取消;overlay抑制后CDP失败;采集时出现dialog | 取消前不再capture;成功/失败/取消均恢复overlay;dialog机制保持既有契约;无额外输入 |
### PR 7:observe 接线
| 怎么测 | 什么算成功 |
|---|---|
| 固定SemanticScene,无视觉候选;普通预算,与BASE replay同一输入 | 语义文本、ref相对顺序、role/name一致;不因为新通道重跑semantic pipeline |
| 同SemanticScene加两个视觉候选,大预算 | 原DOM ref顺序不变,视觉ref在其后;每个输出ref均可在registry找到同目标,registry无未输出新ref |
| DOM文本多到耗尽除预留外的预算 | 固定视觉提示可见(预算允许时);truncated=true;不是DOM stopped后无条件跳过视觉提示 |
| 视觉capture unavailable但语义成功 | 语义结果保留,观察状态能区分empty/unavailable/truncated;debug reason准确且无原始DOM泄漏 |
| snapshot、recording调用、hover probe on/off与BASE对比 | snapshot不新增visual;recording不隐式启用发现;probe默认off,on时仍在静态采集后;相应既有测试通过 |
| blocking layer混合DOM/visual、重复候选、不同预算 | 输出层策略与选择模块的活跃层策略一致;头、摘要、条目、occlusion/omitted行均纳入账本;无悬空ref |
### PR 8:整体验收
复跑上述模块测试、下方性能对照和浏览器场景,确认原始 iWiki 的缺口已解决。检查没有站点特判、重复的几何/预算权威实现或遗留的替代路径;没有顺带扩展到 OCR、逻辑画布聚合或旧算法重写。不能只凭合成页面通过宣布原始问题解决。
## 性能怎么验
在相关模块实现时检查,不拖到最后统一补。旧路径和新增 Canvas 成本分开看:
- **旧路径**:同输入对照 main/前置基线,已有 render/context/sibling 工作量、语义构建次数和 DOM 操作请求不增加;保留 #165 的回归。不要求消除旧算法的历史二次项。
- **无 Canvas**:允许必要检测与共享几何成本,但不构建视觉专用索引、不发视觉专用 CDP 请求、不重复跑语义链路。
- **新增树处理**:deep/wide 节点数取 1k、10k、100k;每节点 context 至多推导一次,每条边每个索引 pass 至多访问一次,pass 数不随规模增加。无递归溢出、每候选完整祖先遍历或全祖先数组复制。
- **选择**:候选数取 1k、10k、100k,K 取 1、32、512;堆峰值不超过 K,去重 key 不超过 50,000,无模糊两两比较。比较计数符合具体算法推导的上界;小规模结果与独立全排序对照一致,容量耗尽后仍能选中最后的大图。
- **请求与取消**:每 target 一次 snapshot、每 frame 一次 AX 批次;单次操作内几何请求共享,不跨操作复用 live cache。几何最大并发 4,新增计算每块最多 256 项并实际让出事件循环,取消后不再启动下一块。
- **接线后耗时**:同机器、浏览器和固定内容,分别测无 Canvas 页与合成表格页。各预热 3 次,再基线/新版本交替各 20 次,比较中位数和 p95;旧阶段与新增成本分开。旧阶段中位数增加超过 max(基线 10%, 2ms) 时复测,两轮均超过且可归因于改动则未通过;噪声无法排除则说明尚未确认。操作数仍须满足前述条件,不能用耗时阈值代替它们。
计数可以由测试插桩取得,不必为验收增加生产接口。性能结果只需说明旧路径是否回退、新增路径的规模与成本,不需要另填表格。
## 浏览器怎么验
### 合成表格(PR 0 复现;PR 7/8 验证修复)
1. 核实 `bsk --version`、`bsk browsers`、`bsk status`;运行 `pnpm ext:build`,确认用户已 reload `apps/extension/dist/chrome-mv3`。
2. 单独启动服务器:`python3 -m http.server 4177 --bind 127.0.0.1 --directory apps/extension/test-fixtures/visual-observation`。
3. `bsk session start --width 1440 --height 1100`,使用返回的 session id 打开下列页面。尖括号替换为实际值,每条命令传同一 session。
```sh
bsk navigate http://127.0.0.1:4177/table.html --session <session> --json
bsk observe --session <session> --json
bsk screenshot --session <session> --out <临时目录>/table.png --json
```
4. 把窗口放前台,实际看到三列六行后再采集 observe。load 完成、AX 工具栏出现或背景占位截图都不能证明表格已经绘制。
5. main 应有 Page action、Insert record、Filter 三个 DOM 控件,truncated=false,但无表格视觉入口。这是 PR 0 的缺口复现。
6. 新实现应保留三个控件,并增加 visual ref。用本次返回的 ref 执行 `bsk screenshot --ref <ref> --session <session> --out <临时目录>/crop.png`,实际查看图片:包含 Canvas column A/B/C 和六行,裁剪对应 780×360 CSS px 的表格区域,不能错到工具栏或 iframe 边框。PNG 满足每边 2048、总像素 4,000,000 的上限。无需生成单元格语义。
### 几何与动态场景
PR 1 验证实际生产几何模块;PR 6 验证截图执行;PR 8 复验完整链路。未接 observe 时可注入人工 facts/ref,但必须调用实际模块,不能以独立 probe 代替实现测试。
- 必测缩放集合:DPR 1/1.5/2 × top/frame;真实 UI zoom 100%/90% × top/frame;CSS zoom 1.25 的 top Canvas;frame scale 1.25 的 same/nested/OOPIF。按 PR 1 的坐标预期核对。OOPIF 需确认实际 iframe target,跨 origin 本身不够。
- UI zoom 要通过浏览器菜单或原生快捷键确认,不能以 CSS zoom 或 pinch emulation 替代。旋转、mask、clip-path、活动 pinch、变换 overflow 按不支持策略降级,不输出猜测的精确 ref。
- 使用 `index.html?noFrames=1` 的 `#plain`,每个实验从 reload 后的新 ref 开始:平移 1px、宽度改为 121px → 旧 ref 截图要求重新观察;移除节点或 document.open → 旧目标失效;仅重绘颜色 → 截图成功且是新颜色。不要在重新 observe 后拿重用的 eN 字符串声称验证了旧 ref。
- visual ref 的 click/fill/hover/select/get_html/DOM 高亮拒绝测试只在合成页或自动测试运行,确认拒绝前没有派发输入、读 HTML 或高亮副作用。
### 原始 iWiki(PR 0 与 PR 8)
入口与就绪说明在本机临时文件 `/private/tmp/bsk-visual-design/private-reproduction.md`。文件丢失或登录失效时向用户取得入口/权限,说明这一项未完成;继续独立的合成验证。
保持只读。前台实际看到表格后采集 observe 和截图:PR 0 确认缺口;PR 8 确认外围 DOM 控件仍在、新 visual ref 能截到实际表格。不要编辑单元格,不要求原页面永久维持历史的 63 个 ref。私有内容不入库。
结束后停止自己的 session、服务器及临时 Chrome,恢复修改过的 zoom/emulation,不关闭其他任务资源。
## 做完怎么回复
直接告诉用户:验了哪个 PR、跑了什么、是否符合上述成功条件;若有失败或未做的检查,说明具体原因。必要时附关键测试输出或截图路径。无需单独报告文件、hash 清单、状态统计或全量日志归档;没运行、被跳过或受阻的项目不要写成通过。
+235
View File
@@ -0,0 +1,235 @@
# Visual observation:PR 0 验证结果与架构决策
日期:2026-09-08。生产基线:main `3c5f838`。分支:`codex/visual-observation-design`。
本次只新增设计、合成 fixture 和诊断工具。没有生产算法变更,没有 commit/push。
总体设计及后续 PR 依赖见 [架构与实施计划](visual-observation.md)。
本文是历史证据;新会话请按 [验收清单](visual-observation-acceptance.md) 执行相关检查并说明结果,不继承本文的通过结论。
## 1. 环境与证据等级
| 环境 | 实际版本/说明 |
|---|---|
| PATH 中的 bsk | 0.1.7,未用于有效页面基线 |
| 仓库已有 target/debug/bsk | 0.2.0;`update --check` 报告发布最新版也是 0.2.0,没有安装更新 |
| daemon / connected extension | 0.2.0,protocol 1.1 |
| 页面基线 Chrome | 151.0.0.0,由 bsk browsers 报告 |
| 生产扩展构建 | `pnpm ext:build`,用户 reload 当前 main 基线构建后采集 |
| 独立协议实验 Chrome | 152.0.7977.76,临时无登录 profile;与用户浏览器不是同一个运行实例 |
| 基线测试 | VOM 47 tests / 2 files;相关扩展 217 tests / 14 files;全部通过 |
页面结果是实际 bsk + reload 扩展的结果。协议实验是独立 Chrome 的真实 CDP 响应。
临时 replay 是将合成响应送入 main adapter 的诊断,不代表另一条真实浏览器完整运行。
不同证据不混用,版本号本身不证明构建 commit。
所有内部页面原文、地址、iframe 标识、截图和用户信息仅保存在本机临时目录,不进入仓库。
本文仅保存匿名化事实,仓库 fixture 完全独立构造。
## 2. 原始问题已确认:表格加载完成后仍有观察缺口
有效就绪条件:前台可见页面已经绘制出网格、列标题和六行表格。不能使用以下替代条件:
- `document.readyState === complete`。
- AX 已有嵌入应用的工具栏。
- 页面出现 iframe。
- 背景截图仍为站点加载 logo。
本次初始后台窗口恰好出现“AX 已有工具栏,图像仍为加载占位”的情况。这些样本已排除。
在前台绘制完成后重新取得匹配的截图和 observe,结果为:
| 事实 | 结果 |
|---|---|
| 实际截图 | 可见列标题、网格与六行表格 |
| main observe | 63 个 ref,含外围和嵌入应用工具栏 |
| truncated | false |
| 表格列内容 | 没有相应文本表示 |
| visual ref | 没有 |
| 直接打开已观察到的嵌入应用 | 发现一个无 aria-label 的 Canvas,CSS 尺寸 1450×835、backing store 2900×1670,DPR 2 |
直接打开嵌入应用用于验证其渲染介质;其宽度变化是独立 tab 布局,不能拿这组坐标当父页面中的 crop。
父页面的截图/VOM 对照与直接嵌入页的 DOM 证据分别记录。
结论:主要能力缺口是视觉内容没有观察入口;不属于这次有效样本的文本预算耗尽。
新架构不能承诺从 Canvas 自动恢复单元格语义;正确结果是保留工具栏,并提供可截图的区域入口。
## 3. 合成最小复现
`table.html` → 跨站点 `grid.html`:一个 iframe、DOM 工具栏、一个无名 Canvas。
Canvas 绘制三列、六行,所有文字与原页面无关。
main 实际输出的关键部分:
```text
@view 1440x923
@e1 button "Page action"
Iframe "Table application"
navigation
@e2 button "Insert record"
@e3 button "Filter"
```
`ref_count=3`,`truncated=false`。截图已人工确认包含 `Canvas column A/B/C` 和六行。
这些像素文字既没有文本表示,也没有 visual ref。该最小复现已覆盖原场景的关键结构,
无需站点权限或生产选择器。
## 4. CSS 与语义/交互分离
真实浏览器实验确认:
- aria-hidden 祖先、inert 祖先、pointer-events:none 不会使 Canvas 自动视觉消失。
- `visibility:hidden` 祖先下的 `visibility:visible` 子 Canvas 可以恢复可见。
- `hidden` 元素被 `display:block` 覆盖时仍可绘制。
- opacity:0 祖先会使子树透明;不能仅检查 Canvas 自己的 opacity。
- display:none 无 layout bounds;visibility:hidden 可以仍有 bounds。
- AX fallback 文本不包含绘制的 `PIXEL TABLE 42`,不能用 fallback 存在作为发现去重条件。
决定:事实层分别保存 computed visibility、透明继承、语义暴露和交互属性。
视觉发现不读取 semantic keep/drop 结果;祖先索引只能传播符合属性语义的状态。
`checkVisibility()` 的实验作用仅限 CSS suppression,不用它证明 overflow、viewport 或遮挡结果。
常规 overflow 由独立几何规则负责;任意遮挡不进入首版精确能力范围。
## 5. 单位、frame 投影与截图
### 5.1 DPR 与截图像素是不同契约
独立 Chrome 上对相同 120×40 CSS clip 的结果:
| Emulated DPR | DOMSnapshot Canvas w×h | Screenshot w×h | layoutViewport/cssLayoutViewport 宽度比 |
|---|---|---|---|
| 1 | 120×40 | 120×40 | 1 |
| 1.5 | 120×40 | 180×60 | 1 |
| 2 | 120×40 | 240×80 | 1 |
因此该比值不能用作通用截图 DPR。原始 DOMSnapshot 单位与截图 raster scale 必须分别适配,
输出 PNG 尺寸是最终预算事实。不能把本组 DPR emulation 结果泛化为所有浏览器缩放组合。
### 5.2 三种缩放分别记账
- CSS `zoom:1.25`:120px 宽 Canvas 的布局 bounds 为 150px,运行时几何同步变化。
- Emulation.setPageScaleFactor(1.5):记录为 page-scale/pinch 类证据,不充当 UI zoom。
- 用户 Chrome 原生 UI 缩放到 90%:界面明确显示 90%,DPR 从 2 变为约 1.8,innerWidth 从 1440 变为 1600,visualViewport.scale 仍为 1;Canvas CSS 宽约 120.0000076,截图仍是 2880px 宽。main VOM 视口为 1600×1025。已恢复 100%。
现有 bsk 的 Meta+- CDP 输入未改变浏览器缩放,所以该无效果尝试不算缩放验证;
有效样本来自原生浏览器 UI 操作。真实 UI zoom 仅验证了上述点,不能宣称完整 zoom 矩阵已通过。
决定:使用 CSS 视口空间作为发现/截图区域公共契约;精确单位 adapter 的实现还必须在 PR 1
跑计划内组合回归,不用一个比例同时解决所有协议字段。
### 5.3 Frame 空间与 main 已知偏移
fixture local Canvas 原点为 (17,23)。
| 场景 | 实测 owner content 原点(target root) | 正确 top Canvas 原点 | main parser replay |
|---|---|---|---|
| 同 target frame,10px border | (50,610) | (67,633) | (57,623) |
| 上述 frame 内 nested,另有 7px border | (87,707) | (104,730) | (87,713) |
| OOPIF,10px border | (410,610) | (427,633) | 本表不混入非完整 replay 的 OOPIF 结果 |
根 target 的 DOMSnapshot 含三份 document(top/same/nested);OOPIF 在单独 target 中只返回自己的 document。
probe 通过实际 `TargetInfo.type=iframe` 验证 OOPIF,而非仅根据 origin 推断进程隔离。
子 frame scrollY=10 后,runtime local y 从 23 变为 13,snapshot document y 仍为 23。
owner scale(1.25) 后 content quad 宽度变为 375,child innerWidth 仍为 300。
决定:frame-local 原始矩形必须先减该 document 的滚动,再经过一次权威投影;同 target 的
owner quad 已是 target-root 坐标,不能重复逐层加偏移。禁止 border-box 替代 content origin。
Box 种类也必须进入契约:带 5px border 的 120px Canvas,其 snapshot/border quad 宽 130,
content quad 宽 120。视觉入口采用 border-box,实时验证同种 box;iframe 投影则采用 content quad。
带 border/padding 的 overflow owner,snapshot.clientRects 对应 `[clientLeft,clientTop,clientWidth,clientHeight]`,
本 fixture 为 `[5,5,68,38]`。裁剪边界应从 client box 派生,不能直接拿 ancestor border bounds。
单轴 `overflow-x:clip;overflow-y:visible` 的 computed style 保持分轴,必须分轴传播。
CSS zoom 1.25 后,上述 owner 的 bounds 变为 `[745,455.625,97,59.5]`,clientRects 仍为
`[5,5,68,38]`,offsetRects 为 `[596,365,78,48]`。直接按 border-width 比例推导 client box
会混入不同空间与整数取整。首版将有 CSS zoom/DOM transform 的 overflow 祖先组合明确降级,
不承诺精确 crop;普通未变换 overflow 及 iframe content-quad 投影保留支持。
## 6. Document 与 ref 时效
真实 CDP 实验:
| 操作 | frameId | loaderId | document backend | anchor |
|---|---|---|---|---|
| history.replaceState | 不变 | 不变 | 不变 | 仍属于当前树 |
| 跨 document 导航 | 不变 | 变化 | 变化 | 旧目标不可继续视为当前内容 |
| document.open/write/close | 不变 | 不变 | 本次实验不变 | 旧 Canvas 失去连接,documentElement 被替换 |
决定:身份取 target attachment epoch + frameId + loaderId + documentElement backend identity;
工具执行时仍检查 anchor.isConnected 及其所属 documentElement。不能仅凭 loaderId 判断有效。
DOMSnapshot 可提供根元素 backend identity,frame graph 需要保留 loaderId;当前 main graph
没有该字段,这是后续数据契约的明确修改点。
12 次静态几何采样完全相同;0.5px 移动可被检测;真实 90% 缩放引入约 1e-5 CSS px 的
数值误差。首版采用最大边误差 0.25 CSS px 作为保守工程阈值,超出要求重新 observe。
这不是大规模统计得到的通用稳定性保证;改变阈值需更新其测试及行为文档。
既有 eN 被下一次观察重用后,单靠旧字符串不能检测它来自上一代。内部 generation 不消除
该协议限制。本次不改变协议,只验证当前存储目标的身份/几何。Canvas 可以在身份与几何
不变时重绘;截图始终表达执行时图像,不承诺观察时像素快照。
## 7. 既有路径回归基线:用于防止负优化
诊断复制当前 renderer 到临时目录,在三个明确循环点加计数;生产文件未改变。
复现命令见 [fixture README](../../apps/extension/test-fixtures/visual-observation/README.md)。
| 输入 | Render stack visits | DOM context candidates | Same-container sibling visits |
|---|---:|---:|---:|
| 100 headings + 100 View buttons | 201 | 10,000 | 15,050 |
| 200 headings + 200 View buttons | 401 | 40,000 | 60,100 |
| 400 headings + 400 View buttons | 801 | 160,000 | 240,200 |
| 2,000 transparent wrappers + control | 2,001 | 0 | 0 |
| 4,000 transparent wrappers + control | 4,001 | 0 | 0 |
显式栈访问线性并通过深树输入;本组宽树上下文计数呈二次增长。这里记录的是现有路径的
特征,供后续同输入比较是否发生回退,不是本次要求修复的问题,也不是 Canvas 新算法的模板。
计数不是完整 CPU profile,更不能替代 Canvas 发现、裁剪、选择和截图各自的性能测试。
处理边界:
1. 既有语义、DOM 操作和截图路径对照 main 不发生性能回退;不把历史算法优化列为本次前置依赖。
2. Canvas 新路径按自己的规模和访问模式论证算法;发现、裁剪、去重、选择和截图分别验收。
3. 精确 crop key 去重的选择来自首版的覆盖等价契约和新增成本分析,与 #165 采用什么算法无关。
4. PR 7/8 分别报告既有路径回归、无 Canvas 页面的额外成本、有 Canvas 页面的新增成本。
新功能有合理的额外成本不等于旧算法负优化;重复执行旧工作则属于需要消除的回退。
## 8. 锁定首版策略与退化
| 决策 | 首版契约 | 理由/验收 |
|---|---|---|
| 去重 | 同 document + 同直接 parent + 四条 crop 边完全相等 | 无模糊聚类;误差导致重复比误合并更可控 |
| 索引容量 | 50,000 keys;耗尽后其余候选继续未去重选择 | 内存有界,不因早期小图耗尽而停止后续发现 |
| K | min(预算推导容量, 512) | 所有行仍逐条核算可变成本 |
| 默认视觉预算 | 未给总 maxTokens 时 2048 estimated tokens | 有显式 maxTokens 时全部纳入同一预算 |
| 摘要预留 | 固定提示行按实际估算器成本预留 | 不用不可验证的固定 token 常量表示某段文本 |
| 几何时效 | 最大边差 > 0.25 CSS px 即重新观察 | 身份和裁剪策略变化独立拒绝 |
| 图像 | max edge 2048、max pixels 4,000,000、最多两次 capture | 成功必须以实际尺寸满足后置条件 |
| 外部几何并发 | 4 | 单操作缓存;操作结束释放 |
| 新增纯计算取消 | 每 256 项形成工作块并让出事件循环 | 不仅是轮询无法及时更新的 signal |
这些数值是可审查的首版资源政策,不是声称已证明用户体验最优的测量结果。
参数可以在对应模块 PR 中经新的证据调整,但必须一并更新契约及测试。
进一步收敛:首版精确区域支持轴向矩形、普通 overflow 链、正向轴对齐平移/缩放与 frame。
旋转、透视、clip-path、mask、活动 pinch-zoom 进入明确的 geometry-unsupported 降级,
不给看似精确的 region ref,提示现有视口截图途径。不会把包围矩形偷换成精确区域。
## 9. 已执行的检查与下一阶段门槛
已执行:
- main VOM tests:47 passed。
- main 相关 extension tests:217 passed,含 frame/element geometry、observation、interaction、ref-store、VOM。
- `pnpm ext:build` 成功,用户 reload 后采集有效页面基线。
- 独立 Chrome browser contract probe:39 个断言通过;可复现命令见 fixture README。
- main 合成 capture replay、显式栈/上下文操作计数。
- 实际 Chrome UI 90% zoom 样本,恢复 100%。
- 原始表格与合成嵌入式表格均人工查看截图,并与未截断 observe 对照。
PR 0 交付的是已明确边界的设计和基线,不是未来功能已经通过:新的 discovery、typed ref、
freshness resolver、pixel planner 和 renderer 接线尚未实现。后续每个 PR 必须验证自己的
生产实现,不能把本次浏览器事实验证替代实现验收。
人工证据不设置默认 CI 对内网站点的访问依赖。合成 probe 可按需执行;页面原文不进仓库。
后续 PR 的职责和验收保持在主设计中,本文负责记录证据、决策及没有作出的保证。
+675
View File
@@ -0,0 +1,675 @@
# BrowserSkill 视觉观察:架构设计、边界约束与 PR 实施计划
> 日期:2026-09-08
> 状态:PR 0 设计验证稿;基线、浏览器实验和参数决策见 [验证结果](visual-observation-evidence.md)。生产实现尚未开始。执行步骤和成功条件见 [验收清单](visual-observation-acceptance.md),无需另写报告。
> 实施基线:从实施时确认的 main 新建分支;本次分析使用本地 main `3c5f838`。
> 参考材料:当前 `fix/vom-canvas-observation` 分支、PR #165、此前的 visual-surface 架构蓝图。旧实现和旧测试是证据来源,不是不可改变的规格。
> 本次范围:设计规格及可复现的合成 fixture/诊断工具;不修改生产代码,不继承旧分支提交。
## 1. 设计结论
本次工程的目标是:
**为 VOM 建立独立的视觉观察通道,共享可靠的采集与几何基础,通过明确的区域身份提供截图能力,并使处理成本随输入规模可预测增长。**
工作方式是先确定整体架构、行为契约、模块依赖、复杂度和验收矩阵,再分 PR 实现。分步实现不等于分步思考架构。任何实现阶段需要改变已定契约时,必须先更新设计及相关验收项,不得用局部特殊分支掩盖契约冲突。
本次不是一次目录整理,也不是通用视觉理解平台重写。设计完整覆盖当前能力的生命周期,但不为尚无需求和证据的未来能力预建实现。
### 1.1 必须成立的五个结果
1. observe 能表达“这里存在需要看图理解的 Canvas 内容”,不依赖该元素是否有可用 AX 语义。
2. 视觉入口的 document、frame、target、节点及坐标归属明确。
3. 视觉 ref 与 DOM 操作 ref 具有明确且不可矛盾的工具契约。
4. 截图解析当前目标并验证时效,返回浏览器最终合成的当前图像及真实尺寸。
5. 采集、索引、发现、选择、输出和截图均有复杂度或资源上限,并从首个实现 PR 开始验证。
### 1.2 不能用来证明成功的指标
- 代码文件拆得更多。
- 旧分支全部测试通过,但没有重新审核测试中的行为假设。
- 能识别某个特定 嵌入式 Canvas 表格 页面,却依赖站点 class、URL 或布局常量。
- 单个简单页面运行很快,但没有最坏输入分析。
- 文本出现 surface 标记,但其截图对应关系、时效和工具限制没有验证。
## 2. 问题本质与现状
### 2.1 main 的基础能力
当前 main 的主要路径为:
```text
DOMSnapshot + 各 frame 的 AX
→ FrameDocument
→ semantic graph:构建、解析、结构整理
→ VomScene
→ VOM 文本及 DOM ref
```
已经存在 frame document、语义图、几何模块、ref store、截图路径和显式栈渲染器。应复用这些职责,不在旁边建立第二套长期运行的采集、frame traversal 或语义树。
缺口是:元素的 DOM/AX 描述不等于 Canvas 内部的文字、图形和布局。补 role、名称或普通可点击 ref,并不能建立完整的视觉观察能力。
### 2.2 必须分别解决的问题
| 问题 | 要回答的具体问题 | 不属于该问题的内容 |
|---|---|---|
| 视觉发现 | 哪些 Canvas 构成可观察候选? | 它是否可点击、输出多少条 |
| 空间定位 | 候选在顶层视口的区域是什么? | 它是否重要、属于哪个业务画布 |
| 输出选择 | 有限预算下展示哪些入口? | 改写几何事实、推断业务实体 |
| ref 身份 | 入口绑定什么目标、允许哪些工具使用? | 像素缩放、内容识别 |
| 时效验证 | 当前目标是否仍符合此次观察契约? | 保证 Canvas 像素未发生重绘 |
| 图像获取 | 如何获取当前合成区域并限制成本? | 重建 Canvas 内部对象 |
### 2.3 当前分支值得保留与需要重审的部分
值得保留:语义/视觉双通道、视觉附录、单节点截图锚点、frame 几何共享、像素预算、相关失败场景和 fixture。
需要重审:
- 将 `aria-hidden`、`inert` 与视觉隐藏合并排除。语义暴露、交互资格、视觉存在是不同维度。
- 将旧区域与实时区域的交集当作时效验证。区域重合不能证明目标仍对应原观察。
- 将几何重合和共同父节点解释成逻辑画布身份。
- 将名称有无作为重要性的绝对优先条件,可能让大量有标签的小图排在无标签主画布之前。
- 视觉附录依赖 DOM 渲染后的剩余预算,可能使视觉通道完全消失。
- 在每个候选上重复祖先遍历,或假设引入空间索引便自动消除了最坏二次复杂度。
PR #165 优化了既有渲染及上下文查询路径。本次将它作为性能回归保护基线,不作为 Canvas 算法模板,也不据此要求重做原有算法。Canvas 的发现、裁剪、选择和截图具有不同的数据规模与访问模式,应分别设计和验证适合自己的算法。
PR 0 已在用户指定页面确认:表格实际绘制后,main 输出外围工具栏而没有表格列内容及视觉入口,且未截断。直接打开嵌入应用确认存在无标签 Canvas。内部页面资料仅留临时目录;入库使用匿名化结论和独立合成 fixture。
## 3. 范围收敛
### 3.1 本次纳入
- Canvas 元素锚定的视觉观察入口。
- 顶层、同 target 子 frame、OOPIF 及嵌套 frame 的身份和坐标归属。
- 已明确支持的视口及 overflow 裁剪。
- 无标签、存在 AX、不可交互但有视觉内容等候选情况。
- 有界输出选择及保守的重复截图入口去重。
- 视觉 ref 的工具契约和 document/几何时效检查。
- 截图区域解析、像素预算、真实输出尺寸验证和有界重试。
- 深层 DOM、密集小图、重叠 Canvas 和多 frame 的性能约束。
### 3.2 本次不纳入
- OCR、图像语义识别、Canvas 内部对象树。
- 画布内部点选、拖拽或内部对象 ref。
- 地图 tile 拼接、跨节点逻辑画布聚合、多 anchor 区域。
- 面向所有 SVG、视频、CSS 背景等介质的通用发现插件系统。
- 任意页面遮挡、透明度、clip-path 和 mask 的精确像素级重建。
- 改写所有现有语义算法、表单、hover probe 或 recording 协议。
- 新的 ref 字符串协议、跨 observe 持久对象 ID、分页接口。
边界外的能力可以提出独立设计,不能通过放宽当前去重阈值或给单节点 target 塞可选字段实现。
## 4. 总体架构
```mermaid
flowchart TD
Browser[浏览器 / CDP] --> Capture[CaptureCoordinator]
Capture --> Batch[RawCaptureBatch]
Batch --> Geometry[GeometryContext]
Batch --> Normalize[Normalize + DocumentIndex]
Geometry --> Normalize
Normalize --> Facts[ObservationFacts]
Facts --> Semantic[现有 SemanticPipeline]
Facts --> Discover[VisualDiscovery]
Discover --> Candidates[VisualCandidates]
Semantic --> SemanticScene[SemanticScene]
Candidates --> Select[VisualSelection]
SemanticScene --> Context[输出上下文 / 活跃层策略]
Context --> Select
SemanticScene --> Renderer[ObservationRenderer + BudgetPlan]
Select --> Renderer
Renderer --> Output[文本 + Typed Ref Descriptors]
Output --> Registry[RefRegistry]
Registry --> Resolve[当前目标及区域解析]
Browser --> Resolve
Resolve --> Pixel[PixelPlanner]
Pixel --> Shot[CaptureExecutor]
Browser --> Shot
```
### 4.1 架构原则
1. 共享事实,分开解释。视觉存在不复用语义节点的保留/排除结论。
2. 语义关系是视觉区域的可选上下文,不是视觉身份的根。
3. 几何只有一套解释和投影实现,观察与工具执行使用不同生命周期的上下文。
4. renderer 只处理数据;浏览器调用止于采集或工具执行边界。
5. 预算选择只影响输出,不回写采集事实。
6. ref 的 kind 决定工具适用性,不能由调用者传入另一份可矛盾能力数组。
7. 模块失败必须可归因;不能将“没有候选”与“采集失败”混为一谈。
8. 所有资源限制都具有可解释的退化行为,不只是一句“超过上限停止”。
## 5. 模块划分与实现位置
以下为目标职责和建议位置,不要求第一个 PR 搬动全部文件。迁移文件与修改算法尽量分开;模块完成切换后删除被替代路径。
```text
apps/extension/src/tools/
observation.ts # 工具入口与结果接线,逐步减去内部实现
observation/
capture-coordinator.ts # CDP 编排及批次生命周期
capture-types.ts # 原始批次、归属、阶段状态
normalize.ts # 原始节点 → 归一化事实
document-index.ts # 按 document 建立共享索引
facts.ts # 下游可读事实契约
geometry/
coordinate-types.ts
frame-context.ts # frame/target 投影权威入口
primitives.ts # 复用/迁入现有数学实现
vom/
capture.ts # 保留或拆出的 DOMSnapshot adapter
frame-document.ts # 复用现有 frame 归属逻辑
semantic-graph/ # 保留语义职责
visual/
types.ts
discovery.ts
selection.ts
coverage-dedup.ts
refs/
target.ts
resolve.ts
screenshot/
resolve-region.ts
pixel-plan.ts
capture.ts
apps/extension/src/session-manager/
ref-store.ts # session 生命周期与注册存储
packages/vom/src/
types.ts # 纯场景及渲染契约
render.ts # 保留显式栈语义遍历
render-budget.ts # 唯一输出预算核算
visual-appendix.ts # 纯数据视觉条目输出
```
不同时保留旧 `geometry.ts` 与新 primitives 的两套数学实现。上述目录可按现有工程规范调整,但职责与依赖不得随意调整。
### 5.1 CaptureCoordinator
输入:tab/target、已验证的 URL、取消信号、采集选项。
输出:带来源、时间范围、document 身份和阶段状态的 RawCaptureBatch。
- 按 target 分组采集;DOMSnapshot 每 target 一次成功采集,重试单独计数且有界。
- AX 按协议支持的 frame/target 组织,数量有预算。
- 原始采集完成后才能运行会改变页面状态的 hover probe,保留已有行为顺序。
- 明确记录无法采集的 frame,不能把失败 frame 的数据借用其他 target 补齐。
- 不判断 Canvas 重要性,不投放 ref。
批次是来源和生命周期边界,不是浏览器原子事务。`Readonly` 或冻结对象不能证明各 CDP 响应来自同一瞬间。遇到可检测的导航/document 更换时应中止或有界重采集,不能拼接不同 document 的数据。
### 5.2 GeometryContext
输入:该次操作的 frame graph、原始 metrics、按需取得的 owner quad。
输出:frame-local/target-local 到 top-viewport 的投影、裁剪及当前节点区域解析。
- 坐标类型同时携带空间及所属 frame/target;仅标记 `frame-local` 不足以防止错 frame 使用。
- 原始 DOMSnapshot 单位、CSS 视口单位、截图像素单位分别适配。
- Canvas 视觉入口采用元素 border-box 的可截图区域;实时解析明确请求同一种 box,不能沿用默认 content-quad 路径再将边框差异误判为移动。iframe 子视口投影仍采用 owner content quad,两者职责不同。
- 普通 overflow 裁剪使用 ancestor client/scrollport box,不使用祖先 border bounds。adapter 保留 includeDOMRects 的 client offsets/size 及其单位;存在缩放时先转换到相同坐标空间,禁止直接相加不同单位。证据不足时明确降级,不猜测 border/padding 常量。
- 一个操作内复用 metrics、owner quad 和 projection promise;不跨 observe 或 tool execution 复用 live geometry。
- 合成变换可以预计算;裁剪边界复杂度须真实计入,不能靠 promise cache 就宣称全部计算线性。
- 不允许通过 iframe border box 加减常量替代有证据支持的 content geometry。
### 5.3 Normalize / ObservationFacts / DocumentIndex
输入:原始批次及几何上下文。
输出:按 document 隔离的只读事实和有界共享索引。
- Raw node 不能含语义不明的 top rect;normalized node 的派生字段由 normalization 创建。
- 节点 key 包含 document/target 所属信息,不能仅用 backendNodeId 跨 target 建表。
- 建立 node lookup、parent/children 等基础索引;派生索引必须有明确消费者和复杂度。
- 用显式栈和 visiting/visited 状态处理任意深度及异常环。
- orphan 保留“祖先信息不完整”状态;可以在已知范围内生成保守候选,但不能伪装成完整裁剪证据。
- 视觉样式、AX 暴露、交互资格分开保存,禁止万能 `excluded` 字段承载全部规则。
- 不给每个节点复制完整祖先数组或完整裁剪链;必要时共享结构或按需解析,计入内存预算。
### 5.4 SemanticPipeline
消费归一化事实,输出现有语义场景。通过窄 adapter 接入新事实契约;迁移完成后删除不再需要的 adapter。
- 不因为视觉发现需求改变 semantic keep/drop、名称恢复或可交互推断规则。
- 不把视觉候选塞回语义树遍历。
- PR #165 的显式栈、上下文索引及行为回归保留。
- 偶然发现的既有语义性能问题只作范围外记录,不构成本次工作项或前置依赖。本次只要求不使原有路径发生性能回退。
### 5.5 VisualDiscovery
输入:ObservationFacts。
输出:候选及发现状态,不含最终 ref,不依赖 token 预算。
候选表达一个 Canvas 节点锚定的截图区域。必须具有可解析地址、支持范围内的几何证据和非空候选区域。
- 无 label 不排除;有 AX 内容也不能证明像素内容已被完整描述。
- `aria-hidden`、`inert`、`pointer-events:none` 不单独构成视觉排除原因。
- 零尺寸、无渲染区域、已证实的完全透明/隐藏、支持范围内的完全裁剪可排除。
- `visibility` 等继承与覆盖行为必须按实际 CSS 语义处理,不能把祖先 hidden 一律不可逆传播。
- `hidden` 等属性不应替代最终渲染事实;存在特殊状态时按实验确认的规则处理。
- agent 自身 overlay 排除应按所属 document/target 传播。
- 常规轴向 overflow 累积在一致的坐标空间计算;变换后不能把 top-space 包围矩形当作精确 local-axis clip。
- 不重建任意遮挡。几何区域是截图候选范围,不保证范围内每个像素均来自该 Canvas。
### 5.6 VisualSelection / CoverageDedup
输入:候选、输出上下文及预算规划所需参数。
输出:选中候选、明确的遗漏/退化状态。
- 默认以区域覆盖量等可解释几何依据排序;label 是命名事实,不作为压倒面积的优先条件。
- 活跃层优先级属于选择策略;被降低优先级不等于物理不可见。
- 排序键预计算;同层按可见面积降序,再按 frameId、top y、left x、backendNodeId 升序决定顺序。字符串按代码单元字典序、数值按数值比较;同一精确去重组取该顺序最小者。不把跨批次 backend id 稳定性作为保证。
- 去重只减少重复截图入口,不产生新的 logical surface。
- 首版仅在同 document、同直接 DOM parent、四条裁剪边数值完全相同的候选之间去重,使用精确 key 的 Map;不使用近似 IoU、尺寸 band 或空间邻居比较。亚像素不同的范围保留独立入口。
- 首版不输出无法证明的 `layers=N` 业务解释。去重成员只是覆盖证据,不是内部对象模型。
- 精确去重索引达到 50,000 个 key 后,剩余候选以未去重形式继续进入有界选择,记录去重退化;不得直接丢掉剩余区域。索引已知 key 仍可命中去重。
- 即使没有去重,top-K 选择和总输出成本也必须有界。
### 5.7 ObservationRenderer / BudgetPlan
输入:SemanticScene、视觉选择结果和输出预算。
输出:文本及与已提交行严格对应的 typed ref descriptors。
- 唯一模块核算总文本预算;摘要、头部、条目和截断提示全部计入。新 observe 的统一账本为每个实际输出行分别 `ceil(line.length / 4)` 后求和,length 使用 JavaScript UTF-16 单位;不混用整段核算与逐行核算。
- 保留语义显式栈和 DOM ref 相对顺序;视觉条目在语义阶段之后提交。
- 有视觉候选时,规划阶段预留简短视觉提示;其余视觉条目使用明确分配的剩余预算。
- 极小预算不保证语义与视觉同时完整,必须产生确定的截断结果。
- 写入行与提交 ref 在逻辑上原子:未写入的条目不能留下可解析 ref。
- 使用既有 token 估算方法时,预算表示“估算 token 上限”,不能宣称精确模型 tokenizer 上限。
- 新 observe 预算规划若发现显式预算不足以容纳最小协议头,返回 insufficient-budget,由工具层映射为既有 invalid_params,附最低估算成本;不生成半截协议头或新 ref。snapshot 的旧预算语义不在本次顺带改变。这是 observe 极小预算的明确兼容性例外。逐行统一账本也可能改变临界预算的截断位置;足够预算下无 Canvas 的语义文本及顺序保持不变。
- 首版固定视觉提示为 `[visual] Canvas regions present; use screenshot`,成本由同一估算器计算。有候选但条目不足时仍尝试保留提示;连提示也容不下则只返回合法的截断语义输出,truncated=true。未知计数不伪装成精确遗漏数。
- 输出标签最多 160 个 Unicode code point,再经 JSON escaping 与整行成本核算;排序不依赖标签长度。原始采集输入量 B 仍单独计入,不因输出裁剪便宣称采集内存有界。
- `snapshot` 继续使用既定静态语义输出;新视觉入口首先仅接入 `observe`。不要顺带改变 recording/其他调用方默认选项。
### 5.8 RefRegistry / Resolve
保存 session/tab 隔离的目标描述。DOM 与视觉目标通过判别联合区分;kind 为权威,外部展示能力可从 kind 派生。
```ts
// 概念契约,具体类型名与仓库适配时确定。
type RefTarget =
| { kind: "dom"; address: NodeAddress }
| {
kind: "visual-region";
anchor: NodeAddress;
observed: ObservedGeometry;
observationId: ObservationId;
};
// NodeAddress 必须能表示 tab、CDP target、frame、document 身份及 backend node。
// 坐标值同时保留所属空间及 owner,不使用无归属 Rect 穿越模块边界。
```
工具适用性:
| 消费入口 | DOM ref | Visual ref |
|---|---|---|
| screenshot | 维持既有 DOM 截图策略 | 使用视觉区域策略 |
| click/fill/hover/select 等 | 维持既有检查 | 拒绝,且拒绝发生在派发输入前 |
| get_html | 维持既有行为 | 拒绝;不隐式降级为 anchor 的 HTML |
| 人工帮助定位 | 维持既有行为 | 若当前入口必须解释为 DOM 目标则拒绝;未来支持视觉区域需另立契约 |
所有 ref 消费者都必须通过类型化 resolver,不能依赖调用者记得传可选 capability 参数。
现有 `eN` 重用存在明确限制:如果新的 observe 已将 `e1` 重新绑定,单凭客户端传来的旧字符串 `e1` 无法判断它来自上一代。内部 generation 不会自动解决该问题。观察身份采用 target attach epoch + frameId + loaderId + documentElement backend identity,并在执行时验证 anchor 仍连接到匹配的 documentElement;loaderId 单独不足以覆盖 document.open。
本次保留当前“最新观察映射”的外部语义;只承诺能检测当前已存条目的 document/节点/几何失效,不承诺识别所有跨代旧字符串。彻底解决需独立协议设计。
### 5.9 ScreenshotExecution
分成三部分:
1. `resolve-region`:按目标种类解析当前身份与几何、验证 document 和区域变化。
2. `pixel-plan`:纯数值函数,输出 clip scale 等计划。
3. `capture`:overlay suppression、CDP 截图、真实尺寸读取、有界重试、取消及 dialog 集成。
- Visual ref 不自动滚动,不通过旧、新区域交集掩盖位移。
- 首版对超过既定容差的位移、尺寸或裁剪变化返回“需要重新观察”;容差在 PR 0 由测量误差证据锁定。
- 截图返回执行时浏览器最终合成图,不是观察时像素快照。
- document 身份验证不能替代几何验证;几何相同也不能证明 Canvas 内容未重绘。
- 像素规划采用明确 metrics 适配值;真实 PNG 尺寸是输出预算后置条件的依据。
- 尺寸头校验不宣称完整 PNG 内容验证;缺失/不合法数据与预算超限分开归因。
- 成功返回必须满足像素上限;有界重试仍失败则返回失败,不能猜测尺寸或无界重拍。
## 6. 算法与资源预算
### 6.0 两类独立的性能责任
**既有路径:防止回退。** 在相同输入、选项和环境下对照 main,检查原有语义处理、DOM ref、普通交互与截图的工作量、内存和实际耗时。不得因接入 Canvas 而重复运行语义解析、重建已有索引、增加原有节点的上下文查询,或撤销 #165 的优化。本次不要求把既有算法统一改成某个复杂度。
**新增路径:独立设计。** 围绕 Canvas 数量、frame 数量、裁剪复杂度、输出预算及截图像素量分析新成本;不照搬旧 renderer 的算法,也不以“旧算法使用了索引/显式栈”代替新问题的算法论证。下文复杂度与资源要求约束新增或本次实际改动的职责。
性能验收分三组,分别报告,不能互相替代:
| 验收组 | 对照方式 | 必须说明的结果 |
|---|---|---|
| 既有语义/操作路径 | 同一输入在 main 与新分支运行,隔离新增视觉工作 | 既有阶段未引入额外遍历、重复请求或可归因的性能回退 |
| 无 Canvas 页面 | 实际完整 observe 对照 | 仅保留必要的候选检测开销;确认无候选后不建视觉索引、不执行视觉几何查询或截图 |
| 有 Canvas 页面 | 单独测量视觉各阶段,再测完整 observe | 新成本与 C/F/P/K/像素等规模相符,资源有界;不把合理的新功能成本误记为旧算法退化 |
实际耗时需要多次同环境对照并结合操作数判断,不能仅凭一个容易波动的耗时值下结论。新增字段采集或共享模块调整的额外开销也必须归因并报告,不能藏在“原有成本”中。
### 6.1 规模定义
| 符号 | 含义 |
|---|---|
| B | 原始协议数据及字符串总量 |
| N | 采集 DOM 节点数 |
| A | AX 节点数 |
| F / T | frame 数 / CDP target 数 |
| C | 视觉候选数 |
| K | 最大选中输出条目数 |
| Q | 去重实际候选比较次数 |
| P | 几何计算量,如顶点处理、裁剪操作总数 |
| H | 语义上下文查询实际处理的数据总量 |
| L | 最终输出文本长度 |
不能忽略 B、P、H 后笼统宣称整个管线 `O(N)`。原始数据解码至少与输入量相关,复杂裁剪和语义上下文的总成本需要显式计算。
### 6.2 模块性能契约
| 阶段 | 复杂度/上限目标 | 禁止的实现 |
|---|---|---|
| 原始解码 | 与 B 线性相关 | 为每个节点重新解码共享列或字符串 |
| 文档索引 | `O(N + A + F)`,不含协议字节解码 | 每个候选全文扫描 |
| 可合成祖先状态 | `O(N)` 建立,候选常数次查询 | 每 Canvas 完整祖先 walk |
| Frame 几何 | 变换/证据按 frame 复用,额外成本计入 P | 每 node 查询 metrics/quad |
| 发现 | 索引后 `O(C)`,额外几何成本计入 P | 发现函数内重新查 CDP |
| top-K | `O(C log(K+1) + K log(K+1))` | 无需要地重复全量排序 |
| 去重 | 精确 key Map,摊销 O(C),索引至多 50,000 个 key;首版 Q=0 | 引入模糊匹配和同 bucket 两两比较 |
| 新增视觉渲染与既有渲染接线 | 新增工作按选中条目及 L 核算;既有 H 对照 main 不回退 | 为视觉条目重新执行语义遍历或扫描全部节点 |
| 截图 | 调用和重试次数有界,成功图像像素数有界 | 无限重试、猜测输出尺寸 |
K 从预算允许的最小条目成本推导,并受异常安全上限约束;实际每行仍核算成本。面积优先 top-K 不应被描述为严格求解“可变长度条目预算最优装包”,本次不实现此类优化问题。
### 6.3 首版资源参数及锁定规则
以下是 PR 0 锁定的首版策略参数。它们是有明确退化语义的工程预算,不是性能最优值的统计结论;调整须更新对应契约测试:
- Visual 截图最大边:2048 px;最大面积:4,000,000 pixels。
- 单次视觉截图最多 2 次 capture 尝试。
- Frame 几何外部请求最大并发:4。
- K 异常安全上限 512;正常数量由预算与条目成本决定。
- 几何变化阈值:top-viewport CSS px 的四条边最大差值超过 0.25 即要求重新 observe;document、anchor、裁剪策略改变不受该容差豁免。
- 没有显式 maxTokens 时,视觉通道估算 token 预算 2048;有显式预算时,优先为固定摘要行按当前估算器计算实际预留量,条目用剩余预算,全部计入同一总预算。
- 精确覆盖去重索引最多 50,000 个 key;耗尽后继续未去重 top-K,标记 dedup-degraded;不得停止扫描剩余候选。
- 新增纯计算阶段每 256 个节点/候选形成可取消工作块;需要实际让出事件循环,而不是只读一个尚无机会更新的 AbortSignal。
每个限额均须有单位、适用阶段、计数口径和耗尽行为。数字集中在所属模块策略配置中,不散落为多个含义相近的常量。若 PR 0 未锁定参数及退化契约,对应实现 PR 不得以随意常量合入。
### 6.4 性能测试方法
- 节点访问、祖先推导、候选比较、CDP 请求数、最大并发及中间集合大小均做可断言计数。
- 对新增树处理使用深树/宽树测试,证明无调用栈溢出或重复全量扫描;既有 renderer 的对应输入用于 main 前后回归对照,不要求本次消除其历史复杂度。
- 规模翻倍时检查操作数增长是否符合分析,避免只看一次墙钟时间。
- 精确去重索引至少测试:全部重合、亚像素差异、同 crop 不同 parent、全部互不相交、容量耗尽;未来若引入空间索引,必须另增退化测试。
- 大量有名小图与无名主画布并存,验证排序不会因标签策略丢失主要区域。
- 保留 #165 回归;为新增模块增加性能契约,而不是声称旧测试覆盖整个链路。
- 真实耗时基准用于观察额外成本和实际收益,需记录环境;不使用耗时阈值代替算法验证。按验收清单的性能步骤 预热 3 次、交替采样 20 次,分别报告旧阶段和新增成本;旧阶段中位数增加超过 max(基线 10%, 2ms) 时独立复测,两轮均超过且可归因于改动则 FAIL,无法排除噪声则 BLOCKED。
## 7. 失败、降级与一致性
内部状态至少区分:
```text
capture-unavailable
document-changed
frame-ownership-unresolved
geometry-unavailable
geometry-unsupported
outside-supported-clip
discovery-partial
selection-truncated
dedup-degraded
target-detached
geometry-changed
image-invalid
pixel-budget-exceeded
```
这些是内部分类,不要求逐项扩展外部 RPC code。外部沿用既有错误分类并给出准确 message/必要 detail;不可把所有时效或几何失败叫权限问题。
原则:
- 视觉分支失败不应使已获得的语义结果无故丢失。
- 没有候选、没有预算、没有采集成功必须可区分。
- exact omitted count 只在可计算时输出;原始候选数与去重后的区域数不能相加冒充精确数量。
- 捕获批次不保证页面静止;可检测的身份变化必须处理,不可检测的像素重绘不作虚假保证。
- 取消在阶段边界和有界工作块之间检查,不能在大型纯计算循环结束后才响应。
## 8. 兼容性与影响面约束
1. 从 main 新建实现分支;不将当前分支整体 cherry-pick 作为迁移方式。
2. 没有视觉候选的页面,语义文本、DOM ref 相对顺序和操作行为维持原有契约。
例外为上述 observe 显式预算小于最小协议头的 invalid_params 行为及统一账本导致的临界预算截断位置变化;足够预算不改变旧语义。
3. 有视觉候选的 observe 允许新增视觉文本,并按新预算规划改变截断位置;这是明确行为变化。
4. `snapshot`、recording、hover probe 默认行为不随视觉通道接入而隐式改变。
5. 修正已证明错误的坐标允许改变输出位置,不以错误 golden 为兼容目标。
6. 几何共享改动必须覆盖已有 DOM 点击/截图路径回归;不以 Canvas 测试代替。
7. CLI/协议外形优先保持;确需新增外部字段、错误子码或参数时,必须在对应 PR 明确列出全部消费者。
8. 不进行无关格式化、目录大搬迁、依赖升级或语义启发式修改。
9. 不长期保留两套 capture decoder、metrics adapter、frame resolver、token accounting 或 pixel planner。
10. 不把站点特征、fixture 标签、CSS class 或 URL 放入生产决策。
## 9. 设计验证清单与关闭方式
| 待验证项 | 证据 | 必须形成的决定 |
|---|---|---|
| 嵌入式 Canvas 表格 原始失败 | main 的观察输出、对应截图及结构证据 | 失败归属、最小复现和成功标准 |
| DOMSnapshot / metrics 单位 | DPR、zoom、scroll、frame 矩阵 | 原始单位适配和转换入口(实验结论见证据文档) |
| frame / target 坐标 | 同 target、OOPIF、nested、带边框/变换样例 | content quad 来源、坐标归属及支持范围 |
| document 身份 | 导航、同 frame document 替换、target 重建 | 使用哪些现有标识/事件,何时失效 |
| CSS 视觉规则 | visibility 覆盖、opacity、hidden、单轴 overflow | 可合成状态和不支持情况 |
| 时效容差 | 静止页面测量噪声、可控移动/缩放/裁剪变化 | 容差单位、比较规则和失败行为 |
| 预算 | 调用方 max_tokens 使用方式、密集画布样例 | 提示预留、K、限额与截断规则 |
| 去重 | 重合、近重合、不同父节点、相邻 tile | 首版等价规则和 Q 上限 |
验证结果与最终决定见 [PR 0 证据及决策](visual-observation-evidence.md)。首版精确几何支持收敛到已验证的轴向矩形、正向轴对齐平移/缩放、普通 frame/overflow 链。旋转、透视、clip-path、mask 和活动 pinch-zoom 不承诺精确区域 ref:返回 geometry-unsupported 状态并提示使用现有视口截图。后续扩展必须独立验证。浏览器 UI zoom 与 CSS zoom 分开验证,不能用 pinch emulation 代替 UI zoom。
进一步限定:CSS zoom/DOM transform 作用于 overflow 裁剪祖先的组合,首版也进入 geometry-unsupported。
实验表明 snapshot.bounds 已缩放,而 clientRects/offsetRects 仍有未缩放和整数取整语义,不能靠一个宽度比准确恢复所有边。
普通 Canvas 的 CSS zoom bounds 与 iframe content quad 缩放仍可使用;这不意味着缩放后的任意 DOM scrollport 都已获得精确支持。
Facts 必须保留 client box、transform/zoom 及 clip-path/mask 等支持性证据,discovery 返回状态而不是另写近似投影。
## 10. PR 划分与依赖
PR 0 是设计与证据入口;PR 1—6 建立模块;PR 7 接入公开 observe;PR 8 完成跨模块验收和迁移收尾。编号是顺序计划,不是实际 GitHub PR 编号。
```text
PR 0 设计规格与实验
└─ PR 1 几何与身份契约
└─ PR 2 采集批次与共享事实
└─ PR 3 视觉发现
└─ PR 4 选择、去重与预算
PR 1 + PR 2 ──→ PR 5 类型化 ref
PR 1 + PR 5 ──→ PR 6 视觉截图执行
PR 2 + PR 3 + PR 4 + PR 5 + PR 6
└─ PR 7 observe 组合与接线
└─ PR 8 整体验收与迁移收尾
```
独立模块可以使用人工输入测试,不必等待真实上游接线。实现可依赖前序已合入 main 的 PR,避免多个大型长期分支互相漂移。阶段未接线的模块必须有紧邻的消费计划,不保留无期限死代码或对用户暴露半成品。
### PR 0:规格收敛、最小复现与基线
完成:
- 审核现有分支 fixture,区分正确规格与需要推翻的预期。
- 记录 嵌入式 Canvas 表格 原始失败及 main 基线,建立独立于站点的最小 fixture。
- 完成第 9 节实验,锁定核心数据类型、支持范围、容差和资源参数。
- 固定 #165 的相关回归结果和操作数基线;不夸大旧测试覆盖范围。
- 将行为契约映射到后续每个模块的测试清单。
不做:生产算法替换、全目录迁移、正式视觉通道接入。
验收:核心身份、坐标、裁剪、预算和失效规则不存在未指定行为;未支持情况有明确策略。若仍有未知项,先调整范围而不是让后续 PR 猜测。
### PR 1:统一几何与节点身份契约
完成:
- 引入有 owner 的坐标和地址类型,区分 frame/target/top/CSS/image 空间。
- 统一 metrics 解释、frame projection 和 owner geometry 查询。
- 共享单次操作内的 promise cache;保证采集和执行使用独立上下文。
- 给既有 DOM 几何入口做窄适配;移除已被替代的投影路径。
不做:Canvas 发现、排序、ref 外部行为变化。
验收:scroll、border、同 target、OOPIF、nested、支持范围内的 transform/DPR/zoom 矩阵通过;已有 DOM 点击与截图回归通过;请求计数和几何成本可解释。
### PR 2:采集批次、归一化事实和共享索引
完成:
- 将原始解码、采集编排、normalization 明确分开。
- 按 target 去重采集,记录 document 身份、时间范围和阶段失败。
- 建立 ObservationFacts、document 隔离 lookup/children 索引和正确的祖先状态结构。
- 现有 SemanticPipeline 通过 adapter 消费新事实;保持 hover 在静态采集后发生。
- 删除不再使用的可变中间态和重复解码/归属推断路径。
不做:变更语义保留规则、视觉输出和截图政策。
验收:语义回归及 frame ownership 通过;deep/wide/orphan/cycle 有明确结果;解码、索引、采集数量符合预算;无每节点完整祖先复制。
### PR 3:纯数据视觉候选发现
完成:
- 实现 VisualCandidate 及 discoverVisualCandidates。
- 落实无标签、有 AX、aria-hidden/inert/pointer-events、透明/隐藏、裁剪规则。
- 候选关联 document/anchor/geometry,语义上下文只作为可选信息。
- 返回发现失败或不完整状态,避免与无候选混淆。
不做:ref 分配、截图、token 排序、逻辑画布合并。
验收:每条发现规则都有正反 fixture;输入同一份事实输出确定;索引后的候选发现不重复扫 DOM 或走完整祖先链;不增加 CDP 调用。
### PR 4:有界选择、保守去重与输出预算计划
完成:
- 明确几何优先排序及确定性 tie-breaker,预计算比较键。
- 实现有界 top-K,K 受文本预算和安全限额约束。
- 实现精确覆盖 key 去重及索引容量耗尽后“继续未去重选择”的退化行为;首版 Q=0,不进行模糊两两比较。
- 建立纯 BudgetPlan 和视觉摘要/条目格式,统一预算核算契约。
- 明确候选数、输出数、遗漏数及去重退化信息的口径。
不做:多 anchor target、业务画布推断、生产 observe 接线。
验收:密集小图、无名主画布、全部重合、同 bucket 不等价、极小预算均有确定结果;比较次数、堆大小、输出成本有界。
### PR 5:类型化 ref 注册与工具解析
完成:
- RefStore 保存判别联合,绑定 document 及 observation 信息。
- kind 派生工具适用性,删除可独立构造的矛盾 capability 状态。
- 审核并迁移截图、交互、get_html、人工帮助等全部 ref 消费者。
- 保持 eN 外部语义,文档明确跨代字符串识别限制。
不做:引入新版 ref 协议、自动把视觉 ref 当 DOM anchor 使用。
验收:跨 session/tab 隔离;document 失效;全部工具矩阵通过;拒绝在输入派发前发生;旧 DOM ref 行为保持。
### PR 6:视觉截图的区域解析、像素规划与执行
完成:
- 分离 resolve-region、pixel-plan、capture executor。
- 实现 visual 不滚动、身份验证、几何容差及重新观察错误。
- 实现真实输出尺寸后置条件、有界重试和准确失败归因。
- 复用 overlay suppression、取消和 dialog 机制。
不做:Canvas 内部截图、像素冻结、多节点 union、内部点交互。
验收:移动/缩放/裁剪变化、节点移除、document 替换、DPR/zoom、无效图像、超预算和取消路径通过;成功图像符合像素预算;现有 DOM 截图回归通过。
### PR 7:观察结果组合与 observe 接线
完成:
- 将 Facts 的语义/视觉两路结果交给统一输出规划和 renderer。
- 保留 #165 显式栈和 DOM ref 相对顺序,追加视觉条目。
- 仅对已写入条目提交 ref,失败/截断状态正确传递。
- 视觉提示参与预先预算,避免 DOM 耗尽预算后完全无提示。
- 仅在 observe 默认启用新视觉通道;必要的 CLI/skill 使用说明同步更新。
不做:顺带修改 snapshot、recording 或 hover 默认策略。
验收:从人工 facts 到文本/ref 的集成测试、真实单 Canvas observe → screenshot 闭环通过;无 Canvas 页面回归通过;极小预算无悬空 ref。
### PR 8:跨模块验收、性能证据与收尾
完成:
- 在真实浏览器运行完整 fixture 矩阵和 嵌入式 Canvas 表格 原始场景,保存可复现结果。
- 验证整个管线的请求数量、集合规模、比较次数、深树安全和真实耗时。
- 检查新模块是否存在重复解释、长期 adapter、无消费者代码或语义影响扩散。
- 收尾剩余迁移文档、调试信息说明、支持范围和已知限制。
不做:把此前未测试的性能正确性全部拖到本 PR,也不接纳新的功能范围。
验收:第 11 节全部通过;若发现某模块契约错误,修正在该模块并补对应验收,不在最终接线处加站点特判。
## 11. 总体验收矩阵
本节是范围索引,具体怎么做、什么算成功见 [验收清单](visual-observation-acceptance.md)。新会话按当前 PR 执行并直接说明结果即可,无需报告模板或逐项登记。修改契约时同步清单,避免两套标准。
| 维度 | 必测场景 | 主要责任 |
|---|---|---|
| 发现 | 无名、有 AX、aria-hidden、inert、pointer-events:none | PR 3 |
| 视觉状态 | 零尺寸、透明祖先、visibility 覆盖、支持范围内 hidden 状态 | PR 0/2/3 |
| 裁剪 | viewport、单轴/嵌套 overflow、支持范围内变换 | PR 1/2/3 |
| frame | 同 target、OOPIF、nested、border、缺失 owner | PR 1/2 |
| 身份 | target 冲突、节点移除、导航、document 替换 | PR 2/5/6 |
| 输出 | DOM 顺序、视觉提示、modal 策略、极小预算、遗漏口径 | PR 4/7 |
| ref | 工具矩阵、跨 tab/session、无悬空 ref | PR 5/7 |
| 截图 | 几何变化、DPR/zoom、实际像素、重试、overlay | PR 6 |
| 算法 | 深树、宽树、高密度、全部重合、索引退化 | 各模块 PR |
| 回归 | #165、普通 DOM 点击/截图、snapshot、recording、hover | PR 1/2/7/8 |
| 产品结果 | main 原始失败与新链路的 嵌入式 Canvas 表格 场景对照 | PR 0/8 |
## 12. 相比旧蓝图的明确调整
| 旧方向 | 本文调整 | 原因 |
|---|---|---|
| 全链路 transaction 先行 | 建立来源明确的 capture batch;不承诺原子快照 | 生命周期模型不能代替一致性证据 |
| candidate → stack → logical surface 必经分层 | candidate + 输出覆盖去重;logical surface 不进入本次 | 限制身份模型及截图复杂度 |
| 统一祖先 excluded | 视觉、语义、交互状态分开 | 避免加速错误规则 |
| 各阶段一律兼容 adapter | 仅消费者迁移需要时使用,并随切换删除 | 避免临时抽象堆积 |
| 性能作为后续优化阶段 | 每个模块设计/实现均有复杂度与计数验收 | 防止功能完成后再补算法 |
| renderer 最后尝试附录 | 先规划预算,再分阶段输出 | 避免视觉入口系统性消失 |
| observed rect 与 live rect 求交 | 身份和几何分别验证 | 避免成功返回错误截图片段 |
| generation 暗示 freshness 已解决 | 明确现有 eN 重用限制 | 防止无法实现的旧字符串识别承诺 |
| 当前测试全部视为兼容基准 | 先审核期望,再保留正确回归 | 测试不能替代行为规格 |
## 13. 完成定义与设计变更纪律
工程完成必须同时满足:
- 五条核心结果全部成立,真实 嵌入式 Canvas 表格 场景及独立 fixture 均有证据。
- 每个模块只有一个权威实现,依赖方向与本文一致。
- 坐标和节点地址跨边界时身份明确,失败可归因。
- 视觉发现不依赖语义树保留结果,不混淆视觉与交互属性。
- 性能上限及退化可测试;复杂度没有遗漏协议输入量和几何工作量。
- 现有语义、DOM 操作及 #165 成果受到明确回归保护。
- 当前支持范围、近似/拒绝行为和 ref freshness 限制已记录。
- 不存在为了通过原始站点而增加的生产特判。
每个实现 PR 的描述必须写明:解决哪个独立问题、改变哪个契约、哪些行为允许变化、验证哪些正确性与性能要求、删除哪条旧路径。若没有删除旧路径,应说明保留原因和明确的后续消费/移除 PR。
新的案例若属于已定规则范围,应修复负责该规则的模块;若要求改变目标身份、几何支持或对外协议,应先形成设计变更。不得在 observation 接线、renderer 或截图执行末端不断补跨层例外。
## 14. 参考与证据来源
- 本地 main:`3c5f838`;当前分析分支:`fix/vom-canvas-observation`。
- PR #165 本地合并提交:`5ed8287`;实现提交:`641d934`。涉及 renderer 上下文索引、显式栈等优化。
- 主要代码范围:`apps/extension/src/tools/observation.ts`、`tools/vom/capture.ts`、`tools/vom/frame-capture.ts`、`tools/vom/semantic-graph/`、`tools/frame-geometry.ts`、`session-manager/ref-store.ts`、`packages/vom/src/render.ts`。
- 当前分支参考:`tools/vom/rendered-surfaces/`、`tools/visual-screenshot-plan.ts` 及相关 fixture/test。
- 原 visual-surface 架构蓝图作为历史参考,不是本设计的执行规格。
- 浏览器语义参考:[aria-hidden](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-hidden)、[inert](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/inert)、[visibility](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/visibility)。本次 CDP 单位、frame 和身份实验结论见验证结果文档;支持范围之外的行为不作推断。
本文中的路径为代码职责定位,最终目录命名可适配工程规范;行为契约、问题边界、复杂度预算和验收要求不能因此省略。
+27 -6
View File
@@ -112,7 +112,7 @@ shell's theme cannot bleed back in.
- **Lifecycle**: hidden while the plugin owns no sessions; appears on the first - **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). `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 - **Focus view**: status row (green/idle/red dot + session + action + mm:ss), the latest page
frame (refreshes every ~1.5s while active, ~8s when idle; the last good frame stays on 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), 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). 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 - **Interrupt**: one click kills the in-flight bsk command of the focus session (same semantics
@@ -129,11 +129,32 @@ shell's theme cannot bleed back in.
- **Pop out (PiP)**: upgrades the card into a native Document PiP window (requires a user - **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 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. in-page card with state intact. Browsers without Document PiP simply hide the button.
- **Wire**: the host serves `GET /bsk-observation/state`, `GET /bsk-observation/events` (SSE), - **Screenshot demand**: the client requests periodic screenshots while an observation view is
`POST /bsk-observation/interrupt`, and `GET /bsk-observation/thumbnail/<attachmentId>` over visible, using the PiP document's visibility when popped out. Hidden or collapsed views can
the dsh `webServer` route seam (dsh 0.1's Typert Remote pipeline is closed to out-of-tree keep subscribing to state without requesting screenshots. The first screenshot subscriber
packages). All commands for one session — tool calls and frame captures alike — run through a starts capture scheduling; the last one's departure cancels timers and skips queued captures.
per-session FIFO, because the daemon accepts only one unfinished command per session. 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 - **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 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 must be a loopback authority (`localhost`, `127.0.0.0/8`, `[::1]`), a present Origin must
@@ -142,6 +142,8 @@
right: 6px; right: 6px;
width: 16px; width: 16px;
height: 16px; height: 16px;
padding: 0;
cursor: pointer;
border-radius: 50%; border-radius: 50%;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -48,7 +48,13 @@ import { createPortal } from "react-dom";
import type { SessionObservation } from "../observation"; import type { SessionObservation } from "../observation";
import css from "./ObservationOverlay.module.css"; import css from "./ObservationOverlay.module.css";
import type { ObservationClientStore } from "./observation-store"; import type { ObservationClientStore } from "./observation-store";
import { focusOf, statusOf, useObservationView, usePip } from "./observation-view"; import {
focusOf,
statusOf,
useObservationView,
usePip,
useThumbnailObservation,
} from "./observation-view";
import { getSidebarMode, subscribeSidebarMode } from "./sidebar-mode"; import { getSidebarMode, subscribeSidebarMode } from "./sidebar-mode";
// The pure view helpers live in observation-view (shared with the sidebar // The pure view helpers live in observation-view (shared with the sidebar
@@ -341,6 +347,7 @@ export function OverlayBody(props: {
onHeaderPointerDown, onHeaderPointerDown,
} = props; } = props;
const [interrupting, setInterrupting] = useState(false); const [interrupting, setInterrupting] = useState(false);
const viewRef = useThumbnailObservation(store, sessions.length > 0);
const thumbId = focus?.thumbnailAttachmentId; const thumbId = focus?.thumbnailAttachmentId;
useEffect(() => { useEffect(() => {
@@ -373,7 +380,12 @@ export function OverlayBody(props: {
const state = !available ? "error" : focus !== undefined ? statusOf(focus) : "idle"; const state = !available ? "error" : focus !== undefined ? statusOf(focus) : "idle";
return ( return (
<div className={cn(css.body, "bsk-obs")} data-state={state} data-in-pip={inPip || undefined}> <div
ref={viewRef}
className={cn(css.body, "bsk-obs")}
data-state={state}
data-in-pip={inPip || undefined}
>
<div <div
className={css.header} className={css.header}
data-testid="obs-header" data-testid="obs-header"
@@ -421,9 +433,15 @@ export function OverlayBody(props: {
</div> </div>
)} )}
{thumb?.status === "error" ? ( {thumb?.status === "error" ? (
<span className={css.badge} aria-label="thumbnail failed"> <button
type="button"
className={css.badge}
aria-label="Retry thumbnail"
title="Frame unavailable — retry"
onClick={() => store.retryThumbnail(thumbId)}
>
<IconWarn size={12} /> <IconWarn size={12} />
</span> </button>
) : null} ) : null}
</div> </div>
{sessions.length >= 2 ? ( {sessions.length >= 2 ? (
@@ -1,10 +1,9 @@
/** /**
* Client-side data layer for the observation overlay: initial state fetch, * Client-side data layer for the observation overlay: an ordered SSE snapshot
* SSE increment stream (with a state refetch on every (re)open — events that * and increment stream (each reconnect starts with a fresh snapshot), on-demand
* fired while the stream was down are otherwise lost forever), on-demand * thumbnail blob loading through the host's ephemeral-frame route,
* thumbnail blob loading through the session-authorized readAttachment path,
* and the interrupt call. The last ready blob per session is held until its * and the interrupt call. The last ready blob per session is held until its
* replacement decodes so the overlay can swap frames in place. All I/O is * replacement loads so the overlay can swap frames in place. All I/O is
* injected so tests never touch a network. * injected so tests never touch a network.
*/ */
@@ -12,8 +11,6 @@ import type { ObservationEvent, SessionObservation } from "../observation";
export interface EventSourceLike { export interface EventSourceLike {
onmessage: ((event: { data: string }) => void) | null; onmessage: ((event: { data: string }) => void) | null;
/** Fires on the initial connect AND on every automatic reconnect. */
onopen?: (() => void) | null;
close(): void; close(): void;
} }
@@ -50,10 +47,10 @@ export interface OverlaySnapshot {
readonly available: boolean; readonly available: boolean;
} }
const STATE_URL = "/bsk-observation/state";
const EVENTS_URL = "/bsk-observation/events"; const EVENTS_URL = "/bsk-observation/events";
const INTERRUPT_URL = "/bsk-observation/interrupt"; const INTERRUPT_URL = "/bsk-observation/interrupt";
const STOP_URL = "/bsk-observation/stop"; const STOP_URL = "/bsk-observation/stop";
const THUMBNAIL_RETRY_DELAYS_MS = [1000, 3000];
function revoke(url: string | undefined): void { function revoke(url: string | undefined): void {
if (url !== undefined && typeof URL.revokeObjectURL === "function") { if (url !== undefined && typeof URL.revokeObjectURL === "function") {
@@ -66,8 +63,10 @@ export class ObservationClientStore {
private thumbs = new Map<string, ThumbnailState>(); private thumbs = new Map<string, ThumbnailState>();
/** sessionId → the attachment id currently advertised for it. */ /** sessionId → the attachment id currently advertised for it. */
private readonly thumbBySession = new Map<string, string>(); private readonly thumbBySession = new Map<string, string>();
/** sessionId → last successfully decoded frame (held across the next load). */ /** sessionId → last successfully loaded frame (held across the next load). */
private readonly lastReady = new Map<string, { attachmentId: string; url: string }>(); private readonly lastReady = new Map<string, { attachmentId: string; url: string }>();
private readonly retryTimers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly retryCounts = new Map<string, number>();
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
private events: EventSourceLike | undefined; private events: EventSourceLike | undefined;
private snapshot: OverlaySnapshot = { private snapshot: OverlaySnapshot = {
@@ -81,6 +80,7 @@ export class ObservationClientStore {
private started = false; private started = false;
/** Refcount of mounted consumers (overlay card, sidebar tab, sidebar fiber). */ /** Refcount of mounted consumers (overlay card, sidebar tab, sidebar fiber). */
private consumers = 0; private consumers = 0;
private thumbnailConsumers = 0;
constructor(private readonly deps: ObservationClientDeps) {} constructor(private readonly deps: ObservationClientDeps) {}
@@ -133,37 +133,16 @@ export class ObservationClientStore {
return undefined; return undefined;
} }
/** (Re)pull the full state: initial load and every SSE (re)open. */ private connectEvents(): void {
private refreshState(): void { const previous = this.events;
void this.deps this.events = undefined;
.fetchFn(STATE_URL) previous?.close();
.then(async (res) => { const events = this.deps.eventSourceFactory(
if (!res.ok) return; `${EVENTS_URL}?thumbnails=${this.thumbnailConsumers > 0 ? "1" : "0"}`,
const body = (await res.json()) as { );
sessions?: SessionObservation[]; this.events = events;
available?: boolean;
};
const sessions = body.sessions ?? [];
this.sessions = new Map(sessions.map((s) => [s.sessionId, s]));
// Prune thumbnails of sessions that vanished while we were away.
const alive = new Set(sessions.map((s) => s.sessionId));
for (const sessionId of [...this.thumbBySession.keys()]) {
if (!alive.has(sessionId)) this.dropThumb(sessionId);
}
for (const s of sessions) this.trackThumb(s.sessionId, s.thumbnailAttachmentId);
if (typeof body.available === "boolean") this.available = body.available;
this.publish();
})
.catch(() => {});
}
/** Initial fetch + SSE subscription. Idempotent. */
start(): void {
if (this.started) return;
this.started = true;
this.refreshState();
const events = this.deps.eventSourceFactory(EVENTS_URL);
events.onmessage = (message) => { events.onmessage = (message) => {
if (!this.started || this.events !== events) return;
let event: ObservationEvent; let event: ObservationEvent;
try { try {
event = JSON.parse(message.data) as ObservationEvent; event = JSON.parse(message.data) as ObservationEvent;
@@ -172,23 +151,24 @@ export class ObservationClientStore {
} }
this.apply(event); this.apply(event);
}; };
events.onopen = () => { }
// Reconnects lose every event fired during the outage — resync.
if (this.events === events) this.refreshState(); /** Every initial connection and reconnect starts with the server's snapshot. */
}; start(): void {
this.events = events; if (this.started) return;
this.started = true;
this.connectEvents();
this.publish(); this.publish();
} }
stop(): void { stop(): void {
this.events?.close(); const previous = this.events;
this.events = undefined; this.events = undefined;
this.started = false; this.started = false;
for (const thumb of this.thumbs.values()) revoke(thumb.url); previous?.close();
this.thumbs.clear(); this.clearThumbnails();
this.thumbBySession.clear();
this.lastReady.clear();
this.sessions.clear(); this.sessions.clear();
this.available = true;
this.publish(); this.publish();
} }
@@ -209,6 +189,33 @@ export class ObservationClientStore {
if (this.consumers === 0) this.stop(); if (this.consumers === 0) this.stop();
} }
/** Only visible image surfaces request screenshots; metadata watchers do not. */
watchThumbnails(): () => void {
this.thumbnailConsumers += 1;
if (this.thumbnailConsumers === 1 && this.started) this.connectEvents();
let released = false;
return () => {
if (released) return;
released = true;
this.thumbnailConsumers -= 1;
if (this.thumbnailConsumers === 0 && this.started) this.connectEvents();
};
}
private forgetThumbnail(attachmentId: string): void {
clearTimeout(this.retryTimers.get(attachmentId));
this.retryTimers.delete(attachmentId);
this.retryCounts.delete(attachmentId);
revoke(this.thumbs.get(attachmentId)?.url);
this.thumbs.delete(attachmentId);
}
private clearThumbnails(): void {
for (const attachmentId of this.thumbs.keys()) this.forgetThumbnail(attachmentId);
this.thumbBySession.clear();
this.lastReady.clear();
}
/** Forget one session's tracked + held frames, revoking their blob URLs. */ /** Forget one session's tracked + held frames, revoking their blob URLs. */
private dropThumb(sessionId: string): void { private dropThumb(sessionId: string): void {
const attachmentId = this.thumbBySession.get(sessionId); const attachmentId = this.thumbBySession.get(sessionId);
@@ -216,39 +223,45 @@ export class ObservationClientStore {
this.thumbBySession.delete(sessionId); this.thumbBySession.delete(sessionId);
this.lastReady.delete(sessionId); this.lastReady.delete(sessionId);
if (attachmentId !== undefined) { if (attachmentId !== undefined) {
revoke(this.thumbs.get(attachmentId)?.url); this.forgetThumbnail(attachmentId);
this.thumbs.delete(attachmentId);
} }
if (held !== undefined && held.attachmentId !== attachmentId) { if (held !== undefined && held.attachmentId !== attachmentId) {
revoke(held.url); this.forgetThumbnail(held.attachmentId);
this.thumbs.delete(held.attachmentId);
} }
} }
/** /**
* Track the frame a session currently advertises. The last *ready* blob is * Track the frame a session currently advertises. The last *ready* blob is
* kept until the replacement decodes — dropping it on the upsert is what * kept until the replacement loads — dropping it on the upsert is what
* made the overlay flash a placeholder between breaths. * made the overlay flash a placeholder between breaths.
*/ */
private trackThumb(sessionId: string, attachmentId: string | undefined): void { private trackThumb(sessionId: string, attachmentId: string | undefined): void {
if (attachmentId === undefined) {
this.dropThumb(sessionId);
return;
}
const previous = this.thumbBySession.get(sessionId); const previous = this.thumbBySession.get(sessionId);
if (previous === attachmentId) return; if (previous === attachmentId) return;
if (attachmentId !== undefined) this.thumbBySession.set(sessionId, attachmentId); this.thumbBySession.set(sessionId, attachmentId);
else this.thumbBySession.delete(sessionId);
const heldId = this.lastReady.get(sessionId)?.attachmentId; const heldId = this.lastReady.get(sessionId)?.attachmentId;
if (previous !== undefined && previous !== heldId) { if (previous !== undefined && previous !== heldId) {
revoke(this.thumbs.get(previous)?.url); this.forgetThumbnail(previous);
this.thumbs.delete(previous);
} }
} }
private apply(event: ObservationEvent): void { private apply(event: ObservationEvent): void {
if (event.type === "reset") { if (event.type === "snapshot") {
this.sessions = new Map(event.sessions.map((session) => [session.sessionId, session]));
for (const sessionId of this.thumbBySession.keys()) {
if (!this.sessions.has(sessionId)) this.dropThumb(sessionId);
}
for (const session of event.sessions) {
this.trackThumb(session.sessionId, session.thumbnailAttachmentId);
}
this.available = event.available;
} else if (event.type === "reset") {
this.sessions.clear(); this.sessions.clear();
for (const thumb of this.thumbs.values()) revoke(thumb.url); this.clearThumbnails();
this.thumbs.clear();
this.thumbBySession.clear();
this.lastReady.clear();
} else if (event.type === "remove" && event.session !== undefined) { } else if (event.type === "remove" && event.session !== undefined) {
this.sessions.delete(event.session.sessionId); this.sessions.delete(event.session.sessionId);
this.dropThumb(event.session.sessionId); this.dropThumb(event.session.sessionId);
@@ -259,44 +272,77 @@ export class ObservationClientStore {
this.available = event.available; this.available = event.available;
} }
this.publish(); this.publish();
if (event.type === "snapshot") {
for (const session of event.sessions) this.retryThumbnail(session.thumbnailAttachmentId);
}
} }
/** /**
* Ensure a thumbnail load is in flight for one attachment reference. New * Ensure a thumbnail load is in flight for one attachment reference. New
* frames replace the old URL only after they decode; failures keep the last * frames replace the old URL only after they load; failures keep the last
* good frame (the caller renders `status: 'error'` as a small badge over * good frame (the caller renders `status: 'error'` as a small badge over
* the old image). * the old image).
*/ */
ensureThumbnail(attachmentId: string | undefined): void { ensureThumbnail(attachmentId: string | undefined): void {
if (attachmentId === undefined || this.thumbs.has(attachmentId)) return; if (
this.thumbs.set(attachmentId, { status: "loading" }); !this.started ||
attachmentId === undefined ||
this.sessionOf(attachmentId) === undefined ||
this.thumbs.has(attachmentId)
)
return;
const loading: ThumbnailState = { status: "loading" };
this.thumbs.set(attachmentId, loading);
this.publish(); this.publish();
this.deps.loadImage(attachmentId).then( this.deps.loadImage(attachmentId).then(
(url) => { (url) => {
if (!this.thumbs.has(attachmentId)) { if (this.thumbs.get(attachmentId) !== loading) {
// Replaced or removed while loading: never resurrect (or leak) it. // Replaced or removed while loading: never resurrect (or leak) it.
revoke(url); revoke(url);
return; return;
} }
this.retryCounts.delete(attachmentId);
this.thumbs.set(attachmentId, { status: "ready", url }); this.thumbs.set(attachmentId, { status: "ready", url });
const sessionId = this.sessionOf(attachmentId); const sessionId = this.sessionOf(attachmentId);
if (sessionId !== undefined) { if (sessionId !== undefined) {
const previous = this.lastReady.get(sessionId); const previous = this.lastReady.get(sessionId);
if (previous !== undefined && previous.attachmentId !== attachmentId) { if (previous !== undefined && previous.attachmentId !== attachmentId) {
revoke(previous.url); this.forgetThumbnail(previous.attachmentId);
this.thumbs.delete(previous.attachmentId);
} }
this.lastReady.set(sessionId, { attachmentId, url }); this.lastReady.set(sessionId, { attachmentId, url });
} }
this.publish(); this.publish();
}, },
() => { () => {
this.thumbs.set(attachmentId, { status: "error" }); if (this.thumbs.get(attachmentId) !== loading) return;
const failed: ThumbnailState = { status: "error" };
this.thumbs.set(attachmentId, failed);
const attempts = this.retryCounts.get(attachmentId) ?? 0;
const delay = THUMBNAIL_RETRY_DELAYS_MS[attempts];
if (delay !== undefined) {
this.retryCounts.set(attachmentId, attempts + 1);
this.retryTimers.set(
attachmentId,
setTimeout(() => {
this.retryTimers.delete(attachmentId);
if (this.thumbs.get(attachmentId) !== failed) return;
this.thumbs.delete(attachmentId);
this.ensureThumbnail(attachmentId);
}, delay),
);
}
this.publish(); this.publish();
}, },
); );
} }
/** Explicit retry (also used on a fresh connection after an outage). */
retryThumbnail(attachmentId: string | undefined): void {
if (attachmentId === undefined || this.thumbs.get(attachmentId)?.status !== "error") return;
this.forgetThumbnail(attachmentId);
this.ensureThumbnail(attachmentId);
}
/** Interrupt the current or named session's in-flight call. */ /** Interrupt the current or named session's in-flight call. */
async interrupt(sessionId?: string): Promise<boolean> { async interrupt(sessionId?: string): Promise<boolean> {
try { try {
@@ -6,7 +6,7 @@
* without duplicating hooks. * without duplicating hooks.
*/ */
import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { SessionObservation } from "../observation"; import type { SessionObservation } from "../observation";
import type { ObservationClientStore, OverlaySnapshot } from "./observation-store"; import type { ObservationClientStore, OverlaySnapshot } from "./observation-store";
@@ -114,6 +114,49 @@ export function useObservationView(
}; };
} }
/** A mounted metadata watcher is not necessarily a visible screenshot viewer. */
export function useThumbnailObservation(store: ObservationClientStore, enabled: boolean) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = ref.current;
if (!enabled || element === null) return;
// A portal uses its PiP document's visibility, independently of the main tab.
const doc = element.ownerDocument;
const Observer = doc.defaultView?.IntersectionObserver;
let active = true;
let intersecting = Observer === undefined;
let release: (() => void) | undefined;
const update = () => {
if (!active) return;
const visible = intersecting && doc.visibilityState !== "hidden";
if (visible && release === undefined) release = store.watchThumbnails();
else if (!visible && release !== undefined) {
release();
release = undefined;
}
};
const observer =
Observer === undefined
? undefined
: new Observer((entries) => {
intersecting = entries.some(
(entry) => entry.target === element && entry.isIntersecting,
);
update();
});
observer?.observe(element);
doc.addEventListener("visibilitychange", update);
update();
return () => {
active = false;
observer?.disconnect();
doc.removeEventListener("visibilitychange", update);
release?.();
};
}, [store, enabled]);
return ref;
}
export interface PipHandle { export interface PipHandle {
/** The open PiP window, or null while the content renders in its carrier. */ /** The open PiP window, or null while the content renders in its carrier. */
readonly pipWindow: Window | null; readonly pipWindow: Window | null;
@@ -5,12 +5,19 @@
* the event allowlist lives in dsh-api-remotes), so the plugin serves its * the event allowlist lives in dsh-api-remotes), so the plugin serves its
* observation channel through the documented `webServer` route seam instead: * observation channel through the documented `webServer` route seam instead:
* *
* GET /bsk-observation/state → { sessions, available } * GET /bsk-observation/state → one-time { sessions, available } read
* GET /bsk-observation/events → SSE stream of ObservationEvent * GET /bsk-observation/events → SSE snapshot, then ObservationEvent increments
* (on every connection, including reconnects)
* ?thumbnails=0: state only; =1: request screenshots
* Omitted parameter defaults to screenshots.
* POST /bsk-observation/interrupt → body {sessionId?} → {interrupted: boolean} * POST /bsk-observation/interrupt → body {sessionId?} → {interrupted: boolean}
* POST /bsk-observation/stop → body {sessionId} → {stopped: boolean} * POST /bsk-observation/stop → body {sessionId} → {stopped: boolean}
* GET /bsk-observation/thumbnail/<attachmentId> → image bytes * GET /bsk-observation/thumbnail/<attachmentId> → image bytes
* *
* Live clients initialize/resynchronize from the SSE snapshot; a separate
* /state read has no ordering guarantee relative to the stream and does not
* request screenshots. Closing an SSE connection releases its screenshot demand.
*
* Routes exist only when a webServer service is mounted (web composition); * Routes exist only when a webServer service is mounted (web composition);
* headless deployments skip registration entirely. * headless deployments skip registration entirely.
*/ */
@@ -99,6 +106,7 @@ export function registerObservationRoutes(
if (webServer === undefined) { if (webServer === undefined) {
return () => {}; return () => {};
} }
const streams = new Set<() => void>();
const disposers = [ const disposers = [
webServer.register({ webServer.register({
kind: "exact", kind: "exact",
@@ -129,14 +137,42 @@ export function registerObservationRoutes(
"cache-control": "no-cache", "cache-control": "no-cache",
connection: "keep-alive", connection: "keep-alive",
}); });
const unsubscribe = observation.subscribe((event) => { const thumbnails =
res.write(`data: ${JSON.stringify(event)}\n\n`); new URL(req.url ?? "/", "http://localhost").searchParams.get("thumbnails") !== "0";
}); let unsubscribe: (() => void) | undefined;
const heartbeat = setInterval(() => res.write(": heartbeat\n\n"), SSE_HEARTBEAT_MS); let heartbeat: ReturnType<typeof setInterval> | undefined;
res.on("close", () => { let closed = false;
const close = () => {
if (closed) return;
closed = true;
clearInterval(heartbeat); clearInterval(heartbeat);
unsubscribe(); unsubscribe?.();
}); streams.delete(close);
res.end();
};
const write = (data: string) => {
if (closed) return;
try {
res.write(data);
} catch {
close();
}
};
streams.add(close);
res.on("close", close);
res.on("error", close);
try {
unsubscribe = observation.subscribe(
(event) => {
write(`data: ${JSON.stringify(event)}\n\n`);
},
{ thumbnails },
);
if (closed) unsubscribe();
else heartbeat = setInterval(() => write(": heartbeat\n\n"), SSE_HEARTBEAT_MS);
} catch {
close();
}
}, },
}), }),
webServer.register({ webServer.register({
@@ -228,6 +264,7 @@ export function registerObservationRoutes(
}), }),
]; ];
return () => { return () => {
for (const close of streams) close();
for (const dispose of disposers) dispose(); for (const dispose of disposers) dispose();
}; };
} }
@@ -49,8 +49,9 @@ export interface SessionObservation {
dshSessionIds?: string[]; dshSessionIds?: string[];
} }
/** Incremental event carried to subscribers (SSE on the wire). */ /** Initial snapshot or incremental change carried to subscribers (SSE on the wire). */
export type ObservationEvent = export type ObservationEvent =
| { type: "snapshot"; sessions: SessionObservation[]; available: boolean }
| { type: "upsert" | "remove" | "reset"; session?: SessionObservation } | { type: "upsert" | "remove" | "reset"; session?: SessionObservation }
| { type: "availability"; available: boolean }; | { type: "availability"; available: boolean };
@@ -89,6 +90,7 @@ export class ObservationService {
private readonly scratchNamespace = randomUUID(); private readonly scratchNamespace = randomUUID();
private readonly observations = new Map<string, SessionObservation>(); private readonly observations = new Map<string, SessionObservation>();
private readonly listeners = new Set<(event: ObservationEvent) => void>(); private readonly listeners = new Set<(event: ObservationEvent) => void>();
private thumbnailViewers = 0;
private readonly captureTimers = new Map<string, unknown>(); private readonly captureTimers = new Map<string, unknown>();
private readonly captureInFlight = new Set<string>(); private readonly captureInFlight = new Set<string>();
/** Captures intentionally cancelled to make way for foreground work. */ /** Captures intentionally cancelled to make way for foreground work. */
@@ -125,7 +127,7 @@ export class ObservationService {
return this.deps.scheduler ?? DEFAULT_SCHEDULER; return this.deps.scheduler ?? DEFAULT_SCHEDULER;
} }
/** All current entries (client initial/resync snapshot). */ /** One-time state read; use subscribe for an ordered snapshot and subsequent changes. */
getState(): SessionObservation[] { getState(): SessionObservation[] {
return [...this.observations.values()].map((entry) => ({ ...entry })); return [...this.observations.values()].map((entry) => ({ ...entry }));
} }
@@ -141,10 +143,42 @@ export class ObservationService {
this.emit({ type: "availability", available }); this.emit({ type: "availability", available });
} }
/** Subscribe to incremental changes; returns an unsubscribe function. */ /**
subscribe(listener: (event: ObservationEvent) => void): () => void { * Subscribe to an ordered initial snapshot and subsequent changes. On an
this.listeners.add(listener); * active service, listener receives the snapshot synchronously before this
return () => this.listeners.delete(listener); * method returns. Subscribing after disposal is a no-op.
*
* `thumbnails` defaults to true for compatibility: this subscription counts
* as a screenshot viewer for the service's owned sessions. Pass false for
* state-only observation. The first viewer starts capture scheduling.
*
* @returns An idempotent unsubscribe function releasing this subscription's
* screenshot demand. When the last viewer leaves, scheduled/queued captures
* are cancelled/skipped; an already running capture may finish.
*/
subscribe(
listener: (event: ObservationEvent) => void,
{ thumbnails = true }: { thumbnails?: boolean } = {},
): () => void {
if (this.disposed) return () => {};
// Each subscription owns its lease, even if a caller reuses a callback.
const receive = (event: ObservationEvent) => listener(event);
this.listeners.add(receive);
try {
listener({ type: "snapshot", sessions: this.getState(), available: this.available });
} catch (error) {
this.listeners.delete(receive);
throw error;
}
if (thumbnails && ++this.thumbnailViewers === 1) {
for (const sessionId of this.observations.keys()) this.scheduleCapture(sessionId, 0);
}
return () => {
if (!this.listeners.delete(receive)) return;
if (thumbnails && --this.thumbnailViewers === 0) {
for (const sessionId of this.captureTimers.keys()) this.cancelCapture(sessionId);
}
};
} }
private emit(event: ObservationEvent): void { private emit(event: ObservationEvent): void {
@@ -355,6 +389,7 @@ export class ObservationService {
this.observations.clear(); this.observations.clear();
this.emit({ type: "reset" }); this.emit({ type: "reset" });
this.listeners.clear(); this.listeners.clear();
this.thumbnailViewers = 0;
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -369,12 +404,21 @@ export class ObservationService {
} }
} }
private canCapture(sessionId: string): boolean {
const entry = this.observations.get(sessionId);
return (
this.deps.options.enabled &&
!this.disposed &&
this.thumbnailViewers > 0 &&
!this.foregroundDepth.has(sessionId) &&
entry !== undefined &&
entry.dead !== true
);
}
/** Schedule the next capture for a session; `delayMs` 0 means "as soon as the event loop allows". */ /** Schedule the next capture for a session; `delayMs` 0 means "as soon as the event loop allows". */
private scheduleCapture(sessionId: string, delayMs?: number): void { private scheduleCapture(sessionId: string, delayMs?: number): void {
if (!this.deps.options.enabled || this.disposed) return; if (!this.canCapture(sessionId)) return;
if (this.foregroundDepth.has(sessionId)) return;
const entry = this.observations.get(sessionId);
if (entry === undefined || entry.dead === true) return;
this.cancelCapture(sessionId); this.cancelCapture(sessionId);
const now = this.scheduler.now(); const now = this.scheduler.now();
const lastSeen = this.lastActivity.get(sessionId) ?? 0; const lastSeen = this.lastActivity.get(sessionId) ?? 0;
@@ -399,9 +443,7 @@ export class ObservationService {
* Failures keep the previous frame and back off silently. * Failures keep the previous frame and back off silently.
*/ */
private async capture(sessionId: string): Promise<void> { private async capture(sessionId: string): Promise<void> {
if (this.disposed) return; if (!this.canCapture(sessionId)) return;
const current = this.observations.get(sessionId);
if (current === undefined || current.dead === true) return;
if (this.captureInFlight.has(sessionId)) return; if (this.captureInFlight.has(sessionId)) return;
this.captureInFlight.add(sessionId); this.captureInFlight.add(sessionId);
// Stable within this service, isolated from captures owned by other instances. // Stable within this service, isolated from captures owned by other instances.
@@ -410,8 +452,8 @@ export class ObservationService {
let writtenPath = outPath; let writtenPath = outPath;
try { try {
const result = await this.deps.queue.run(sessionId, () => { const result = await this.deps.queue.run(sessionId, () => {
// A foreground lease may have arrived while this capture was queued. // The last viewer may have left, or foreground work arrived, while queued.
if (this.foregroundDepth.has(sessionId)) return Promise.resolve(undefined); if (!this.canCapture(sessionId)) return Promise.resolve(undefined);
return this.deps.runner.run(["screenshot", "--session", sessionId, "--out", outPath], { return this.deps.runner.run(["screenshot", "--session", sessionId, "--out", outPath], {
timeoutMs: 15_000, timeoutMs: 15_000,
tag: `observation:${sessionId}`, tag: `observation:${sessionId}`,
@@ -445,6 +487,7 @@ export class ObservationService {
this.setAvailable(true); this.setAvailable(true);
this.publishFrame(sessionId, entry, data); this.publishFrame(sessionId, entry, data);
} catch { } catch {
if (this.disposed || !this.observations.has(sessionId)) return;
// Foreground preemption is healthy scheduling, not browser/daemon // Foreground preemption is healthy scheduling, not browser/daemon
// unavailability; do not let normal tool traffic trip capture backoff. // unavailability; do not let normal tool traffic trip capture backoff.
if (!this.capturePreempted.delete(sessionId)) { if (!this.capturePreempted.delete(sessionId)) {
@@ -19,10 +19,12 @@ interface Harness {
emitRaw: (event: unknown) => void; emitRaw: (event: unknown) => void;
fetches: { url: string; init?: { body?: string } }[]; fetches: { url: string; init?: { body?: string } }[];
loadImage: ReturnType<typeof vi.fn>; loadImage: ReturnType<typeof vi.fn>;
eventUrls: string[];
} }
function makeHarness(initial: SessionObservation[]): Harness { function makeHarness(initial: SessionObservation[]): Harness {
const fetches: Harness["fetches"] = []; const fetches: Harness["fetches"] = [];
const eventUrls: string[] = [];
let es: EventSourceLike | undefined; let es: EventSourceLike | undefined;
let current = initial; let current = initial;
const loadImage = vi.fn(async (id: string) => `blob:${id}`); const loadImage = vi.fn(async (id: string) => `blob:${id}`);
@@ -34,9 +36,16 @@ function makeHarness(initial: SessionObservation[]): Harness {
} }
return { ok: true, json: async () => ({ interrupted: true }) }; return { ok: true, json: async () => ({ interrupted: true }) };
}, },
eventSourceFactory: () => { eventSourceFactory: (url) => {
es = { onmessage: null, close: vi.fn() }; eventUrls.push(url);
return es; const connection = { onmessage: null, close: vi.fn() } as EventSourceLike;
es = connection;
queueMicrotask(() =>
connection.onmessage?.({
data: JSON.stringify({ type: "snapshot", sessions: current, available: true }),
}),
);
return connection;
}, },
loadImage, loadImage,
}); });
@@ -58,10 +67,120 @@ function makeHarness(initial: SessionObservation[]): Harness {
}, },
fetches, fetches,
loadImage, loadImage,
eventUrls,
}; };
} }
afterEach(cleanup); afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("visible thumbnail demand", () => {
function intersections() {
const observers: Array<{
notify: (visible: boolean) => void;
disconnect: ReturnType<typeof vi.fn>;
}> = [];
vi.stubGlobal(
"IntersectionObserver",
class {
private target?: Element;
disconnect = vi.fn();
constructor(private callback: IntersectionObserverCallback) {
observers.push(this);
}
observe(target: Element) {
this.target = target;
}
notify(visible: boolean) {
this.callback(
[{ target: this.target, isIntersecting: visible } as IntersectionObserverEntry],
this as unknown as IntersectionObserver,
);
}
},
);
return observers;
}
it("pauses screenshots on collapse while keeping the metadata feed", async () => {
const observers = intersections();
const h = makeHarness([BUSY]);
const { unmount } = render(<ObservationOverlay store={h.store} />);
await screen.findByTestId("obs-card");
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0");
act(() => observers[0].notify(true));
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=1");
fireEvent.click(screen.getByRole("button", { name: "Collapse" }));
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0");
expect(h.es().close).not.toHaveBeenCalled();
expect(observers[0].disconnect).toHaveBeenCalledOnce();
// A callback queued before disconnect must not reclaim screenshot demand.
act(() => observers[0].notify(true));
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0");
fireEvent.click(screen.getByRole("button", { name: /Expand browser observation/ }));
act(() => observers[1].notify(true));
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=1");
unmount();
expect(h.es().close).toHaveBeenCalledOnce();
});
it("ignores an initial visibility notification delivered after collapse", async () => {
const observers = intersections();
const h = makeHarness([BUSY]);
render(<ObservationOverlay store={h.store} />);
await screen.findByTestId("obs-card");
fireEvent.click(screen.getByRole("button", { name: "Collapse" }));
act(() => observers[0].notify(true));
expect(h.eventUrls).toEqual(["/bsk-observation/events?thumbnails=0"]);
});
it("pauses for a hidden document or hidden panel and resumes when visible", async () => {
const observers = intersections();
let visibility: DocumentVisibilityState = "visible";
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility);
const h = makeHarness([BUSY]);
render(<ObservationOverlay store={h.store} />);
await screen.findByTestId("obs-card");
act(() => observers[0].notify(true));
expect(h.eventUrls.at(-1)).toContain("thumbnails=1");
act(() => {
visibility = "hidden";
document.dispatchEvent(new Event("visibilitychange"));
});
expect(h.eventUrls.at(-1)).toContain("thumbnails=0");
act(() => {
visibility = "visible";
document.dispatchEvent(new Event("visibilitychange"));
});
expect(h.eventUrls.at(-1)).toContain("thumbnails=1");
act(() => observers[0].notify(false));
expect(h.eventUrls.at(-1)).toContain("thumbnails=0");
});
it("keeps a visible PiP watching when the main document is hidden", async () => {
// The detached PiP test document has no window/IntersectionObserver.
vi.stubGlobal("IntersectionObserver", undefined);
let visibility: DocumentVisibilityState = "visible";
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility);
const pipDoc = document.implementation.createHTMLDocument("pip");
Object.defineProperty(pipDoc, "visibilityState", { value: "visible" });
(window as unknown as Record<string, unknown>).documentPictureInPicture = {
requestWindow: async () => ({ document: pipDoc, addEventListener: () => {}, close: vi.fn() }),
};
const h = makeHarness([BUSY]);
render(<ObservationOverlay store={h.store} />);
fireEvent.click(await screen.findByRole("button", { name: /Pop out/ }));
await waitFor(() => expect(pipDoc.body.textContent).toContain("s1"));
act(() => {
visibility = "hidden";
document.dispatchEvent(new Event("visibilitychange"));
});
expect(h.eventUrls.at(-1)).toContain("thumbnails=1");
});
});
describe("ObservationOverlay", () => { describe("ObservationOverlay", () => {
beforeEach(() => { beforeEach(() => {
@@ -92,6 +211,16 @@ describe("ObservationOverlay", () => {
await screen.findByRole("img", { name: "session s1 view" }); await screen.findByRole("img", { name: "session s1 view" });
}); });
it("offers a retry after a thumbnail fails without requiring a new frame id", async () => {
const h = makeHarness([{ ...BUSY, thumbnailAttachmentId: "att-9" }]);
h.loadImage.mockRejectedValueOnce(new Error("temporary failure"));
render(<ObservationOverlay store={h.store} />);
fireEvent.click(await screen.findByRole("button", { name: "Retry thumbnail" }));
await screen.findByRole("img", { name: "session s1 view" });
expect(h.loadImage).toHaveBeenCalledTimes(2);
expect(screen.queryByRole("button", { name: "Retry thumbnail" })).toBeNull();
});
it("keeps the current frame on stage while the next thumbnail loads", async () => { it("keeps the current frame on stage while the next thumbnail loads", async () => {
let resolveNext: ((url: string) => void) | undefined; let resolveNext: ((url: string) => void) | undefined;
const h = makeHarness([{ ...BUSY, thumbnailAttachmentId: "att-1" }]); const h = makeHarness([{ ...BUSY, thumbnailAttachmentId: "att-1" }]);
@@ -53,8 +53,14 @@ function makeHarness(initial: SessionObservation[]): Harness {
return { ok: true, json: async () => ({ interrupted: true }) }; return { ok: true, json: async () => ({ interrupted: true }) };
}, },
eventSourceFactory: () => { eventSourceFactory: () => {
es = { onmessage: null, close: vi.fn() }; const connection = { onmessage: null, close: vi.fn() } as EventSourceLike;
return es; es = connection;
queueMicrotask(() =>
connection.onmessage?.({
data: JSON.stringify({ type: "snapshot", sessions: current, available: true }),
}),
);
return connection;
}, },
loadImage: async (id: string) => `blob:${id}`, loadImage: async (id: string) => `blob:${id}`,
}); });
@@ -1,12 +1,19 @@
// ObservationClientStore: initial fetch + SSE application, thumbnail loading // ObservationClientStore: ordered SSE snapshots/increments, thumbnail loading
// lifecycle, interrupt wire shape. All I/O faked. // lifecycle, interrupt wire shape. All I/O faked.
import { describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store"; import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store";
import type { ObservationEvent, SessionObservation } from "../../src/observation"; import type { ObservationEvent, SessionObservation } from "../../src/observation";
const OBS_IDLE: SessionObservation = { sessionId: "s1", action: "idle", since: 1000 }; const OBS_IDLE: SessionObservation = { sessionId: "s1", action: "idle", since: 1000 };
const OBS_BUSY: SessionObservation = { sessionId: "s1", action: "clicking", since: 2000 }; const OBS_BUSY: SessionObservation = { sessionId: "s1", action: "clicking", since: 2000 };
const stores: ObservationClientStore[] = [];
afterEach(() => {
for (const store of stores.splice(0)) store.stop();
vi.restoreAllMocks();
vi.useRealTimers();
});
function harness(state: SessionObservation[] = []) { function harness(state: SessionObservation[] = []) {
const fetches: { const fetches: {
@@ -14,6 +21,7 @@ function harness(state: SessionObservation[] = []) {
init?: { method?: string; body?: string; headers?: Record<string, string> }; init?: { method?: string; body?: string; headers?: Record<string, string> };
}[] = []; }[] = [];
const esInstances: EventSourceLike[] = []; const esInstances: EventSourceLike[] = [];
const eventUrls: string[] = [];
const fetchFn = vi.fn( const fetchFn = vi.fn(
async ( async (
url: string, url: string,
@@ -29,11 +37,14 @@ function harness(state: SessionObservation[] = []) {
const eventSourceFactory = (url: string) => { const eventSourceFactory = (url: string) => {
const es: EventSourceLike = { onmessage: null, close: vi.fn() }; const es: EventSourceLike = { onmessage: null, close: vi.fn() };
esInstances.push(es); esInstances.push(es);
eventUrls.push(url);
queueMicrotask(() => emit(es, { type: "snapshot", sessions: state, available: true }));
return es; return es;
}; };
const loadImage = vi.fn(async (id: string) => `blob:url-${id}`); const loadImage = vi.fn(async (id: string) => `blob:url-${id}`);
const store = new ObservationClientStore({ fetchFn, eventSourceFactory, loadImage }); const store = new ObservationClientStore({ fetchFn, eventSourceFactory, loadImage });
return { store, fetches, esInstances, loadImage }; stores.push(store);
return { store, fetches, esInstances, eventUrls, loadImage };
} }
function emit(es: EventSourceLike, event: ObservationEvent): void { function emit(es: EventSourceLike, event: ObservationEvent): void {
@@ -73,7 +84,9 @@ describe("ObservationClientStore", () => {
}); });
it("loads each thumbnail once and reports ready", async () => { it("loads each thumbnail once and reports ready", async () => {
const { store, loadImage } = harness(); const { store, loadImage } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "att-1" }]);
store.start();
await Promise.resolve();
store.ensureThumbnail("att-1"); store.ensureThumbnail("att-1");
store.ensureThumbnail("att-1"); store.ensureThumbnail("att-1");
expect(loadImage).toHaveBeenCalledTimes(1); expect(loadImage).toHaveBeenCalledTimes(1);
@@ -86,7 +99,9 @@ describe("ObservationClientStore", () => {
}); });
it("marks failed thumbnail loads as error", async () => { it("marks failed thumbnail loads as error", async () => {
const { store, loadImage } = harness(); const { store, loadImage } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "att-bad" }]);
store.start();
await Promise.resolve();
loadImage.mockRejectedValueOnce(new Error("nope")); loadImage.mockRejectedValueOnce(new Error("nope"));
store.ensureThumbnail("att-bad"); store.ensureThumbnail("att-bad");
await vi.waitFor(() => expect(store.getSnapshot().thumbnails["att-bad"].status).toBe("error")); await vi.waitFor(() => expect(store.getSnapshot().thumbnails["att-bad"].status).toBe("error"));
@@ -124,9 +139,7 @@ describe("ObservationClientStore", () => {
describe("thumbnail blob URL lifecycle", () => { describe("thumbnail blob URL lifecycle", () => {
function withRevokeSpy() { function withRevokeSpy() {
const revoke = vi.fn(); return vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
(URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = revoke;
return revoke;
} }
it("holds the last ready frame until the replacement decodes", async () => { it("holds the last ready frame until the replacement decodes", async () => {
@@ -231,16 +244,216 @@ describe("thumbnail blob URL lifecycle", () => {
}); });
}); });
describe("SSE (re)open resync", () => { describe("SSE snapshot ordering", () => {
it("refetches the full state when the stream (re)opens", async () => { it("ignores events from a stopped connection after a restart", async () => {
const { store, esInstances } = harness([OBS_IDLE]);
store.start();
await Promise.resolve();
const old = esInstances[0];
store.stop();
emit(old, { type: "upsert", session: OBS_BUSY });
expect(store.getSnapshot().sessions).toEqual([]);
store.start();
await Promise.resolve();
emit(esInstances[1], { type: "upsert", session: OBS_BUSY });
emit(old, { type: "snapshot", sessions: [], available: false });
emit(old, { type: "remove", session: OBS_BUSY });
expect(store.getSnapshot()).toMatchObject({ sessions: [OBS_BUSY], available: true });
});
it("does not apply a queued initial snapshot after stop", async () => {
const { store } = harness([OBS_BUSY]);
store.start();
store.stop();
await Promise.resolve();
expect(store.getSnapshot()).toMatchObject({ sessions: [], thumbnails: {}, subscribed: false });
});
it("replaces state on reconnect before applying subsequent events, without a competing fetch", async () => {
const { store, fetches, esInstances } = harness([OBS_IDLE]); const { store, fetches, esInstances } = harness([OBS_IDLE]);
store.start(); store.start();
await vi.waitFor(() => expect(store.getSnapshot().sessions).toHaveLength(1)); await vi.waitFor(() => expect(store.getSnapshot().sessions).toHaveLength(1));
const before = fetches.filter((f) => f.url === "/bsk-observation/state").length; emit(esInstances[0], { type: "snapshot", sessions: [], available: false });
esInstances[0].onopen?.(); expect(store.getSnapshot()).toMatchObject({ sessions: [], available: false });
await vi.waitFor(() => emit(esInstances[0], { type: "upsert", session: OBS_BUSY });
expect(fetches.filter((f) => f.url === "/bsk-observation/state").length).toBe(before + 1), await Promise.resolve();
); expect(store.getSnapshot().sessions).toEqual([OBS_BUSY]);
expect(fetches).toEqual([]);
store.stop(); store.stop();
}); });
}); });
describe("thumbnail recovery and stale loads", () => {
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
it("retries a transient failure without needing a different frame id", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const { store, loadImage } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "a1" }]);
store.start();
await Promise.resolve();
loadImage.mockRejectedValueOnce(new Error("temporary"));
store.ensureThumbnail("a1");
await Promise.resolve();
expect(store.getSnapshot().thumbnails.a1.status).toBe("error");
await vi.advanceTimersByTimeAsync(1000);
expect(loadImage).toHaveBeenCalledTimes(2);
expect(store.getSnapshot().displayFrames.s1).toEqual({ status: "ready", url: "blob:url-a1" });
expect(vi.getTimerCount()).toBe(0);
});
it("bounds automatic retries and allows an explicit recovery", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const { store, loadImage } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "a1" }]);
store.start();
await Promise.resolve();
loadImage.mockRejectedValue(new Error("offline"));
store.ensureThumbnail("a1");
await vi.advanceTimersByTimeAsync(60_000);
expect(loadImage).toHaveBeenCalledTimes(3);
expect(vi.getTimerCount()).toBe(0);
store.ensureThumbnail("a1");
expect(loadImage).toHaveBeenCalledTimes(3);
loadImage.mockResolvedValue("blob:recovered");
store.retryThumbnail("a1");
await Promise.resolve();
expect(store.getSnapshot().displayFrames.s1.url).toBe("blob:recovered");
});
it("cancels backoff when a reconnect retries the current frame", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const session = { ...OBS_IDLE, thumbnailAttachmentId: "a1" };
const { store, esInstances, loadImage } = harness([session]);
store.start();
await Promise.resolve();
loadImage.mockRejectedValueOnce(new Error("offline"));
store.ensureThumbnail("a1");
await Promise.resolve();
emit(esInstances[0], { type: "snapshot", sessions: [session], available: true });
await Promise.resolve();
expect(store.getSnapshot().thumbnails.a1.status).toBe("ready");
await vi.advanceTimersByTimeAsync(60_000);
expect(loadImage).toHaveBeenCalledTimes(2);
});
it.each([
"remove",
"reset",
"stop",
"missing-thumbnail",
"snapshot-removal",
])("cleans frames and cancels retries on %s", async (action) => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const { store, esInstances, loadImage } = harness([
{ ...OBS_IDLE, thumbnailAttachmentId: "a1" },
]);
store.start();
await Promise.resolve();
store.ensureThumbnail("a1");
await Promise.resolve();
emit(esInstances[0], { type: "upsert", session: { ...OBS_IDLE, thumbnailAttachmentId: "a2" } });
loadImage.mockRejectedValueOnce(new Error("no frame"));
store.ensureThumbnail("a2");
await Promise.resolve();
expect(store.getSnapshot().displayFrames.s1).toEqual({ status: "error", url: "blob:url-a1" });
if (action === "stop") store.stop();
else if (action === "remove") emit(esInstances[0], { type: "remove", session: OBS_IDLE });
else if (action === "reset") emit(esInstances[0], { type: "reset" });
else if (action === "missing-thumbnail")
emit(esInstances[0], { type: "upsert", session: OBS_IDLE });
else emit(esInstances[0], { type: "snapshot", sessions: [], available: true });
expect(store.getSnapshot().thumbnails).toEqual({});
expect(store.getSnapshot().displayFrames).toEqual({});
expect(revoke).toHaveBeenCalledExactlyOnceWith("blob:url-a1");
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(60_000);
expect(loadImage).toHaveBeenCalledTimes(2);
});
it.each([
"resolve",
"reject",
])("ignores a stale load's %s even when the same id is loaded again", async (outcome) => {
const revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const { store, esInstances, loadImage } = harness([
{ ...OBS_IDLE, thumbnailAttachmentId: "a1" },
]);
const old = deferred<string>();
loadImage.mockReturnValueOnce(old.promise);
store.start();
await Promise.resolve();
store.ensureThumbnail("a1");
emit(esInstances[0], { type: "upsert", session: { ...OBS_IDLE, thumbnailAttachmentId: "a2" } });
emit(esInstances[0], { type: "upsert", session: { ...OBS_IDLE, thumbnailAttachmentId: "a1" } });
store.ensureThumbnail("a1");
await Promise.resolve();
if (outcome === "resolve") old.resolve("blob:stale");
else old.reject(new Error("stale error"));
await Promise.resolve();
expect(store.getSnapshot().displayFrames.s1).toEqual({ status: "ready", url: "blob:url-a1" });
expect(revoke).not.toHaveBeenCalledWith("blob:url-a1");
if (outcome === "resolve") expect(revoke).toHaveBeenCalledWith("blob:stale");
});
it.each(["resolve", "reject"])("ignores an in-flight load's %s after stop", async (outcome) => {
const revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const { store, loadImage } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "a1" }]);
const pending = deferred<string>();
loadImage.mockReturnValueOnce(pending.promise);
store.start();
await Promise.resolve();
store.ensureThumbnail("a1");
store.stop();
if (outcome === "resolve") pending.resolve("blob:late");
else pending.reject(new Error("late error"));
await Promise.resolve();
expect(store.getSnapshot()).toMatchObject({ sessions: [], thumbnails: {}, displayFrames: {} });
if (outcome === "resolve") expect(revoke).toHaveBeenCalledWith("blob:late");
});
});
describe("thumbnail viewer leases", () => {
it("keeps metadata subscribed while the last screenshot viewer leaves", async () => {
const { store, eventUrls, esInstances } = harness([OBS_IDLE]);
store.acquire();
await Promise.resolve();
expect(eventUrls).toEqual(["/bsk-observation/events?thumbnails=0"]);
const first = store.watchThumbnails();
expect(eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=1");
const second = store.watchThumbnails();
expect(eventUrls).toHaveLength(2);
first();
first();
expect(eventUrls).toHaveLength(2);
second();
expect(eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0");
expect(store.getSnapshot().subscribed).toBe(true);
store.release();
expect(esInstances.at(-1)?.close).toHaveBeenCalledOnce();
expect(store.getSnapshot().subscribed).toBe(false);
});
it("ignores delayed snapshots from a replaced stream without clearing the displayed state", async () => {
const { store, esInstances } = harness([OBS_IDLE]);
store.start();
await Promise.resolve();
emit(esInstances[0], { type: "upsert", session: OBS_BUSY });
const release = store.watchThumbnails();
expect(store.getSnapshot().sessions).toEqual([OBS_BUSY]);
await Promise.resolve();
emit(esInstances[1], { type: "upsert", session: OBS_BUSY });
emit(esInstances[0], { type: "snapshot", sessions: [], available: false });
expect(store.getSnapshot()).toMatchObject({ sessions: [OBS_BUSY], available: true });
store.stop();
release();
expect(esInstances).toHaveLength(2);
});
});
@@ -6,7 +6,7 @@ import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { basename, dirname } from "node:path"; import { basename, dirname } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
actionForLabel, actionForLabel,
type ObservationEvent, type ObservationEvent,
@@ -138,6 +138,8 @@ function setup(opts: {
runner?: FakeRunner; runner?: FakeRunner;
registry?: SessionRegistry; registry?: SessionRegistry;
scheduler?: ReturnType<typeof fakeScheduler>; scheduler?: ReturnType<typeof fakeScheduler>;
thumbnails?: boolean;
queue?: KeyedExecutor;
}) { }) {
const registry = opts.registry ?? new SessionRegistry(5); const registry = opts.registry ?? new SessionRegistry(5);
const runner = opts.runner ?? fakeRunner(); const runner = opts.runner ?? fakeRunner();
@@ -146,13 +148,18 @@ function setup(opts: {
ctx: fakeCtx(), ctx: fakeCtx(),
runner, runner,
registry, registry,
queue: new KeyedExecutor(), queue: opts.queue ?? new KeyedExecutor(),
options: OPTIONS, options: OPTIONS,
scheduler, scheduler,
}); });
const events: ObservationEvent[] = []; const events: ObservationEvent[] = [];
service.subscribe((event) => events.push(event)); const unsubscribe = service.subscribe(
return { service, registry, runner, scheduler, events }; (event) => {
if (event.type !== "snapshot") events.push(event);
},
{ thumbnails: opts.thumbnails ?? true },
);
return { service, registry, runner, scheduler, events, unsubscribe };
} }
/** Drive the registry through a real start so the session is owned. */ /** Drive the registry through a real start so the session is owned. */
@@ -224,6 +231,111 @@ describe("state machine", () => {
}); });
describe("thumbnail cadence", () => { describe("thumbnail cadence", () => {
it("keeps metadata current without screenshots and resumes when a viewer arrives", async () => {
const { service, scheduler, runner, events } = setup({ thumbnails: false });
service.addSession("s1");
service.beginAction("s1", "clicking");
service.endAction("s1");
const releaseForeground = service.acquireForeground("s1");
releaseForeground();
expect(service.getState()[0].action).toBe("idle");
expect(events).toHaveLength(3);
expect(scheduler.pending()).toEqual([]);
expect(runner.calls).toEqual([]);
const snapshots: ObservationEvent[] = [];
const leave = service.subscribe((event) => snapshots.push(event));
expect(snapshots[0]).toEqual({
type: "snapshot",
sessions: service.getState(),
available: true,
});
expect(scheduler.pending()).toEqual([0]);
scheduler.runNext();
await waitFor(() => scheduler.pending().length === 1);
expect(runner.calls).toHaveLength(1);
leave();
leave();
expect(scheduler.pending()).toEqual([]);
service.endAction("s1");
expect(scheduler.pending()).toEqual([]);
const resume = service.subscribe(() => {});
expect(scheduler.pending()).toEqual([0]);
resume();
service.dispose();
});
it("does not capture in a headless service with no subscribers", () => {
const { service, scheduler, unsubscribe } = setup({});
unsubscribe();
service.addSession("s1");
service.endAction("s1");
expect(scheduler.pending()).toEqual([]);
expect(service.getState()).toHaveLength(1);
service.dispose();
});
it("keeps capturing until the last viewer leaves, including reused callbacks", () => {
const { service, scheduler } = setup({ thumbnails: false });
service.addSession("s1");
const listener = () => {};
const first = service.subscribe(listener);
const second = service.subscribe(listener);
expect(scheduler.pending()).toEqual([0]);
first();
first();
expect(scheduler.pending()).toEqual([0]);
second();
expect(scheduler.pending()).toEqual([]);
service.dispose();
});
it("skips a queued screenshot if its last viewer leaves before execution", async () => {
const queue = new KeyedExecutor();
let unblock!: () => void;
const gate = new Promise<void>((resolve) => {
unblock = resolve;
});
const busy = queue.run("s1", () => gate);
const { service, scheduler, runner, unsubscribe } = setup({ queue });
service.addSession("s1");
scheduler.runNext();
unsubscribe();
unblock();
await busy;
await queue.run("s1", async () => {});
expect(runner.calls).toEqual([]);
expect(scheduler.pending()).toEqual([]);
service.dispose();
});
it("lets an already running capture finish without restarting the loop after the last viewer leaves", async () => {
const runner = fakeRunner();
const run = runner.run.bind(runner);
let finish!: () => void;
let started = false;
runner.run = async (args, options) => {
started = true;
await new Promise<void>((resolve) => {
finish = resolve;
});
return run(args, options);
};
const { service, scheduler, unsubscribe } = setup({ runner });
service.addSession("s1");
scheduler.runNext();
await waitFor(() => started);
unsubscribe();
finish();
await waitFor(() => service.getState()[0]?.thumbnailAttachmentId !== undefined);
const args = runner.calls[0].args;
await waitFor(() => !existsSync(args[args.indexOf("--out") + 1]));
expect(scheduler.pending()).toEqual([]);
const leave = service.subscribe(() => {});
expect(scheduler.pending()).toEqual([0]);
leave();
service.dispose();
});
it("gives foreground work priority and resumes with one fresh capture", async () => { it("gives foreground work priority and resumes with one fresh capture", async () => {
const scheduler = fakeScheduler(); const scheduler = fakeScheduler();
let finishCapture: ((result: BskRunResult) => void) | undefined; let finishCapture: ((result: BskRunResult) => void) | undefined;
@@ -540,6 +652,12 @@ describe("HTTP/SSE interface", () => {
const eventsRes = fakeRes(); const eventsRes = fakeRes();
await eventsRoute?.handler(fakeReq(), eventsRes.res as never); await eventsRoute?.handler(fakeReq(), eventsRes.res as never);
service.beginAction("s1", "clicking"); service.beginAction("s1", "clicking");
const streamed = eventsRes.chunks.map((chunk) => JSON.parse(chunk.slice(6)));
expect(streamed.map((event) => event.type)).toEqual(["snapshot", "upsert"]);
expect(streamed[0]).toMatchObject({
sessions: [{ sessionId: "s1", action: "idle" }],
available: true,
});
expect(eventsRes.res.body()).toContain('"action":"clicking"'); expect(eventsRes.res.body()).toContain('"action":"clicking"');
eventsRes.res.close(); eventsRes.res.close();
@@ -566,6 +684,47 @@ describe("HTTP/SSE interface", () => {
service.dispose(); service.dispose();
}); });
it("state-only SSE does not capture, and route disposal releases every active viewer", async () => {
const { service, scheduler } = setup({ thumbnails: false });
service.addSession("s1");
const { routes, webServer } = routeHarness();
const dispose = registerObservationRoutes({ get: () => webServer } as never, service);
const route = routes.get("/bsk-observation/events")!;
const metadata = fakeRes();
await route.handler(fakeReq({ url: "/bsk-observation/events?thumbnails=0" }), metadata.res);
expect(scheduler.pending()).toEqual([]);
const viewing = fakeRes();
await route.handler(fakeReq({ url: "/bsk-observation/events?thumbnails=1" }), viewing.res);
expect(scheduler.pending()).toEqual([0]);
const endMetadata = vi.spyOn(metadata.res, "end");
const endViewing = vi.spyOn(viewing.res, "end");
dispose();
expect(endMetadata).toHaveBeenCalledOnce();
expect(endViewing).toHaveBeenCalledOnce();
expect(scheduler.pending()).toEqual([]);
viewing.res.close();
metadata.res.close();
expect(endViewing).toHaveBeenCalledOnce();
service.dispose();
});
it("releases a viewer when writing its initial snapshot fails", async () => {
const { service, scheduler } = setup({ thumbnails: false });
service.addSession("s1");
const { routes, webServer } = routeHarness();
const dispose = registerObservationRoutes({ get: () => webServer } as never, service);
const response = fakeRes();
vi.spyOn(response.res, "write").mockImplementation(() => {
throw new Error("closed socket");
});
const end = vi.spyOn(response.res, "end");
await routes.get("/bsk-observation/events")!.handler(fakeReq(), response.res);
expect(end).toHaveBeenCalledOnce();
expect(scheduler.pending()).toEqual([]);
dispose();
service.dispose();
});
it("refuses a stop without a sessionId", async () => { it("refuses a stop without a sessionId", async () => {
const registry = new SessionRegistry(5); const registry = new SessionRegistry(5);
own(registry, "s1"); own(registry, "s1");