fix(record): preserve trace v2/v3 compatibility

Keep protocol 1.0 peers connected, return v2 traces to legacy clients, and allow the new CLI to export both v2 and v3 recordings.
This commit is contained in:
“haonan”
2026-08-17 16:32:48 +08:00
parent 464a8da86b
commit fd85b80c78
23 changed files with 2424 additions and 261 deletions
@@ -105,7 +105,7 @@ describe("App", () => {
snapshot: {
...baseSnapshot,
lastError:
"[handshake] daemon rejected handshake: version_too_old — peer protocol 1.0 is below local min_compatible_protocol 1.1",
"[handshake] daemon rejected handshake: version_too_old — peer protocol 1.0 is below local min_compatible_protocol 1.0",
},
statusState: "disconnected",
setLabel,
@@ -84,9 +84,10 @@ describe("computeConnectedState (protocol-based compat)", () => {
}
});
it("rejects a pre-Trace-v3 daemon using the configured extension floor", () => {
const result = computeConnectedState(handshake("1.0", "1.0"));
expect(result.kind).toBe("rejected");
it("returns version_skew when daemon protocol is 1.0 and floor is satisfied", () => {
expect(computeConnectedState(handshake("1.0", "1.0"))).toEqual({
kind: "version_skew",
});
});
it("rejects malformed daemon min_compatible_protocol with a daemon-floor reason", () => {
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { buildTraceV2, shouldRecordPress } from "@/lib/trace-reducer-v2";
import type { DraftTraceStep } from "@/transport/types";
describe("trace-reducer-v2", () => {
it("drops scroll and bare character press steps", () => {
const steps: DraftTraceStep[] = [
{
op: "scroll",
page_url: "https://example.com/",
},
{
op: "press",
key: "a",
page_url: "https://example.com/",
},
];
const trace = buildTraceV2({
steps,
startedAt: "2026-01-01T00:00:00.000Z",
startUrl: "https://example.com/",
});
expect(trace.steps).toHaveLength(0);
expect(trace.pages).toHaveLength(1);
});
it("drops hover steps unsupported by historical v2 clients", () => {
const trace = buildTraceV2({
steps: [
{
op: "hover",
target: { unmatched: true },
captureTarget: { tag: "button", role: "button", name: "结束" },
page_url: "https://example.com/",
},
],
startedAt: "2026-01-01T00:00:00.000Z",
startUrl: "https://example.com/",
});
expect(trace.steps).toHaveLength(0);
});
it("maps captureTarget to v2 target with required tag", () => {
const steps: DraftTraceStep[] = [
{
op: "click",
target: { unmatched: true },
captureTarget: { tag: "button", role: "button", name: "Submit" },
page_url: "https://example.com/",
},
];
const trace = buildTraceV2({
steps,
startedAt: "2026-01-01T00:00:00.000Z",
startUrl: "https://example.com/",
});
expect(trace.steps).toHaveLength(1);
expect(trace.steps[0]?.op).toBe("click");
if (trace.steps[0]?.op === "click") {
expect(trace.steps[0].target.tag).toBe("button");
expect(trace.steps[0].target.name).toBe("Submit");
}
expect("version" in trace).toBe(false);
});
it("records Enter presses", () => {
expect(shouldRecordPress("Enter")).toBe(true);
});
});
+297
View File
@@ -0,0 +1,297 @@
import type { CaptureTargetDescriptor } from "@/lib/describe-target";
import type { DraftTraceStep, KeyModifier } from "@/transport/types";
const CLIPBOARD_KEYS = new Set(["a", "c", "v", "x", "A", "C", "V", "X"]);
const MODIFIER_ONLY_KEYS = new Set(["Meta", "Control", "Alt", "Shift", "OS", "Hyper", "Super"]);
export interface TargetDescriptorV2 {
role?: string;
name?: string;
tag: string;
name_attr?: string;
placeholder?: string;
nearby_label?: string;
}
export interface PageRef {
id: string;
url: string;
title?: string;
}
export interface SelectedOptionV2 {
value: string;
label?: string;
}
export interface StepEffectV2 {
navigated_to: string;
}
export type StepV2 =
| { op: "navigate"; id: number; page: string; to: string; effect?: StepEffectV2 }
| { op: "click"; id: number; page: string; target: TargetDescriptorV2; effect?: StepEffectV2 }
| {
op: "fill";
id: number;
page: string;
target: TargetDescriptorV2;
value: string;
redacted?: boolean;
effect?: StepEffectV2;
}
| {
op: "select";
id: number;
page: string;
target: TargetDescriptorV2;
selection: SelectedOptionV2[];
effect?: StepEffectV2;
}
| {
op: "press";
id: number;
page: string;
key: string;
modifiers?: KeyModifier[];
target?: TargetDescriptorV2;
effect?: StepEffectV2;
};
export interface TraceV2 {
recorded_at: string;
started_at?: string;
purpose?: string;
entry: { start_url: string };
pages: PageRef[];
steps: StepV2[];
}
export function shouldRecordPress(
key: string,
modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">,
): boolean {
if (MODIFIER_ONLY_KEYS.has(key)) return false;
const mods = modifiers ?? [];
const hasCtrlOrMeta = mods.includes("ctrl") || mods.includes("meta");
if (hasCtrlOrMeta && CLIPBOARD_KEYS.has(key)) return false;
if (key === "Enter" || key === "Escape") return true;
if (key.length === 1 && !hasCtrlOrMeta && !mods.includes("alt")) return false;
return false;
}
function shouldIncludeDraft(step: DraftTraceStep): boolean {
if (step.op === "scroll" || step.op === "hover") return false;
if (step.op === "fill" && !(step.value ?? "").trim() && !step.redacted) return false;
if (step.op === "press" && !shouldRecordPress(step.key, step.modifiers)) return false;
return true;
}
function collapseNavigations(steps: DraftTraceStep[]): DraftTraceStep[] {
const out: DraftTraceStep[] = [];
for (const step of steps) {
const prev = out[out.length - 1];
if (step.op === "navigate" && prev?.op === "navigate") {
out[out.length - 1] = step;
continue;
}
out.push(step);
}
return out;
}
function collectUrls(steps: DraftTraceStep[], startUrl?: string): string[] {
const urls: string[] = [];
if (startUrl) urls.push(startUrl);
for (const step of steps) {
if (step.op === "navigate") {
urls.push(step.url);
continue;
}
if ("page_url" in step && step.page_url) urls.push(step.page_url);
if ("navigated_to" in step && step.navigated_to) urls.push(step.navigated_to);
}
const seen = new Set<string>();
const unique: string[] = [];
for (const url of urls) {
if (!url || seen.has(url)) continue;
seen.add(url);
unique.push(url);
}
return unique;
}
function buildPageRegistry(
steps: DraftTraceStep[],
startUrl?: string,
): { pages: PageRef[]; urlToId: Map<string, string> } {
const urls = collectUrls(steps, startUrl);
const urlToId = new Map<string, string>();
const pages = urls.map((url, index) => {
const id = `p${index + 1}`;
urlToId.set(url, id);
return { id, url };
});
return { pages, urlToId };
}
function pageIdFor(
url: string | undefined,
urlToId: Map<string, string>,
fallbackUrl?: string,
): string {
if (url && urlToId.has(url)) return urlToId.get(url)!;
if (fallbackUrl && urlToId.has(fallbackUrl)) return urlToId.get(fallbackUrl)!;
return urlToId.values().next().value ?? "p1";
}
function pageUrlForDraft(step: DraftTraceStep, fallbackUrl?: string): string | undefined {
if (step.op === "navigate") return step.page_url ?? step.url;
if ("page_url" in step && step.page_url) return step.page_url;
return fallbackUrl;
}
function effectForNavigation(
navigatedTo: string | undefined,
urlToId: Map<string, string>,
): StepEffectV2 | undefined {
if (!navigatedTo) return undefined;
const pageId = urlToId.get(navigatedTo);
if (!pageId) return undefined;
return { navigated_to: pageId };
}
function captureTargetToV2Target(capture?: CaptureTargetDescriptor): TargetDescriptorV2 {
return {
tag: capture?.tag ?? "unknown",
...(capture?.role ? { role: capture.role } : {}),
...(capture?.name ? { name: capture.name } : {}),
...(capture?.name_attr ? { name_attr: capture.name_attr } : {}),
...(capture?.placeholder ? { placeholder: capture.placeholder } : {}),
...(capture?.nearby_label ? { nearby_label: capture.nearby_label } : {}),
};
}
function targetForDraft(step: DraftTraceStep): TargetDescriptorV2 | undefined {
if (!("target" in step) && !("captureTarget" in step)) return undefined;
const capture = "captureTarget" in step ? step.captureTarget : undefined;
if (capture) return captureTargetToV2Target(capture);
if ("target" in step && step.target && "tag" in (step.target as object)) {
const legacy = step.target as TargetDescriptorV2 & { tag?: string };
if (legacy.tag) return legacy;
}
return undefined;
}
function toSelection(values: string[], labels?: string[]): SelectedOptionV2[] {
return values.map((value, index) => ({
value,
...(labels?.[index] ? { label: labels[index] } : {}),
}));
}
function toV2Step(
step: DraftTraceStep,
id: number,
urlToId: Map<string, string>,
fallbackUrl?: string,
): StepV2 | null {
if (!shouldIncludeDraft(step)) return null;
const pageUrl = pageUrlForDraft(step, fallbackUrl);
const page = pageIdFor(pageUrl, urlToId, fallbackUrl);
const effect =
"navigated_to" in step ? effectForNavigation(step.navigated_to, urlToId) : undefined;
switch (step.op) {
case "navigate":
return {
op: "navigate",
id,
page: pageIdFor(step.url, urlToId, fallbackUrl),
to: step.url,
};
case "click": {
const target = targetForDraft(step);
if (!target) return null;
return { op: "click", id, page, target, ...(effect ? { effect } : {}) };
}
case "hover":
return null;
case "fill": {
const target = targetForDraft(step);
if (!target) return null;
return {
op: "fill",
id,
page,
target,
value: step.value,
...(step.redacted ? { redacted: true } : {}),
};
}
case "press":
return {
op: "press",
id,
page,
key: step.key,
...(step.target ? { target: targetForDraft(step) } : {}),
...(step.modifiers?.length ? { modifiers: step.modifiers } : {}),
...(effect ? { effect } : {}),
};
case "select": {
const target = targetForDraft(step);
if (!target) return null;
return {
op: "select",
id,
page,
target,
selection: toSelection(step.values, step.labels),
...(effect ? { effect } : {}),
};
}
case "scroll":
return null;
}
}
export interface BuildTraceV2Input {
steps: DraftTraceStep[];
startedAt: string;
startUrl?: string;
purpose?: string;
}
export function buildTraceV2(input: BuildTraceV2Input): TraceV2 {
const collapsed = collapseNavigations(input.steps);
const startUrl =
input.startUrl ??
collapsed.find(
(step): step is Extract<DraftTraceStep, { op: "navigate" }> => step.op === "navigate",
)?.url ??
collapsed.find((step) => "page_url" in step && step.page_url)?.page_url ??
"about:blank";
const { pages, urlToId } = buildPageRegistry(collapsed, startUrl);
const out: StepV2[] = [];
let id = 1;
let lastUrl = startUrl;
for (const draft of collapsed) {
if (draft.op === "navigate") lastUrl = draft.url;
else if ("navigated_to" in draft && draft.navigated_to) lastUrl = draft.navigated_to;
else if ("page_url" in draft && draft.page_url) lastUrl = draft.page_url;
const step = toV2Step(draft, id, urlToId, lastUrl);
if (!step) continue;
out.push(step);
id += 1;
}
return {
recorded_at: new Date().toISOString(),
started_at: input.startedAt,
...(input.purpose ? { purpose: input.purpose } : {}),
entry: { start_url: startUrl },
pages,
steps: out,
};
}
@@ -10,7 +10,7 @@ import { resetStateIdCounterForTests } from "@/lib/trace-reducer";
import type { SessionManager } from "@/session-manager/manager";
import type { CdpRunner } from "@/tools/shared";
import { EXTENSION_VERSION } from "@/transport/handshake";
import type { RecordStopResult, Trace } from "@/transport/types";
import type { RecordedTrace, RecordStopResult, TraceV3 } from "@/transport/types";
import {
attachRecordFinishListener,
attachRecordQueryListener,
@@ -23,6 +23,14 @@ import {
const AGENT_WINDOW_ID = 100;
const TAB_ID = 4;
const START_URL = "https://example.com/";
const RECORD_START_V3 = { session_id: "abcd", url: START_URL, trace_version: 3 as const };
function asTraceV3(trace: RecordedTrace): TraceV3 {
if (!("version" in trace)) {
throw new Error("expected v3 trace");
}
return trace;
}
type RuntimeListener = (
message: unknown,
@@ -264,11 +272,7 @@ describe("recorded user steps reach the exported trace", () => {
});
const startDeps = { tabsApi, sendToTab, cdp };
const started = await handleRecordStart(
manager,
{ session_id: "abcd", url: START_URL },
startDeps,
);
const started = await handleRecordStart(manager, RECORD_START_V3, startDeps);
expect(started).toEqual({ tab_id: TAB_ID, recording: true });
expect(requestId).not.toBe("");
@@ -298,7 +302,7 @@ describe("recorded user steps reach the exported trace", () => {
);
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab });
const trace = (stopped as RecordStopResult).trace as Trace;
const trace = (stopped as RecordStopResult).trace as TraceV3;
expect(trace.recorder.bsk).toBe(EXTENSION_VERSION);
expect(trace.steps).toHaveLength(1);
@@ -321,11 +325,7 @@ describe("recorded user steps reach the exported trace", () => {
return { ok: true };
});
await handleRecordStart(
manager,
{ session_id: "abcd", url: START_URL },
{ tabsApi, sendToTab, cdp: makeFakeCdp() },
);
await handleRecordStart(manager, RECORD_START_V3, { tabsApi, sendToTab, cdp: makeFakeCdp() });
attachRecordStepListener({ tabsApi, sendToTab });
const emit = (step: unknown) => {
@@ -350,7 +350,7 @@ describe("recorded user steps reach the exported trace", () => {
});
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab });
const trace = (stopped as RecordStopResult).trace as Trace;
const trace = (stopped as RecordStopResult).trace as TraceV3;
expect(trace.steps.map((step) => step.op)).toEqual(["fill", "click"]);
expect(trace.steps.map((step) => step.id)).toEqual([1, 2]);
@@ -366,11 +366,7 @@ describe("recorded user steps reach the exported trace", () => {
const tabsApi = makeTabsApi();
const sendToTab = vi.fn(async () => ({ ok: true }));
await handleRecordStart(
manager,
{ session_id: "abcd", url: START_URL },
{ tabsApi, sendToTab, cdp: makeFakeCdp() },
);
await handleRecordStart(manager, RECORD_START_V3, { tabsApi, sendToTab, cdp: makeFakeCdp() });
const destination = "https://example.com/next";
chromeApi.webNavigationOnCommitted.emit({
@@ -384,7 +380,7 @@ describe("recorded user steps reach the exported trace", () => {
await new Promise((resolve) => setTimeout(resolve, 0));
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab });
const trace = (stopped as RecordStopResult).trace as Trace;
const trace = (stopped as RecordStopResult).trace as TraceV3;
expect(trace.steps).toHaveLength(1);
const [step] = trace.steps;
@@ -404,19 +400,15 @@ describe("recorded user steps reach the exported trace", () => {
});
let requestId = "";
await handleRecordStart(
manager,
{ session_id: "abcd", url: START_URL },
{
tabsApi,
sendToTab,
cdp: makeFakeCdp([
axTree("Example Domain", ["Learn more"]),
axTree("腾讯 iWiki"),
axTree("工作台 - 腾讯iWiki", ["文档C+D"]),
]),
},
);
await handleRecordStart(manager, RECORD_START_V3, {
tabsApi,
sendToTab,
cdp: makeFakeCdp([
axTree("Example Domain", ["Learn more"]),
axTree("腾讯 iWiki"),
axTree("工作台 - 腾讯iWiki", ["文档C+D"]),
]),
});
attachRecordStepListener({ tabsApi, sendToTab });
const commit = (url: string, transitionType: string) => {
@@ -446,7 +438,7 @@ describe("recorded user steps reach the exported trace", () => {
});
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab });
const trace = (stopped as RecordStopResult).trace as Trace;
const trace = (stopped as RecordStopResult).trace as TraceV3;
expect(trace.states.map((state) => state.url)).toEqual([
START_URL,
@@ -481,20 +473,16 @@ describe("recorded user steps reach the exported trace", () => {
const callbackUrl = "https://passport.example/callback";
const dashboardUrl = "https://app.example/dashboard";
await handleRecordStart(
manager,
{ session_id: "abcd", url: START_URL },
{
tabsApi,
sendToTab,
cdp: makeFakeCdp([
axTree("Example Domain"),
axTree("OA登录", ["发起验证"]),
axTree("工作台", ["新建"]),
axTree("工作台", ["新建", "文档"]),
]),
},
);
await handleRecordStart(manager, RECORD_START_V3, {
tabsApi,
sendToTab,
cdp: makeFakeCdp([
axTree("Example Domain"),
axTree("OA登录", ["发起验证"]),
axTree("工作台", ["新建"]),
axTree("工作台", ["新建", "文档"]),
]),
});
attachRecordStepListener({ tabsApi, sendToTab });
const commit = (url: string, transitionType: string, transitionQualifiers: string[] = []) => {
@@ -527,7 +515,7 @@ describe("recorded user steps reach the exported trace", () => {
await settleWait();
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab });
const trace = (stopped as RecordStopResult).trace as Trace;
const trace = (stopped as RecordStopResult).trace as TraceV3;
// Login → dashboard may collapse into one navigate in the reducer; what
// matters is the click is bound to the dashboard observation, not login.
@@ -554,15 +542,11 @@ describe("recorded user steps reach the exported trace", () => {
});
let requestId = "";
await handleRecordStart(
manager,
{ session_id: "abcd", url: START_URL },
{
tabsApi,
sendToTab,
cdp: makeFakeCdp([axTree("编辑", ["发布"]), axTree("已发布")]),
},
);
await handleRecordStart(manager, RECORD_START_V3, {
tabsApi,
sendToTab,
cdp: makeFakeCdp([axTree("编辑", ["发布"]), axTree("已发布")]),
});
attachRecordStepListener({ tabsApi, sendToTab });
// Click 发布, the page navigates to the published doc, and the user hits
@@ -583,7 +567,7 @@ describe("recorded user steps reach the exported trace", () => {
} as unknown as chrome.webNavigation.WebNavigationTransitionCallbackDetails);
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab });
const trace = (stopped as RecordStopResult).trace as Trace;
const trace = (stopped as RecordStopResult).trace as TraceV3;
expect(trace.steps.map((step) => step.op)).toEqual(["click"]);
expect(trace.steps[0]).toMatchObject({ target: { name: "发布" } });
@@ -602,15 +586,11 @@ describe("recorded user steps reach the exported trace", () => {
// Only the initial observation succeeds; every later capture throws.
const cdp = makeFakeCdp([axTree("编辑", ["发布"])]);
await handleRecordStart(
manager,
{ session_id: "abcd", url: START_URL },
{
tabsApi,
sendToTab,
cdp,
},
);
await handleRecordStart(manager, RECORD_START_V3, {
tabsApi,
sendToTab,
cdp,
});
attachRecordStepListener({ tabsApi, sendToTab });
runtimeOnMessageEmit(chromeApi, requestId, {
@@ -623,7 +603,7 @@ describe("recorded user steps reach the exported trace", () => {
await settleWait();
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab });
const trace = (stopped as RecordStopResult).trace as Trace;
const trace = (stopped as RecordStopResult).trace as TraceV3;
// No post-action observation exists anywhere, but the action still happened.
expect(trace.steps.map((step) => step.op)).toEqual(["click"]);
@@ -643,15 +623,11 @@ describe("recorded user steps reach the exported trace", () => {
let requestId = "";
const cdp = makeFakeCdp([axTree("编辑", ["发布"]), axTree("已发布", ["编辑"])]);
await handleRecordStart(
manager,
{ session_id: "abcd", url: START_URL },
{
tabsApi,
sendToTab,
cdp,
},
);
await handleRecordStart(manager, RECORD_START_V3, {
tabsApi,
sendToTab,
cdp,
});
attachRecordStepListener({ tabsApi, sendToTab });
runtimeOnMessageEmit(chromeApi, requestId, {
@@ -668,7 +644,7 @@ describe("recorded user steps reach the exported trace", () => {
await settleWait();
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab });
const trace = (stopped as RecordStopResult).trace as Trace;
const trace = (stopped as RecordStopResult).trace as TraceV3;
expect(trace.steps.map((step) => step.op)).toEqual(["click"]);
expect(trace.steps[0]?.result.state).toBe("s2");
@@ -687,15 +663,11 @@ describe("recorded user steps reach the exported trace", () => {
let requestId = "";
const cdp = makeFakeCdp([axTree("编辑", ["发布"]), axTree("已发布", ["编辑"])]);
await handleRecordStart(
manager,
{ session_id: "abcd", url: START_URL },
{
tabsApi,
sendToTab,
cdp,
},
);
await handleRecordStart(manager, RECORD_START_V3, {
tabsApi,
sendToTab,
cdp,
});
attachRecordStepListener({ tabsApi, sendToTab });
runtimeOnMessageEmit(chromeApi, requestId, {
@@ -712,7 +684,7 @@ describe("recorded user steps reach the exported trace", () => {
cdp.setCaptureFailure(false);
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab });
const trace = (stopped as RecordStopResult).trace as Trace;
const trace = (stopped as RecordStopResult).trace as TraceV3;
expect(trace.steps.map((step) => step.op)).toEqual(["click"]);
expect(trace.steps[0]?.state).toBe("s1");
@@ -737,15 +709,11 @@ describe("recorded user steps reach the exported trace", () => {
axTree("分组 已展开", ["添加配置", "确认"]),
axTree("停止时的页面", ["无关"]),
]);
await handleRecordStart(
manager,
{ session_id: "abcd", url: START_URL },
{
tabsApi,
sendToTab,
cdp,
},
);
await handleRecordStart(manager, RECORD_START_V3, {
tabsApi,
sendToTab,
cdp,
});
attachRecordStepListener({ tabsApi, sendToTab });
runtimeOnMessageEmit(chromeApi, requestId, {
@@ -777,7 +745,7 @@ describe("recorded user steps reach the exported trace", () => {
// landing state of an earlier step.
tabsApi.goTo("https://example.com/elsewhere", "停止时的页面");
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab });
const trace = (stopped as RecordStopResult).trace as Trace;
const trace = (stopped as RecordStopResult).trace as TraceV3;
expect(trace.steps.map((step) => step.op)).toEqual(["click", "click"]);
const [first, second] = trace.steps;
@@ -803,15 +771,11 @@ describe("recorded user steps reach the exported trace", () => {
axTree("分组 已展开", ["确认"]),
axTree("停止时的页面", ["无关"]),
]);
await handleRecordStart(
manager,
{ session_id: "abcd", url: START_URL },
{
tabsApi,
sendToTab,
cdp,
},
);
await handleRecordStart(manager, RECORD_START_V3, {
tabsApi,
sendToTab,
cdp,
});
attachRecordStepListener({ tabsApi, sendToTab });
// First click: its own settle never produces an observation.
@@ -836,7 +800,7 @@ describe("recorded user steps reach the exported trace", () => {
tabsApi.goTo("https://example.com/elsewhere", "停止时的页面");
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab });
const trace = (stopped as RecordStopResult).trace as Trace;
const trace = (stopped as RecordStopResult).trace as TraceV3;
expect(trace.steps.map((step) => step.op)).toEqual(["click", "click"]);
const [first, second] = trace.steps;
@@ -858,11 +822,7 @@ describe("recorded user steps reach the exported trace", () => {
let requestId = "";
const cdp = makeFakeCdp([axTree("列表", ["打开"]), axTree("详情", ["返回"])]);
await handleRecordStart(
manager,
{ session_id: "abcd", url: START_URL },
{ tabsApi, sendToTab, cdp },
);
await handleRecordStart(manager, RECORD_START_V3, { tabsApi, sendToTab, cdp });
attachRecordStepListener({ tabsApi, sendToTab });
// The click starts a render that runs well past any fixed settle delay.
@@ -876,7 +836,7 @@ describe("recorded user steps reach the exported trace", () => {
await new Promise((resolve) => setTimeout(resolve, 2_000));
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab });
const trace = (stopped as RecordStopResult).trace as Trace;
const trace = (stopped as RecordStopResult).trace as TraceV3;
const [step] = trace.steps;
const landing = trace.states.find((state) => state.id === step?.result.state);
@@ -898,11 +858,7 @@ describe("recorded user steps reach the exported trace", () => {
axTree("编辑器 弹窗", ["输入标题", "确定"]),
axTree("编辑器", ["发布"]),
]);
await handleRecordStart(
manager,
{ session_id: "abcd", url: START_URL },
{ tabsApi, sendToTab, cdp },
);
await handleRecordStart(manager, RECORD_START_V3, { tabsApi, sendToTab, cdp });
attachRecordStepListener({ tabsApi, sendToTab });
// Typing keeps the page busy, so the fill has not settled when the user
@@ -924,7 +880,7 @@ describe("recorded user steps reach the exported trace", () => {
await new Promise((resolve) => setTimeout(resolve, 1_500));
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab });
const trace = (stopped as RecordStopResult).trace as Trace;
const trace = (stopped as RecordStopResult).trace as TraceV3;
expect(trace.steps.map((step) => step.op)).toEqual(["fill", "click"]);
const [fill, click] = trace.steps;
@@ -944,19 +900,15 @@ describe("recorded user steps reach the exported trace", () => {
});
let requestId = "";
await handleRecordStart(
manager,
{ session_id: "abcd", url: START_URL },
{
tabsApi,
sendToTab,
cdp: makeFakeCdp([
axTree("Example Domain", ["Submit"]),
axTree("Loading"),
axTree("Done", ["Next"]),
]),
},
);
await handleRecordStart(manager, RECORD_START_V3, {
tabsApi,
sendToTab,
cdp: makeFakeCdp([
axTree("Example Domain", ["Submit"]),
axTree("Loading"),
axTree("Done", ["Next"]),
]),
});
attachRecordStepListener({ tabsApi, sendToTab });
runtimeOnMessageEmit(chromeApi, requestId, {
@@ -974,7 +926,7 @@ describe("recorded user steps reach the exported trace", () => {
runtimeOnMessageEmit(chromeApi, requestId, { op: "click", page_url: START_URL });
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab });
const trace = (stopped as RecordStopResult).trace as Trace;
const trace = (stopped as RecordStopResult).trace as TraceV3;
expect(trace.steps).toHaveLength(1);
expect(trace.steps[0]).toMatchObject({
@@ -1006,15 +958,11 @@ describe("recorded user steps reach the exported trace", () => {
return { ok: true };
});
await handleRecordStart(
manager,
{ session_id: "abcd", url: START_URL },
{ tabsApi, sendToTab, cdp: makeFakeCdp() },
);
await handleRecordStart(manager, RECORD_START_V3, { tabsApi, sendToTab, cdp: makeFakeCdp() });
attachRecordStepListener({ tabsApi, sendToTab });
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab });
const trace = (stopped as RecordStopResult).trace as Trace;
const trace = (stopped as RecordStopResult).trace as TraceV3;
expect(trace.steps).toEqual([expect.objectContaining({ op: "fill", value: "saved at stop" })]);
});
@@ -1027,11 +975,11 @@ describe("recorded user steps reach the exported trace", () => {
const intermediateUrl = "https://idp.example/callback";
const finalUrl = "https://app.example/home";
await handleRecordStart(
manager,
{ session_id: "abcd", url: START_URL },
{ tabsApi, sendToTab, cdp: makeFakeCdp([axTree("Start"), axTree("Home")]) },
);
await handleRecordStart(manager, RECORD_START_V3, {
tabsApi,
sendToTab,
cdp: makeFakeCdp([axTree("Start"), axTree("Home")]),
});
tabsApi.goTo(finalUrl, "Home");
chromeApi.webNavigationOnCommitted.emit({
@@ -1044,7 +992,7 @@ describe("recorded user steps reach the exported trace", () => {
await Promise.resolve();
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab });
const trace = (stopped as RecordStopResult).trace as Trace;
const trace = (stopped as RecordStopResult).trace as TraceV3;
expect(trace.steps).toEqual([expect.objectContaining({ op: "navigate", to: finalUrl })]);
}, 15_000);
@@ -1062,15 +1010,11 @@ describe("recorded user steps reach the exported trace", () => {
const intermediateUrl = "https://example.com/loading";
const finalUrl = "https://example.com/result";
await handleRecordStart(
manager,
{ session_id: "abcd", url: START_URL },
{
tabsApi,
sendToTab,
cdp: makeFakeCdp([axTree("Search", ["Go"]), axTree("Result", ["Open"])]),
},
);
await handleRecordStart(manager, RECORD_START_V3, {
tabsApi,
sendToTab,
cdp: makeFakeCdp([axTree("Search", ["Go"]), axTree("Result", ["Open"])]),
});
attachRecordStepListener({ tabsApi, sendToTab });
runtimeOnMessageEmit(chromeApi, requestId, {
@@ -1090,7 +1034,7 @@ describe("recorded user steps reach the exported trace", () => {
tabsApi.goTo(finalUrl, "Result");
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab });
const trace = (stopped as RecordStopResult).trace as Trace;
const trace = (stopped as RecordStopResult).trace as TraceV3;
const resultState = trace.states.find((state) => state.id === trace.steps[0]?.result.state);
expect(resultState?.url).toBe(finalUrl);
@@ -1119,11 +1063,7 @@ describe("recorded user steps reach the exported trace", () => {
return { ok: true };
});
await handleRecordStart(
manager,
{ session_id: "abcd", url: START_URL },
{ tabsApi, sendToTab, cdp: makeFakeCdp() },
);
await handleRecordStart(manager, RECORD_START_V3, { tabsApi, sendToTab, cdp: makeFakeCdp() });
attachRecordFinishListener({ tabsApi, sendToTab });
chromeApi.runtimeOnMessage.emit(
{ type: RECORD_FINISH, requestId },
@@ -1136,7 +1076,7 @@ describe("recorded user steps reach the exported trace", () => {
releaseStop();
const stopped = await cliStop;
expect((stopped as RecordStopResult).trace.stopped_by).toBe("user_finish");
expect(asTraceV3((stopped as RecordStopResult).trace).stopped_by).toBe("user_finish");
});
it("retries STOP only on armed tabs whose final delivery did not ack", async () => {
@@ -1173,7 +1113,7 @@ describe("recorded user steps reach the exported trace", () => {
});
const deps = { tabsApi, sendToTab, cdp: makeFakeCdp() };
await handleRecordStart(manager, { session_id: "abcd", url: START_URL }, deps);
await handleRecordStart(manager, RECORD_START_V3, deps);
attachRecordQueryListener(deps);
await new Promise<void>((resolve) => {
chromeApi.runtimeOnMessage.emit(
@@ -1188,10 +1128,55 @@ describe("recorded user steps reach the exported trace", () => {
expect(stopTabs).toEqual([TAB_ID, 5]);
const retry = await handleRecordStop(manager, { session_id: "abcd" }, deps);
expect((retry as RecordStopResult).trace.version).toBe(3);
const retryTrace = (retry as RecordStopResult).trace;
expect("version" in retryTrace && retryTrace.version).toBe(3);
expect(stopTabs).toEqual([TAB_ID, 5, 5]);
});
it("defaults to trace v2 when trace_version is omitted", async () => {
const { runtimeOnMessage } = installChrome();
const manager = fakeManager();
const tabsApi = makeTabsApi();
let requestId = "";
const sendToTab = vi.fn(async (_tabId: number, msg: unknown) => {
const typed = msg as { type?: string; requestId?: string };
if (typed.type === RECORD_START && typed.requestId) requestId = typed.requestId;
return { ok: true };
});
const deps = { tabsApi, sendToTab, cdp: makeFakeCdp() };
await handleRecordStart(manager, { session_id: "abcd", url: START_URL }, deps);
attachRecordStepListener(deps);
runtimeOnMessage.emit(
{
type: RECORD_STEP,
requestId,
step: {
op: "click",
page_url: START_URL,
target: { role: "button", name: "Submit", tag: "button" },
},
},
{ tab: { id: TAB_ID } } as chrome.runtime.MessageSender,
() => {},
);
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, deps);
const trace = (stopped as RecordStopResult).trace;
expect("version" in trace).toBe(false);
expect("pages" in trace && trace.pages.length).toBeGreaterThan(0);
});
it("rejects unsupported trace_version values", async () => {
const manager = fakeManager();
const result = await handleRecordStart(
manager,
{ session_id: "abcd", url: START_URL, trace_version: 99 },
{ tabsApi: makeTabsApi(), sendToTab: vi.fn(), cdp: makeFakeCdp() },
);
expect(result).toMatchObject({ code: "invalid_params" });
});
it("does not let a closed armed tab block finish", async () => {
const chromeApi = installChrome();
const manager = fakeManager();
@@ -1217,7 +1202,7 @@ describe("recorded user steps reach the exported trace", () => {
});
const deps = { tabsApi, sendToTab, cdp: makeFakeCdp() };
await handleRecordStart(manager, { session_id: "abcd", url: START_URL }, deps);
await handleRecordStart(manager, RECORD_START_V3, deps);
attachRecordQueryListener(deps);
await new Promise<void>((resolve) => {
chromeApi.runtimeOnMessage.emit(
@@ -1230,7 +1215,8 @@ describe("recorded user steps reach the exported trace", () => {
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, deps);
expect((stopped as RecordStopResult).trace.version).toBe(3);
const stoppedTrace = (stopped as RecordStopResult).trace;
expect("version" in stoppedTrace && stoppedTrace.version).toBe(3);
expect(stopTabs).toEqual([TAB_ID]);
});
@@ -1272,7 +1258,7 @@ describe("recorded user steps reach the exported trace", () => {
});
const deps = { tabsApi, sendToTab, cdp: makeFakeCdp() };
await handleRecordStart(manager, { session_id: "abcd", url: START_URL }, deps);
await handleRecordStart(manager, RECORD_START_V3, deps);
attachRecordQueryListener(deps);
chromeApi.runtimeOnMessage.emit(
{ type: RECORD_QUERY },
@@ -1286,7 +1272,8 @@ describe("recorded user steps reach the exported trace", () => {
releaseSecondStart();
const stopped = await stopping;
expect((stopped as RecordStopResult).trace.version).toBe(3);
const stoppedTrace = (stopped as RecordStopResult).trace;
expect("version" in stoppedTrace && stoppedTrace.version).toBe(3);
expect(stopTabs).toEqual([TAB_ID, 5]);
});
@@ -1331,7 +1318,7 @@ describe("recorded user steps reach the exported trace", () => {
});
const deps = { tabsApi, sendToTab, cdp };
await handleRecordStart(manager, { session_id: "abcd", url: START_URL }, deps);
await handleRecordStart(manager, RECORD_START_V3, deps);
attachRecordStepListener(deps);
runtimeOnMessageEmit(chromeApi, requestId, {
op: "click",
@@ -1353,7 +1340,7 @@ describe("recorded user steps reach the exported trace", () => {
]);
releaseLookup();
const stopped = earlyStop?.result ?? (await stopping);
const trace = (stopped as RecordStopResult).trace;
const trace = asTraceV3((stopped as RecordStopResult).trace);
expect(trace.steps.some((step) => step.op === "navigate" && step.to === secondUrl)).toBe(true);
const navigation = trace.steps.find((step) => step.op === "navigate" && step.to === secondUrl);
@@ -1390,7 +1377,7 @@ describe("recorded user steps reach the exported trace", () => {
const sendToTab = vi.fn(async () => ({ ok: true }));
const deps = { tabsApi, sendToTab, cdp };
await handleRecordStart(manager, { session_id: "abcd", url: START_URL }, deps);
await handleRecordStart(manager, RECORD_START_V3, deps);
chromeApi.webNavigationOnCommitted.emit({
tabId: 5,
frameId: 0,
@@ -1401,7 +1388,7 @@ describe("recorded user steps reach the exported trace", () => {
await settleWait();
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, deps);
const trace = (stopped as RecordStopResult).trace;
const trace = asTraceV3((stopped as RecordStopResult).trace);
const navigation = trace.steps.find((step) => step.op === "navigate" && step.to === secondUrl);
const landed = trace.states.find((state) => state.id === navigation?.result.state);
+38 -14
View File
@@ -35,20 +35,22 @@ import {
settleUnsettledDrafts,
} from "@/lib/record-observation";
import { appendRecordedPayload, observeRecordedNavigation } from "@/lib/recording-step-buffer";
import { buildTraceV2 } from "@/lib/trace-reducer-v2";
import type { SessionManager } from "@/session-manager/manager";
import { EXTENSION_VERSION } from "@/transport/handshake";
import type {
DraftTraceStep,
RecordAwaitParams,
RecordAwaitResult,
RecordedTrace,
RecordStartParams,
RecordStartResult,
RecordStopParams,
RecordStopResult,
RpcError,
StopReason,
Trace,
} from "@/transport/types";
import { TRACE_VERSION } from "@/transport/types";
import { handleNavigate } from "./navigation";
import {
type CdpRunner,
@@ -68,8 +70,9 @@ interface ActiveRecording {
steps: DraftTraceStep[];
startedAt: string;
startedAtMs: number;
finishPromise: Promise<Trace>;
resolveFinish: (trace: Trace) => void;
traceVersion: 2 | 3;
finishPromise: Promise<RecordedTrace>;
resolveFinish: (trace: RecordedTrace) => void;
rejectFinish: (err: Error) => void;
settled: boolean;
finishing: boolean;
@@ -202,15 +205,32 @@ async function sendRecordStartWithAck(
throw lastError ?? new Error("failed to start recording in content script");
}
function buildTrace(recording: ActiveRecording): Trace {
return buildTraceV3({
obs: recording.observation,
function negotiatedTraceVersion(params: RecordStartParams): 2 | 3 | RpcError {
if (params.trace_version === undefined) return 2;
if (params.trace_version === TRACE_VERSION) return 3;
return {
code: "invalid_params",
message: `unsupported trace_version ${params.trace_version}; supported values are omitted (v2) or ${TRACE_VERSION} (v3)`,
};
}
function buildTrace(recording: ActiveRecording): RecordedTrace {
if (recording.traceVersion === 3) {
return buildTraceV3({
obs: recording.observation,
steps: recording.steps,
startedAt: recording.startedAt,
...(recording.purpose ? { purpose: recording.purpose } : {}),
startUrl: recording.startUrl,
stoppedBy: recording.stoppedBy,
bskVersion: BSK_TRACE_VERSION,
});
}
return buildTraceV2({
steps: recording.steps,
startedAt: recording.startedAt,
...(recording.startUrl ? { startUrl: recording.startUrl } : {}),
...(recording.purpose ? { purpose: recording.purpose } : {}),
startUrl: recording.startUrl,
stoppedBy: recording.stoppedBy,
bskVersion: BSK_TRACE_VERSION,
});
}
@@ -750,7 +770,7 @@ async function finishRecording(
sessionId: string,
deps: RecordDeps,
stoppedBy: StopReason,
): Promise<Trace | null> {
): Promise<RecordedTrace | null> {
const recording = recordings.get(sessionId);
if (!recording || recording.settled) return null;
if (recording.finishing) return recording.finishPromise;
@@ -798,6 +818,9 @@ export async function handleRecordStart(
params: RecordStartParams,
deps: RecordDeps = getDefaultDeps(),
): Promise<RecordStartResult | RpcError> {
const traceVersionOrErr = negotiatedTraceVersion(params);
if (typeof traceVersionOrErr !== "number") return traceVersionOrErr;
const ctxOrErr = lookupSession(manager, params, "record_start");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr;
@@ -814,9 +837,9 @@ 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: Trace) => void;
let resolveFinish!: (trace: RecordedTrace) => void;
let rejectFinish!: (err: Error) => void;
const finishPromise = new Promise<Trace>((resolve, reject) => {
const finishPromise = new Promise<RecordedTrace>((resolve, reject) => {
resolveFinish = resolve;
rejectFinish = reject;
});
@@ -833,6 +856,7 @@ export async function handleRecordStart(
steps: [],
startedAt: new Date(startedAtMs).toISOString(),
startedAtMs,
traceVersion: traceVersionOrErr,
finishPromise,
resolveFinish,
rejectFinish,
@@ -1055,9 +1079,9 @@ export async function handleRecordAwait(
return { code: "cancelled", message: "record_await aborted" };
}
const outcome = await new Promise<{ trace: Trace } | { error: RpcError }>((resolve) => {
const outcome = await new Promise<{ trace: RecordedTrace } | { error: RpcError }>((resolve) => {
let settled = false;
const finish = (result: { trace: Trace } | { error: RpcError }) => {
const finish = (result: { trace: RecordedTrace } | { error: RpcError }) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
@@ -65,9 +65,9 @@ function deferredFakeTransport(): { transport: Transport; emit: (frame: Protocol
}
describe("performHandshake", () => {
it("advertises the Trace v3 compatibility boundary", () => {
it("advertises the protocol compatibility boundary", () => {
expect(PROTOCOL_VERSION).toBe("1.1");
expect(MIN_COMPATIBLE_PROTOCOL).toBe("1.1");
expect(MIN_COMPATIBLE_PROTOCOL).toBe("1.0");
});
it("sends system.handshake with identity and both compat fields", async () => {
@@ -81,7 +81,7 @@ describe("performHandshake", () => {
version: "0.1.0",
protocol_version: "1.1",
min_compatible_peer: "0.0.0",
min_compatible_protocol: "1.1",
min_compatible_protocol: "1.0",
},
};
});
@@ -107,7 +107,7 @@ describe("performHandshake", () => {
},
});
expect(outcome.result.min_compatible_peer).toBe("0.0.0");
expect(outcome.result.min_compatible_protocol).toBe("1.1");
expect(outcome.result.min_compatible_protocol).toBe("1.0");
});
it("accepts legacy daemon reply with only min_compatible_peer", async () => {
+1 -1
View File
@@ -18,7 +18,7 @@ export const EXTENSION_VERSION: string =
* Lowest **protocol** version this extension accepts (e.g. `"1.0"`).
* Must stay in sync with daemon `MIN_COMPATIBLE_PROTOCOL`.
*/
export const MIN_COMPATIBLE_PROTOCOL = "1.1";
export const MIN_COMPATIBLE_PROTOCOL = "1.0";
/**
* **Deprecated** — legacy app-semver floor for wire compat with old
* daemons. New code sends `"0.0.0"`; compat decisions ignore this.
+72 -3
View File
@@ -663,6 +663,8 @@ export interface EmulateResult {
// --------------------------------------------------------------------------
export const TRACE_VERSION = 3;
export const TRACE_VERSION_V2 = 2;
export const DEFAULT_TRACE_VERSION = 2;
export const VOM_FORMAT_VERSION = 1;
export interface TargetDescriptor {
@@ -857,7 +859,7 @@ export type Step =
})
| ({ op: "scroll" } & StepCommon);
export interface Trace {
export interface TraceV3 {
version: number;
recorded_at: string;
started_at?: string;
@@ -869,6 +871,71 @@ export interface Trace {
steps: Step[];
}
/** @deprecated Use TraceV3 — kept as alias for existing call sites. */
export type Trace = TraceV3;
export interface TargetDescriptorV2 {
role?: string;
name?: string;
tag: string;
name_attr?: string;
placeholder?: string;
nearby_label?: string;
}
export interface PageRef {
id: string;
url: string;
title?: string;
}
export type StepV2 =
| { op: "navigate"; id: number; page: string; to: string; effect?: { navigated_to: string } }
| {
op: "click";
id: number;
page: string;
target: TargetDescriptorV2;
effect?: { navigated_to: string };
}
| {
op: "fill";
id: number;
page: string;
target: TargetDescriptorV2;
value: string;
redacted?: boolean;
effect?: { navigated_to: string };
}
| {
op: "select";
id: number;
page: string;
target: TargetDescriptorV2;
selection: SelectedOption[];
effect?: { navigated_to: string };
}
| {
op: "press";
id: number;
page: string;
key: string;
modifiers?: KeyModifier[];
target?: TargetDescriptorV2;
effect?: { navigated_to: string };
};
export interface TraceV2 {
recorded_at: string;
started_at?: string;
purpose?: string;
entry: TraceEntry;
pages: PageRef[];
steps: StepV2[];
}
export type RecordedTrace = TraceV2 | TraceV3;
export interface RecordStartParams {
session_id: string;
tab_id?: number;
@@ -876,6 +943,8 @@ export interface RecordStartParams {
purpose?: string;
max_page_tokens?: number;
redact_values?: boolean;
/** Omitted ⇒ v2; `3` ⇒ state-linked v3 bundle. */
trace_version?: number;
}
export interface RecordStartResult {
@@ -888,7 +957,7 @@ export interface RecordStopParams {
}
export interface RecordStopResult {
trace: Trace;
trace: RecordedTrace;
}
export interface RecordAwaitParams {
@@ -897,5 +966,5 @@ export interface RecordAwaitParams {
}
export interface RecordAwaitResult {
trace: Trace;
trace: RecordedTrace;
}