feat: version topology graph metadata
This commit is contained in:
@@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url";
|
|||||||
import { chromium, type Browser, type BrowserContext, type Page, type Response } from "playwright";
|
import { chromium, type Browser, type BrowserContext, type Page, type Response } from "playwright";
|
||||||
import { shortHash } from "./hash.js";
|
import { shortHash } from "./hash.js";
|
||||||
import { resolveLocator } from "./locator.js";
|
import { resolveLocator } from "./locator.js";
|
||||||
|
import { buildEmptyGraph, defaultViewport } from "./metadata.js";
|
||||||
import { isLowRiskClickable, classifyClickRisk } from "./risk.js";
|
import { isLowRiskClickable, classifyClickRisk } from "./risk.js";
|
||||||
import { snapshotPage } from "./snapshot.js";
|
import { snapshotPage } from "./snapshot.js";
|
||||||
import type { ActionEdge, EffectRecord, TopologyGraph } from "./types.js";
|
import type { ActionEdge, EffectRecord, TopologyGraph } from "./types.js";
|
||||||
@@ -24,18 +25,14 @@ export async function scanUrl(options: EngineOptions): Promise<TopologyGraph> {
|
|||||||
await page.goto(toNavigableUrl(options.url), { waitUntil: "domcontentloaded" });
|
await page.goto(toNavigableUrl(options.url), { waitUntil: "domcontentloaded" });
|
||||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||||
const snapshot = await snapshotPage(page);
|
const snapshot = await snapshotPage(page);
|
||||||
const graph: TopologyGraph = {
|
const graph = buildEmptyGraph({
|
||||||
schemaVersion: "0.1",
|
inputUrl: options.url,
|
||||||
metadata: {
|
entryUrl: page.url(),
|
||||||
entryUrl: page.url(),
|
mode: "scan",
|
||||||
generatedAt: new Date().toISOString(),
|
storageState: options.storageState,
|
||||||
engineVersion: "0.1.0",
|
});
|
||||||
mode: "scan",
|
graph.states.push(snapshot.state);
|
||||||
},
|
graph.elements.push(...snapshot.elements);
|
||||||
states: [snapshot.state],
|
|
||||||
elements: snapshot.elements,
|
|
||||||
edges: [],
|
|
||||||
};
|
|
||||||
await maybeWriteGraph(graph, options.out);
|
await maybeWriteGraph(graph, options.out);
|
||||||
await context.close();
|
await context.close();
|
||||||
return graph;
|
return graph;
|
||||||
@@ -54,18 +51,14 @@ export async function exploreUrl(options: EngineOptions): Promise<TopologyGraph>
|
|||||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||||
|
|
||||||
const entrySnapshot = await snapshotPage(page);
|
const entrySnapshot = await snapshotPage(page);
|
||||||
const graph: TopologyGraph = {
|
const graph = buildEmptyGraph({
|
||||||
schemaVersion: "0.1",
|
inputUrl: options.url,
|
||||||
metadata: {
|
entryUrl: page.url(),
|
||||||
entryUrl: page.url(),
|
mode: "explore",
|
||||||
generatedAt: new Date().toISOString(),
|
storageState: options.storageState,
|
||||||
engineVersion: "0.1.0",
|
});
|
||||||
mode: "explore",
|
graph.states.push(entrySnapshot.state);
|
||||||
},
|
graph.elements.push(...entrySnapshot.elements);
|
||||||
states: [entrySnapshot.state],
|
|
||||||
elements: [...entrySnapshot.elements],
|
|
||||||
edges: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const candidates = entrySnapshot.elements.filter(isLowRiskClickable).slice(0, options.maxActions ?? 10);
|
const candidates = entrySnapshot.elements.filter(isLowRiskClickable).slice(0, options.maxActions ?? 10);
|
||||||
await page.close();
|
await page.close();
|
||||||
@@ -132,7 +125,8 @@ export async function exploreUrl(options: EngineOptions): Promise<TopologyGraph>
|
|||||||
async function newContext(browser: Browser, options: EngineOptions): Promise<BrowserContext> {
|
async function newContext(browser: Browser, options: EngineOptions): Promise<BrowserContext> {
|
||||||
return browser.newContext({
|
return browser.newContext({
|
||||||
storageState: options.storageState,
|
storageState: options.storageState,
|
||||||
viewport: { width: 1440, height: 1000 },
|
viewport: defaultViewport(),
|
||||||
|
locale: "en-US",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,4 +180,3 @@ function upsertById<T extends Record<K, string>, K extends keyof T>(items: T[],
|
|||||||
next[index] = item;
|
next[index] = item;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
73
src/metadata.ts
Normal file
73
src/metadata.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { ENGINE_VERSION, SCHEMA_VERSION, type GraphMode, type RuntimeContext, type TopologyGraph } from "./types.js";
|
||||||
|
import { shortHash } from "./hash.js";
|
||||||
|
|
||||||
|
const DEFAULT_VIEWPORT = { width: 1440, height: 1000 } as const;
|
||||||
|
|
||||||
|
export function defaultViewport(): { width: number; height: number } {
|
||||||
|
return { ...DEFAULT_VIEWPORT };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRuntimeContext(storageState?: string): RuntimeContext {
|
||||||
|
return {
|
||||||
|
browserName: "chromium",
|
||||||
|
viewport: defaultViewport(),
|
||||||
|
locale: "en-US",
|
||||||
|
timezoneId: null,
|
||||||
|
authContext: storageState ? "storage_state" : "anonymous",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEmptyGraph(args: {
|
||||||
|
inputUrl: string;
|
||||||
|
entryUrl: string;
|
||||||
|
mode: GraphMode;
|
||||||
|
storageState?: string;
|
||||||
|
}): TopologyGraph {
|
||||||
|
const generatedAt = new Date().toISOString();
|
||||||
|
const crawlSessionId = `crawl_${randomUUID()}`;
|
||||||
|
const inputFingerprint = `input_${shortHash([
|
||||||
|
args.inputUrl,
|
||||||
|
args.mode,
|
||||||
|
"risk-policy-v0.2",
|
||||||
|
"normalization-v0.2",
|
||||||
|
"locator-scoring-v0.2",
|
||||||
|
])}`;
|
||||||
|
return {
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
metadata: {
|
||||||
|
artifactVersion: "topology-graph-v0.2",
|
||||||
|
inputUrl: args.inputUrl,
|
||||||
|
entryUrl: args.entryUrl,
|
||||||
|
generatedAt,
|
||||||
|
engineVersion: ENGINE_VERSION,
|
||||||
|
mode: args.mode,
|
||||||
|
crawlSessionId,
|
||||||
|
inputSeed: args.inputUrl,
|
||||||
|
inputFingerprint,
|
||||||
|
runtimeContext: buildRuntimeContext(args.storageState),
|
||||||
|
site: {
|
||||||
|
baseUrl: baseUrl(args.entryUrl),
|
||||||
|
entryUrl: args.entryUrl,
|
||||||
|
},
|
||||||
|
riskPolicyVersion: "risk-policy-v0.2",
|
||||||
|
normalizationRuleVersion: "normalization-v0.2",
|
||||||
|
locatorScoringVersion: "locator-scoring-v0.2",
|
||||||
|
},
|
||||||
|
states: [],
|
||||||
|
elements: [],
|
||||||
|
edges: [],
|
||||||
|
skippedActions: [],
|
||||||
|
failedObservations: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseUrl(value: string): string {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (url.protocol === "file:") return url.protocol;
|
||||||
|
return `${url.protocol}//${url.host}`;
|
||||||
|
} catch {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/types.ts
81
src/types.ts
@@ -1,3 +1,24 @@
|
|||||||
|
export const SCHEMA_VERSION = "0.2" as const;
|
||||||
|
export const ENGINE_VERSION = "0.2.0" as const;
|
||||||
|
|
||||||
|
export type GraphMode = "scan" | "explore";
|
||||||
|
export type EvidenceLevel = "observed" | "derived" | "verified" | "heuristic" | "external";
|
||||||
|
export type StateLifecycle = "observed" | "fingerprinted" | "verified" | "stale" | "deprecated" | "conflicted";
|
||||||
|
|
||||||
|
export interface EvidenceRecord {
|
||||||
|
level: EvidenceLevel;
|
||||||
|
source: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeContext {
|
||||||
|
browserName: "chromium";
|
||||||
|
viewport: { width: number; height: number };
|
||||||
|
locale: string;
|
||||||
|
timezoneId: string | null;
|
||||||
|
authContext: "anonymous" | "storage_state";
|
||||||
|
}
|
||||||
|
|
||||||
export type LocatorDescriptor =
|
export type LocatorDescriptor =
|
||||||
| { kind: "role"; role: string; name?: string }
|
| { kind: "role"; role: string; name?: string }
|
||||||
| { kind: "label"; label: string }
|
| { kind: "label"; label: string }
|
||||||
@@ -106,16 +127,69 @@ export interface ActionEdge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TopologyGraph {
|
export interface TopologyGraph {
|
||||||
schemaVersion: "0.1";
|
schemaVersion: typeof SCHEMA_VERSION;
|
||||||
metadata: {
|
metadata: {
|
||||||
|
artifactVersion: string;
|
||||||
|
inputUrl: string;
|
||||||
entryUrl: string;
|
entryUrl: string;
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
engineVersion: string;
|
engineVersion: typeof ENGINE_VERSION;
|
||||||
mode: "scan" | "explore";
|
mode: GraphMode;
|
||||||
|
crawlSessionId: string;
|
||||||
|
inputSeed: string;
|
||||||
|
inputFingerprint: string;
|
||||||
|
runtimeContext: RuntimeContext;
|
||||||
|
site: {
|
||||||
|
baseUrl: string;
|
||||||
|
entryUrl: string;
|
||||||
|
};
|
||||||
|
riskPolicyVersion: "risk-policy-v0.2";
|
||||||
|
normalizationRuleVersion: "normalization-v0.2";
|
||||||
|
locatorScoringVersion: "locator-scoring-v0.2";
|
||||||
};
|
};
|
||||||
states: StateNode[];
|
states: StateNode[];
|
||||||
elements: ActionableElement[];
|
elements: ActionableElement[];
|
||||||
edges: ActionEdge[];
|
edges: ActionEdge[];
|
||||||
|
skippedActions: SkippedAction[];
|
||||||
|
failedObservations: FailedObservation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkippedAction {
|
||||||
|
skippedActionId: string;
|
||||||
|
stateId: string;
|
||||||
|
elementId: string;
|
||||||
|
actionType: ActionType;
|
||||||
|
riskLevel: "low" | "medium" | "high";
|
||||||
|
reason: string;
|
||||||
|
evidence: EvidenceRecord[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FailedObservation {
|
||||||
|
failedObservationId: string;
|
||||||
|
stateId: string;
|
||||||
|
elementId: string | null;
|
||||||
|
actionType: ActionType | null;
|
||||||
|
reason:
|
||||||
|
| "locator_not_found"
|
||||||
|
| "locator_not_unique"
|
||||||
|
| "element_hidden"
|
||||||
|
| "element_disabled"
|
||||||
|
| "element_obscured"
|
||||||
|
| "state_precondition_missing"
|
||||||
|
| "frame_inaccessible"
|
||||||
|
| "shadow_root_closed"
|
||||||
|
| "navigation_timeout"
|
||||||
|
| "network_error"
|
||||||
|
| "permission_denied"
|
||||||
|
| "auth_required"
|
||||||
|
| "captcha_or_challenge"
|
||||||
|
| "risk_policy_blocked"
|
||||||
|
| "effect_not_observed"
|
||||||
|
| "unexpected_state";
|
||||||
|
message: string;
|
||||||
|
evidence: EvidenceRecord[];
|
||||||
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RawActionableElement {
|
export interface RawActionableElement {
|
||||||
@@ -136,4 +210,3 @@ export interface SnapshotResult {
|
|||||||
elements: ActionableElement[];
|
elements: ActionableElement[];
|
||||||
visibleText: string;
|
visibleText: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,20 @@ test("scan captures a state-local inventory without clicking high-risk actions",
|
|||||||
assert.equal(graph.elements.some((element) => element.accessibleName === "Delete account"), false);
|
assert.equal(graph.elements.some((element) => element.accessibleName === "Delete account"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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.runtimeContext.browserName, "chromium");
|
||||||
|
assert.equal(graph.metadata.runtimeContext.viewport.width, 1440);
|
||||||
|
assert.equal(graph.metadata.runtimeContext.viewport.height, 1000);
|
||||||
|
assert.equal(graph.metadata.riskPolicyVersion, "risk-policy-v0.2");
|
||||||
|
assert.equal(graph.metadata.locatorScoringVersion, "locator-scoring-v0.2");
|
||||||
|
assert.equal(typeof graph.metadata.crawlSessionId, "string");
|
||||||
|
assert.equal(graph.metadata.inputUrl, fixturePath);
|
||||||
|
assert.equal(typeof graph.metadata.inputFingerprint, "string");
|
||||||
|
});
|
||||||
|
|
||||||
test("explore records low-risk edges and keeps high-risk submit actions out of default execution", async () => {
|
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 }));
|
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(graph.edges.some((edge) => edge.riskLevel === "low" && edge.toStateId), true);
|
||||||
|
|||||||
45
tests/metadata.test.ts
Normal file
45
tests/metadata.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { buildEmptyGraph } from "../src/metadata.js";
|
||||||
|
|
||||||
|
const graphArgs = {
|
||||||
|
inputUrl: "./examples/simple.html",
|
||||||
|
entryUrl: "file:///examples/simple.html",
|
||||||
|
mode: "scan" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
test("input fingerprint is stable for the same input and mode", () => {
|
||||||
|
const first = buildEmptyGraph(graphArgs);
|
||||||
|
const second = buildEmptyGraph(graphArgs);
|
||||||
|
|
||||||
|
assert.equal(first.metadata.inputFingerprint, second.metadata.inputFingerprint);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("crawl session id is unique for each generated graph", () => {
|
||||||
|
const first = withFixedDate(() => buildEmptyGraph(graphArgs));
|
||||||
|
const second = withFixedDate(() => buildEmptyGraph(graphArgs));
|
||||||
|
|
||||||
|
assert.notEqual(first.metadata.crawlSessionId, second.metadata.crawlSessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
function withFixedDate<T>(callback: () => T): T {
|
||||||
|
const RealDate = Date;
|
||||||
|
const fixedTime = new RealDate("2026-06-16T00:00:00.000Z").getTime();
|
||||||
|
|
||||||
|
class FixedDate extends RealDate {
|
||||||
|
constructor(value?: string | number | Date) {
|
||||||
|
super(value ?? fixedTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
static now(): number {
|
||||||
|
return fixedTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.Date = FixedDate as DateConstructor;
|
||||||
|
try {
|
||||||
|
return callback();
|
||||||
|
} finally {
|
||||||
|
globalThis.Date = RealDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user