diff --git a/src/engine.ts b/src/engine.ts index 584d25e..11c956c 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url"; import { chromium, type Browser, type BrowserContext, type Page, type Response } from "playwright"; import { shortHash } from "./hash.js"; import { resolveLocator } from "./locator.js"; +import { buildEmptyGraph, defaultViewport } from "./metadata.js"; import { isLowRiskClickable, classifyClickRisk } from "./risk.js"; import { snapshotPage } from "./snapshot.js"; import type { ActionEdge, EffectRecord, TopologyGraph } from "./types.js"; @@ -24,18 +25,14 @@ export async function scanUrl(options: EngineOptions): Promise { await page.goto(toNavigableUrl(options.url), { waitUntil: "domcontentloaded" }); await page.waitForLoadState("networkidle").catch(() => undefined); const snapshot = await snapshotPage(page); - const graph: TopologyGraph = { - schemaVersion: "0.1", - metadata: { - entryUrl: page.url(), - generatedAt: new Date().toISOString(), - engineVersion: "0.1.0", - mode: "scan", - }, - states: [snapshot.state], - elements: snapshot.elements, - edges: [], - }; + const graph = buildEmptyGraph({ + inputUrl: options.url, + entryUrl: page.url(), + mode: "scan", + storageState: options.storageState, + }); + graph.states.push(snapshot.state); + graph.elements.push(...snapshot.elements); await maybeWriteGraph(graph, options.out); await context.close(); return graph; @@ -54,18 +51,14 @@ export async function exploreUrl(options: EngineOptions): Promise await page.waitForLoadState("networkidle").catch(() => undefined); const entrySnapshot = await snapshotPage(page); - const graph: TopologyGraph = { - schemaVersion: "0.1", - metadata: { - entryUrl: page.url(), - generatedAt: new Date().toISOString(), - engineVersion: "0.1.0", - mode: "explore", - }, - states: [entrySnapshot.state], - elements: [...entrySnapshot.elements], - edges: [], - }; + const graph = buildEmptyGraph({ + inputUrl: options.url, + entryUrl: page.url(), + mode: "explore", + storageState: options.storageState, + }); + graph.states.push(entrySnapshot.state); + graph.elements.push(...entrySnapshot.elements); const candidates = entrySnapshot.elements.filter(isLowRiskClickable).slice(0, options.maxActions ?? 10); await page.close(); @@ -132,7 +125,8 @@ export async function exploreUrl(options: EngineOptions): Promise async function newContext(browser: Browser, options: EngineOptions): Promise { return browser.newContext({ storageState: options.storageState, - viewport: { width: 1440, height: 1000 }, + viewport: defaultViewport(), + locale: "en-US", }); } @@ -186,4 +180,3 @@ function upsertById, K extends keyof T>(items: T[], next[index] = item; return next; } - diff --git a/src/metadata.ts b/src/metadata.ts new file mode 100644 index 0000000..ba73668 --- /dev/null +++ b/src/metadata.ts @@ -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"; + } +} diff --git a/src/types.ts b/src/types.ts index 8a35245..4a77f7e 100644 --- a/src/types.ts +++ b/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 = | { kind: "role"; role: string; name?: string } | { kind: "label"; label: string } @@ -106,16 +127,69 @@ export interface ActionEdge { } export interface TopologyGraph { - schemaVersion: "0.1"; + schemaVersion: typeof SCHEMA_VERSION; metadata: { + artifactVersion: string; + inputUrl: string; entryUrl: string; generatedAt: string; - engineVersion: string; - mode: "scan" | "explore"; + engineVersion: typeof ENGINE_VERSION; + 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[]; elements: ActionableElement[]; 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 { @@ -136,4 +210,3 @@ export interface SnapshotResult { elements: ActionableElement[]; visibleText: string; } - diff --git a/tests/integration.test.ts b/tests/integration.test.ts index f084b52..e9daff1 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -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); }); +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 () => { const graph = await withTempOutput("explore", (out) => exploreUrl({ url: fixturePath, maxActions: 8, out })); assert.equal(graph.edges.some((edge) => edge.riskLevel === "low" && edge.toStateId), true); diff --git a/tests/metadata.test.ts b/tests/metadata.test.ts new file mode 100644 index 0000000..df4089c --- /dev/null +++ b/tests/metadata.test.ts @@ -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(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; + } +}