Initialize action topology engine
This commit is contained in:
189
src/engine.ts
Normal file
189
src/engine.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
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 { isLowRiskClickable, classifyClickRisk } from "./risk.js";
|
||||
import { snapshotPage } from "./snapshot.js";
|
||||
import type { ActionEdge, EffectRecord, TopologyGraph } from "./types.js";
|
||||
|
||||
export interface EngineOptions {
|
||||
url: string;
|
||||
out?: string;
|
||||
headed?: boolean;
|
||||
storageState?: string;
|
||||
maxActions?: number;
|
||||
}
|
||||
|
||||
export async function scanUrl(options: EngineOptions): Promise<TopologyGraph> {
|
||||
const browser = await chromium.launch({ headless: !options.headed });
|
||||
try {
|
||||
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: 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: [],
|
||||
};
|
||||
await maybeWriteGraph(graph, options.out);
|
||||
await context.close();
|
||||
return graph;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function exploreUrl(options: EngineOptions): Promise<TopologyGraph> {
|
||||
const browser = await chromium.launch({ headless: !options.headed });
|
||||
try {
|
||||
const context = await newContext(browser, options);
|
||||
const entryUrl = toNavigableUrl(options.url);
|
||||
const page = await context.newPage();
|
||||
await page.goto(entryUrl, { waitUntil: "domcontentloaded" });
|
||||
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 candidates = entrySnapshot.elements.filter(isLowRiskClickable).slice(0, options.maxActions ?? 10);
|
||||
await page.close();
|
||||
|
||||
for (const element of candidates) {
|
||||
const actionPage = await context.newPage();
|
||||
const networkEvents: EffectRecord[] = [];
|
||||
actionPage.on("response", (response) => {
|
||||
const record = responseToEffect(response);
|
||||
if (record) networkEvents.push(record);
|
||||
});
|
||||
|
||||
await actionPage.goto(entryUrl, { waitUntil: "domcontentloaded" });
|
||||
await actionPage.waitForLoadState("networkidle").catch(() => undefined);
|
||||
const beforeUrl = actionPage.url();
|
||||
const beforeSnapshot = await snapshotPage(actionPage);
|
||||
const locator = element.locators.find((candidate) => candidate.verified) ?? element.locators[0];
|
||||
|
||||
let toStateId: string | null = null;
|
||||
const effects: EffectRecord[] = [];
|
||||
if (locator) {
|
||||
try {
|
||||
await resolveLocator(actionPage, locator.descriptor).first().click({ timeout: 2000 });
|
||||
await actionPage.waitForLoadState("networkidle", { timeout: 2000 }).catch(() => undefined);
|
||||
const afterSnapshot = await snapshotPage(actionPage);
|
||||
graph.states = upsertById(graph.states, afterSnapshot.state, "stateId");
|
||||
graph.elements.push(...afterSnapshot.elements);
|
||||
toStateId = afterSnapshot.state.stateId;
|
||||
effects.push(...diffEffects(beforeUrl, beforeSnapshot.visibleText, actionPage.url(), afterSnapshot.visibleText));
|
||||
} catch (error) {
|
||||
effects.push({
|
||||
type: "dom",
|
||||
description: `Action failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const edge: ActionEdge = {
|
||||
edgeId: `edge_${shortHash([entrySnapshot.state.stateId, element.elementId, "click"])}`,
|
||||
fromStateId: entrySnapshot.state.stateId,
|
||||
toStateId,
|
||||
elementId: element.elementId,
|
||||
actionType: "click",
|
||||
actionParams: {},
|
||||
preconditions: [],
|
||||
effects: [...effects, ...networkEvents],
|
||||
parameterSpecs: [],
|
||||
riskLevel: classifyClickRisk(element),
|
||||
confidence: locator?.verified ? 0.82 : 0.55,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
graph.edges = upsertById(graph.edges, edge, "edgeId");
|
||||
await actionPage.close();
|
||||
}
|
||||
|
||||
await maybeWriteGraph(graph, options.out);
|
||||
await context.close();
|
||||
return graph;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function newContext(browser: Browser, options: EngineOptions): Promise<BrowserContext> {
|
||||
return browser.newContext({
|
||||
storageState: options.storageState,
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
});
|
||||
}
|
||||
|
||||
async function maybeWriteGraph(graph: TopologyGraph, out?: string): Promise<void> {
|
||||
if (!out) {
|
||||
console.log(JSON.stringify(graph, null, 2));
|
||||
return;
|
||||
}
|
||||
await mkdir(path.dirname(out), { recursive: true });
|
||||
await writeFile(out, `${JSON.stringify(graph, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function toNavigableUrl(value: string): string {
|
||||
if (/^https?:\/\//.test(value) || value.startsWith("file://")) return value;
|
||||
return pathToFileURL(path.resolve(value)).toString();
|
||||
}
|
||||
|
||||
function diffEffects(beforeUrl: string, beforeText: string, afterUrl: string, afterText: string): EffectRecord[] {
|
||||
const effects: EffectRecord[] = [];
|
||||
if (beforeUrl !== afterUrl) {
|
||||
effects.push({ type: "url", before: beforeUrl, after: afterUrl, description: "URL changed after action" });
|
||||
}
|
||||
if (shortHash(beforeText) !== shortHash(afterText)) {
|
||||
effects.push({
|
||||
type: "visibleText",
|
||||
before: shortHash(beforeText),
|
||||
after: shortHash(afterText),
|
||||
description: "Visible text changed after action",
|
||||
});
|
||||
}
|
||||
return effects;
|
||||
}
|
||||
|
||||
function responseToEffect(response: Response): EffectRecord | null {
|
||||
const request = response.request();
|
||||
const resourceType = request.resourceType();
|
||||
if (!["fetch", "xhr", "document"].includes(resourceType)) return null;
|
||||
return {
|
||||
type: "network",
|
||||
method: request.method(),
|
||||
url: response.url(),
|
||||
status: response.status(),
|
||||
description: `${request.method()} ${response.url()} -> ${response.status()}`,
|
||||
};
|
||||
}
|
||||
|
||||
function upsertById<T extends Record<K, string>, K extends keyof T>(items: T[], item: T, key: K): T[] {
|
||||
const index = items.findIndex((existing) => existing[key] === item[key]);
|
||||
if (index === -1) return [...items, item];
|
||||
const next = [...items];
|
||||
next[index] = item;
|
||||
return next;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user