189 lines
7.9 KiB
TypeScript
189 lines
7.9 KiB
TypeScript
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;
|
|
|
|
async function withTempOutput<T>(name: string, callback: (out: string) => Promise<T>): Promise<T> {
|
|
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("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);
|
|
});
|