205 lines
7.2 KiB
TypeScript
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 };
|
|
}
|