From 5ea50e7288759b9377797affc69192dafaf9bdf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E4=B9=89=E4=BB=91?= Date: Tue, 16 Jun 2026 18:48:10 +0800 Subject: [PATCH] test: add topology evidence baseline fixtures --- package.json | 2 +- tests/fixtures/context-risk.html | 88 ++++++++++++++++++++++++++++++++ tests/integration.test.ts | 46 +++++++++++++++++ tests/locator.test.ts | 45 ++++++++++++++++ tests/risk.test.ts | 41 +++++++++++++++ 5 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/context-risk.html create mode 100644 tests/integration.test.ts create mode 100644 tests/locator.test.ts create mode 100644 tests/risk.test.ts 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

+ + + + + + + + + + + + + + + + +
NameStatusAction
Acme CoActive
Beta LLCTrial
+
+ +
+ + + +
+ + + + + + + +

+ + + 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); +});