Initialize action topology engine

This commit is contained in:
赵义仑
2026-06-16 14:26:08 +08:00
commit 853404c67a
17 changed files with 3664 additions and 0 deletions

66
src/cli.ts Normal file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env node
import { exploreUrl, scanUrl, type EngineOptions } from "./engine.js";
interface ParsedArgs extends EngineOptions {
command: "scan" | "explore";
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const graph = args.command === "scan" ? await scanUrl(args) : await exploreUrl(args);
if (args.out) {
console.error(`Wrote ${graph.states.length} state(s), ${graph.elements.length} element(s), ${graph.edges.length} edge(s) to ${args.out}`);
}
}
function parseArgs(argv: string[]): ParsedArgs {
const command = argv.shift();
if (command !== "scan" && command !== "explore") usage();
const options: Partial<ParsedArgs> = { command };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--url":
options.url = requireValue(argv, ++index, "--url");
break;
case "--out":
options.out = requireValue(argv, ++index, "--out");
break;
case "--storage-state":
options.storageState = requireValue(argv, ++index, "--storage-state");
break;
case "--max-actions":
options.maxActions = Number(requireValue(argv, ++index, "--max-actions"));
break;
case "--headed":
options.headed = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!options.url) usage();
return options as ParsedArgs;
}
function requireValue(argv: string[], index: number, flag: string): string {
const value = argv[index];
if (!value) throw new Error(`Missing value for ${flag}`);
return value;
}
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]
`);
process.exit(1);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});

189
src/engine.ts Normal file
View 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;
}

27
src/hash.ts Normal file
View File

@@ -0,0 +1,27 @@
import { createHash } from "node:crypto";
export function stableHash(value: unknown): string {
const normalized = typeof value === "string" ? value : stableStringify(value);
return createHash("sha256").update(normalized).digest("hex");
}
export function shortHash(value: unknown, length = 12): string {
return stableHash(value).slice(0, length);
}
function stableStringify(value: unknown): string {
return JSON.stringify(sortValue(value));
}
function sortValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortValue);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, item]) => [key, sortValue(item)]),
);
}
return value;
}

170
src/locator.ts Normal file
View File

@@ -0,0 +1,170 @@
import type { Locator, Page } from "playwright";
import { shortHash } from "./hash.js";
import type { LocatorCandidate, LocatorDescriptor, RawActionableElement } from "./types.js";
export function generateLocators(elementId: string, raw: RawActionableElement): LocatorCandidate[] {
const candidates: Array<Omit<LocatorCandidate, "locatorId" | "elementId" | "verified" | "failureCount">> = [];
const name = clean(raw.accessibleName);
const label = clean(raw.label);
const placeholder = clean(raw.placeholder);
const text = clean(raw.text);
const testId = raw.attributes["data-testid"] || raw.attributes["data-test-id"] || raw.attributes["data-test"];
const id = raw.attributes.id;
const nameAttr = raw.attributes.name;
if (raw.role && name) {
candidates.push({
framework: "playwright",
strategy: "role",
descriptor: { kind: "role", role: raw.role, name },
expression: `page.getByRole(${JSON.stringify(raw.role)}, { name: ${JSON.stringify(name)} })`,
score: 0.95,
reason: "role plus accessible name",
});
}
if (testId) {
candidates.push({
framework: "playwright",
strategy: "testId",
descriptor: { kind: "testId", testId },
expression: `page.getByTestId(${JSON.stringify(testId)})`,
score: 0.92,
reason: "explicit test id",
});
}
if (label) {
candidates.push({
framework: "playwright",
strategy: "label",
descriptor: { kind: "label", label },
expression: `page.getByLabel(${JSON.stringify(label)})`,
score: 0.9,
reason: "associated label",
});
}
if (placeholder) {
candidates.push({
framework: "playwright",
strategy: "placeholder",
descriptor: { kind: "placeholder", placeholder },
expression: `page.getByPlaceholder(${JSON.stringify(placeholder)})`,
score: 0.86,
reason: "placeholder text",
});
}
if (text && text.length <= 80 && ["a", "button"].includes(raw.tag)) {
candidates.push({
framework: "playwright",
strategy: "text",
descriptor: { kind: "text", text, exact: true },
expression: `page.getByText(${JSON.stringify(text)}, { exact: true })`,
score: 0.75,
reason: "visible text fallback",
});
}
if (id && /^[A-Za-z][A-Za-z0-9_-]*$/.test(id)) {
candidates.push({
framework: "css",
strategy: "css",
descriptor: { kind: "css", selector: `#${cssEscape(id)}` },
expression: `page.locator(${JSON.stringify(`#${cssEscape(id)}`)})`,
score: 0.72,
reason: "id selector fallback",
});
}
if (nameAttr) {
candidates.push({
framework: "css",
strategy: "css",
descriptor: { kind: "css", selector: `${raw.tag}[name="${cssString(nameAttr)}"]` },
expression: `page.locator(${JSON.stringify(`${raw.tag}[name="${cssString(nameAttr)}"]`)})`,
score: 0.68,
reason: "name attribute fallback",
});
}
return dedupeCandidates(candidates).map((candidate) => ({
...candidate,
locatorId: `loc_${shortHash([elementId, candidate.descriptor])}`,
elementId,
verified: false,
failureCount: 0,
}));
}
export async function verifyLocators(page: Page, locators: LocatorCandidate[]): Promise<LocatorCandidate[]> {
const verifiedAt = new Date().toISOString();
const verified: LocatorCandidate[] = [];
for (const locator of locators) {
try {
const matchCount = await resolveLocator(page, locator.descriptor).count();
verified.push({
...locator,
verified: matchCount === 1,
matchCount,
lastVerifiedAt: verifiedAt,
score: matchCount === 1 ? locator.score : Math.max(0.1, locator.score - 0.35),
});
} catch {
verified.push({
...locator,
verified: false,
matchCount: 0,
lastVerifiedAt: verifiedAt,
score: Math.max(0.1, locator.score - 0.45),
failureCount: locator.failureCount + 1,
});
}
}
return verified.sort((a, b) => b.score - a.score);
}
export function resolveLocator(page: Page, descriptor: LocatorDescriptor): Locator {
switch (descriptor.kind) {
case "role":
return page.getByRole(descriptor.role as never, descriptor.name ? { name: descriptor.name } : undefined);
case "label":
return page.getByLabel(descriptor.label);
case "placeholder":
return page.getByPlaceholder(descriptor.placeholder);
case "text":
return page.getByText(descriptor.text, { exact: descriptor.exact });
case "testId":
return page.getByTestId(descriptor.testId);
case "css":
return page.locator(descriptor.selector);
}
}
function dedupeCandidates<T extends { expression: string }>(candidates: T[]): T[] {
const seen = new Set<string>();
const deduped: T[] = [];
for (const candidate of candidates) {
if (seen.has(candidate.expression)) continue;
seen.add(candidate.expression);
deduped.push(candidate);
}
return deduped;
}
function clean(value: string | null | undefined): string | undefined {
const cleaned = value?.replace(/\s+/g, " ").trim();
return cleaned || undefined;
}
function cssEscape(value: string): string {
return value.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
}
function cssString(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}

