mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b6cdde168 | ||
|
|
4aade4b94f | ||
|
|
616af1099f | ||
|
|
3656f0dd3c | ||
|
|
9742d83213 | ||
|
|
615e866608 | ||
|
|
c08fed5de3 | ||
|
|
887d1ed0b9 | ||
|
|
1a3d3b7de8 | ||
|
|
72834980d2 | ||
|
|
38b0debba9 | ||
|
|
4677149e5e | ||
|
|
8ea5477e11 | ||
|
|
17af1af65a |
@@ -1,10 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RECORD_STOP, type RecordStepPayload } from "@/lib/record-bridge";
|
||||
import { handleRecordContentMessage, startRecordCapture } from "../record-capture";
|
||||
import type { RecordStepPayload } from "@/lib/record-bridge";
|
||||
import { startRecordCapture } from "../record-capture";
|
||||
|
||||
vi.stubGlobal("chrome", {
|
||||
runtime: {
|
||||
sendMessage: vi.fn(() => Promise.resolve()),
|
||||
sendMessage: vi.fn((message: { sequence?: number }) =>
|
||||
Promise.resolve({ ok: true, sequence: message.sequence }),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -46,58 +48,6 @@ function click(el: Element): void {
|
||||
el.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
|
||||
}
|
||||
|
||||
describe("handleRecordContentMessage stop/cancel", () => {
|
||||
it("ignores STOP when no recording is active", () => {
|
||||
const dispose = vi.fn();
|
||||
const onStop = vi.fn();
|
||||
const setActiveRequestId = vi.fn();
|
||||
const setCapture = vi.fn();
|
||||
const sendResponse = vi.fn();
|
||||
|
||||
const needsAsync = handleRecordContentMessage(
|
||||
{ type: RECORD_STOP, requestId: "rec-stale" },
|
||||
{
|
||||
activeRequestId: null,
|
||||
capture: { dispose },
|
||||
setActiveRequestId,
|
||||
setCapture,
|
||||
onStart: vi.fn(),
|
||||
onStop,
|
||||
},
|
||||
sendResponse,
|
||||
);
|
||||
|
||||
expect(needsAsync).toBe(false);
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
expect(onStop).not.toHaveBeenCalled();
|
||||
expect(setActiveRequestId).not.toHaveBeenCalled();
|
||||
expect(setCapture).not.toHaveBeenCalled();
|
||||
expect(sendResponse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores STOP for a mismatched requestId", () => {
|
||||
const dispose = vi.fn();
|
||||
const onStop = vi.fn();
|
||||
|
||||
const needsAsync = handleRecordContentMessage(
|
||||
{ type: RECORD_STOP, requestId: "rec-other" },
|
||||
{
|
||||
activeRequestId: "rec-1",
|
||||
capture: { dispose },
|
||||
setActiveRequestId: vi.fn(),
|
||||
setCapture: vi.fn(),
|
||||
onStart: vi.fn(),
|
||||
onStop,
|
||||
},
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(needsAsync).toBe(false);
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
expect(onStop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("record-capture semantic", () => {
|
||||
let steps: RecordStepPayload[];
|
||||
|
||||
@@ -174,6 +124,19 @@ describe("record-capture semantic", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not emit page navigation from a child Document capture", () => {
|
||||
const originalUrl = location.href;
|
||||
const capture = startRecordCapture("rec-child", (step) => steps.push(step), {
|
||||
captureNavigation: false,
|
||||
});
|
||||
|
||||
history.pushState({}, "", "#inside-frame");
|
||||
expect(steps).toEqual([]);
|
||||
|
||||
capture.dispose();
|
||||
history.replaceState({}, "", originalUrl);
|
||||
});
|
||||
|
||||
it("does not record clicks on anonymous layout divs", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="chrome">page chrome</div>
|
||||
@@ -228,6 +191,7 @@ describe("record-capture semantic", () => {
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { role: "button", name: "Open user navigation menu" },
|
||||
geometry: { tag: "button", rect: { x: 900, y: 8, w: 32, h: 32 } },
|
||||
});
|
||||
expect(steps[1]).toMatchObject({
|
||||
op: "click",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RecordFramePortMessage } from "@/lib/recording/frame-bridge";
|
||||
import { RECORD_FRAME_START } from "@/lib/recording/frame-bridge";
|
||||
import { RECORD_DOCUMENT_ATTRIBUTE } from "@/shared/recording-document-identity";
|
||||
import { RecordFrameAgent } from "../recording/frame-agent";
|
||||
|
||||
class PortListeners<T extends (...args: never[]) => unknown> {
|
||||
readonly values = new Set<T>();
|
||||
addListener = (listener: T) => this.values.add(listener);
|
||||
removeListener = (listener: T) => this.values.delete(listener);
|
||||
}
|
||||
|
||||
function portHarness() {
|
||||
const onMessage = new PortListeners<(message: unknown) => void>();
|
||||
const onDisconnect = new PortListeners<() => void>();
|
||||
const outbound: RecordFramePortMessage[] = [];
|
||||
const port = {
|
||||
name: "bsk-record-frame",
|
||||
onMessage,
|
||||
onDisconnect,
|
||||
postMessage(message: RecordFramePortMessage) {
|
||||
outbound.push(message);
|
||||
if (message.type === "ready") {
|
||||
queueMicrotask(() => {
|
||||
for (const listener of onMessage.values) {
|
||||
listener({
|
||||
type: "ready_ack",
|
||||
requestId: message.requestId,
|
||||
producerId: message.producerId,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
disconnect: vi.fn(),
|
||||
} as unknown as chrome.runtime.Port;
|
||||
return {
|
||||
port,
|
||||
outbound,
|
||||
receive(message: RecordFramePortMessage) {
|
||||
for (const listener of onMessage.values) listener(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("RecordFrameAgent", () => {
|
||||
afterEach(() => {
|
||||
document.documentElement.removeAttribute(RECORD_DOCUMENT_ATTRIBUTE);
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("keeps a failed stop retryable and flushes the final dirty fill", async () => {
|
||||
const harness = portHarness();
|
||||
const sendMessage = vi
|
||||
.fn<(message: { sequence: number }) => Promise<unknown>>()
|
||||
.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockRejectedValueOnce(new Error("still offline"))
|
||||
.mockImplementation(async (message) => ({ ok: true, sequence: message.sequence }));
|
||||
vi.stubGlobal("chrome", {
|
||||
runtime: {
|
||||
connect: vi.fn(() => harness.port),
|
||||
sendMessage,
|
||||
},
|
||||
});
|
||||
document.body.innerHTML = `<label for="draft">Draft</label><input id="draft" />`;
|
||||
const agent = new RecordFrameAgent();
|
||||
|
||||
await expect(
|
||||
agent.start({ type: RECORD_FRAME_START, requestId: "rec-1", startedAtMs: 10 }),
|
||||
).resolves.toEqual({ ok: true });
|
||||
expect(document.documentElement.hasAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe(true);
|
||||
|
||||
const input = document.querySelector("input")!;
|
||||
input.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
|
||||
input.value = "final value";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
|
||||
harness.receive({ type: "stop", requestId: "rec-1", commandId: "stop-1" });
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.outbound).toContainEqual({
|
||||
type: "stopped",
|
||||
requestId: "rec-1",
|
||||
commandId: "stop-1",
|
||||
ok: false,
|
||||
error: "failed to deliver one or more recorded steps",
|
||||
}),
|
||||
);
|
||||
expect(document.documentElement.hasAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe(true);
|
||||
|
||||
harness.receive({ type: "stop", requestId: "rec-1", commandId: "stop-2" });
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.outbound).toContainEqual({
|
||||
type: "stopped",
|
||||
requestId: "rec-1",
|
||||
commandId: "stop-2",
|
||||
ok: true,
|
||||
}),
|
||||
);
|
||||
expect(sendMessage).toHaveBeenCalledTimes(3);
|
||||
expect(document.documentElement.hasAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { RecordStepMessage } from "@/lib/record-bridge";
|
||||
import { RecordStepDelivery } from "../record-step-delivery";
|
||||
|
||||
describe("RecordStepDelivery", () => {
|
||||
it("retries from the first unacknowledged sequence before sending later steps", async () => {
|
||||
const sent: RecordStepMessage[] = [];
|
||||
const send = vi.fn(async (message: RecordStepMessage) => {
|
||||
sent.push(message);
|
||||
if (sent.length === 1) throw new Error("service worker unavailable");
|
||||
return { ok: true, sequence: message.sequence };
|
||||
});
|
||||
const delivery = new RecordStepDelivery("rec-ordered", send, "document-1");
|
||||
|
||||
delivery.enqueue({ op: "click", target: { tag: "button", name: "First" } });
|
||||
delivery.enqueue({ op: "click", target: { tag: "button", name: "Second" } });
|
||||
|
||||
await expect(delivery.flush()).resolves.toBe(true);
|
||||
expect(sent.map((message) => message.sequence)).toEqual([1, 1, 2]);
|
||||
expect(sent.map((message) => message.step.target?.name)).toEqual(["First", "First", "Second"]);
|
||||
});
|
||||
|
||||
it("keeps unacknowledged steps pending for a later flush", async () => {
|
||||
const send = vi
|
||||
.fn<(message: RecordStepMessage) => Promise<unknown>>()
|
||||
.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockRejectedValueOnce(new Error("still offline"))
|
||||
.mockImplementation(async (message) => ({ ok: true, sequence: message.sequence }));
|
||||
const delivery = new RecordStepDelivery("rec-retry", send, "document-1");
|
||||
delivery.enqueue({ op: "click", target: { tag: "button", name: "Save" } });
|
||||
|
||||
await expect(delivery.flush()).resolves.toBe(false);
|
||||
await expect(delivery.flush()).resolves.toBe(true);
|
||||
expect(send).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -12,21 +12,7 @@ import {
|
||||
hasDirectHoverInteractiveSignal,
|
||||
hasStrongHoverExpansionSignal,
|
||||
} from "@/lib/hover-trigger-policy";
|
||||
import {
|
||||
isRecordCancelMessage,
|
||||
isRecordStartMessage,
|
||||
isRecordStopMessage,
|
||||
RECORD_CANCEL,
|
||||
RECORD_START,
|
||||
RECORD_STEP,
|
||||
RECORD_STOP,
|
||||
type RecordCancelMessage,
|
||||
type RecordStartAck,
|
||||
type RecordStartMessage,
|
||||
type RecordStepPayload,
|
||||
type RecordStopAck,
|
||||
type RecordStopMessage,
|
||||
} from "@/lib/record-bridge";
|
||||
import type { RecordStepPayload } from "@/lib/record-bridge";
|
||||
import { shouldRecordPress } from "@/lib/recording/draft-policy";
|
||||
import {
|
||||
closestHoverSurfaceCandidate,
|
||||
@@ -37,28 +23,6 @@ import {
|
||||
isLikelyHoverSurfaceOwner,
|
||||
} from "./record-hover-surface";
|
||||
|
||||
const pendingStepSends = new Map<string, Set<Promise<boolean>>>();
|
||||
const failedStepDeliveries = new Set<string>();
|
||||
const knownRecordRequests = new Set<string>();
|
||||
|
||||
function sendRecordStep(requestId: string, step: RecordStepPayload): void {
|
||||
const payload = { type: RECORD_STEP, requestId, step };
|
||||
const pending = Promise.resolve(chrome.runtime.sendMessage(payload)).then(
|
||||
() => true,
|
||||
() => {
|
||||
failedStepDeliveries.add(requestId);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
const sends = pendingStepSends.get(requestId) ?? new Set<Promise<boolean>>();
|
||||
sends.add(pending);
|
||||
pendingStepSends.set(requestId, sends);
|
||||
void pending.then(() => {
|
||||
sends.delete(pending);
|
||||
if (sends.size === 0) pendingStepSends.delete(requestId);
|
||||
});
|
||||
}
|
||||
|
||||
export interface RecordCaptureController {
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -68,6 +32,7 @@ interface FillSession {
|
||||
target: CaptureTargetDescriptor;
|
||||
baselineValue: string;
|
||||
lastValue: string;
|
||||
pendingCommit?: "enter" | "suggestion" | "blur";
|
||||
}
|
||||
|
||||
interface HoverCandidate {
|
||||
@@ -157,6 +122,24 @@ function nearbyFillableFromSearchChrome(target: Element): FillableElement | null
|
||||
return null;
|
||||
}
|
||||
|
||||
function captureGeometry(el: Element): RecordStepPayload["geometry"] {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
rect: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
|
||||
tag: el.tagName.toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
function geometryForEventTarget(
|
||||
target: EventTarget | null,
|
||||
): RecordStepPayload["geometry"] | undefined {
|
||||
if (!(target instanceof Element)) return undefined;
|
||||
const clickable = target.closest(
|
||||
'a,button,input,select,textarea,[role="button"],[role="link"],[role="menuitem"],[contenteditable="true"]',
|
||||
);
|
||||
return captureGeometry(clickable ?? target);
|
||||
}
|
||||
|
||||
function fillableValue(el: FillableElement): string {
|
||||
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
||||
return el.value;
|
||||
@@ -376,6 +359,7 @@ function scheduleInputCompletionCommit(
|
||||
export function startRecordCapture(
|
||||
_requestId: string,
|
||||
sendStep: (step: RecordStepPayload) => void,
|
||||
options: { captureNavigation?: boolean } = {},
|
||||
): RecordCaptureController {
|
||||
const emitStep = (step: RecordStepPayload) => {
|
||||
sendStep({ page_url: location.href, ...step });
|
||||
@@ -414,14 +398,17 @@ export function startRecordCapture(
|
||||
emitStep({
|
||||
op: "fill",
|
||||
target: session.target,
|
||||
geometry: captureGeometry(session.element),
|
||||
value,
|
||||
commit: session.pendingCommit ?? "blur",
|
||||
...(isPassword ? { redacted: true } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
const commitFillSession = () => {
|
||||
const commitFillSession = (commit: "enter" | "suggestion" | "blur" = "blur") => {
|
||||
if (!fillSession || composing) return;
|
||||
const session = fillSession;
|
||||
session.pendingCommit = commit;
|
||||
fillSession = null;
|
||||
emitFill(session);
|
||||
committedValues.set(session.element, session.lastValue);
|
||||
@@ -627,6 +614,7 @@ export function startRecordCapture(
|
||||
emitStep({
|
||||
op: "hover",
|
||||
target: hover.target,
|
||||
geometry: captureGeometry(hover.element),
|
||||
});
|
||||
emittedHoverElements.add(hover.element);
|
||||
};
|
||||
@@ -681,6 +669,7 @@ export function startRecordCapture(
|
||||
emitStep({
|
||||
op: "click",
|
||||
target,
|
||||
geometry: geometryForEventTarget(eventTarget(event)),
|
||||
expects_navigation: true,
|
||||
});
|
||||
};
|
||||
@@ -782,7 +771,7 @@ export function startRecordCapture(
|
||||
scheduleInputCompletionCommit(
|
||||
sessionElement,
|
||||
syncFillSessionValue,
|
||||
commitFillSession,
|
||||
() => commitFillSession("suggestion"),
|
||||
(el) => fillSession?.element === el,
|
||||
);
|
||||
return;
|
||||
@@ -842,6 +831,7 @@ export function startRecordCapture(
|
||||
emitStep({
|
||||
op: "select",
|
||||
target: desc,
|
||||
geometry: captureGeometry(target),
|
||||
values,
|
||||
labels,
|
||||
expects_navigation: true,
|
||||
@@ -882,7 +872,7 @@ export function startRecordCapture(
|
||||
};
|
||||
}
|
||||
if (fillable) {
|
||||
commitFillSession();
|
||||
commitFillSession(event.key === "Enter" ? "enter" : "blur");
|
||||
}
|
||||
const desc = describeEventTarget(target);
|
||||
if (!desc && !event.key) return;
|
||||
@@ -890,7 +880,7 @@ export function startRecordCapture(
|
||||
emitStep({
|
||||
op: "press",
|
||||
key: event.key,
|
||||
...(desc ? { target: desc } : {}),
|
||||
...(desc ? { target: desc, geometry: geometryForEventTarget(target) } : {}),
|
||||
...(modifiers.length ? { modifiers } : {}),
|
||||
expects_navigation: event.key === "Enter",
|
||||
});
|
||||
@@ -906,23 +896,33 @@ export function startRecordCapture(
|
||||
document.addEventListener("change", onChange, true);
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
|
||||
const urlObserver = new MutationObserver(() => emitNavigateIfChanged());
|
||||
urlObserver.observe(document, { subtree: true, childList: true });
|
||||
const captureNavigation = options.captureNavigation ?? true;
|
||||
const urlObserver = captureNavigation
|
||||
? new MutationObserver(() => emitNavigateIfChanged())
|
||||
: null;
|
||||
urlObserver?.observe(document, { subtree: true, childList: true });
|
||||
const onUrlEvent = () => emitNavigateIfChanged();
|
||||
window.addEventListener("hashchange", onUrlEvent);
|
||||
window.addEventListener("popstate", onUrlEvent);
|
||||
window.addEventListener("pagehide", commitFillSession);
|
||||
// Wrap: passing commitFillSession directly would forward the DOM event as
|
||||
// the `commit` argument and stamp it onto the recorded fill step.
|
||||
const onPageHide = () => commitFillSession();
|
||||
if (captureNavigation) {
|
||||
window.addEventListener("hashchange", onUrlEvent);
|
||||
window.addEventListener("popstate", onUrlEvent);
|
||||
}
|
||||
window.addEventListener("pagehide", onPageHide);
|
||||
|
||||
const originalPushState = history.pushState;
|
||||
const originalReplaceState = history.replaceState;
|
||||
history.pushState = function (...args: Parameters<History["pushState"]>) {
|
||||
originalPushState.apply(this, args);
|
||||
emitNavigateIfChanged(navigationActionPending ? true : undefined);
|
||||
};
|
||||
history.replaceState = function (...args: Parameters<History["replaceState"]>) {
|
||||
originalReplaceState.apply(this, args);
|
||||
emitNavigateIfChanged(navigationActionPending ? true : undefined);
|
||||
};
|
||||
if (captureNavigation) {
|
||||
history.pushState = function (...args: Parameters<History["pushState"]>) {
|
||||
originalPushState.apply(this, args);
|
||||
emitNavigateIfChanged(navigationActionPending ? true : undefined);
|
||||
};
|
||||
history.replaceState = function (...args: Parameters<History["replaceState"]>) {
|
||||
originalReplaceState.apply(this, args);
|
||||
emitNavigateIfChanged(navigationActionPending ? true : undefined);
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
dispose() {
|
||||
@@ -936,93 +936,16 @@ export function startRecordCapture(
|
||||
document.removeEventListener("compositionend", onCompositionEnd, true);
|
||||
document.removeEventListener("change", onChange, true);
|
||||
document.removeEventListener("keydown", onKeyDown, true);
|
||||
urlObserver.disconnect();
|
||||
window.removeEventListener("hashchange", onUrlEvent);
|
||||
window.removeEventListener("popstate", onUrlEvent);
|
||||
window.removeEventListener("pagehide", commitFillSession);
|
||||
history.pushState = originalPushState;
|
||||
history.replaceState = originalReplaceState;
|
||||
urlObserver?.disconnect();
|
||||
if (captureNavigation) {
|
||||
window.removeEventListener("hashchange", onUrlEvent);
|
||||
window.removeEventListener("popstate", onUrlEvent);
|
||||
}
|
||||
window.removeEventListener("pagehide", onPageHide);
|
||||
if (captureNavigation) {
|
||||
history.pushState = originalPushState;
|
||||
history.replaceState = originalReplaceState;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type RecordContentMessage = RecordStartMessage | RecordStopMessage | RecordCancelMessage;
|
||||
|
||||
export function isRecordContentMessage(msg: unknown): msg is RecordContentMessage {
|
||||
return isRecordStartMessage(msg) || isRecordStopMessage(msg) || isRecordCancelMessage(msg);
|
||||
}
|
||||
|
||||
export function handleRecordContentMessage(
|
||||
message: RecordContentMessage,
|
||||
state: {
|
||||
activeRequestId: string | null;
|
||||
capture: RecordCaptureController | null;
|
||||
setActiveRequestId(id: string | null): void;
|
||||
setCapture(capture: RecordCaptureController | null): void;
|
||||
onStart(requestId: string, startedAtMs?: number): void;
|
||||
onStop(): void;
|
||||
},
|
||||
sendResponse?: (response: RecordStartAck | RecordStopAck) => void,
|
||||
): boolean {
|
||||
if (isRecordStartMessage(message)) {
|
||||
if (!knownRecordRequests.has(message.requestId)) {
|
||||
knownRecordRequests.add(message.requestId);
|
||||
failedStepDeliveries.delete(message.requestId);
|
||||
}
|
||||
state.capture?.dispose();
|
||||
state.setCapture(
|
||||
startRecordCapture(message.requestId, (step) => {
|
||||
sendRecordStep(message.requestId, step);
|
||||
}),
|
||||
);
|
||||
state.setActiveRequestId(message.requestId);
|
||||
state.onStart(message.requestId, message.startedAtMs);
|
||||
sendResponse?.({ ok: true });
|
||||
return sendResponse !== undefined;
|
||||
}
|
||||
|
||||
if (isRecordStopMessage(message) || isRecordCancelMessage(message)) {
|
||||
// Require an active recording that matches this requestId — otherwise a
|
||||
// stray STOP/CANCEL (e.g. after teardown) would still run finishStop and
|
||||
// clear overlay state even though nothing was capturing.
|
||||
if (!state.activeRequestId || state.activeRequestId !== message.requestId) {
|
||||
return false;
|
||||
}
|
||||
state.capture?.dispose();
|
||||
state.setCapture(null);
|
||||
const finishStop = () => {
|
||||
state.onStop();
|
||||
state.setActiveRequestId(null);
|
||||
};
|
||||
if (isRecordStopMessage(message) && sendResponse) {
|
||||
const pending = [...(pendingStepSends.get(message.requestId) ?? [])];
|
||||
void Promise.all(pending).then((delivered) => {
|
||||
finishStop();
|
||||
const succeeded = delivered.every(Boolean) && !failedStepDeliveries.has(message.requestId);
|
||||
if (succeeded) {
|
||||
failedStepDeliveries.delete(message.requestId);
|
||||
knownRecordRequests.delete(message.requestId);
|
||||
}
|
||||
sendResponse(
|
||||
succeeded
|
||||
? { ok: true }
|
||||
: {
|
||||
ok: false,
|
||||
error: "failed to deliver one or more recorded steps",
|
||||
},
|
||||
);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (isRecordCancelMessage(message)) {
|
||||
failedStepDeliveries.delete(message.requestId);
|
||||
knownRecordRequests.delete(message.requestId);
|
||||
}
|
||||
finishStop();
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export { RECORD_CANCEL, RECORD_START, RECORD_STEP, RECORD_STOP };
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
isAcceptedRecordStepAck,
|
||||
RECORD_STEP,
|
||||
type RecordStepMessage,
|
||||
type RecordStepPayload,
|
||||
} from "@/lib/record-bridge";
|
||||
|
||||
export type SendRecordStepMessage = (message: RecordStepMessage) => Promise<unknown>;
|
||||
|
||||
export class RecordStepDelivery {
|
||||
readonly #requestId: string;
|
||||
readonly #producerId: string;
|
||||
readonly #send: SendRecordStepMessage;
|
||||
readonly #pending = new Map<number, RecordStepPayload>();
|
||||
#nextSequence = 1;
|
||||
#tail = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
requestId: string,
|
||||
send: SendRecordStepMessage = (message) => chrome.runtime.sendMessage(message),
|
||||
producerId: string = crypto.randomUUID(),
|
||||
) {
|
||||
this.#requestId = requestId;
|
||||
this.#producerId = producerId;
|
||||
this.#send = send;
|
||||
}
|
||||
|
||||
enqueue(step: RecordStepPayload): void {
|
||||
const sequence = this.#nextSequence;
|
||||
this.#nextSequence += 1;
|
||||
this.#pending.set(sequence, step);
|
||||
void this.#schedule();
|
||||
}
|
||||
|
||||
async flush(): Promise<boolean> {
|
||||
await this.#tail;
|
||||
await this.#schedule();
|
||||
return this.#pending.size === 0;
|
||||
}
|
||||
|
||||
#schedule(): Promise<void> {
|
||||
const drain = () => this.#drain();
|
||||
this.#tail = this.#tail.then(drain, drain);
|
||||
return this.#tail;
|
||||
}
|
||||
|
||||
async #drain(): Promise<void> {
|
||||
for (const [sequence, step] of this.#pending) {
|
||||
const message: RecordStepMessage = {
|
||||
type: RECORD_STEP,
|
||||
requestId: this.#requestId,
|
||||
producerId: this.#producerId,
|
||||
sequence,
|
||||
step,
|
||||
};
|
||||
try {
|
||||
const response = await this.#send(message);
|
||||
if (!isAcceptedRecordStepAck(response, sequence)) return;
|
||||
this.#pending.delete(sequence);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
markRecordingDocument,
|
||||
type RecordingDocumentMarker,
|
||||
waitForDocumentElement,
|
||||
} from "@/lib/recording/document-marker";
|
||||
import {
|
||||
isRecordFrameStartMessage,
|
||||
RECORD_FRAME_PORT,
|
||||
RECORD_FRAME_QUERY,
|
||||
RECORD_FRAME_START,
|
||||
type RecordFramePortMessage,
|
||||
type RecordFrameQueryResponse,
|
||||
type RecordFrameStartMessage,
|
||||
} from "@/lib/recording/frame-bridge";
|
||||
import { type RecordCaptureController, startRecordCapture } from "../record-capture";
|
||||
import { RecordStepDelivery } from "../record-step-delivery";
|
||||
|
||||
interface ActiveFrameRecording {
|
||||
requestId: string;
|
||||
producerId: string;
|
||||
port: chrome.runtime.Port;
|
||||
delivery: RecordStepDelivery;
|
||||
capture: RecordCaptureController;
|
||||
marker: RecordingDocumentMarker;
|
||||
}
|
||||
|
||||
const RECORD_FRAME_REGISTER_TIMEOUT_MS = 5_000;
|
||||
|
||||
export class RecordFrameAgent {
|
||||
#active: ActiveFrameRecording | null = null;
|
||||
#startPromise: Promise<{ ok: true } | { ok: false; error: string }> | null = null;
|
||||
#disposed = false;
|
||||
|
||||
async start(
|
||||
message: RecordFrameStartMessage,
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
if (this.#active?.requestId === message.requestId) return { ok: true };
|
||||
if (this.#disposed) return { ok: false, error: "recording document is disposed" };
|
||||
if (this.#startPromise) {
|
||||
await this.#startPromise;
|
||||
return this.start(message);
|
||||
}
|
||||
if (this.#active) this.cancel(this.#active.requestId);
|
||||
const starting = this.#start(message);
|
||||
this.#startPromise = starting;
|
||||
try {
|
||||
return await starting;
|
||||
} finally {
|
||||
if (this.#startPromise === starting) this.#startPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
cancel(requestId: string): void {
|
||||
const active = this.#active;
|
||||
if (!active || active.requestId !== requestId) return;
|
||||
active.capture.dispose();
|
||||
active.marker.restore();
|
||||
active.port.disconnect();
|
||||
this.#active = null;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.#disposed = true;
|
||||
if (this.#active) this.cancel(this.#active.requestId);
|
||||
}
|
||||
|
||||
async #start(
|
||||
message: RecordFrameStartMessage,
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const producerId = crypto.randomUUID();
|
||||
const root = await waitForDocumentElement();
|
||||
if (this.#disposed) return { ok: false, error: "recording document is disposed" };
|
||||
const marker = markRecordingDocument(producerId, root);
|
||||
const port = chrome.runtime.connect({ name: RECORD_FRAME_PORT });
|
||||
try {
|
||||
await registerPort(port, message.requestId, producerId);
|
||||
} catch {
|
||||
marker.restore();
|
||||
port.disconnect();
|
||||
return { ok: false, error: "failed to register recording document" };
|
||||
}
|
||||
if (this.#disposed) {
|
||||
marker.restore();
|
||||
port.disconnect();
|
||||
return { ok: false, error: "recording document is disposed" };
|
||||
}
|
||||
|
||||
const delivery = new RecordStepDelivery(message.requestId, undefined, producerId);
|
||||
const capture = startRecordCapture(
|
||||
message.requestId,
|
||||
(step) => {
|
||||
marker.ensure();
|
||||
delivery.enqueue(step);
|
||||
},
|
||||
{ captureNavigation: window.top === window },
|
||||
);
|
||||
const active: ActiveFrameRecording = {
|
||||
requestId: message.requestId,
|
||||
producerId,
|
||||
port,
|
||||
delivery,
|
||||
capture,
|
||||
marker,
|
||||
};
|
||||
this.#active = active;
|
||||
port.onMessage.addListener((raw: unknown) => {
|
||||
const command = raw as Partial<RecordFramePortMessage>;
|
||||
if (command.type === "stop" && command.requestId === active.requestId) {
|
||||
void this.#stop(active, command.commandId);
|
||||
} else if (command.type === "cancel" && command.requestId === active.requestId) {
|
||||
this.cancel(active.requestId);
|
||||
}
|
||||
});
|
||||
port.onDisconnect.addListener(() => {
|
||||
if (this.#active !== active) return;
|
||||
active.capture.dispose();
|
||||
active.marker.restore();
|
||||
this.#active = null;
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async #stop(active: ActiveFrameRecording, commandId: string | undefined): Promise<void> {
|
||||
if (!commandId || this.#active !== active) return;
|
||||
active.capture.dispose();
|
||||
const succeeded = await active.delivery.flush();
|
||||
try {
|
||||
active.port.postMessage({
|
||||
type: "stopped",
|
||||
requestId: active.requestId,
|
||||
commandId,
|
||||
ok: succeeded,
|
||||
...(succeeded ? {} : { error: "failed to deliver one or more recorded steps" }),
|
||||
} satisfies RecordFramePortMessage);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!succeeded || this.#active !== active) return;
|
||||
active.marker.restore();
|
||||
this.#active = null;
|
||||
active.port.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function registerPort(
|
||||
port: chrome.runtime.Port,
|
||||
requestId: string,
|
||||
producerId: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("recording document registration timed out"));
|
||||
}, RECORD_FRAME_REGISTER_TIMEOUT_MS);
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
port.onMessage.removeListener(onMessage);
|
||||
port.onDisconnect.removeListener(onDisconnect);
|
||||
};
|
||||
const onMessage = (raw: unknown) => {
|
||||
const message = raw as Partial<RecordFramePortMessage>;
|
||||
if (
|
||||
message.type !== "ready_ack" ||
|
||||
message.requestId !== requestId ||
|
||||
message.producerId !== producerId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onDisconnect = () => {
|
||||
cleanup();
|
||||
reject(new Error("recording document port disconnected"));
|
||||
};
|
||||
port.onMessage.addListener(onMessage);
|
||||
port.onDisconnect.addListener(onDisconnect);
|
||||
port.postMessage({ type: "ready", requestId, producerId } satisfies RecordFramePortMessage);
|
||||
});
|
||||
}
|
||||
|
||||
export function attachRecordFrameAgent(): () => void {
|
||||
const agent = new RecordFrameAgent();
|
||||
const onMessage = (
|
||||
message: unknown,
|
||||
_sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: { ok: true } | { ok: false; error: string }) => void,
|
||||
) => {
|
||||
if (!isRecordFrameStartMessage(message)) return false;
|
||||
void agent.start(message).then(sendResponse);
|
||||
return true;
|
||||
};
|
||||
chrome.runtime.onMessage.addListener(onMessage);
|
||||
void chrome.runtime
|
||||
.sendMessage({ type: RECORD_FRAME_QUERY })
|
||||
.then((response: RecordFrameQueryResponse | undefined) => {
|
||||
if (!response?.active || !response.requestId || response.startedAtMs === undefined) return;
|
||||
return agent.start({
|
||||
type: RECORD_FRAME_START,
|
||||
requestId: response.requestId,
|
||||
startedAtMs: response.startedAtMs,
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
chrome.runtime.onMessage.removeListener(onMessage);
|
||||
agent.dispose();
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type OverlayMode,
|
||||
} from "@/lib/overlay-bridge";
|
||||
import { POPUP_PORT_NAME, type PopupInbound, type PopupOutbound } from "@/lib/popup-bridge";
|
||||
import { recordFrameCoordinator } from "@/lib/recording/frame-coordinator";
|
||||
import { attachSessionsLiveFlag } from "@/lib/sessions-live-flag";
|
||||
import { createDisconnectCleanup } from "@/session-manager/disconnect-cleanup";
|
||||
import { attachSessionEventHandler } from "@/session-manager/event-handler";
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
attachRecordFinishListener,
|
||||
attachRecordQueryListener,
|
||||
attachRecordStepListener,
|
||||
type RecordRuntimeDeps,
|
||||
} from "@/tools/record";
|
||||
import { detectBrowserMeta } from "@/transport/handshake";
|
||||
import type { Transport } from "@/transport/transport";
|
||||
@@ -145,10 +147,33 @@ export default defineBackground(() => {
|
||||
void sessionsLive.syncFromManager();
|
||||
},
|
||||
});
|
||||
const recordDeps = {
|
||||
tabsApi: chrome.tabs,
|
||||
cdp,
|
||||
frameCoordinator: recordFrameCoordinator,
|
||||
sendToTab: (tabId: number, msg: Parameters<typeof chrome.tabs.sendMessage>[1]) =>
|
||||
chrome.tabs.sendMessage(tabId, msg),
|
||||
bypassOverlay: async (tabId: number, enabled: boolean) => {
|
||||
try {
|
||||
await chrome.tabs.sendMessage(tabId, {
|
||||
type: OVERLAY_AUTOMATION_BYPASS,
|
||||
enabled,
|
||||
});
|
||||
} catch {
|
||||
// Content script may be unavailable on restricted pages.
|
||||
}
|
||||
},
|
||||
} satisfies RecordRuntimeDeps;
|
||||
recordFrameCoordinator.attach();
|
||||
attachRecordStepListener(recordDeps);
|
||||
attachRecordFinishListener(recordDeps);
|
||||
attachRecordQueryListener(recordDeps);
|
||||
|
||||
const dispatcher = new ToolDispatcher({
|
||||
transport,
|
||||
sessions,
|
||||
cdp,
|
||||
recording: recordDeps,
|
||||
onSessionsChanged: onOverlaySessionStateChanged,
|
||||
onBrowserControlResumed,
|
||||
approveBorrow: (ctx) =>
|
||||
@@ -170,27 +195,6 @@ export default defineBackground(() => {
|
||||
}),
|
||||
});
|
||||
dispatcher.start();
|
||||
const recordDeps = {
|
||||
tabsApi: chrome.tabs,
|
||||
sendToTab: (tabId: number, msg: Parameters<typeof chrome.tabs.sendMessage>[1]) =>
|
||||
chrome.tabs.sendMessage(tabId, msg),
|
||||
bypassOverlay: async (tabId: number, enabled: boolean) => {
|
||||
try {
|
||||
await chrome.tabs.sendMessage(tabId, {
|
||||
type: OVERLAY_AUTOMATION_BYPASS,
|
||||
enabled,
|
||||
});
|
||||
} catch {
|
||||
// Content script may be unavailable on restricted pages.
|
||||
}
|
||||
},
|
||||
};
|
||||
// Message listeners stay up (cheap; fire only for record message types).
|
||||
// Tab / webNavigation observation attaches lazily while a recording is
|
||||
// active — see ensureBrowserObservationListeners in tools/record.ts.
|
||||
attachRecordStepListener();
|
||||
attachRecordFinishListener(recordDeps);
|
||||
attachRecordQueryListener(recordDeps);
|
||||
if (typeof chrome.notifications?.onClicked?.addListener === "function") {
|
||||
attachBorrowNotificationClickHandler({
|
||||
onClicked: chrome.notifications.onClicked,
|
||||
|
||||
@@ -11,11 +11,6 @@ import { createHelpRequestData } from "@/content/help-request";
|
||||
import overlayCss from "@/content/overlay.css?inline";
|
||||
import { OverlayController, shouldShowAgentControlOverlay } from "@/content/overlay-controller";
|
||||
import { RecordOverlay } from "@/content/RecordOverlay";
|
||||
import {
|
||||
handleRecordContentMessage,
|
||||
isRecordContentMessage,
|
||||
type RecordCaptureController,
|
||||
} from "@/content/record-capture";
|
||||
import {
|
||||
type CaptureSuppressAck,
|
||||
type CaptureSuppressMessage,
|
||||
@@ -45,11 +40,17 @@ import {
|
||||
} from "@/lib/overlay-bridge";
|
||||
import { sendInterrupt } from "@/lib/overlay-interrupt-client";
|
||||
import {
|
||||
isRecordCancelMessage,
|
||||
isRecordStartMessage,
|
||||
isRecordStopMessage,
|
||||
RECORD_FINISH,
|
||||
RECORD_QUERY,
|
||||
type RecordCancelMessage,
|
||||
type RecordQueryResponse,
|
||||
type RecordStartAck,
|
||||
type RecordStartMessage,
|
||||
type RecordStopAck,
|
||||
type RecordStopMessage,
|
||||
} from "@/lib/record-bridge";
|
||||
import type {
|
||||
BorrowCancelMessage,
|
||||
@@ -69,7 +70,6 @@ export default defineContentScript({
|
||||
if (window.top !== window) return;
|
||||
|
||||
const overlays = new OverlayController();
|
||||
let recordCapture: RecordCaptureController | null = null;
|
||||
let activeRecordRequestId: string | null = null;
|
||||
let reactRoot: ReactDOM.Root | null = null;
|
||||
let overlayHost: HTMLElement | null = null;
|
||||
@@ -228,8 +228,6 @@ export default defineContentScript({
|
||||
if (previousHelp) {
|
||||
void sendHelpFinish(previousHelp.id, "cancelled");
|
||||
}
|
||||
recordCapture?.dispose();
|
||||
recordCapture = null;
|
||||
activeRecordRequestId = null;
|
||||
renderAll();
|
||||
}
|
||||
@@ -259,6 +257,9 @@ export default defineContentScript({
|
||||
| HelpRequestMessage
|
||||
| HelpCancelMessage
|
||||
| CaptureSuppressMessage
|
||||
| RecordStartMessage
|
||||
| RecordStopMessage
|
||||
| RecordCancelMessage
|
||||
| OverlayAgentOverlayResetMessage
|
||||
| OverlayAgentStateMessage
|
||||
| OverlayAutomationBypassMessage,
|
||||
@@ -269,41 +270,32 @@ export default defineContentScript({
|
||||
return captureSuppress.handleMessage(message, sendResponse);
|
||||
}
|
||||
|
||||
if (isRecordContentMessage(message)) {
|
||||
const needsAsync = handleRecordContentMessage(
|
||||
message,
|
||||
{
|
||||
activeRequestId: activeRecordRequestId,
|
||||
capture: recordCapture,
|
||||
setActiveRequestId: (id) => {
|
||||
activeRecordRequestId = id;
|
||||
},
|
||||
setCapture: (capture) => {
|
||||
recordCapture = capture;
|
||||
},
|
||||
onStart: (requestId, startedAtMs) => {
|
||||
overlays.setAgentRecordRequest({
|
||||
id: requestId,
|
||||
...(typeof startedAtMs === "number" ? { startedAtMs } : {}),
|
||||
onFinish: () => {
|
||||
void chrome.runtime.sendMessage({
|
||||
type: RECORD_FINISH,
|
||||
requestId,
|
||||
});
|
||||
},
|
||||
});
|
||||
renderAll();
|
||||
},
|
||||
onStop: () => {
|
||||
overlays.clearAgentRecordRequest(activeRecordRequestId ?? undefined);
|
||||
renderAll();
|
||||
},
|
||||
if (isRecordStartMessage(message)) {
|
||||
activeRecordRequestId = message.requestId;
|
||||
overlays.setAgentRecordRequest({
|
||||
id: message.requestId,
|
||||
...(typeof message.startedAtMs === "number" ? { startedAtMs: message.startedAtMs } : {}),
|
||||
onFinish: () => {
|
||||
void chrome.runtime.sendMessage({
|
||||
type: RECORD_FINISH,
|
||||
requestId: message.requestId,
|
||||
});
|
||||
},
|
||||
sendResponse as unknown as
|
||||
| ((response: RecordStartAck | RecordStopAck) => void)
|
||||
| undefined,
|
||||
);
|
||||
return needsAsync;
|
||||
});
|
||||
renderAll();
|
||||
(sendResponse as unknown as (response: RecordStartAck) => void)({ ok: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isRecordStopMessage(message) || isRecordCancelMessage(message)) {
|
||||
if (activeRecordRequestId !== message.requestId) return false;
|
||||
overlays.clearAgentRecordRequest(message.requestId);
|
||||
activeRecordRequestId = null;
|
||||
renderAll();
|
||||
if (isRecordStopMessage(message)) {
|
||||
(sendResponse as unknown as (response: RecordStopAck) => void)({ ok: true });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -526,9 +518,6 @@ export default defineContentScript({
|
||||
chrome.runtime.onMessage.removeListener(onMessage);
|
||||
chrome.storage.onChanged.removeListener(onStorageChange);
|
||||
window.removeEventListener("pageshow", onPageShow);
|
||||
// Restore history hooks / remove capture listeners before the CS unloads.
|
||||
recordCapture?.dispose();
|
||||
recordCapture = null;
|
||||
activeRecordRequestId = null;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -200,6 +200,9 @@ describe("App", () => {
|
||||
expect(copied).toContain("--browser 03c3e47f");
|
||||
expect(copied).toContain('--purpose "发布 wiki 文档"');
|
||||
expect(copied).not.toMatch(/bsk record start[^\n]*--url/);
|
||||
expect(copied).toContain("./trace");
|
||||
expect(copied).toContain("trace.json");
|
||||
expect(copied).toContain("states/");
|
||||
// Button label stays static; a transient toast confirms the copy.
|
||||
expect(copyButton.textContent).toContain("复制录制指令");
|
||||
await waitFor(() => expect(screen.getByRole("status").textContent).toContain("已复制"));
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { attachRecordFrameAgent } from "@/content/recording/frame-agent";
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ["<all_urls>"],
|
||||
runAt: "document_start",
|
||||
allFrames: true,
|
||||
matchAboutBlank: true,
|
||||
matchOriginAsFallback: true,
|
||||
|
||||
main(ctx) {
|
||||
const dispose = attachRecordFrameAgent();
|
||||
ctx.onInvalidated(dispose);
|
||||
},
|
||||
});
|
||||
@@ -26,20 +26,20 @@ function handshake(
|
||||
}
|
||||
|
||||
describe("computeConnectedState (protocol-based compat)", () => {
|
||||
it("returns connected when protocol strings match", () => {
|
||||
expect(computeConnectedState(handshake("1.0", "1.0"), MIN_COMPATIBLE_PROTOCOL)).toEqual({
|
||||
it("returns connected when daemon protocol equals extension protocol", () => {
|
||||
expect(computeConnectedState(handshake("1.1", "1.0"), MIN_COMPATIBLE_PROTOCOL)).toEqual({
|
||||
kind: "connected",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns version_skew when daemon protocol minor is newer", () => {
|
||||
expect(computeConnectedState(handshake("1.1", "1.0"))).toEqual({
|
||||
expect(computeConnectedState(handshake("1.2", "1.0"))).toEqual({
|
||||
kind: "version_skew",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns version_skew when daemon protocol string differs but floor is satisfied", () => {
|
||||
expect(computeConnectedState(handshake("1", "1.0"))).toEqual({
|
||||
expect(computeConnectedState(handshake("1.1.0", "1.0"))).toEqual({
|
||||
kind: "version_skew",
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,7 @@ describe("computeConnectedState (protocol-based compat)", () => {
|
||||
});
|
||||
|
||||
it("rejects when extension is below daemon min_compatible_protocol", () => {
|
||||
const result = computeConnectedState(handshake("1.0", "1.5"));
|
||||
const result = computeConnectedState(handshake("1.1", "1.5"));
|
||||
expect(result.kind).toBe("rejected");
|
||||
if (result.kind === "rejected") {
|
||||
expect(result.reason).toContain("min_compatible_protocol");
|
||||
@@ -65,7 +65,7 @@ describe("computeConnectedState (protocol-based compat)", () => {
|
||||
const result = computeConnectedState({
|
||||
server: "browser-skill-daemon",
|
||||
version: "0.1.0",
|
||||
protocol_version: "1.0",
|
||||
protocol_version: "1.1",
|
||||
min_compatible_peer: "0.1.0",
|
||||
});
|
||||
expect(result).toEqual({ kind: "connected" });
|
||||
@@ -80,7 +80,7 @@ describe("computeConnectedState (protocol-based compat)", () => {
|
||||
});
|
||||
|
||||
it("rejects malformed daemon min_compatible_protocol with a daemon-floor reason", () => {
|
||||
const result = computeConnectedState(handshake("1.0", "not-a-protocol"));
|
||||
const result = computeConnectedState(handshake("1.1", "not-a-protocol"));
|
||||
expect(result.kind).toBe("rejected");
|
||||
if (result.kind === "rejected") {
|
||||
expect(result.reason).toContain("daemon min_compatible_protocol");
|
||||
@@ -242,11 +242,11 @@ describe("ConnectionController connectionEnabled", () => {
|
||||
const second = transport.send.mock.calls[1]?.[0] as { id: string };
|
||||
expect(second.id).not.toBe(first.id);
|
||||
|
||||
transport.emitMessage({ id: first.id, result: handshake("1.0", "1.0") });
|
||||
transport.emitMessage({ id: first.id, result: handshake("1.1", "1.0") });
|
||||
await Promise.resolve();
|
||||
expect(controller.snapshot().state).not.toBe("connected");
|
||||
|
||||
transport.emitMessage({ id: second.id, result: handshake("1.0", "1.0") });
|
||||
transport.emitMessage({ id: second.id, result: handshake("1.1", "1.0") });
|
||||
await vi.waitFor(() => expect(controller.snapshot().state).toBe("connected"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isAcceptedRecordStepAck,
|
||||
isRecordStepMessage,
|
||||
RECORD_STEP,
|
||||
type RecordStepMessage,
|
||||
} from "../record-bridge";
|
||||
|
||||
const message: RecordStepMessage = {
|
||||
type: RECORD_STEP,
|
||||
requestId: "rec-1",
|
||||
producerId: "document-1",
|
||||
sequence: 1,
|
||||
step: { op: "click", target: { tag: "button", name: "Submit" } },
|
||||
};
|
||||
|
||||
describe("record step delivery protocol", () => {
|
||||
it("requires a producer and a positive integer sequence", () => {
|
||||
expect(isRecordStepMessage(message)).toBe(true);
|
||||
expect(isRecordStepMessage({ ...message, producerId: "" })).toBe(false);
|
||||
expect(isRecordStepMessage({ ...message, sequence: 0 })).toBe(false);
|
||||
expect(isRecordStepMessage({ ...message, sequence: 1.5 })).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts only the acknowledgement for the sent sequence", () => {
|
||||
expect(isAcceptedRecordStepAck({ ok: true, sequence: 2 }, 2)).toBe(true);
|
||||
expect(isAcceptedRecordStepAck({ ok: true, sequence: 1 }, 2)).toBe(false);
|
||||
expect(isAcceptedRecordStepAck({ ok: false, expectedSequence: 2, error: "gap" }, 2)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RECORD_FRAME_PORT, RECORD_FRAME_QUERY } from "../recording/frame-bridge";
|
||||
import { RecordFrameCoordinator } from "../recording/frame-coordinator";
|
||||
|
||||
class ListenerSet<T extends (...args: never[]) => unknown> {
|
||||
readonly listeners = new Set<T>();
|
||||
addListener = (listener: T) => this.listeners.add(listener);
|
||||
removeListener = (listener: T) => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
function fakePort(sender: chrome.runtime.MessageSender) {
|
||||
const messages: unknown[] = [];
|
||||
const onMessage = new ListenerSet<(message: unknown) => void>();
|
||||
const onDisconnect = new ListenerSet<() => void>();
|
||||
return {
|
||||
port: {
|
||||
name: RECORD_FRAME_PORT,
|
||||
sender,
|
||||
onMessage,
|
||||
onDisconnect,
|
||||
postMessage: vi.fn((message: unknown) => messages.push(message)),
|
||||
disconnect: vi.fn(),
|
||||
} as unknown as chrome.runtime.Port,
|
||||
messages,
|
||||
receive(message: unknown) {
|
||||
for (const listener of onMessage.listeners) listener(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("RecordFrameCoordinator", () => {
|
||||
const onConnect = new ListenerSet<(port: chrome.runtime.Port) => void>();
|
||||
const onMessage = new ListenerSet<
|
||||
(
|
||||
message: unknown,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: unknown) => void,
|
||||
) => boolean
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
onConnect.listeners.clear();
|
||||
onMessage.listeners.clear();
|
||||
vi.stubGlobal("chrome", {
|
||||
runtime: { onConnect, onMessage },
|
||||
});
|
||||
});
|
||||
|
||||
it("answers child-document queries for the initial tab before it is armed", () => {
|
||||
const coordinator = new RecordFrameCoordinator({
|
||||
getAllFrames: async () => [],
|
||||
sendToDocument: async () => ({ ok: true }),
|
||||
});
|
||||
coordinator.attach();
|
||||
coordinator.begin("rec-1", 10, 3);
|
||||
|
||||
const query = (tabId: number) => {
|
||||
let response: unknown;
|
||||
for (const listener of onMessage.listeners) {
|
||||
listener(
|
||||
{ type: RECORD_FRAME_QUERY },
|
||||
{
|
||||
tab: { id: tabId },
|
||||
frameId: 7,
|
||||
documentId: "child-document",
|
||||
} as chrome.runtime.MessageSender,
|
||||
(value) => {
|
||||
response = value;
|
||||
},
|
||||
);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
expect(query(3)).toEqual({ active: true, requestId: "rec-1", startedAtMs: 10 });
|
||||
expect(query(4)).toEqual({ active: false });
|
||||
});
|
||||
|
||||
it("starts a child Document that appears after the initial frame snapshot", async () => {
|
||||
let notifyFrameNavigation:
|
||||
| ((frame: {
|
||||
tabId: number;
|
||||
frameId: number;
|
||||
documentId?: string;
|
||||
lifecycle: "committed" | "completed";
|
||||
}) => void)
|
||||
| undefined;
|
||||
const sendToDocument = vi.fn(async () => ({ ok: true }));
|
||||
const coordinator = new RecordFrameCoordinator({
|
||||
getAllFrames: async () => [{ frameId: 0, documentId: "top-document" }],
|
||||
sendToDocument,
|
||||
subscribeFrameNavigation(listener) {
|
||||
notifyFrameNavigation = listener;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
coordinator.attach();
|
||||
const onDocumentReady = vi.fn();
|
||||
coordinator.begin("rec-1", 10, 3, onDocumentReady);
|
||||
await coordinator.armTab("rec-1", 3);
|
||||
sendToDocument.mockClear();
|
||||
|
||||
notifyFrameNavigation?.({
|
||||
tabId: 3,
|
||||
frameId: 7,
|
||||
documentId: "late-child",
|
||||
lifecycle: "completed",
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(sendToDocument).toHaveBeenCalledWith(
|
||||
3,
|
||||
{ type: "bsk-record-frame-start", requestId: "rec-1", startedAtMs: 10 },
|
||||
{ documentId: "late-child" },
|
||||
);
|
||||
});
|
||||
expect(onDocumentReady).not.toHaveBeenCalled();
|
||||
|
||||
const child = fakePort({
|
||||
tab: { id: 3 },
|
||||
frameId: 7,
|
||||
documentId: "late-child",
|
||||
} as chrome.runtime.MessageSender);
|
||||
for (const listener of onConnect.listeners) listener(child.port);
|
||||
child.receive({ type: "ready", requestId: "rec-1", producerId: "producer-1" });
|
||||
notifyFrameNavigation?.({
|
||||
tabId: 3,
|
||||
frameId: 7,
|
||||
documentId: "late-child",
|
||||
lifecycle: "completed",
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(onDocumentReady).toHaveBeenCalledWith({
|
||||
tabId: 3,
|
||||
documentId: "late-child",
|
||||
browserFrameId: 7,
|
||||
producerId: "producer-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("binds a producer to its sender Document and keeps final steps valid while stopping", async () => {
|
||||
const sendToDocument = vi.fn(async () => ({ ok: true }));
|
||||
const coordinator = new RecordFrameCoordinator({
|
||||
getAllFrames: async () => [
|
||||
{ frameId: 0, documentId: "top-document" },
|
||||
{ frameId: 7, documentId: "child-document" },
|
||||
],
|
||||
sendToDocument,
|
||||
});
|
||||
coordinator.attach();
|
||||
coordinator.begin("rec-1", 10, 3);
|
||||
await expect(coordinator.armTab("rec-1", 3)).resolves.toBe(true);
|
||||
|
||||
const sender = {
|
||||
tab: { id: 3 },
|
||||
frameId: 7,
|
||||
documentId: "child-document",
|
||||
} as chrome.runtime.MessageSender;
|
||||
const frame = fakePort(sender);
|
||||
for (const listener of onConnect.listeners) listener(frame.port);
|
||||
frame.receive({ type: "ready", requestId: "rec-1", producerId: "producer-1" });
|
||||
|
||||
expect(coordinator.sourceFor("rec-1", "producer-1", sender)).toEqual({
|
||||
tabId: 3,
|
||||
documentId: "child-document",
|
||||
browserFrameId: 7,
|
||||
producerId: "producer-1",
|
||||
});
|
||||
|
||||
const stopping = coordinator.stop("rec-1");
|
||||
expect(coordinator.sourceFor("rec-1", "producer-1", sender)).not.toBeNull();
|
||||
const stop = frame.messages.find(
|
||||
(message): message is { type: "stop"; commandId: string } =>
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
(message as { type?: string }).type === "stop",
|
||||
);
|
||||
expect(stop).toBeDefined();
|
||||
frame.receive({
|
||||
type: "stopped",
|
||||
requestId: "rec-1",
|
||||
commandId: stop!.commandId,
|
||||
ok: true,
|
||||
});
|
||||
await expect(stopping).resolves.toBe(true);
|
||||
expect(coordinator.sourceFor("rec-1", "producer-1", sender)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a producer used from another Document", async () => {
|
||||
const coordinator = new RecordFrameCoordinator({
|
||||
getAllFrames: async () => [{ frameId: 0, documentId: "top-document" }],
|
||||
sendToDocument: async () => ({ ok: true }),
|
||||
});
|
||||
coordinator.attach();
|
||||
coordinator.begin("rec-1", 10, 3);
|
||||
await coordinator.armTab("rec-1", 3);
|
||||
const sender = {
|
||||
tab: { id: 3 },
|
||||
frameId: 0,
|
||||
documentId: "top-document",
|
||||
} as chrome.runtime.MessageSender;
|
||||
const frame = fakePort(sender);
|
||||
for (const listener of onConnect.listeners) listener(frame.port);
|
||||
frame.receive({ type: "ready", requestId: "rec-1", producerId: "producer-1" });
|
||||
|
||||
expect(
|
||||
coordinator.sourceFor("rec-1", "producer-1", {
|
||||
...sender,
|
||||
documentId: "other-document",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
RECORD_DOCUMENT_ATTRIBUTE,
|
||||
recordingDocumentMarkerValue,
|
||||
} from "@/shared/recording-document-identity";
|
||||
import { markRecordingDocument } from "../recording/document-marker";
|
||||
import { ObservationNodeIndex } from "../recording/observation-capture";
|
||||
|
||||
describe("recording document marker", () => {
|
||||
it("rejects a non-random Document identity", () => {
|
||||
expect(() => markRecordingDocument("user@example.com")).toThrow(
|
||||
"recording Document identity must be a random UUID",
|
||||
);
|
||||
});
|
||||
|
||||
it("restores the Document attribute exactly", () => {
|
||||
document.documentElement.setAttribute(RECORD_DOCUMENT_ATTRIBUTE, "page-value");
|
||||
const producerId = "123e4567-e89b-42d3-a456-426614174000";
|
||||
const marker = markRecordingDocument(producerId);
|
||||
|
||||
expect(document.documentElement.getAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe(
|
||||
recordingDocumentMarkerValue(producerId),
|
||||
);
|
||||
marker.restore();
|
||||
expect(document.documentElement.getAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe("page-value");
|
||||
document.documentElement.removeAttribute(RECORD_DOCUMENT_ATTRIBUTE);
|
||||
});
|
||||
|
||||
it("indexes the CDP scope from the safe frame identity", () => {
|
||||
const index = new ObservationNodeIndex({
|
||||
rootFrameId: "root",
|
||||
frames: [
|
||||
{
|
||||
frameId: "child",
|
||||
target: { tabId: 3, sessionId: "oopif-session" },
|
||||
recordingDocumentId: "producer-1",
|
||||
},
|
||||
],
|
||||
matchNodes: [],
|
||||
refs: [],
|
||||
});
|
||||
|
||||
expect(index.documentScope("producer-1")).toEqual({
|
||||
frameId: "child",
|
||||
target: { tabId: 3, sessionId: "oopif-session" },
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when a recording identity maps to multiple Documents", () => {
|
||||
const index = new ObservationNodeIndex({
|
||||
rootFrameId: "root",
|
||||
frames: [
|
||||
{
|
||||
frameId: "left",
|
||||
target: { tabId: 3, sessionId: "left-session" },
|
||||
recordingDocumentId: "duplicate",
|
||||
},
|
||||
{
|
||||
frameId: "right",
|
||||
target: { tabId: 3, sessionId: "right-session" },
|
||||
recordingDocumentId: "duplicate",
|
||||
},
|
||||
],
|
||||
matchNodes: [],
|
||||
refs: [],
|
||||
});
|
||||
|
||||
expect(index.documentScope("duplicate")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ function sessionWithInput(redactValues = false): RecordingObservationSession {
|
||||
const session = new RecordingObservationSession({ redactValues });
|
||||
const state = session.registry.register({
|
||||
url: URL,
|
||||
rawVomText: '@vom 1\ntextbox "Password" value="••••••" [ref=e1]',
|
||||
vomText: '@vom 1\ntextbox "Password" value="••••••" [ref=e1]',
|
||||
});
|
||||
session.cursor.lastSettled = {
|
||||
stateId: state.id,
|
||||
@@ -34,7 +34,7 @@ function sessionWithInput(redactValues = false): RecordingObservationSession {
|
||||
return session;
|
||||
}
|
||||
|
||||
function finalizedFillBody(value: string, redactValues: boolean): string {
|
||||
function finalizedFillTrace(value: string, redactValues: boolean) {
|
||||
const session = sessionWithInput(redactValues);
|
||||
const draft: RecordingDraftStep = {
|
||||
op: "fill",
|
||||
@@ -53,15 +53,20 @@ function finalizedFillBody(value: string, redactValues: boolean): string {
|
||||
startedAt: "2026-08-12T00:00:00.000Z",
|
||||
stoppedBy: "user_finish",
|
||||
bskVersion: "test",
|
||||
}).states[0]!.body;
|
||||
redactValues,
|
||||
});
|
||||
}
|
||||
|
||||
function finalizedFillBody(value: string, redactValues: boolean): string {
|
||||
return finalizedFillTrace(value, redactValues).states[0]!.body;
|
||||
}
|
||||
|
||||
describe("record observation annotations", () => {
|
||||
it("omits a fill literal when values are redacted", () => {
|
||||
const secret = "hunter2-private";
|
||||
const body = finalizedFillBody(secret, true);
|
||||
expect(body).toContain("step 1: fill");
|
||||
expect(body).not.toContain(secret);
|
||||
const dumped = JSON.stringify(finalizedFillTrace(secret, true));
|
||||
expect(dumped).toContain("step 1: fill");
|
||||
expect(dumped).not.toContain(secret);
|
||||
});
|
||||
|
||||
it("keeps ordinary fill details", () => {
|
||||
@@ -73,7 +78,7 @@ describe("record observation annotations", () => {
|
||||
const state = registry.register({
|
||||
url: "https://example.com/a\nb",
|
||||
title: "hello\nworld",
|
||||
rawVomText: "@vom 1",
|
||||
vomText: "@vom 1",
|
||||
});
|
||||
const trace = buildTraceV3({
|
||||
registry,
|
||||
@@ -91,18 +96,18 @@ describe("recording state ownership", () => {
|
||||
it("deduplicates within one recording and isolates ids between recordings", () => {
|
||||
const first = new RecordingStateRegistry();
|
||||
const second = new RecordingStateRegistry();
|
||||
expect(first.register({ url: URL, rawVomText: "same" }).id).toBe("s1");
|
||||
expect(first.register({ url: URL, rawVomText: "same" }).id).toBe("s1");
|
||||
expect(second.register({ url: URL, rawVomText: "other" }).id).toBe("s1");
|
||||
expect(first.register({ url: URL, vomText: "same" }).id).toBe("s1");
|
||||
expect(first.register({ url: URL, vomText: "same" }).id).toBe("s1");
|
||||
expect(second.register({ url: URL, vomText: "other" }).id).toBe("s1");
|
||||
});
|
||||
|
||||
it("enriches metadata when a deduplicated observation becomes more complete", () => {
|
||||
const registry = new RecordingStateRegistry();
|
||||
registry.register({ url: URL, rawVomText: "same" });
|
||||
registry.register({ url: URL, vomText: "same" });
|
||||
const state = registry.register({
|
||||
url: URL,
|
||||
title: "Login",
|
||||
rawVomText: "same",
|
||||
vomText: "same",
|
||||
truncated: true,
|
||||
});
|
||||
expect(state).toMatchObject({ id: "s1", title: "Login", truncated: true });
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CdpRunner, ChromeTabsApi } from "@/tools/shared";
|
||||
|
||||
const captureRecordingObservation = vi.hoisted(() => vi.fn());
|
||||
const waitForDocumentSettled = vi.hoisted(() => vi.fn(async () => "quiet" as const));
|
||||
|
||||
vi.mock("../recording/observation-capture", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../recording/observation-capture")>();
|
||||
return { ...actual, captureRecordingObservation };
|
||||
});
|
||||
|
||||
vi.mock("../recording/document-settle", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../recording/document-settle")>();
|
||||
return { ...actual, waitForDocumentSettled };
|
||||
});
|
||||
|
||||
import { ObservationNodeIndex } from "../recording/observation-capture";
|
||||
import { RecordingObservationRuntime } from "../recording/recording-runtime";
|
||||
import type { RecordingDraftStep } from "../recording/types";
|
||||
|
||||
function observation(url = "https://example.com/") {
|
||||
return {
|
||||
rootFrameId: "root",
|
||||
index: new ObservationNodeIndex({ rootFrameId: "root", matchNodes: [], refs: [] }),
|
||||
url,
|
||||
title: "Example",
|
||||
vomText: '@vom 1\nRootWebArea "Example"',
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
function runtime(): RecordingObservationRuntime {
|
||||
return new RecordingObservationRuntime({
|
||||
cdp: { send: vi.fn() as unknown as CdpRunner["send"] },
|
||||
tabsApi: { get: vi.fn(), query: vi.fn() } as unknown as ChromeTabsApi,
|
||||
});
|
||||
}
|
||||
|
||||
describe("RecordingObservationRuntime", () => {
|
||||
afterEach(() => {
|
||||
captureRecordingObservation.mockReset();
|
||||
waitForDocumentSettled.mockClear();
|
||||
});
|
||||
|
||||
it("shares one initial capture and includes it in flush", async () => {
|
||||
let release!: (value: ReturnType<typeof observation>) => void;
|
||||
captureRecordingObservation.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const recording = runtime();
|
||||
|
||||
const first = recording.captureInitial(7);
|
||||
const second = recording.captureInitial(7);
|
||||
const flushed = recording.flush();
|
||||
let flushCompleted = false;
|
||||
void flushed.then(() => {
|
||||
flushCompleted = true;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(captureRecordingObservation).toHaveBeenCalledTimes(1);
|
||||
expect(flushCompleted).toBe(false);
|
||||
|
||||
release(observation());
|
||||
await Promise.all([first, second, flushed]);
|
||||
expect(flushCompleted).toBe(true);
|
||||
});
|
||||
|
||||
it("cancels an in-flight initial capture", async () => {
|
||||
captureRecordingObservation.mockImplementationOnce(
|
||||
(input: { signal?: AbortSignal }) =>
|
||||
new Promise((_, reject) => {
|
||||
input.signal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(new DOMException("observation aborted", "AbortError")),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const recording = runtime();
|
||||
|
||||
const pending = recording.captureInitial(7);
|
||||
recording.cancel();
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
|
||||
it("retries a failed initial capture when the recording settles at stop", async () => {
|
||||
captureRecordingObservation
|
||||
.mockRejectedValueOnce(new Error("document swapped"))
|
||||
.mockResolvedValueOnce(observation());
|
||||
const recording = runtime();
|
||||
|
||||
await expect(recording.captureInitial(7)).rejects.toThrow("document swapped");
|
||||
await recording.settleTrailing(7, []);
|
||||
const trace = recording.buildTrace({
|
||||
drafts: [],
|
||||
startedAt: "2026-08-19T00:00:00.000Z",
|
||||
stoppedBy: "user_finish",
|
||||
bskVersion: "0.1.6",
|
||||
});
|
||||
|
||||
expect(captureRecordingObservation).toHaveBeenCalledTimes(2);
|
||||
expect(trace.states).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps runtime matching data out of the serialized trace", async () => {
|
||||
const captured = observation();
|
||||
captured.index = new ObservationNodeIndex({
|
||||
rootFrameId: "root",
|
||||
matchNodes: [
|
||||
{
|
||||
frameId: "root",
|
||||
backendNodeId: 42,
|
||||
tag: "button",
|
||||
rect: { x: 10, y: 20, w: 100, h: 30 },
|
||||
localRect: { x: 10, y: 20, w: 100, h: 30 },
|
||||
},
|
||||
],
|
||||
refs: [{ ref: "e1", backendNodeId: 42, role: "button", name: "Submit", line: 1 }],
|
||||
});
|
||||
captureRecordingObservation.mockResolvedValueOnce(captured);
|
||||
const recording = runtime();
|
||||
|
||||
await recording.captureInitial(7);
|
||||
const dumped = JSON.stringify(
|
||||
recording.buildTrace({
|
||||
drafts: [],
|
||||
startedAt: "2026-08-19T00:00:00.000Z",
|
||||
stoppedBy: "user_finish",
|
||||
bskVersion: "0.1.6",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(dumped).toContain("RootWebArea");
|
||||
expect(dumped).not.toContain("matchNodes");
|
||||
expect(dumped).not.toContain("localRect");
|
||||
expect(dumped).not.toContain("backendNodeId");
|
||||
expect(dumped).not.toContain("ObservationNodeIndex");
|
||||
});
|
||||
|
||||
it("captures a tab transition without sharing observation cursors", async () => {
|
||||
captureRecordingObservation
|
||||
.mockResolvedValueOnce(observation("https://example.com/first"))
|
||||
.mockResolvedValueOnce(observation("https://example.com/second"));
|
||||
const recording = runtime();
|
||||
|
||||
await recording.captureInitial(4);
|
||||
const transition = await recording.captureTabTransition(4, 5);
|
||||
await recording.captureInitial(4);
|
||||
await recording.captureInitial(5);
|
||||
|
||||
expect(transition).toEqual({
|
||||
preStateId: "s1",
|
||||
postStateId: "s2",
|
||||
targetUrl: "https://example.com/second",
|
||||
});
|
||||
expect(captureRecordingObservation.mock.calls.map(([input]) => input.tabId)).toEqual([4, 5]);
|
||||
});
|
||||
|
||||
it("binds an iframe draft to its marked CDP Document scope", async () => {
|
||||
captureRecordingObservation.mockResolvedValueOnce({
|
||||
...observation(),
|
||||
index: new ObservationNodeIndex({
|
||||
rootFrameId: "root",
|
||||
frames: [
|
||||
{
|
||||
frameId: "child",
|
||||
target: { tabId: 7, sessionId: "oopif-session" },
|
||||
recordingDocumentId: "producer-1",
|
||||
},
|
||||
],
|
||||
matchNodes: [
|
||||
{
|
||||
backendNodeId: 42,
|
||||
frameId: "child",
|
||||
tag: "button",
|
||||
rect: { x: 410, y: 20, w: 100, h: 30 },
|
||||
localRect: { x: 10, y: 20, w: 100, h: 30 },
|
||||
},
|
||||
],
|
||||
refs: [{ ref: "e1", backendNodeId: 42, frameId: "child", line: 1 }],
|
||||
}),
|
||||
});
|
||||
const recording = runtime();
|
||||
const drafts: RecordingDraftStep[] = [
|
||||
{
|
||||
op: "click" as const,
|
||||
captureTarget: { tag: "button", role: "button", name: "Save" },
|
||||
targetHint: {
|
||||
geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await recording.captureInitial(7);
|
||||
await recording.processDraft(7, drafts, 0, "producer-1");
|
||||
recording.cancel();
|
||||
|
||||
const draft = drafts[0];
|
||||
expect(draft?.op).toBe("click");
|
||||
if (!draft || draft.op !== "click") throw new Error("expected click draft");
|
||||
expect(draft.targetHint).toMatchObject({
|
||||
frameId: "child",
|
||||
geometrySpace: "local",
|
||||
});
|
||||
expect(draft.matchedTarget?.ref).toBe("e1");
|
||||
});
|
||||
|
||||
it("refreshes a late iframe before matching its first action", async () => {
|
||||
const iframeObservation = {
|
||||
...observation(),
|
||||
index: new ObservationNodeIndex({
|
||||
rootFrameId: "root",
|
||||
frames: [
|
||||
{
|
||||
frameId: "child",
|
||||
target: { tabId: 7, sessionId: "oopif-session" },
|
||||
recordingDocumentId: "producer-1",
|
||||
},
|
||||
],
|
||||
matchNodes: [
|
||||
{
|
||||
backendNodeId: 42,
|
||||
frameId: "child",
|
||||
tag: "button",
|
||||
rect: { x: 410, y: 20, w: 100, h: 30 },
|
||||
localRect: { x: 10, y: 20, w: 100, h: 30 },
|
||||
},
|
||||
],
|
||||
refs: [
|
||||
{
|
||||
ref: "e1",
|
||||
backendNodeId: 42,
|
||||
frameId: "child",
|
||||
role: "button",
|
||||
name: "表格视图",
|
||||
line: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
captureRecordingObservation
|
||||
.mockResolvedValueOnce(observation())
|
||||
.mockResolvedValueOnce(iframeObservation)
|
||||
.mockResolvedValueOnce(iframeObservation);
|
||||
const recording = runtime();
|
||||
|
||||
await recording.captureInitial(7);
|
||||
await recording.refreshDocument(7, "producer-1");
|
||||
const drafts: RecordingDraftStep[] = [
|
||||
{
|
||||
op: "click",
|
||||
captureTarget: { tag: "button", role: "button", name: "表格视图" },
|
||||
targetHint: {
|
||||
geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" },
|
||||
},
|
||||
},
|
||||
];
|
||||
await recording.processDraft(7, drafts, 0, "producer-1");
|
||||
recording.cancel();
|
||||
|
||||
expect(captureRecordingObservation).toHaveBeenCalledTimes(3);
|
||||
expect(waitForDocumentSettled).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ frameId: "child", target: { tabId: 7, sessionId: "oopif-session" } },
|
||||
{ signal: expect.any(AbortSignal) },
|
||||
);
|
||||
const draft = drafts[0];
|
||||
expect(draft?.op).toBe("click");
|
||||
if (!draft || draft.op !== "click") throw new Error("expected click draft");
|
||||
expect(draft.matchedTarget).toMatchObject({ ref: "e1", name: "表格视图" });
|
||||
});
|
||||
|
||||
it("does not poison final flush when a frame readiness refresh fails", async () => {
|
||||
captureRecordingObservation
|
||||
.mockResolvedValueOnce(observation())
|
||||
.mockRejectedValueOnce(new Error("child Document replaced"));
|
||||
const recording = runtime();
|
||||
|
||||
await recording.captureInitial(7);
|
||||
await expect(recording.refreshDocument(7, "producer-1")).rejects.toThrow(
|
||||
"child Document replaced",
|
||||
);
|
||||
await expect(recording.flush()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { appendRecordedPayload, observeRecordedNavigation } from "../recording/s
|
||||
|
||||
describe("recording-step-buffer", () => {
|
||||
it("stores semantic click without summary", () => {
|
||||
const buffer = { steps: [], pendingNavigation: false };
|
||||
const buffer = { steps: [], navigation: { pendingNavigation: false } };
|
||||
appendRecordedPayload(buffer, {
|
||||
op: "click",
|
||||
target: { tag: "button", role: "button", name: "发布" },
|
||||
@@ -15,7 +15,40 @@ describe("recording-step-buffer", () => {
|
||||
captureTarget: { tag: "button", role: "button", name: "发布" },
|
||||
},
|
||||
]);
|
||||
expect(buffer.pendingNavigation).toBe(true);
|
||||
expect(buffer.navigation.pendingNavigation).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the hovered element description as a capture fallback", () => {
|
||||
const buffer = { steps: [], navigation: { pendingNavigation: false } };
|
||||
appendRecordedPayload(
|
||||
buffer,
|
||||
{
|
||||
op: "hover",
|
||||
target: { tag: "button", role: "button", name: "新建" },
|
||||
geometry: {
|
||||
rect: { x: 900, y: 8, w: 60, h: 32 },
|
||||
tag: "button",
|
||||
},
|
||||
},
|
||||
{
|
||||
geometry: {
|
||||
rect: { x: 900, y: 8, w: 60, h: 32 },
|
||||
tag: "button",
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(buffer.steps).toEqual([
|
||||
{
|
||||
op: "hover",
|
||||
captureTarget: { tag: "button", role: "button", name: "新建" },
|
||||
targetHint: {
|
||||
geometry: {
|
||||
rect: { x: 900, y: 8, w: 60, h: 32 },
|
||||
tag: "button",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("annotates navigated_to on action-caused navigation instead of wait_for_navigation", () => {
|
||||
@@ -26,9 +59,11 @@ describe("recording-step-buffer", () => {
|
||||
captureTarget: { tag: "button", role: "button", name: "发布" },
|
||||
},
|
||||
],
|
||||
currentUrl: "https://example.com/a",
|
||||
pendingNavigation: true,
|
||||
pendingNavigationDeadline: Date.now() + 5_000,
|
||||
navigation: {
|
||||
currentUrl: "https://example.com/a",
|
||||
pendingNavigation: true,
|
||||
pendingNavigationDeadline: Date.now() + 5_000,
|
||||
},
|
||||
};
|
||||
observeRecordedNavigation(buffer, "https://example.com/b", true);
|
||||
expect(buffer.steps).toEqual([
|
||||
@@ -50,9 +85,11 @@ describe("recording-step-buffer", () => {
|
||||
values: ["tech"],
|
||||
},
|
||||
],
|
||||
currentUrl: "https://example.com/list",
|
||||
pendingNavigation: true,
|
||||
pendingNavigationDeadline: Date.now() + 5_000,
|
||||
navigation: {
|
||||
currentUrl: "https://example.com/list",
|
||||
pendingNavigation: true,
|
||||
pendingNavigationDeadline: Date.now() + 5_000,
|
||||
},
|
||||
};
|
||||
observeRecordedNavigation(buffer, "https://example.com/list?cat=tech", true);
|
||||
expect(buffer.steps).toEqual([
|
||||
@@ -68,13 +105,60 @@ describe("recording-step-buffer", () => {
|
||||
it("emits navigate for uncaused URL changes", () => {
|
||||
const buffer = {
|
||||
steps: [],
|
||||
currentUrl: "https://example.com/a",
|
||||
pendingNavigation: false,
|
||||
navigation: { currentUrl: "https://example.com/a", pendingNavigation: false },
|
||||
};
|
||||
observeRecordedNavigation(buffer, "https://example.com/b", false);
|
||||
const result = observeRecordedNavigation(buffer, "https://example.com/b", false);
|
||||
expect(result).toEqual({ kind: "appended", index: 0 });
|
||||
expect(buffer.steps[0]).toMatchObject({
|
||||
op: "navigate",
|
||||
url: "https://example.com/b",
|
||||
});
|
||||
});
|
||||
|
||||
it("asks the recorder to coalesce redirect hops instead of emitting each one", () => {
|
||||
const buffer = {
|
||||
steps: [],
|
||||
navigation: {
|
||||
currentUrl: "https://passport.example/login",
|
||||
pendingNavigation: false,
|
||||
},
|
||||
};
|
||||
const hop1 = observeRecordedNavigation(
|
||||
buffer,
|
||||
"https://passport.example/callback",
|
||||
false,
|
||||
"link",
|
||||
["server_redirect"],
|
||||
);
|
||||
expect(hop1).toEqual({
|
||||
kind: "coalesce_redirect",
|
||||
url: "https://passport.example/callback",
|
||||
});
|
||||
expect(buffer.steps).toEqual([]);
|
||||
|
||||
const hop2 = observeRecordedNavigation(buffer, "https://app.example/dashboard", false, "link", [
|
||||
"client_redirect",
|
||||
]);
|
||||
expect(hop2).toEqual({
|
||||
kind: "coalesce_redirect",
|
||||
url: "https://app.example/dashboard",
|
||||
});
|
||||
expect(buffer.steps).toEqual([]);
|
||||
expect(buffer.navigation.currentUrl).toBe("https://app.example/dashboard");
|
||||
});
|
||||
|
||||
it("does not reuse redirect metadata for a later content-observed URL change", () => {
|
||||
const buffer = {
|
||||
steps: [],
|
||||
navigation: {
|
||||
currentUrl: "https://example.com/redirect",
|
||||
pendingNavigation: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = observeRecordedNavigation(buffer, "https://example.com/spa");
|
||||
|
||||
expect(result).toEqual({ kind: "appended", index: 0 });
|
||||
expect(buffer.steps[0]).toMatchObject({ op: "navigate", url: "https://example.com/spa" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RecordingTabCoordinator } from "../recording/tab-coordinator";
|
||||
|
||||
describe("RecordingTabCoordinator", () => {
|
||||
it("keeps navigation state isolated per tab", () => {
|
||||
const tabs = new RecordingTabCoordinator(4, "https://example.com/first");
|
||||
const first = tabs.navigation(4);
|
||||
first.pendingNavigation = true;
|
||||
const second = tabs.navigation(5, "https://example.com/second");
|
||||
|
||||
expect(second).toEqual({
|
||||
currentUrl: "https://example.com/second",
|
||||
pendingNavigation: false,
|
||||
});
|
||||
expect(tabs.navigation(4)).toBe(first);
|
||||
expect(tabs.navigation(4).pendingNavigation).toBe(true);
|
||||
});
|
||||
|
||||
it("invalidates an earlier activation when a newer tab becomes active", () => {
|
||||
const tabs = new RecordingTabCoordinator(4);
|
||||
const second = tabs.noteActivation(5);
|
||||
const third = tabs.noteActivation(6);
|
||||
|
||||
expect(tabs.isLatest(second)).toBe(false);
|
||||
expect(tabs.isLatest(third)).toBe(true);
|
||||
tabs.commit(6);
|
||||
expect(tabs.currentTabId).toBe(6);
|
||||
});
|
||||
|
||||
it("commits the freshly observed URL when returning to an existing tab", () => {
|
||||
const tabs = new RecordingTabCoordinator(4);
|
||||
tabs.navigation(5, "https://example.com/old");
|
||||
|
||||
tabs.commit(5, "https://example.com/new");
|
||||
|
||||
expect(tabs.currentTabId).toBe(5);
|
||||
expect(tabs.navigation(5).currentUrl).toBe("https://example.com/new");
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,23 @@ describe("matchObservationTarget", () => {
|
||||
expect(target.ref).toBe("e2");
|
||||
});
|
||||
|
||||
it("matches iframe capture geometry in the child viewport coordinate space", () => {
|
||||
const child = node(42, "child", 410);
|
||||
child.localRect = { x: 10, y: 20, w: 100, h: 30 };
|
||||
const target = matchObservationTarget({
|
||||
observation: observation(
|
||||
[child],
|
||||
[{ ref: "e1", backendNodeId: 42, frameId: "child", line: 1 }],
|
||||
),
|
||||
hint: {
|
||||
frameId: "child",
|
||||
geometrySpace: "local",
|
||||
geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" },
|
||||
},
|
||||
});
|
||||
expect(target.ref).toBe("e1");
|
||||
});
|
||||
|
||||
it("restricts a missing frame hint to the root frame", () => {
|
||||
const target = matchObservationTarget({
|
||||
observation: observation(
|
||||
@@ -81,6 +98,21 @@ describe("matchObservationTarget", () => {
|
||||
expect(target).toEqual({ name: "发布", unmatched: true });
|
||||
});
|
||||
|
||||
it("fails closed when the source Document has no VOM frame mapping", () => {
|
||||
const target = matchObservationTarget({
|
||||
observation: observation(
|
||||
[node(42, "root")],
|
||||
[{ ref: "e1", backendNodeId: 42, role: "button", name: "发布", line: 1 }],
|
||||
),
|
||||
hint: {
|
||||
frameId: null,
|
||||
geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" },
|
||||
},
|
||||
fallback: { tag: "button", role: "button", name: "发布" },
|
||||
});
|
||||
expect(target).toEqual({ role: "button", name: "发布", unmatched: true });
|
||||
});
|
||||
|
||||
it("returns unmatched for ambiguous geometry", () => {
|
||||
const target = matchObservationTarget({
|
||||
observation: observation(
|
||||
|
||||
@@ -27,6 +27,17 @@ describe("shouldRecordPress", () => {
|
||||
});
|
||||
|
||||
describe("reduceTraceSteps", () => {
|
||||
it("does not expose internal tab transitions in trace v2", () => {
|
||||
const trace = reduceTraceSteps([
|
||||
{
|
||||
op: "switch_tab",
|
||||
preStateId: "s1",
|
||||
postStateId: "s2",
|
||||
},
|
||||
]);
|
||||
expect(trace.steps).toEqual([]);
|
||||
});
|
||||
|
||||
it("builds steps with pages dictionary and page id references", () => {
|
||||
const drafts: RecordingDraftStep[] = [
|
||||
{
|
||||
@@ -160,7 +171,7 @@ describe("reduceTraceSteps", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps hover steps before menu clicks", () => {
|
||||
it("drops hover steps, which only exist in trace v3", () => {
|
||||
const { steps } = reduceTraceSteps(
|
||||
[
|
||||
{
|
||||
@@ -176,12 +187,7 @@ describe("reduceTraceSteps", () => {
|
||||
],
|
||||
"https://example.com/app",
|
||||
);
|
||||
expect(steps.map((s) => s.op)).toEqual(["hover", "click"]);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "hover",
|
||||
page: "p1",
|
||||
target: { name: "Account" },
|
||||
});
|
||||
expect(steps.map((s) => s.op)).toEqual(["click"]);
|
||||
});
|
||||
|
||||
it("resolveTraceStartUrl prefers explicit start URL", () => {
|
||||
|
||||
@@ -6,6 +6,76 @@ import { reduceTraceStepsV3 } from "../recording/trace-reducer-v3";
|
||||
import type { RecordingDraftStep } from "../recording/types";
|
||||
|
||||
describe("trace reducer v3", () => {
|
||||
it("removes form literals when value redaction is requested", () => {
|
||||
const reduced = reduceTraceStepsV3(
|
||||
[
|
||||
{
|
||||
op: "fill",
|
||||
value: "private@example.com",
|
||||
preStateId: "s1",
|
||||
postStateId: "s1",
|
||||
},
|
||||
{
|
||||
op: "select",
|
||||
values: ["private-account-id"],
|
||||
labels: ["Private account"],
|
||||
preStateId: "s1",
|
||||
postStateId: "s1",
|
||||
},
|
||||
],
|
||||
{ redactValues: true },
|
||||
);
|
||||
|
||||
expect(reduced.steps[0]).toMatchObject({ op: "fill", value: "***", redacted: true });
|
||||
expect(reduced.steps[1]).toMatchObject({ op: "select" });
|
||||
expect(reduced.steps[1]).not.toHaveProperty("selection");
|
||||
expect(JSON.stringify(reduced.steps)).not.toContain("private@example.com");
|
||||
expect(JSON.stringify(reduced.steps)).not.toContain("private-account-id");
|
||||
});
|
||||
|
||||
it("emits tab transitions only for callers that advertised support", () => {
|
||||
const drafts: RecordingDraftStep[] = [
|
||||
{
|
||||
op: "switch_tab",
|
||||
preStateId: "s1",
|
||||
postStateId: "s2",
|
||||
},
|
||||
];
|
||||
|
||||
expect(reduceTraceStepsV3(drafts).steps).toEqual([]);
|
||||
expect(reduceTraceStepsV3(drafts, { includeTabSwitches: true }).steps).toEqual([
|
||||
{ op: "switch_tab", id: 1, state: "s1", result: { state: "s2" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps hover steps before menu clicks", () => {
|
||||
const drafts: RecordingDraftStep[] = [
|
||||
{
|
||||
op: "hover",
|
||||
captureTarget: { tag: "span", role: "button", name: "Account" },
|
||||
preStateId: "s1",
|
||||
postStateId: "s1",
|
||||
},
|
||||
{
|
||||
op: "click",
|
||||
captureTarget: { tag: "a", role: "link", name: "Profile" },
|
||||
preStateId: "s1",
|
||||
postStateId: "s1",
|
||||
},
|
||||
];
|
||||
|
||||
expect(reduceTraceStepsV3(drafts).steps.map((s) => s.op)).toEqual(["hover", "click"]);
|
||||
});
|
||||
|
||||
it("does not export a tab transition without both observation endpoints", () => {
|
||||
const sourceOnly: RecordingDraftStep = { op: "switch_tab", preStateId: "s1" };
|
||||
const targetOnly: RecordingDraftStep = { op: "switch_tab", postStateId: "s2" };
|
||||
|
||||
expect(
|
||||
reduceTraceStepsV3([sourceOnly, targetOnly], { includeTabSwitches: true }).steps,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("collapses redirect hops while retaining draft-to-step identity", () => {
|
||||
const drafts: RecordingDraftStep[] = [
|
||||
{ op: "navigate", url: "https://example.com/start", preStateId: "s1", postStateId: "s2" },
|
||||
@@ -36,7 +106,7 @@ describe("trace reducer v3", () => {
|
||||
|
||||
it("builds the wire model from protocol constants", () => {
|
||||
const registry = new RecordingStateRegistry();
|
||||
const state = registry.register({ url: "https://example.com", rawVomText: "@vom 1" });
|
||||
const state = registry.register({ url: "https://example.com", vomText: "@vom 1" });
|
||||
const trace = buildTraceV3({
|
||||
registry,
|
||||
drafts: [
|
||||
|
||||
@@ -16,6 +16,10 @@ export interface RecordStartAck {
|
||||
ok: true;
|
||||
}
|
||||
|
||||
export type RecordStepAck =
|
||||
| { ok: true; sequence: number }
|
||||
| { ok: false; expectedSequence: number; error: string };
|
||||
|
||||
export type RecordStopAck = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export interface RecordQueryMessage {
|
||||
@@ -50,17 +54,30 @@ export interface RecordStepPayload {
|
||||
labels?: string[];
|
||||
url?: string;
|
||||
redacted?: boolean;
|
||||
commit?: "enter" | "suggestion" | "blur";
|
||||
/** Page URL when the step was captured. */
|
||||
page_url?: string;
|
||||
/** Event-target bounds in the source frame's viewport coordinate space. */
|
||||
geometry?: {
|
||||
rect: { x: number; y: number; w: number; h: number };
|
||||
tag: string;
|
||||
};
|
||||
/** Capture-only hint; never persisted unless converted to navigated_to. */
|
||||
expects_navigation?: boolean;
|
||||
/** Whether an observed URL change was synchronously caused by the action. */
|
||||
navigation_caused_by_action?: boolean;
|
||||
/** Raw webNavigation transition metadata for cause mapping. */
|
||||
transitionType?: string;
|
||||
transitionQualifiers?: string[];
|
||||
}
|
||||
|
||||
export interface RecordStepMessage {
|
||||
type: typeof RECORD_STEP;
|
||||
requestId: string;
|
||||
/** Stable for the lifetime of one content-script document. */
|
||||
producerId: string;
|
||||
/** Monotonic within one content-script producer and recording request. */
|
||||
sequence: number;
|
||||
step: RecordStepPayload;
|
||||
}
|
||||
|
||||
@@ -112,8 +129,27 @@ export function isRecordQueryMessage(msg: unknown): msg is RecordQueryMessage {
|
||||
export function isRecordStepMessage(msg: unknown): msg is RecordStepMessage {
|
||||
if (typeof msg !== "object" || msg === null) return false;
|
||||
const m = msg as Record<string, unknown>;
|
||||
if (m.type !== RECORD_STEP || typeof m.requestId !== "string") return false;
|
||||
if (
|
||||
m.type !== RECORD_STEP ||
|
||||
typeof m.requestId !== "string" ||
|
||||
typeof m.producerId !== "string" ||
|
||||
m.producerId.length === 0 ||
|
||||
typeof m.sequence !== "number" ||
|
||||
!Number.isSafeInteger(m.sequence) ||
|
||||
m.sequence < 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const step = m.step;
|
||||
if (typeof step !== "object" || step === null) return false;
|
||||
return typeof (step as RecordStepPayload).op === "string";
|
||||
}
|
||||
|
||||
export function isAcceptedRecordStepAck(
|
||||
value: unknown,
|
||||
sequence: number,
|
||||
): value is Extract<RecordStepAck, { ok: true }> {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const ack = value as Partial<RecordStepAck>;
|
||||
return ack.ok === true && ack.sequence === sequence;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
RECORD_DOCUMENT_ATTRIBUTE,
|
||||
recordingDocumentMarkerValue,
|
||||
} from "@/shared/recording-document-identity";
|
||||
|
||||
export interface RecordingDocumentMarker {
|
||||
restore(): void;
|
||||
ensure(): void;
|
||||
}
|
||||
|
||||
export function waitForDocumentElement(): Promise<HTMLElement> {
|
||||
if (document.documentElement) return Promise.resolve(document.documentElement);
|
||||
return new Promise((resolve) => {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!document.documentElement) return;
|
||||
observer.disconnect();
|
||||
resolve(document.documentElement);
|
||||
});
|
||||
observer.observe(document, { childList: true });
|
||||
});
|
||||
}
|
||||
|
||||
export function markRecordingDocument(
|
||||
producerId: string,
|
||||
root: HTMLElement = document.documentElement,
|
||||
): RecordingDocumentMarker {
|
||||
const previous = root.getAttribute(RECORD_DOCUMENT_ATTRIBUTE);
|
||||
const markerValue = recordingDocumentMarkerValue(producerId);
|
||||
const ensure = () => root.setAttribute(RECORD_DOCUMENT_ATTRIBUTE, markerValue);
|
||||
ensure();
|
||||
return {
|
||||
ensure,
|
||||
restore() {
|
||||
if (root.getAttribute(RECORD_DOCUMENT_ATTRIBUTE) !== markerValue) return;
|
||||
if (previous === null) root.removeAttribute(RECORD_DOCUMENT_ATTRIBUTE);
|
||||
else root.setAttribute(RECORD_DOCUMENT_ATTRIBUTE, previous);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export const RECORD_FRAME_PORT = "bsk-record-frame";
|
||||
export const RECORD_FRAME_QUERY = "bsk-record-frame-query";
|
||||
export const RECORD_FRAME_START = "bsk-record-frame-start";
|
||||
|
||||
export interface RecordFrameQueryMessage {
|
||||
type: typeof RECORD_FRAME_QUERY;
|
||||
}
|
||||
|
||||
export interface RecordFrameQueryResponse {
|
||||
active: boolean;
|
||||
requestId?: string;
|
||||
startedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface RecordFrameStartMessage {
|
||||
type: typeof RECORD_FRAME_START;
|
||||
requestId: string;
|
||||
startedAtMs: number;
|
||||
}
|
||||
|
||||
export type RecordFramePortMessage =
|
||||
| {
|
||||
type: "ready";
|
||||
requestId: string;
|
||||
producerId: string;
|
||||
}
|
||||
| {
|
||||
type: "ready_ack";
|
||||
requestId: string;
|
||||
producerId: string;
|
||||
}
|
||||
| {
|
||||
type: "stop";
|
||||
requestId: string;
|
||||
commandId: string;
|
||||
}
|
||||
| {
|
||||
type: "cancel";
|
||||
requestId: string;
|
||||
}
|
||||
| {
|
||||
type: "stopped";
|
||||
requestId: string;
|
||||
commandId: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function isRecordFrameQueryMessage(value: unknown): value is RecordFrameQueryMessage {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
(value as { type?: unknown }).type === RECORD_FRAME_QUERY
|
||||
);
|
||||
}
|
||||
|
||||
export function isRecordFrameStartMessage(value: unknown): value is RecordFrameStartMessage {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const message = value as Partial<RecordFrameStartMessage>;
|
||||
return (
|
||||
message.type === RECORD_FRAME_START &&
|
||||
typeof message.requestId === "string" &&
|
||||
typeof message.startedAtMs === "number"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import {
|
||||
isRecordFrameQueryMessage,
|
||||
RECORD_FRAME_PORT,
|
||||
RECORD_FRAME_START,
|
||||
type RecordFramePortMessage,
|
||||
type RecordFrameQueryResponse,
|
||||
type RecordFrameStartMessage,
|
||||
} from "./frame-bridge";
|
||||
|
||||
export interface RecordingCaptureScope {
|
||||
tabId: number;
|
||||
documentId: string;
|
||||
browserFrameId: number;
|
||||
producerId: string;
|
||||
}
|
||||
|
||||
interface ArmedRecording {
|
||||
requestId: string;
|
||||
startedAtMs: number;
|
||||
tabIds: Set<number>;
|
||||
agents: Map<string, FrameAgent>;
|
||||
onDocumentReady?: (scope: RecordingCaptureScope) => void | Promise<void>;
|
||||
finishing: boolean;
|
||||
}
|
||||
|
||||
interface FrameAgent extends RecordingCaptureScope {
|
||||
requestId: string;
|
||||
port: chrome.runtime.Port;
|
||||
stopWaiters: Map<string, (ok: boolean) => void>;
|
||||
}
|
||||
|
||||
interface BrowserFrame {
|
||||
frameId: number;
|
||||
documentId?: string;
|
||||
}
|
||||
|
||||
interface BrowserFrameNavigation extends BrowserFrame {
|
||||
tabId: number;
|
||||
lifecycle: "committed" | "completed";
|
||||
}
|
||||
|
||||
const RECORD_FRAME_STOP_TIMEOUT_MS = 5_000;
|
||||
|
||||
export interface RecordFrameCoordinatorDeps {
|
||||
getAllFrames(tabId: number): Promise<BrowserFrame[]>;
|
||||
sendToDocument(
|
||||
tabId: number,
|
||||
message: RecordFrameStartMessage,
|
||||
target: { documentId?: string; frameId?: number },
|
||||
): Promise<unknown>;
|
||||
subscribeFrameNavigation?(listener: (frame: BrowserFrameNavigation) => void): () => void;
|
||||
}
|
||||
|
||||
function documentKey(tabId: number, documentId: string): string {
|
||||
return `${tabId}:${documentId}`;
|
||||
}
|
||||
|
||||
function senderAddress(
|
||||
sender: chrome.runtime.MessageSender,
|
||||
): { tabId: number; documentId: string; browserFrameId: number } | null {
|
||||
const tabId = sender.tab?.id;
|
||||
const documentId = sender.documentId;
|
||||
const browserFrameId = sender.frameId;
|
||||
if (
|
||||
typeof tabId !== "number" ||
|
||||
typeof documentId !== "string" ||
|
||||
typeof browserFrameId !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { tabId, documentId, browserFrameId };
|
||||
}
|
||||
|
||||
function defaultDeps(): RecordFrameCoordinatorDeps {
|
||||
return {
|
||||
async getAllFrames(tabId) {
|
||||
return (await chrome.webNavigation.getAllFrames({ tabId })) ?? [];
|
||||
},
|
||||
sendToDocument(tabId, message, target) {
|
||||
return chrome.tabs.sendMessage(tabId, message, target);
|
||||
},
|
||||
subscribeFrameNavigation(listener) {
|
||||
const onCommitted = (details: chrome.webNavigation.WebNavigationTransitionCallbackDetails) =>
|
||||
listener({ ...details, lifecycle: "committed" });
|
||||
const onCompleted = (details: chrome.webNavigation.WebNavigationFramedCallbackDetails) =>
|
||||
listener({ ...details, lifecycle: "completed" });
|
||||
chrome.webNavigation.onCommitted.addListener(onCommitted);
|
||||
chrome.webNavigation.onCompleted.addListener(onCompleted);
|
||||
return () => {
|
||||
chrome.webNavigation.onCommitted.removeListener(onCommitted);
|
||||
chrome.webNavigation.onCompleted.removeListener(onCompleted);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class RecordFrameCoordinator {
|
||||
readonly #deps: RecordFrameCoordinatorDeps;
|
||||
readonly #recordings = new Map<string, ArmedRecording>();
|
||||
#attached = false;
|
||||
#detachFrameNavigation: (() => void) | null = null;
|
||||
|
||||
constructor(deps: RecordFrameCoordinatorDeps = defaultDeps()) {
|
||||
this.#deps = deps;
|
||||
}
|
||||
|
||||
attach(): () => void {
|
||||
if (this.#attached) return () => {};
|
||||
this.#attached = true;
|
||||
chrome.runtime.onConnect.addListener(this.#onConnect);
|
||||
chrome.runtime.onMessage.addListener(this.#onMessage);
|
||||
this.#detachFrameNavigation =
|
||||
this.#deps.subscribeFrameNavigation?.(this.#onFrameNavigation) ?? null;
|
||||
return () => {
|
||||
if (!this.#attached) return;
|
||||
this.#attached = false;
|
||||
chrome.runtime.onConnect.removeListener(this.#onConnect);
|
||||
chrome.runtime.onMessage.removeListener(this.#onMessage);
|
||||
this.#detachFrameNavigation?.();
|
||||
this.#detachFrameNavigation = null;
|
||||
for (const recording of this.#recordings.values()) this.#cancelAgents(recording);
|
||||
this.#recordings.clear();
|
||||
};
|
||||
}
|
||||
|
||||
begin(
|
||||
requestId: string,
|
||||
startedAtMs: number,
|
||||
initialTabId: number,
|
||||
onDocumentReady?: (scope: RecordingCaptureScope) => void | Promise<void>,
|
||||
): void {
|
||||
const previous = this.#recordings.get(requestId);
|
||||
if (previous) this.#cancelAgents(previous);
|
||||
this.#recordings.set(requestId, {
|
||||
requestId,
|
||||
startedAtMs,
|
||||
// Register the initial tab before navigation begins. A newly-created
|
||||
// child document can query at document_start before armTab's frame
|
||||
// snapshot sees it, especially when Chromium promotes it to an OOPIF.
|
||||
tabIds: new Set([initialTabId]),
|
||||
agents: new Map(),
|
||||
...(onDocumentReady ? { onDocumentReady } : {}),
|
||||
finishing: false,
|
||||
});
|
||||
}
|
||||
|
||||
async armTab(requestId: string, tabId: number): Promise<boolean> {
|
||||
const recording = this.#recordings.get(requestId);
|
||||
if (!recording || recording.finishing) return false;
|
||||
recording.tabIds.add(tabId);
|
||||
|
||||
let frames: BrowserFrame[];
|
||||
try {
|
||||
frames = await this.#deps.getAllFrames(tabId);
|
||||
} catch {
|
||||
frames = [{ frameId: 0 }];
|
||||
}
|
||||
if (!frames.some((frame) => frame.frameId === 0)) frames.unshift({ frameId: 0 });
|
||||
|
||||
const results = await Promise.all(
|
||||
frames.map(async (frame) => {
|
||||
const started = await this.#startFrame(recording, tabId, frame);
|
||||
return { frameId: frame.frameId, started };
|
||||
}),
|
||||
);
|
||||
return results.some((result) => result.frameId === 0 && result.started);
|
||||
}
|
||||
|
||||
sourceFor(
|
||||
requestId: string,
|
||||
producerId: string,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
): RecordingCaptureScope | null {
|
||||
const address = senderAddress(sender);
|
||||
const recording = this.#recordings.get(requestId);
|
||||
if (!address || !recording) return null;
|
||||
const agent = recording.agents.get(documentKey(address.tabId, address.documentId));
|
||||
if (!agent || agent.producerId !== producerId) return null;
|
||||
return {
|
||||
tabId: agent.tabId,
|
||||
documentId: agent.documentId,
|
||||
browserFrameId: agent.browserFrameId,
|
||||
producerId: agent.producerId,
|
||||
};
|
||||
}
|
||||
|
||||
async stop(requestId: string): Promise<boolean> {
|
||||
const recording = this.#recordings.get(requestId);
|
||||
if (!recording) return true;
|
||||
recording.finishing = true;
|
||||
const commandId = crypto.randomUUID();
|
||||
const results = await Promise.all(
|
||||
[...recording.agents.values()].map((agent) => this.#stopAgent(agent, requestId, commandId)),
|
||||
);
|
||||
const succeeded = results.every(Boolean);
|
||||
if (succeeded) this.#recordings.delete(requestId);
|
||||
else recording.finishing = false;
|
||||
return succeeded;
|
||||
}
|
||||
|
||||
cancel(requestId: string): void {
|
||||
const recording = this.#recordings.get(requestId);
|
||||
if (!recording) return;
|
||||
this.#cancelAgents(recording);
|
||||
this.#recordings.delete(requestId);
|
||||
}
|
||||
|
||||
readonly #onMessage = (
|
||||
message: unknown,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: RecordFrameQueryResponse) => void,
|
||||
): boolean => {
|
||||
if (!isRecordFrameQueryMessage(message)) return false;
|
||||
const tabId = sender.tab?.id;
|
||||
const recording =
|
||||
typeof tabId === "number"
|
||||
? [...this.#recordings.values()].find(
|
||||
(candidate) => !candidate.finishing && candidate.tabIds.has(tabId),
|
||||
)
|
||||
: undefined;
|
||||
sendResponse(
|
||||
recording
|
||||
? {
|
||||
active: true,
|
||||
requestId: recording.requestId,
|
||||
startedAtMs: recording.startedAtMs,
|
||||
}
|
||||
: { active: false },
|
||||
);
|
||||
return false;
|
||||
};
|
||||
|
||||
readonly #onFrameNavigation = (frame: BrowserFrameNavigation): void => {
|
||||
if (frame.frameId === 0) return;
|
||||
for (const recording of this.#recordings.values()) {
|
||||
if (recording.finishing || !recording.tabIds.has(frame.tabId)) continue;
|
||||
// onCommitted can race document_start, while onCompleted runs after the
|
||||
// content script is available. Starting twice is safe and closes both
|
||||
// the early-query and late-SPA-frame gaps.
|
||||
void (async () => {
|
||||
const started = await this.#startFrame(recording, frame.tabId, frame);
|
||||
if (!started || frame.lifecycle !== "completed" || recording.finishing) return;
|
||||
const agent = frame.documentId
|
||||
? recording.agents.get(documentKey(frame.tabId, frame.documentId))
|
||||
: [...recording.agents.values()].find(
|
||||
(candidate) =>
|
||||
candidate.tabId === frame.tabId && candidate.browserFrameId === frame.frameId,
|
||||
);
|
||||
if (!agent) return;
|
||||
await recording.onDocumentReady?.({
|
||||
tabId: agent.tabId,
|
||||
documentId: agent.documentId,
|
||||
browserFrameId: agent.browserFrameId,
|
||||
producerId: agent.producerId,
|
||||
});
|
||||
})();
|
||||
}
|
||||
};
|
||||
|
||||
readonly #onConnect = (port: chrome.runtime.Port): void => {
|
||||
if (port.name !== RECORD_FRAME_PORT) return;
|
||||
const address = senderAddress(port.sender ?? {});
|
||||
if (!address) {
|
||||
port.disconnect();
|
||||
return;
|
||||
}
|
||||
let registered: FrameAgent | null = null;
|
||||
port.onMessage.addListener((raw: unknown) => {
|
||||
const message = raw as Partial<RecordFramePortMessage>;
|
||||
if (message.type === "ready") {
|
||||
if (typeof message.requestId !== "string" || typeof message.producerId !== "string") {
|
||||
port.disconnect();
|
||||
return;
|
||||
}
|
||||
const requestId = message.requestId;
|
||||
const producerId = message.producerId;
|
||||
if (registered) {
|
||||
if (registered.requestId === requestId && registered.producerId === producerId) {
|
||||
port.postMessage({
|
||||
type: "ready_ack",
|
||||
requestId,
|
||||
producerId,
|
||||
} satisfies RecordFramePortMessage);
|
||||
} else {
|
||||
port.disconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const recording = this.#recordings.get(requestId);
|
||||
if (
|
||||
!recording ||
|
||||
recording.finishing ||
|
||||
!recording.tabIds.has(address.tabId) ||
|
||||
producerId.length === 0
|
||||
) {
|
||||
port.disconnect();
|
||||
return;
|
||||
}
|
||||
const agent: FrameAgent = {
|
||||
...address,
|
||||
requestId,
|
||||
producerId,
|
||||
port,
|
||||
stopWaiters: new Map(),
|
||||
};
|
||||
registered = agent;
|
||||
const key = documentKey(address.tabId, address.documentId);
|
||||
const previous = recording.agents.get(key);
|
||||
if (previous && previous !== agent) previous.port.disconnect();
|
||||
recording.agents.set(key, agent);
|
||||
port.postMessage({
|
||||
type: "ready_ack",
|
||||
requestId,
|
||||
producerId,
|
||||
} satisfies RecordFramePortMessage);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
message.type === "stopped" &&
|
||||
registered &&
|
||||
message.requestId === registered.requestId &&
|
||||
typeof message.commandId === "string"
|
||||
) {
|
||||
registered.stopWaiters.get(message.commandId)?.(message.ok === true);
|
||||
registered.stopWaiters.delete(message.commandId);
|
||||
}
|
||||
});
|
||||
port.onDisconnect.addListener(() => {
|
||||
if (!registered) return;
|
||||
for (const resolve of registered.stopWaiters.values()) resolve(true);
|
||||
const recording = this.#recordings.get(registered.requestId);
|
||||
const key = documentKey(registered.tabId, registered.documentId);
|
||||
if (recording?.agents.get(key) === registered) recording.agents.delete(key);
|
||||
});
|
||||
};
|
||||
|
||||
#stopAgent(agent: FrameAgent, requestId: string, commandId: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
agent.stopWaiters.delete(commandId);
|
||||
resolve(false);
|
||||
}, RECORD_FRAME_STOP_TIMEOUT_MS);
|
||||
agent.stopWaiters.set(commandId, (ok) => {
|
||||
clearTimeout(timer);
|
||||
resolve(ok);
|
||||
});
|
||||
try {
|
||||
agent.port.postMessage({
|
||||
type: "stop",
|
||||
requestId,
|
||||
commandId,
|
||||
} satisfies RecordFramePortMessage);
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
agent.stopWaiters.delete(commandId);
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async #startFrame(
|
||||
recording: ArmedRecording,
|
||||
tabId: number,
|
||||
frame: BrowserFrame,
|
||||
): Promise<boolean> {
|
||||
const message: RecordFrameStartMessage = {
|
||||
type: RECORD_FRAME_START,
|
||||
requestId: recording.requestId,
|
||||
startedAtMs: recording.startedAtMs,
|
||||
};
|
||||
try {
|
||||
const response = await this.#deps.sendToDocument(tabId, message, {
|
||||
...(frame.documentId ? { documentId: frame.documentId } : { frameId: frame.frameId }),
|
||||
});
|
||||
return isStarted(response);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#cancelAgents(recording: ArmedRecording): void {
|
||||
for (const agent of recording.agents.values()) {
|
||||
try {
|
||||
agent.port.postMessage({
|
||||
type: "cancel",
|
||||
requestId: recording.requestId,
|
||||
} satisfies RecordFramePortMessage);
|
||||
} catch {
|
||||
// A destroyed Document has no remaining capture state to cancel.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isStarted(value: unknown): boolean {
|
||||
return typeof value === "object" && value !== null && (value as { ok?: unknown }).ok === true;
|
||||
}
|
||||
|
||||
export const recordFrameCoordinator = new RecordFrameCoordinator();
|
||||
@@ -0,0 +1,5 @@
|
||||
const REDIRECT_QUALIFIERS = new Set(["client_redirect", "server_redirect"]);
|
||||
|
||||
export function hasRedirectQualifier(qualifiers?: readonly string[]): boolean {
|
||||
return (qualifiers ?? []).some((qualifier) => REDIRECT_QUALIFIERS.has(qualifier));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RenderedRef } from "@browser-skill/vom";
|
||||
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
||||
import {
|
||||
type CaptureVomMatchNode,
|
||||
type CaptureVomObservationResult,
|
||||
@@ -28,6 +29,11 @@ export interface RegisteredObservation {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface RecordingDocumentScope {
|
||||
frameId: string;
|
||||
target: CdpTarget;
|
||||
}
|
||||
|
||||
function nodeKey(frameId: string, backendNodeId: number): string {
|
||||
return `${frameId}:${backendNodeId}`;
|
||||
}
|
||||
@@ -40,8 +46,12 @@ export class ObservationNodeIndex {
|
||||
readonly #nodesByFrameTag = new Map<string, IndexedObservationNode[]>();
|
||||
readonly #refById = new Map<string, RenderedRef>();
|
||||
readonly #refsByFrame = new Map<string, RenderedRef[]>();
|
||||
readonly #scopeByProducer = new Map<string, RecordingDocumentScope | null>();
|
||||
|
||||
constructor(input: Pick<CaptureVomObservationResult, "rootFrameId" | "matchNodes" | "refs">) {
|
||||
constructor(
|
||||
input: Pick<CaptureVomObservationResult, "rootFrameId" | "matchNodes" | "refs"> &
|
||||
Partial<Pick<CaptureVomObservationResult, "frames">>,
|
||||
) {
|
||||
const refByNode = new Map<string, RenderedRef>();
|
||||
for (const ref of input.refs) {
|
||||
const frameId = ref.frameId ?? input.rootFrameId;
|
||||
@@ -51,6 +61,16 @@ export class ObservationNodeIndex {
|
||||
frameRefs.push(ref);
|
||||
this.#refsByFrame.set(frameId, frameRefs);
|
||||
}
|
||||
for (const frame of input.frames ?? []) {
|
||||
const documentId = frame.recordingDocumentId;
|
||||
if (!documentId) continue;
|
||||
this.#scopeByProducer.set(
|
||||
documentId,
|
||||
this.#scopeByProducer.has(documentId)
|
||||
? null
|
||||
: { frameId: frame.frameId, target: frame.target },
|
||||
);
|
||||
}
|
||||
for (const geometry of input.matchNodes) {
|
||||
const { frameId } = geometry;
|
||||
const entry = {
|
||||
@@ -76,6 +96,10 @@ export class ObservationNodeIndex {
|
||||
refs(frameId: string): readonly RenderedRef[] {
|
||||
return this.#refsByFrame.get(frameId) ?? [];
|
||||
}
|
||||
|
||||
documentScope(producerId: string): RecordingDocumentScope | undefined {
|
||||
return this.#scopeByProducer.get(producerId) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function readTabMeta(
|
||||
|
||||
@@ -28,7 +28,13 @@ function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
}
|
||||
|
||||
function isTargeted(draft: RecordingDraftStep): draft is TargetedRecordingDraft {
|
||||
return draft.op !== "navigate" && draft.op !== "scroll";
|
||||
return (
|
||||
draft.op === "click" ||
|
||||
draft.op === "hover" ||
|
||||
draft.op === "fill" ||
|
||||
draft.op === "press" ||
|
||||
draft.op === "select"
|
||||
);
|
||||
}
|
||||
|
||||
export class RecordingObservationSession {
|
||||
@@ -74,7 +80,7 @@ export class RecordingObservationSession {
|
||||
const state = this.registry.register({
|
||||
url: captured.url,
|
||||
title: captured.title,
|
||||
rawVomText: captured.vomText,
|
||||
vomText: captured.vomText,
|
||||
truncated: captured.truncated,
|
||||
});
|
||||
const observation: RegisteredObservation = {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import type { CdpRunner, ChromeTabsApi } from "@/tools/shared";
|
||||
import type { StopReason, TraceV3 } from "@/transport/types";
|
||||
import { type DocumentSettleScope, waitForDocumentSettled } from "./document-settle";
|
||||
import type { RegisteredObservation } from "./observation-capture";
|
||||
import { RecordingObservationSession } from "./observation-session";
|
||||
import { inferMissingPostStates, SettleController } from "./settle-controller";
|
||||
import { RecordingStateRegistry } from "./state-registry";
|
||||
import { buildTraceV3 } from "./trace-builder-v3";
|
||||
import type { RecordingDraftStep, StepAnnotation } from "./types";
|
||||
|
||||
interface TabRecordingContext {
|
||||
session: RecordingObservationSession;
|
||||
settle: SettleController;
|
||||
pendingCapture: PendingCapture | null;
|
||||
frameRefresh: Promise<void>;
|
||||
frameRefreshAbort: AbortController | null;
|
||||
}
|
||||
|
||||
interface PendingCapture {
|
||||
promise: Promise<RegisteredObservation>;
|
||||
abort: AbortController;
|
||||
}
|
||||
|
||||
export class RecordingObservationRuntime {
|
||||
readonly #cdp: CdpRunner;
|
||||
readonly #tabsApi: ChromeTabsApi;
|
||||
readonly #registry = new RecordingStateRegistry();
|
||||
readonly #annotations: StepAnnotation[] = [];
|
||||
readonly #contexts = new Map<number, TabRecordingContext>();
|
||||
readonly #maxTokens?: number;
|
||||
readonly #redactValues: boolean;
|
||||
|
||||
constructor(input: {
|
||||
cdp: CdpRunner;
|
||||
tabsApi: ChromeTabsApi;
|
||||
maxTokens?: number;
|
||||
redactValues?: boolean;
|
||||
}) {
|
||||
this.#cdp = input.cdp;
|
||||
this.#tabsApi = input.tabsApi;
|
||||
this.#maxTokens = input.maxTokens;
|
||||
this.#redactValues = input.redactValues ?? false;
|
||||
}
|
||||
|
||||
#context(tabId: number): TabRecordingContext {
|
||||
const existing = this.#contexts.get(tabId);
|
||||
if (existing) return existing;
|
||||
const session = new RecordingObservationSession({
|
||||
registry: this.#registry,
|
||||
annotations: this.#annotations,
|
||||
maxTokens: this.#maxTokens,
|
||||
redactValues: this.#redactValues,
|
||||
});
|
||||
const context: TabRecordingContext = {
|
||||
session,
|
||||
settle: new SettleController({
|
||||
session,
|
||||
cdp: this.#cdp,
|
||||
tabsApi: this.#tabsApi,
|
||||
tabId,
|
||||
}),
|
||||
pendingCapture: null,
|
||||
frameRefresh: Promise.resolve(),
|
||||
frameRefreshAbort: null,
|
||||
};
|
||||
this.#contexts.set(tabId, context);
|
||||
return context;
|
||||
}
|
||||
|
||||
async #capture(tabId: number): Promise<RegisteredObservation> {
|
||||
const context = this.#context(tabId);
|
||||
if (context.pendingCapture) return context.pendingCapture.promise;
|
||||
|
||||
const abort = new AbortController();
|
||||
let pending!: PendingCapture;
|
||||
const promise = context.session.capture(this.#cdp, this.#tabsApi, tabId, abort.signal);
|
||||
pending = {
|
||||
abort,
|
||||
promise: promise.finally(() => {
|
||||
if (context.pendingCapture === pending) context.pendingCapture = null;
|
||||
}),
|
||||
};
|
||||
context.pendingCapture = pending;
|
||||
return pending.promise;
|
||||
}
|
||||
|
||||
async captureInitial(tabId: number): Promise<void> {
|
||||
const context = this.#context(tabId);
|
||||
if (context.session.cursor.lastSettled) return;
|
||||
await this.#capture(tabId);
|
||||
}
|
||||
|
||||
/** Refresh the safe observation after a late child Document becomes usable. */
|
||||
async refreshDocument(tabId: number, producerId: string): Promise<void> {
|
||||
const context = this.#context(tabId);
|
||||
context.frameRefreshAbort?.abort();
|
||||
const abort = new AbortController();
|
||||
context.frameRefreshAbort = abort;
|
||||
const previous = context.frameRefresh;
|
||||
const refresh = (async () => {
|
||||
await previous;
|
||||
if (abort.signal.aborted) return;
|
||||
if (context.pendingCapture) await Promise.allSettled([context.pendingCapture.promise]);
|
||||
if (abort.signal.aborted) return;
|
||||
|
||||
// The first capture discovers the safe producer -> CDP Document scope.
|
||||
await this.#capture(tabId);
|
||||
if (abort.signal.aborted) return;
|
||||
const scope = context.session.cursor.lastSettled?.index.documentScope(producerId);
|
||||
if (scope) {
|
||||
await waitForDocumentSettled(this.#cdp, scope, { signal: abort.signal });
|
||||
}
|
||||
if (abort.signal.aborted) return;
|
||||
|
||||
// Capture again after the child SPA is quiet so the next action can be
|
||||
// matched against refs that actually exist in its pre-action state.
|
||||
await this.#capture(tabId);
|
||||
})();
|
||||
const tracked = refresh.catch(() => {});
|
||||
context.frameRefresh = tracked;
|
||||
try {
|
||||
await refresh;
|
||||
} finally {
|
||||
if (context.frameRefresh === tracked) context.frameRefreshAbort = null;
|
||||
}
|
||||
}
|
||||
|
||||
async captureTabTransition(
|
||||
fromTabId: number,
|
||||
toTabId: number,
|
||||
): Promise<{ preStateId?: string; postStateId?: string; targetUrl?: string }> {
|
||||
const from = this.#context(fromTabId);
|
||||
await this.#flushContext(from);
|
||||
if (!from.session.cursor.lastSettled) {
|
||||
try {
|
||||
await this.captureInitial(fromTabId);
|
||||
} catch {
|
||||
// A target-tab observation can still preserve the forward transition.
|
||||
}
|
||||
}
|
||||
|
||||
const to = this.#context(toTabId);
|
||||
let target = to.session.cursor.lastSettled;
|
||||
try {
|
||||
target = await this.#capture(toTabId);
|
||||
} catch {
|
||||
// Reuse the last settled target state when a refresh races navigation.
|
||||
}
|
||||
|
||||
const source = from.session.cursor.lastSettled;
|
||||
return {
|
||||
...(source ? { preStateId: source.stateId } : {}),
|
||||
...(target ? { postStateId: target.stateId, targetUrl: target.url } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
bindTabTransition(
|
||||
draft: Extract<RecordingDraftStep, { op: "switch_tab" }>,
|
||||
draftId: number,
|
||||
): void {
|
||||
if (draft.preStateId) this.#registry.markStep(draft.preStateId, draftId);
|
||||
}
|
||||
|
||||
async processDraft(
|
||||
tabId: number,
|
||||
drafts: RecordingDraftStep[],
|
||||
draftIndex: number,
|
||||
producerId?: string,
|
||||
): Promise<void> {
|
||||
const draft = drafts[draftIndex];
|
||||
if (!draft) return;
|
||||
const context = this.#context(tabId);
|
||||
if (!context.session.cursor.lastSettled) {
|
||||
try {
|
||||
await this.captureInitial(tabId);
|
||||
} catch {
|
||||
// Post-action settle can still provide a usable state.
|
||||
}
|
||||
}
|
||||
const scope = producerId
|
||||
? context.session.cursor.lastSettled?.index.documentScope(producerId)
|
||||
: undefined;
|
||||
if (
|
||||
producerId &&
|
||||
(draft.op === "click" ||
|
||||
draft.op === "hover" ||
|
||||
draft.op === "fill" ||
|
||||
draft.op === "press" ||
|
||||
draft.op === "select")
|
||||
) {
|
||||
draft.targetHint = {
|
||||
...(draft.targetHint ?? {}),
|
||||
frameId: scope?.frameId ?? null,
|
||||
geometrySpace: "local",
|
||||
};
|
||||
}
|
||||
context.session.bindDraft(draft, draftIndex + 1, context.settle.hasPending);
|
||||
context.settle.schedule(drafts, draftIndex, scope);
|
||||
}
|
||||
|
||||
scheduleSettle(
|
||||
tabId: number,
|
||||
drafts: RecordingDraftStep[],
|
||||
draftIndex: number,
|
||||
scope?: DocumentSettleScope,
|
||||
): void {
|
||||
this.#context(tabId).settle.schedule(drafts, draftIndex, scope);
|
||||
}
|
||||
|
||||
clearRedirect(tabId: number): void {
|
||||
this.#contexts.get(tabId)?.settle.clearRedirect();
|
||||
}
|
||||
|
||||
scheduleRedirect(tabId: number, drafts: RecordingDraftStep[], url: string): void {
|
||||
this.#context(tabId).settle.scheduleRedirect(drafts, url);
|
||||
}
|
||||
|
||||
async #flushContext(context: TabRecordingContext): Promise<void> {
|
||||
await context.frameRefresh;
|
||||
if (context.pendingCapture) await Promise.allSettled([context.pendingCapture.promise]);
|
||||
await context.settle.flushRedirects();
|
||||
await context.settle.flush();
|
||||
}
|
||||
|
||||
async flushRedirects(tabId: number): Promise<void> {
|
||||
await this.#contexts.get(tabId)?.settle.flushRedirects();
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
for (const context of this.#contexts.values()) await this.#flushContext(context);
|
||||
}
|
||||
|
||||
async settleTrailing(tabId: number, drafts: RecordingDraftStep[]): Promise<void> {
|
||||
const context = this.#context(tabId);
|
||||
if (context.pendingCapture) await Promise.allSettled([context.pendingCapture.promise]);
|
||||
if (!context.session.cursor.lastSettled) {
|
||||
try {
|
||||
await this.captureInitial(tabId);
|
||||
} catch {
|
||||
// The trace may remain observation-free when CDP is unavailable.
|
||||
}
|
||||
}
|
||||
await context.settle.settleTrailing(drafts);
|
||||
inferMissingPostStates(drafts);
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
for (const context of this.#contexts.values()) {
|
||||
context.frameRefreshAbort?.abort();
|
||||
context.pendingCapture?.abort.abort();
|
||||
context.settle.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
buildTrace(input: {
|
||||
drafts: RecordingDraftStep[];
|
||||
startedAt: string;
|
||||
purpose?: string;
|
||||
startUrl?: string;
|
||||
stoppedBy: StopReason;
|
||||
bskVersion: string;
|
||||
includeTabSwitches?: boolean;
|
||||
}): TraceV3 {
|
||||
return buildTraceV3({
|
||||
registry: this.#registry,
|
||||
drafts: input.drafts,
|
||||
annotations: this.#annotations,
|
||||
startedAt: input.startedAt,
|
||||
purpose: input.purpose,
|
||||
startUrl: input.startUrl,
|
||||
stoppedBy: input.stoppedBy,
|
||||
bskVersion: input.bskVersion,
|
||||
redactValues: this.#redactValues,
|
||||
includeTabSwitches: input.includeTabSwitches,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,7 @@ export class SettleController {
|
||||
url: finalUrl,
|
||||
pageUrl: finalUrl,
|
||||
cause: "browser",
|
||||
transitionQualifiers: ["server_redirect"],
|
||||
preStateId: this.#session.cursor.lastSettled?.stateId,
|
||||
};
|
||||
drafts.push(draft);
|
||||
|
||||
@@ -2,7 +2,8 @@ export interface RecordedStateEntry {
|
||||
id: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
rawVomText: string;
|
||||
/** Rendered VOM text; capture indexes and source DOM/AX data never enter the registry. */
|
||||
vomText: string;
|
||||
truncated: boolean;
|
||||
stepsHere: number[];
|
||||
}
|
||||
@@ -19,10 +20,10 @@ export class RecordingStateRegistry {
|
||||
register(input: {
|
||||
url: string;
|
||||
title?: string;
|
||||
rawVomText: string;
|
||||
vomText: string;
|
||||
truncated?: boolean;
|
||||
}): RecordedStateEntry {
|
||||
const identity = stateIdentity(input.url, input.rawVomText);
|
||||
const identity = stateIdentity(input.url, input.vomText);
|
||||
const existingId = this.#idByIdentity.get(identity);
|
||||
if (existingId) {
|
||||
const existing = this.#entriesById.get(existingId)!;
|
||||
@@ -35,7 +36,7 @@ export class RecordingStateRegistry {
|
||||
id: `s${this.#nextId}`,
|
||||
url: input.url,
|
||||
...(input.title ? { title: input.title } : {}),
|
||||
rawVomText: input.rawVomText,
|
||||
vomText: input.vomText,
|
||||
truncated: input.truncated ?? false,
|
||||
stepsHere: [],
|
||||
};
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { RecordStepPayload } from "../record-bridge";
|
||||
import type { RecordingDraftStep } from "./types";
|
||||
import { hasRedirectQualifier } from "./navigation-policy";
|
||||
import type { RecordingDraftStep, TargetMatchHint } from "./types";
|
||||
|
||||
export interface RecordingStepBuffer {
|
||||
steps: RecordingDraftStep[];
|
||||
navigation: RecordingNavigationCursor;
|
||||
}
|
||||
|
||||
export interface RecordingNavigationCursor {
|
||||
currentUrl?: string;
|
||||
pendingNavigation: boolean;
|
||||
pendingNavigationDeadline?: number;
|
||||
@@ -10,33 +15,29 @@ export interface RecordingStepBuffer {
|
||||
|
||||
const NAVIGATION_TRIGGER_WINDOW_MS = 3_000;
|
||||
|
||||
function toDraftStep(payload: RecordStepPayload): RecordingDraftStep | null {
|
||||
function toDraftStep(
|
||||
payload: RecordStepPayload,
|
||||
targetHint?: TargetMatchHint,
|
||||
): RecordingDraftStep | null {
|
||||
const pageUrl = payload.page_url;
|
||||
const common = {
|
||||
...(pageUrl ? { pageUrl } : {}),
|
||||
...(targetHint ? { targetHint } : {}),
|
||||
};
|
||||
switch (payload.op) {
|
||||
case "click":
|
||||
return payload.target
|
||||
? {
|
||||
op: "click",
|
||||
captureTarget: payload.target,
|
||||
...(pageUrl ? { pageUrl } : {}),
|
||||
}
|
||||
: null;
|
||||
return payload.target ? { op: "click", captureTarget: payload.target, ...common } : null;
|
||||
case "hover":
|
||||
return payload.target
|
||||
? {
|
||||
op: "hover",
|
||||
captureTarget: payload.target,
|
||||
...(pageUrl ? { pageUrl } : {}),
|
||||
}
|
||||
: null;
|
||||
return payload.target ? { op: "hover", captureTarget: payload.target, ...common } : null;
|
||||
case "fill":
|
||||
return payload.target
|
||||
? {
|
||||
op: "fill",
|
||||
captureTarget: payload.target,
|
||||
value: payload.value ?? "",
|
||||
...(payload.commit ? { commit: payload.commit } : {}),
|
||||
...(payload.redacted ? { redacted: true } : {}),
|
||||
...(pageUrl ? { pageUrl } : {}),
|
||||
...common,
|
||||
}
|
||||
: null;
|
||||
case "press":
|
||||
@@ -46,7 +47,7 @@ function toDraftStep(payload: RecordStepPayload): RecordingDraftStep | null {
|
||||
key: payload.key,
|
||||
...(payload.target ? { captureTarget: payload.target } : {}),
|
||||
...(payload.modifiers?.length ? { modifiers: payload.modifiers } : {}),
|
||||
...(pageUrl ? { pageUrl } : {}),
|
||||
...common,
|
||||
}
|
||||
: null;
|
||||
case "select":
|
||||
@@ -56,7 +57,7 @@ function toDraftStep(payload: RecordStepPayload): RecordingDraftStep | null {
|
||||
captureTarget: payload.target,
|
||||
values: payload.values,
|
||||
...(payload.labels?.length ? { labels: payload.labels } : {}),
|
||||
...(pageUrl ? { pageUrl } : {}),
|
||||
...common,
|
||||
}
|
||||
: null;
|
||||
case "navigate":
|
||||
@@ -64,73 +65,91 @@ function toDraftStep(payload: RecordStepPayload): RecordingDraftStep | null {
|
||||
}
|
||||
}
|
||||
|
||||
function annotateLastStepNavigation(buffer: RecordingStepBuffer, url: string): boolean {
|
||||
for (let i = buffer.steps.length - 1; i >= 0; i -= 1) {
|
||||
const step = buffer.steps[i];
|
||||
function annotateLastStepNavigation(buffer: RecordingStepBuffer, url: string): number {
|
||||
for (let index = buffer.steps.length - 1; index >= 0; index -= 1) {
|
||||
const step = buffer.steps[index];
|
||||
if (!step) continue;
|
||||
if (step.op === "click" || step.op === "press" || step.op === "select") {
|
||||
buffer.steps[i] = { ...step, navigatedTo: url };
|
||||
return true;
|
||||
if (step.op === "click" || step.op === "press" || step.op === "select" || step.op === "fill") {
|
||||
step.navigatedTo = url;
|
||||
return index;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
return -1;
|
||||
}
|
||||
|
||||
export type NavigationObserveResult =
|
||||
| { kind: "noop" }
|
||||
| { kind: "annotated"; index: number }
|
||||
| { kind: "appended"; index: number }
|
||||
| { kind: "coalesce_redirect"; url: string };
|
||||
|
||||
export function observeRecordedNavigation(
|
||||
buffer: RecordingStepBuffer,
|
||||
url: string,
|
||||
causedByAction?: boolean,
|
||||
): void {
|
||||
if (!url || url === buffer.currentUrl) return;
|
||||
buffer.currentUrl = url;
|
||||
transitionType?: string,
|
||||
transitionQualifiers?: string[],
|
||||
): NavigationObserveResult {
|
||||
const navigation = buffer.navigation;
|
||||
if (!url || url === navigation.currentUrl) return { kind: "noop" };
|
||||
navigation.currentUrl = url;
|
||||
|
||||
const pendingIsCurrent =
|
||||
buffer.pendingNavigation &&
|
||||
(buffer.pendingNavigationDeadline === undefined ||
|
||||
buffer.pendingNavigationDeadline >= Date.now());
|
||||
navigation.pendingNavigation &&
|
||||
(navigation.pendingNavigationDeadline === undefined ||
|
||||
navigation.pendingNavigationDeadline >= Date.now());
|
||||
|
||||
if (causedByAction === true || (causedByAction === undefined && pendingIsCurrent)) {
|
||||
buffer.pendingNavigation = false;
|
||||
buffer.pendingNavigationDeadline = undefined;
|
||||
if (!annotateLastStepNavigation(buffer, url)) {
|
||||
buffer.steps.push({
|
||||
op: "navigate",
|
||||
url,
|
||||
pageUrl: url,
|
||||
});
|
||||
}
|
||||
return;
|
||||
navigation.pendingNavigation = false;
|
||||
navigation.pendingNavigationDeadline = undefined;
|
||||
const annotatedIndex = annotateLastStepNavigation(buffer, url);
|
||||
if (annotatedIndex >= 0) return { kind: "annotated", index: annotatedIndex };
|
||||
} else {
|
||||
navigation.pendingNavigation = false;
|
||||
navigation.pendingNavigationDeadline = undefined;
|
||||
}
|
||||
|
||||
buffer.pendingNavigation = false;
|
||||
buffer.pendingNavigationDeadline = undefined;
|
||||
if (hasRedirectQualifier(transitionQualifiers)) {
|
||||
return { kind: "coalesce_redirect", url };
|
||||
}
|
||||
buffer.steps.push({
|
||||
op: "navigate",
|
||||
url,
|
||||
pageUrl: url,
|
||||
transitionType,
|
||||
transitionQualifiers,
|
||||
});
|
||||
return { kind: "appended", index: buffer.steps.length - 1 };
|
||||
}
|
||||
|
||||
export function appendRecordedPayload(
|
||||
buffer: RecordingStepBuffer,
|
||||
payload: RecordStepPayload,
|
||||
): void {
|
||||
targetHint?: TargetMatchHint,
|
||||
): number | null {
|
||||
if (payload.op === "navigate") {
|
||||
if (payload.url) {
|
||||
observeRecordedNavigation(buffer, payload.url, payload.navigation_caused_by_action);
|
||||
}
|
||||
return;
|
||||
if (!payload.url) return null;
|
||||
const result = observeRecordedNavigation(
|
||||
buffer,
|
||||
payload.url,
|
||||
payload.navigation_caused_by_action,
|
||||
payload.transitionType,
|
||||
payload.transitionQualifiers,
|
||||
);
|
||||
return result.kind === "appended" ? result.index : null;
|
||||
}
|
||||
const step = toDraftStep({
|
||||
...payload,
|
||||
page_url: payload.page_url ?? buffer.currentUrl,
|
||||
});
|
||||
if (!step) return;
|
||||
const step = toDraftStep(
|
||||
{ ...payload, page_url: payload.page_url ?? buffer.navigation.currentUrl },
|
||||
targetHint,
|
||||
);
|
||||
if (!step) return null;
|
||||
buffer.steps.push(step);
|
||||
if (step.op === "click" || step.op === "press" || step.op === "select" || step.op === "fill") {
|
||||
buffer.pendingNavigation = payload.expects_navigation === true;
|
||||
buffer.pendingNavigationDeadline = buffer.pendingNavigation
|
||||
buffer.navigation.pendingNavigation = payload.expects_navigation === true;
|
||||
buffer.navigation.pendingNavigationDeadline = buffer.navigation.pendingNavigation
|
||||
? Date.now() + NAVIGATION_TRIGGER_WINDOW_MS
|
||||
: undefined;
|
||||
}
|
||||
return buffer.steps.length - 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { RecordingNavigationCursor } from "./step-buffer";
|
||||
|
||||
export interface TabActivation {
|
||||
tabId: number;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export class RecordingTabCoordinator {
|
||||
readonly #navigationByTab = new Map<number, RecordingNavigationCursor>();
|
||||
#activeTabId: number;
|
||||
#currentTabId: number;
|
||||
#activationRevision = 0;
|
||||
|
||||
constructor(initialTabId: number, initialUrl?: string) {
|
||||
this.#activeTabId = initialTabId;
|
||||
this.#currentTabId = initialTabId;
|
||||
this.#navigationByTab.set(initialTabId, {
|
||||
currentUrl: initialUrl,
|
||||
pendingNavigation: false,
|
||||
});
|
||||
}
|
||||
|
||||
get activeTabId(): number {
|
||||
return this.#activeTabId;
|
||||
}
|
||||
|
||||
get currentTabId(): number {
|
||||
return this.#currentTabId;
|
||||
}
|
||||
|
||||
noteActivation(tabId: number): TabActivation {
|
||||
this.#activeTabId = tabId;
|
||||
this.#activationRevision += 1;
|
||||
return { tabId, revision: this.#activationRevision };
|
||||
}
|
||||
|
||||
isLatest(activation: TabActivation): boolean {
|
||||
return (
|
||||
activation.tabId === this.#activeTabId && activation.revision === this.#activationRevision
|
||||
);
|
||||
}
|
||||
|
||||
commit(tabId: number, currentUrl?: string): void {
|
||||
if (currentUrl !== undefined) this.navigation(tabId).currentUrl = currentUrl;
|
||||
this.#currentTabId = tabId;
|
||||
}
|
||||
|
||||
navigation(tabId: number, fallbackUrl?: string): RecordingNavigationCursor {
|
||||
const existing = this.#navigationByTab.get(tabId);
|
||||
if (existing) {
|
||||
if (!existing.currentUrl && fallbackUrl) existing.currentUrl = fallbackUrl;
|
||||
return existing;
|
||||
}
|
||||
const created: RecordingNavigationCursor = {
|
||||
currentUrl: fallbackUrl,
|
||||
pendingNavigation: false,
|
||||
};
|
||||
this.#navigationByTab.set(tabId, created);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,13 @@ function rectMatches(a: TargetGeometry["rect"], b: TargetGeometry["rect"]): bool
|
||||
return close(a.x, b.x) && close(a.y, b.y) && close(a.w, b.w) && close(a.h, b.h);
|
||||
}
|
||||
|
||||
function candidateMatches(candidate: IndexedObservationNode, geometry: TargetGeometry): boolean {
|
||||
return candidate.geometry.rect !== null && rectMatches(geometry.rect, candidate.geometry.rect);
|
||||
function candidateMatches(
|
||||
candidate: IndexedObservationNode,
|
||||
geometry: TargetGeometry,
|
||||
geometrySpace: "top" | "local",
|
||||
): boolean {
|
||||
const rect = geometrySpace === "local" ? candidate.geometry.localRect : candidate.geometry.rect;
|
||||
return rect != null && rectMatches(geometry.rect, rect);
|
||||
}
|
||||
|
||||
export function unmatchedTarget(fallback?: CaptureTargetDescriptor): TargetDescriptorV3 {
|
||||
@@ -53,12 +58,17 @@ export function matchObservationTarget(input: {
|
||||
hint?: TargetMatchHint;
|
||||
fallback?: CaptureTargetDescriptor;
|
||||
}): TargetDescriptorV3 {
|
||||
if (input.hint?.frameId === null) return unmatchedTarget(input.fallback);
|
||||
const frameId = input.hint?.frameId ?? input.observation.rootFrameId;
|
||||
const geometry = input.hint?.geometry;
|
||||
if (geometry) {
|
||||
const matches = input.observation.index
|
||||
.candidates(frameId, geometry.tag)
|
||||
.filter((candidate) => candidate.ref && candidateMatches(candidate, geometry));
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.ref &&
|
||||
candidateMatches(candidate, geometry, input.hint?.geometrySpace ?? "top"),
|
||||
);
|
||||
if (matches.length === 1) return descriptor(matches[0]!.ref!);
|
||||
const semanticMatches = matches.filter(
|
||||
(candidate) => candidate.ref && matchesSemantics(candidate.ref, input.fallback),
|
||||
|
||||
@@ -39,8 +39,13 @@ export function buildTraceV3(input: {
|
||||
startUrl?: string;
|
||||
stoppedBy: StopReason;
|
||||
bskVersion: string;
|
||||
redactValues?: boolean;
|
||||
includeTabSwitches?: boolean;
|
||||
}): TraceV3 {
|
||||
const reduced = reduceTraceStepsV3(input.drafts);
|
||||
const reduced = reduceTraceStepsV3(input.drafts, {
|
||||
includeTabSwitches: input.includeTabSwitches,
|
||||
redactValues: input.redactValues,
|
||||
});
|
||||
const entries = publishedEntries(input.registry, reduced.steps);
|
||||
const publishedId = new Map(entries.map((entry, index) => [entry.id, `s${index + 1}`]));
|
||||
const annotationsByState = new Map<string, StepAnnotation[]>();
|
||||
@@ -65,7 +70,7 @@ export function buildTraceV3(input: {
|
||||
url: entry.url,
|
||||
title: entry.title,
|
||||
stepIds: remapDraftIds(entry.stepsHere, reduced.stepIdByDraftId),
|
||||
vomText: entry.rawVomText,
|
||||
vomText: entry.vomText,
|
||||
annotations: annotationsByState.get(entry.id) ?? [],
|
||||
stepIdByDraftId: reduced.stepIdByDraftId,
|
||||
}),
|
||||
|
||||
@@ -119,14 +119,6 @@ function toV2Step(
|
||||
},
|
||||
effectForNavigation(step.navigatedTo, urlToId),
|
||||
);
|
||||
case "hover":
|
||||
if (!step.captureTarget) return null;
|
||||
return {
|
||||
op: "hover",
|
||||
id,
|
||||
page,
|
||||
target: step.captureTarget,
|
||||
};
|
||||
case "fill":
|
||||
if (!step.captureTarget) return null;
|
||||
return {
|
||||
@@ -161,7 +153,11 @@ function toV2Step(
|
||||
},
|
||||
effectForNavigation(step.navigatedTo, urlToId),
|
||||
);
|
||||
// `hover` only exists in Trace v3. Peers that negotiate v2 predate the
|
||||
// step variant and fail to decode the whole result if we emit it.
|
||||
case "hover":
|
||||
case "scroll":
|
||||
case "switch_tab":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NavigationCause, StepV3 } from "@/transport/types";
|
||||
import { shouldIncludeDraft } from "./draft-policy";
|
||||
import { hasRedirectQualifier } from "./navigation-policy";
|
||||
import { unmatchedTarget } from "./target-matcher";
|
||||
import type { RecordingDraftStep } from "./types";
|
||||
|
||||
@@ -8,7 +9,6 @@ interface CollapsedDraft {
|
||||
draftIds: number[];
|
||||
}
|
||||
|
||||
const REDIRECT_QUALIFIERS = new Set(["client_redirect", "server_redirect"]);
|
||||
const TRANSITION_CAUSES: Record<string, NavigationCause> = {
|
||||
typed: "user_typed",
|
||||
generated: "user_typed",
|
||||
@@ -22,7 +22,7 @@ const TRANSITION_CAUSES: Record<string, NavigationCause> = {
|
||||
};
|
||||
|
||||
function isRedirect(step: Extract<RecordingDraftStep, { op: "navigate" }>): boolean {
|
||||
return (step.transitionQualifiers ?? []).some((qualifier) => REDIRECT_QUALIFIERS.has(qualifier));
|
||||
return hasRedirectQualifier(step.transitionQualifiers);
|
||||
}
|
||||
|
||||
function collapseRedirects(steps: RecordingDraftStep[]): CollapsedDraft[] {
|
||||
@@ -58,8 +58,26 @@ function selection(values: string[], labels?: string[]): Array<{ value: string;
|
||||
}));
|
||||
}
|
||||
|
||||
function reduceDraft(draft: RecordingDraftStep, id: number): StepV3 | null {
|
||||
interface ReduceDraftOptions {
|
||||
includeTabSwitches: boolean;
|
||||
redactValues: boolean;
|
||||
}
|
||||
|
||||
function reduceDraft(
|
||||
draft: RecordingDraftStep,
|
||||
id: number,
|
||||
options: ReduceDraftOptions,
|
||||
): StepV3 | null {
|
||||
if (!shouldIncludeDraft(draft)) return null;
|
||||
if (draft.op === "switch_tab") {
|
||||
if (!options.includeTabSwitches || !draft.preStateId || !draft.postStateId) return null;
|
||||
return {
|
||||
op: "switch_tab",
|
||||
id,
|
||||
state: draft.preStateId,
|
||||
result: { state: draft.postStateId },
|
||||
};
|
||||
}
|
||||
const state = draft.preStateId ?? draft.postStateId;
|
||||
const resultState = draft.postStateId ?? draft.preStateId;
|
||||
if (!state || !resultState) return null;
|
||||
@@ -81,13 +99,14 @@ function reduceDraft(draft: RecordingDraftStep, id: number): StepV3 | null {
|
||||
target: draft.matchedTarget ?? unmatchedTarget(draft.captureTarget),
|
||||
};
|
||||
case "fill":
|
||||
const fillIsRedacted = options.redactValues || draft.redacted === true;
|
||||
return {
|
||||
op: "fill",
|
||||
...common,
|
||||
target: draft.matchedTarget ?? unmatchedTarget(draft.captureTarget),
|
||||
value: draft.value,
|
||||
value: fillIsRedacted ? "***" : draft.value,
|
||||
commit: draft.commit ?? "blur",
|
||||
...(draft.redacted ? { redacted: true } : {}),
|
||||
...(fillIsRedacted ? { redacted: true } : {}),
|
||||
};
|
||||
case "press":
|
||||
return {
|
||||
@@ -104,7 +123,7 @@ function reduceDraft(draft: RecordingDraftStep, id: number): StepV3 | null {
|
||||
op: "select",
|
||||
...common,
|
||||
target: draft.matchedTarget ?? unmatchedTarget(draft.captureTarget),
|
||||
selection: selection(draft.values, draft.labels),
|
||||
...(!options.redactValues ? { selection: selection(draft.values, draft.labels) } : {}),
|
||||
};
|
||||
case "scroll":
|
||||
return { op: "scroll", ...common };
|
||||
@@ -116,11 +135,17 @@ export interface ReducedTraceV3 {
|
||||
stepIdByDraftId: Map<number, number>;
|
||||
}
|
||||
|
||||
export function reduceTraceStepsV3(steps: RecordingDraftStep[]): ReducedTraceV3 {
|
||||
export function reduceTraceStepsV3(
|
||||
steps: RecordingDraftStep[],
|
||||
options: { includeTabSwitches?: boolean; redactValues?: boolean } = {},
|
||||
): ReducedTraceV3 {
|
||||
const output: StepV3[] = [];
|
||||
const stepIdByDraftId = new Map<number, number>();
|
||||
for (const { draft, draftIds } of collapseRedirects(steps)) {
|
||||
const step = reduceDraft(draft, output.length + 1);
|
||||
const step = reduceDraft(draft, output.length + 1, {
|
||||
includeTabSwitches: options.includeTabSwitches === true,
|
||||
redactValues: options.redactValues === true,
|
||||
});
|
||||
if (!step) continue;
|
||||
output.push(step);
|
||||
for (const draftId of draftIds) stepIdByDraftId.set(draftId, step.id);
|
||||
|
||||
@@ -15,8 +15,9 @@ export interface TargetGeometry {
|
||||
|
||||
export interface TargetMatchHint {
|
||||
geometry?: TargetGeometry;
|
||||
/** Missing means the current top frame, never an unrestricted frame search. */
|
||||
frameId?: string;
|
||||
/** Missing means top frame; null means the source Document could not be resolved. */
|
||||
frameId?: string | null;
|
||||
geometrySpace?: "top" | "local";
|
||||
}
|
||||
|
||||
export interface StepAnnotation {
|
||||
@@ -69,6 +70,7 @@ export type RecordingDraftStep =
|
||||
DraftTarget &
|
||||
DraftNavigationEffect)
|
||||
| ({ op: "scroll" } & DraftStateLink)
|
||||
| ({ op: "switch_tab" } & DraftStateLink)
|
||||
| ({
|
||||
op: "navigate";
|
||||
url: string;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export const RECORD_DOCUMENT_ATTRIBUTE = "data-bsk-record-document";
|
||||
|
||||
const RECORDING_DOCUMENT_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const RECORDING_DOCUMENT_MARKER_PREFIX = "bsk:";
|
||||
|
||||
export function recordingDocumentMarkerValue(identity: string): string {
|
||||
if (!RECORDING_DOCUMENT_ID_PATTERN.test(identity)) {
|
||||
throw new TypeError("recording Document identity must be a random UUID");
|
||||
}
|
||||
return `${RECORDING_DOCUMENT_MARKER_PREFIX}${identity}`;
|
||||
}
|
||||
|
||||
/** Read BrowserSkill's opaque per-Document recording identity from captured attributes. */
|
||||
export function readRecordingDocumentIdentity(
|
||||
attrs: Readonly<Record<string, string>>,
|
||||
): string | undefined {
|
||||
const marker = attrs[RECORD_DOCUMENT_ATTRIBUTE];
|
||||
if (!marker?.startsWith(RECORDING_DOCUMENT_MARKER_PREFIX)) return undefined;
|
||||
const identity = marker.slice(RECORDING_DOCUMENT_MARKER_PREFIX.length);
|
||||
return identity && RECORDING_DOCUMENT_ID_PATTERN.test(identity) ? identity : undefined;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
RequestFrame,
|
||||
} from "@/transport/types";
|
||||
import { ToolDispatcher } from "../dispatcher";
|
||||
import { resetBrowserObservationForTests, setBrowserObservationAttachForTests } from "../record";
|
||||
|
||||
type TestDispatcherCdp = NonNullable<ConstructorParameters<typeof ToolDispatcher>[0]["cdp"]>;
|
||||
|
||||
@@ -44,9 +45,72 @@ function makeRequest(method: string, params: unknown): RequestFrame {
|
||||
|
||||
describe("ToolDispatcher", () => {
|
||||
afterEach(() => {
|
||||
resetBrowserObservationForTests();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("uses the configured recording runtime for start and stop", async () => {
|
||||
const { transport, sent, deliver } = fakeTransport();
|
||||
const sessions = new SessionManager({
|
||||
agentWindow: {
|
||||
create: vi.fn(async () => 4242),
|
||||
remove: vi.fn(),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
},
|
||||
});
|
||||
await sessions.start("aa11");
|
||||
const tab = {
|
||||
id: 7,
|
||||
windowId: 4242,
|
||||
active: true,
|
||||
status: "complete",
|
||||
url: "https://example.com/start",
|
||||
} as chrome.tabs.Tab;
|
||||
const frameCoordinator = {
|
||||
begin: vi.fn(),
|
||||
armTab: vi.fn(async () => true),
|
||||
sourceFor: vi.fn(() => null),
|
||||
stop: vi.fn(async () => true),
|
||||
cancel: vi.fn(),
|
||||
};
|
||||
const sendToTab = vi.fn(async () => ({ ok: true }));
|
||||
setBrowserObservationAttachForTests(
|
||||
() => () => {},
|
||||
() => () => {},
|
||||
);
|
||||
const dispatcher = new ToolDispatcher({
|
||||
transport,
|
||||
sessions,
|
||||
recording: {
|
||||
tabsApi: {
|
||||
get: vi.fn(async () => tab),
|
||||
query: vi.fn(async () => [tab]),
|
||||
},
|
||||
frameCoordinator,
|
||||
sendToTab,
|
||||
},
|
||||
});
|
||||
dispatcher.start();
|
||||
|
||||
deliver(
|
||||
makeRequest("tool.record_start", {
|
||||
session_id: "aa11",
|
||||
url: "https://example.com/start",
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(sent).toHaveLength(1));
|
||||
|
||||
expect(sent[0]).toMatchObject({ result: { tab_id: 7, recording: true } });
|
||||
expect(frameCoordinator.begin).toHaveBeenCalledOnce();
|
||||
expect(frameCoordinator.armTab).toHaveBeenCalledWith(expect.any(String), 7);
|
||||
|
||||
deliver({ id: "r-2", method: "tool.record_stop", params: { session_id: "aa11" } });
|
||||
await vi.waitFor(() => expect(sent).toHaveLength(2));
|
||||
|
||||
expect(frameCoordinator.stop).toHaveBeenCalledOnce();
|
||||
expect(sent[1]).toMatchObject({ id: "r-2", result: { trace: { steps: [] } } });
|
||||
});
|
||||
|
||||
it("routes tool.session_start to the SessionManager and replies with the window id", async () => {
|
||||
const { transport, sent, deliver } = fakeTransport();
|
||||
const sessions = new SessionManager({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,12 @@ import {
|
||||
handleScreenshot,
|
||||
handleSnapshot,
|
||||
} from "./observation";
|
||||
import { handleRecordAwait, handleRecordStart, handleRecordStop } from "./record";
|
||||
import {
|
||||
handleRecordAwait,
|
||||
handleRecordStart,
|
||||
handleRecordStop,
|
||||
type RecordRuntimeDeps,
|
||||
} from "./record";
|
||||
import {
|
||||
handleSessionStart,
|
||||
handleSessionStop,
|
||||
@@ -99,6 +104,7 @@ export interface DispatcherDeps {
|
||||
transport: Transport;
|
||||
sessions: SessionManager;
|
||||
cdp?: DispatcherCdpRunner;
|
||||
recording?: RecordRuntimeDeps;
|
||||
/**
|
||||
* Invoked whenever a dispatched RPC may have changed the live
|
||||
* session set (currently `tool.session_start` and
|
||||
@@ -134,6 +140,7 @@ export class ToolDispatcher {
|
||||
private readonly transport: Transport;
|
||||
private readonly sessions: SessionManager;
|
||||
private readonly cdp?: DispatcherCdpRunner;
|
||||
private readonly recording?: RecordRuntimeDeps;
|
||||
private readonly onSessionsChanged?: () => void;
|
||||
private readonly onBrowserControlResumed?: (sessionId: string) => void;
|
||||
private readonly approveBorrow?: BorrowConfirmationApprover;
|
||||
@@ -153,6 +160,7 @@ export class ToolDispatcher {
|
||||
this.transport = deps.transport;
|
||||
this.sessions = deps.sessions;
|
||||
this.cdp = deps.cdp;
|
||||
this.recording = deps.recording;
|
||||
this.onSessionsChanged = deps.onSessionsChanged;
|
||||
this.onBrowserControlResumed = deps.onBrowserControlResumed;
|
||||
this.approveBorrow = deps.approveBorrow;
|
||||
@@ -531,44 +539,26 @@ export class ToolDispatcher {
|
||||
signal,
|
||||
});
|
||||
case "tool.record_start":
|
||||
return handleRecordStart(this.sessions, req.params as RecordStartParams, {
|
||||
tabsApi: chromeTabsApi,
|
||||
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
|
||||
bypassOverlay: async (tabId, enabled) => {
|
||||
try {
|
||||
await chrome.tabs.sendMessage(tabId, {
|
||||
type: OVERLAY_AUTOMATION_BYPASS,
|
||||
enabled,
|
||||
});
|
||||
} catch {
|
||||
// Content script may be unavailable on restricted pages.
|
||||
}
|
||||
},
|
||||
...(this.cdp ? { cdp: this.cdp } : {}),
|
||||
signal,
|
||||
});
|
||||
return this.recording
|
||||
? handleRecordStart(this.sessions, req.params as RecordStartParams, {
|
||||
...this.recording,
|
||||
signal,
|
||||
})
|
||||
: recordingRuntimeUnavailable();
|
||||
case "tool.record_stop":
|
||||
return handleRecordStop(this.sessions, req.params as RecordStopParams, {
|
||||
tabsApi: chromeTabsApi,
|
||||
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
|
||||
bypassOverlay: async (tabId, enabled) => {
|
||||
try {
|
||||
await chrome.tabs.sendMessage(tabId, {
|
||||
type: OVERLAY_AUTOMATION_BYPASS,
|
||||
enabled,
|
||||
});
|
||||
} catch {
|
||||
// Content script may be unavailable on restricted pages.
|
||||
}
|
||||
},
|
||||
signal,
|
||||
});
|
||||
return this.recording
|
||||
? handleRecordStop(this.sessions, req.params as RecordStopParams, {
|
||||
...this.recording,
|
||||
signal,
|
||||
})
|
||||
: recordingRuntimeUnavailable();
|
||||
case "tool.record_await":
|
||||
return handleRecordAwait(this.sessions, req.params as RecordAwaitParams, {
|
||||
tabsApi: chromeTabsApi,
|
||||
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
|
||||
signal,
|
||||
});
|
||||
return this.recording
|
||||
? handleRecordAwait(this.sessions, req.params as RecordAwaitParams, {
|
||||
...this.recording,
|
||||
signal,
|
||||
})
|
||||
: recordingRuntimeUnavailable();
|
||||
default:
|
||||
return {
|
||||
code: "unknown_method",
|
||||
@@ -704,6 +694,13 @@ function isRpcError(v: unknown): v is RpcError {
|
||||
);
|
||||
}
|
||||
|
||||
function recordingRuntimeUnavailable(): RpcError {
|
||||
return {
|
||||
code: "protocol_error",
|
||||
message: "recording runtime is unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
function sessionIdForBrowserControlMethod(req: RequestFrame): string | null {
|
||||
switch (req.method) {
|
||||
case "tool.tab_create":
|
||||
|
||||
@@ -15,22 +15,37 @@ import {
|
||||
type RecordQueryResponse,
|
||||
type RecordStartAck,
|
||||
type RecordStartMessage,
|
||||
type RecordStepAck,
|
||||
type RecordStopMessage,
|
||||
} from "@/lib/record-bridge";
|
||||
import { appendRecordedPayload, observeRecordedNavigation } from "@/lib/recording/step-buffer";
|
||||
import {
|
||||
type RecordFrameCoordinator,
|
||||
type RecordingCaptureScope,
|
||||
recordFrameCoordinator,
|
||||
} from "@/lib/recording/frame-coordinator";
|
||||
import { RecordingObservationRuntime } from "@/lib/recording/recording-runtime";
|
||||
import {
|
||||
appendRecordedPayload,
|
||||
observeRecordedNavigation,
|
||||
type RecordingStepBuffer,
|
||||
} from "@/lib/recording/step-buffer";
|
||||
import { RecordingTabCoordinator, type TabActivation } from "@/lib/recording/tab-coordinator";
|
||||
import { buildTraceV2 } from "@/lib/recording/trace-reducer-v2";
|
||||
import type { RecordingDraftStep } from "@/lib/recording/types";
|
||||
import type { SessionManager } from "@/session-manager/manager";
|
||||
import { EXTENSION_VERSION } from "@/transport/handshake";
|
||||
import type {
|
||||
RecordAwaitParams,
|
||||
RecordAwaitResult,
|
||||
RecordedTrace,
|
||||
RecordStartParams,
|
||||
RecordStartResult,
|
||||
RecordStopParams,
|
||||
RecordStopResult,
|
||||
RpcError,
|
||||
TraceV2,
|
||||
StopReason,
|
||||
} from "@/transport/types";
|
||||
import { TRACE_VERSION_V3 } from "@/transport/types";
|
||||
import { handleNavigate } from "./navigation";
|
||||
import {
|
||||
type CdpRunner,
|
||||
@@ -43,21 +58,46 @@ import {
|
||||
|
||||
interface ActiveRecording {
|
||||
requestId: string;
|
||||
tabId: number;
|
||||
tabs: RecordingTabCoordinator;
|
||||
agentWindowId: number;
|
||||
startUrl?: string;
|
||||
purpose?: string;
|
||||
steps: RecordingDraftStep[];
|
||||
startedAt: string;
|
||||
startedAtMs: number;
|
||||
finishPromise: Promise<TraceV2>;
|
||||
resolveFinish: (trace: TraceV2) => void;
|
||||
traceVersion: 2 | 3;
|
||||
supportsTabSwitchSteps: boolean;
|
||||
finishPromise: Promise<RecordedTrace>;
|
||||
resolveFinish: (trace: RecordedTrace) => void;
|
||||
rejectFinish: (err: Error) => void;
|
||||
settled: boolean;
|
||||
finishing: boolean;
|
||||
currentUrl?: string;
|
||||
pendingNavigation: boolean;
|
||||
pendingNavigationDeadline?: number;
|
||||
finishAttempt: Promise<RecordedTrace | null> | null;
|
||||
observation: RecordingObservationRuntime | null;
|
||||
stoppedBy: StopReason;
|
||||
/** Navigation callbacks tracked from event receipt through action enqueue. */
|
||||
navigationCallbacks: Set<Promise<void>>;
|
||||
/** Synchronous intake gate closed only after finish drains to stability. */
|
||||
acceptingNavigation: boolean;
|
||||
/**
|
||||
* Serializes step appends with navigation observation so a click is always
|
||||
* in `steps` before a same-turn `webNavigation` tries to annotate it.
|
||||
*/
|
||||
actionQueue: Promise<void>;
|
||||
/** Last accepted sequence for each content-script document producer. */
|
||||
lastStepSequenceByProducer: Map<string, number>;
|
||||
}
|
||||
|
||||
function enqueueRecordingAction(
|
||||
recording: ActiveRecording,
|
||||
task: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const queued = recording.actionQueue.then(task, task);
|
||||
recording.actionQueue = queued.catch(() => {});
|
||||
return queued;
|
||||
}
|
||||
|
||||
function isRecordingFinishing(recording: ActiveRecording): boolean {
|
||||
return recording.settled || recording.finishAttempt !== null;
|
||||
}
|
||||
|
||||
const recordings = new Map<string, ActiveRecording>();
|
||||
@@ -78,6 +118,9 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** Recording producer version mirrored into trace.recorder.bsk. */
|
||||
export const BSK_TRACE_VERSION = EXTENSION_VERSION;
|
||||
|
||||
/** Injectable http(s) landing page when `tool.record_start` omits `url`. */
|
||||
export const RECORD_DEFAULT_START_URL = "https://example.com/";
|
||||
|
||||
@@ -137,9 +180,11 @@ async function sendRecordStartWithAck(
|
||||
tabId: number,
|
||||
msg: RecordStartMessage,
|
||||
sendToTab: RecordDeps["sendToTab"],
|
||||
cancelled?: () => boolean,
|
||||
): Promise<void> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < RECORD_START_RETRIES; attempt += 1) {
|
||||
if (cancelled?.()) throw new Error("record start cancelled");
|
||||
try {
|
||||
const response = await sendToTab(tabId, msg);
|
||||
if (isRecordStartAck(response)) return;
|
||||
@@ -147,6 +192,7 @@ async function sendRecordStartWithAck(
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
if (cancelled?.()) throw new Error("record start cancelled");
|
||||
if (attempt + 1 < RECORD_START_RETRIES) {
|
||||
await sleep(RECORD_START_RETRY_DELAY_MS);
|
||||
}
|
||||
@@ -154,7 +200,30 @@ async function sendRecordStartWithAck(
|
||||
throw lastError ?? new Error("failed to start recording in content script");
|
||||
}
|
||||
|
||||
function buildTrace(recording: ActiveRecording): TraceV2 {
|
||||
function negotiatedTraceVersion(params: RecordStartParams): 2 | 3 | RpcError {
|
||||
if (params.trace_version === undefined) return 2;
|
||||
if (params.trace_version === TRACE_VERSION_V3) return 3;
|
||||
return {
|
||||
code: "invalid_params",
|
||||
message: `unsupported trace_version ${params.trace_version}; supported values are omitted (v2) or ${TRACE_VERSION_V3} (v3)`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTrace(recording: ActiveRecording): RecordedTrace {
|
||||
if (recording.traceVersion === 3 && recording.observation) {
|
||||
return recording.observation.buildTrace({
|
||||
drafts: recording.steps,
|
||||
startedAt: recording.startedAt,
|
||||
purpose: recording.purpose,
|
||||
startUrl: recording.startUrl,
|
||||
stoppedBy: recording.stoppedBy,
|
||||
bskVersion: BSK_TRACE_VERSION,
|
||||
includeTabSwitches: recording.supportsTabSwitchSteps,
|
||||
});
|
||||
}
|
||||
if (recording.traceVersion === 3) {
|
||||
throw new Error("trace v3 observation runtime is unavailable");
|
||||
}
|
||||
return buildTraceV2({
|
||||
steps: recording.steps,
|
||||
startedAt: recording.startedAt,
|
||||
@@ -163,6 +232,51 @@ function buildTrace(recording: ActiveRecording): TraceV2 {
|
||||
});
|
||||
}
|
||||
|
||||
function stepBufferFor(
|
||||
recording: ActiveRecording,
|
||||
tabId: number,
|
||||
fallbackUrl?: string,
|
||||
): RecordingStepBuffer {
|
||||
return {
|
||||
steps: recording.steps,
|
||||
navigation: recording.tabs.navigation(tabId, fallbackUrl),
|
||||
};
|
||||
}
|
||||
|
||||
async function activateRecordingTab(
|
||||
recording: ActiveRecording,
|
||||
targetTabId: number,
|
||||
): Promise<void> {
|
||||
const previousTabId = recording.tabs.currentTabId;
|
||||
if (targetTabId === previousTabId) return;
|
||||
|
||||
const transition = recording.observation
|
||||
? await recording.observation.captureTabTransition(previousTabId, targetTabId)
|
||||
: {};
|
||||
const { targetUrl, ...stateLinks } = transition;
|
||||
const draft: Extract<RecordingDraftStep, { op: "switch_tab" }> = {
|
||||
op: "switch_tab",
|
||||
...stateLinks,
|
||||
};
|
||||
recording.steps.push(draft);
|
||||
recording.observation?.bindTabTransition(draft, recording.steps.length);
|
||||
recording.tabs.commit(targetTabId, targetUrl);
|
||||
}
|
||||
|
||||
async function processRecordedStep(
|
||||
recording: ActiveRecording,
|
||||
draftIndex: number,
|
||||
tabId: number,
|
||||
producerId?: string,
|
||||
): Promise<void> {
|
||||
if (!recording.observation) return;
|
||||
try {
|
||||
await recording.observation.processDraft(tabId, recording.steps, draftIndex, producerId);
|
||||
} catch (err) {
|
||||
console.warn(`[bsk record] observation failed for step ${draftIndex + 1}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RecordDeps {
|
||||
tabsApi: ChromeTabsApi;
|
||||
sendToTab(
|
||||
@@ -170,16 +284,25 @@ export interface RecordDeps {
|
||||
msg: RecordStartMessage | RecordStopMessage | RecordCancelMessage,
|
||||
): Promise<unknown>;
|
||||
bypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
|
||||
frameCoordinator?: Pick<
|
||||
RecordFrameCoordinator,
|
||||
"begin" | "armTab" | "sourceFor" | "stop" | "cancel"
|
||||
>;
|
||||
cdp?: CdpRunner;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export type RecordRuntimeDeps = Omit<RecordDeps, "frameCoordinator" | "signal"> & {
|
||||
frameCoordinator: NonNullable<RecordDeps["frameCoordinator"]>;
|
||||
};
|
||||
|
||||
let defaultDeps: RecordDeps | null = null;
|
||||
function getDefaultDeps(): RecordDeps {
|
||||
if (!defaultDeps) {
|
||||
defaultDeps = {
|
||||
tabsApi: chromeTabsApi,
|
||||
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
|
||||
frameCoordinator: recordFrameCoordinator,
|
||||
};
|
||||
}
|
||||
return defaultDeps;
|
||||
@@ -237,16 +360,58 @@ export function releaseBrowserObservationListenersIfIdle(): void {
|
||||
detachBrowserObservation = null;
|
||||
}
|
||||
|
||||
export function attachRecordStepListener(): () => void {
|
||||
export function attachRecordStepListener(deps: RecordDeps = getDefaultDeps()): () => void {
|
||||
const listener = (
|
||||
message: unknown,
|
||||
_sender: chrome.runtime.MessageSender,
|
||||
_sendResponse: () => void,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: RecordStepAck) => void,
|
||||
) => {
|
||||
if (!isRecordStepMessage(message)) return false;
|
||||
for (const recording of recordings.values()) {
|
||||
if (recording.requestId !== message.requestId) continue;
|
||||
appendRecordedPayload(recording, message.step);
|
||||
const source: RecordingCaptureScope | null | undefined = deps.frameCoordinator
|
||||
? deps.frameCoordinator.sourceFor(message.requestId, message.producerId, sender)
|
||||
: undefined;
|
||||
if (deps.frameCoordinator && !source) return false;
|
||||
const sourceTabId = source?.tabId ?? sender.tab?.id ?? recording.tabs.currentTabId;
|
||||
const sourceWasActive = sender.tab?.active ?? sourceTabId === recording.tabs.activeTabId;
|
||||
const producerKey = source
|
||||
? `${source.tabId}:${source.documentId}:${source.producerId}`
|
||||
: `${sourceTabId}:${message.producerId}`;
|
||||
const expectedSequence = (recording.lastStepSequenceByProducer.get(producerKey) ?? 0) + 1;
|
||||
if (message.sequence < expectedSequence) {
|
||||
sendResponse({ ok: true, sequence: message.sequence });
|
||||
return false;
|
||||
}
|
||||
if (message.sequence > expectedSequence) {
|
||||
sendResponse({
|
||||
ok: false,
|
||||
expectedSequence,
|
||||
error: `out-of-order recorded step ${message.sequence}`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
recording.lastStepSequenceByProducer.set(producerKey, message.sequence);
|
||||
enqueueRecordingAction(recording, async () => {
|
||||
if (sourceTabId !== recording.tabs.currentTabId) {
|
||||
if (!sourceWasActive) return;
|
||||
if (sourceTabId !== recording.tabs.activeTabId)
|
||||
recording.tabs.noteActivation(sourceTabId);
|
||||
await activateRecordingTab(recording, sourceTabId);
|
||||
}
|
||||
await recording.observation?.flushRedirects(sourceTabId);
|
||||
const targetHint = message.step.geometry ? { geometry: message.step.geometry } : undefined;
|
||||
const draftIndex = appendRecordedPayload(
|
||||
stepBufferFor(recording, sourceTabId, message.step.page_url),
|
||||
message.step,
|
||||
targetHint,
|
||||
);
|
||||
if (draftIndex !== null) {
|
||||
await processRecordedStep(recording, draftIndex, sourceTabId, source?.producerId);
|
||||
}
|
||||
});
|
||||
sendResponse({ ok: true, sequence: message.sequence });
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
@@ -273,7 +438,7 @@ export function attachRecordFinishListener(deps: RecordDeps = getDefaultDeps()):
|
||||
|
||||
function findRecordingByTabId(tabId: number): ActiveRecording | null {
|
||||
for (const recording of recordings.values()) {
|
||||
if (recording.tabId === tabId && !recording.settled) return recording;
|
||||
if (recording.tabs.currentTabId === tabId && !recording.settled) return recording;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -310,7 +475,7 @@ async function clearRearmTimersForRecording(
|
||||
recording: ActiveRecording,
|
||||
deps: RecordDeps,
|
||||
): Promise<void> {
|
||||
clearRearmTimer(recording.tabId);
|
||||
clearRearmTimer(recording.tabs.currentTabId);
|
||||
try {
|
||||
const tabs = await deps.tabsApi.query({ windowId: recording.agentWindowId });
|
||||
for (const tab of tabs) {
|
||||
@@ -325,13 +490,16 @@ async function stopRecordingOnAllAgentTabs(
|
||||
recording: ActiveRecording,
|
||||
deps: RecordDeps,
|
||||
): Promise<void> {
|
||||
if (deps.frameCoordinator && !(await deps.frameCoordinator.stop(recording.requestId))) {
|
||||
throw new Error("failed to flush one or more recording documents");
|
||||
}
|
||||
const stopMsg: RecordStopMessage = { type: RECORD_STOP, requestId: recording.requestId };
|
||||
let tabIds = [recording.tabId];
|
||||
let tabIds = [recording.tabs.currentTabId];
|
||||
try {
|
||||
const tabs = await deps.tabsApi.query({ windowId: recording.agentWindowId });
|
||||
tabIds = [
|
||||
...new Set([
|
||||
recording.tabId,
|
||||
recording.tabs.currentTabId,
|
||||
...tabs.flatMap((tab) => (typeof tab.id === "number" ? [tab.id] : [])),
|
||||
]),
|
||||
];
|
||||
@@ -342,11 +510,11 @@ async function stopRecordingOnAllAgentTabs(
|
||||
for (const tabId of tabIds) {
|
||||
try {
|
||||
const response = await deps.sendToTab(tabId, stopMsg);
|
||||
if (tabId === recording.tabId && !isRecordStartAck(response)) {
|
||||
if (tabId === recording.tabs.currentTabId && !isRecordStartAck(response)) {
|
||||
throw new Error("content script did not confirm recorded steps");
|
||||
}
|
||||
} catch {
|
||||
if (tabId === recording.tabId) {
|
||||
if (tabId === recording.tabs.currentTabId) {
|
||||
throw new Error("failed to flush recorded steps");
|
||||
}
|
||||
}
|
||||
@@ -364,22 +532,39 @@ async function rearmRecording(
|
||||
recording: ActiveRecording,
|
||||
targetTabId: number,
|
||||
deps: RecordDeps,
|
||||
activation?: TabActivation,
|
||||
): Promise<boolean> {
|
||||
// Do NOT toggle automation-bypass here: each retry used to increment the
|
||||
// content-script counter, and a single stop decrement left the ControlOverlay
|
||||
// stuck with pointer-events:none (page usable, Interrupt dead). RecordOverlay
|
||||
// already hides the control chrome while activeRecord is set.
|
||||
const isFinishing = () => isRecordingFinishing(recording);
|
||||
for (let attempt = 0; attempt < RECORD_REARM_MAX_ATTEMPTS; attempt += 1) {
|
||||
if (isFinishing()) return false;
|
||||
const startMsg: RecordStartMessage = {
|
||||
type: RECORD_START,
|
||||
requestId: recording.requestId,
|
||||
startedAtMs: recording.startedAtMs,
|
||||
};
|
||||
try {
|
||||
await sendRecordStartWithAck(targetTabId, startMsg, deps.sendToTab);
|
||||
recording.tabId = targetTabId;
|
||||
if (deps.frameCoordinator) {
|
||||
const frameStarted = await deps.frameCoordinator.armTab(recording.requestId, targetTabId);
|
||||
if (!frameStarted) throw new Error("recording document did not start");
|
||||
}
|
||||
await sendRecordStartWithAck(targetTabId, startMsg, deps.sendToTab, isFinishing);
|
||||
if (isFinishing()) return false;
|
||||
if (activation) {
|
||||
if (!recording.tabs.isLatest(activation)) return true;
|
||||
await enqueueRecordingAction(recording, async () => {
|
||||
if (isFinishing() || !recording.tabs.isLatest(activation)) {
|
||||
return;
|
||||
}
|
||||
await activateRecordingTab(recording, targetTabId);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
if (isFinishing()) return false;
|
||||
if (attempt + 1 < RECORD_REARM_MAX_ATTEMPTS) {
|
||||
await sleep(RECORD_REARM_RETRY_DELAY_MS);
|
||||
}
|
||||
@@ -388,7 +573,7 @@ async function rearmRecording(
|
||||
return false;
|
||||
}
|
||||
|
||||
function scheduleRearmForTab(tabId: number, deps: RecordDeps): void {
|
||||
function scheduleRearmForTab(tabId: number, deps: RecordDeps, activation?: TabActivation): void {
|
||||
const existing = rearmTimers.get(tabId);
|
||||
if (existing) clearTimeout(existing);
|
||||
rearmTimers.set(
|
||||
@@ -397,7 +582,7 @@ function scheduleRearmForTab(tabId: number, deps: RecordDeps): void {
|
||||
rearmTimers.delete(tabId);
|
||||
void (async () => {
|
||||
const current = await findRecordingForTab(tabId, deps);
|
||||
if (current) await rearmRecording(current, tabId, deps);
|
||||
if (current) await rearmRecording(current, tabId, deps, activation);
|
||||
})();
|
||||
}, RECORD_REARM_DEBOUNCE_MS),
|
||||
);
|
||||
@@ -409,14 +594,20 @@ export function attachRecordTabListener(deps: RecordDeps = getDefaultDeps()): ()
|
||||
const windowId = tab.windowId;
|
||||
if (tabId === undefined || windowId === undefined) return;
|
||||
for (const recording of recordings.values()) {
|
||||
if (recording.settled || recording.agentWindowId !== windowId) continue;
|
||||
if (isRecordingFinishing(recording) || recording.agentWindowId !== windowId) continue;
|
||||
scheduleRearmForTab(tabId, deps);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const onActivated = (activeInfo: chrome.tabs.TabActiveInfo) => {
|
||||
scheduleRearmForTab(activeInfo.tabId, deps);
|
||||
for (const recording of recordings.values()) {
|
||||
if (isRecordingFinishing(recording) || recording.agentWindowId !== activeInfo.windowId)
|
||||
continue;
|
||||
const activation = recording.tabs.noteActivation(activeInfo.tabId);
|
||||
scheduleRearmForTab(activeInfo.tabId, deps, activation);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
chrome.tabs.onCreated.addListener(onCreated);
|
||||
@@ -428,13 +619,47 @@ export function attachRecordTabListener(deps: RecordDeps = getDefaultDeps()): ()
|
||||
}
|
||||
|
||||
export function attachRecordNavigationListener(deps: RecordDeps = getDefaultDeps()): () => void {
|
||||
const observeMainFrame = (tabId: number, url?: string, causedByAction?: boolean) => {
|
||||
void (async () => {
|
||||
const recording = await findRecordingForTab(tabId, deps);
|
||||
if (recording && url) {
|
||||
observeRecordedNavigation(recording, url, causedByAction);
|
||||
}
|
||||
})();
|
||||
const observeMainFrame = (
|
||||
tabId: number,
|
||||
url?: string,
|
||||
causedByAction?: boolean,
|
||||
transitionType?: string,
|
||||
transitionQualifiers?: string[],
|
||||
) => {
|
||||
if (!url) return;
|
||||
const candidates = [...recordings.values()].filter(
|
||||
(recording) =>
|
||||
!recording.settled && recording.acceptingNavigation && recording.tabs.activeTabId === tabId,
|
||||
);
|
||||
for (const recording of candidates) {
|
||||
const queued = enqueueRecordingAction(recording, async () => {
|
||||
if (tabId !== recording.tabs.currentTabId) {
|
||||
await activateRecordingTab(recording, tabId);
|
||||
}
|
||||
const result = observeRecordedNavigation(
|
||||
stepBufferFor(recording, tabId, url),
|
||||
url,
|
||||
causedByAction,
|
||||
transitionType,
|
||||
transitionQualifiers,
|
||||
);
|
||||
if (result.kind === "coalesce_redirect") {
|
||||
recording.observation?.scheduleRedirect(tabId, recording.steps, result.url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.kind === "noop") return;
|
||||
recording.observation?.clearRedirect(tabId);
|
||||
if (result.kind === "appended") {
|
||||
await processRecordedStep(recording, result.index, tabId);
|
||||
} else {
|
||||
recording.observation?.scheduleSettle(tabId, recording.steps, result.index);
|
||||
}
|
||||
});
|
||||
const tracked = queued.catch(() => {});
|
||||
recording.navigationCallbacks.add(tracked);
|
||||
void tracked.finally(() => recording.navigationCallbacks.delete(tracked));
|
||||
}
|
||||
};
|
||||
const onMainFrameComplete = (tabId: number, url?: string) => {
|
||||
void (async () => {
|
||||
@@ -454,18 +679,13 @@ export function attachRecordNavigationListener(deps: RecordDeps = getDefaultDeps
|
||||
details: chrome.webNavigation.WebNavigationTransitionCallbackDetails,
|
||||
) => {
|
||||
if (details.frameId !== 0) return;
|
||||
const causedByAction =
|
||||
details.transitionType === "link" || details.transitionType === "form_submit"
|
||||
? true
|
||||
: details.transitionType === "typed" ||
|
||||
details.transitionType === "auto_bookmark" ||
|
||||
details.transitionType === "generated" ||
|
||||
details.transitionType === "keyword" ||
|
||||
details.transitionType === "keyword_generated" ||
|
||||
details.transitionType === "reload"
|
||||
? false
|
||||
: undefined;
|
||||
observeMainFrame(details.tabId, details.url, causedByAction);
|
||||
observeMainFrame(
|
||||
details.tabId,
|
||||
details.url,
|
||||
undefined,
|
||||
details.transitionType,
|
||||
details.transitionQualifiers,
|
||||
);
|
||||
};
|
||||
chrome.webNavigation.onCompleted.addListener(completedListener);
|
||||
chrome.webNavigation.onCommitted?.addListener(committedListener);
|
||||
@@ -483,6 +703,26 @@ export function attachRecordNavigationListener(deps: RecordDeps = getDefaultDeps
|
||||
return () => chrome.tabs.onUpdated.removeListener(listener);
|
||||
}
|
||||
|
||||
const MAX_FINISH_DRAIN_ROUNDS = 10;
|
||||
|
||||
async function drainRecordingToStability(recording: ActiveRecording): Promise<boolean> {
|
||||
for (let round = 0; round < MAX_FINISH_DRAIN_ROUNDS; round += 1) {
|
||||
await Promise.all([...recording.navigationCallbacks]);
|
||||
const actionTail = recording.actionQueue;
|
||||
await actionTail;
|
||||
await recording.observation?.flush();
|
||||
|
||||
if (recording.navigationCallbacks.size === 0 && recording.actionQueue === actionTail) {
|
||||
// No event or promise continuation can interleave with this synchronous
|
||||
// check-and-close, so work accepted before the cutoff is fully drained.
|
||||
recording.acceptingNavigation = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
console.warn("[bsk record] navigation/action queues did not stabilize at stop");
|
||||
return false;
|
||||
}
|
||||
|
||||
export function attachRecordQueryListener(deps: RecordDeps = getDefaultDeps()): () => void {
|
||||
const listener = (
|
||||
message: unknown,
|
||||
@@ -501,7 +741,8 @@ export function attachRecordQueryListener(deps: RecordDeps = getDefaultDeps()):
|
||||
sendResponse({ active: false });
|
||||
return;
|
||||
}
|
||||
await rearmRecording(recording, tabId, deps);
|
||||
const activation = sender.tab?.active ? recording.tabs.noteActivation(tabId) : undefined;
|
||||
await rearmRecording(recording, tabId, deps, activation);
|
||||
sendResponse({
|
||||
active: true,
|
||||
requestId: recording.requestId,
|
||||
@@ -523,23 +764,50 @@ async function finishRecordingByRequest(
|
||||
if (recording.requestId !== requestId || recording.settled) continue;
|
||||
const match = await findRecordingForTab(tabId, deps);
|
||||
if (match !== recording) continue;
|
||||
await finishRecording(sessionId, deps);
|
||||
await finishRecording(sessionId, deps, "user_finish");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function finishRecording(sessionId: string, deps: RecordDeps): Promise<TraceV2 | null> {
|
||||
async function finishRecording(
|
||||
sessionId: string,
|
||||
deps: RecordDeps,
|
||||
stoppedBy: StopReason,
|
||||
): Promise<RecordedTrace | null> {
|
||||
const recording = recordings.get(sessionId);
|
||||
if (!recording || recording.settled || recording.finishing) return null;
|
||||
recording.finishing = true;
|
||||
if (!recording || recording.settled) return null;
|
||||
if (recording.finishAttempt) return recording.finishAttempt;
|
||||
recording.stoppedBy = stoppedBy;
|
||||
|
||||
const attempt = finishRecordingAttempt(sessionId, recording, deps);
|
||||
recording.finishAttempt = attempt;
|
||||
try {
|
||||
return await attempt;
|
||||
} finally {
|
||||
if (!recording.settled) {
|
||||
recording.finishAttempt = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function finishRecordingAttempt(
|
||||
sessionId: string,
|
||||
recording: ActiveRecording,
|
||||
deps: RecordDeps,
|
||||
): Promise<RecordedTrace | null> {
|
||||
await clearRearmTimersForRecording(recording, deps);
|
||||
try {
|
||||
// Disposing content capture may commit a final dirty fill. Flush content
|
||||
// first so all resulting RECORD_STEP messages enter actionQueue before it
|
||||
// and the observation queues are drained.
|
||||
await stopRecordingOnAllAgentTabs(recording, deps);
|
||||
} catch {
|
||||
recording.finishing = false;
|
||||
return null;
|
||||
}
|
||||
if (!(await drainRecordingToStability(recording))) {
|
||||
return null;
|
||||
}
|
||||
await recording.observation?.settleTrailing(recording.tabs.currentTabId, recording.steps);
|
||||
|
||||
recording.settled = true;
|
||||
recordings.delete(sessionId);
|
||||
@@ -554,6 +822,15 @@ export async function handleRecordStart(
|
||||
params: RecordStartParams,
|
||||
deps: RecordDeps = getDefaultDeps(),
|
||||
): Promise<RecordStartResult | RpcError> {
|
||||
const traceVersionOrErr = negotiatedTraceVersion(params);
|
||||
if (typeof traceVersionOrErr !== "number") return traceVersionOrErr;
|
||||
if (traceVersionOrErr === 3 && !deps.cdp) {
|
||||
return {
|
||||
code: "protocol_error",
|
||||
message: "trace v3 recording requires an active CDP connection",
|
||||
};
|
||||
}
|
||||
|
||||
const ctxOrErr = lookupSession(manager, params, "record_start");
|
||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||
const ctx = ctxOrErr;
|
||||
@@ -570,37 +847,64 @@ export async function handleRecordStart(
|
||||
// on the destination page can RECORD_QUERY → rearm → show RecordOverlay
|
||||
// instead of flashing ControlOverlay ("Agent 正在控制").
|
||||
const requestId = makeRequestId(target.tabId);
|
||||
let resolveFinish!: (trace: TraceV2) => void;
|
||||
let resolveFinish!: (trace: RecordedTrace) => void;
|
||||
let rejectFinish!: (err: Error) => void;
|
||||
const finishPromise = new Promise<TraceV2>((resolve, reject) => {
|
||||
const finishPromise = new Promise<RecordedTrace>((resolve, reject) => {
|
||||
resolveFinish = resolve;
|
||||
rejectFinish = reject;
|
||||
});
|
||||
const navigateUrl = params.url ?? RECORD_DEFAULT_START_URL;
|
||||
const startedAtMs = Date.now();
|
||||
const maxPageTokens = params.max_page_tokens;
|
||||
const redactValues = params.redact_values ?? false;
|
||||
recordings.set(params.session_id, {
|
||||
requestId,
|
||||
tabId: target.tabId,
|
||||
tabs: new RecordingTabCoordinator(target.tabId, navigateUrl),
|
||||
agentWindowId: ctx.agentWindowId,
|
||||
startUrl: navigateUrl,
|
||||
...(params.purpose ? { purpose: params.purpose } : {}),
|
||||
steps: [],
|
||||
startedAt: new Date(startedAtMs).toISOString(),
|
||||
startedAtMs,
|
||||
traceVersion: traceVersionOrErr,
|
||||
supportsTabSwitchSteps: params.supports_tab_switch_steps === true,
|
||||
finishPromise,
|
||||
resolveFinish,
|
||||
rejectFinish,
|
||||
settled: false,
|
||||
finishing: false,
|
||||
currentUrl: navigateUrl,
|
||||
pendingNavigation: false,
|
||||
pendingNavigationDeadline: undefined,
|
||||
finishAttempt: null,
|
||||
observation:
|
||||
traceVersionOrErr === 3 && deps.cdp
|
||||
? new RecordingObservationRuntime({
|
||||
cdp: deps.cdp,
|
||||
tabsApi: deps.tabsApi,
|
||||
maxTokens: maxPageTokens,
|
||||
redactValues,
|
||||
})
|
||||
: null,
|
||||
stoppedBy: "user_finish",
|
||||
navigationCallbacks: new Set(),
|
||||
acceptingNavigation: true,
|
||||
actionQueue: Promise.resolve(),
|
||||
lastStepSequenceByProducer: new Map(),
|
||||
});
|
||||
deps.frameCoordinator?.begin(requestId, startedAtMs, target.tabId, async (scope) => {
|
||||
const recording = recordings.get(params.session_id);
|
||||
if (!recording || recording.requestId !== requestId || recording.settled) return;
|
||||
try {
|
||||
await recording.observation?.refreshDocument(scope.tabId, scope.producerId);
|
||||
} catch {
|
||||
// The normal action-time capture remains available if a child Document
|
||||
// is replaced while its readiness refresh is in flight.
|
||||
}
|
||||
});
|
||||
// Observe navigations for the whole recording lifetime; attach before
|
||||
// optional navigate so the destination load can rearm capture.
|
||||
ensureBrowserObservationListeners(deps);
|
||||
|
||||
const abortPending = async (notifyContent: boolean) => {
|
||||
recordings.get(params.session_id)?.observation?.cancel();
|
||||
deps.frameCoordinator?.cancel(requestId);
|
||||
recordings.delete(params.session_id);
|
||||
releaseBrowserObservationListenersIfIdle();
|
||||
if (notifyContent) {
|
||||
@@ -682,7 +986,7 @@ export async function handleRecordStart(
|
||||
return cancelledError();
|
||||
}
|
||||
active.startUrl = startUrl;
|
||||
active.currentUrl = startUrl;
|
||||
active.tabs.navigation(target.tabId, startUrl).currentUrl = startUrl;
|
||||
|
||||
if (isContentScriptRestrictedUrl(startUrl)) {
|
||||
await abortPending(false);
|
||||
@@ -690,7 +994,7 @@ export async function handleRecordStart(
|
||||
code: "invalid_params",
|
||||
message: params.url
|
||||
? `cannot record on restricted URL (${startUrl}); use an http(s) page`
|
||||
: `cannot record on restricted URL (${startUrl}); default start page must be injectable http(s)`,
|
||||
: `cannot record on restricted URL (${startUrl}); default start page https://example.com/ did not load — pass --url with a page you can open`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -717,6 +1021,10 @@ export async function handleRecordStart(
|
||||
const startMsg: RecordStartMessage = { type: RECORD_START, requestId, startedAtMs };
|
||||
|
||||
try {
|
||||
if (deps.frameCoordinator) {
|
||||
const frameStarted = await deps.frameCoordinator.armTab(requestId, target.tabId);
|
||||
if (!frameStarted) throw new Error("top recording document did not start");
|
||||
}
|
||||
await sendRecordStartWithAck(target.tabId, startMsg, deps.sendToTab);
|
||||
} catch {
|
||||
await abortPending(true);
|
||||
@@ -727,6 +1035,17 @@ export async function handleRecordStart(
|
||||
};
|
||||
}
|
||||
|
||||
if (active.observation) {
|
||||
const activeRecording = recordings.get(params.session_id);
|
||||
if (activeRecording) {
|
||||
try {
|
||||
await activeRecording.observation?.captureInitial(target.tabId);
|
||||
} catch {
|
||||
// Proceed without initial observation; steps may be dropped by reducer.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const cancelled = await abortIfCancelled(true);
|
||||
if (cancelled) return cancelled;
|
||||
@@ -751,7 +1070,7 @@ export async function handleRecordStop(
|
||||
};
|
||||
}
|
||||
|
||||
const trace = await finishRecording(params.session_id, deps);
|
||||
const trace = await finishRecording(params.session_id, deps, "cli_stop");
|
||||
if (!trace) {
|
||||
return {
|
||||
code: "protocol_error",
|
||||
@@ -781,9 +1100,9 @@ export async function handleRecordAwait(
|
||||
return { code: "cancelled", message: "record_await aborted" };
|
||||
}
|
||||
|
||||
const outcome = await new Promise<{ trace: TraceV2 } | { error: RpcError }>((resolve) => {
|
||||
const outcome = await new Promise<{ trace: RecordedTrace } | { error: RpcError }>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (result: { trace: TraceV2 } | { error: RpcError }) => {
|
||||
const finish = (result: { trace: RecordedTrace } | { error: RpcError }) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
@@ -825,6 +1144,8 @@ export function clearRecordingForSession(sessionId: string): void {
|
||||
}
|
||||
void clearRearmTimersForRecording(recording, getDefaultDeps());
|
||||
if (!recording.settled) {
|
||||
getDefaultDeps().frameCoordinator?.cancel(recording.requestId);
|
||||
recording.observation?.cancel();
|
||||
recording.settled = true;
|
||||
recording.rejectFinish(new Error("recording cleared"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
RECORD_DOCUMENT_ATTRIBUTE,
|
||||
recordingDocumentMarkerValue,
|
||||
} from "@/shared/recording-document-identity";
|
||||
import { projectRecordSafeObservation } from "../record-safe-observation";
|
||||
|
||||
describe("projectRecordSafeObservation", () => {
|
||||
it("projects the opaque recording identity without exposing captured attributes", () => {
|
||||
const recordingDocumentId = "123e4567-e89b-42d3-a456-426614174000";
|
||||
const result = projectRecordSafeObservation({
|
||||
rootFrameId: "child",
|
||||
frameDocuments: [
|
||||
{
|
||||
frameId: "child",
|
||||
target: { tabId: 4, sessionId: "oopif-session" },
|
||||
contextScopeId: "scope-1",
|
||||
axNodes: [],
|
||||
domNodes: [
|
||||
{
|
||||
backendNodeId: 1,
|
||||
parentBackendNodeId: null,
|
||||
tag: "html",
|
||||
attrs: {
|
||||
[RECORD_DOCUMENT_ATTRIBUTE]: recordingDocumentMarkerValue(recordingDocumentId),
|
||||
value: "user@example.com",
|
||||
},
|
||||
rect: null,
|
||||
paintOrder: 0,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
rendered: { text: '@vom 1\nRootWebArea "Example"', refs: [], truncated: false },
|
||||
});
|
||||
|
||||
expect(result.frames).toEqual([
|
||||
{
|
||||
frameId: "child",
|
||||
target: { tabId: 4, sessionId: "oopif-session" },
|
||||
recordingDocumentId,
|
||||
},
|
||||
]);
|
||||
const dumped = JSON.stringify(result);
|
||||
expect(dumped).not.toContain("user@example.com");
|
||||
expect(dumped).not.toContain("attrs");
|
||||
expect(dumped).not.toContain("domNodes");
|
||||
expect(dumped).not.toContain("axNodes");
|
||||
});
|
||||
|
||||
it("does not project a page-controlled marker value as recording identity", () => {
|
||||
const result = projectRecordSafeObservation({
|
||||
rootFrameId: "root",
|
||||
frameDocuments: [
|
||||
{
|
||||
frameId: "root",
|
||||
target: { tabId: 4 },
|
||||
contextScopeId: "scope-1",
|
||||
axNodes: [],
|
||||
domNodes: [
|
||||
{
|
||||
backendNodeId: 1,
|
||||
parentBackendNodeId: null,
|
||||
tag: "html",
|
||||
attrs: { [RECORD_DOCUMENT_ATTRIBUTE]: "bsk:user@example.com" },
|
||||
rect: null,
|
||||
paintOrder: 0,
|
||||
position: "static",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
rendered: { text: '@vom 1\nRootWebArea "Example"', refs: [], truncated: false },
|
||||
});
|
||||
|
||||
expect(result.frames).toEqual([{ frameId: "root", target: { tabId: 4 } }]);
|
||||
expect(JSON.stringify(result)).not.toContain("user@example.com");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Rect, RenderedRef, VomResult } from "@browser-skill/vom";
|
||||
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
||||
import { readRecordingDocumentIdentity } from "@/shared/recording-document-identity";
|
||||
import type { CapturedSurfaceProbe } from "./capture";
|
||||
import type { CapturedFrameDocument, FrameAxNode } from "./frame-capture";
|
||||
|
||||
@@ -9,6 +10,8 @@ export interface CaptureVomFrame {
|
||||
target: CdpTarget;
|
||||
parentFrameId?: string;
|
||||
ownerBackendNodeId?: number;
|
||||
/** BrowserSkill-generated identity for the recording agent in this Document. */
|
||||
recordingDocumentId?: string;
|
||||
}
|
||||
|
||||
/** Allowlisted geometry used to match a recorded action to a rendered ref. */
|
||||
@@ -37,14 +40,22 @@ export interface CaptureVomObservationResult {
|
||||
}
|
||||
|
||||
function projectFrames(documents: CapturedFrameDocument<FrameAxNode>[]): CaptureVomFrame[] {
|
||||
return documents.map((document) => ({
|
||||
frameId: document.frameId,
|
||||
target: document.target,
|
||||
...(document.parentFrameId ? { parentFrameId: document.parentFrameId } : {}),
|
||||
...(document.ownerBackendNodeId !== undefined
|
||||
? { ownerBackendNodeId: document.ownerBackendNodeId }
|
||||
: {}),
|
||||
}));
|
||||
return documents.map((document) => {
|
||||
let recordingDocumentId: string | undefined;
|
||||
for (const node of document.domNodes) {
|
||||
recordingDocumentId = readRecordingDocumentIdentity(node.attrs);
|
||||
if (recordingDocumentId) break;
|
||||
}
|
||||
return {
|
||||
frameId: document.frameId,
|
||||
target: document.target,
|
||||
...(document.parentFrameId ? { parentFrameId: document.parentFrameId } : {}),
|
||||
...(document.ownerBackendNodeId !== undefined
|
||||
? { ownerBackendNodeId: document.ownerBackendNodeId }
|
||||
: {}),
|
||||
...(recordingDocumentId ? { recordingDocumentId } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function projectMatchNodes(documents: CapturedFrameDocument<FrameAxNode>[]): CaptureVomMatchNode[] {
|
||||
|
||||
@@ -65,6 +65,11 @@ function deferredFakeTransport(): { transport: Transport; emit: (frame: Protocol
|
||||
}
|
||||
|
||||
describe("performHandshake", () => {
|
||||
it("advertises the protocol compatibility boundary", () => {
|
||||
expect(PROTOCOL_VERSION).toBe("1.1");
|
||||
expect(MIN_COMPATIBLE_PROTOCOL).toBe("1.0");
|
||||
});
|
||||
|
||||
it("sends system.handshake with identity and both compat fields", async () => {
|
||||
let sentFrame: ProtocolFrame | null = null;
|
||||
const transport = fakeTransport((req) => {
|
||||
@@ -74,7 +79,7 @@ describe("performHandshake", () => {
|
||||
result: {
|
||||
server: "browser-skill-daemon",
|
||||
version: "0.1.0",
|
||||
protocol_version: "1.0",
|
||||
protocol_version: "1.1",
|
||||
min_compatible_peer: "0.0.0",
|
||||
min_compatible_protocol: "1.0",
|
||||
},
|
||||
@@ -129,8 +134,8 @@ describe("performHandshake", () => {
|
||||
const response = {
|
||||
server: "browser-skill-daemon",
|
||||
version: "0.1.0",
|
||||
protocol_version: "1.0",
|
||||
min_compatible_protocol: "1.0",
|
||||
protocol_version: "1.1",
|
||||
min_compatible_protocol: "1.1",
|
||||
} satisfies HandshakeResult;
|
||||
const transport = fakeTransport((req) => ({
|
||||
id: (req as { id: string }).id,
|
||||
@@ -145,7 +150,7 @@ describe("performHandshake", () => {
|
||||
});
|
||||
|
||||
expect(outcome.result.min_compatible_peer).toBeUndefined();
|
||||
expect(outcome.result.min_compatible_protocol).toBe("1.0");
|
||||
expect(outcome.result.min_compatible_protocol).toBe("1.1");
|
||||
});
|
||||
|
||||
it("rejects when the daemon responds with an error", async () => {
|
||||
@@ -178,9 +183,9 @@ describe("performHandshake", () => {
|
||||
result: {
|
||||
server: "browser-skill-daemon",
|
||||
version: "0.1.0",
|
||||
protocol_version: "1.0",
|
||||
protocol_version: "1.1",
|
||||
min_compatible_peer: "0.0.0",
|
||||
min_compatible_protocol: "1.0",
|
||||
min_compatible_protocol: "1.1",
|
||||
},
|
||||
});
|
||||
emit({
|
||||
@@ -188,14 +193,14 @@ describe("performHandshake", () => {
|
||||
result: {
|
||||
server: "browser-skill-daemon",
|
||||
version: "0.1.0",
|
||||
protocol_version: "1.0",
|
||||
protocol_version: "1.1",
|
||||
min_compatible_peer: "0.0.0",
|
||||
min_compatible_protocol: "1.0",
|
||||
min_compatible_protocol: "1.1",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
result: { server: "browser-skill-daemon", protocol_version: "1.0" },
|
||||
result: { server: "browser-skill-daemon", protocol_version: "1.1" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
ResponseFrame,
|
||||
} from "./types";
|
||||
|
||||
export const PROTOCOL_VERSION = "1.0";
|
||||
export const PROTOCOL_VERSION = "1.1";
|
||||
/**
|
||||
* Extension semver, injected at build time from `package.json` via
|
||||
* Vite's `define` (see `wxt.config.ts` and `vitest.config.ts`).
|
||||
|
||||
@@ -780,6 +780,7 @@ export interface SelectedOptionV3 {
|
||||
|
||||
export type StepV3 =
|
||||
| ({ op: "navigate" } & StepCommonV3 & { to: string; cause: NavigationCause })
|
||||
| ({ op: "switch_tab" } & StepCommonV3)
|
||||
| ({ op: "click" } & StepCommonV3 & { target: TargetDescriptorV3 })
|
||||
| ({ op: "hover" } & StepCommonV3 & { target: TargetDescriptorV3 })
|
||||
| ({ op: "fill" } & StepCommonV3 & {
|
||||
@@ -819,6 +820,12 @@ export interface RecordStartParams {
|
||||
tab_id?: number;
|
||||
url?: string;
|
||||
purpose?: string;
|
||||
max_page_tokens?: number;
|
||||
redact_values?: boolean;
|
||||
/** Omitted means v2; `3` requests a state-linked v3 trace. */
|
||||
trace_version?: number;
|
||||
/** Client can decode the v3 `switch_tab` step variant. */
|
||||
supports_tab_switch_steps?: boolean;
|
||||
}
|
||||
|
||||
export interface RecordStartResult {
|
||||
|
||||
@@ -265,19 +265,27 @@ retry — complete the task autonomously or stop gracefully.
|
||||
|
||||
### Recording — `bsk record`
|
||||
|
||||
Capture the user's own actions in the Agent Window to a `trace.json`, for later LLM-driven automation:
|
||||
Capture the user's own actions in the Agent Window to a **trace bundle**, for later LLM-driven automation. New CLI builds request **trace v3** (page observations + action chain); older extensions may still return **trace v2** (actions only), which the CLI exports as a single `trace.json` without `states/`.
|
||||
|
||||
```bash
|
||||
bsk record start --browser <instance-id-or-label> [--url https://…] [--purpose "publish a wiki doc"] [--output trace.json]
|
||||
bsk record start --browser <instance-id-or-label> \
|
||||
[--url https://…] [--purpose "publish a wiki doc"] \
|
||||
[--max-page-tokens 3000] [--redact-values] \
|
||||
[--output trace]
|
||||
# `--url` is optional; default https://example.com/ when omitted (must be http(s)).
|
||||
# Blocks until the user clicks Finish in the recording panel, then writes ./trace.json and closes the window.
|
||||
# Blocks until the user clicks Finish in the recording panel, then writes:
|
||||
# trace/trace.json — action chain (+ state index when v3)
|
||||
# trace/states/ — v3 only: one `sN.txt` observe snapshot per settled page state
|
||||
|
||||
bsk record stop [--output trace.json] # terminal fallback if the browser panel is unavailable
|
||||
bsk record stop [--output trace] # terminal fallback if the browser panel is unavailable
|
||||
```
|
||||
|
||||
- The trace is a **record-only action log** (a `pages[]` dictionary + `navigate`/`click`/`fill`/`select`/`press` steps with `target` descriptors). It records *what the user did*; deciding which inputs are variable is left to the executing agent.
|
||||
- **v3 bundle (preferred):** `--output` is a directory (default `./trace`) containing `trace.json` and `states/`. `trace.json` lists `states[]` (page observation ids) and `steps[]` (each step binds `state` = observe snapshot *before* the action and `result.state` = snapshot *after* settle). Page bodies live in `states/sN.txt` using the same VOM format as `bsk observe`.
|
||||
- **v2 fallback:** when the connected extension is older, export may contain only legacy `trace.json` with `pages[]` action context and no `states/` observation files. Update the extension for full v3 bundles.
|
||||
- Each `states[]` entry is one **settled page observation** (captured after recording start, navigation landing, or action settle — not on a timer).
|
||||
- `target.ref` values like `@e12` exist **only inside** the bundle for disambiguation; do **not** copy `@eN` refs into SKILL.md or agent runbooks — use visible names from the observation text instead.
|
||||
- `--purpose` is optional context metadata; it does **not** change what gets captured.
|
||||
- There is **no** `bsk replay` — to redo a flow, read the trace and reuse the existing `session` / `snapshot` / `@eN` / `click` / `fill` tools. Follow **Stop when the goal is met**.
|
||||
- `--redact-values` masks all form values in page files as `[filled]` / `[empty]`.
|
||||
- Do **not** record on banking/SSO/password-manager pages; passwords are redacted but traces may still contain sensitive text.
|
||||
|
||||
## Error handling
|
||||
|
||||
@@ -22,6 +22,7 @@ pub mod navigate;
|
||||
pub mod network;
|
||||
pub mod observe;
|
||||
pub mod record;
|
||||
pub mod record_recovery;
|
||||
pub mod record_state;
|
||||
pub mod render_error;
|
||||
pub mod screenshot;
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
//! `bsk record start|stop` — capture user actions in the Agent Window.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use bsk_protocol::Method;
|
||||
use bsk_protocol::tools::{
|
||||
RecordAwaitParams, RecordAwaitResult, RecordStartParams, RecordStartResult, RecordStopParams,
|
||||
RecordStopResult, RecordedTrace,
|
||||
RecordStopResult, RecordedTrace, TRACE_VERSION_V3,
|
||||
};
|
||||
use bsk_protocol::{ErrorCode, Method};
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
use crate::cli::TOOL_IPC_TIMEOUT;
|
||||
|
||||
mod export;
|
||||
|
||||
use crate::cli::business_rpc;
|
||||
use crate::cli::ensure_daemon::ensure_daemon;
|
||||
use crate::cli::error::{CliError, Format};
|
||||
use crate::cli::record_recovery;
|
||||
use crate::cli::record_state;
|
||||
use crate::cli::session::{SessionStartOptions, start_session, stop_session};
|
||||
use export::{
|
||||
ExportMeta, export_with_recovery, states_dir_for_output, trace_json_path,
|
||||
validate_record_output,
|
||||
};
|
||||
|
||||
/// Max wait for the user to click 结束 in the browser (24 hours).
|
||||
const RECORD_AWAIT_TIMEOUT_MS: u32 = 86_400_000;
|
||||
@@ -50,7 +57,8 @@ pub struct RecordStartArgs {
|
||||
pub tab_id: Option<i64>,
|
||||
|
||||
/// Navigate to this http(s) URL before recording. When omitted, defaults
|
||||
/// to `https://example.com/`.
|
||||
/// to `https://example.com/`. If that page does not load, pass `--url`
|
||||
/// with a site you can open in this browser.
|
||||
#[arg(long)]
|
||||
pub url: Option<String>,
|
||||
|
||||
@@ -58,15 +66,25 @@ pub struct RecordStartArgs {
|
||||
#[arg(long)]
|
||||
pub purpose: Option<String>,
|
||||
|
||||
/// Output path for the trace JSON (default `./trace.json`).
|
||||
#[arg(long, default_value = "trace.json")]
|
||||
/// Max VOM tokens per page observation file (default 3000).
|
||||
#[arg(long = "max-page-tokens")]
|
||||
pub max_page_tokens: Option<u32>,
|
||||
|
||||
/// Redact all form values in page observations (`[filled]` only).
|
||||
#[arg(long = "redact-values")]
|
||||
pub redact_values: bool,
|
||||
|
||||
/// Output directory for the trace bundle (default `./trace`).
|
||||
/// Writes `<dir>/trace.json` and `<dir>/states/*.txt`.
|
||||
#[arg(long, default_value = "trace")]
|
||||
pub output: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct RecordStopArgs {
|
||||
/// Output path for the trace JSON (default `./trace.json`).
|
||||
#[arg(long, default_value = "trace.json")]
|
||||
/// Output directory for the trace bundle (default `./trace`).
|
||||
/// Writes `<dir>/trace.json` and `<dir>/states/*.txt`.
|
||||
#[arg(long, default_value = "trace")]
|
||||
pub output: PathBuf,
|
||||
}
|
||||
|
||||
@@ -78,11 +96,7 @@ pub fn dispatch(cmd: RecordCmd, format: Format) -> Result<(), CliError> {
|
||||
}
|
||||
|
||||
fn dispatch_start(args: RecordStartArgs, format: Format) -> Result<(), CliError> {
|
||||
if record_state::read().is_ok() {
|
||||
return Err(CliError::Local(anyhow::anyhow!(
|
||||
"a recording is already in progress; run `bsk record stop` first"
|
||||
)));
|
||||
}
|
||||
prepare_record_start(&args.output)?;
|
||||
|
||||
let info = ensure_daemon().context("ensure daemon is running")?;
|
||||
let session = start_session(
|
||||
@@ -96,8 +110,12 @@ fn dispatch_start(args: RecordStartArgs, format: Format) -> Result<(), CliError>
|
||||
let start_params = RecordStartParams {
|
||||
session_id: session.session_id.clone(),
|
||||
tab_id: args.tab_id,
|
||||
url: args.url,
|
||||
url: args.url.clone(),
|
||||
purpose: args.purpose.clone(),
|
||||
max_page_tokens: args.max_page_tokens,
|
||||
redact_values: Some(args.redact_values),
|
||||
trace_version: Some(TRACE_VERSION_V3),
|
||||
supports_tab_switch_steps: Some(true),
|
||||
};
|
||||
let start_result = business_rpc::call::<RecordStartParams, RecordStartResult>(
|
||||
info.sock_path.clone(),
|
||||
@@ -111,7 +129,7 @@ fn dispatch_start(args: RecordStartArgs, format: Format) -> Result<(), CliError>
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
let _ = stop_session(info.sock_path, &session.session_id);
|
||||
return Err(err);
|
||||
return Err(annotate_default_start_page_error(err, args.url.as_deref()));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -142,8 +160,8 @@ fn dispatch_start(args: RecordStartArgs, format: Format) -> Result<(), CliError>
|
||||
// Keep write/render inside a Result so `?` cannot skip session teardown.
|
||||
let run_result: Result<(), CliError> = match await_result {
|
||||
Ok(result) => (|| {
|
||||
write_trace_file(&args.output, &result.trace)?;
|
||||
render_finish(&result.trace, &args.output, format)
|
||||
let exported = export_with_recovery(&args.output, &result.trace)?;
|
||||
render_finish(&result.trace, &args.output, &exported, format)
|
||||
})(),
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
@@ -157,11 +175,12 @@ fn dispatch_start(args: RecordStartArgs, format: Format) -> Result<(), CliError>
|
||||
}
|
||||
|
||||
fn dispatch_stop(args: RecordStopArgs, format: Format) -> Result<(), CliError> {
|
||||
let state = record_state::read().map_err(CliError::Local)?;
|
||||
let info = ensure_daemon().context("ensure daemon is running")?;
|
||||
let session_id = state.session_id.clone();
|
||||
validate_record_output(&args.output)?;
|
||||
|
||||
if let Ok(state) = record_state::read() {
|
||||
let info = ensure_daemon().context("ensure daemon is running")?;
|
||||
let session_id = state.session_id.clone();
|
||||
|
||||
let run_result: Result<(), CliError> = (|| {
|
||||
let params = RecordStopParams {
|
||||
session_id: session_id.clone(),
|
||||
};
|
||||
@@ -172,16 +191,85 @@ fn dispatch_stop(args: RecordStopArgs, format: Format) -> Result<(), CliError> {
|
||||
Some(params),
|
||||
TOOL_IPC_TIMEOUT,
|
||||
)?;
|
||||
write_trace_file(&args.output, &result.trace)?;
|
||||
render_stop(&result, &args.output, format)
|
||||
})();
|
||||
|
||||
let session_stop_result = stop_session(info.sock_path, &session_id);
|
||||
record_state::clear();
|
||||
let run_result: Result<(), CliError> = (|| {
|
||||
let exported = export_with_recovery(&args.output, &result.trace)?;
|
||||
render_stop(&result, &args.output, &exported, format)
|
||||
})();
|
||||
|
||||
run_result?;
|
||||
session_stop_result?;
|
||||
Ok(())
|
||||
let session_stop_result = stop_session(info.sock_path, &session_id);
|
||||
record_state::clear();
|
||||
|
||||
run_result?;
|
||||
session_stop_result?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(trace) = record_recovery::load().map_err(CliError::Local)? else {
|
||||
return Err(CliError::Local(anyhow::anyhow!(
|
||||
"no recording in progress; run `bsk record start` first"
|
||||
)));
|
||||
};
|
||||
|
||||
let exported = export_with_recovery(&args.output, &trace)?;
|
||||
render_finish(&trace, &args.output, &exported, format)
|
||||
}
|
||||
|
||||
/// When `record start` omitted `--url` and the default example.com page
|
||||
/// never loaded, rewrite the RPC error so the CLI hint points at `--url`.
|
||||
fn annotate_default_start_page_error(err: CliError, user_url: Option<&str>) -> CliError {
|
||||
if user_url.is_some() {
|
||||
return err;
|
||||
}
|
||||
let CliError::Rpc {
|
||||
code,
|
||||
message,
|
||||
data,
|
||||
source,
|
||||
} = err
|
||||
else {
|
||||
return err;
|
||||
};
|
||||
if !is_default_start_page_failure(code, &message) {
|
||||
return CliError::Rpc {
|
||||
code,
|
||||
message,
|
||||
data,
|
||||
source,
|
||||
};
|
||||
}
|
||||
CliError::Rpc {
|
||||
code,
|
||||
message,
|
||||
data: Some(serde_json::json!({
|
||||
"reason": crate::cli::render_error::reason::RECORD_START_PAGE_UNREACHABLE
|
||||
})),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_default_start_page_failure(code: ErrorCode, message: &str) -> bool {
|
||||
match code {
|
||||
ErrorCode::CdpFailed | ErrorCode::Timeout => true,
|
||||
ErrorCode::InvalidParams => {
|
||||
message.contains("restricted URL") || message.contains("about:blank")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_record_start(output: &Path) -> Result<(), CliError> {
|
||||
if record_state::read().is_ok() {
|
||||
return Err(CliError::Local(anyhow::anyhow!(
|
||||
"a recording is already in progress; run `bsk record stop` first"
|
||||
)));
|
||||
}
|
||||
if record_recovery::exists() {
|
||||
return Err(CliError::Local(anyhow::anyhow!(
|
||||
"a previous recording was not exported; run `bsk record stop --output <dir>` to recover it first"
|
||||
)));
|
||||
}
|
||||
validate_record_output(output)
|
||||
}
|
||||
|
||||
fn record_await_ipc_timeout(timeout_ms: u32) -> Duration {
|
||||
@@ -190,43 +278,66 @@ fn record_await_ipc_timeout(timeout_ms: u32) -> Duration {
|
||||
.unwrap_or(Duration::from_secs(u64::from(timeout_ms / 1_000) + 15))
|
||||
}
|
||||
|
||||
fn write_trace_file(output: &PathBuf, trace: &RecordedTrace) -> Result<(), CliError> {
|
||||
let json = serde_json::to_string_pretty(trace)
|
||||
.context("serialize trace JSON")
|
||||
.map_err(CliError::Local)?;
|
||||
if let Some(parent) = output.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("create output directory {}", parent.display()))
|
||||
.map_err(CliError::Local)?;
|
||||
}
|
||||
}
|
||||
fs::write(output, format!("{json}\n"))
|
||||
.with_context(|| format!("write trace to {}", output.display()))
|
||||
.map_err(CliError::Local)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_finish(trace: &RecordedTrace, output: &PathBuf, format: Format) -> Result<(), CliError> {
|
||||
fn render_finish(
|
||||
trace: &RecordedTrace,
|
||||
output_dir: &PathBuf,
|
||||
exported: &ExportMeta,
|
||||
format: Format,
|
||||
) -> Result<(), CliError> {
|
||||
let trace_path = trace_json_path(output_dir);
|
||||
match format {
|
||||
Format::Json => {
|
||||
let payload = match trace {
|
||||
RecordedTrace::V3(t) => serde_json::json!({
|
||||
"output": output_dir,
|
||||
"trace_json": trace_path,
|
||||
"trace_version": exported.trace_version,
|
||||
"states_dir": exported.states_dir,
|
||||
"trace": t,
|
||||
"window_closed": true,
|
||||
}),
|
||||
RecordedTrace::V2(t) => serde_json::json!({
|
||||
"output": output_dir,
|
||||
"trace_json": trace_path,
|
||||
"trace_version": exported.trace_version,
|
||||
"states_dir": null,
|
||||
"trace": t,
|
||||
"window_closed": true,
|
||||
}),
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"output": output,
|
||||
"trace": trace,
|
||||
"window_closed": true,
|
||||
}))
|
||||
.map_err(|e| CliError::Local(anyhow::anyhow!(e)))?
|
||||
serde_json::to_string(&payload).map_err(|e| CliError::Local(anyhow::anyhow!(e)))?
|
||||
);
|
||||
}
|
||||
Format::Human => {
|
||||
let step_count = match trace {
|
||||
RecordedTrace::V2(trace) => trace.steps.len(),
|
||||
RecordedTrace::V3(trace) => trace.steps.len(),
|
||||
};
|
||||
println!("saved {step_count} steps to {}", output.display());
|
||||
}
|
||||
Format::Human => match trace {
|
||||
RecordedTrace::V3(t) => {
|
||||
let states_dir = exported
|
||||
.states_dir
|
||||
.as_ref()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|| states_dir_for_output(output_dir).display().to_string());
|
||||
println!(
|
||||
"saved {} steps to {} and {} states to {}",
|
||||
t.steps.len(),
|
||||
trace_path.display(),
|
||||
t.states.len(),
|
||||
states_dir
|
||||
);
|
||||
}
|
||||
RecordedTrace::V2(t) => {
|
||||
if exported.v2_fallback {
|
||||
eprintln!(
|
||||
"note: extension returned trace v2 (no page observations); update the BrowserSkill extension for v3 bundles"
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"saved {} steps to {} (trace v2)",
|
||||
t.steps.len(),
|
||||
trace_path.display()
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -234,33 +345,89 @@ fn render_finish(trace: &RecordedTrace, output: &PathBuf, format: Format) -> Res
|
||||
fn render_stop(
|
||||
result: &RecordStopResult,
|
||||
output: &PathBuf,
|
||||
exported: &ExportMeta,
|
||||
format: Format,
|
||||
) -> Result<(), CliError> {
|
||||
render_finish(&result.trace, output, format)
|
||||
render_finish(&result.trace, output, exported, format)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_output_is_trace_json() {
|
||||
let args = RecordStopArgs {
|
||||
output: PathBuf::from("trace.json"),
|
||||
};
|
||||
assert_eq!(args.output, PathBuf::from("trace.json"));
|
||||
fn with_temp_home<F: FnOnce()>(f: F) {
|
||||
let _lock = record_recovery::test_env_lock();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
unsafe {
|
||||
std::env::set_var(crate::daemon::paths::BSK_HOME_ENV, tmp.path());
|
||||
}
|
||||
f();
|
||||
record_recovery::clear();
|
||||
record_state::clear();
|
||||
unsafe {
|
||||
std::env::remove_var(crate::daemon::paths::BSK_HOME_ENV);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_args_default_output_is_trace_json() {
|
||||
fn prepare_record_start_rejects_existing_trace_json_file() {
|
||||
with_temp_home(|| {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = dir.path().join("trace.json");
|
||||
std::fs::write(&output, "{}\n").unwrap();
|
||||
|
||||
let err = prepare_record_start(&output).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("JSON file") || msg.contains("json file"),
|
||||
"{msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("states/") || msg.contains("--output trace"),
|
||||
"{msg}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_record_start_rejects_unexported_recovery() {
|
||||
with_temp_home(|| {
|
||||
crate::daemon::paths::ensure_bsk_home().unwrap();
|
||||
std::fs::write(
|
||||
crate::daemon::paths::record_recovery_path().unwrap(),
|
||||
"{}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = prepare_record_start(Path::new("trace")).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("not exported") || msg.contains("recover"),
|
||||
"{msg}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_output_is_trace_dir() {
|
||||
let args = RecordStopArgs {
|
||||
output: PathBuf::from("trace"),
|
||||
};
|
||||
assert_eq!(args.output, PathBuf::from("trace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_args_default_output_is_trace_dir() {
|
||||
let args = RecordStartArgs {
|
||||
browser: None,
|
||||
tab_id: None,
|
||||
url: None,
|
||||
purpose: None,
|
||||
output: PathBuf::from("trace.json"),
|
||||
max_page_tokens: None,
|
||||
redact_values: false,
|
||||
output: PathBuf::from("trace"),
|
||||
};
|
||||
assert_eq!(args.output, PathBuf::from("trace.json"));
|
||||
assert_eq!(args.output, PathBuf::from("trace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -268,4 +435,35 @@ mod tests {
|
||||
let got = record_await_ipc_timeout(RECORD_AWAIT_TIMEOUT_MS);
|
||||
assert!(got >= Duration::from_secs(86_400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotate_default_start_page_error_adds_url_reason() {
|
||||
let err = CliError::from_rpc(bsk_protocol::RpcError {
|
||||
code: ErrorCode::CdpFailed,
|
||||
message: "Page.navigate rejected: net::ERR_ABORTED".into(),
|
||||
data: None,
|
||||
});
|
||||
let annotated = annotate_default_start_page_error(err, None);
|
||||
assert_eq!(
|
||||
crate::cli::render_error::reason_for_data(annotated.data()),
|
||||
Some(crate::cli::render_error::reason::RECORD_START_PAGE_UNREACHABLE)
|
||||
);
|
||||
let info =
|
||||
crate::cli::render_error::info_for_error(annotated.code().unwrap(), annotated.data());
|
||||
assert!(info.hint.unwrap().contains("--url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotate_default_start_page_error_skips_when_user_passed_url() {
|
||||
let err = CliError::from_rpc(bsk_protocol::RpcError {
|
||||
code: ErrorCode::CdpFailed,
|
||||
message: "Page.navigate rejected: net::ERR_ABORTED".into(),
|
||||
data: None,
|
||||
});
|
||||
let annotated = annotate_default_start_page_error(err, Some("https://www.example.org/"));
|
||||
assert_eq!(
|
||||
crate::cli::render_error::reason_for_data(annotated.data()),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,976 @@
|
||||
use std::collections::HashSet;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Context;
|
||||
use bsk_protocol::tools::{RecordedTrace, TRACE_VERSION_V2, TRACE_VERSION_V3, TraceV2, TraceV3};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::cli::error::CliError;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ExportMeta {
|
||||
pub(super) states_dir: Option<PathBuf>,
|
||||
pub(super) trace_version: u32,
|
||||
/// True when the CLI requested v3 but the extension returned legacy v2.
|
||||
pub(super) v2_fallback: bool,
|
||||
}
|
||||
|
||||
pub(super) fn export_recorded_trace(
|
||||
output_dir: &Path,
|
||||
trace: &RecordedTrace,
|
||||
) -> Result<ExportMeta, CliError> {
|
||||
match trace {
|
||||
RecordedTrace::V3(trace) => {
|
||||
let states_dir = write_trace_bundle(output_dir, trace)?;
|
||||
Ok(ExportMeta {
|
||||
states_dir: Some(states_dir),
|
||||
trace_version: TRACE_VERSION_V3,
|
||||
v2_fallback: false,
|
||||
})
|
||||
}
|
||||
RecordedTrace::V2(trace) => {
|
||||
write_trace_v2(output_dir, trace)?;
|
||||
Ok(ExportMeta {
|
||||
states_dir: None,
|
||||
trace_version: TRACE_VERSION_V2,
|
||||
v2_fallback: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the completed Trace first, then write the bundle. Recovery is
|
||||
/// removed only after the export commits so a bad `--output` cannot drop
|
||||
/// a recording the extension already returned.
|
||||
pub(super) fn export_with_recovery(
|
||||
output_dir: &Path,
|
||||
trace: &RecordedTrace,
|
||||
) -> Result<ExportMeta, CliError> {
|
||||
let save_result = crate::cli::record_recovery::save(trace);
|
||||
match export_recorded_trace(output_dir, trace) {
|
||||
Ok(meta) => {
|
||||
crate::cli::record_recovery::clear();
|
||||
Ok(meta)
|
||||
}
|
||||
Err(export_err) => {
|
||||
let export_err = annotate_recovery_hint(export_err, save_result.is_ok());
|
||||
if let Err(save_err) = save_result {
|
||||
return Err(match export_err {
|
||||
CliError::Local(inner) => CliError::Local(
|
||||
inner.context(format!("also failed to save recovery data: {save_err}")),
|
||||
),
|
||||
other => other,
|
||||
});
|
||||
}
|
||||
Err(export_err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn annotate_recovery_hint(err: CliError, recovery_saved: bool) -> CliError {
|
||||
if !recovery_saved {
|
||||
return err;
|
||||
}
|
||||
match err {
|
||||
CliError::Local(inner) => CliError::Local(inner.context(
|
||||
"export failed; the recorded trace was saved and can be recovered with `bsk record stop --output <dir>`",
|
||||
)),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_json_output(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
|
||||
}
|
||||
|
||||
fn legacy_json_output_error(path: &Path) -> CliError {
|
||||
CliError::Local(anyhow::anyhow!(
|
||||
"--output {} is a JSON file path; Trace v3 writes a bundle directory \
|
||||
(`<dir>/trace.json` and `<dir>/states/`), not a single file. \
|
||||
Use `--output trace` or another directory.",
|
||||
path.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// Reject legacy `--output trace.json` (and any existing non-directory)
|
||||
/// before a recording starts, so the user is not blocked only after Finish.
|
||||
pub(super) fn validate_record_output(path: &Path) -> Result<(), CliError> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => Err(CliError::Local(anyhow::anyhow!(
|
||||
"--output {} must not be a symlink",
|
||||
path.display()
|
||||
))),
|
||||
Ok(metadata) if metadata.is_dir() => Ok(()),
|
||||
Ok(_) if looks_like_json_output(path) => Err(legacy_json_output_error(path)),
|
||||
Ok(_) => Err(CliError::Local(anyhow::anyhow!(
|
||||
"--output {} is not a directory; Trace v3 writes a bundle directory \
|
||||
(`<dir>/trace.json` and `<dir>/states/`). Use `--output trace`.",
|
||||
path.display()
|
||||
))),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||
if looks_like_json_output(path) {
|
||||
Err(legacy_json_output_error(path))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Err(err) => Err(CliError::Local(
|
||||
anyhow::Error::new(err).context(format!("inspect --output {}", path.display())),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn acquire_export_lock(output_dir: &Path) -> Result<std::fs::File, CliError> {
|
||||
validate_bundle_directory(output_dir, "output bundle directory")?;
|
||||
fs::create_dir_all(output_dir)
|
||||
.with_context(|| format!("create output dir {}", output_dir.display()))
|
||||
.map_err(CliError::Local)?;
|
||||
let lock_path = output_dir.join(".bsk-record-export.lock");
|
||||
let lock_file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(&lock_path)
|
||||
.with_context(|| format!("open export lock {}", lock_path.display()))
|
||||
.map_err(CliError::Local)?;
|
||||
fs2::FileExt::lock_exclusive(&lock_file)
|
||||
.with_context(|| format!("lock export bundle {}", output_dir.display()))
|
||||
.map_err(CliError::Local)?;
|
||||
Ok(lock_file)
|
||||
}
|
||||
|
||||
pub(super) fn write_trace_v2(output_dir: &Path, trace: &TraceV2) -> Result<(), CliError> {
|
||||
let trace_path = trace_json_path(output_dir);
|
||||
let states_dir = states_dir_for_output(output_dir);
|
||||
let json = serde_json::to_string_pretty(trace)
|
||||
.context("serialize trace JSON")
|
||||
.map_err(CliError::Local)?;
|
||||
|
||||
let _lock = acquire_export_lock(output_dir)?;
|
||||
validate_replaceable_file(&trace_path, "trace JSON")?;
|
||||
|
||||
let transaction_id = uuid::Uuid::new_v4();
|
||||
let staging_dir = output_dir.join(format!(".bsk-record-stage-{transaction_id}"));
|
||||
fs::create_dir_all(&staging_dir)
|
||||
.with_context(|| format!("create staging dir {}", staging_dir.display()))
|
||||
.map_err(CliError::Local)?;
|
||||
|
||||
let staged_trace = staging_dir.join("trace.json");
|
||||
let stage_result: Result<(), CliError> = (|| {
|
||||
fs::write(&staged_trace, format!("{json}\n"))
|
||||
.with_context(|| format!("write staged trace to {}", staged_trace.display()))
|
||||
.map_err(CliError::Local)?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(err) = stage_result {
|
||||
let _ = fs::remove_dir_all(&staging_dir);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
commit_export_transaction(
|
||||
&staging_dir,
|
||||
&states_dir,
|
||||
&HashSet::new(),
|
||||
transaction_id,
|
||||
vec![(
|
||||
staged_trace,
|
||||
trace_path,
|
||||
output_dir.join(format!(".trace.json.{transaction_id}.bak")),
|
||||
)],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn states_dir_for_output(output_dir: &Path) -> PathBuf {
|
||||
output_dir.join("states")
|
||||
}
|
||||
|
||||
pub(super) fn trace_json_path(output_dir: &Path) -> PathBuf {
|
||||
output_dir.join("trace.json")
|
||||
}
|
||||
|
||||
fn is_canonical_state_id(id: &str) -> bool {
|
||||
let Some(number) = id.strip_prefix('s') else {
|
||||
return false;
|
||||
};
|
||||
let mut digits = number.bytes();
|
||||
matches!(digits.next(), Some(b'1'..=b'9')) && digits.all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn validate_bundle_directory(path: &Path, description: &str) -> Result<(), CliError> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => Err(CliError::Local(anyhow::anyhow!(
|
||||
"{description} {} must not be a symlink",
|
||||
path.display()
|
||||
))),
|
||||
Ok(metadata) if metadata.is_dir() => Ok(()),
|
||||
Ok(_) if looks_like_json_output(path) => Err(legacy_json_output_error(path)),
|
||||
Ok(_) => Err(CliError::Local(anyhow::anyhow!(
|
||||
"{description} {} is not a directory; Trace v3 writes `<dir>/trace.json` and `<dir>/states/`. Use `--output trace`.",
|
||||
path.display()
|
||||
))),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(CliError::Local(
|
||||
anyhow::Error::new(err).context(format!("inspect {description} {}", path.display())),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_replaceable_file(path: &Path, description: &str) -> Result<(), CliError> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata) if metadata.file_type().is_file() => Ok(()),
|
||||
Ok(_) => Err(CliError::Local(anyhow::anyhow!(
|
||||
"{description} {} is not a regular file",
|
||||
path.display()
|
||||
))),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(CliError::Local(
|
||||
anyhow::Error::new(err).context(format!("inspect {description} {}", path.display())),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_staged_file(staged: &Path, target: &Path, backup: &Path) -> io::Result<Option<PathBuf>> {
|
||||
let old_file = match fs::symlink_metadata(target) {
|
||||
Ok(_) => {
|
||||
fs::rename(target, backup)?;
|
||||
Some(backup.to_path_buf())
|
||||
}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => None,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
if let Err(err) = fs::rename(staged, target) {
|
||||
if let Some(old_file) = old_file.as_deref() {
|
||||
if let Err(restore_err) = fs::rename(old_file, target) {
|
||||
return Err(io::Error::new(
|
||||
restore_err.kind(),
|
||||
format!(
|
||||
"{err}; additionally failed to restore {}: {restore_err}",
|
||||
target.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(old_file)
|
||||
}
|
||||
|
||||
fn rollback_installed_files(installed: &[(PathBuf, Option<PathBuf>)]) -> io::Result<()> {
|
||||
let mut rollback_error = None;
|
||||
for (target, backup) in installed.iter().rev() {
|
||||
match fs::remove_file(target) {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(err) => {
|
||||
rollback_error.get_or_insert(err);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(backup) = backup {
|
||||
if let Err(err) = fs::rename(backup, target) {
|
||||
rollback_error.get_or_insert(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
match rollback_error {
|
||||
Some(err) => Err(err),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_generated_state_name(name: &str) -> bool {
|
||||
name.strip_suffix(".txt").is_some_and(is_canonical_state_id)
|
||||
}
|
||||
|
||||
fn restore_staged_stale_states(staged: &[(PathBuf, PathBuf)]) -> io::Result<()> {
|
||||
let mut restore_error = None;
|
||||
for (original, backup) in staged.iter().rev() {
|
||||
if let Err(err) = fs::rename(backup, original) {
|
||||
restore_error.get_or_insert(err);
|
||||
}
|
||||
}
|
||||
match restore_error {
|
||||
Some(err) => Err(err),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Move stale generated states aside so replacement and cleanup form one
|
||||
/// rollback boundary. User-owned files, including arbitrary non-`sN.txt`, stay.
|
||||
fn stage_stale_states(
|
||||
states_dir: &Path,
|
||||
keep: &HashSet<String>,
|
||||
transaction_id: uuid::Uuid,
|
||||
) -> Result<Vec<(PathBuf, PathBuf)>, CliError> {
|
||||
let entries = match fs::read_dir(states_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(err) => {
|
||||
return Err(CliError::Local(
|
||||
anyhow::Error::new(err)
|
||||
.context(format!("read states dir {}", states_dir.display())),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let mut staged = Vec::new();
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(err) => {
|
||||
let restore_result = restore_staged_stale_states(&staged);
|
||||
let message = match restore_result {
|
||||
Ok(()) => format!("read stale state entry: {err}"),
|
||||
Err(restore_err) => {
|
||||
format!("read stale state entry: {err}; restore also failed: {restore_err}")
|
||||
}
|
||||
};
|
||||
return Err(CliError::Local(anyhow::anyhow!(message)));
|
||||
}
|
||||
};
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
if !is_generated_state_name(name) || keep.contains(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let original = entry.path();
|
||||
let file_type = match entry.file_type() {
|
||||
Ok(file_type) => file_type,
|
||||
Err(err) => {
|
||||
let restore_result = restore_staged_stale_states(&staged);
|
||||
let message = match restore_result {
|
||||
Ok(()) => format!("inspect stale state {}: {err}", original.display()),
|
||||
Err(restore_err) => format!(
|
||||
"inspect stale state {}: {err}; restore also failed: {restore_err}",
|
||||
original.display()
|
||||
),
|
||||
};
|
||||
return Err(CliError::Local(anyhow::anyhow!(message)));
|
||||
}
|
||||
};
|
||||
if file_type.is_dir() {
|
||||
let restore_result = restore_staged_stale_states(&staged);
|
||||
let message = match restore_result {
|
||||
Ok(()) => format!("stale state {} is a directory", original.display()),
|
||||
Err(restore_err) => format!(
|
||||
"stale state {} is a directory; restore also failed: {restore_err}",
|
||||
original.display()
|
||||
),
|
||||
};
|
||||
return Err(CliError::Local(anyhow::anyhow!(message)));
|
||||
}
|
||||
|
||||
let backup = states_dir.join(format!(".{name}.{transaction_id}.stale"));
|
||||
if let Err(err) = fs::rename(&original, &backup) {
|
||||
let restore_result = restore_staged_stale_states(&staged);
|
||||
let message = match restore_result {
|
||||
Ok(()) => format!("stage stale state {}: {err}", original.display()),
|
||||
Err(restore_err) => format!(
|
||||
"stage stale state {}: {err}; restore also failed: {restore_err}",
|
||||
original.display()
|
||||
),
|
||||
};
|
||||
return Err(CliError::Local(anyhow::anyhow!(message)));
|
||||
}
|
||||
staged.push((original, backup));
|
||||
}
|
||||
Ok(staged)
|
||||
}
|
||||
|
||||
fn commit_export_transaction(
|
||||
staging_dir: &Path,
|
||||
states_dir: &Path,
|
||||
keep_states: &HashSet<String>,
|
||||
transaction_id: uuid::Uuid,
|
||||
replacements: Vec<(PathBuf, PathBuf, PathBuf)>,
|
||||
) -> Result<(), CliError> {
|
||||
let mut installed = Vec::with_capacity(replacements.len());
|
||||
for (staged, target, backup) in replacements {
|
||||
match commit_staged_file(&staged, &target, &backup) {
|
||||
Ok(old_file) => installed.push((target, old_file)),
|
||||
Err(err) => {
|
||||
let rollback_result = rollback_installed_files(&installed);
|
||||
let _ = fs::remove_dir_all(staging_dir);
|
||||
let message = match rollback_result {
|
||||
Ok(()) => format!("commit replacement {}: {err}", target.display()),
|
||||
Err(rollback_err) => format!(
|
||||
"commit replacement {}: {err}; rollback also failed: {rollback_err}",
|
||||
target.display()
|
||||
),
|
||||
};
|
||||
return Err(CliError::Local(anyhow::anyhow!(message)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stale_states = match stage_stale_states(states_dir, keep_states, transaction_id) {
|
||||
Ok(stale) => stale,
|
||||
Err(err) => {
|
||||
let rollback_result = rollback_installed_files(&installed);
|
||||
let _ = fs::remove_dir_all(staging_dir);
|
||||
let message = match rollback_result {
|
||||
Ok(()) => err.to_string(),
|
||||
Err(rollback_err) => {
|
||||
format!("{err}; replacement rollback also failed: {rollback_err}")
|
||||
}
|
||||
};
|
||||
return Err(CliError::Local(anyhow::anyhow!(message)));
|
||||
}
|
||||
};
|
||||
|
||||
for (_, backup) in &installed {
|
||||
if let Some(backup) = backup {
|
||||
let _ = fs::remove_file(backup);
|
||||
}
|
||||
}
|
||||
for (_, backup) in stale_states {
|
||||
let _ = fs::remove_file(backup);
|
||||
}
|
||||
let _ = fs::remove_dir_all(staging_dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct BundleState<'a> {
|
||||
id: &'a str,
|
||||
url: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
title: Option<&'a str>,
|
||||
page: String,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct BundleTrace<'a> {
|
||||
version: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
purpose: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
started_at: Option<&'a str>,
|
||||
recorded_at: &'a str,
|
||||
stopped_by: &'a bsk_protocol::tools::StopReason,
|
||||
entry: &'a bsk_protocol::tools::TraceEntry,
|
||||
recorder: &'a bsk_protocol::tools::RecorderInfo,
|
||||
states: Vec<BundleState<'a>>,
|
||||
steps: &'a [bsk_protocol::tools::StepV3],
|
||||
}
|
||||
|
||||
pub(super) fn write_trace_bundle(output_dir: &Path, trace: &TraceV3) -> Result<PathBuf, CliError> {
|
||||
let states_dir = states_dir_for_output(output_dir);
|
||||
let trace_path = trace_json_path(output_dir);
|
||||
|
||||
if trace.version != TRACE_VERSION_V3 {
|
||||
return Err(CliError::Local(anyhow::anyhow!(
|
||||
"trace version {} is not supported (expected {})",
|
||||
trace.version,
|
||||
TRACE_VERSION_V3
|
||||
)));
|
||||
}
|
||||
|
||||
let mut state_ids = HashSet::with_capacity(trace.states.len());
|
||||
for state in &trace.states {
|
||||
if !is_canonical_state_id(&state.id) {
|
||||
return Err(CliError::Local(anyhow::anyhow!(
|
||||
"state id {:?} is not canonical (expected s1, s2, ...)",
|
||||
state.id
|
||||
)));
|
||||
}
|
||||
if !state_ids.insert(state.id.as_str()) {
|
||||
return Err(CliError::Local(anyhow::anyhow!(
|
||||
"duplicate state id {:?}",
|
||||
state.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut disk_states = Vec::with_capacity(trace.states.len());
|
||||
let mut state_writes = Vec::new();
|
||||
let mut keep = HashSet::new();
|
||||
for state in &trace.states {
|
||||
let filename = format!("{}.txt", state.id);
|
||||
let state_path = states_dir.join(&filename);
|
||||
if state_path.parent() != Some(states_dir.as_path()) {
|
||||
return Err(CliError::Local(anyhow::anyhow!(
|
||||
"state path {} escapes states directory {}",
|
||||
state_path.display(),
|
||||
states_dir.display()
|
||||
)));
|
||||
}
|
||||
keep.insert(filename.clone());
|
||||
state_writes.push((filename.clone(), state.body.as_str()));
|
||||
disk_states.push(BundleState {
|
||||
id: &state.id,
|
||||
url: &state.url,
|
||||
title: state.title.as_deref(),
|
||||
page: filename,
|
||||
truncated: state.truncated,
|
||||
});
|
||||
}
|
||||
|
||||
let disk_trace = BundleTrace {
|
||||
version: trace.version,
|
||||
purpose: trace.purpose.as_deref(),
|
||||
started_at: trace.started_at.as_deref(),
|
||||
recorded_at: &trace.recorded_at,
|
||||
stopped_by: &trace.stopped_by,
|
||||
entry: &trace.entry,
|
||||
recorder: &trace.recorder,
|
||||
states: disk_states,
|
||||
steps: &trace.steps,
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&disk_trace)
|
||||
.context("serialize trace JSON")
|
||||
.map_err(CliError::Local)?;
|
||||
|
||||
validate_bundle_directory(output_dir, "output bundle directory")?;
|
||||
fs::create_dir_all(output_dir)
|
||||
.with_context(|| format!("create output dir {}", output_dir.display()))
|
||||
.map_err(CliError::Local)?;
|
||||
|
||||
validate_bundle_directory(&states_dir, "states directory")?;
|
||||
|
||||
let _lock = acquire_export_lock(output_dir)?;
|
||||
|
||||
fs::create_dir_all(&states_dir)
|
||||
.with_context(|| format!("create states dir {}", states_dir.display()))
|
||||
.map_err(CliError::Local)?;
|
||||
|
||||
for (filename, _) in &state_writes {
|
||||
validate_replaceable_file(&states_dir.join(filename), "state observation")?;
|
||||
}
|
||||
validate_replaceable_file(&trace_path, "trace JSON")?;
|
||||
|
||||
let transaction_id = uuid::Uuid::new_v4();
|
||||
let staging_dir = output_dir.join(format!(".bsk-record-stage-{transaction_id}"));
|
||||
let staging_states = staging_dir.join("states");
|
||||
fs::create_dir_all(&staging_states)
|
||||
.with_context(|| format!("create staging dir {}", staging_states.display()))
|
||||
.map_err(CliError::Local)?;
|
||||
|
||||
let stage_result: Result<(), CliError> = (|| {
|
||||
for (filename, body) in &state_writes {
|
||||
let staged_path = staging_states.join(filename);
|
||||
fs::write(&staged_path, body)
|
||||
.with_context(|| {
|
||||
format!("write staged state observation {}", staged_path.display())
|
||||
})
|
||||
.map_err(CliError::Local)?;
|
||||
}
|
||||
let staged_trace = staging_dir.join("trace.json");
|
||||
fs::write(&staged_trace, format!("{json}\n"))
|
||||
.with_context(|| format!("write staged trace to {}", staged_trace.display()))
|
||||
.map_err(CliError::Local)?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(err) = stage_result {
|
||||
let _ = fs::remove_dir_all(&staging_dir);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let mut replacements: Vec<(PathBuf, PathBuf, PathBuf)> =
|
||||
Vec::with_capacity(state_writes.len() + 1);
|
||||
for (filename, _) in &state_writes {
|
||||
replacements.push((
|
||||
staging_states.join(filename),
|
||||
states_dir.join(filename),
|
||||
states_dir.join(format!(".{filename}.{transaction_id}.bak")),
|
||||
));
|
||||
}
|
||||
replacements.push((
|
||||
staging_dir.join("trace.json"),
|
||||
trace_path.clone(),
|
||||
output_dir.join(format!(".trace.json.{transaction_id}.bak")),
|
||||
));
|
||||
|
||||
commit_export_transaction(
|
||||
&staging_dir,
|
||||
&states_dir,
|
||||
&keep,
|
||||
transaction_id,
|
||||
replacements,
|
||||
)?;
|
||||
|
||||
Ok(states_dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use super::*;
|
||||
use bsk_protocol::tools::{
|
||||
NavigationCause, PageRefV2 as PageRef, RecorderInfo, StepCommonV3 as StepCommon,
|
||||
StepResultV3 as StepResult, StepV3 as Step, StopReason, TRACE_VERSION_V2, TRACE_VERSION_V3,
|
||||
TraceEntry, TraceStateV3, TraceV2, TraceV3, VOM_FORMAT_VERSION,
|
||||
};
|
||||
|
||||
fn sample_trace(state_id: &str, body: &str) -> TraceV3 {
|
||||
TraceV3 {
|
||||
version: TRACE_VERSION_V3,
|
||||
recorded_at: "2026-08-10T02:12:55.360Z".into(),
|
||||
stopped_by: StopReason::UserFinish,
|
||||
entry: TraceEntry {
|
||||
start_url: "https://example.com/".into(),
|
||||
},
|
||||
recorder: RecorderInfo {
|
||||
bsk: "0.1.10".into(),
|
||||
vom: VOM_FORMAT_VERSION,
|
||||
},
|
||||
states: vec![TraceStateV3 {
|
||||
id: state_id.into(),
|
||||
url: "https://example.com/".into(),
|
||||
title: Some("Example".into()),
|
||||
body: body.into(),
|
||||
truncated: false,
|
||||
}],
|
||||
steps: vec![Step::Navigate {
|
||||
common: StepCommon {
|
||||
id: 1,
|
||||
state: state_id.into(),
|
||||
result: StepResult {
|
||||
state: state_id.into(),
|
||||
},
|
||||
},
|
||||
to: "https://example.com/".into(),
|
||||
cause: NavigationCause::UserTyped,
|
||||
}],
|
||||
purpose: None,
|
||||
started_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_trace_bundle_writes_dir_layout() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = dir.path().join("flow");
|
||||
let trace = sample_trace("s1", "@vom 1\nL1 page");
|
||||
let states_dir = write_trace_bundle(&output, &trace).unwrap();
|
||||
let trace_path = output.join("trace.json");
|
||||
assert!(trace_path.exists());
|
||||
assert_eq!(states_dir, output.join("states"));
|
||||
assert!(states_dir.join("s1.txt").exists());
|
||||
let disk: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&trace_path).unwrap()).unwrap();
|
||||
assert_eq!(disk["states"][0]["page"], "s1.txt");
|
||||
assert!(disk["states"][0].get("body").is_none());
|
||||
assert_eq!(
|
||||
fs::read_to_string(states_dir.join("s1.txt")).unwrap(),
|
||||
"@vom 1\nL1 page"
|
||||
);
|
||||
|
||||
// Re-exporting drops states from the previous run but keeps the
|
||||
// directory in place for the concurrent writer.
|
||||
fs::write(states_dir.join("s9.txt"), "stale").unwrap();
|
||||
fs::write(states_dir.join("notes.md"), "keep me").unwrap();
|
||||
fs::write(states_dir.join("reference.txt"), "user file").unwrap();
|
||||
write_trace_bundle(&output, &trace).unwrap();
|
||||
assert!(!states_dir.join("s9.txt").exists());
|
||||
assert!(states_dir.join("s1.txt").exists());
|
||||
assert!(states_dir.join("notes.md").exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(states_dir.join("reference.txt")).unwrap(),
|
||||
"user file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_trace_bundle_rejects_traversal_state_id() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = dir.path().join("flow");
|
||||
let trace = sample_trace("../escaped", "@vom 1\nL1 page");
|
||||
|
||||
let err = write_trace_bundle(&output, &trace).unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("state id"));
|
||||
assert!(!output.join("escaped.txt").exists());
|
||||
assert!(!output.join("trace.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_trace_bundle_rejects_duplicate_state_id() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = dir.path().join("flow");
|
||||
let mut trace = sample_trace("s1", "@vom 1\nL1 first");
|
||||
trace.states.push(TraceStateV3 {
|
||||
body: "@vom 1\nL1 duplicate".into(),
|
||||
..trace.states[0].clone()
|
||||
});
|
||||
|
||||
let err = write_trace_bundle(&output, &trace).unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("duplicate state id"));
|
||||
assert!(!output.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_version_preserves_existing_bundle() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = dir.path().join("flow");
|
||||
write_trace_bundle(&output, &sample_trace("s1", "original")).unwrap();
|
||||
let original_json = fs::read(output.join("trace.json")).unwrap();
|
||||
|
||||
let mut replacement = sample_trace("s2", "replacement");
|
||||
replacement.version += 1;
|
||||
let err = write_trace_bundle(&output, &replacement).unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("not supported"));
|
||||
assert_eq!(fs::read(output.join("trace.json")).unwrap(), original_json);
|
||||
assert_eq!(
|
||||
fs::read_to_string(output.join("states/s1.txt")).unwrap(),
|
||||
"original"
|
||||
);
|
||||
assert!(!output.join("states/s2.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_replacement_failure_preserves_existing_bundle() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = dir.path().join("flow");
|
||||
write_trace_bundle(&output, &sample_trace("s1", "original")).unwrap();
|
||||
let original_json = fs::read(output.join("trace.json")).unwrap();
|
||||
fs::write(output.join("states/notes.md"), "keep me").unwrap();
|
||||
fs::create_dir(output.join("states/s2.txt")).unwrap();
|
||||
|
||||
let err = write_trace_bundle(&output, &sample_trace("s2", "replacement")).unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("state"));
|
||||
assert_eq!(fs::read(output.join("trace.json")).unwrap(), original_json);
|
||||
assert_eq!(
|
||||
fs::read_to_string(output.join("states/s1.txt")).unwrap(),
|
||||
"original"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(output.join("states/notes.md")).unwrap(),
|
||||
"keep me"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_state_cleanup_failure_rolls_back_replacement() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = dir.path().join("flow");
|
||||
write_trace_bundle(&output, &sample_trace("s1", "original")).unwrap();
|
||||
let original_json = fs::read(output.join("trace.json")).unwrap();
|
||||
fs::create_dir(output.join("states/s9.txt")).unwrap();
|
||||
|
||||
let err = write_trace_bundle(&output, &sample_trace("s2", "replacement")).unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("stale state"));
|
||||
assert_eq!(fs::read(output.join("trace.json")).unwrap(), original_json);
|
||||
assert_eq!(
|
||||
fs::read_to_string(output.join("states/s1.txt")).unwrap(),
|
||||
"original"
|
||||
);
|
||||
assert!(!output.join("states/s2.txt").exists());
|
||||
assert!(output.join("states/s9.txt").is_dir());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn write_trace_bundle_rejects_symlinked_states_directory() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = dir.path().join("flow");
|
||||
let external_states = dir.path().join("external-states");
|
||||
fs::create_dir_all(&output).unwrap();
|
||||
fs::create_dir_all(&external_states).unwrap();
|
||||
fs::write(external_states.join("s9.txt"), "external").unwrap();
|
||||
symlink(&external_states, output.join("states")).unwrap();
|
||||
|
||||
let err = write_trace_bundle(&output, &sample_trace("s1", "replacement")).unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("symlink"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(external_states.join("s9.txt")).unwrap(),
|
||||
"external"
|
||||
);
|
||||
assert!(!external_states.join("s1.txt").exists());
|
||||
assert!(!output.join("trace.json").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn write_trace_bundle_rejects_symlinked_output_directory() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let external_output = dir.path().join("external-output");
|
||||
let output = dir.path().join("flow");
|
||||
fs::create_dir_all(&external_output).unwrap();
|
||||
fs::write(external_output.join("notes.md"), "external").unwrap();
|
||||
symlink(&external_output, &output).unwrap();
|
||||
|
||||
let err = write_trace_bundle(&output, &sample_trace("s1", "replacement")).unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("symlink"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(external_output.join("notes.md")).unwrap(),
|
||||
"external"
|
||||
);
|
||||
assert!(!external_output.join("trace.json").exists());
|
||||
assert!(!external_output.join("states").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_trace_v2_writes_single_json_and_prunes_stale_states() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = dir.path().join("flow");
|
||||
fs::create_dir_all(output.join("states")).unwrap();
|
||||
fs::write(output.join("states/s1.txt"), "stale").unwrap();
|
||||
|
||||
let trace = TraceV2 {
|
||||
recorded_at: "2026-08-10T02:12:55.360Z".into(),
|
||||
started_at: None,
|
||||
purpose: None,
|
||||
entry: bsk_protocol::tools::TraceEntry {
|
||||
start_url: "https://example.com/".into(),
|
||||
},
|
||||
pages: vec![PageRef {
|
||||
id: "p1".into(),
|
||||
url: "https://example.com/".into(),
|
||||
title: None,
|
||||
}],
|
||||
steps: vec![],
|
||||
};
|
||||
write_trace_v2(&output, &trace).unwrap();
|
||||
assert!(output.join("trace.json").exists());
|
||||
assert!(!output.join("states/s1.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_trace_v2_stale_cleanup_failure_rolls_back_trace() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = dir.path().join("flow");
|
||||
write_trace_bundle(&output, &sample_trace("s1", "original")).unwrap();
|
||||
let original_json = fs::read(output.join("trace.json")).unwrap();
|
||||
fs::create_dir(output.join("states/s9.txt")).unwrap();
|
||||
let trace = TraceV2 {
|
||||
recorded_at: "2026-08-10T02:12:55.360Z".into(),
|
||||
started_at: None,
|
||||
purpose: None,
|
||||
entry: bsk_protocol::tools::TraceEntry {
|
||||
start_url: "https://example.com/".into(),
|
||||
},
|
||||
pages: vec![],
|
||||
steps: vec![],
|
||||
};
|
||||
|
||||
let err = write_trace_v2(&output, &trace).unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("stale state"));
|
||||
assert_eq!(fs::read(output.join("trace.json")).unwrap(), original_json);
|
||||
assert_eq!(
|
||||
fs::read_to_string(output.join("states/s1.txt")).unwrap(),
|
||||
"original"
|
||||
);
|
||||
assert!(output.join("states/s9.txt").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_recorded_trace_accepts_v2() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = dir.path().join("flow");
|
||||
let trace = RecordedTrace::V2(TraceV2 {
|
||||
recorded_at: "2026-08-10T02:12:55.360Z".into(),
|
||||
started_at: None,
|
||||
purpose: None,
|
||||
entry: bsk_protocol::tools::TraceEntry {
|
||||
start_url: "https://example.com/".into(),
|
||||
},
|
||||
pages: vec![],
|
||||
steps: vec![],
|
||||
});
|
||||
let exported = export_recorded_trace(&output, &trace).unwrap();
|
||||
assert_eq!(exported.trace_version, TRACE_VERSION_V2);
|
||||
assert!(exported.states_dir.is_none());
|
||||
assert!(output.join("trace.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_trace_json_file_is_rejected_with_bundle_usage() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = dir.path().join("trace.json");
|
||||
fs::write(&output, "{}\n").unwrap();
|
||||
|
||||
let err = validate_record_output(&output).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("JSON file") || msg.contains("json file"),
|
||||
"should name the legacy JSON file usage: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("states/") || msg.contains("--output trace"),
|
||||
"should hint at the new bundle directory usage: {msg}"
|
||||
);
|
||||
assert_eq!(fs::read_to_string(&output).unwrap(), "{}\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_trace_json_path_is_rejected_as_legacy_output() {
|
||||
let err = validate_record_output(Path::new("trace.json")).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("JSON file") || msg.contains("json file"),
|
||||
"{msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("states/") || msg.contains("--output trace"),
|
||||
"{msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_failure_keeps_recovery_for_retry() {
|
||||
let _lock = crate::cli::record_recovery::test_env_lock();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
unsafe {
|
||||
std::env::set_var(crate::daemon::paths::BSK_HOME_ENV, tmp.path());
|
||||
}
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let bad_output = dir.path().join("trace.json");
|
||||
fs::write(&bad_output, "{}\n").unwrap();
|
||||
let trace = RecordedTrace::V3(sample_trace("s1", "@vom 1\nL1 page"));
|
||||
|
||||
let err = export_with_recovery(&bad_output, &trace).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("recover") || msg.contains("record stop"),
|
||||
"export failure should tell the user how to recover: {msg}"
|
||||
);
|
||||
assert!(
|
||||
crate::cli::record_recovery::exists(),
|
||||
"recovery data must survive a failed bundle export"
|
||||
);
|
||||
let recovered = crate::cli::record_recovery::load()
|
||||
.unwrap()
|
||||
.expect("recovery file");
|
||||
assert_eq!(recovered, trace);
|
||||
assert_eq!(fs::read_to_string(&bad_output).unwrap(), "{}\n");
|
||||
|
||||
let good_output = dir.path().join("bundle");
|
||||
export_with_recovery(&good_output, &recovered).unwrap();
|
||||
assert!(good_output.join("trace.json").exists());
|
||||
assert!(good_output.join("states/s1.txt").exists());
|
||||
assert!(
|
||||
!crate::cli::record_recovery::exists(),
|
||||
"successful export must delete recovery data"
|
||||
);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var(crate::daemon::paths::BSK_HOME_ENV);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Persist a completed Trace so a failed bundle export can be retried.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use bsk_protocol::tools::RecordedTrace;
|
||||
|
||||
use crate::daemon::paths;
|
||||
|
||||
pub fn save(trace: &RecordedTrace) -> Result<()> {
|
||||
let path = paths::record_recovery_path()?;
|
||||
paths::ensure_bsk_home()?;
|
||||
let json = serde_json::to_string_pretty(trace).context("serialize record recovery")?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
fs::write(&tmp, format!("{json}\n")).with_context(|| format!("write {}", tmp.display()))?;
|
||||
fs::rename(&tmp, &path).with_context(|| format!("commit {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Option<RecordedTrace>> {
|
||||
let path = paths::record_recovery_path()?;
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
|
||||
let trace = serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
|
||||
Ok(Some(trace))
|
||||
}
|
||||
|
||||
pub fn exists() -> bool {
|
||||
paths::record_recovery_path()
|
||||
.map(|path| path.exists())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn clear() {
|
||||
if let Ok(path) = paths::record_recovery_path() {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
crate::daemon::paths::test_env_lock()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bsk_protocol::tools::{
|
||||
RecorderInfo, StopReason, TRACE_VERSION_V3, TraceEntry, TraceStateV3, TraceV3,
|
||||
VOM_FORMAT_VERSION,
|
||||
};
|
||||
|
||||
fn sample_trace() -> RecordedTrace {
|
||||
RecordedTrace::V3(TraceV3 {
|
||||
version: TRACE_VERSION_V3,
|
||||
recorded_at: "2026-08-10T02:12:55.360Z".into(),
|
||||
stopped_by: StopReason::UserFinish,
|
||||
entry: TraceEntry {
|
||||
start_url: "https://example.com/".into(),
|
||||
},
|
||||
recorder: RecorderInfo {
|
||||
bsk: "0.1.10".into(),
|
||||
vom: VOM_FORMAT_VERSION,
|
||||
},
|
||||
states: vec![TraceStateV3 {
|
||||
id: "s1".into(),
|
||||
url: "https://example.com/".into(),
|
||||
title: Some("Example".into()),
|
||||
body: "@vom 1\nL1 page".into(),
|
||||
truncated: false,
|
||||
}],
|
||||
steps: vec![],
|
||||
purpose: None,
|
||||
started_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn with_temp_home<F: FnOnce()>(f: F) {
|
||||
let _lock = test_env_lock();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
unsafe {
|
||||
std::env::set_var(crate::daemon::paths::BSK_HOME_ENV, tmp.path());
|
||||
}
|
||||
f();
|
||||
clear();
|
||||
unsafe {
|
||||
std::env::remove_var(crate::daemon::paths::BSK_HOME_ENV);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_load_round_trips() {
|
||||
with_temp_home(|| {
|
||||
let trace = sample_trace();
|
||||
save(&trace).unwrap();
|
||||
assert!(exists());
|
||||
assert_eq!(load().unwrap(), Some(trace));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_removes_recovery_file() {
|
||||
with_temp_home(|| {
|
||||
save(&sample_trace()).unwrap();
|
||||
clear();
|
||||
assert!(!exists());
|
||||
assert_eq!(load().unwrap(), None);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -59,15 +59,9 @@ fn started_at_unix_ms() -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn env_guard() -> &'static Mutex<()> {
|
||||
static GUARD: Mutex<()> = Mutex::new(());
|
||||
&GUARD
|
||||
}
|
||||
|
||||
fn with_temp_home<F: FnOnce()>(f: F) {
|
||||
let _lock = env_guard().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _lock = crate::daemon::paths::test_env_lock();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
unsafe {
|
||||
std::env::set_var(crate::daemon::paths::BSK_HOME_ENV, tmp.path());
|
||||
|
||||
@@ -50,6 +50,7 @@ pub mod reason {
|
||||
pub const BORROW_CONFLICT: &str = "borrow_conflict";
|
||||
pub const SCREENSHOT_CAPTURE_FAILED: &str = "screenshot_capture_failed";
|
||||
pub const SESSION_BUSY: &str = crate::rpc_reason::SESSION_BUSY;
|
||||
pub const RECORD_START_PAGE_UNREACHABLE: &str = "record_start_page_unreachable";
|
||||
}
|
||||
|
||||
/// Per-code rendering metadata. The `summary` is what the CLI prints
|
||||
@@ -269,6 +270,16 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
|
||||
),
|
||||
exit_code: base.exit_code,
|
||||
},
|
||||
(
|
||||
ErrorCode::CdpFailed | ErrorCode::Timeout | ErrorCode::InvalidParams,
|
||||
reason::RECORD_START_PAGE_UNREACHABLE,
|
||||
) => RenderInfo {
|
||||
summary: "default start page https://example.com/ could not be opened",
|
||||
hint: Some(
|
||||
"that site may be unreachable from your network. Pass --url with a page you can open, e.g. `bsk record start --url https://<site>/`",
|
||||
),
|
||||
exit_code: base.exit_code,
|
||||
},
|
||||
_ => base,
|
||||
}
|
||||
}
|
||||
@@ -378,6 +389,22 @@ mod tests {
|
||||
assert_eq!(info.exit_code, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_start_page_unreachable_overrides_cdp_failed_copy() {
|
||||
let data = serde_json::json!({ "reason": reason::RECORD_START_PAGE_UNREACHABLE });
|
||||
let info = info_for_error(ErrorCode::CdpFailed, Some(&data));
|
||||
assert_eq!(
|
||||
info.summary,
|
||||
"default start page https://example.com/ could not be opened"
|
||||
);
|
||||
assert!(
|
||||
info.hint.unwrap().contains("--url"),
|
||||
"expected --url hint, got {:?}",
|
||||
info.hint
|
||||
);
|
||||
assert_eq!(info.exit_code, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_capture_failed_overrides_cdp_failed_copy() {
|
||||
let data = serde_json::json!({ "reason": reason::SCREENSHOT_CAPTURE_FAILED });
|
||||
|
||||
@@ -100,6 +100,14 @@ pub fn record_session_path() -> Result<PathBuf> {
|
||||
Ok(bsk_home()?.join("record-session.json"))
|
||||
}
|
||||
|
||||
/// Path to a completed Trace saved before bundle export (`record-recovery.json`).
|
||||
///
|
||||
/// Kept until export succeeds so a failed `--output` write cannot drop the
|
||||
/// recording the extension already returned.
|
||||
pub fn record_recovery_path() -> Result<PathBuf> {
|
||||
Ok(bsk_home()?.join("record-recovery.json"))
|
||||
}
|
||||
|
||||
/// Windows named-pipe name. Include the resolved `BSK_HOME` path in the
|
||||
/// token so test homes and custom installs do not share a predictable
|
||||
/// per-username pipe.
|
||||
@@ -119,6 +127,13 @@ pub fn pipe_name() -> String {
|
||||
render_pipe_name(&user, &home)
|
||||
}
|
||||
|
||||
/// Process-wide lock for tests that mutate `BSK_HOME`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
GUARD.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Render the Windows named-pipe name for a user/home pair. Kept
|
||||
/// platform-agnostic so unit tests on any host can pin the output
|
||||
/// character set.
|
||||
@@ -136,19 +151,12 @@ fn render_pipe_name(user: &str, home: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Serialise tests that mutate the global `BSK_HOME` env var.
|
||||
fn env_guard() -> &'static Mutex<()> {
|
||||
static GUARD: Mutex<()> = Mutex::new(());
|
||||
&GUARD
|
||||
}
|
||||
|
||||
fn with_temp_home<F: FnOnce(&Path)>(f: F) {
|
||||
let _lock = env_guard().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _lock = test_env_lock();
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// SAFETY: serialised by env_guard above.
|
||||
// SAFETY: serialised by test_env_lock above.
|
||||
unsafe {
|
||||
std::env::set_var(BSK_HOME_ENV, tmp.path().join("bsk"));
|
||||
}
|
||||
@@ -187,6 +195,10 @@ mod tests {
|
||||
record_session_path().unwrap(),
|
||||
home.join("record-session.json")
|
||||
);
|
||||
assert_eq!(
|
||||
record_recovery_path().unwrap(),
|
||||
home.join("record-recovery.json")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ use crate::daemon::{
|
||||
browsers::{BROWSER_LIVENESS_TICK, BROWSER_LIVENESS_TIMEOUT, EXTENSION_CONNECT_WAIT},
|
||||
info as daemon_info, ipc, lockfile, paths,
|
||||
sessions::{StopSessionError, forget_session, stop_session},
|
||||
state::DaemonState,
|
||||
state::{DaemonState, PROTOCOL_VERSION},
|
||||
ws,
|
||||
};
|
||||
|
||||
@@ -297,7 +297,7 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
|
||||
ws_port,
|
||||
sock_path: sock_path.clone(),
|
||||
daemon_version: env!("CARGO_PKG_VERSION"),
|
||||
protocol_version: "1.0",
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
};
|
||||
let handler = ipc::full_handler(status, Arc::clone(&state));
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::start::DaemonConfig;
|
||||
use super::ws::WsHandle;
|
||||
|
||||
pub const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub const PROTOCOL_VERSION: &str = "1.0";
|
||||
pub const PROTOCOL_VERSION: &str = "1.1";
|
||||
/// Lowest **protocol** version peers must speak (e.g. `"1.0"`).
|
||||
pub const MIN_COMPATIBLE_PROTOCOL: &str = "1.0";
|
||||
/// Legacy app-semver floor used only when `HandshakeResult.min_compatible_peer`
|
||||
|
||||
@@ -108,12 +108,12 @@ async fn send_handshake_with_floors(
|
||||
async fn handshake_ok_when_protocol_matches() {
|
||||
let (handle, _sock) = spawn_daemon().await;
|
||||
let mut ws = open_ws(handle.ws_addr()).await;
|
||||
let resp = send_handshake(&mut ws, "1.0", env!("CARGO_PKG_VERSION")).await;
|
||||
let resp = send_handshake(&mut ws, "1.1", env!("CARGO_PKG_VERSION")).await;
|
||||
let result: HandshakeResult = match resp.body {
|
||||
ResponseBody::Ok(v) => serde_json::from_value(v).unwrap(),
|
||||
ResponseBody::Err(e) => panic!("expected ok handshake, got {e:?}"),
|
||||
};
|
||||
assert_eq!(result.protocol_version, "1.0");
|
||||
assert_eq!(result.protocol_version, "1.1");
|
||||
assert_eq!(
|
||||
result
|
||||
.min_compatible_peer
|
||||
@@ -135,7 +135,7 @@ async fn handshake_ok_when_app_versions_differ_but_protocol_matches() {
|
||||
let (handle, _sock) = spawn_daemon().await;
|
||||
let mut ws = open_ws(handle.ws_addr()).await;
|
||||
let resp =
|
||||
send_handshake_with_floors(&mut ws, "1.0", "9.9.9", Some("0.0.0"), Some("1.0")).await;
|
||||
send_handshake_with_floors(&mut ws, "1.1", "9.9.9", Some("0.0.0"), Some("1.1")).await;
|
||||
match resp.body {
|
||||
ResponseBody::Ok(_) => {}
|
||||
other => panic!("expected ok when protocol matches, got {other:?}"),
|
||||
@@ -149,10 +149,10 @@ async fn handshake_skew_when_protocol_minor_differs() {
|
||||
let mut ws = open_ws(handle.ws_addr()).await;
|
||||
let resp = send_handshake_with_floors(
|
||||
&mut ws,
|
||||
"1.1",
|
||||
"1.2",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
Some("0.0.0"),
|
||||
Some("1.0"),
|
||||
Some("1.1"),
|
||||
)
|
||||
.await;
|
||||
match resp.body {
|
||||
@@ -210,7 +210,7 @@ async fn handshake_legacy_ext_without_protocol_floor_still_ok() {
|
||||
let mut ws = open_ws(handle.ws_addr()).await;
|
||||
let resp = send_handshake_with_floors(
|
||||
&mut ws,
|
||||
"1.0",
|
||||
"1.1",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
Some("0.1.0"),
|
||||
None,
|
||||
@@ -235,7 +235,7 @@ async fn status_surfaces_version_skew_for_skewed_browser() {
|
||||
browser_name: "chrome".into(),
|
||||
browser_version: "131.0".into(),
|
||||
extension_version: "9.9.9".into(),
|
||||
extension_protocol_version: "1.1".into(),
|
||||
extension_protocol_version: "1.2".into(),
|
||||
label: "Older".into(),
|
||||
sink: bsk::daemon::browsers::BrowserSink { tx },
|
||||
pending: Mutex::new(bsk::daemon::browsers::Pending::default()),
|
||||
@@ -263,8 +263,8 @@ async fn status_surfaces_version_skew_for_skewed_browser() {
|
||||
.iter()
|
||||
.find(|s| s.instance_id == "skew-only-test")
|
||||
.expect("status must list our skew client");
|
||||
assert_eq!(skew.client_protocol_version, "1.1");
|
||||
assert_eq!(skew.server_protocol_version, "1.0");
|
||||
assert_eq!(skew.client_protocol_version, "1.2");
|
||||
assert_eq!(skew.server_protocol_version, "1.1");
|
||||
assert_eq!(skew.client_version, "9.9.9");
|
||||
let entry = status
|
||||
.browsers
|
||||
@@ -281,7 +281,7 @@ async fn handshake_rejects_when_local_below_peer_min_compatible_protocol() {
|
||||
let mut ws = open_ws(handle.ws_addr()).await;
|
||||
let resp = send_handshake_with_floors(
|
||||
&mut ws,
|
||||
"1.0",
|
||||
"1.1",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
Some("0.0.0"),
|
||||
Some("99.0.0"),
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use bsk::daemon::info::DaemonInfo;
|
||||
use bsk::daemon::paths::BSK_HOME_ENV;
|
||||
use bsk::daemon::{self, DaemonConfig};
|
||||
use bsk::ipc_client::IpcClient;
|
||||
use bsk_protocol::system::{HandshakeParams, HandshakeResult};
|
||||
use bsk_protocol::tools::{
|
||||
RecordStopResult, RecordedTrace, RecorderInfo, SessionStartParams, SessionStartResult,
|
||||
StopReason, TraceEntry, TraceStateV3, TraceV3, VOM_FORMAT_VERSION,
|
||||
};
|
||||
use bsk_protocol::{BrowserPeerInfo, Frame, Method, RequestFrame, ResponseBody, ResponseFrame};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::json;
|
||||
use tokio::process::Command;
|
||||
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
|
||||
use tokio_tungstenite::tungstenite::http::Request;
|
||||
use tokio_tungstenite::tungstenite::protocol::Message;
|
||||
|
||||
const TEST_EXT_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
|
||||
|
||||
type Ws =
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
fn bsk_bin() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_BIN_EXE_bsk"))
|
||||
}
|
||||
|
||||
async fn connect_extension(addr: std::net::SocketAddr) -> Ws {
|
||||
let origin = format!("chrome-extension://{TEST_EXT_ID}");
|
||||
let request = Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!("ws://{addr}/"))
|
||||
.header("Host", addr.to_string())
|
||||
.header("Upgrade", "websocket")
|
||||
.header("Connection", "Upgrade")
|
||||
.header("Sec-WebSocket-Version", "13")
|
||||
.header("Sec-WebSocket-Key", generate_key())
|
||||
.header("Origin", origin)
|
||||
.body(())
|
||||
.unwrap();
|
||||
tokio_tungstenite::connect_async(request).await.unwrap().0
|
||||
}
|
||||
|
||||
async fn handshake(ws: &mut Ws) {
|
||||
let request = RequestFrame {
|
||||
id: "handshake".into(),
|
||||
method: Method::SystemHandshake,
|
||||
params: Some(
|
||||
serde_json::to_value(HandshakeParams {
|
||||
client: "browser-skill-extension".into(),
|
||||
version: "0.1.10".parse().unwrap(),
|
||||
protocol_version: "1.1".into(),
|
||||
instance_id: TEST_EXT_ID.into(),
|
||||
browser: BrowserPeerInfo {
|
||||
name: "chrome".into(),
|
||||
version: "131.0".into(),
|
||||
},
|
||||
min_compatible_peer: Some("0.1.10".parse().unwrap()),
|
||||
min_compatible_protocol: Some("1.1".into()),
|
||||
label: "Record export recovery test".into(),
|
||||
})
|
||||
.unwrap(),
|
||||
),
|
||||
};
|
||||
ws.send(Message::Text(serde_json::to_string(&request).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
let Message::Text(response) = ws.next().await.unwrap().unwrap() else {
|
||||
panic!("expected text handshake response");
|
||||
};
|
||||
let response: ResponseFrame = serde_json::from_str(&response).unwrap();
|
||||
let ResponseBody::Ok(value) = response.body else {
|
||||
panic!("handshake failed");
|
||||
};
|
||||
let _: HandshakeResult = serde_json::from_value(value).unwrap();
|
||||
}
|
||||
|
||||
fn sample_trace() -> TraceV3 {
|
||||
TraceV3 {
|
||||
version: 3,
|
||||
purpose: Some("export recovery".into()),
|
||||
started_at: Some("2026-08-12T10:00:00Z".into()),
|
||||
recorded_at: "2026-08-12T10:01:00Z".into(),
|
||||
stopped_by: StopReason::CliStop,
|
||||
entry: TraceEntry {
|
||||
start_url: "https://example.com/".into(),
|
||||
},
|
||||
recorder: RecorderInfo {
|
||||
bsk: "0.1.10".into(),
|
||||
vom: VOM_FORMAT_VERSION,
|
||||
},
|
||||
states: vec![TraceStateV3 {
|
||||
id: "s1".into(),
|
||||
url: "https://example.com/".into(),
|
||||
title: Some("Example".into()),
|
||||
body: "@vom 1\nL1 page".into(),
|
||||
truncated: false,
|
||||
}],
|
||||
steps: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn run_extension(
|
||||
mut ws: Ws,
|
||||
record_stop_calls: Arc<AtomicUsize>,
|
||||
session_stop_calls: Arc<AtomicUsize>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
while let Some(Ok(Message::Text(text))) = ws.next().await {
|
||||
let Ok(Frame::Request(request)) = serde_json::from_str::<Frame>(&text) else {
|
||||
continue;
|
||||
};
|
||||
let body = match request.method {
|
||||
Method::ToolSessionStart => {
|
||||
let _: SessionStartParams =
|
||||
serde_json::from_value(request.params.clone().unwrap()).unwrap();
|
||||
ResponseBody::Ok(
|
||||
serde_json::to_value(SessionStartResult {
|
||||
agent_window_id: Some(100),
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
Method::ToolRecordStop => {
|
||||
record_stop_calls.fetch_add(1, Ordering::SeqCst);
|
||||
ResponseBody::Ok(
|
||||
serde_json::to_value(RecordStopResult {
|
||||
trace: RecordedTrace::V3(sample_trace()),
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
Method::ToolSessionStop => {
|
||||
session_stop_calls.fetch_add(1, Ordering::SeqCst);
|
||||
ResponseBody::Ok(json!({}))
|
||||
}
|
||||
_ => ResponseBody::Err(bsk_protocol::RpcError {
|
||||
code: bsk_protocol::ErrorCode::ProtocolError,
|
||||
message: format!("unexpected method {:?}", request.method),
|
||||
data: None,
|
||||
}),
|
||||
};
|
||||
let response = ResponseFrame {
|
||||
id: request.id,
|
||||
body,
|
||||
};
|
||||
ws.send(Message::Text(serde_json::to_string(&response).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn start_session(sock: &Path) -> String {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct StartResult {
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
let mut client = IpcClient::connect(sock).await.unwrap();
|
||||
client
|
||||
.call::<(), StartResult>(
|
||||
"session-start",
|
||||
Method::SessionStart,
|
||||
None,
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.session_id
|
||||
}
|
||||
|
||||
fn write_record_session(home: &Path, session_id: &str) {
|
||||
std::fs::create_dir_all(home).unwrap();
|
||||
std::fs::write(
|
||||
home.join("record-session.json"),
|
||||
format!("{{\"session_id\":\"{session_id}\",\"started_at\":\"1\"}}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn run_record_stop(home: &Path, output: &Path) -> std::process::Output {
|
||||
Command::new(bsk_bin())
|
||||
.arg("record")
|
||||
.arg("stop")
|
||||
.arg("--output")
|
||||
.arg(output)
|
||||
.env(BSK_HOME_ENV, home)
|
||||
.output()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn setup_daemon(temp: &Path) -> (bsk::daemon::DaemonHandle, PathBuf, PathBuf) {
|
||||
let home = temp.join("bsk-home");
|
||||
let sock = temp.join("daemon.sock");
|
||||
let daemon = daemon::run(DaemonConfig::new(0), Some(sock.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
bsk::daemon::info::write_to_path(
|
||||
&DaemonInfo::now(
|
||||
std::process::id(),
|
||||
sock.clone(),
|
||||
daemon.ws_addr().port(),
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
),
|
||||
&home.join("daemon.json"),
|
||||
)
|
||||
.unwrap();
|
||||
(daemon, home, sock)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn existing_trace_json_file_is_rejected_before_record_stop() {
|
||||
let temp = tempfile::Builder::new()
|
||||
.prefix("bsk-record-json-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let (daemon, home, sock) = setup_daemon(temp.path()).await;
|
||||
let output = temp.path().join("trace.json");
|
||||
std::fs::write(&output, "{}\n").unwrap();
|
||||
|
||||
let mut extension = connect_extension(daemon.ws_addr()).await;
|
||||
handshake(&mut extension).await;
|
||||
let record_stop_calls = Arc::new(AtomicUsize::new(0));
|
||||
let session_stop_calls = Arc::new(AtomicUsize::new(0));
|
||||
run_extension(
|
||||
extension,
|
||||
Arc::clone(&record_stop_calls),
|
||||
Arc::clone(&session_stop_calls),
|
||||
);
|
||||
|
||||
let session_id = start_session(&sock).await;
|
||||
write_record_session(&home, &session_id);
|
||||
|
||||
let first = run_record_stop(&home, &output).await;
|
||||
let stderr = String::from_utf8_lossy(&first.stderr);
|
||||
assert!(
|
||||
!first.status.success(),
|
||||
"legacy JSON --output should fail: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("JSON file") || stderr.contains("bundle directory"),
|
||||
"{stderr}"
|
||||
);
|
||||
let state: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(home.join("record-session.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(state["session_id"], session_id);
|
||||
assert_eq!(
|
||||
record_stop_calls.load(Ordering::SeqCst),
|
||||
0,
|
||||
"existing trace.json must be rejected before the extension returns the Trace"
|
||||
);
|
||||
assert_eq!(session_stop_calls.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(std::fs::read_to_string(&output).unwrap(), "{}\n");
|
||||
|
||||
daemon.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn failed_bundle_export_can_be_recovered() {
|
||||
let temp = tempfile::Builder::new()
|
||||
.prefix("bsk-record-recov-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let (daemon, home, sock) = setup_daemon(temp.path()).await;
|
||||
let output = temp.path().join("trace");
|
||||
std::fs::create_dir_all(&output).unwrap();
|
||||
std::fs::write(output.join("states"), "not a directory\n").unwrap();
|
||||
|
||||
let mut extension = connect_extension(daemon.ws_addr()).await;
|
||||
handshake(&mut extension).await;
|
||||
let record_stop_calls = Arc::new(AtomicUsize::new(0));
|
||||
let session_stop_calls = Arc::new(AtomicUsize::new(0));
|
||||
run_extension(
|
||||
extension,
|
||||
Arc::clone(&record_stop_calls),
|
||||
Arc::clone(&session_stop_calls),
|
||||
);
|
||||
|
||||
let session_id = start_session(&sock).await;
|
||||
write_record_session(&home, &session_id);
|
||||
|
||||
let first = run_record_stop(&home, &output).await;
|
||||
let stderr = String::from_utf8_lossy(&first.stderr);
|
||||
assert!(
|
||||
!first.status.success(),
|
||||
"blocked states path should fail export: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
home.join("record-recovery.json").exists(),
|
||||
"completed Trace must be saved before the failed export"
|
||||
);
|
||||
assert!(
|
||||
!home.join("record-session.json").exists(),
|
||||
"session state is cleared after the extension returns the Trace"
|
||||
);
|
||||
assert_eq!(record_stop_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(session_stop_calls.load(Ordering::SeqCst), 1);
|
||||
|
||||
std::fs::remove_file(output.join("states")).unwrap();
|
||||
let second = run_record_stop(&home, &output).await;
|
||||
assert!(
|
||||
second.status.success(),
|
||||
"recovery export failed: {}",
|
||||
String::from_utf8_lossy(&second.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
record_stop_calls.load(Ordering::SeqCst),
|
||||
1,
|
||||
"recovery must not call the extension again"
|
||||
);
|
||||
assert!(output.join("trace.json").exists());
|
||||
assert!(output.join("states/s1.txt").exists());
|
||||
assert!(
|
||||
!home.join("record-recovery.json").exists(),
|
||||
"successful export must delete recovery data"
|
||||
);
|
||||
|
||||
daemon.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use bsk::cli::record_state;
|
||||
use bsk::daemon::info::DaemonInfo;
|
||||
use bsk::daemon::paths::BSK_HOME_ENV;
|
||||
use bsk::daemon::{self, DaemonConfig};
|
||||
use bsk::ipc_client::IpcClient;
|
||||
use bsk_protocol::system::{HandshakeParams, HandshakeResult};
|
||||
use bsk_protocol::tools::{
|
||||
RecordStopResult, RecordedTrace, RecorderInfo, SessionStartParams, SessionStartResult,
|
||||
StopReason, TraceEntry, TraceStateV3, TraceV3, VOM_FORMAT_VERSION,
|
||||
};
|
||||
use bsk_protocol::{
|
||||
BrowserPeerInfo, ErrorCode, Frame, Method, RequestFrame, ResponseBody, ResponseFrame, RpcError,
|
||||
};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::json;
|
||||
use tokio::process::Command;
|
||||
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
|
||||
use tokio_tungstenite::tungstenite::http::Request;
|
||||
use tokio_tungstenite::tungstenite::protocol::Message;
|
||||
|
||||
const TEST_EXT_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
|
||||
|
||||
type Ws =
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
fn bsk_bin() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_BIN_EXE_bsk"))
|
||||
}
|
||||
|
||||
async fn connect_extension(addr: std::net::SocketAddr) -> Ws {
|
||||
let origin = format!("chrome-extension://{TEST_EXT_ID}");
|
||||
let request = Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!("ws://{addr}/"))
|
||||
.header("Host", addr.to_string())
|
||||
.header("Upgrade", "websocket")
|
||||
.header("Connection", "Upgrade")
|
||||
.header("Sec-WebSocket-Version", "13")
|
||||
.header("Sec-WebSocket-Key", generate_key())
|
||||
.header("Origin", origin)
|
||||
.body(())
|
||||
.unwrap();
|
||||
tokio_tungstenite::connect_async(request).await.unwrap().0
|
||||
}
|
||||
|
||||
async fn handshake(ws: &mut Ws) {
|
||||
let request = RequestFrame {
|
||||
id: "handshake".into(),
|
||||
method: Method::SystemHandshake,
|
||||
params: Some(
|
||||
serde_json::to_value(HandshakeParams {
|
||||
client: "browser-skill-extension".into(),
|
||||
version: "0.1.10".parse().unwrap(),
|
||||
protocol_version: "1.1".into(),
|
||||
instance_id: TEST_EXT_ID.into(),
|
||||
browser: BrowserPeerInfo {
|
||||
name: "chrome".into(),
|
||||
version: "131.0".into(),
|
||||
},
|
||||
min_compatible_peer: Some("0.1.10".parse().unwrap()),
|
||||
min_compatible_protocol: Some("1.1".into()),
|
||||
label: "Record stop retry test".into(),
|
||||
})
|
||||
.unwrap(),
|
||||
),
|
||||
};
|
||||
ws.send(Message::Text(serde_json::to_string(&request).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
let Message::Text(response) = ws.next().await.unwrap().unwrap() else {
|
||||
panic!("expected text handshake response");
|
||||
};
|
||||
let response: ResponseFrame = serde_json::from_str(&response).unwrap();
|
||||
let ResponseBody::Ok(value) = response.body else {
|
||||
panic!("handshake failed");
|
||||
};
|
||||
let _: HandshakeResult = serde_json::from_value(value).unwrap();
|
||||
}
|
||||
|
||||
fn sample_trace() -> TraceV3 {
|
||||
TraceV3 {
|
||||
version: 3,
|
||||
purpose: Some("retry record stop".into()),
|
||||
started_at: Some("2026-08-12T10:00:00Z".into()),
|
||||
recorded_at: "2026-08-12T10:01:00Z".into(),
|
||||
stopped_by: StopReason::CliStop,
|
||||
entry: TraceEntry {
|
||||
start_url: "https://example.com/".into(),
|
||||
},
|
||||
recorder: RecorderInfo {
|
||||
bsk: "0.1.10".into(),
|
||||
vom: VOM_FORMAT_VERSION,
|
||||
},
|
||||
states: vec![TraceStateV3 {
|
||||
id: "s1".into(),
|
||||
url: "https://example.com/".into(),
|
||||
title: Some("Example".into()),
|
||||
body: "@vom 1\nL1 page".into(),
|
||||
truncated: false,
|
||||
}],
|
||||
steps: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn run_extension(
|
||||
mut ws: Ws,
|
||||
record_stop_calls: Arc<AtomicUsize>,
|
||||
session_stop_calls: Arc<AtomicUsize>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
while let Some(Ok(Message::Text(text))) = ws.next().await {
|
||||
let Ok(Frame::Request(request)) = serde_json::from_str::<Frame>(&text) else {
|
||||
continue;
|
||||
};
|
||||
let body = match request.method {
|
||||
Method::ToolSessionStart => {
|
||||
let _: SessionStartParams =
|
||||
serde_json::from_value(request.params.clone().unwrap()).unwrap();
|
||||
ResponseBody::Ok(
|
||||
serde_json::to_value(SessionStartResult {
|
||||
agent_window_id: Some(100),
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
Method::ToolRecordStop => {
|
||||
if record_stop_calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
ResponseBody::Err(RpcError {
|
||||
code: ErrorCode::ProtocolError,
|
||||
message: "recording remains active; retry record stop".into(),
|
||||
data: Some(json!({ "recording": true, "retryable": true })),
|
||||
})
|
||||
} else {
|
||||
ResponseBody::Ok(
|
||||
serde_json::to_value(RecordStopResult {
|
||||
trace: RecordedTrace::V3(sample_trace()),
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Method::ToolSessionStop => {
|
||||
session_stop_calls.fetch_add(1, Ordering::SeqCst);
|
||||
ResponseBody::Ok(json!({}))
|
||||
}
|
||||
_ => ResponseBody::Err(RpcError {
|
||||
code: ErrorCode::ProtocolError,
|
||||
message: format!("unexpected method {:?}", request.method),
|
||||
data: None,
|
||||
}),
|
||||
};
|
||||
let response = ResponseFrame {
|
||||
id: request.id,
|
||||
body,
|
||||
};
|
||||
ws.send(Message::Text(serde_json::to_string(&response).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn start_session(sock: &Path) -> String {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct StartResult {
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
let mut client = IpcClient::connect(sock).await.unwrap();
|
||||
client
|
||||
.call::<(), StartResult>(
|
||||
"session-start",
|
||||
Method::SessionStart,
|
||||
None,
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.session_id
|
||||
}
|
||||
|
||||
async fn run_record_stop(home: &Path, output: &Path) -> std::process::Output {
|
||||
Command::new(bsk_bin())
|
||||
.arg("record")
|
||||
.arg("stop")
|
||||
.arg("--output")
|
||||
.arg(output)
|
||||
.env(BSK_HOME_ENV, home)
|
||||
.output()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn failed_record_stop_keeps_state_and_session_for_retry() {
|
||||
// Keep the Unix-domain socket below macOS's SUN_LEN even from a deep worktree.
|
||||
let temp = tempfile::Builder::new()
|
||||
.prefix("bsk-record-stop-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let home = temp.path().join("bsk-home");
|
||||
let sock = temp.path().join("daemon.sock");
|
||||
let output = temp.path().join("trace");
|
||||
|
||||
let daemon = daemon::run(DaemonConfig::new(0), Some(sock.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
bsk::daemon::info::write_to_path(
|
||||
&DaemonInfo::now(
|
||||
std::process::id(),
|
||||
sock.clone(),
|
||||
daemon.ws_addr().port(),
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
),
|
||||
&home.join("daemon.json"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut extension = connect_extension(daemon.ws_addr()).await;
|
||||
handshake(&mut extension).await;
|
||||
let record_stop_calls = Arc::new(AtomicUsize::new(0));
|
||||
let session_stop_calls = Arc::new(AtomicUsize::new(0));
|
||||
run_extension(
|
||||
extension,
|
||||
Arc::clone(&record_stop_calls),
|
||||
Arc::clone(&session_stop_calls),
|
||||
);
|
||||
|
||||
let session_id = start_session(&sock).await;
|
||||
unsafe {
|
||||
std::env::set_var(BSK_HOME_ENV, &home);
|
||||
}
|
||||
record_state::write(&session_id).unwrap();
|
||||
|
||||
let first = run_record_stop(&home, &output).await;
|
||||
assert!(
|
||||
!first.status.success(),
|
||||
"first stop unexpectedly succeeded: {}",
|
||||
String::from_utf8_lossy(&first.stdout)
|
||||
);
|
||||
assert_eq!(record_state::read().unwrap().session_id, session_id);
|
||||
assert_eq!(record_stop_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
session_stop_calls.load(Ordering::SeqCst),
|
||||
0,
|
||||
"failed record stop must leave the session active"
|
||||
);
|
||||
|
||||
let second = run_record_stop(&home, &output).await;
|
||||
assert!(
|
||||
second.status.success(),
|
||||
"retry failed: {}",
|
||||
String::from_utf8_lossy(&second.stderr)
|
||||
);
|
||||
assert!(record_state::read().is_err());
|
||||
assert_eq!(record_stop_calls.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(session_stop_calls.load(Ordering::SeqCst), 1);
|
||||
assert!(output.join("trace.json").exists());
|
||||
assert!(output.join("states/s1.txt").exists());
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var(BSK_HOME_ENV);
|
||||
}
|
||||
daemon.shutdown().await;
|
||||
}
|
||||
@@ -61,7 +61,7 @@ fn bsk_status_json_returns_structured_payload() {
|
||||
|
||||
assert!(parsed["pid"].as_u64().unwrap() > 0);
|
||||
assert!(!parsed["daemon_version"].as_str().unwrap().is_empty());
|
||||
assert_eq!(parsed["protocol_version"], "1.0");
|
||||
assert_eq!(parsed["protocol_version"], "1.1");
|
||||
assert!(parsed["sock_path"].as_str().is_some());
|
||||
assert_eq!(parsed["browsers"], serde_json::json!([]));
|
||||
assert_eq!(parsed["sessions"], serde_json::json!([]));
|
||||
|
||||
@@ -53,7 +53,7 @@ pub async fn send_handshake(
|
||||
let params = HandshakeParams {
|
||||
client: "browser-skill-extension".into(),
|
||||
version: "0.1.0-dev.0".parse().unwrap(),
|
||||
protocol_version: "1.0".into(),
|
||||
protocol_version: "1.1".into(),
|
||||
instance_id: instance_id.into(),
|
||||
browser: BrowserPeerInfo {
|
||||
name: "chrome".into(),
|
||||
@@ -91,7 +91,7 @@ async fn ws_handshake_registers_browser_in_state() {
|
||||
let mut ws = connect_ext(handle.ws_addr(), &origin).await;
|
||||
let result = send_handshake(&mut ws, TEST_EXT_ID).await;
|
||||
assert_eq!(result.server, "browser-skill-daemon");
|
||||
assert_eq!(result.protocol_version, "1.0");
|
||||
assert_eq!(result.protocol_version, "1.1");
|
||||
|
||||
let state = handle.state();
|
||||
let browsers = state.browsers.snapshot();
|
||||
|
||||
@@ -480,6 +480,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Fields shared by every step variant (flattened in JSON).",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"op",
|
||||
"result",
|
||||
"state"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"op": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"switch_tab"
|
||||
]
|
||||
},
|
||||
"result": {
|
||||
"$ref": "#/definitions/StepResultV3"
|
||||
},
|
||||
"state": {
|
||||
"description": "Observation id immediately before this action.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Fields shared by every step variant (flattened in JSON).",
|
||||
"type": "object",
|
||||
|
||||
@@ -6,15 +6,36 @@
|
||||
"session_id"
|
||||
],
|
||||
"properties": {
|
||||
"max_page_tokens": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint32",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"purpose": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"redact_values": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"supports_tab_switch_steps": {
|
||||
"description": "Client can decode the v3 `switch_tab` step variant.",
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"tab_id": {
|
||||
"type": [
|
||||
"integer",
|
||||
@@ -22,6 +43,15 @@
|
||||
],
|
||||
"format": "int64"
|
||||
},
|
||||
"trace_version": {
|
||||
"description": "Desired trace export format. Omitted means v2; `3` requests v3.",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint32",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"url": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -480,6 +480,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Fields shared by every step variant (flattened in JSON).",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"op",
|
||||
"result",
|
||||
"state"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"op": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"switch_tab"
|
||||
]
|
||||
},
|
||||
"result": {
|
||||
"$ref": "#/definitions/StepResultV3"
|
||||
},
|
||||
"state": {
|
||||
"description": "Observation id immediately before this action.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Fields shared by every step variant (flattened in JSON).",
|
||||
"type": "object",
|
||||
|
||||
@@ -469,6 +469,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Fields shared by every step variant (flattened in JSON).",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"op",
|
||||
"result",
|
||||
"state"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"op": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"switch_tab"
|
||||
]
|
||||
},
|
||||
"result": {
|
||||
"$ref": "#/definitions/StepResultV3"
|
||||
},
|
||||
"state": {
|
||||
"description": "Observation id immediately before this action.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Fields shared by every step variant (flattened in JSON).",
|
||||
"type": "object",
|
||||
|
||||
@@ -431,6 +431,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Fields shared by every step variant (flattened in JSON).",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"op",
|
||||
"result",
|
||||
"state"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"op": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"switch_tab"
|
||||
]
|
||||
},
|
||||
"result": {
|
||||
"$ref": "#/definitions/StepResultV3"
|
||||
},
|
||||
"state": {
|
||||
"description": "Observation id immediately before this action.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Fields shared by every step variant (flattened in JSON).",
|
||||
"type": "object",
|
||||
|
||||
@@ -41,6 +41,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Fields shared by every step variant (flattened in JSON).",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"op",
|
||||
"result",
|
||||
"state"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"op": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"switch_tab"
|
||||
]
|
||||
},
|
||||
"result": {
|
||||
"$ref": "#/definitions/StepResultV3"
|
||||
},
|
||||
"state": {
|
||||
"description": "Observation id immediately before this action.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Fields shared by every step variant (flattened in JSON).",
|
||||
"type": "object",
|
||||
|
||||
@@ -173,6 +173,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Fields shared by every step variant (flattened in JSON).",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"op",
|
||||
"result",
|
||||
"state"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"op": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"switch_tab"
|
||||
]
|
||||
},
|
||||
"result": {
|
||||
"$ref": "#/definitions/StepResultV3"
|
||||
},
|
||||
"state": {
|
||||
"description": "Observation id immediately before this action.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Fields shared by every step variant (flattened in JSON).",
|
||||
"type": "object",
|
||||
|
||||
@@ -32,6 +32,16 @@ pub struct RecordStartParams {
|
||||
pub url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub purpose: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_page_tokens: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub redact_values: Option<bool>,
|
||||
/// Desired trace export format. Omitted means v2; `3` requests v3.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub trace_version: Option<u32>,
|
||||
/// Client can decode the v3 `switch_tab` step variant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub supports_tab_switch_steps: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
@@ -267,6 +277,249 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn sample_common(id: u32, state: &str, result_state: &str) -> StepCommonV3 {
|
||||
StepCommonV3 {
|
||||
id,
|
||||
state: state.into(),
|
||||
result: StepResultV3 {
|
||||
state: result_state.into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_target() -> TargetDescriptorV3 {
|
||||
TargetDescriptorV3 {
|
||||
element_ref: Some("e21".into()),
|
||||
role: Some("button".into()),
|
||||
name: Some("发布".into()),
|
||||
ctx: Some("金桔柠檬 6 号".into()),
|
||||
unmatched: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_trace() -> TraceV3 {
|
||||
TraceV3 {
|
||||
version: TRACE_VERSION_V3,
|
||||
purpose: Some("把草稿商品发布上架".into()),
|
||||
started_at: Some("2026-08-10T02:10:41.080Z".into()),
|
||||
recorded_at: "2026-08-10T02:12:55.360Z".into(),
|
||||
stopped_by: StopReason::UserFinish,
|
||||
entry: TraceEntry {
|
||||
start_url: "https://example.com/".into(),
|
||||
},
|
||||
recorder: RecorderInfo {
|
||||
bsk: "0.1.10".into(),
|
||||
vom: VOM_FORMAT_VERSION,
|
||||
},
|
||||
states: vec![
|
||||
TraceStateV3 {
|
||||
id: "s1".into(),
|
||||
url: "https://example.com/".into(),
|
||||
title: Some("Example Domain".into()),
|
||||
body: "@vom 1\nRootWebArea \"Example Domain\"".into(),
|
||||
truncated: false,
|
||||
},
|
||||
TraceStateV3 {
|
||||
id: "s2".into(),
|
||||
url: "https://shop.example.com/products?status=draft".into(),
|
||||
title: Some("商品管理".into()),
|
||||
body: "@vom 1\nRootWebArea \"商品管理\"".into(),
|
||||
truncated: false,
|
||||
},
|
||||
],
|
||||
steps: vec![
|
||||
StepV3::Navigate {
|
||||
common: sample_common(1, "s1", "s2"),
|
||||
to: "https://shop.example.com/products?status=draft".into(),
|
||||
cause: NavigationCause::UserTyped,
|
||||
},
|
||||
StepV3::Fill {
|
||||
common: sample_common(2, "s2", "s2"),
|
||||
target: TargetDescriptorV3 {
|
||||
element_ref: Some("e12".into()),
|
||||
role: Some("textbox".into()),
|
||||
name: Some("搜索商品".into()),
|
||||
ctx: None,
|
||||
unmatched: false,
|
||||
},
|
||||
value: "金桔柠檬".into(),
|
||||
commit: FillCommit::Enter,
|
||||
redacted: false,
|
||||
},
|
||||
StepV3::Click {
|
||||
common: sample_common(3, "s2", "s2"),
|
||||
target: sample_target(),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_click_round_trips() {
|
||||
let step = StepV3::Click {
|
||||
common: sample_common(1, "s1", "s2"),
|
||||
target: sample_target(),
|
||||
};
|
||||
let v = serde_json::to_value(&step).unwrap();
|
||||
assert_eq!(v.get("op").and_then(|v| v.as_str()), Some("click"));
|
||||
assert_eq!(v.get("state").and_then(|v| v.as_str()), Some("s1"));
|
||||
assert_eq!(v["result"]["state"], "s2");
|
||||
assert_eq!(v["target"]["ref"], "e21");
|
||||
let round: StepV3 = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, step);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_fill_with_commit_round_trips() {
|
||||
let step = StepV3::Fill {
|
||||
common: sample_common(2, "s2", "s3"),
|
||||
target: TargetDescriptorV3 {
|
||||
element_ref: Some("e12".into()),
|
||||
role: Some("textbox".into()),
|
||||
name: Some("搜索商品".into()),
|
||||
ctx: None,
|
||||
unmatched: false,
|
||||
},
|
||||
value: "browser skill".into(),
|
||||
commit: FillCommit::Enter,
|
||||
redacted: false,
|
||||
};
|
||||
let v = serde_json::to_value(&step).unwrap();
|
||||
assert_eq!(v["op"], "fill");
|
||||
assert_eq!(v["commit"], "enter");
|
||||
assert!(v.get("redacted").is_none());
|
||||
let round: StepV3 = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, step);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_fill_password_is_redacted() {
|
||||
let step = StepV3::Fill {
|
||||
common: sample_common(1, "s1", "s1"),
|
||||
target: TargetDescriptorV3 {
|
||||
element_ref: Some("e3".into()),
|
||||
role: Some("textbox".into()),
|
||||
name: Some("密码".into()),
|
||||
ctx: None,
|
||||
unmatched: false,
|
||||
},
|
||||
value: "***".into(),
|
||||
commit: FillCommit::Blur,
|
||||
redacted: true,
|
||||
};
|
||||
let v = serde_json::to_value(&step).unwrap();
|
||||
assert_eq!(v["value"], "***");
|
||||
assert_eq!(v["redacted"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_navigate_with_cause_round_trips() {
|
||||
let step = StepV3::Navigate {
|
||||
common: sample_common(1, "s1", "s2"),
|
||||
to: "https://example.com".into(),
|
||||
cause: NavigationCause::UserTyped,
|
||||
};
|
||||
let v = serde_json::to_value(&step).unwrap();
|
||||
assert_eq!(v["op"], "navigate");
|
||||
assert_eq!(v["cause"], "user_typed");
|
||||
let round: StepV3 = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, step);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_switch_tab_round_trips() {
|
||||
let step = StepV3::SwitchTab {
|
||||
common: sample_common(2, "s2", "s5"),
|
||||
};
|
||||
let v = serde_json::to_value(&step).unwrap();
|
||||
assert_eq!(v["op"], "switch_tab");
|
||||
assert_eq!(v["state"], "s2");
|
||||
assert_eq!(v["result"]["state"], "s5");
|
||||
let round: StepV3 = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, step);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_v3_round_trips() {
|
||||
let trace = sample_trace();
|
||||
let v = serde_json::to_value(&trace).unwrap();
|
||||
assert_eq!(v.get("version").and_then(|v| v.as_u64()), Some(3));
|
||||
assert!(v.get("pages").is_none());
|
||||
assert_eq!(v["recorder"]["vom"], 1);
|
||||
assert_eq!(v["states"].as_array().unwrap().len(), 2);
|
||||
let round: TraceV3 = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, trace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_fields_are_omitted() {
|
||||
let step = StepV3::Click {
|
||||
common: sample_common(1, "s1", "s1"),
|
||||
target: TargetDescriptorV3 {
|
||||
element_ref: Some("e1".into()),
|
||||
role: Some("button".into()),
|
||||
name: Some("OK".into()),
|
||||
ctx: None,
|
||||
unmatched: false,
|
||||
},
|
||||
};
|
||||
let v = serde_json::to_value(&step).unwrap();
|
||||
assert!(v.get("unmatched").is_none());
|
||||
assert!(v["target"].get("ctx").is_none());
|
||||
assert!(v["target"].get("unmatched").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_start_trace_options_are_explicit_and_optional() {
|
||||
let legacy = RecordStartParams {
|
||||
session_id: "session".into(),
|
||||
tab_id: None,
|
||||
url: None,
|
||||
purpose: None,
|
||||
max_page_tokens: None,
|
||||
redact_values: None,
|
||||
trace_version: None,
|
||||
supports_tab_switch_steps: None,
|
||||
};
|
||||
let legacy_value = serde_json::to_value(legacy).unwrap();
|
||||
assert!(legacy_value.get("trace_version").is_none());
|
||||
assert!(legacy_value.get("max_page_tokens").is_none());
|
||||
assert!(legacy_value.get("supports_tab_switch_steps").is_none());
|
||||
|
||||
let v3 = RecordStartParams {
|
||||
session_id: "session".into(),
|
||||
tab_id: None,
|
||||
url: None,
|
||||
purpose: None,
|
||||
max_page_tokens: Some(4_000),
|
||||
redact_values: Some(true),
|
||||
trace_version: Some(TRACE_VERSION_V3),
|
||||
supports_tab_switch_steps: Some(true),
|
||||
};
|
||||
let v3_value = serde_json::to_value(v3).unwrap();
|
||||
assert_eq!(v3_value["trace_version"], TRACE_VERSION_V3);
|
||||
assert_eq!(v3_value["max_page_tokens"], 4_000);
|
||||
assert_eq!(v3_value["redact_values"], true);
|
||||
assert_eq!(v3_value["supports_tab_switch_steps"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_target_serializes_flag() {
|
||||
let step = StepV3::Click {
|
||||
common: sample_common(1, "s1", "s2"),
|
||||
target: TargetDescriptorV3 {
|
||||
element_ref: None,
|
||||
role: Some("button".into()),
|
||||
name: Some("发布".into()),
|
||||
ctx: None,
|
||||
unmatched: true,
|
||||
},
|
||||
};
|
||||
let v = serde_json::to_value(&step).unwrap();
|
||||
assert_eq!(v["target"]["unmatched"], true);
|
||||
assert!(v["target"].get("ref").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_trace_classifies_v2_and_v3() {
|
||||
let v2 = json!({
|
||||
@@ -452,4 +705,43 @@ mod tests {
|
||||
assert!(schema["definitions"].get("StepV2").is_some());
|
||||
assert!(schema["definitions"].get("StepV3").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_trace_deserializes() {
|
||||
let v = json!({
|
||||
"version": 3,
|
||||
"recorded_at": "2026-07-21T08:00:00Z",
|
||||
"started_at": "2026-07-21T07:59:00Z",
|
||||
"purpose": "demo",
|
||||
"stopped_by": "user_finish",
|
||||
"entry": { "start_url": "https://example.com/editor" },
|
||||
"recorder": { "bsk": "0.1.10", "vom": 1 },
|
||||
"states": [
|
||||
{ "id": "s1", "url": "https://example.com/editor", "body": "@vom 1" },
|
||||
{ "id": "s2", "url": "https://example.com/p/99", "body": "@vom 1" }
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"op": "fill",
|
||||
"id": 1,
|
||||
"state": "s1",
|
||||
"result": { "state": "s1" },
|
||||
"target": { "ref": "e1", "role": "textbox", "name": "标题" },
|
||||
"value": "hello",
|
||||
"commit": "blur"
|
||||
},
|
||||
{
|
||||
"op": "click",
|
||||
"id": 2,
|
||||
"state": "s1",
|
||||
"result": { "state": "s2" },
|
||||
"target": { "ref": "e2", "role": "button", "name": "发布" }
|
||||
}
|
||||
]
|
||||
});
|
||||
let trace: TraceV3 = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(trace.version, 3);
|
||||
assert_eq!(trace.states.len(), 2);
|
||||
assert_eq!(trace.steps.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,10 @@ pub enum StepV3 {
|
||||
to: String,
|
||||
cause: NavigationCause,
|
||||
},
|
||||
SwitchTab {
|
||||
#[serde(flatten)]
|
||||
common: StepCommonV3,
|
||||
},
|
||||
Click {
|
||||
#[serde(flatten)]
|
||||
common: StepCommonV3,
|
||||
@@ -340,6 +344,19 @@ mod tests {
|
||||
assert_eq!(round, step);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_switch_tab_round_trips() {
|
||||
let step = StepV3::SwitchTab {
|
||||
common: sample_common(2, "s2", "s5"),
|
||||
};
|
||||
let v = serde_json::to_value(&step).unwrap();
|
||||
assert_eq!(v["op"], "switch_tab");
|
||||
assert_eq!(v["state"], "s2");
|
||||
assert_eq!(v["result"]["state"], "s5");
|
||||
let round: StepV3 = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(round, step);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_v3_round_trips() {
|
||||
let trace = sample_trace();
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"record": {
|
||||
"sectionTitle": "Action recording",
|
||||
"cardDesc": "Record your actions for Agent reference",
|
||||
"sectionHint": "Generates a recording instruction — send it to your Agent to start recording your actions; the Agent can then use the recording to automate similar tasks.",
|
||||
"sectionHint": "Generates a recording instruction — send it to your Agent to start recording your actions; the Agent can then use the resulting trace bundle to automate similar tasks.",
|
||||
"topicLabel": "Topic (optional)",
|
||||
"topicPlaceholder": "e.g. Publish an article",
|
||||
"startUrlLabel": "Starting URL (optional)",
|
||||
@@ -56,7 +56,7 @@
|
||||
"copyButton": "Copy recording instructions",
|
||||
"copied": "Copied",
|
||||
"hintDisconnected": "Available when connected",
|
||||
"promptTemplate": "Use BrowserSkill's `bsk` CLI to record the browser actions I'm about to demonstrate.\n\nSteps:\n1. Run: {{command}}\n2. Once you confirm it's started, I'll operate in the opened window myself.\n3. After I click Finish, read the returned trace.json and summarize the actions (pages + steps); don't re-run or \"verify\"."
|
||||
"promptTemplate": "Use BrowserSkill's `bsk` CLI to record the browser actions I'm about to demonstrate.\n\nSteps:\n1. Run: {{command}}\n2. Once you confirm it's started, I'll operate in the opened window myself.\n3. After I click Finish, read the written `./trace` bundle (`trace.json` + `states/`) and summarize what I did."
|
||||
}
|
||||
},
|
||||
"controlOverlay": {
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"record": {
|
||||
"sectionTitle": "操作录制",
|
||||
"cardDesc": "录制你的操作,供 Agent 参考",
|
||||
"sectionHint": "生成一段录制指令,发给 Agent 后可以启动插件录制用户操作,之后 Agent 可以参考录制结果自动完成同类任务。",
|
||||
"sectionHint": "生成一段录制指令,发给 Agent 后可以启动插件录制用户操作,之后 Agent 可以参考写出的 trace 目录包自动完成同类任务。",
|
||||
"topicLabel": "操作主题(可选)",
|
||||
"topicPlaceholder": "例如:发布一篇文章",
|
||||
"startUrlLabel": "起始网址(可选)",
|
||||
@@ -56,7 +56,7 @@
|
||||
"copyButton": "复制录制指令",
|
||||
"copied": "已复制",
|
||||
"hintDisconnected": "连接后可用",
|
||||
"promptTemplate": "用 BrowserSkill 的 `bsk` CLI 录制我接下来的浏览器操作。\n\n步骤:\n1. 执行:{{command}}\n2. 你确认已开始后,我会在打开的窗口里自己操作。\n3. 我点“结束”后,读取返回的 trace.json 并总结操作(pages + steps),不要重跑或“验证”。"
|
||||
"promptTemplate": "用 BrowserSkill 的 `bsk` CLI 录制我接下来的浏览器操作。\n\n步骤:\n1. 执行:{{command}}\n2. 你确认已开始后,我会在打开的窗口里自己操作。\n3. 我点“结束”后,读取写出的 `./trace` 目录包(`trace.json` + `states/`),总结我做了什么。"
|
||||
}
|
||||
},
|
||||
"controlOverlay": {
|
||||
|
||||
+14
-6
@@ -265,19 +265,27 @@ retry — complete the task autonomously or stop gracefully.
|
||||
|
||||
### Recording — `bsk record`
|
||||
|
||||
Capture the user's own actions in the Agent Window to a `trace.json`, for later LLM-driven automation:
|
||||
Capture the user's own actions in the Agent Window to a **trace bundle**, for later LLM-driven automation. New CLI builds request **trace v3** (page observations + action chain); older extensions may still return **trace v2** (actions only), which the CLI exports as a single `trace.json` without `states/`.
|
||||
|
||||
```bash
|
||||
bsk record start --browser <instance-id-or-label> [--url https://…] [--purpose "publish a wiki doc"] [--output trace.json]
|
||||
bsk record start --browser <instance-id-or-label> \
|
||||
[--url https://…] [--purpose "publish a wiki doc"] \
|
||||
[--max-page-tokens 3000] [--redact-values] \
|
||||
[--output trace]
|
||||
# `--url` is optional; default https://example.com/ when omitted (must be http(s)).
|
||||
# Blocks until the user clicks Finish in the recording panel, then writes ./trace.json and closes the window.
|
||||
# Blocks until the user clicks Finish in the recording panel, then writes:
|
||||
# trace/trace.json — action chain (+ state index when v3)
|
||||
# trace/states/ — v3 only: one `sN.txt` observe snapshot per settled page state
|
||||
|
||||
bsk record stop [--output trace.json] # terminal fallback if the browser panel is unavailable
|
||||
bsk record stop [--output trace] # terminal fallback if the browser panel is unavailable
|
||||
```
|
||||
|
||||
- The trace is a **record-only action log** (a `pages[]` dictionary + `navigate`/`click`/`fill`/`select`/`press` steps with `target` descriptors). It records *what the user did*; deciding which inputs are variable is left to the executing agent.
|
||||
- **v3 bundle (preferred):** `--output` is a directory (default `./trace`) containing `trace.json` and `states/`. `trace.json` lists `states[]` (page observation ids) and `steps[]` (each step binds `state` = observe snapshot *before* the action and `result.state` = snapshot *after* settle). Page bodies live in `states/sN.txt` using the same VOM format as `bsk observe`.
|
||||
- **v2 fallback:** when the connected extension is older, export may contain only legacy `trace.json` with `pages[]` action context and no `states/` observation files. Update the extension for full v3 bundles.
|
||||
- Each `states[]` entry is one **settled page observation** (captured after recording start, navigation landing, or action settle — not on a timer).
|
||||
- `target.ref` values like `@e12` exist **only inside** the bundle for disambiguation; do **not** copy `@eN` refs into SKILL.md or agent runbooks — use visible names from the observation text instead.
|
||||
- `--purpose` is optional context metadata; it does **not** change what gets captured.
|
||||
- There is **no** `bsk replay` — to redo a flow, read the trace and reuse the existing `session` / `snapshot` / `@eN` / `click` / `fill` tools. Follow **Stop when the goal is met**.
|
||||
- `--redact-values` masks all form values in page files as `[filled]` / `[empty]`.
|
||||
- Do **not** record on banking/SSO/password-manager pages; passwords are redacted but traces may still contain sensitive text.
|
||||
|
||||
## Error handling
|
||||
|
||||
Reference in New Issue
Block a user