Files
action-topology-engine/tests/integration.test.ts
2026-06-16 20:35:20 +08:00

258 lines
12 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;
const examplePath = path.resolve("examples/simple.html");
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("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,
);
});