NanoJev-Web / runtime /workflow.mjs
candypunk's picture
Release runtime 1.1 with named workflows and hybrid by default
d786697 verified
Raw History Blame Contribute Delete
3.71 kB
// Compile a small, explicit workflow. Natural-language planning stays with the host.
const FIELD_OPS = new Set(['fill', 'select', 'check']);
const target = value => typeof value === 'string' ? {name: value} : value;
export function validateTarget(value) {
const t = target(value);
if (!t || typeof t !== 'object' || Array.isArray(t) ||
!(typeof t.name === 'string' && t.name.trim() || typeof t.selector === 'string' && t.selector.trim()))
throw Error('A target needs an exact accessible name or selector');
for (const key of ['role', 'within']) if (t[key] !== undefined && typeof t[key] !== 'string') throw Error('Invalid target ' + key);
return t;
}
export class Workflow {
constructor(task) {
if (!Array.isArray(task.steps) || !task.steps.length || task.steps.length > 100) throw Error('Expected 1–100 workflow steps');
this.steps = task.steps.map((step, index) => {
const ops = Object.keys(step).filter(k => ['click', ...FIELD_OPS].includes(k));
if (ops.length !== 1 || Object.keys(step).some(k => ![ops[0], 'purpose', 'expect'].includes(k))) throw Error('Each step needs exactly one supported operation');
const op = ops[0], source = step[op];
if (op === 'click') {
const t = validateTarget(source);
return [{id: `step-${index}-click`, op, target: t, label: t.name ?? t.selector,
purpose: step.purpose ?? (index === task.steps.length - 1 ? 'submit' : 'interaction'), expect: step.expect}];
}
if (!source || typeof source !== 'object' || !Object.keys(source).length) throw Error('Field step needs a nonempty mapping or target/value list');
const entries = Array.isArray(source) ? source : Object.entries(source).map(([name, value]) => ({target: {name}, value}));
return entries.map(({target: suppliedTarget, value}, i) => {
const t = validateTarget(suppliedTarget);
if (op === 'check' ? typeof value !== 'boolean' : !(typeof value === 'string' || typeof value === 'number' || op === 'select' && Array.isArray(value) && value.every(x => typeof x === 'string'))) throw Error('Invalid assigned field value');
return {id: `step-${index}-field-${i}`, op: op === 'check' ? value ? 'check' : 'uncheck' : op,
target: t, label: t.name ?? t.selector, value, purpose: 'input', selectByLabel: op === 'select'};
});
});
this.index = 0;
this.completed = [];
}
get finished() { return this.index >= this.steps.length; }
controls() { return this.finished ? [] : this.steps[this.index]; }
advance(observation) {
if (this.finished || this.controls()[0].op === 'click') return false;
const controls = observation.dom.controls;
if (controls.length !== this.controls().length || !controls.every(c => c.count === 1 && c.current === 'match')) return false;
this.completed.push({step: this.index + 1, operation: 'fields', fields: controls.map(c => ({name: c.label, matched: true}))});
this.index++;
return true;
}
acted(action) {
if (!this.finished && action.op === 'click' && this.controls()[0].op === 'click') {
this.completed.push({step: this.index + 1, operation: 'click', name: action.label, executed: true});
this.index++;
}
}
}
export function workflowSuccess(expect) {
if (!expect || typeof expect !== 'object') throw Error('An explicit final expectation is required');
if (typeof expect.visibleText === 'string' && expect.visibleText.trim()) return {kind: 'visibleText', value: expect.visibleText};
if (expect.target) return {kind: 'visible', target: validateTarget(expect.target)};
if (expect.kind) return expect;
throw Error('Use expect.visibleText, expect.target or a documented condition');
}