feat: capture state and element evidence

This commit is contained in:
赵义仑
2026-06-16 19:15:52 +08:00
parent 1324f1882c
commit 581724ea42
6 changed files with 431 additions and 4 deletions

View File

@@ -58,6 +58,42 @@
<button type="submit">Submit search</button>
</form>
<form>
<p>
This unnamed form deliberately contains a long body of descendant text that should remain available as bounded
context evidence but must not be promoted into the anchor name used for element identity. It describes internal
notes, operator workflow details, historical context, and extra explanatory copy that would make a noisy and
unstable identifier if copied into the context anchor name.
</p>
<label>
Unbounded Probe
<input name="unboundedProbe" />
</label>
</form>
<main aria-label="Operations">
<button data-testid="main-action">Main action</button>
</main>
<section aria-label="Duplicate controls">
<button>Duplicate</button>
<button>Duplicate</button>
</section>
<section role="dialog" aria-modal="true" class="open">
<span>Short volatile copy</span>
<button data-testid="short-dialog-action">Short dialog action</button>
</section>
<section role="dialog" aria-modal="true" class="open">
<p>
This unnamed dialog carries noisy descendant copy about multi-step operator notes, escalation background,
support annotations, internal timestamps, and other volatile details that must stay out of the dialog anchor
name while remaining available as bounded context text evidence for nearby actionable controls.
</p>
<button data-testid="dialog-probe">Dialog probe</button>
</section>
<button disabled data-testid="disabled-open">Open disabled panel</button>
<button data-testid="open-settings" onclick="document.getElementById('settings-dialog').classList.add('open')">Open settings</button>
<section id="settings-dialog" role="dialog" aria-modal="true" aria-label="Settings dialog">

View File

