hangwin--mcp-chrome
337 行
10 KiB
TypeScript
337 行
10 KiB
TypeScript
// rr-graph.ts — shared DAG helpers for Record & Replay
|
|
// Note: keep types lightweight to avoid cross-package coupling
|
|
// Centralize step type strings and tiny helpers here to avoid magic literals.
|
|
|
|
import { EDGE_LABELS, type EdgeLabel } from './labels';
|
|
|
|
export interface RRNode {
|
|
id: string;
|
|
type: string;
|
|
config?: Record<string, unknown>;
|
|
}
|
|
export interface RREdge {
|
|
id: string;
|
|
from: string;
|
|
to: string;
|
|
label?: EdgeLabel;
|
|
}
|
|
|
|
// Centralized step type strings (kept in shared to avoid duplication)
|
|
export const RR_STEP_TYPES = {
|
|
CLICK: 'click',
|
|
DBLCLICK: 'dblclick',
|
|
FILL: 'fill',
|
|
DRAG: 'drag',
|
|
KEY: 'key',
|
|
WAIT: 'wait',
|
|
ASSERT: 'assert',
|
|
IF: 'if',
|
|
FOREACH: 'foreach',
|
|
WHILE: 'while',
|
|
NAVIGATE: 'navigate',
|
|
SCRIPT: 'script',
|
|
HTTP: 'http',
|
|
EXTRACT: 'extract',
|
|
SCREENSHOT: 'screenshot',
|
|
SCROLL: 'scroll',
|
|
TRIGGER_EVENT: 'triggerEvent',
|
|
SET_ATTRIBUTE: 'setAttribute',
|
|
LOOP_ELEMENTS: 'loopElements',
|
|
SWITCH_FRAME: 'switchFrame',
|
|
OPEN_TAB: 'openTab',
|
|
SWITCH_TAB: 'switchTab',
|
|
CLOSE_TAB: 'closeTab',
|
|
EXECUTE_FLOW: 'executeFlow',
|
|
HANDLE_DOWNLOAD: 'handleDownload',
|
|
// UI-only, mapped to WAIT
|
|
DELAY: 'delay',
|
|
} as const;
|
|
export type RRStepType = (typeof RR_STEP_TYPES)[keyof typeof RR_STEP_TYPES];
|
|
|
|
function ensureTarget(t: any) {
|
|
return t && typeof t === 'object' ? t : { candidates: [] };
|
|
}
|
|
|
|
// Topological order using Kahn's algorithm; edges considered as-is (caller may pre-filter labels)
|
|
export function topoOrder<T extends RRNode>(nodes: T[], edges: RREdge[]): T[] {
|
|
const id2n = new Map(nodes.map((n) => [n.id, n] as const));
|
|
const indeg = new Map<string, number>(nodes.map((n) => [n.id, 0] as const));
|
|
for (const e of edges) indeg.set(e.to, (indeg.get(e.to) || 0) + 1);
|
|
const nexts = new Map<string, string[]>(nodes.map((n) => [n.id, [] as string[]] as const));
|
|
for (const e of edges) nexts.get(e.from)!.push(e.to);
|
|
const q: string[] = nodes.filter((n) => (indeg.get(n.id) || 0) === 0).map((n) => n.id);
|
|
const out: T[] = [];
|
|
while (q.length) {
|
|
const id = q.shift()!;
|
|
const n = id2n.get(id);
|
|
if (!n) continue;
|
|
out.push(n as T);
|
|
for (const v of nexts.get(id)!) {
|
|
indeg.set(v, (indeg.get(v) || 0) - 1);
|
|
if ((indeg.get(v) || 0) === 0) q.push(v);
|
|
}
|
|
}
|
|
return out.length === nodes.length ? out : nodes.slice();
|
|
}
|
|
|
|
// Map a Node (Flow V2) to a linear Step (Flow V1)
|
|
export function mapNodeToStep(node: RRNode): any {
|
|
const c: any = node.config || {};
|
|
const base = { id: node.id } as any;
|
|
// Config-driven generic mapping (prefer this path)
|
|
try {
|
|
const type = String(node.type);
|
|
// UI-only helper: delay -> wait.sleep
|
|
if (type === 'delay') {
|
|
const sleep = Number((c as any).sleep ?? (c as any).ms ?? 1000);
|
|
return { ...base, type: 'wait', condition: { sleep: Math.max(0, sleep) } };
|
|
}
|
|
const step: any = { ...base, type, ...c };
|
|
if (step.target) step.target = ensureTarget(step.target);
|
|
if (step.start) step.start = ensureTarget(step.start);
|
|
if (step.end) step.end = ensureTarget(step.end);
|
|
return step;
|
|
} catch {}
|
|
switch (node.type) {
|
|
case RR_STEP_TYPES.CLICK:
|
|
case RR_STEP_TYPES.DBLCLICK:
|
|
return {
|
|
...base,
|
|
type: node.type,
|
|
target: ensureTarget(c.target),
|
|
before: c.before,
|
|
after: c.after,
|
|
};
|
|
case RR_STEP_TYPES.FILL:
|
|
return {
|
|
...base,
|
|
type: RR_STEP_TYPES.FILL,
|
|
target: ensureTarget(c.target),
|
|
value: c.value || '',
|
|
};
|
|
case RR_STEP_TYPES.DRAG:
|
|
return {
|
|
...base,
|
|
type: RR_STEP_TYPES.DRAG,
|
|
start: ensureTarget(c.start),
|
|
end: ensureTarget(c.end),
|
|
path: Array.isArray(c.path) ? c.path : undefined,
|
|
};
|
|
case RR_STEP_TYPES.KEY:
|
|
return { ...base, type: RR_STEP_TYPES.KEY, keys: c.keys || '' };
|
|
case RR_STEP_TYPES.WAIT:
|
|
return {
|
|
...base,
|
|
type: RR_STEP_TYPES.WAIT,
|
|
condition: c.condition || { text: '', appear: true },
|
|
};
|
|
case RR_STEP_TYPES.ASSERT:
|
|
return {
|
|
...base,
|
|
type: RR_STEP_TYPES.ASSERT,
|
|
assert: c.assert || { exists: '' },
|
|
failStrategy: c.failStrategy,
|
|
};
|
|
case RR_STEP_TYPES.IF:
|
|
return { ...base, type: RR_STEP_TYPES.IF, condition: c.condition || {} };
|
|
case RR_STEP_TYPES.FOREACH:
|
|
return {
|
|
...base,
|
|
type: RR_STEP_TYPES.FOREACH,
|
|
listVar: c.listVar || '',
|
|
itemVar: c.itemVar || 'item',
|
|
subflowId: c.subflowId || '',
|
|
};
|
|
case RR_STEP_TYPES.WHILE:
|
|
return {
|
|
...base,
|
|
type: RR_STEP_TYPES.WHILE,
|
|
condition: c.condition || {},
|
|
subflowId: c.subflowId || '',
|
|
maxIterations: Math.max(0, Number(c.maxIterations ?? 100)),
|
|
};
|
|
case RR_STEP_TYPES.NAVIGATE:
|
|
return { ...base, type: RR_STEP_TYPES.NAVIGATE, url: c.url || '' };
|
|
case RR_STEP_TYPES.SCRIPT:
|
|
return {
|
|
...base,
|
|
type: RR_STEP_TYPES.SCRIPT,
|
|
world: c.world || 'ISOLATED',
|
|
code: c.code || '',
|
|
when: c.when,
|
|
};
|
|
case RR_STEP_TYPES.DELAY: // map to wait.sleep to avoid navigation confusion
|
|
return {
|
|
...base,
|
|
type: RR_STEP_TYPES.WAIT,
|
|
condition: { sleep: Math.max(0, Number(c.ms ?? 1000)) },
|
|
};
|
|
case RR_STEP_TYPES.HTTP:
|
|
return {
|
|
...base,
|
|
type: RR_STEP_TYPES.HTTP,
|
|
method: c.method || 'GET',
|
|
url: c.url || '',
|
|
headers: c.headers || {},
|
|
body: c.body,
|
|
formData: c.formData,
|
|
saveAs: c.saveAs || '',
|
|
};
|
|
case RR_STEP_TYPES.EXTRACT:
|
|
return {
|
|
...base,
|
|
type: RR_STEP_TYPES.EXTRACT,
|
|
selector: c.selector || '',
|
|
attr: c.attr || 'text',
|
|
js: c.js || '',
|
|
saveAs: c.saveAs || '',
|
|
};
|
|
case RR_STEP_TYPES.SCREENSHOT:
|
|
return {
|
|
...base,
|
|
type: RR_STEP_TYPES.SCREENSHOT,
|
|
selector: c.selector || '',
|
|
fullPage: !!c.fullPage,
|
|
saveAs: c.saveAs || '',
|
|
};
|
|
case RR_STEP_TYPES.SCROLL:
|
|
return {
|
|
...base,
|
|
type: RR_STEP_TYPES.SCROLL,
|
|
mode: c.mode || 'offset',
|
|
target: ensureTarget(c.target),
|
|
offset: c.offset || { x: 0, y: 300 },
|
|
};
|
|
case RR_STEP_TYPES.TRIGGER_EVENT:
|
|
return {
|
|
...base,
|
|
type: RR_STEP_TYPES.TRIGGER_EVENT,
|
|
target: ensureTarget(c.target),
|
|
event: c.event || 'input',
|
|
bubbles: c.bubbles !== false,
|
|
cancelable: !!c.cancelable,
|
|
};
|
|
case RR_STEP_TYPES.SET_ATTRIBUTE:
|
|
return {
|
|
...base,
|
|
type: RR_STEP_TYPES.SET_ATTRIBUTE,
|
|
target: ensureTarget(c.target),
|
|
name: c.name || '',
|
|
value: c.value,
|
|
remove: !!c.remove,
|
|
};
|
|
case RR_STEP_TYPES.LOOP_ELEMENTS:
|
|
return {
|
|
...base,
|
|
type: RR_STEP_TYPES.LOOP_ELEMENTS,
|
|
selector: c.selector || '',
|
|
saveAs: c.saveAs || 'elements',
|
|
itemVar: c.itemVar || 'item',
|
|
subflowId: c.subflowId || '',
|
|
};
|
|
case RR_STEP_TYPES.SWITCH_FRAME:
|
|
return {
|
|
...base,
|
|
type: RR_STEP_TYPES.SWITCH_FRAME,
|
|
frame: {
|
|
index: c.frame && c.frame.index != null ? Number(c.frame.index) : undefined,
|
|
urlContains: c.frame?.urlContains || '',
|
|
},
|
|
};
|
|
case RR_STEP_TYPES.OPEN_TAB:
|
|
return { ...base, type: RR_STEP_TYPES.OPEN_TAB, url: c.url || '', newWindow: !!c.newWindow };
|
|
case RR_STEP_TYPES.SWITCH_TAB:
|
|
return {
|
|
...base,
|
|
type: RR_STEP_TYPES.SWITCH_TAB,
|
|
tabId: c.tabId || undefined,
|
|
urlContains: c.urlContains || '',
|
|
titleContains: c.titleContains || '',
|
|
};
|
|
case RR_STEP_TYPES.CLOSE_TAB:
|
|
return {
|
|
...base,
|
|
type: RR_STEP_TYPES.CLOSE_TAB,
|
|
tabIds: Array.isArray(c.tabIds) ? c.tabIds : undefined,
|
|
url: c.url || '',
|
|
};
|
|
case RR_STEP_TYPES.EXECUTE_FLOW:
|
|
return {
|
|
...base,
|
|
type: RR_STEP_TYPES.EXECUTE_FLOW,
|
|
flowId: c.flowId || '',
|
|
inline: c.inline !== false,
|
|
args: c.args || {},
|
|
};
|
|
case RR_STEP_TYPES.HANDLE_DOWNLOAD:
|
|
return {
|
|
...base,
|
|
type: RR_STEP_TYPES.HANDLE_DOWNLOAD,
|
|
filenameContains: c.filenameContains || '',
|
|
waitForComplete: c.waitForComplete !== false,
|
|
timeoutMs: Math.max(0, Number(c.timeoutMs ?? 60000)),
|
|
saveAs: c.saveAs || '',
|
|
};
|
|
default:
|
|
return { ...base, type: RR_STEP_TYPES.SCRIPT, world: 'ISOLATED', code: '' };
|
|
}
|
|
}
|
|
|
|
export function nodesToSteps(nodes: RRNode[], edges: RREdge[]): any[] {
|
|
const order = edges && edges.length ? topoOrder(nodes, edges) : nodes.slice();
|
|
return order.map((n) => mapNodeToStep(n));
|
|
}
|
|
|
|
// Reverse mapping (Step -> Node config)
|
|
export function mapStepToNodeConfig(step: unknown): Record<string, unknown> {
|
|
if (!step || typeof step !== 'object') return {};
|
|
const src = step as Record<string, unknown>;
|
|
const out: Record<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(src)) {
|
|
if (k === 'id' || k === 'type') continue;
|
|
out[k] = v;
|
|
}
|
|
const target = out['target'];
|
|
if (target) out['target'] = ensureTarget(target);
|
|
const start = out['start'];
|
|
if (start) out['start'] = ensureTarget(start);
|
|
const end = out['end'];
|
|
if (end) out['end'] = ensureTarget(end);
|
|
return out;
|
|
}
|
|
|
|
export function stepsToNodes(steps: ReadonlyArray<unknown>): RRNode[] {
|
|
const arr: RRNode[] = [];
|
|
steps.forEach((step, i) => {
|
|
const obj: Record<string, unknown> =
|
|
step && typeof step === 'object' ? (step as Record<string, unknown>) : {};
|
|
const idValue = obj['id'];
|
|
const typeValue = obj['type'];
|
|
const id = typeof idValue === 'string' && idValue ? idValue : `n_${i}`;
|
|
const type = typeof typeValue === 'string' && typeValue ? typeValue : RR_STEP_TYPES.SCRIPT;
|
|
arr.push({ id, type, config: mapStepToNodeConfig(step) });
|
|
});
|
|
return arr;
|
|
}
|
|
|
|
/**
|
|
* Convert linear steps array to DAG format (nodes + edges).
|
|
* Generates sequential edges connecting nodes in order.
|
|
*/
|
|
export function stepsToDAG(steps: ReadonlyArray<unknown>): { nodes: RRNode[]; edges: RREdge[] } {
|
|
const nodes = stepsToNodes(steps);
|
|
const edges: RREdge[] = [];
|
|
for (let i = 0; i < nodes.length - 1; i++) {
|
|
const from = nodes[i].id;
|
|
const to = nodes[i + 1].id;
|
|
// Include index in edge id to avoid collision when step ids repeat
|
|
edges.push({
|
|
id: `e_${i}_${from}_${to}`,
|
|
from,
|
|
to,
|
|
label: EDGE_LABELS.DEFAULT,
|
|
});
|
|
}
|
|
return { nodes, edges };
|
|
}
|