mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat(extension): record user flows as trace v3 page-state observations
Capture settled page observations into a states/steps trace, match targets by geometry with capture fallbacks, and reuse VOM rendering with richer refs and optional value redaction for record bundles.
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DraftTraceStep } from "@/transport/types";
|
||||
import { reduceTraceSteps, resolveTraceStartUrl, shouldRecordPress } from "../trace-reducer";
|
||||
import { resetStateIdCounterForTests } from "@/lib/record-constants";
|
||||
import {
|
||||
reduceTraceSteps,
|
||||
registerObservation,
|
||||
resolveTraceStartUrl,
|
||||
shouldRecordPress,
|
||||
} from "../trace-reducer";
|
||||
|
||||
describe("shouldRecordPress", () => {
|
||||
it("keeps Enter and Escape", () => {
|
||||
@@ -17,86 +22,85 @@ describe("shouldRecordPress", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceTraceSteps", () => {
|
||||
it("builds steps with pages dictionary and page id references", () => {
|
||||
const drafts: DraftTraceStep[] = [
|
||||
{
|
||||
op: "navigate",
|
||||
url: "https://example.com/search?q=hello&utm_source=x",
|
||||
page_url: "https://example.com/search?q=hello&utm_source=x",
|
||||
},
|
||||
{
|
||||
op: "fill",
|
||||
target: { tag: "input", role: "textbox", name: "搜索", name_attr: "q" },
|
||||
value: "browser skill",
|
||||
page_url: "https://example.com/search?q=hello&utm_source=x",
|
||||
},
|
||||
{
|
||||
op: "press",
|
||||
key: "Enter",
|
||||
target: { tag: "input", role: "textbox", name: "搜索", name_attr: "q" },
|
||||
navigated_to: "https://example.com/results/42",
|
||||
page_url: "https://example.com/search?q=hello&utm_source=x",
|
||||
},
|
||||
{
|
||||
op: "click",
|
||||
target: { tag: "button", role: "button", name: "发布" },
|
||||
navigated_to: "https://example.com/p/99",
|
||||
page_url: "https://example.com/results/42",
|
||||
},
|
||||
{
|
||||
op: "press",
|
||||
key: "a",
|
||||
page_url: "https://example.com/p/99",
|
||||
},
|
||||
];
|
||||
|
||||
const { pages, steps } = reduceTraceSteps(
|
||||
drafts,
|
||||
"https://example.com/search?q=hello&utm_source=x",
|
||||
);
|
||||
expect(JSON.stringify(steps)).not.toContain("parameters");
|
||||
expect(JSON.stringify(steps)).not.toContain("intent");
|
||||
expect(JSON.stringify(steps)).not.toContain("summary");
|
||||
expect(steps.map((s) => s.op)).toEqual(["navigate", "fill", "press", "click"]);
|
||||
expect(pages.map((p) => p.url)).toEqual([
|
||||
"https://example.com/search?q=hello&utm_source=x",
|
||||
"https://example.com/results/42",
|
||||
"https://example.com/p/99",
|
||||
]);
|
||||
|
||||
expect(steps[0]).toMatchObject({
|
||||
id: 1,
|
||||
op: "navigate",
|
||||
page: "p1",
|
||||
to: "https://example.com/search?q=hello&utm_source=x",
|
||||
describe("reduceTraceSteps v3", () => {
|
||||
it("builds steps with state/result references and deduped states", () => {
|
||||
resetStateIdCounterForTests();
|
||||
const registry = new Map();
|
||||
const body = "@vom 1\nL1 page";
|
||||
const s1 = registerObservation(registry, {
|
||||
url: "https://example.com/search",
|
||||
rawVomText: body,
|
||||
});
|
||||
const s2 = registerObservation(registry, {
|
||||
url: "https://example.com/results/42",
|
||||
rawVomText: "@vom 1\nL1 results",
|
||||
});
|
||||
|
||||
const { states, steps } = reduceTraceSteps(
|
||||
[
|
||||
{
|
||||
op: "navigate",
|
||||
url: "https://example.com/search",
|
||||
preStateId: s1,
|
||||
postStateId: s1,
|
||||
},
|
||||
{
|
||||
op: "fill",
|
||||
target: { ref: "e1", role: "textbox", name: "搜索" },
|
||||
value: "browser skill",
|
||||
commit: "enter",
|
||||
preStateId: s1,
|
||||
postStateId: s1,
|
||||
},
|
||||
{
|
||||
op: "press",
|
||||
key: "Enter",
|
||||
target: { ref: "e1", role: "textbox", name: "搜索" },
|
||||
preStateId: s1,
|
||||
postStateId: s2,
|
||||
navigated_to: "https://example.com/results/42",
|
||||
},
|
||||
{
|
||||
op: "click",
|
||||
target: { ref: "e2", role: "button", name: "发布" },
|
||||
preStateId: s2,
|
||||
postStateId: s2,
|
||||
},
|
||||
],
|
||||
registry,
|
||||
);
|
||||
|
||||
expect(states).toHaveLength(2);
|
||||
expect(steps.map((s) => s.op)).toEqual(["navigate", "fill", "press", "click"]);
|
||||
expect(steps[1]).toMatchObject({
|
||||
op: "fill",
|
||||
page: "p1",
|
||||
value: "browser skill",
|
||||
state: s1,
|
||||
result: { state: s1 },
|
||||
commit: "enter",
|
||||
});
|
||||
|
||||
expect(steps[2]).toMatchObject({
|
||||
op: "press",
|
||||
key: "Enter",
|
||||
page: "p1",
|
||||
effect: { navigated_to: "p2" },
|
||||
});
|
||||
|
||||
expect(steps[3]).toMatchObject({
|
||||
op: "click",
|
||||
page: "p2",
|
||||
effect: { navigated_to: "p3" },
|
||||
state: s1,
|
||||
result: { state: s2 },
|
||||
});
|
||||
expect(JSON.stringify(steps)).not.toContain("page");
|
||||
expect(JSON.stringify(steps)).not.toContain("effect");
|
||||
});
|
||||
|
||||
it("collapses consecutive navigations", () => {
|
||||
const { steps } = reduceTraceSteps([
|
||||
{ op: "navigate", url: "https://a.example/redirect1" },
|
||||
{ op: "navigate", url: "https://a.example/final" },
|
||||
]);
|
||||
resetStateIdCounterForTests();
|
||||
const registry = new Map();
|
||||
const s1 = registerObservation(registry, {
|
||||
url: "https://a.example/redirect1",
|
||||
rawVomText: "a",
|
||||
});
|
||||
const { steps } = reduceTraceSteps(
|
||||
[
|
||||
{ op: "navigate", url: "https://a.example/redirect1", preStateId: s1, postStateId: s1 },
|
||||
{ op: "navigate", url: "https://a.example/final", preStateId: s1, postStateId: s1 },
|
||||
],
|
||||
registry,
|
||||
);
|
||||
expect(steps).toHaveLength(1);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "navigate",
|
||||
@@ -104,52 +108,107 @@ describe("reduceTraceSteps", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("maps select navigated_to onto effect.navigated_to (page id)", () => {
|
||||
const { pages, steps } = reduceTraceSteps(
|
||||
it("keeps the origin state and cause of the hop that started a redirect chain", () => {
|
||||
resetStateIdCounterForTests();
|
||||
const registry = new Map();
|
||||
const start = registerObservation(registry, {
|
||||
url: "https://example.com/",
|
||||
rawVomText: "start",
|
||||
});
|
||||
const hop = registerObservation(registry, {
|
||||
url: "https://iwiki.woa.com/",
|
||||
rawVomText: "hop",
|
||||
});
|
||||
const landing = registerObservation(registry, {
|
||||
url: "https://iwiki.woa.com/dashboard",
|
||||
rawVomText: "landing",
|
||||
});
|
||||
|
||||
const { steps, stepIdByDraftId } = reduceTraceSteps(
|
||||
[
|
||||
{
|
||||
op: "select",
|
||||
target: { tag: "select", role: "combobox", name: "分类" },
|
||||
values: ["tech"],
|
||||
labels: ["技术"],
|
||||
navigated_to: "https://example.com/list?cat=tech",
|
||||
page_url: "https://example.com/list",
|
||||
op: "navigate",
|
||||
url: "https://iwiki.woa.com/",
|
||||
preStateId: start,
|
||||
postStateId: hop,
|
||||
transitionType: "typed",
|
||||
},
|
||||
{
|
||||
op: "navigate",
|
||||
url: "https://iwiki.woa.com/dashboard",
|
||||
preStateId: hop,
|
||||
postStateId: landing,
|
||||
transitionType: "link",
|
||||
},
|
||||
],
|
||||
"https://example.com/list",
|
||||
registry,
|
||||
);
|
||||
expect(pages.map((p) => p.url)).toEqual([
|
||||
"https://example.com/list",
|
||||
"https://example.com/list?cat=tech",
|
||||
]);
|
||||
|
||||
expect(steps).toHaveLength(1);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "select",
|
||||
page: "p1",
|
||||
selection: [{ value: "tech", label: "技术" }],
|
||||
effect: { navigated_to: "p2" },
|
||||
op: "navigate",
|
||||
id: 1,
|
||||
state: start,
|
||||
result: { state: landing },
|
||||
to: "https://iwiki.woa.com/dashboard",
|
||||
cause: "user_typed",
|
||||
});
|
||||
// Both drafts fold into step 1, so anything tagged with either draft id
|
||||
// must resolve to it.
|
||||
expect(stepIdByDraftId.get(1)).toBe(1);
|
||||
expect(stepIdByDraftId.get(2)).toBe(1);
|
||||
});
|
||||
|
||||
it("maps draft ids to published step ids across dropped drafts", () => {
|
||||
resetStateIdCounterForTests();
|
||||
const registry = new Map();
|
||||
const s1 = registerObservation(registry, { url: "https://a.example/", rawVomText: "a" });
|
||||
|
||||
const { steps, stepIdByDraftId } = reduceTraceSteps(
|
||||
[
|
||||
// Dropped: blank fill.
|
||||
{ op: "fill", target: { ref: "e1" }, value: " ", preStateId: s1, postStateId: s1 },
|
||||
{ op: "click", target: { ref: "e2" }, preStateId: s1, postStateId: s1 },
|
||||
],
|
||||
registry,
|
||||
);
|
||||
|
||||
expect(steps.map((step) => step.op)).toEqual(["click"]);
|
||||
expect(stepIdByDraftId.get(1)).toBeUndefined();
|
||||
expect(stepIdByDraftId.get(2)).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps hover steps before menu clicks", () => {
|
||||
resetStateIdCounterForTests();
|
||||
const registry = new Map();
|
||||
const state = registerObservation(registry, {
|
||||
url: "https://example.com/app",
|
||||
rawVomText: '@e1 button "Account"\n@e2 link "Profile"',
|
||||
});
|
||||
const { steps } = reduceTraceSteps(
|
||||
[
|
||||
{
|
||||
op: "hover",
|
||||
target: { tag: "span", role: "button", name: "Account" },
|
||||
target: { ref: "e1", role: "button", name: "Account" },
|
||||
page_url: "https://example.com/app",
|
||||
preStateId: state,
|
||||
postStateId: state,
|
||||
},
|
||||
{
|
||||
op: "click",
|
||||
target: { tag: "a", role: "link", name: "Profile" },
|
||||
target: { ref: "e2", role: "link", name: "Profile" },
|
||||
page_url: "https://example.com/app",
|
||||
preStateId: state,
|
||||
postStateId: state,
|
||||
},
|
||||
],
|
||||
"https://example.com/app",
|
||||
registry,
|
||||
);
|
||||
expect(steps.map((s) => s.op)).toEqual(["hover", "click"]);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "hover",
|
||||
page: "p1",
|
||||
state,
|
||||
result: { state },
|
||||
target: { name: "Account" },
|
||||
});
|
||||
});
|
||||
@@ -157,7 +216,7 @@ describe("reduceTraceSteps", () => {
|
||||
it("resolveTraceStartUrl prefers explicit start URL", () => {
|
||||
expect(
|
||||
resolveTraceStartUrl(
|
||||
[{ op: "navigate", url: "https://example.com/other" }],
|
||||
[{ op: "navigate", url: "https://example.com/other", preStateId: "s1", postStateId: "s1" }],
|
||||
"https://example.com/start",
|
||||
),
|
||||
).toBe("https://example.com/start");
|
||||
|
||||
Reference in New Issue
Block a user