scripts / workflow.mjs
import { setTimeout as delay } from "node:timers/promises";
import { evaluate } from "./connection.mjs";
const allowedFields = new Map([
["fill", ["type", "selector", "text"]],
["click", ["type", "selector"]],
["wait-for", ["type", "selector", "text", "state", "timeoutMs"]],
["read", ["type", "selector", "limit"]],
["checkpoint", ["type", "label"]],
]);
export function integerInRange(value, minimum, maximum, name) {
if (!Number.isInteger(value) || value < minimum || value > maximum) throw new Error(`${name} must be an integer from ${minimum} to ${maximum}.`);
return value;
}
export function validateWorkflow(document) {
if (document === null || typeof document !== "object" || Array.isArray(document) ||
Object.keys(document).some(key => key !== "steps") || !Array.isArray(document.steps)) throw new Error("Expected { steps: [...] }.");
integerInRange(document.steps.length, 1, 100, "Step count");
for (const [index, step] of document.steps.entries()) {
if (step === null || typeof step !== "object" || Array.isArray(step)) throw new Error(`Invalid step ${index + 1}.`);
const fields = allowedFields.get(step.type);
if (fields === undefined || Object.keys(step).some(key => !fields.includes(key))) throw new Error(`Unknown operation or field in step ${index + 1}.`);
if (step.type !== "checkpoint" && (typeof step.selector !== "string" || step.selector.length === 0 || step.selector.length > 1000)) throw new Error(`Invalid selector in step ${index + 1}.`);
if (step.type === "fill" && (typeof step.text !== "string" || step.text.length > 20000)) throw new Error(`Invalid fill text in step ${index + 1}.`);
if (step.type === "checkpoint" && (typeof step.label !== "string" || step.label.length === 0 || step.label.length > 160)) throw new Error(`Invalid checkpoint in step ${index + 1}.`);
if (step.type === "read") integerInRange(step.limit ?? 4000, 1, 10000, "Read limit");
if (step.type === "wait-for") {
if (step.state !== undefined && !["visible", "hidden"].includes(step.state)) throw new Error("Wait state must be visible or hidden.");
if (step.text !== undefined && (typeof step.text !== "string" || step.text.length > 1000 || step.state === "hidden")) throw new Error("Wait text requires a visible target and at most 1000 characters.");
integerInRange(step.timeoutMs ?? 5000, 1, 15000, "Wait timeout");
}
}
return document.steps;
}
// Only this packaged function runs in the page; workflow JSON cannot supply code.
function inspectElement(selector, operation, parameter) {
const matches = [...document.querySelectorAll(selector)];
if (matches.length > 1) throw new Error("Selector is ambiguous; narrow it to one element.");
const element = matches[0];
const isVisible = candidate => {
if (candidate === undefined) return false;
const style = getComputedStyle(candidate);
const rectangle = candidate.getBoundingClientRect();
return style.visibility !== "hidden" && style.visibility !== "collapse" && style.display !== "none" && rectangle.width > 0 && rectangle.height > 0;
};
if (operation === "wait-for") {
const visible = isVisible(element);
if (parameter.state === "hidden") return !visible;
return visible && (parameter.text === undefined || element.innerText === parameter.text);
}
if (!isVisible(element)) throw new Error("No visible matching element.");
if (operation === "read") {
const text = element.innerText;
if (typeof text !== "string") throw new Error("Target has no visible text; select an HTML text container.");
return { text: text.slice(0, parameter.limit), truncated: text.length > parameter.limit };
}
if (element.disabled === true || element.getAttribute("aria-disabled") === "true") throw new Error("Control is disabled.");
element.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
if (operation === "fill") {
if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) throw new Error("Fill requires a text input or textarea.");
if (element.readOnly) throw new Error("Control is read-only.");
if (element instanceof HTMLInputElement && !["text", "search", "email", "url", "tel"].includes(element.type)) throw new Error("This input type requires user interaction.");
element.focus();
if (document.activeElement !== element) throw new Error("Control did not receive focus.");
element.select();
return null;
}
const rectangle = element.getBoundingClientRect();
const positionX = rectangle.left + rectangle.width / 2;
const positionY = rectangle.top + rectangle.height / 2;
const topElement = document.elementFromPoint(positionX, positionY);
if (topElement === null || !element.contains(topElement)) throw new Error("Control is obscured.");
return { positionX, positionY };
}
export async function readText(connection, selector, limit = 4000) {
validateWorkflow({ steps: [{ type: "read", selector, limit }] });
return await evaluate(connection, `(${inspectElement})(${JSON.stringify(selector)}, "read", ${JSON.stringify({ limit })})`);
}
export async function runWorkflow(connection, document, { timeoutMs = 30000 } = {}) {
const steps = validateWorkflow(document);
integerInRange(timeoutMs, 1, 120000, "Workflow timeout");
const started = Date.now();
const deadline = started + timeoutMs;
const results = [];
const checkpoints = [];
let completedSteps = 0;
let failedStep = 0;
let writeDispatched = false;
try {
const initialFrame = (await connection.send("Page.getFrameTree")).frameTree.frame;
const checkDocument = async () => {
const currentFrame = (await connection.send("Page.getFrameTree")).frameTree.frame;
if (currentFrame.loaderId !== initialFrame.loaderId || currentFrame.url !== initialFrame.url) throw new Error("Page navigated; inspect it before continuing.");
};
// Check selector syntax before any writes; later steps may target not-yet-created nodes.
await evaluate(connection, `(() => { for (const selector of ${JSON.stringify(steps.filter(step => step.selector !== undefined).map(step => step.selector))}) document.querySelector(selector); return true; })()`);
for (const [index, step] of steps.entries()) {
failedStep = index + 1;
writeDispatched = false;
if (Date.now() >= deadline) throw new Error("Workflow deadline exceeded.");
await checkDocument();
if (step.type === "checkpoint") {
if (Buffer.byteLength(JSON.stringify({ results, checkpoints: [...checkpoints, step.label] })) > 12000) throw new Error("Workflow output budget reached; split the job.");
checkpoints.push(step.label);
} else if (step.type === "read") {
const result = { step: index + 1, ...await readText(connection, step.selector, step.limit) };
if (Buffer.byteLength(JSON.stringify({ results: [...results, result], checkpoints })) > 12000) throw new Error("Workflow output budget reached; split the job.");
results.push(result);
} else if (step.type === "wait-for") {
const waitDeadline = Math.min(deadline, Date.now() + (step.timeoutMs ?? 5000));
while (true) {
if (Date.now() >= waitDeadline) throw new Error("Wait condition was not met before its deadline.");
await checkDocument();
const matched = await evaluate(connection, `(${inspectElement})(${JSON.stringify(step.selector)}, "wait-for", ${JSON.stringify({ state: step.state ?? "visible", text: step.text })})`);
if (matched === true) break;
await delay(Math.min(50, Math.max(1, waitDeadline - Date.now())));
}
} else if (step.type === "fill") {
await evaluate(connection, `(${inspectElement})(${JSON.stringify(step.selector)}, "fill", {})`);
writeDispatched = true;
if (step.text.length === 0) {
await connection.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 });
await connection.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 });
} else {
await connection.send("Input.insertText", { text: step.text });
}
} else if (step.type === "click") {
const position = await evaluate(connection, `(${inspectElement})(${JSON.stringify(step.selector)}, "click", {})`);
writeDispatched = true;
await connection.send("Input.dispatchMouseEvent", { type: "mousePressed", x: position.positionX, y: position.positionY, button: "left", clickCount: 1 });
await connection.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: position.positionX, y: position.positionY, button: "left", clickCount: 1 });
}
completedSteps += 1;
writeDispatched = false;
}
return { status: "completed", completedSteps, results, checkpoints, durationMs: Date.now() - started };
} catch (error) {
return { status: "failed", completedSteps, failedStep, outcomeUnknown: writeDispatched,
error: String(error.message ?? error).slice(0, 500), results, checkpoints, durationMs: Date.now() - started };
}
}
scripts / workflow.mjs
import { setTimeout as delay } from "node:timers/promises";
import { evaluate } from "./connection.mjs";
const allowedFields = new Map([
["fill", ["type", "selector", "text"]],
["click", ["type", "selector"]],
["wait-for", ["type", "selector", "text", "state", "timeoutMs"]],
["read", ["type", "selector", "limit"]],
["checkpoint", ["type", "label"]],
]);
export function integerInRange(value, minimum, maximum, name) {
if (!Number.isInteger(value) || value < minimum || value > maximum) throw new Error(`${name} must be an integer from ${minimum} to ${maximum}.`);
return value;
}
export function validateWorkflow(document) {
if (document === null || typeof document !== "object" || Array.isArray(document) ||
Object.keys(document).some(key => key !== "steps") || !Array.isArray(document.steps)) throw new Error("Expected { steps: [...] }.");
integerInRange(document.steps.length, 1, 100, "Step count");
for (const [index, step] of document.steps.entries()) {
if (step === null || typeof step !== "object" || Array.isArray(step)) throw new Error(`Invalid step ${index + 1}.`);
const fields = allowedFields.get(step.type);
if (fields === undefined || Object.keys(step).some(key => !fields.includes(key))) throw new Error(`Unknown operation or field in step ${index + 1}.`);
if (step.type !== "checkpoint" && (typeof step.selector !== "string" || step.selector.length === 0 || step.selector.length > 1000)) throw new Error(`Invalid selector in step ${index + 1}.`);
if (step.type === "fill" && (typeof step.text !== "string" || step.text.length > 20000)) throw new Error(`Invalid fill text in step ${index + 1}.`);
if (step.type === "checkpoint" && (typeof step.label !== "string" || step.label.length === 0 || step.label.length > 160)) throw new Error(`Invalid checkpoint in step ${index + 1}.`);
if (step.type === "read") integerInRange(step.limit ?? 4000, 1, 10000, "Read limit");
if (step.type === "wait-for") {
if (step.state !== undefined && !["visible", "hidden"].includes(step.state)) throw new Error("Wait state must be visible or hidden.");
if (step.text !== undefined && (typeof step.text !== "string" || step.text.length > 1000 || step.state === "hidden")) throw new Error("Wait text requires a visible target and at most 1000 characters.");
integerInRange(step.timeoutMs ?? 5000, 1, 15000, "Wait timeout");
}
}
return document.steps;
}
// Only this packaged function runs in the page; workflow JSON cannot supply code.
function inspectElement(selector, operation, parameter) {
const matches = [...document.querySelectorAll(selector)];
if (matches.length > 1) throw new Error("Selector is ambiguous; narrow it to one element.");
const element = matches[0];
const isVisible = candidate => {
if (candidate === undefined) return false;
const style = getComputedStyle(candidate);
const rectangle = candidate.getBoundingClientRect();
return style.visibility !== "hidden" && style.visibility !== "collapse" && style.display !== "none" && rectangle.width > 0 && rectangle.height > 0;
};
if (operation === "wait-for") {
const visible = isVisible(element);
if (parameter.state === "hidden") return !visible;
return visible && (parameter.text === undefined || element.innerText === parameter.text);
}
if (!isVisible(element)) throw new Error("No visible matching element.");
if (operation === "read") {
const text = element.innerText;
if (typeof text !== "string") throw new Error("Target has no visible text; select an HTML text container.");
return { text: text.slice(0, parameter.limit), truncated: text.length > parameter.limit };
}
if (element.disabled === true || element.getAttribute("aria-disabled") === "true") throw new Error("Control is disabled.");
element.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
if (operation === "fill") {
if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) throw new Error("Fill requires a text input or textarea.");
if (element.readOnly) throw new Error("Control is read-only.");
if (element instanceof HTMLInputElement && !["text", "search", "email", "url", "tel"].includes(element.type)) throw new Error("This input type requires user interaction.");
element.focus();
if (document.activeElement !== element) throw new Error("Control did not receive focus.");
element.select();
return null;
}
const rectangle = element.getBoundingClientRect();
const positionX = rectangle.left + rectangle.width / 2;
const positionY = rectangle.top + rectangle.height / 2;
const topElement = document.elementFromPoint(positionX, positionY);
if (topElement === null || !element.contains(topElement)) throw new Error("Control is obscured.");
return { positionX, positionY };
}
export async function readText(connection, selector, limit = 4000) {
validateWorkflow({ steps: [{ type: "read", selector, limit }] });
return await evaluate(connection, `(${inspectElement})(${JSON.stringify(selector)}, "read", ${JSON.stringify({ limit })})`);
}
export async function runWorkflow(connection, document, { timeoutMs = 30000 } = {}) {
const steps = validateWorkflow(document);
integerInRange(timeoutMs, 1, 120000, "Workflow timeout");
const started = Date.now();
const deadline = started + timeoutMs;
const results = [];
const checkpoints = [];
let completedSteps = 0;
let failedStep = 0;
let writeDispatched = false;
try {
const initialFrame = (await connection.send("Page.getFrameTree")).frameTree.frame;
const checkDocument = async () => {
const currentFrame = (await connection.send("Page.getFrameTree")).frameTree.frame;
if (currentFrame.loaderId !== initialFrame.loaderId || currentFrame.url !== initialFrame.url) throw new Error("Page navigated; inspect it before continuing.");
};
// Check selector syntax before any writes; later steps may target not-yet-created nodes.
await evaluate(connection, `(() => { for (const selector of ${JSON.stringify(steps.filter(step => step.selector !== undefined).map(step => step.selector))}) document.querySelector(selector); return true; })()`);
for (const [index, step] of steps.entries()) {
failedStep = index + 1;
writeDispatched = false;
if (Date.now() >= deadline) throw new Error("Workflow deadline exceeded.");
await checkDocument();
if (step.type === "checkpoint") {
if (Buffer.byteLength(JSON.stringify({ results, checkpoints: [...checkpoints, step.label] })) > 12000) throw new Error("Workflow output budget reached; split the job.");
checkpoints.push(step.label);
} else if (step.type === "read") {
const result = { step: index + 1, ...await readText(connection, step.selector, step.limit) };
if (Buffer.byteLength(JSON.stringify({ results: [...results, result], checkpoints })) > 12000) throw new Error("Workflow output budget reached; split the job.");
results.push(result);
} else if (step.type === "wait-for") {
const waitDeadline = Math.min(deadline, Date.now() + (step.timeoutMs ?? 5000));
while (true) {
if (Date.now() >= waitDeadline) throw new Error("Wait condition was not met before its deadline.");
await checkDocument();
const matched = await evaluate(connection, `(${inspectElement})(${JSON.stringify(step.selector)}, "wait-for", ${JSON.stringify({ state: step.state ?? "visible", text: step.text })})`);
if (matched === true) break;
await delay(Math.min(50, Math.max(1, waitDeadline - Date.now())));
}
} else if (step.type === "fill") {
await evaluate(connection, `(${inspectElement})(${JSON.stringify(step.selector)}, "fill", {})`);
writeDispatched = true;
if (step.text.length === 0) {
await connection.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 });
await connection.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 });
} else {
await connection.send("Input.insertText", { text: step.text });
}
} else if (step.type === "click") {
const position = await evaluate(connection, `(${inspectElement})(${JSON.stringify(step.selector)}, "click", {})`);
writeDispatched = true;
await connection.send("Input.dispatchMouseEvent", { type: "mousePressed", x: position.positionX, y: position.positionY, button: "left", clickCount: 1 });
await connection.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: position.positionX, y: position.positionY, button: "left", clickCount: 1 });
}
completedSteps += 1;
writeDispatched = false;
}
return { status: "completed", completedSteps, results, checkpoints, durationMs: Date.now() - started };
} catch (error) {
return { status: "failed", completedSteps, failedStep, outcomeUnknown: writeDispatched,
error: String(error.message ?? error).slice(0, 500), results, checkpoints, durationMs: Date.now() - started };
}
}