65 KiB
v0.2.1 Evidence Layer Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the next practical slice of the deterministic website action topology engine: richer evidence capture, context-scoped locator evidence, replay verification, state-local risk skip records, and a v0.2 graph contract.
Architecture: Keep the repository as a small Playwright-based topology engine. Add a typed evidence layer around the existing snapshotPage, generateLocators, and exploreUrl flow; do not add LLM planning, natural-language execution, product UI, rrweb alignment, full BFS crawling, or a general browser-agent platform. Preserve JSON artifact output while making every state, element, locator, edge, skip, and failure more explainable.
Tech Stack: TypeScript, Playwright, Node built-in test runner through tsx --test, JSON artifacts, Markdown schema docs.
Design Inputs
docs/design/action-topology-engine-v0.2.md: source of truth for scope, state-local elements, evidence levels, risk categories, parameter surfaces, state lifecycle, action edges, failures, and artifact versioning.docs/design/no-llm-topology-engine.md: first slice remains deterministic and no-LLM.docs/source/2026-06-16-browser-automation-crawling-research.md: near-term direction is capture evidence layer, locator context ranking, replay verification, conservative explore policy, and non-evasive anti-bot handling.AGENTS.md: repository must remain focused on topology capture/extraction/locator verification/action edges/graph artifacts.
Implementation Guardrails
- Every
ActionableElementmust remain state-local. Element IDs must include state identity or otherwise encode state locality; never collapse elements across states with a global DOM-only ID. - Every explored
ActionEdgemust be replayed from its declaredfromStateId. In v0.2.1 this means each candidate starts from a fresh page at the entry URL before the action runs. SkippedActionis reserved for risk-policy refusals. Hidden, disabled, obscured, duplicate-locator, and other not-actionable cases must be recorded asFailedObservation, not as risk skips.- Newly discovered states must have their own high-risk actions recorded as state-local
SkippedActionentries, even though v0.2.1 does not recursively explore those new states. - Locator context scopes in v0.2.1 are evidence for ranking and disambiguation. They are not compiled into scoped locators unless a task explicitly adds a scoped descriptor.
- v0.2.1 effect records are snapshot-diff based: URL, DOM hash, visible text hash, and actionable inventory hash. Network, storage, download, and dialog event buffers are reserved for a later slice unless explicitly added.
crawlSessionIdis a unique run identifier, not a deterministic fingerprint. Deterministic comparison usesinputFingerprint.openedByEdgeIdmeans first observed by that edge in this run, not the only possible predecessor.
Current Baseline
npm run typecheck: passes.npm run build: passes.npm run scan -- --url ./examples/simple.html --out artifacts/baseline-simple-scan.json: writes 1 state, 3 elements, 0 edges.npm run example: writes 3 states, 12 elements, 2 edges.
File Structure
- Modify
package.json: add atestscript using existingtsx. - Modify
src/types.ts: versioned graph types, evidence records, richer metadata, risk policy records, locator verification records, context anchors, parameter surfaces, state-local skipped actions, failed observations. - Modify
src/snapshot.ts: collect state evidence, element context, form/parameter surface, actionability facts, language/viewport, and stable state signatures. - Modify
src/locator.ts: keep Playwright user-facing locators first, add context-scope evidence, strengthen verification from uniqueness-only to identity checks. - Modify
src/risk.ts: replace simple low/medium/high classification internals with risk categories and policy decisions while keepingriskLevelcompatibility. - Modify
src/engine.ts: build v0.2 metadata, record state-local skipped high-risk actions, record failed observations, passopenedByEdgeId, dedupe state-local elements, and replay each candidate from a clean entry state. - Create
src/metadata.ts: central constants and runtime metadata builder. - Create
src/effects.ts: compare before/after snapshots into typed snapshot-diff effect records. - Create
tests/fixtures/context-risk.html: deterministic fixture with repeated row actions, dialog actions, form fields, high-risk actions, and local effects. - Create
tests/risk.test.ts: unit tests for risk category and default explore policy. - Create
tests/locator.test.ts: unit tests for locator ordering and context metadata. - Create
tests/integration.test.ts: Playwright-backed graph tests for scan and explore on the fixture. - Modify
schemas/topology-graph.schema.md: document v0.2 fields and compatibility notes. - Modify
README.md: update scope summary, commands, and output model.
Task 1: Test Harness and Deterministic Fixture
Files:
-
Modify:
package.json -
Create:
tests/fixtures/context-risk.html -
Create:
tests/risk.test.ts -
Create:
tests/locator.test.ts -
Create:
tests/integration.test.ts -
Step 1: Add the test script
Modify package.json scripts to include test without adding a new dependency:
{
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "tsx --test tests/**/*.test.ts",
"scan": "tsx src/cli.ts scan",
"explore": "tsx src/cli.ts explore",
"example": "tsx src/cli.ts explore --url ./examples/simple.html --out artifacts/simple-graph.json --max-actions 8"
}
}
- Step 2: Create the fixture
Create tests/fixtures/context-risk.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Topology Context Risk Fixture</title>
<style>
body { font-family: system-ui, sans-serif; margin: 24px; }
table { border-collapse: collapse; margin: 16px 0; }
th, td { border: 1px solid #ccc; padding: 8px 10px; }
button, input, select, a { margin: 4px; padding: 6px 10px; }
[role="dialog"] { display: none; border: 1px solid #999; padding: 16px; margin-top: 16px; }
[role="dialog"].open { display: block; }
#toast { margin-top: 16px; min-height: 24px; }
</style>
</head>
<body>
<h1>Accounts</h1>
<nav aria-label="Primary">
<a href="#overview" data-testid="overview-link">Overview</a>
<a href="#reports" data-testid="reports-link">Reports</a>
</nav>
<section aria-labelledby="customers-heading">
<h2 id="customers-heading">Customers</h2>
<table aria-label="Customer accounts">
<thead>
<tr><th>Name</th><th>Status</th><th>Action</th></tr>
</thead>
<tbody>
<tr>
<td>Acme Co</td>
<td>Active</td>
<td><button data-testid="edit-acme" onclick="openEditor('Acme Co')">Edit</button></td>
</tr>
<tr>
<td>Beta LLC</td>
<td>Trial</td>
<td><button data-testid="edit-beta" onclick="openEditor('Beta LLC')">Edit</button></td>
</tr>
</tbody>
</table>
</section>
<form aria-label="Search customers" action="/search" method="get">
<label>
Query
<input name="q" placeholder="Search customers" required maxlength="40" />
</label>
<label>
Segment
<select name="segment">
<option value="">Any</option>
<option value="active">Active</option>
<option value="trial">Trial</option>
</select>
</label>
<button type="submit">Submit search</button>
</form>
<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">
<h2>Settings</h2>
<button data-testid="save-settings" onclick="document.getElementById('toast').textContent='Saved settings'">Save</button>
<button data-testid="delete-account">Delete account</button>
<button data-testid="close-settings" onclick="document.getElementById('settings-dialog').classList.remove('open')">Close</button>
</section>
<section id="editor" role="dialog" aria-modal="true" aria-label="Customer editor">
<h2 id="editor-title">Customer editor</h2>
<label>
Customer name
<input id="customer-name" name="customerName" required />
</label>
<button data-testid="close-editor" onclick="document.getElementById('editor').classList.remove('open')">Close editor</button>
</section>
<p id="toast" role="status"></p>
<script>
function openEditor(name) {
document.getElementById("customer-name").value = name;
document.getElementById("editor-title").textContent = "Editing " + name;
document.getElementById("editor").classList.add("open");
}
</script>
</body>
</html>
- Step 3: Write the initial risk tests
Create tests/risk.test.ts:
import assert from "node:assert/strict";
import { test } from "node:test";
import { classifyClickRisk, isLowRiskClickable } from "../src/risk.js";
import type { ActionableElement } from "../src/types.js";
function element(overrides: Partial<ActionableElement>): ActionableElement {
return {
elementId: "el_test",
stateId: "state_test",
tag: "button",
role: "button",
accessibleName: "Open settings",
text: "Open settings",
label: null,
placeholder: null,
attributes: {},
bbox: { x: 0, y: 0, width: 100, height: 30 },
framePath: [],
shadowPath: [],
interactability: { visible: true, enabled: true, editable: false, receivesEvents: true },
locators: [],
...overrides,
};
}
test("risk classifier blocks destructive and submission terms", () => {
assert.equal(classifyClickRisk(element({ accessibleName: "Delete account" })), "high");
assert.equal(classifyClickRisk(element({ accessibleName: "提交订单" })), "high");
assert.equal(classifyClickRisk(element({ attributes: { type: "submit" }, text: "Submit search" })), "high");
});
test("risk classifier treats editable controls as medium", () => {
assert.equal(classifyClickRisk(element({ tag: "input", role: "textbox", accessibleName: "Query" })), "medium");
assert.equal(classifyClickRisk(element({ tag: "select", role: "combobox", accessibleName: "Segment" })), "medium");
});
test("default explorer only clicks low-risk visible clickable elements", () => {
assert.equal(isLowRiskClickable(element({ accessibleName: "Open settings" })), true);
assert.equal(isLowRiskClickable(element({ accessibleName: "Delete account" })), false);
assert.equal(isLowRiskClickable(element({ interactability: { visible: true, enabled: false, editable: false, receivesEvents: true } })), false);
});
- Step 4: Write locator tests against the current API
Create tests/locator.test.ts:
import assert from "node:assert/strict";
import { test } from "node:test";
import { generateLocators } from "../src/locator.js";
import type { RawActionableElement } from "../src/types.js";
function raw(overrides: Partial<RawActionableElement>): RawActionableElement {
return {
uid: "button_0_Open settings",
tag: "button",
role: "button",
accessibleName: "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 },
...overrides,
};
}
test("locator generation prioritizes user-facing Playwright locators before CSS fallbacks", () => {
const candidates = generateLocators("el_test", raw({}));
assert.equal(candidates[0].strategy, "role");
assert.equal(candidates[0].framework, "playwright");
assert.equal(candidates.some((candidate) => candidate.strategy === "testId"), true);
assert.equal(candidates.at(-1)?.strategy, "css");
});
test("locator generation keeps CSS as fallback for id and name", () => {
const candidates = generateLocators(
"el_input",
raw({
tag: "input",
role: "textbox",
accessibleName: "Query",
text: null,
label: "Query",
placeholder: "Search customers",
attributes: { id: "queryInput", name: "q" },
}),
);
assert.equal(candidates.some((candidate) => candidate.expression.includes("#queryInput")), true);
assert.equal(candidates.some((candidate) => candidate.expression.includes("[name=")), true);
});
- Step 5: Write integration tests that define the v0.2 behavioral baseline
Create tests/integration.test.ts:
import assert from "node:assert/strict";
import { test } from "node:test";
import path from "node:path";
import { exploreUrl, scanUrl } from "../src/engine.js";
const fixturePath = path.resolve("tests/fixtures/context-risk.html");
test("scan captures a state-local inventory without clicking high-risk actions", async () => {
const graph = await scanUrl({ url: fixturePath });
assert.equal(graph.states.length, 1);
assert.equal(graph.edges.length, 0);
assert.equal(graph.elements.some((element) => element.accessibleName === "Open settings"), true);
assert.equal(graph.elements.some((element) => element.accessibleName === "Delete account"), false);
});
test("explore records low-risk edges and keeps high-risk submit actions out of default execution", async () => {
const graph = await exploreUrl({ url: fixturePath, maxActions: 8 });
assert.equal(graph.edges.some((edge) => edge.riskLevel === "low" && edge.toStateId), true);
assert.equal(
graph.edges.some((edge) => {
const element = graph.elements.find((item) => item.elementId === edge.elementId);
return element?.accessibleName === "Delete account";
}),
false,
);
assert.equal(graph.states.length >= 2, true);
});
- Step 6: Run the new tests before implementation changes
Run:
npm run test
Expected: current API unit tests pass. Integration assertions that target later v0.2 fields are introduced in later tasks, so this first test batch should establish the fixture and baseline without requiring v0.2 fields yet. If the shell reports no matching test files, verify that the files above are saved under tests/.
- Step 7: Commit
git add package.json tests
git commit -m "test: add topology evidence baseline fixtures"
Task 2: Versioned Graph Contract and Metadata
Files:
-
Modify:
src/types.ts -
Create:
src/metadata.ts -
Modify:
src/engine.ts -
Modify:
tests/integration.test.ts -
Step 1: Add failing assertions for v0.2 metadata
Extend tests/integration.test.ts with this test:
test("graph metadata records generation context and policy versions", async () => {
const graph = await scanUrl({ url: fixturePath });
assert.equal(graph.schemaVersion, "0.2");
assert.equal(graph.metadata.engineVersion, "0.2.0");
assert.equal(graph.metadata.runtimeContext.browserName, "chromium");
assert.equal(graph.metadata.runtimeContext.viewport.width, 1440);
assert.equal(graph.metadata.runtimeContext.viewport.height, 1000);
assert.equal(graph.metadata.riskPolicyVersion, "risk-policy-v0.2");
assert.equal(graph.metadata.locatorScoringVersion, "locator-scoring-v0.2");
assert.equal(typeof graph.metadata.crawlSessionId, "string");
assert.equal(graph.metadata.inputUrl, fixturePath);
assert.equal(typeof graph.metadata.inputFingerprint, "string");
});
- Step 2: Update graph types
In src/types.ts, add these definitions near the top:
export const SCHEMA_VERSION = "0.2" as const;
export const ENGINE_VERSION = "0.2.0" as const;
export type GraphMode = "scan" | "explore";
export type EvidenceLevel = "observed" | "derived" | "verified" | "heuristic" | "external";
export type StateLifecycle = "observed" | "fingerprinted" | "verified" | "stale" | "deprecated" | "conflicted";
export interface EvidenceRecord {
level: EvidenceLevel;
source: string;
description: string;
}
export interface RuntimeContext {
browserName: "chromium";
viewport: { width: number; height: number };
locale: string;
timezoneId: string | null;
authContext: "anonymous" | "storage_state";
}
Update TopologyGraph metadata in src/types.ts:
export interface TopologyGraph {
schemaVersion: typeof SCHEMA_VERSION;
metadata: {
artifactVersion: string;
inputUrl: string;
entryUrl: string;
generatedAt: string;
engineVersion: typeof ENGINE_VERSION;
mode: GraphMode;
crawlSessionId: string;
inputSeed: string;
inputFingerprint: string;
runtimeContext: RuntimeContext;
site: {
baseUrl: string;
entryUrl: string;
};
riskPolicyVersion: "risk-policy-v0.2";
normalizationRuleVersion: "normalization-v0.2";
locatorScoringVersion: "locator-scoring-v0.2";
};
states: StateNode[];
elements: ActionableElement[];
edges: ActionEdge[];
skippedActions: SkippedAction[];
failedObservations: FailedObservation[];
}
Add temporary compatibility type definitions in src/types.ts; later tasks enrich their fields:
export interface SkippedAction {
skippedActionId: string;
stateId: string;
elementId: string;
actionType: ActionType;
riskLevel: "low" | "medium" | "high";
reason: string;
evidence: EvidenceRecord[];
createdAt: string;
}
export interface FailedObservation {
failedObservationId: string;
stateId: string;
elementId: string | null;
actionType: ActionType | null;
reason:
| "locator_not_found"
| "locator_not_unique"
| "element_hidden"
| "element_disabled"
| "element_obscured"
| "state_precondition_missing"
| "frame_inaccessible"
| "shadow_root_closed"
| "navigation_timeout"
| "network_error"
| "permission_denied"
| "auth_required"
| "captcha_or_challenge"
| "risk_policy_blocked"
| "effect_not_observed"
| "unexpected_state";
message: string;
evidence: EvidenceRecord[];
createdAt: string;
}
- Step 3: Create metadata builder
Create src/metadata.ts:
import { ENGINE_VERSION, SCHEMA_VERSION, type GraphMode, type RuntimeContext, type TopologyGraph } from "./types.js";
import { shortHash } from "./hash.js";
const DEFAULT_VIEWPORT = { width: 1440, height: 1000 } as const;
export function defaultViewport(): { width: number; height: number } {
return { ...DEFAULT_VIEWPORT };
}
export function buildRuntimeContext(storageState?: string): RuntimeContext {
return {
browserName: "chromium",
viewport: defaultViewport(),
locale: "en-US",
timezoneId: null,
authContext: storageState ? "storage_state" : "anonymous",
};
}
export function buildEmptyGraph(args: {
inputUrl: string;
entryUrl: string;
mode: GraphMode;
storageState?: string;
}): TopologyGraph {
const generatedAt = new Date().toISOString();
const crawlSessionId = `crawl_${shortHash([args.entryUrl, args.mode, generatedAt])}`;
const inputFingerprint = `input_${shortHash([
args.inputUrl,
args.mode,
"risk-policy-v0.2",
"normalization-v0.2",
"locator-scoring-v0.2",
])}`;
return {
schemaVersion: SCHEMA_VERSION,
metadata: {
artifactVersion: "topology-graph-v0.2",
inputUrl: args.inputUrl,
entryUrl: args.entryUrl,
generatedAt,
engineVersion: ENGINE_VERSION,
mode: args.mode,
crawlSessionId,
inputSeed: args.inputUrl,
inputFingerprint,
runtimeContext: buildRuntimeContext(args.storageState),
site: {
baseUrl: baseUrl(args.entryUrl),
entryUrl: args.entryUrl,
},
riskPolicyVersion: "risk-policy-v0.2",
normalizationRuleVersion: "normalization-v0.2",
locatorScoringVersion: "locator-scoring-v0.2",
},
states: [],
elements: [],
edges: [],
skippedActions: [],
failedObservations: [],
};
}
function baseUrl(value: string): string {
try {
const url = new URL(value);
if (url.protocol === "file:") return url.protocol;
return `${url.protocol}//${url.host}`;
} catch {
return "unknown";
}
}
- Step 4: Use metadata builder in
engine.ts
In src/engine.ts, import:
import { buildEmptyGraph, defaultViewport } from "./metadata.js";
Replace inline TopologyGraph construction in scanUrl:
const graph = buildEmptyGraph({
inputUrl: options.url,
entryUrl: page.url(),
mode: "scan",
storageState: options.storageState,
});
graph.states.push(snapshot.state);
graph.elements.push(...snapshot.elements);
Replace inline TopologyGraph construction in exploreUrl:
const graph = buildEmptyGraph({
inputUrl: options.url,
entryUrl: page.url(),
mode: "explore",
storageState: options.storageState,
});
graph.states.push(entrySnapshot.state);
graph.elements.push(...entrySnapshot.elements);
Update newContext viewport:
viewport: defaultViewport(),
- Step 5: Run focused verification
Run:
npm run test
npm run typecheck
Expected: tests pass and TypeScript reports no errors.
- Step 6: Commit
git add src/types.ts src/metadata.ts src/engine.ts tests/integration.test.ts
git commit -m "feat: version topology graph metadata"
Task 3: State and Element Evidence Capture
Files:
-
Modify:
src/types.ts -
Modify:
src/snapshot.ts -
Modify:
tests/integration.test.ts -
Step 1: Add failing assertions for state and element evidence
Extend tests/integration.test.ts:
test("scan records state lifecycle, language, context anchors, and parameter surfaces", async () => {
const graph = await scanUrl({ url: fixturePath });
const state = graph.states[0];
assert.equal(state.lifecycle, "fingerprinted");
assert.equal(state.language, "en");
assert.equal(state.viewport.width, 1440);
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");
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);
});
- Step 2: Extend types for context and parameters
Add to src/types.ts:
export interface ContextAnchor {
kind: "dialog" | "form" | "section" | "row" | "landmark" | "listitem";
name: string | null;
text: string | null;
}
export interface ElementContext {
dialog: ContextAnchor | null;
form: ContextAnchor | null;
section: ContextAnchor | null;
row: ContextAnchor | null;
landmark: ContextAnchor | null;
listitem: ContextAnchor | null;
}
export interface ParameterSurface {
name: string | null;
inputType: string | null;
required: boolean;
readonly: boolean;
disabled: boolean;
pattern: string | null;
min: string | null;
max: string | null;
maxLength: number | null;
options: Array<{ label: string; value: string; disabled: boolean; selected: boolean }>;
}
Extend StateNode:
lifecycle: StateLifecycle;
urlCanonicalKey: string;
language: string | null;
viewport: { width: number; height: number };
evidence: EvidenceRecord[];
Extend ActionableElement:
context: ElementContext;
parameterSurface: ParameterSurface | null;
evidence: EvidenceRecord[];
Extend RawActionableElement with the same context, parameterSurface, and evidence fields.
- Step 3: Update test object factories for the new required fields
In tests/risk.test.ts, add these default fields to the object returned by element() before ...overrides:
context: {
dialog: null,
form: null,
section: null,
row: null,
landmark: null,
listitem: null,
},
parameterSurface: null,
evidence: [],
In tests/locator.test.ts, add these default fields to the object returned by raw() before ...overrides:
context: {
dialog: null,
form: null,
section: null,
row: null,
landmark: null,
listitem: null,
},
parameterSurface: null,
evidence: [],
- Step 4: Populate state evidence in
snapshotPage
Update the type import at the top of src/snapshot.ts:
import type {
ActionableElement,
ContextAnchor,
ElementContext,
RawActionableElement,
SnapshotResult,
StateNode,
} from "./types.js";
In snapshotPage, set the new StateNode fields:
const viewport = raw.viewport;
const state: StateNode = {
stateId: `state_${shortHash(stateHash)}`,
url: raw.url,
route: raw.route,
title: raw.title,
frameContext: [],
stateHash,
domHash: stableHash(raw.dom),
visibleTextHash: stableHash(raw.visibleText),
actionableSignatureHash: stableHash(raw.elements.map(actionableSignature)),
modalStack: raw.modalStack,
openedByEdgeId,
createdAt,
lifecycle: "fingerprinted",
urlCanonicalKey: raw.urlCanonicalKey,
language: raw.language,
viewport,
evidence: [
{ level: "observed", source: "browser.location", description: "URL and route captured from the page" },
{ level: "derived", source: "snapshot.hash", description: "State hashes derived from DOM, visible text, and actionable signatures" },
],
};
- Step 5: Extend browser snapshot shape
In src/snapshot.ts, extend BrowserSnapshot:
interface BrowserSnapshot {
url: string;
route: string;
title: string;
dom: string;
visibleText: string;
modalStack: string[];
language: string | null;
viewport: { width: number; height: number };
urlCanonicalKey: string;
elements: RawActionableElement[];
}
In collectPageSnapshot, return:
language: normalizeNullable(document.documentElement.lang || navigator.language || ""),
viewport: { width: window.innerWidth, height: window.innerHeight },
urlCanonicalKey: canonicalUrl(location.href),
Add browser-side helper:
function canonicalUrl(value: string): string {
const url = new URL(value);
url.hash = "";
const params = Array.from(url.searchParams.entries())
.filter(([key]) => !/^utm_|^fbclid$|^gclid$/.test(key))
.sort(([left], [right]) => left.localeCompare(right));
url.search = "";
for (const [key, item] of params) url.searchParams.append(key, item);
return url.toString();
}
- Step 6: Populate element context and parameter surface
In toRawElement, add:
const context = getElementContext(element);
const parameterSurface = getParameterSurface(element);
Return these fields:
context,
parameterSurface,
evidence: [
{ level: "observed", source: "dom", description: "Element was visible and matched actionable selector" },
{ level: "derived", source: "accessibility", description: "Role, name, label, and placeholder were normalized from DOM evidence" },
],
Add browser-side helpers in collectPageSnapshot:
function getElementContext(element: HTMLElement): ElementContext {
return {
dialog: anchor(element.closest<HTMLElement>("[role='dialog'], dialog[open], [aria-modal='true']"), "dialog"),
form: anchor(element.closest<HTMLElement>("form"), "form"),
section: anchor(element.closest<HTMLElement>("section, article, main"), "section"),
row: anchor(element.closest<HTMLElement>("tr, [role='row']"), "row"),
landmark: anchor(element.closest<HTMLElement>("nav, aside, header, footer, main, [role='navigation'], [role='main'], [role='complementary']"), "landmark"),
listitem: anchor(element.closest<HTMLElement>("li, [role='listitem']"), "listitem"),
};
}
function anchor(element: HTMLElement | null, kind: ContextAnchor["kind"]): ContextAnchor | null {
if (!element) return null;
return {
kind,
name: getAccessibleName(element) || element.getAttribute("aria-label") || element.id || null,
text: normalizeNullable((element.innerText || element.textContent || "").slice(0, 240)),
};
}
function getParameterSurface(element: HTMLElement): ParameterSurface | null {
const tag = element.tagName.toLowerCase();
if (!["input", "select", "textarea"].includes(tag) && !element.isContentEditable) return null;
const input = element as HTMLInputElement;
const select = element as HTMLSelectElement;
return {
name: input.name || element.getAttribute("name"),
inputType: tag === "input" ? (input.type || "text").toLowerCase() : tag,
required: Boolean((input as HTMLInputElement).required),
readonly: Boolean((input as HTMLInputElement).readOnly),
disabled: Boolean((input as HTMLInputElement).disabled),
pattern: input.getAttribute("pattern"),
min: input.getAttribute("min"),
max: input.getAttribute("max"),
maxLength: Number.isFinite(input.maxLength) && input.maxLength >= 0 ? input.maxLength : null,
options:
tag === "select"
? Array.from(select.options).map((option) => ({
label: normalizeText(option.label || option.textContent || ""),
value: option.value,
disabled: option.disabled,
selected: option.selected,
}))
: [],
};
}
- Step 7: Generate state-local element IDs and carry new raw fields into
ActionableElement
Replace the current elementId assignment with a state-local ID that cannot collapse elements across states:
const elementId = `el_${shortHash([
state.stateId,
rawElement.uid,
rawElement.tag,
rawElement.role,
rawElement.accessibleName,
rawElement.context.dialog?.name,
rawElement.context.form?.name,
rawElement.context.row?.text,
rawElement.attributes["data-testid"] || rawElement.attributes["data-test-id"] || rawElement.attributes["data-test"],
])}`;
The state.stateId entry is required. Do not replace it with a DOM-only selector or a global ordinal.
In the elements.push block inside snapshotPage, add:
context: rawElement.context,
parameterSurface: rawElement.parameterSurface,
evidence: rawElement.evidence,
- Step 8: Run verification
Run:
npm run test
npm run typecheck
Expected: all tests pass and the generated graph contains state lifecycle and element context fields.
- Step 9: Commit
git add src/types.ts src/snapshot.ts tests/risk.test.ts tests/locator.test.ts tests/integration.test.ts
git commit -m "feat: capture state and element evidence"
Task 4: Context Evidence for Locator Ranking and Identity Verification
Files:
-
Modify:
src/types.ts -
Modify:
src/locator.ts -
Modify:
tests/locator.test.ts -
Modify:
tests/integration.test.ts -
Step 1: Add failing locator context assertions
Extend tests/locator.test.ts:
test("locator candidates carry context-scope evidence for duplicated actions", () => {
const candidates = generateLocators(
"el_edit",
raw({
uid: "button_4_Edit",
tag: "button",
role: "button",
accessibleName: "Edit",
text: "Edit",
attributes: {},
context: {
dialog: null,
form: null,
section: { kind: "section", name: "Customers", text: "Customers Acme Co Active Edit" },
row: { kind: "row", name: null, text: "Acme Co Active Edit" },
landmark: null,
listitem: null,
},
}),
);
assert.equal(candidates[0].scopes.some((scope) => scope.kind === "row"), true);
});
Extend tests/integration.test.ts:
test("duplicated row actions are verified with semantic locator evidence", async () => {
const graph = await scanUrl({ url: fixturePath });
const editButtons = graph.elements.filter((element) => element.accessibleName === "Edit");
assert.equal(editButtons.length, 2);
for (const edit of editButtons) {
assert.equal(edit.locators.some((locator) => locator.verified && locator.identity?.sameAccessibleName), true);
assert.equal(edit.locators.some((locator) => locator.scopes.some((scope) => scope.kind === "row")), true);
}
});
This task adds scope evidence to locator candidates. It does not compile row/form/dialog scopes into executable scoped locators in v0.2.1.
- Step 2: Extend locator candidate types
In src/types.ts, add:
export interface LocatorScopeDescriptor {
kind: ContextAnchor["kind"];
name: string | null;
text: string | null;
}
export interface LocatorIdentityEvidence {
sameTag: boolean;
sameRole: boolean;
sameAccessibleName: boolean;
sameText: boolean;
sameVisibility: boolean;
}
Extend LocatorCandidate:
scopes: LocatorScopeDescriptor[];
identity: LocatorIdentityEvidence | null;
evidence: EvidenceRecord[];
- Step 3: Add context-scope evidence to generated locators
In generateLocators, define:
const scopes = contextScopes(raw);
Add scopes and evidence to every candidate object before locatorId is added:
scopes,
evidence: [
{ level: "derived", source: "locator.generator", description: "Locator candidate generated from state-local element semantics" },
],
Add helper:
function contextScopes(raw: RawActionableElement): LocatorScopeDescriptor[] {
const contexts = [raw.context.dialog, raw.context.row, raw.context.form, raw.context.section, raw.context.landmark, raw.context.listitem];
return contexts
.filter((context): context is LocatorScopeDescriptor => Boolean(context && (context.name || context.text)))
.map((context) => ({ kind: context.kind, name: clean(context.name) ?? null, text: clean(context.text) ?? null }));
}
When mapping final candidates, set:
identity: null,
- Step 4: Verify locator identity, not only count
Change verifyLocators signature:
export async function verifyLocators(page: Page, raw: RawActionableElement, locators: LocatorCandidate[]): Promise<LocatorCandidate[]> {
Update caller in snapshotPage:
const locators = await verifyLocators(page, rawElement, generateLocators(elementId, rawElement));
In verifyLocators, after matchCount, evaluate semantic identity when matchCount >= 1. Use the same normalization rules as snapshot.ts: explicit ARIA label, aria-labelledby, associated label, title/alt, normalized visible text, and inferred native role.
const resolved = resolveLocator(page, locator.descriptor).first();
const identity = matchCount > 0 ? await resolved.evaluate((element, expected) => {
const html = element as HTMLElement;
const tag = html.tagName.toLowerCase();
const text = normalizeNullable(html.innerText || html.textContent || "");
const accessibleName = getAccessibleName(html);
const inferredRole = html.getAttribute("role") || inferRole(html);
const visible = isVisible(html);
return {
sameTag: tag === expected.tag,
sameRole: expected.role ? inferredRole === expected.role : true,
sameAccessibleName: expected.accessibleName ? accessibleName === expected.accessibleName : true,
sameText: expected.text ? text === expected.text : true,
sameVisibility: visible === expected.visible,
};
function inferRole(item: HTMLElement): string | null {
const itemTag = item.tagName.toLowerCase();
if (itemTag === "button") return "button";
if (itemTag === "a" && item.hasAttribute("href")) return "link";
if (itemTag === "select") return "combobox";
if (itemTag === "textarea") return "textbox";
if (itemTag === "input") {
const type = ((item as HTMLInputElement).type || "text").toLowerCase();
if (["button", "submit", "reset"].includes(type)) return "button";
if (type === "checkbox") return "checkbox";
if (type === "radio") return "radio";
return "textbox";
}
return null;
}
function getAccessibleName(item: HTMLElement): string | null {
const ariaLabel = item.getAttribute("aria-label");
if (ariaLabel) return normalizeNullable(ariaLabel);
const labelledBy = item.getAttribute("aria-labelledby");
if (labelledBy) {
const labelledText = labelledBy
.split(/\s+/)
.map((id) => document.getElementById(id)?.innerText || document.getElementById(id)?.textContent || "")
.join(" ");
if (labelledText.trim()) return normalizeNullable(labelledText);
}
const label = getLabel(item);
if (label) return label;
const title = item.getAttribute("title");
if (title) return normalizeNullable(title);
const alt = item.getAttribute("alt");
if (alt) return normalizeNullable(alt);
return normalizeNullable(item.innerText || item.textContent || "");
}
function getLabel(item: HTMLElement): string | null {
const id = item.getAttribute("id");
if (id) {
const explicit = document.querySelector<HTMLLabelElement>(`label[for="${CSS.escape(id)}"]`);
if (explicit) return normalizeNullable(explicit.innerText || explicit.textContent || "");
}
const implicit = item.closest("label");
if (implicit) return normalizeNullable(implicit.innerText || implicit.textContent || "");
return null;
}
function isVisible(item: HTMLElement): boolean {
const style = getComputedStyle(item);
if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity) === 0) return false;
const rect = item.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function normalizeText(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function normalizeNullable(value: string): string | null {
const normalized = normalizeText(value);
return normalized || null;
}
}, {
tag: raw.tag,
role: raw.role,
accessibleName: raw.accessibleName,
text: raw.text,
visible: raw.visibility.visible,
}) : null;
Compute verification:
const identityMatches = identity ? Object.values(identity).every(Boolean) : false;
const verified = matchCount === 1 && identityMatches;
Update score:
score: verified ? locator.score : Math.max(0.1, locator.score - (matchCount === 1 ? 0.2 : 0.35)),
identity,
evidence: [
...locator.evidence,
{ level: verified ? "verified" : "observed", source: "locator.verify", description: `Locator matched ${matchCount} element(s)` },
],
- Step 5: Run verification
Run:
npm run test
npm run typecheck
Expected: duplicated Edit elements have context scopes, and verified locators require both uniqueness and semantic identity.
- Step 6: Commit
git add src/types.ts src/locator.ts tests/locator.test.ts tests/integration.test.ts
git commit -m "feat: verify locators with context evidence"
Task 5: Risk Policy and State-Local Skip Records
Files:
-
Modify:
src/types.ts -
Modify:
src/risk.ts -
Modify:
src/engine.ts -
Modify:
tests/risk.test.ts -
Modify:
tests/integration.test.ts -
Step 1: Add failing tests for risk policy decisions and skip records
Extend tests/risk.test.ts:
import { classifyActionRisk, defaultExploreDecision } from "../src/risk.js";
test("risk policy exposes category, level, and reasons", () => {
const decision = defaultExploreDecision(element({ accessibleName: "Authorize payment" }), "click");
assert.equal(decision.allowed, false);
assert.equal(decision.risk.category, "payment_sensitive");
assert.equal(decision.risk.level, "high");
assert.equal(decision.reason.includes("payment_sensitive"), true);
});
test("risk policy treats UI reveal as default explorable", () => {
const risk = classifyActionRisk(element({ accessibleName: "Open settings" }), "click");
assert.equal(risk.category, "ui_reveal");
assert.equal(defaultExploreDecision(element({ accessibleName: "Open settings" }), "click").allowed, true);
});
Extend tests/integration.test.ts:
test("explore records state-local skipped high-risk actions with evidence", async () => {
const graph = await exploreUrl({ url: fixturePath, maxActions: 8 });
assert.equal(graph.skippedActions.some((skip) => skip.reason.includes("form_submit")), true);
const deleteElement = graph.elements.find((element) => element.accessibleName === "Delete account");
assert.ok(deleteElement);
const deleteSkip = graph.skippedActions.find((skip) => skip.elementId === deleteElement.elementId);
assert.ok(deleteSkip);
assert.equal(deleteSkip.stateId, deleteElement.stateId);
assert.equal(deleteSkip.riskCategory, "destructive");
assert.equal(deleteSkip.riskLevel, "high");
assert.equal(graph.skippedActions.some((skip) => skip.reason === "allowed:ui_reveal"), false);
assert.equal(graph.failedObservations.every((failure) => failure.message.length > 0), true);
});
test("disabled low-risk controls are failed observations, not risk skips", async () => {
const graph = await exploreUrl({ url: fixturePath, maxActions: 8 });
const disabled = graph.elements.find((element) => element.accessibleName === "Open disabled panel");
assert.ok(disabled);
assert.equal(graph.skippedActions.some((skip) => skip.elementId === disabled.elementId), false);
assert.equal(
graph.failedObservations.some(
(failure) => failure.elementId === disabled.elementId && failure.reason === "element_disabled",
),
true,
);
});
- Step 2: Add risk types
In src/types.ts, add:
export type RiskLevel = "low" | "medium" | "high";
export type RiskCategory =
| "read_only"
| "navigation"
| "ui_reveal"
| "input_edit"
| "form_submit"
| "data_mutation"
| "destructive"
| "auth_sensitive"
| "payment_sensitive"
| "unknown";
export interface RiskClassification {
level: RiskLevel;
category: RiskCategory;
reasons: string[];
evidence: EvidenceRecord[];
}
export interface RiskPolicyDecision {
allowed: boolean;
risk: RiskClassification;
reason: string;
}
Update ActionEdge.riskLevel and SkippedAction.riskLevel to use RiskLevel, and add riskCategory: RiskCategory to both.
- Step 3: Implement richer risk classification
Replace src/risk.ts internals with this API while keeping classifyClickRisk and isLowRiskClickable exports:
import type { ActionType, ActionableElement, RiskClassification, RiskPolicyDecision } from "./types.js";
const HIGH_RISK_TERMS = [
"delete", "remove", "destroy", "drop", "logout", "sign out",
"pay", "payment", "purchase", "checkout",
"submit", "confirm", "authorize",
"删除", "移除", "注销", "退出", "支付", "购买", "提交", "确认", "授权",
];
const DESTRUCTIVE_TERMS = ["delete", "remove", "destroy", "drop", "删除", "移除"];
const PAYMENT_TERMS = ["pay", "payment", "purchase", "checkout", "支付", "购买"];
const AUTH_TERMS = ["authorize", "login", "logout", "sign out", "授权", "注销", "退出"];
const SUBMIT_TERMS = ["submit", "confirm", "提交", "确认"];
export function classifyActionRisk(element: ActionableElement, actionType: ActionType): RiskClassification {
const joined = riskText(element);
const reasons: string[] = [];
if (containsAny(joined, DESTRUCTIVE_TERMS)) {
reasons.push("matched destructive action term");
return risk("high", "destructive", reasons);
}
if (containsAny(joined, PAYMENT_TERMS)) {
reasons.push("matched payment-sensitive action term");
return risk("high", "payment_sensitive", reasons);
}
if (containsAny(joined, AUTH_TERMS)) {
reasons.push("matched auth-sensitive action term");
return risk("high", "auth_sensitive", reasons);
}
if (containsAny(joined, SUBMIT_TERMS) || element.attributes.type === "submit") {
reasons.push("matched form submission action term or submit type");
return risk("high", "form_submit", reasons);
}
if (actionType === "fill" || element.tag === "input" || element.tag === "textarea" || element.tag === "select") {
reasons.push("editable control captured but not default-explored");
return risk("medium", "input_edit", reasons);
}
if (element.tag === "a" || element.role === "link" || element.attributes.href) {
reasons.push("link or href navigation");
return risk("low", "navigation", reasons);
}
if (element.role === "button" || element.role === "tab" || element.role === "menuitem" || element.attributes["aria-haspopup"] || element.attributes["aria-expanded"]) {
reasons.push("button-like UI reveal candidate");
return risk("low", "ui_reveal", reasons);
}
if (HIGH_RISK_TERMS.some((term) => joined.includes(term.toLowerCase()))) {
reasons.push("matched high-risk fallback term");
return risk("high", "unknown", reasons);
}
reasons.push("no low-risk semantic evidence");
return risk("high", "unknown", reasons);
}
export function defaultExploreDecision(element: ActionableElement, actionType: ActionType): RiskPolicyDecision {
const risk = classifyActionRisk(element, actionType);
const allowed = risk.level === "low" && ["navigation", "ui_reveal", "read_only"].includes(risk.category);
return {
allowed,
risk,
reason: allowed ? `allowed:${risk.category}` : `blocked:${risk.category}`,
};
}
export function classifyClickRisk(element: ActionableElement): "low" | "medium" | "high" {
return classifyActionRisk(element, "click").level;
}
export function isLowRiskClickable(element: ActionableElement): boolean {
if (!element.interactability.visible || !element.interactability.enabled || !element.interactability.receivesEvents) return false;
const role = element.role;
const clickable = element.tag === "button" || element.tag === "a" || role === "button" || role === "link" || role === "tab" || role === "menuitem";
return clickable && defaultExploreDecision(element, "click").allowed;
}
function risk(level: RiskClassification["level"], category: RiskClassification["category"], reasons: string[]): RiskClassification {
return {
level,
category,
reasons,
evidence: [{ level: "heuristic", source: "risk.policy", description: reasons.join("; ") }],
};
}
function riskText(element: ActionableElement): string {
return [element.accessibleName, element.text, element.label, element.placeholder, element.attributes.href, element.attributes.type]
.filter(Boolean)
.join(" ")
.toLowerCase();
}
function containsAny(value: string, terms: string[]): boolean {
return terms.some((term) => value.includes(term.toLowerCase()));
}
- Step 4: Record state-local risk skips and actionability failures in
exploreUrl
In src/engine.ts, import:
import { defaultExploreDecision } from "./risk.js";
import type { ActionableElement, SnapshotResult } from "./types.js";
Add these helpers in src/engine.ts:
function recordSkippedActionsForState(graph: TopologyGraph, snapshot: SnapshotResult): void {
for (const element of snapshot.elements) {
const decision = defaultExploreDecision(element, "click");
if (decision.allowed) continue;
graph.skippedActions = upsertById(
graph.skippedActions,
{
skippedActionId: `skip_${shortHash([snapshot.state.stateId, element.elementId, "click", decision.reason])}`,
stateId: snapshot.state.stateId,
elementId: element.elementId,
actionType: "click",
riskLevel: decision.risk.level,
riskCategory: decision.risk.category,
reason: decision.reason,
evidence: decision.risk.evidence,
createdAt: new Date().toISOString(),
},
"skippedActionId",
);
}
}
function recordActionabilityFailure(graph: TopologyGraph, snapshot: SnapshotResult, element: ActionableElement): void {
const reason = !element.interactability.enabled
? "element_disabled"
: !element.interactability.visible
? "element_hidden"
: "element_obscured";
graph.failedObservations = upsertById(
graph.failedObservations,
{
failedObservationId: `fail_${shortHash([snapshot.state.stateId, element.elementId, "click", reason])}`,
stateId: snapshot.state.stateId,
elementId: element.elementId,
actionType: "click",
reason,
message: `Element is not actionable for default click exploration: ${reason}`,
evidence: [{ level: "observed", source: "element.interactability", description: reason }],
createdAt: new Date().toISOString(),
},
"failedObservationId",
);
}
function isActionableForClick(element: ActionableElement): boolean {
const role = element.role;
const clickable = element.tag === "button" || element.tag === "a" || role === "button" || role === "link" || role === "tab" || role === "menuitem";
return clickable && element.interactability.visible && element.interactability.enabled && element.interactability.receivesEvents;
}
Replace candidate selection:
recordSkippedActionsForState(graph, entrySnapshot);
const lowRiskCandidates: ActionableElement[] = [];
for (const element of entrySnapshot.elements) {
const decision = defaultExploreDecision(element, "click");
if (!decision.allowed) continue;
if (!isActionableForClick(element)) {
recordActionabilityFailure(graph, entrySnapshot, element);
continue;
}
lowRiskCandidates.push(element);
}
const candidates = lowRiskCandidates.slice(0, options.maxActions ?? 10);
Do not insert allowed:* decisions into skippedActions. A SkippedAction means the risk policy blocked execution.
Update edge risk fields:
const riskDecision = defaultExploreDecision(element, "click");
Set on edge:
riskLevel: riskDecision.risk.level,
riskCategory: riskDecision.risk.category,
- Step 5: Record risk skips for newly discovered states and failed observations for action errors
After every successful afterSnapshot, call:
recordSkippedActionsForState(graph, afterSnapshot);
This records Delete account as a destructive skip only in the dialog-open state where it is visible.
In the catch block around click execution, add:
graph.failedObservations.push({
failedObservationId: `fail_${shortHash([entrySnapshot.state.stateId, element.elementId, String(error)])}`,
stateId: entrySnapshot.state.stateId,
elementId: element.elementId,
actionType: "click",
reason: "unexpected_state",
message: error instanceof Error ? error.message : String(error),
evidence: [{ level: "observed", source: "playwright.click", description: "Action execution failed during default exploration" }],
createdAt: new Date().toISOString(),
});
- Step 6: Run verification
Run:
npm run test
npm run typecheck
npm run example
Expected: tests pass, example still avoids Delete account, and graph JSON includes skippedActions.
- Step 7: Commit
git add src/types.ts src/risk.ts src/engine.ts tests/risk.test.ts tests/integration.test.ts
git commit -m "feat: record risk policy decisions"
Task 6: Replay-Based Exploration and Effect Records
Files:
-
Create:
src/effects.ts -
Modify:
src/engine.ts -
Modify:
src/types.ts -
Modify:
tests/integration.test.ts -
Step 1: Add failing tests for edge preconditions and effects
Extend tests/integration.test.ts:
test("explore records edge effects and opened-by state relationship", async () => {
const graph = await exploreUrl({ url: fixturePath, maxActions: 8 });
const openSettingsEdge = graph.edges.find((edge) => {
const element = graph.elements.find((candidate) => candidate.elementId === edge.elementId);
return element?.accessibleName === "Open settings";
});
assert.ok(openSettingsEdge);
assert.equal(openSettingsEdge.effects.some((effect) => effect.type === "visibleText" || effect.type === "dom"), true);
assert.equal(graph.states.some((state) => state.openedByEdgeId === openSettingsEdge.edgeId), true);
});
test("each v0.2.1 explored edge is replayed from the entry state", async () => {
const graph = await exploreUrl({ url: fixturePath, maxActions: 8 });
const entryStateId = graph.states[0].stateId;
for (const edge of graph.edges) {
assert.equal(edge.fromStateId, entryStateId);
}
});
- Step 2: Add effect helper
Create src/effects.ts:
import { shortHash } from "./hash.js";
import type { EffectRecord, SnapshotResult } from "./types.js";
export function diffSnapshotEffects(args: {
beforeUrl: string;
afterUrl: string;
beforeSnapshot: SnapshotResult;
afterSnapshot: SnapshotResult;
}): EffectRecord[] {
const effects: EffectRecord[] = [];
if (args.beforeUrl !== args.afterUrl) {
effects.push({ type: "url", before: args.beforeUrl, after: args.afterUrl, description: "URL changed after action" });
}
if (args.beforeSnapshot.state.domHash !== args.afterSnapshot.state.domHash) {
effects.push({
type: "dom",
before: args.beforeSnapshot.state.domHash,
after: args.afterSnapshot.state.domHash,
description: "DOM hash changed after action",
});
}
if (shortHash(args.beforeSnapshot.visibleText) !== shortHash(args.afterSnapshot.visibleText)) {
effects.push({
type: "visibleText",
before: shortHash(args.beforeSnapshot.visibleText),
after: shortHash(args.afterSnapshot.visibleText),
description: "Visible text changed after action",
});
}
if (args.beforeSnapshot.state.actionableSignatureHash !== args.afterSnapshot.state.actionableSignatureHash) {
effects.push({
type: "dom",
before: args.beforeSnapshot.state.actionableSignatureHash,
after: args.afterSnapshot.state.actionableSignatureHash,
description: "Actionable inventory changed after action",
});
}
return effects;
}
- Step 3: Create a clean replay page for every candidate
In src/engine.ts, add this helper:
async function createReplayPage(context: BrowserContext, entryUrl: string): Promise<Page> {
const page = await context.newPage();
await page.goto(entryUrl, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle").catch(() => undefined);
return page;
}
Inside the candidate loop, create a fresh page for the candidate and snapshot the declared from-state before acting:
const actionPage = await createReplayPage(context, entryUrl);
const beforeUrl = actionPage.url();
const beforeSnapshot = await snapshotPage(actionPage);
const edgeId = `edge_${shortHash([beforeSnapshot.state.stateId, element.elementId, "click"])}`;
Every candidate must use a new actionPage. Do not reuse a page mutated by a previous candidate.
- Step 4: Use replay-safe edge IDs before clicking
Pass the edge id into the after snapshot:
const afterSnapshot = await snapshotPage(actionPage, edgeId);
Set openedByEdgeId via snapshotPage, keep toStateId = afterSnapshot.state.stateId, and set the edge source to the actual pre-action state:
fromStateId: beforeSnapshot.state.stateId,
After the successful afterSnapshot, record state-local high-risk skips for the newly discovered state without recursively exploring that state:
recordSkippedActionsForState(graph, afterSnapshot);
- Step 5: Replace local diff helper with
diffSnapshotEffects
In src/engine.ts, import:
import { diffSnapshotEffects } from "./effects.js";
Replace:
effects.push(...diffEffects(beforeUrl, beforeSnapshot.visibleText, actionPage.url(), afterSnapshot.visibleText));
with:
effects.push(
...diffSnapshotEffects({
beforeUrl,
afterUrl: actionPage.url(),
beforeSnapshot,
afterSnapshot,
}),
);
Remove the old diffEffects function from engine.ts.
- Step 6: Add trial actionability check before real click
Before real click:
const target = resolveLocator(actionPage, locator.descriptor).first();
await target.click({ timeout: 2000, trial: true });
await target.click({ timeout: 2000 });
If trial fails, the existing catch block records a failed observation without mutating the page.
- Step 7: Dedupe graph elements by state-local ID
Add helper in engine.ts:
function upsertManyById<T extends Record<K, string>, K extends keyof T>(items: T[], incoming: T[], key: K): T[] {
return incoming.reduce((current, item) => upsertById(current, item, key), items);
}
Replace:
graph.elements.push(...afterSnapshot.elements);
with:
graph.elements = upsertManyById(graph.elements, afterSnapshot.elements, "elementId");
This is safe only because Task 3 made elementId state-local. Do not upsert elements across states unless their elementId already encodes state identity.
- Step 8: Run verification
Run:
npm run test
npm run typecheck
npm run build
npm run example
Expected: tests pass, build succeeds, example emits states with openedByEdgeId for first-observed action-created states, and every v0.2.1 edge starts from the entry state.
- Step 9: Commit
git add src/effects.ts src/engine.ts src/types.ts tests/integration.test.ts
git commit -m "feat: verify explored edges by replay"
Task 7: Schema, README, and Artifact Sanity Checks
Files:
-
Modify:
schemas/topology-graph.schema.md -
Modify:
README.md -
Modify:
tests/integration.test.ts -
Step 1: Add artifact sanity assertions
Extend tests/integration.test.ts:
test("explore artifact keeps all referenced states and elements resolvable", async () => {
const graph = await exploreUrl({ url: fixturePath, maxActions: 8 });
const stateIds = new Set(graph.states.map((state) => state.stateId));
const elementIds = new Set(graph.elements.map((element) => element.elementId));
for (const edge of graph.edges) {
assert.equal(stateIds.has(edge.fromStateId), true);
if (edge.toStateId) assert.equal(stateIds.has(edge.toStateId), true);
assert.equal(elementIds.has(edge.elementId), true);
}
for (const skip of graph.skippedActions) {
assert.equal(stateIds.has(skip.stateId), true);
assert.equal(elementIds.has(skip.elementId), true);
}
});
- Step 2: Update schema document
Update schemas/topology-graph.schema.md so the top section says:
# Topology Graph Schema
This document describes the JSON shape emitted by the engine. It is a practical schema note, not yet a formal JSON Schema file.
## `TopologyGraph`
- `schemaVersion`: currently `0.2`.
- `metadata.artifactVersion`: currently `topology-graph-v0.2`.
- `metadata.inputUrl`: raw URL or file path supplied by the caller.
- `metadata.entryUrl`: entry URL after Playwright navigation.
- `metadata.mode`: `scan` or `explore`.
- `metadata.crawlSessionId`: unique identifier for this graph generation session.
- `metadata.inputFingerprint`: deterministic hash of input URL, mode, and policy/scoring versions.
- `metadata.runtimeContext`: browser engine, viewport, locale, timezone, and auth context.
- `metadata.site`: base URL and entry URL.
- `metadata.riskPolicyVersion`: currently `risk-policy-v0.2`.
- `metadata.normalizationRuleVersion`: currently `normalization-v0.2`.
- `metadata.locatorScoringVersion`: currently `locator-scoring-v0.2`.
- `states`: page states.
- `elements`: actionable elements bound to a state.
- `edges`: observed actions from one state to another.
- `skippedActions`: state-local actions that default exploration refused to execute because risk policy blocked them.
- `failedObservations`: failed locator/action/effect observations recorded as evidence.
Add sections for:
## Evidence Levels
- `observed`: directly captured browser fact.
- `derived`: deterministic rule output from observed facts.
- `verified`: re-resolved or replay-verified claim.
- `heuristic`: conservative inference such as risk classification.
- `external`: human or downstream annotation.
## `StateNode`
- `stateId`: stable id derived from URL, normalized state hashes, and actionable signatures.
- `lifecycle`: `observed`, `fingerprinted`, `verified`, `stale`, `deprecated`, or `conflicted`.
- `url`, `route`, `urlCanonicalKey`, `title`, `language`, `viewport`.
- `stateHash`, `domHash`, `visibleTextHash`, `actionableSignatureHash`.
- `modalStack`, `frameContext`.
- `openedByEdgeId`: first edge that observed this state in the current run; not the only possible predecessor.
- `evidence`: observed and derived state facts.
## `ActionableElement`
- `elementId`: state-local element id. It must encode state identity and must not be treated as a global DOM node id.
- `stateId`: owning state.
- `tag`, `role`, `accessibleName`, `text`, `label`, `placeholder`.
- `attributes`: selected stable attributes such as test id, id, name, type, href, and ARIA fields.
- `bbox`, `framePath`, `shadowPath`, `interactability`.
- `context`: nearest dialog, form, section, row, landmark, and list item anchors.
- `parameterSurface`: input/select/textarea constraints, options, required state, pattern, min/max, and maxLength.
- `locators`: scored locator candidates.
- `evidence`: observed and derived element facts.
## `LocatorCandidate`
- `descriptor`: structured Playwright locator descriptor.
- `expression`: human-readable locator expression.
- `score`, `reason`, `verified`, `matchCount`, `failureCount`, `lastVerifiedAt`.
- `scopes`: context-scope evidence such as row, dialog, form, or section anchors. In v0.2.1 this is evidence, not executable scoping.
- `identity`: semantic identity checks for tag, role, accessible name, text, and visibility.
- `evidence`: generation and verification facts.
## `ActionEdge`
- `fromStateId`, `toStateId`, `elementId`, `actionType`.
- `actionParams`, `preconditions`, `parameterSpecs`.
- `effects`: snapshot-diff effects observed after replay.
- `riskLevel`, `riskCategory`.
- `confidence`, `createdAt`.
## `EffectRecord`
- `type`: `url`, `dom`, `visibleText`, `network`, `download`, `dialog`, or `storage`.
- v0.2.1 emits snapshot-diff effects for URL, DOM hash, visible text hash, and actionable inventory hash.
- Network, download, dialog, and storage event buffers are reserved for later slices unless explicitly implemented.
## `SkippedAction`
- `skippedActionId`, `stateId`, `elementId`, `actionType`.
- `riskLevel`, `riskCategory`.
- `reason`: policy decision string such as `blocked:destructive`.
- `evidence`: risk-policy evidence explaining why the action was skipped.
- Hidden, disabled, obscured, duplicate-locator, and timeout cases are not `SkippedAction`; they are `FailedObservation`.
## `FailedObservation`
- `failedObservationId`, `stateId`, optional `elementId`, optional `actionType`.
- `reason`: machine-readable failure code.
- `message`: diagnostic message.
- `evidence`: observed facts explaining the failure.
- Step 3: Update README output model
Update README.md current scope bullets to include:
- Capture reproducibility metadata: browser, viewport, locale, auth mode, policy versions, and crawl session id.
- Record state-local skipped default-exploration actions when risk policy blocks them.
- Record failed observations instead of turning failed actions into successful edges.
Update key objects:
- `SkippedAction`: risk-policy default exploration refusal with risk category, reason, state id, element id, and evidence.
- `FailedObservation`: locator/action/effect failure with machine-readable reason and diagnostic evidence.
- Step 4: Run full verification
Run:
npm run typecheck
npm run test
npm run build
npm run scan -- --url ./examples/simple.html --out artifacts/simple-scan-v0.2.json
npm run example
Expected:
-
TypeScript passes with no errors.
-
Tests pass.
-
Build writes
dist/. -
Scan writes a v0.2 artifact with
skippedActions: []andfailedObservations: []. -
Example writes a v0.2 explore artifact with low-risk edges and skipped high-risk actions.
-
Step 5: Commit
git add schemas/topology-graph.schema.md README.md tests/integration.test.ts
git commit -m "docs: document topology graph v0.2 contract"
Final Verification Gate
After all tasks are complete, run:
npm run typecheck
npm run test
npm run build
npm run scan -- --url ./examples/simple.html --out artifacts/simple-scan-v0.2.json
npm run example
git status --short
Expected command results:
npm run typecheck: exits 0.npm run test: exits 0.npm run build: exits 0.- Static scan writes a graph with one entry state and no action edges.
- Example exploration writes a graph with low-risk action edges, skipped high-risk actions, and no default execution of destructive/submission/payment/auth-sensitive actions.
git status --shortshows only intended changes before the final commit, or is clean after the final commit.
Explicit Non-Scope
- Do not add LLM calls, model selection, natural-language task execution, or semantic planning.
- Do not add rrweb ingestion, skill generation, or browser-agent orchestration.
- Do not add stealth, anti-bot bypass, captcha solving, or unauthorized access handling.
- Do not introduce database storage before the JSON graph shape stabilizes.
- Do not make CSS/XPath primary locator strategies; they remain fallback candidates.
- Do not implement full BFS crawling or multi-step task planning in v0.2.1. It records first-hop explored edges and discovered state-local evidence only.
Self-Review
- Spec coverage: The plan covers v0.2 design requirements for state-local elements, evidence levels, parameter surfaces, risk classification, failed observations, artifact metadata, context-scope locator evidence, and action effects.
- Boundary check: The plan stays inside the topology engine and does not add LLM, product UI, rrweb, script generation, or a general crawler.
- Type consistency: New graph fields are added first in
src/types.ts, then populated by metadata, snapshot, locator, risk, and engine modules. - Testability: Every behavioral change has a unit or integration assertion and a verification command.