maestro/src/user-folder/script-compiler.ts
2026-06-03 05:08:00 +00:00

205 lines
7.2 KiB
TypeScript

import type { RecordedAction, FrameChainEntry } from '../engine/browser-recorder.js';
import type { ParamSpec } from './frontmatter.js';
import { serializeScript } from './frontmatter.js';
import type { ScriptMeta } from './frontmatter.js';
// ── Public API ────────────────────────────────────────────────────────────────
export interface CompileScriptOptions {
recording: RecordedAction[];
description: string;
sessionProfileId?: number;
paramHints?: { name: string; valueToReplace: string; type: 'string' | 'number' | 'boolean' }[];
recordingSource?: string;
}
export interface CompiledScript {
/** Frontmatter + body, ready to write to scripts/{name}.js */
source: string;
/** Frontmatter only (parsed back-shape for previewing). */
meta: ScriptMeta;
}
export interface CompiledScriptBody {
/** JS body only (no frontmatter). Pass to serializeScript({ frontmatter, body }). */
body: string;
/** Param specs that were actually used from paramHints (unused hints are dropped). */
paramSpecs: ParamSpec[];
}
// ── Quoting helpers ───────────────────────────────────────────────────────────
/** Single-quoted JS string literal (selector / simple URLs). */
function quoteSingle(s: string): string {
return "'" + s.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
}
/** Double-quoted JS string literal via JSON.stringify — safe for arbitrary strings. */
function quoteDouble(s: string): string {
return JSON.stringify(s);
}
/**
* Normalize a frameChain entry to the structured form.
* Legacy recordings may store `string[]`; we accept both shapes.
*/
function normalizeFrameEntry(e: FrameChainEntry | string): FrameChainEntry {
if (typeof e === 'string') return { selector: e };
return e;
}
/**
* Compose a Playwright FrameLocator / Page chain expression for `frameChain`.
*
* Returns 'page' when chain is empty. Each entry maps to either:
* - `.frameLocator(sel)` when only `selector` is set (unique attr selector)
* - `.locator(sel).nth(N).contentFrame()` when `index` is set (positional fallback)
*
* Both forms return a FrameLocator and compose, so a mixed chain works:
* page.frameLocator('iframe[name="x"]').locator('iframe').nth(0).contentFrame()
*/
function targetExpr(rawChain?: (FrameChainEntry | string)[]): string {
if (!rawChain || rawChain.length === 0) return 'page';
let expr = 'page';
for (const raw of rawChain) {
const entry = normalizeFrameEntry(raw);
if (entry.index !== undefined) {
expr = `${expr}.locator(${quoteSingle(entry.selector)}).nth(${entry.index}).contentFrame()`;
} else {
expr = `${expr}.frameLocator(${quoteSingle(entry.selector)})`;
}
}
return expr;
}
// ── Core compiler ─────────────────────────────────────────────────────────────
/**
* Compile just the JS body from a recording, returning the body string and the
* param specs that were actually used. Callers that need to control frontmatter
* timestamps (e.g. flushAndStageRecording) should use this instead of
* compileScript, then call serializeScript themselves.
*/
export function compileScriptBody(opts: CompileScriptOptions): CompiledScriptBody {
const { recording, paramHints = [] } = opts;
// Build a value→paramHint lookup (first-wins for duplicate valueToReplace)
const hintByValue = new Map<string, typeof paramHints[0]>();
for (const hint of paramHints) {
if (!hintByValue.has(hint.valueToReplace)) {
hintByValue.set(hint.valueToReplace, hint);
}
}
// Track which param names actually appear in the recording (preserving order of first use)
const usedParamNames = new Map<string, ParamSpec>(); // name → spec
// Generate action lines
const lines: string[] = [];
const lastIdx = recording.length - 1;
let returnVar: string | undefined;
for (let i = 0; i < recording.length; i++) {
const action = recording[i];
const isLast = i === lastIdx;
switch (action.type) {
case 'goto': {
const url = action.url ?? '';
lines.push(` await page.goto(${quoteSingle(url)});`);
break;
}
case 'click': {
const selector = action.selector ?? '';
const target = targetExpr(action.frameChain);
lines.push(` await ${target}.locator(${quoteSingle(selector)}).click();`);
break;
}
case 'fill': {
const selector = action.selector ?? '';
const value = action.value ?? '';
const hint = hintByValue.get(value);
let valueExpr: string;
if (hint) {
valueExpr = `params.${hint.name}`;
if (!usedParamNames.has(hint.name)) {
usedParamNames.set(hint.name, { name: hint.name, type: hint.type });
}
} else {
valueExpr = quoteDouble(value);
}
const target = targetExpr(action.frameChain);
lines.push(` await ${target}.locator(${quoteSingle(selector)}).fill(${valueExpr});`);
break;
}
case 'wait': {
const ms = action.ms ?? 0;
lines.push(` await page.waitForTimeout(${ms});`);
break;
}
case 'screenshot': {
const value = action.value ?? '';
lines.push(` await page.screenshot({ path: ${quoteDouble(value)} });`);
break;
}
case 'getText': {
const selector = action.selector ?? '';
const target = targetExpr(action.frameChain);
lines.push(` const __text = await ${target}.locator(${quoteSingle(selector)}).textContent();`);
if (isLast) returnVar = '__text';
break;
}
case 'dumpHtml': {
const selector = action.selector ?? '';
const target = targetExpr(action.frameChain);
lines.push(
` const __html = await ${target}.locator(${quoteSingle(selector)}).evaluate(el => el.outerHTML);`
);
if (isLast) returnVar = '__html';
break;
}
}
}
// Build body
const bodyLines: string[] = [
'async function main({ context, params }) {',
' const page = await context.newPage();',
' try {',
...lines,
];
if (returnVar) {
bodyLines.push(` return ${returnVar};`);
}
bodyLines.push(' } finally {', ' await page.close();', ' }', '}', 'module.exports = main;', '');
const body = bodyLines.join('\n');
const paramSpecs: ParamSpec[] = Array.from(usedParamNames.values());
return { body, paramSpecs };
}
export function compileScript(opts: CompileScriptOptions): CompiledScript {
const { description, sessionProfileId, recordingSource } = opts;
const { body, paramSpecs } = compileScriptBody(opts);
// Build frontmatter meta
const meta: ScriptMeta = { description, params: paramSpecs };
if (sessionProfileId !== undefined) meta.sessionProfileId = sessionProfileId;
if (recordingSource !== undefined) meta.recordingSource = recordingSource;
// Serialize using Task 2.1's serializeScript
const source = serializeScript({ frontmatter: meta, body });
return { source, meta };
}