import assert from "node:assert/strict"; 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; const examplePath = path.resolve("examples/simple.html"); async function withTempOutput(name: string, callback: (out: string) => Promise): Promise { const dir = await mkdtemp(path.join(tmpdir(), "action-topology-engine-test-")); try { return await callback(path.join(dir, `${name}.json`)); } finally { await rm(dir, { recursive: true, force: true }); } } function hasEdgeForAccessibleName( graph: { edges: Array<{ elementId: string }>; elements: Array<{ elementId: string; accessibleName: string | null }>; }, accessibleName: string, ): boolean { return graph.edges.some((edge) => { const element = graph.elements.find((item) => item.elementId === edge.elementId); return element?.accessibleName === accessibleName; }); } test("scan captures a state-local inventory without clicking high-risk actions", async () => { const graph = await withTempOutput("scan", (out) => scanUrl({ url: fixturePath, out })); 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("duplicated row actions are verified with semantic locator evidence", async () => { const graph = await withTempOutput("duplicate-locators", (out) => scanUrl({ url: fixturePath, out })); 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); } }); test("graph metadata records generation context and policy versions", async () => { const graph = await withTempOutput("metadata", (out) => scanUrl({ url: fixturePath, out })); 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"); }); 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); assert.equal(hasEdgeForAccessibleName(graph, "Delete account"), false); assert.equal(hasEdgeForAccessibleName(graph, "Submit search"), false); assert.equal(graph.states.length >= 2, true); }); test("explore records state-local skipped high-risk actions with evidence", async () => { const graph = await withTempOutput("explore-skips", (out) => exploreUrl({ url: fixturePath, maxActions: 8, out })); 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 withTempOutput("explore-disabled", (out) => exploreUrl({ url: fixturePath, maxActions: 8, out })); 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, ); }); test("explore does not record non-click elements as skipped click actions", async () => { const graph = await withTempOutput("explore-example-skips", (out) => exploreUrl({ url: examplePath, maxActions: 8, out })); const searchInput = graph.elements.find((element) => element.accessibleName === "Search"); assert.ok(searchInput); assert.equal(graph.skippedActions.some((skip) => skip.elementId === searchInput.elementId), false); const settingsDialog = graph.elements.find((element) => element.accessibleName === "Settings dialog"); assert.ok(settingsDialog); assert.equal(graph.skippedActions.some((skip) => skip.elementId === settingsDialog.elementId), false); const deleteElement = graph.elements.find((element) => element.accessibleName === "Delete account"); assert.ok(deleteElement); assert.equal(graph.skippedActions.some((skip) => skip.elementId === deleteElement.elementId && skip.riskCategory === "destructive"), true); }); test("disabled high-risk click candidates are failed observations, not risk skips", async () => { const graph = await withTempOutput("explore-disabled-high-risk", (out) => exploreUrl({ url: fixturePath, maxActions: 8, out })); const disabled = graph.elements.find((element) => element.accessibleName === "Delete disabled account"); 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, ); });