test: add topology evidence baseline fixtures
This commit is contained in:
@@ -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
88
tests/fixtures/context-risk.html
vendored
Normal 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
46
tests/integration.test.ts
Normal 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
45
tests/locator.test.ts
Normal 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
41
tests/risk.test.ts
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user