fix(record): rebase and dto policy

This commit is contained in:
Ljy-0827
2026-08-23 21:26:40 +08:00
committed by GitHub
parent 87cdcfe5ca
commit 6300eda0aa
5 changed files with 50 additions and 57 deletions
@@ -18,22 +18,13 @@ function sessionWithInput(redactValues = false): RecordingObservationSession {
rootFrameId: "root",
index: new ObservationNodeIndex({
rootFrameId: "root",
frameDocuments: [
matchNodes: [
{
frameId: "root",
domNodes: [
{
backendNodeId: 42,
parentBackendNodeId: null,
frameId: "root",
tag: "input",
attrs: {},
rect: { x: 20, y: 40, w: 200, h: 30 },
paintOrder: 1,
position: "static",
pointerEvents: "auto",
},
],
backendNodeId: 42,
tag: "input",
rect: { x: 20, y: 40, w: 200, h: 30 },
localRect: { x: 20, y: 40, w: 200, h: 30 },
},
],
refs: [{ ref: "e1", backendNodeId: 42, role: "textbox", name: "Password", line: 1 }],
@@ -8,7 +8,7 @@ import type { RecordingDraftStep } from "../recording/types";
const OBSERVATION: RegisteredObservation = {
stateId: "s-next",
rootFrameId: "root",
index: new ObservationNodeIndex({ rootFrameId: "root", frameDocuments: [], refs: [] }),
index: new ObservationNodeIndex({ rootFrameId: "root", matchNodes: [], refs: [] }),
url: "https://example.com/next",
};
@@ -1,40 +1,50 @@
import type { RenderedRef } from "@browser-skill/vom";
import { describe, expect, it } from "vitest";
import type { CapturedNode } from "@/tools/vom/capture";
import type { CaptureVomMatchNode } from "@/tools/capture-vom-observation";
import { ObservationNodeIndex, type RegisteredObservation } from "../recording/observation-capture";
import { matchObservationTarget } from "../recording/target-matcher";
function node(backendNodeId: number, frameId: string, x = 10): CapturedNode {
function node(backendNodeId: number, frameId: string, x = 10): CaptureVomMatchNode {
return {
backendNodeId,
parentBackendNodeId: null,
frameId,
tag: "button",
attrs: {},
rect: { x, y: 20, w: 100, h: 30 },
paintOrder: 1,
position: "static",
pointerEvents: "auto",
localRect: { x, y: 20, w: 100, h: 30 },
};
}
function observation(
documents: Array<{ frameId: string; domNodes: CapturedNode[] }>,
matchNodes: CaptureVomMatchNode[],
refs: RenderedRef[],
): RegisteredObservation {
return {
stateId: "s1",
rootFrameId: "root",
index: new ObservationNodeIndex({ rootFrameId: "root", frameDocuments: documents, refs }),
index: new ObservationNodeIndex({ rootFrameId: "root", matchNodes, refs }),
url: "https://example.com",
};
}
describe("matchObservationTarget", () => {
it("indexes the safe geometry DTO without discarding frame-local geometry", () => {
const geometry = node(42, "child");
geometry.localRect = { x: 5, y: 6, w: 100, h: 30 };
const ref: RenderedRef = {
ref: "e1",
backendNodeId: 42,
frameId: "child",
line: 1,
};
const candidate = observation([geometry], [ref]).index.candidates("child", "button")[0];
expect(candidate).toEqual({ frameId: "child", geometry, ref });
});
it("matches a unique node using canonical top-level viewport geometry", () => {
const target = matchObservationTarget({
observation: observation(
[{ frameId: "root", domNodes: [node(42, "root")] }],
[node(42, "root")],
[{ ref: "e1", backendNodeId: 42, role: "button", name: "发布", line: 1 }],
),
hint: { geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" } },
@@ -45,10 +55,7 @@ describe("matchObservationTarget", () => {
it("uses frame id with backend node id so sibling frames cannot collide", () => {
const target = matchObservationTarget({
observation: observation(
[
{ frameId: "left", domNodes: [node(42, "left")] },
{ frameId: "right", domNodes: [node(42, "right")] },
],
[node(42, "left"), node(42, "right")],
[
{ ref: "e1", backendNodeId: 42, frameId: "left", line: 1 },
{ ref: "e2", backendNodeId: 42, frameId: "right", line: 2 },
@@ -65,10 +72,7 @@ describe("matchObservationTarget", () => {
it("restricts a missing frame hint to the root frame", () => {
const target = matchObservationTarget({
observation: observation(
[
{ frameId: "root", domNodes: [] },
{ frameId: "child", domNodes: [node(42, "child")] },
],
[node(42, "child")],
[{ ref: "e1", backendNodeId: 42, frameId: "child", line: 1 }],
),
hint: { geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" } },
@@ -80,7 +84,7 @@ describe("matchObservationTarget", () => {
it("returns unmatched for ambiguous geometry", () => {
const target = matchObservationTarget({
observation: observation(
[{ frameId: "root", domNodes: [node(42, "root"), node(43, "root")] }],
[node(42, "root"), node(43, "root")],
[
{ ref: "e1", backendNodeId: 42, line: 1 },
{ ref: "e2", backendNodeId: 43, line: 2 },
@@ -94,10 +98,7 @@ describe("matchObservationTarget", () => {
it("uses semantics when geometry is unavailable without crossing frame boundaries", () => {
const target = matchObservationTarget({
observation: observation(
[
{ frameId: "root", domNodes: [node(41, "root")] },
{ frameId: "child", domNodes: [node(42, "child")] },
],
[node(41, "root"), node(42, "child")],
[
{ ref: "e1", backendNodeId: 41, role: "button", name: "保存", line: 1 },
{ ref: "e2", backendNodeId: 42, frameId: "child", role: "button", name: "保存", line: 2 },
@@ -112,7 +113,7 @@ describe("matchObservationTarget", () => {
it("uses semantics to disambiguate equal geometry in one frame", () => {
const target = matchObservationTarget({
observation: observation(
[{ frameId: "root", domNodes: [node(42, "root"), node(43, "root")] }],
[node(42, "root"), node(43, "root")],
[
{ ref: "e1", backendNodeId: 42, role: "button", name: "保存", line: 1 },
{ ref: "e2", backendNodeId: 43, role: "button", name: "取消", line: 2 },
@@ -1,11 +1,14 @@
import type { RenderedRef } from "@browser-skill/vom";
import { captureVomObservation } from "@/tools/capture-vom-observation";
import {
type CaptureVomMatchNode,
type CaptureVomObservationResult,
captureVomObservation,
} from "@/tools/capture-vom-observation";
import type { CdpRunner, ChromeTabsApi } from "@/tools/shared";
import type { CapturedNode } from "@/tools/vom/capture";
export interface IndexedObservationNode {
frameId: string;
node: CapturedNode;
geometry: CaptureVomMatchNode;
ref?: RenderedRef;
}
@@ -38,11 +41,7 @@ export class ObservationNodeIndex {
readonly #refById = new Map<string, RenderedRef>();
readonly #refsByFrame = new Map<string, RenderedRef[]>();
constructor(input: {
rootFrameId: string;
frameDocuments: Array<{ frameId: string; domNodes: CapturedNode[] }>;
refs: RenderedRef[];
}) {
constructor(input: Pick<CaptureVomObservationResult, "rootFrameId" | "matchNodes" | "refs">) {
const refByNode = new Map<string, RenderedRef>();
for (const ref of input.refs) {
const frameId = ref.frameId ?? input.rootFrameId;
@@ -52,15 +51,17 @@ export class ObservationNodeIndex {
frameRefs.push(ref);
this.#refsByFrame.set(frameId, frameRefs);
}
for (const document of input.frameDocuments) {
for (const node of document.domNodes) {
const frameId = node.frameId ?? document.frameId;
const entry = { frameId, node, ref: refByNode.get(nodeKey(frameId, node.backendNodeId)) };
const key = frameTagKey(frameId, node.tag);
const bucket = this.#nodesByFrameTag.get(key) ?? [];
bucket.push(entry);
this.#nodesByFrameTag.set(key, bucket);
}
for (const geometry of input.matchNodes) {
const { frameId } = geometry;
const entry = {
frameId,
geometry,
ref: refByNode.get(nodeKey(frameId, geometry.backendNodeId)),
};
const key = frameTagKey(frameId, geometry.tag);
const bucket = this.#nodesByFrameTag.get(key) ?? [];
bucket.push(entry);
this.#nodesByFrameTag.set(key, bucket);
}
}
@@ -15,7 +15,7 @@ function rectMatches(a: TargetGeometry["rect"], b: TargetGeometry["rect"]): bool
}
function candidateMatches(candidate: IndexedObservationNode, geometry: TargetGeometry): boolean {
return candidate.node.rect !== null && rectMatches(geometry.rect, candidate.node.rect);
return candidate.geometry.rect !== null && rectMatches(geometry.rect, candidate.geometry.rect);
}
export function unmatchedTarget(fallback?: CaptureTargetDescriptor): TargetDescriptorV3 {