feat: add topology graph v0.2.2 contract
This commit is contained in:
@@ -33,6 +33,12 @@ function parseArgs(argv: string[]): ParsedArgs {
|
||||
case "--max-actions":
|
||||
options.maxActions = Number(requireValue(argv, ++index, "--max-actions"));
|
||||
break;
|
||||
case "--navigation-timeout-ms":
|
||||
options.navigationTimeoutMs = Number(requireValue(argv, ++index, "--navigation-timeout-ms"));
|
||||
break;
|
||||
case "--action-timeout-ms":
|
||||
options.actionTimeoutMs = Number(requireValue(argv, ++index, "--action-timeout-ms"));
|
||||
break;
|
||||
case "--headed":
|
||||
options.headed = true;
|
||||
break;
|
||||
@@ -54,7 +60,7 @@ function requireValue(argv: string[], index: number, flag: string): string {
|
||||
function usage(): never {
|
||||
console.error(`Usage:
|
||||
action-topology scan --url <url-or-file> [--out graph.json] [--headed] [--storage-state state.json]
|
||||
action-topology explore --url <url-or-file> [--out graph.json] [--max-actions 10] [--headed] [--storage-state state.json]
|
||||
action-topology explore --url <url-or-file> [--out graph.json] [--max-actions 10] [--navigation-timeout-ms 30000] [--action-timeout-ms 2000] [--headed] [--storage-state state.json]
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -63,4 +69,3 @@ main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
134
src/engine.ts
134
src/engine.ts
@@ -8,6 +8,7 @@ import { resolveLocator } from "./locator.js";
|
||||
import { buildEmptyGraph, defaultViewport } from "./metadata.js";
|
||||
import { defaultExploreDecision } from "./risk.js";
|
||||
import { snapshotPage } from "./snapshot.js";
|
||||
import { refreshGraphSummary } from "./summary.js";
|
||||
import type { ActionableElement, ActionEdge, FailedObservation, LocatorCandidate, SnapshotResult, TopologyGraph } from "./types.js";
|
||||
|
||||
export interface EngineOptions {
|
||||
@@ -16,6 +17,8 @@ export interface EngineOptions {
|
||||
headed?: boolean;
|
||||
storageState?: string;
|
||||
maxActions?: number;
|
||||
navigationTimeoutMs?: number;
|
||||
actionTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface ExploreCandidate {
|
||||
@@ -26,22 +29,40 @@ interface ExploreCandidate {
|
||||
export async function scanUrl(options: EngineOptions): Promise<TopologyGraph> {
|
||||
const browser = await chromium.launch({ headless: !options.headed });
|
||||
try {
|
||||
const requestedEntryUrl = toNavigableUrl(options.url);
|
||||
const context = await newContext(browser, options);
|
||||
const page = await context.newPage();
|
||||
await page.goto(toNavigableUrl(options.url), { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||
const snapshot = await snapshotPage(page);
|
||||
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;
|
||||
try {
|
||||
const page = await createReplayPage(context, requestedEntryUrl, options);
|
||||
const snapshot = await snapshotPage(page);
|
||||
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);
|
||||
return graph;
|
||||
} catch (error) {
|
||||
const graph = buildEmptyGraph({
|
||||
inputUrl: options.url,
|
||||
entryUrl: requestedEntryUrl,
|
||||
mode: "scan",
|
||||
storageState: options.storageState,
|
||||
});
|
||||
recordNavigationFailure(graph, {
|
||||
stateId: null,
|
||||
elementId: null,
|
||||
actionType: "navigate",
|
||||
url: requestedEntryUrl,
|
||||
error,
|
||||
});
|
||||
await maybeWriteGraph(graph, options.out);
|
||||
return graph;
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -55,9 +76,25 @@ export async function exploreUrl(options: EngineOptions): Promise<TopologyGraph>
|
||||
let entrySnapshot: SnapshotResult;
|
||||
let entryUrl: string;
|
||||
try {
|
||||
const page = await createReplayPage(entryContext, requestedEntryUrl);
|
||||
const page = await createReplayPage(entryContext, requestedEntryUrl, options);
|
||||
entrySnapshot = await snapshotPage(page);
|
||||
entryUrl = page.url();
|
||||
} catch (error) {
|
||||
const graph = buildEmptyGraph({
|
||||
inputUrl: options.url,
|
||||
entryUrl: requestedEntryUrl,
|
||||
mode: "explore",
|
||||
storageState: options.storageState,
|
||||
});
|
||||
recordNavigationFailure(graph, {
|
||||
stateId: null,
|
||||
elementId: null,
|
||||
actionType: "navigate",
|
||||
url: requestedEntryUrl,
|
||||
error,
|
||||
});
|
||||
await maybeWriteGraph(graph, options.out);
|
||||
return graph;
|
||||
} finally {
|
||||
await entryContext.close();
|
||||
}
|
||||
@@ -96,7 +133,19 @@ export async function exploreUrl(options: EngineOptions): Promise<TopologyGraph>
|
||||
const decision = defaultExploreDecision(element, "click");
|
||||
const actionContext = await newContext(browser, options);
|
||||
try {
|
||||
const actionPage = await createReplayPage(actionContext, entryUrl);
|
||||
let actionPage: Page;
|
||||
try {
|
||||
actionPage = await createReplayPage(actionContext, entryUrl, options);
|
||||
} catch (error) {
|
||||
recordNavigationFailure(graph, {
|
||||
stateId: entrySnapshot.state.stateId,
|
||||
elementId: element.elementId,
|
||||
actionType: "click",
|
||||
url: entryUrl,
|
||||
error,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const beforeUrl = actionPage.url();
|
||||
const beforeSnapshot = await snapshotPage(actionPage);
|
||||
const edgeId = `edge_${shortHash([beforeSnapshot.state.stateId, element.elementId, "click"])}`;
|
||||
@@ -108,14 +157,14 @@ export async function exploreUrl(options: EngineOptions): Promise<TopologyGraph>
|
||||
}
|
||||
|
||||
try {
|
||||
await target.click({ timeout: 2000, trial: true });
|
||||
await target.click({ timeout: 2000 });
|
||||
await target.click({ timeout: actionTimeout(options), trial: true });
|
||||
await target.click({ timeout: actionTimeout(options) });
|
||||
} catch (error) {
|
||||
recordClickFailure(graph, beforeSnapshot, element, error);
|
||||
continue;
|
||||
}
|
||||
|
||||
await actionPage.waitForLoadState("networkidle", { timeout: 2000 }).catch(() => undefined);
|
||||
await actionPage.waitForLoadState("networkidle", { timeout: actionTimeout(options) }).catch(() => undefined);
|
||||
const afterSnapshot = await snapshotPage(actionPage, edgeId);
|
||||
const discoveredState = addStateIfNew(graph, afterSnapshot.state);
|
||||
graph.elements = upsertManyById(graph.elements, afterSnapshot.elements, "elementId");
|
||||
@@ -162,10 +211,10 @@ async function newContext(browser: Browser, options: EngineOptions): Promise<Bro
|
||||
});
|
||||
}
|
||||
|
||||
async function createReplayPage(context: BrowserContext, entryUrl: string): Promise<Page> {
|
||||
async function createReplayPage(context: BrowserContext, entryUrl: string, options: EngineOptions): Promise<Page> {
|
||||
const page = await context.newPage();
|
||||
await page.goto(entryUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||
await page.goto(entryUrl, { waitUntil: "domcontentloaded", timeout: navigationTimeout(options) });
|
||||
await page.waitForLoadState("networkidle", { timeout: actionTimeout(options) }).catch(() => undefined);
|
||||
return page;
|
||||
}
|
||||
|
||||
@@ -243,6 +292,7 @@ function recordFailedObservation(graph: TopologyGraph, observation: FailedObserv
|
||||
}
|
||||
|
||||
async function maybeWriteGraph(graph: TopologyGraph, out?: string): Promise<void> {
|
||||
refreshGraphSummary(graph);
|
||||
if (!out) {
|
||||
console.log(JSON.stringify(graph, null, 2));
|
||||
return;
|
||||
@@ -251,6 +301,44 @@ async function maybeWriteGraph(graph: TopologyGraph, out?: string): Promise<void
|
||||
await writeFile(out, `${JSON.stringify(graph, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function navigationTimeout(options: EngineOptions): number {
|
||||
return options.navigationTimeoutMs ?? 30_000;
|
||||
}
|
||||
|
||||
function actionTimeout(options: EngineOptions): number {
|
||||
return options.actionTimeoutMs ?? 2_000;
|
||||
}
|
||||
|
||||
function recordNavigationFailure(
|
||||
graph: TopologyGraph,
|
||||
args: {
|
||||
stateId: string | null;
|
||||
elementId: string | null;
|
||||
actionType: FailedObservation["actionType"];
|
||||
url: string;
|
||||
error: unknown;
|
||||
},
|
||||
): void {
|
||||
const message = args.error instanceof Error ? args.error.message : String(args.error);
|
||||
const reason: FailedObservation["reason"] = /timeout/i.test(message) ? "navigation_timeout" : "network_error";
|
||||
recordFailedObservation(graph, {
|
||||
failedObservationId: `fail_${shortHash([args.stateId ?? "graph", args.elementId ?? "none", args.actionType, reason, args.url])}`,
|
||||
stateId: args.stateId,
|
||||
elementId: args.elementId,
|
||||
actionType: args.actionType,
|
||||
reason,
|
||||
message: `Navigation failed for ${args.url}: ${message}`,
|
||||
evidence: [
|
||||
{
|
||||
level: "observed",
|
||||
source: "engine.navigation",
|
||||
description: "Navigation failure was recorded without converting it into an observed edge",
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function toNavigableUrl(value: string): string {
|
||||
if (/^https?:\/\//.test(value) || value.startsWith("file://")) return value;
|
||||
return pathToFileURL(path.resolve(value)).toString();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Locator, Page } from "playwright";
|
||||
import { shortHash } from "./hash.js";
|
||||
import type { LocatorCandidate, LocatorDescriptor, LocatorScopeDescriptor, RawActionableElement } from "./types.js";
|
||||
import type { BaseLocatorDescriptor, LocatorCandidate, LocatorDescriptor, LocatorScopeDescriptor, RawActionableElement } from "./types.js";
|
||||
|
||||
type LocatorRoot = Page | Locator;
|
||||
|
||||
export function generateLocators(elementId: string, raw: RawActionableElement): LocatorCandidate[] {
|
||||
const candidates: Array<Omit<LocatorCandidate, "locatorId" | "elementId" | "verified" | "failureCount" | "identity">> = [];
|
||||
@@ -20,6 +22,23 @@ export function generateLocators(elementId: string, raw: RawActionableElement):
|
||||
},
|
||||
];
|
||||
|
||||
if (raw.role && name) {
|
||||
const scope = executableScope(scopes, name);
|
||||
if (scope) {
|
||||
const target: BaseLocatorDescriptor = { kind: "role", role: raw.role, name };
|
||||
candidates.push({
|
||||
framework: "playwright",
|
||||
strategy: "scoped",
|
||||
descriptor: { kind: "scoped", scope, target },
|
||||
expression: `${scopeExpression(scope)}.getByRole(${JSON.stringify(raw.role)}, { name: ${JSON.stringify(name)} })`,
|
||||
score: 0.97,
|
||||
reason: "context-scoped role plus accessible name",
|
||||
scopes,
|
||||
evidence,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (raw.role && name) {
|
||||
candidates.push({
|
||||
framework: "playwright",
|
||||
@@ -258,19 +277,44 @@ export async function verifyLocators(page: Page, raw: RawActionableElement, loca
|
||||
}
|
||||
|
||||
export function resolveLocator(page: Page, descriptor: LocatorDescriptor): Locator {
|
||||
if (descriptor.kind === "scoped") {
|
||||
return resolveBaseLocator(resolveScopeLocator(page, descriptor.scope), descriptor.target);
|
||||
}
|
||||
return resolveBaseLocator(page, descriptor);
|
||||
}
|
||||
|
||||
function resolveBaseLocator(root: LocatorRoot, descriptor: BaseLocatorDescriptor): Locator {
|
||||
switch (descriptor.kind) {
|
||||
case "role":
|
||||
return page.getByRole(descriptor.role as never, descriptor.name ? { name: descriptor.name } : undefined);
|
||||
return root.getByRole(descriptor.role as never, descriptor.name ? { name: descriptor.name } : undefined);
|
||||
case "label":
|
||||
return page.getByLabel(descriptor.label);
|
||||
return root.getByLabel(descriptor.label);
|
||||
case "placeholder":
|
||||
return page.getByPlaceholder(descriptor.placeholder);
|
||||
return root.getByPlaceholder(descriptor.placeholder);
|
||||
case "text":
|
||||
return page.getByText(descriptor.text, { exact: descriptor.exact });
|
||||
return root.getByText(descriptor.text, { exact: descriptor.exact });
|
||||
case "testId":
|
||||
return page.getByTestId(descriptor.testId);
|
||||
return root.getByTestId(descriptor.testId);
|
||||
case "css":
|
||||
return page.locator(descriptor.selector);
|
||||
return root.locator(descriptor.selector);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveScopeLocator(page: Page, scope: LocatorScopeDescriptor): Locator {
|
||||
const text = scope.name ?? scope.text ?? "";
|
||||
switch (scope.kind) {
|
||||
case "row":
|
||||
return page.getByRole("row").filter({ hasText: text });
|
||||
case "dialog":
|
||||
return scope.name ? page.getByRole("dialog", { name: scope.name }) : page.getByRole("dialog").filter({ hasText: text });
|
||||
case "form":
|
||||
return page.locator("form").filter({ hasText: text });
|
||||
case "section":
|
||||
return page.locator("section, article").filter({ hasText: text });
|
||||
case "landmark":
|
||||
return page.locator("nav, aside, header, footer, main, [role='navigation'], [role='main'], [role='complementary']").filter({ hasText: text });
|
||||
case "listitem":
|
||||
return page.getByRole("listitem").filter({ hasText: text });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +336,42 @@ function contextScopes(raw: RawActionableElement): LocatorScopeDescriptor[] {
|
||||
.map((context) => ({ kind: context.kind, name: clean(context.name) ?? null, text: clean(context.text) ?? null }));
|
||||
}
|
||||
|
||||
function executableScope(scopes: LocatorScopeDescriptor[], targetName: string): LocatorScopeDescriptor | null {
|
||||
const scope = scopes.find((item) => ["row", "dialog", "form", "listitem"].includes(item.kind) && Boolean(item.name || item.text));
|
||||
if (!scope) return null;
|
||||
return {
|
||||
...scope,
|
||||
text: shortenScopeText(scope.text, targetName),
|
||||
};
|
||||
}
|
||||
|
||||
function shortenScopeText(scopeText: string | null, targetName: string): string | null {
|
||||
if (!scopeText) return null;
|
||||
const escapedTarget = targetName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const shortened = scopeText.replace(new RegExp(`\\s*${escapedTarget}\\s*$`), "").trim();
|
||||
return clean(shortened) ?? scopeText;
|
||||
}
|
||||
|
||||
function scopeExpression(scope: LocatorScopeDescriptor): string {
|
||||
const text = scope.name ?? scope.text ?? "";
|
||||
switch (scope.kind) {
|
||||
case "row":
|
||||
return `page.getByRole("row").filter({ hasText: ${JSON.stringify(text)} })`;
|
||||
case "dialog":
|
||||
return scope.name
|
||||
? `page.getByRole("dialog", { name: ${JSON.stringify(scope.name)} })`
|
||||
: `page.getByRole("dialog").filter({ hasText: ${JSON.stringify(text)} })`;
|
||||
case "form":
|
||||
return `page.locator("form").filter({ hasText: ${JSON.stringify(text)} })`;
|
||||
case "listitem":
|
||||
return `page.getByRole("listitem").filter({ hasText: ${JSON.stringify(text)} })`;
|
||||
case "section":
|
||||
return `page.locator("section, article").filter({ hasText: ${JSON.stringify(text)} })`;
|
||||
case "landmark":
|
||||
return `page.locator("nav, aside, header, footer, main").filter({ hasText: ${JSON.stringify(text)} })`;
|
||||
}
|
||||
}
|
||||
|
||||
function clean(value: string | null | undefined): string | undefined {
|
||||
const cleaned = value?.replace(/\s+/g, " ").trim();
|
||||
return cleaned || undefined;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ENGINE_VERSION, SCHEMA_VERSION, type GraphMode, type RuntimeContext, type TopologyGraph } from "./types.js";
|
||||
import { shortHash } from "./hash.js";
|
||||
import { summarizeGraph } from "./summary.js";
|
||||
|
||||
const DEFAULT_VIEWPORT = { width: 1440, height: 1000 } as const;
|
||||
|
||||
@@ -33,7 +34,7 @@ export function buildEmptyGraph(args: {
|
||||
"normalization-v0.2",
|
||||
"locator-scoring-v0.2",
|
||||
])}`;
|
||||
return {
|
||||
const graph: TopologyGraph = {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
metadata: {
|
||||
artifactVersion: "topology-graph-v0.2",
|
||||
@@ -59,7 +60,19 @@ export function buildEmptyGraph(args: {
|
||||
edges: [],
|
||||
skippedActions: [],
|
||||
failedObservations: [],
|
||||
summary: {
|
||||
states: 0,
|
||||
elements: 0,
|
||||
edges: 0,
|
||||
skippedActions: 0,
|
||||
failedObservations: 0,
|
||||
failedObservationsByReason: {},
|
||||
skippedActionsByRiskCategory: {},
|
||||
edgesByRiskCategory: {},
|
||||
},
|
||||
};
|
||||
graph.summary = summarizeGraph(graph);
|
||||
return graph;
|
||||
}
|
||||
|
||||
function baseUrl(value: string): string {
|
||||
|
||||
28
src/summary.ts
Normal file
28
src/summary.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { GraphSummary, TopologyGraph } from "./types.js";
|
||||
|
||||
export function summarizeGraph(graph: TopologyGraph): GraphSummary {
|
||||
return {
|
||||
states: graph.states.length,
|
||||
elements: graph.elements.length,
|
||||
edges: graph.edges.length,
|
||||
skippedActions: graph.skippedActions.length,
|
||||
failedObservations: graph.failedObservations.length,
|
||||
failedObservationsByReason: countBy(graph.failedObservations, (failure) => failure.reason),
|
||||
skippedActionsByRiskCategory: countBy(graph.skippedActions, (skip) => skip.riskCategory),
|
||||
edgesByRiskCategory: countBy(graph.edges, (edge) => edge.riskCategory),
|
||||
};
|
||||
}
|
||||
|
||||
export function refreshGraphSummary(graph: TopologyGraph): TopologyGraph {
|
||||
graph.summary = summarizeGraph(graph);
|
||||
return graph;
|
||||
}
|
||||
|
||||
function countBy<T>(items: T[], keyFor: (item: T) => string): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const item of items) {
|
||||
const key = keyFor(item);
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
22
src/types.ts
22
src/types.ts
@@ -1,5 +1,5 @@
|
||||
export const SCHEMA_VERSION = "0.2" as const;
|
||||
export const ENGINE_VERSION = "0.2.0" as const;
|
||||
export const ENGINE_VERSION = "0.2.2" as const;
|
||||
|
||||
export type GraphMode = "scan" | "explore";
|
||||
export type EvidenceLevel = "observed" | "derived" | "verified" | "heuristic" | "external";
|
||||
@@ -73,7 +73,7 @@ export interface RuntimeContext {
|
||||
authContext: "anonymous" | "storage_state";
|
||||
}
|
||||
|
||||
export type LocatorDescriptor =
|
||||
export type BaseLocatorDescriptor =
|
||||
| { kind: "role"; role: string; name?: string }
|
||||
| { kind: "label"; label: string }
|
||||
| { kind: "placeholder"; placeholder: string }
|
||||
@@ -81,6 +81,10 @@ export type LocatorDescriptor =
|
||||
| { kind: "testId"; testId: string }
|
||||
| { kind: "css"; selector: string };
|
||||
|
||||
export type LocatorDescriptor =
|
||||
| BaseLocatorDescriptor
|
||||
| { kind: "scoped"; scope: LocatorScopeDescriptor; target: BaseLocatorDescriptor };
|
||||
|
||||
export type LocatorFramework = "playwright" | "css";
|
||||
|
||||
export interface LocatorScopeDescriptor {
|
||||
@@ -229,6 +233,18 @@ export interface TopologyGraph {
|
||||
edges: ActionEdge[];
|
||||
skippedActions: SkippedAction[];
|
||||
failedObservations: FailedObservation[];
|
||||
summary: GraphSummary;
|
||||
}
|
||||
|
||||
export interface GraphSummary {
|
||||
states: number;
|
||||
elements: number;
|
||||
edges: number;
|
||||
skippedActions: number;
|
||||
failedObservations: number;
|
||||
failedObservationsByReason: Partial<Record<FailedObservation["reason"], number>>;
|
||||
skippedActionsByRiskCategory: Partial<Record<RiskCategory, number>>;
|
||||
edgesByRiskCategory: Partial<Record<RiskCategory, number>>;
|
||||
}
|
||||
|
||||
export interface SkippedAction {
|
||||
@@ -245,7 +261,7 @@ export interface SkippedAction {
|
||||
|
||||
export interface FailedObservation {
|
||||
failedObservationId: string;
|
||||
stateId: string;
|
||||
stateId: string | null;
|
||||
elementId: string | null;
|
||||
actionType: ActionType | null;
|
||||
reason:
|
||||
|
||||
Reference in New Issue
Block a user