mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user