@@ -3,9 +3,12 @@ import { test } from "node:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { exploreUrl, scanUrl } from "../src/engine.js";
import { shortHash } from "../src/hash.js";
const fixturePath = path.resolve("tests/fixtures/context-risk.html");
const fixtureFileUrl = pathToFileURL(fixturePath).href;
async function withTempOutput<T>(name: string, callback: (out: string) => Promise<T>): Promise<T> {
const dir = await mkdtemp(path.join(tmpdir(), "action-topology-engine-test-"));
@@ -51,6 +54,131 @@ test("graph metadata records generation context and policy versions", async () =
assert.equal(typeof graph.metadata.inputFingerprint, "string");
});
test("scan records state lifecycle, language, context anchors, and parameter surfaces", async () => {
const graph = await withTempOutput("evidence", (out) =>
scanUrl({ url: `${fixtureFileUrl}?utm_source=codex&gclid=ad-click&keep=1#customers`, out }),
);
const state = graph.states[0];
assert.equal(state.lifecycle, "fingerprinted");
assert.equal(state.language, "en");
assert.equal(state.viewport.width, 1440);
assert.equal(state.urlCanonicalKey, `${fixtureFileUrl}?keep=1`);
assert.equal(state.evidence.some((item) => item.level === "observed" && item.source === "browser.location"), true);
assert.equal(state.evidence.some((item) => item.level === "derived" && item.source === "snapshot.hash"), true);
const query = graph.elements.find((element) => element.accessibleName === "Query");
assert.ok(query);
assert.equal(query.parameterSurface?.name, "q");
assert.equal(query.parameterSurface?.required, true);
assert.equal(query.parameterSurface?.maxLength, 40);
assert.equal(query.context.form?.name, "Search customers");
assert.equal(query.evidence.some((item) => item.level === "observed"), true);
const dialogProbe = graph.elements.find((element) => element.accessibleName === "Dialog probe");
assert.ok(dialogProbe);
assert.equal(dialogProbe.context.dialog?.name, null);
assert.equal(dialogProbe.context.dialog?.text?.includes("noisy descendant copy"), true);
const shortUnnamedDialog = graph.elements.find(
(element) => element.tag === "section" && element.role === "dialog" && element.text?.includes("Short volatile copy"),
);
assert.ok(shortUnnamedDialog);
assert.equal(shortUnnamedDialog.context.dialog?.name, null);
assert.equal(shortUnnamedDialog.context.dialog?.text?.includes("Short volatile copy"), true);
const shortUnnamedDialogIndex = graph.elements.findIndex((element) => element === shortUnnamedDialog);
assert.notEqual(shortUnnamedDialogIndex, -1);
assert.equal(
shortUnnamedDialog.elementId,
`el_${shortHash([
state.stateId,
shortUnnamedDialogIndex,
shortUnnamedDialog.tag,
shortUnnamedDialog.role,
null,
null,
null,
null,
shortUnnamedDialog.context.dialog?.name,
shortUnnamedDialog.context.form?.name,
shortUnnamedDialog.context.section?.name,
shortUnnamedDialog.context.landmark?.name,
shortUnnamedDialog.context.listitem?.name,
null,
])}`,
);
const unnamedDialog = graph.elements.find(
(element) => element.tag === "section" && element.role === "dialog" && element.text?.includes("noisy descendant copy"),
);
assert.ok(unnamedDialog);
const unnamedDialogIndex = graph.elements.findIndex((element) => element === unnamedDialog);
assert.notEqual(unnamedDialogIndex, -1);
assert.equal(
unnamedDialog.elementId,
`el_${shortHash([
state.stateId,
unnamedDialogIndex,
unnamedDialog.tag,
unnamedDialog.role,
null,
null,
null,
null,
unnamedDialog.context.dialog?.name,
unnamedDialog.context.form?.name,
unnamedDialog.context.section?.name,
unnamedDialog.context.landmark?.name,
unnamedDialog.context.listitem?.name,
null,
])}`,
);
const duplicates = graph.elements.filter((element) => element.accessibleName === "Duplicate");
assert.equal(duplicates.length, 2);
assert.notEqual(duplicates[0]?.elementId, duplicates[1]?.elementId);
const unboundedProbe = graph.elements.find((element) => element.accessibleName === "Unbounded Probe");
assert.ok(unboundedProbe);
assert.equal(unboundedProbe.context.form?.name, null);
assert.equal(unboundedProbe.context.form?.text?.includes("long body of descendant text"), true);
const mainAction = graph.elements.find((element) => element.accessibleName === "Main action");
assert.ok(mainAction);
assert.equal(mainAction.context.section, null);
assert.equal(mainAction.context.landmark?.name, "Operations");
const edit = graph.elements.find((element) => element.accessibleName === "Edit");
assert.ok(edit);
assert.equal(edit.context.row?.text.includes("Acme Co") || edit.context.row?.text.includes("Beta LLC"), true);
const edits = graph.elements.filter((element) => element.accessibleName === "Edit");
assert.equal(edits.length, 2);
assert.notEqual(edits[0]?.elementId, edits[1]?.elementId);
assert.notEqual(edits[0]?.context.row?.text, edits[1]?.context.row?.text);
for (const item of edits) {
const index = graph.elements.findIndex((element) => element === item);
assert.notEqual(index, -1);
assert.equal(
item.elementId,
`el_${shortHash([
state.stateId,
index,
item.tag,
item.role,
item.attributes["data-testid"] || item.attributes["data-test-id"] || item.attributes["data-test"] || null,
item.attributes.id || null,
item.attributes.name || null,
item.accessibleName,
item.context.dialog?.name,
item.context.form?.name,
item.context.section?.name,
item.context.landmark?.name,
item.context.listitem?.name,
null,
])}`,
);
}
});
test("explore records low-risk edges and keeps high-risk submit actions out of default execution", async () => {
const graph = await withTempOutput("explore", (out) => exploreUrl({ url: fixturePath, maxActions: 8, out }));
assert.equal(graph.edges.some((edge) => edge.riskLevel === "low" && edge.toStateId), true);

View File

@@ -6,15 +6,27 @@ import type { RawActionableElement } from "../src/types.js";
function raw(overrides: Partial<RawActionableElement>): RawActionableElement {
return {
uid: "button_0_Open settings",
candidateIndex: 0,
tag: "button",
role: "button",
accessibleName: "Open settings",
identityText: "Open settings",
text: "Open settings",
label: null,
placeholder: null,
attributes: { "data-testid": "open-settings", id: "openSettings" },
bbox: { x: 0, y: 0, width: 100, height: 30 },
visibility: { visible: true, enabled: true, editable: false, receivesEvents: true },
context: {
dialog: null,
form: null,
section: null,
row: null,
landmark: null,
listitem: null,
},
parameterSurface: null,
evidence: [],
...overrides,
};
}

View File

@@ -19,6 +19,16 @@ function element(overrides: Partial<ActionableElement>): ActionableElement {
shadowPath: [],
interactability: { visible: true, enabled: true, editable: false, receivesEvents: true },
locators: [],
context: {
dialog: null,
form: null,
section: null,
row: null,
landmark: null,
listitem: null,
},
parameterSurface: null,
evidence: [],
...overrides,
};
}