test: add topology evidence baseline fixtures

This commit is contained in:
赵义仑
2026-06-16 18:48:10 +08:00
parent ff122226b4
commit 5ea50e7288
5 changed files with 221 additions and 1 deletions

View File

@@ -10,6 +10,7 @@
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json",
"test": "tsx --test tests/**/*.test.ts",
"scan": "tsx src/cli.ts scan", "scan": "tsx src/cli.ts scan",
"explore": "tsx src/cli.ts explore", "explore": "tsx src/cli.ts explore",
"example": "tsx src/cli.ts explore --url ./examples/simple.html --out artifacts/simple-graph.json --max-actions 8" "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" "typescript": "^5.8.3"
} }
} }

88
tests/fixtures/context-risk.html vendored Normal file
View File

@@ -0,0 +1,88 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Topology Context Risk Fixture</title>
<style>
body { font-family: system-ui, sans-serif; margin: 24px; }
table { border-collapse: collapse; margin: 16px 0; }
th, td { border: 1px solid #ccc; padding: 8px 10px; }
button, input, select, a { margin: 4px; padding: 6px 10px; }
[role="dialog"] { display: none; border: 1px solid #999; padding: 16px; margin-top: 16px; }
[role="dialog"].open { display: block; }
#toast { margin-top: 16px; min-height: 24px; }
</style>
</head>
<body>
<h1>Accounts</h1>
<nav aria-label="Primary">
<a href="#overview" data-testid="overview-link">Overview</a>
<a href="#reports" data-testid="reports-link">Reports</a>
</nav>
<section aria-labelledby="customers-heading">
<h2 id="customers-heading">Customers</h2>
<table aria-label="Customer accounts">
<thead>
<tr><th>Name</th><th>Status</th><th>Action</th></tr>
</thead>
<tbody>
<tr>
<td>Acme Co</td>
<td>Active</td>
<td><button data-testid="edit-acme" onclick="openEditor('Acme Co')">Edit</button></td>
</tr>
<tr>
<td>Beta LLC</td>
<td>Trial</td>
<td><button data-testid="edit-beta" onclick="openEditor('Beta LLC')">Edit</button></td>
</tr>
</tbody>
</table>
</section>
<form aria-label="Search customers" action="/search" method="get">
<label>
Query
<input name="q" placeholder="Search customers" required maxlength="40" />
</label>
<label>
Segment
<select name="segment">
<option value="">Any</option>
<option value="active">Active</option>
<option value="trial">Trial</option>
</select>
</label>
<button type="submit">Submit search</button>
</form>
<button disabled data-testid="disabled-open">Open disabled panel</button>
<button data-testid="open-settings" onclick="document.getElementById('settings-dialog').classList.add('open')">Open settings</button>
<section id="settings-dialog" role="dialog" aria-modal="true" aria-label="Settings dialog">
<h2>Settings</h2>
<button data-testid="save-settings" onclick="document.getElementById('toast').textContent='Saved settings'">Save</button>
<button data-testid="delete-account">Delete account</button>
<button data-testid="close-settings" onclick="document.getElementById('settings-dialog').classList.remove('open')">Close</button>
</section>
<section id="editor" role="dialog" aria-modal="true" aria-label="Customer editor">
<h2 id="editor-title">Customer editor</h2>
<label>
Customer name
<input id="customer-name" name="customerName" required />
</label>
<button data-testid="close-editor" onclick="document.getElementById('editor').classList.remove('open')">Close editor</button>
</section>
<p id="toast" role="status"></p>
<script>
function openEditor(name) {
document.getElementById("customer-name").value = name;
document.getElementById("editor-title").textContent = "Editing " + name;
document.getElementById("editor").classList.add("open");
}
</script>
</body>
</html>

46
tests/integration.test.ts Normal file
View File

@@ -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<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("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);
});

45
tests/locator.test.ts Normal file
View File

@@ -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>): 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);
});

41
tests/risk.test.ts Normal file
View File

@@ -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>): 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);
});