51
src/risk.ts Normal file
View File

@@ -0,0 +1,51 @@
import type { ActionableElement } from "./types.js";
const HIGH_RISK_TERMS = [
"delete",
"remove",
"destroy",
"drop",
"logout",
"sign out",
"pay",
"purchase",
"checkout",
"submit",
"confirm",
"authorize",
"删除",
"移除",
"注销",
"退出",
"支付",
"购买",
"提交",
"确认",
"授权",
];
export function classifyClickRisk(element: ActionableElement): "low" | "medium" | "high" {
const joined = [
element.accessibleName,
element.text,
element.label,
element.placeholder,
element.attributes.href,
element.attributes.type,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (HIGH_RISK_TERMS.some((term) => joined.includes(term.toLowerCase()))) return "high";
if (element.tag === "input" || element.tag === "textarea" || element.tag === "select") return "medium";
return "low";
}
export function isLowRiskClickable(element: ActionableElement): boolean {
if (!element.interactability.visible || !element.interactability.enabled || !element.interactability.receivesEvents) return false;
if (classifyClickRisk(element) !== "low") return false;
const role = element.role;
return element.tag === "button" || element.tag === "a" || role === "button" || role === "link" || role === "tab" || role === "menuitem";
}

251
src/snapshot.ts Normal file
View File

@@ -0,0 +1,251 @@
import type { Page } from "playwright";
import { shortHash, stableHash } from "./hash.js";
import { generateLocators, verifyLocators } from "./locator.js";
import type { ActionableElement, RawActionableElement, SnapshotResult, StateNode } from "./types.js";
export async function snapshotPage(page: Page, openedByEdgeId: string | null = null): Promise<SnapshotResult> {
const raw = await page.evaluate(collectPageSnapshot);
const createdAt = new Date().toISOString();
const stateHash = stableHash({
url: raw.url,
title: raw.title,
domHash: stableHash(raw.dom),
visibleTextHash: stableHash(raw.visibleText),
actionableSignatureHash: stableHash(raw.elements.map(actionableSignature)),
});
const state: StateNode = {
stateId: `state_${shortHash(stateHash)}`,
url: raw.url,
route: raw.route,
title: raw.title,
frameContext: [],
stateHash,
domHash: stableHash(raw.dom),
visibleTextHash: stableHash(raw.visibleText),
actionableSignatureHash: stableHash(raw.elements.map(actionableSignature)),
modalStack: raw.modalStack,
openedByEdgeId,
createdAt,
};
const elements: ActionableElement[] = [];
for (const rawElement of raw.elements) {
const elementId = `el_${shortHash([state.stateId, rawElement.uid, actionableSignature(rawElement)])}`;
const locators = await verifyLocators(page, generateLocators(elementId, rawElement));
elements.push({
elementId,
stateId: state.stateId,
tag: rawElement.tag,
role: rawElement.role,
accessibleName: rawElement.accessibleName,
text: rawElement.text,
label: rawElement.label,
placeholder: rawElement.placeholder,
attributes: rawElement.attributes,
bbox: rawElement.bbox,
framePath: [],
shadowPath: [],
interactability: rawElement.visibility,
locators,
});
}
return { state, elements, visibleText: raw.visibleText };
}
interface BrowserSnapshot {
url: string;
route: string;
title: string;
dom: string;
visibleText: string;
modalStack: string[];
elements: RawActionableElement[];
}
function actionableSignature(element: RawActionableElement): unknown {
return {
tag: element.tag,
role: element.role,
accessibleName: element.accessibleName,
text: element.text,
label: element.label,
placeholder: element.placeholder,
attributes: element.attributes,
};
}
function collectPageSnapshot(): BrowserSnapshot {
const actionableSelector = [
"a[href]",
"button",
"input",
"select",
"textarea",
"[role]",
"[onclick]",
"[aria-haspopup]",
"[aria-expanded]",
"[contenteditable='true']",
"[tabindex]:not([tabindex='-1'])",
].join(",");
const elements = Array.from(document.querySelectorAll<HTMLElement>(actionableSelector))
.filter((element) => isCandidate(element))
.slice(0, 500)
.map((element, index) => toRawElement(element, index));
return {
url: location.href,
route: `${location.pathname}${location.search}`,
title: document.title,
dom: document.documentElement.outerHTML,
visibleText: normalizeText(document.body?.innerText || ""),
modalStack: Array.from(document.querySelectorAll<HTMLElement>("[role='dialog'], dialog[open], [aria-modal='true']"))
.filter((element) => isVisible(element))
.map((element) => getAccessibleName(element) || element.id || element.tagName.toLowerCase()),
elements,
};
function isCandidate(element: HTMLElement): boolean {
const tag = element.tagName.toLowerCase();
if (tag === "input" && (element as HTMLInputElement).type === "hidden") return false;
if (!isVisible(element)) return false;
const box = element.getBoundingClientRect();
return box.width > 0 && box.height > 0;
}
function toRawElement(element: HTMLElement, index: number): RawActionableElement {
const rect = element.getBoundingClientRect();
const tag = element.tagName.toLowerCase();
const attributes = collectAttributes(element);
const text = normalizeText(element.innerText || element.textContent || "");
const role = attributes.role || inferRole(element);
const label = getLabel(element);
const placeholder = "placeholder" in element ? normalizeNullable(String((element as HTMLInputElement).placeholder || "")) : null;
const accessibleName = normalizeNullable(getAccessibleName(element) || label || placeholder || text);
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const topAtCenter = document.elementFromPoint(centerX, centerY);
return {
uid: `${tag}_${index}_${attributes.id || attributes.name || accessibleName || text || ""}`,
tag,
role,
accessibleName,
text: normalizeNullable(text),
label,
placeholder,
attributes,
bbox: {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
},
visibility: {
visible: isVisible(element),
enabled: !("disabled" in element && Boolean((element as HTMLButtonElement).disabled)),
editable: isEditable(element),
receivesEvents: topAtCenter === element || Boolean(topAtCenter && element.contains(topAtCenter)),
},
};
}
function collectAttributes(element: HTMLElement): Record<string, string> {
const keep = new Set([
"id",
"name",
"type",
"href",
"value",
"role",
"aria-label",
"aria-labelledby",
"aria-expanded",
"aria-haspopup",
"aria-controls",
"aria-disabled",
"data-testid",
"data-test-id",
"data-test",
]);
const attrs: Record<string, string> = {};
for (const attr of Array.from(element.attributes)) {
if (keep.has(attr.name)) attrs[attr.name] = attr.value;
}
return attrs;
}
function inferRole(element: HTMLElement): string | null {
const tag = element.tagName.toLowerCase();
if (tag === "button") return "button";
if (tag === "a" && element.hasAttribute("href")) return "link";
if (tag === "select") return "combobox";
if (tag === "textarea") return "textbox";
if (tag === "input") {
const type = ((element as HTMLInputElement).type || "text").toLowerCase();
if (["button", "submit", "reset"].includes(type)) return "button";
if (type === "checkbox") return "checkbox";
if (type === "radio") return "radio";
return "textbox";
}
return null;
}
function getAccessibleName(element: HTMLElement): string | null {
const ariaLabel = element.getAttribute("aria-label");
if (ariaLabel) return normalizeNullable(ariaLabel);
const labelledBy = element.getAttribute("aria-labelledby");
if (labelledBy) {
const text = labelledBy
.split(/\s+/)
.map((id) => document.getElementById(id)?.innerText || document.getElementById(id)?.textContent || "")
.join(" ");
if (text.trim()) return normalizeNullable(text);
}
const title = element.getAttribute("title");
if (title) return normalizeNullable(title);
const alt = element.getAttribute("alt");
if (alt) return normalizeNullable(alt);
return normalizeNullable(element.innerText || element.textContent || "");
}
function getLabel(element: HTMLElement): string | null {
const id = element.getAttribute("id");
if (id) {
const explicit = document.querySelector<HTMLLabelElement>(`label[for="${CSS.escape(id)}"]`);
if (explicit) return normalizeNullable(explicit.innerText || explicit.textContent || "");
}
const implicit = element.closest("label");
if (implicit) return normalizeNullable(implicit.innerText || implicit.textContent || "");
return null;
}
function isVisible(element: HTMLElement): boolean {
const style = getComputedStyle(element);
if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity) === 0) return false;
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function isEditable(element: HTMLElement): boolean {
if (element.isContentEditable) return true;
const tag = element.tagName.toLowerCase();
if (tag === "textarea") return true;
if (tag !== "input") return false;
const type = ((element as HTMLInputElement).type || "text").toLowerCase();
return !["button", "submit", "reset", "checkbox", "radio", "hidden"].includes(type);
}
function normalizeText(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function normalizeNullable(value: string): string | null {
const normalized = normalizeText(value);
return normalized || null;
}
}

139
src/types.ts Normal file
View File

@@ -0,0 +1,139 @@
export type LocatorDescriptor =
| { kind: "role"; role: string; name?: string }
| { kind: "label"; label: string }
| { kind: "placeholder"; placeholder: string }
| { kind: "text"; text: string; exact: boolean }
| { kind: "testId"; testId: string }
| { kind: "css"; selector: string };
export type LocatorFramework = "playwright" | "css";
export interface LocatorCandidate {
locatorId: string;
elementId: string;
framework: LocatorFramework;
strategy: LocatorDescriptor["kind"];
descriptor: LocatorDescriptor;
expression: string;
score: number;
reason: string;
verified: boolean;
lastVerifiedAt?: string;
matchCount?: number;
failureCount: number;
}
export interface VisibilityInfo {
visible: boolean;
enabled: boolean;
editable: boolean;
receivesEvents: boolean;
}
export interface BoundingBox {
x: number;
y: number;
width: number;
height: number;
}
export interface ActionableElement {
elementId: string;
stateId: string;
tag: string;
role: string | null;
accessibleName: string | null;
text: string | null;
label: string | null;
placeholder: string | null;
attributes: Record<string, string>;
bbox: BoundingBox | null;
framePath: string[];
shadowPath: string[];
interactability: VisibilityInfo;
locators: LocatorCandidate[];
}
export interface StateNode {
stateId: string;
url: string;
route: string;
title: string;
frameContext: string[];
stateHash: string;
domHash: string;
visibleTextHash: string;
actionableSignatureHash: string;
modalStack: string[];
openedByEdgeId: string | null;
createdAt: string;
}
export type ActionType = "click" | "fill" | "select" | "check" | "uncheck" | "navigate";
export interface EffectRecord {
type: "url" | "dom" | "visibleText" | "network" | "download" | "dialog" | "storage";
before?: string;
after?: string;
method?: string;
url?: string;
status?: number;
description: string;
}
export interface ParameterSpec {
name: string;
type: "string" | "number" | "boolean" | "enum" | "date" | "unknown";
required: boolean;
enumValues?: string[];
pattern?: string;
examples?: string[];
}
export interface ActionEdge {
edgeId: string;
fromStateId: string;
toStateId: string | null;
elementId: string;
actionType: ActionType;
actionParams: Record<string, unknown>;
preconditions: string[];
effects: EffectRecord[];
parameterSpecs: ParameterSpec[];
riskLevel: "low" | "medium" | "high";
confidence: number;
createdAt: string;
}
export interface TopologyGraph {
schemaVersion: "0.1";
metadata: {
entryUrl: string;
generatedAt: string;
engineVersion: string;
mode: "scan" | "explore";
};
states: StateNode[];
elements: ActionableElement[];
edges: ActionEdge[];
}
export interface RawActionableElement {
uid: string;
tag: string;
role: string | null;
accessibleName: string | null;
text: string | null;
label: string | null;
placeholder: string | null;
attributes: Record<string, string>;
bbox: BoundingBox | null;
visibility: VisibilityInfo;
}
export interface SnapshotResult {
state: StateNode;
elements: ActionableElement[];
visibleText: string;
}