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 { buildEmptyGraph, defaultViewport } from "./metadata.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 { 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 = 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; } finally { await browser.close(); } } export async function exploreUrl(options: EngineOptions): Promise { 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 = 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(); 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 { return browser.newContext({ storageState: options.storageState, viewport: defaultViewport(), locale: "en-US", }); } async function maybeWriteGraph(graph: TopologyGraph, out?: string): Promise { 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, 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; }