diff --git a/package.json b/package.json
index d4c7d30..b87d772 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
"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"
@@ -23,4 +24,3 @@
"typescript": "^5.8.3"
}
}
-
diff --git a/tests/fixtures/context-risk.html b/tests/fixtures/context-risk.html
new file mode 100644
index 0000000..258b324
--- /dev/null
+++ b/tests/fixtures/context-risk.html
@@ -0,0 +1,88 @@
+
+
+
+
+
+ Topology Context Risk Fixture
+
+
+
+ Accounts
+
+
+
+ Customers
+
+
+ | Name | Status | Action |
+
+
+
+ | Acme Co |
+ Active |
+ |
+
+
+ | Beta LLC |
+ Trial |
+ |
+
+
+
+
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/integration.test.ts b/tests/integration.test.ts
new file mode 100644
index 0000000..f084b52
--- /dev/null
+++ b/tests/integration.test.ts
@@ -0,0 +1,46 @@
+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 { exploreUrl, scanUrl } from "../src/engine.js";
+
+const fixturePath = path.resolve("tests/fixtures/context-risk.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("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);
+});
diff --git a/tests/locator.test.ts b/tests/locator.test.ts
new file mode 100644
index 0000000..bf030b4
--- /dev/null
+++ b/tests/locator.test.ts
@@ -0,0 +1,45 @@
+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 {
+ 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);
+});
diff --git a/tests/risk.test.ts b/tests/risk.test.ts
new file mode 100644
index 0000000..4ddd4d6
--- /dev/null
+++ b/tests/risk.test.ts
@@ -0,0 +1,41 @@
+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 {
+ 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);
+});