feat: add topology graph v0.2.2 contract

This commit is contained in:
赵义仑
2026-06-24 09:47:23 +08:00
parent a7458265c0
commit 2f12fe8cda
16 changed files with 1041 additions and 42 deletions

View File

@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { mkdtemp, rm } from "node:fs/promises";
import { createServer, type Server } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -21,6 +22,20 @@ async function withTempOutput<T>(name: string, callback: (out: string) => Promis
}
}
async function withServer<T>(handler: Parameters<typeof createServer>[0], callback: (url: string) => Promise<T>): Promise<T> {
const server = createServer(handler);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assert.ok(address && typeof address === "object");
try {
return await callback(`http://127.0.0.1:${address.port}/`);
} finally {
await new Promise<void>((resolve, reject) => {
(server as Server).close((error) => (error ? reject(error) : resolve()));
});
}
}
function hasEdgeForAccessibleName(
graph: {
edges: Array<{ elementId: string }>;
@@ -73,7 +88,7 @@ test("duplicated row actions are verified with semantic locator evidence", async
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.engineVersion, "0.2.2");
assert.equal(graph.metadata.runtimeContext.browserName, "chromium");
assert.equal(graph.metadata.runtimeContext.viewport.width, 1440);
assert.equal(graph.metadata.runtimeContext.viewport.height, 1000);
@@ -297,6 +312,7 @@ test("explore artifact preserves referential integrity and snapshot-diff effect
}
for (const failure of graph.failedObservations) {
if (failure.stateId === null) continue;
assert.equal(stateIds.has(failure.stateId), true);
if (failure.elementId === null) continue;
const element = graph.elements.find((item) => item.elementId === failure.elementId);
@@ -385,6 +401,35 @@ test("explore records locator failures instead of edges or fake effects for unsa
);
});
test("explore records replay navigation timeout as a failed observation", async () => {
let requestCount = 0;
await withServer(
(_request, response) => {
requestCount += 1;
if (requestCount === 1) {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end("<!doctype html><button>Open panel</button>");
return;
}
// Leave replay navigation hanging so the engine must record a timeout.
},
async (url) => {
const graph = await withTempOutput("explore-navigation-timeout", (out) =>
exploreUrl({ url, maxActions: 1, navigationTimeoutMs: 50, out }),
);
const button = graph.elements.find((element) => element.accessibleName === "Open panel");
assert.ok(button);
assert.equal(graph.edges.length, 0);
assert.equal(
graph.failedObservations.some(
(failure) => failure.elementId === button.elementId && failure.reason === "navigation_timeout",
),
true,
);
},
);
});
test("explore records state-local skipped high-risk actions with evidence", async () => {
const graph = await withTempOutput("explore-skips", (out) => exploreUrl({ url: fixturePath, maxActions: 12, out }));
assert.equal(graph.skippedActions.some((skip) => skip.reason.includes("form_submit")), true);

View File

@@ -91,6 +91,45 @@ test("locator candidates carry context-scope evidence for duplicated actions", (
assert.equal(candidates[0].scopes.some((scope) => scope.kind === "row"), true);
});
test("scoped row locator resolves duplicated row actions to one target", async () => {
await withPage(
`
<table>
<tr><td>Acme Co</td><td><button>Edit</button></td></tr>
<tr><td>Beta LLC</td><td><button>Edit</button></td></tr>
</table>
`,
async (page) => {
const rawElement = raw({
uid: "button_1_Edit",
candidateIndex: 1,
tag: "button",
role: "button",
accessibleName: "Edit",
identityText: "Edit",
text: "Edit",
attributes: {},
context: {
dialog: null,
form: null,
section: null,
row: { kind: "row", name: null, text: "Acme Co Edit" },
landmark: null,
listitem: null,
},
});
const scoped = generateLocators("el_scoped_edit", rawElement).find((candidate) => candidate.strategy === "scoped");
assert.ok(scoped);
const [verified] = await verifyLocators(page, rawElement, [scoped]);
assert.equal(verified.verified, true);
assert.equal(verified.matchCount, 1);
assert.equal(verified.identity?.sameAccessibleName, true);
},
);
});
test("placeholder-only input locator verifies identity from raw placeholder evidence", async () => {
await withPage(`<input placeholder="Search customers" />`, async (page) => {
const rawElement = raw({

View File

@@ -22,6 +22,21 @@ test("crawl session id is unique for each generated graph", () => {
assert.notEqual(first.metadata.crawlSessionId, second.metadata.crawlSessionId);
});
test("empty graph starts with a machine-readable summary", () => {
const graph = buildEmptyGraph(graphArgs);
assert.deepEqual(graph.summary, {
states: 0,
elements: 0,
edges: 0,
skippedActions: 0,
failedObservations: 0,
failedObservationsByReason: {},
skippedActionsByRiskCategory: {},
edgesByRiskCategory: {},
});
});
function withFixedDate<T>(callback: () => T): T {
const RealDate = Date;
const fixedTime = new RealDate("2026-06-16T00:00:00.000Z").getTime();

21
tests/schema.test.ts Normal file
View File

@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "node:test";
import Ajv from "ajv";
import { scanUrl } from "../src/engine.js";
test("topology graph JSON schema validates emitted scan artifacts", async () => {
const schema = JSON.parse(await readFile("schemas/topology-graph.schema.json", "utf8"));
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);
const dir = await mkdtemp(path.join(tmpdir(), "action-topology-schema-test-"));
try {
const graph = await scanUrl({ url: "examples/simple.html", out: path.join(dir, "graph.json") });
assert.equal(validate(graph), true, JSON.stringify(validate.errors, null, 2));
} finally {
await rm(dir, { recursive: true, force: true });
}
});