feat: add topology graph v0.2.2 contract
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
21
tests/schema.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user