| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492 |
- /**
- * Workflow Editor HTML — Logic Tests
- * Tests the JavaScript logic extracted from workflow-editor.html
- * without requiring a browser DOM.
- */
- const fs = require('fs');
- const path = require('path');
- // Extract JS from the HTML file
- const html = fs.readFileSync(path.join(__dirname, '../ui/workflow-editor.html'), 'utf8');
- const scriptMatch = html.match(/<script>([\s\S]*)<\/script>/);
- if (!scriptMatch) { console.error('Cannot extract <script> from workflow-editor.html'); process.exit(1); }
- const jsCode = scriptMatch[1];
- // ===== Minimal DOM shim =====
- const elements = {};
- function $(id) { return elements[id] || (elements[id] = createEl(id)); }
- function createEl(id) {
- return {
- id, textContent: '', style: { display: '', left: '', top: '' },
- innerHTML: '', className: '', value: '',
- classList: { add(){}, remove(){}, contains(){ return false; } },
- offsetLeft: 100, offsetTop: 100, offsetWidth: 240, offsetHeight: 120,
- scrollIntoView() {},
- addEventListener() {},
- querySelectorAll() { return []; },
- setAttribute() {},
- appendChild() {},
- click() {},
- getContext() {
- return {
- clearRect(){}, beginPath(){}, moveTo(){}, lineTo(){}, stroke(){},
- fillRect(){}, fill(){}, fillText(){}, arc(){}, bezierCurveTo(){},
- quadraticCurveTo(){}, closePath(){}, setLineDash(){}, strokeRect(){},
- scale(){}, set globalAlpha(v){}, get globalAlpha(){ return 1; },
- set fillStyle(v){}, get fillStyle(){ return ''; },
- set strokeStyle(v){}, get strokeStyle(){ return ''; },
- set lineWidth(v){}, set font(v){}, set textAlign(v){},
- };
- },
- get width() { return 360; }, set width(v) {},
- get height() { return 200; }, set height(v) {},
- get offsetWidth() { return 180; },
- get clientWidth() { return 800; },
- get clientHeight() { return 600; },
- get scrollLeft() { return 0; },
- get scrollTop() { return 0; },
- get files() { return []; },
- };
- }
- // Patch globals
- global.document = {
- getElementById: (id) => $(id),
- createElement: (tag) => createEl(tag),
- createElementNS: (ns, tag) => createEl(tag),
- addEventListener() {},
- };
- global.window = {
- parent: { postMessage() {} },
- addEventListener() {},
- innerWidth: 1200,
- innerHeight: 800,
- };
- global.navigator = { clipboard: { writeText() {} } };
- global.localStorage = {
- _store: {},
- getItem(k) { return this._store[k] || null; },
- setItem(k, v) { this._store[k] = v; },
- removeItem(k) { delete this._store[k]; },
- };
- global.URL = { createObjectURL() { return 'blob:test'; } };
- global.Blob = class Blob { constructor() {} };
- global.requestAnimationFrame = (fn) => fn();
- global.setTimeout = (fn, ms) => fn();
- global.fetch = async () => ({ ok: true, json: async () => ({}), body: { getReader() { return { read: async () => ({ done: true }) }; } } });
- // Evaluate the editor JS in this context
- const fn = new Function('$', jsCode);
- // We need $ to be our DOM lookup
- // But the code defines its own $ — so we need to let it run
- eval(`(function(){
- ${jsCode}
- // Export to global for testing
- global._editor = {
- parseWorkflow, getStepType, autoLayout, computeIO, state,
- RESERVED_NEXT, KNOWN_TYPES, NODE_ICONS,
- renderLoopBody, renderDownloadBody, renderUnzipBody,
- renderLLMBody, renderBranchBody, renderPauseBody,
- rerunFromStep, saveCheckpointToStorage, restoreFromStorage,
- _currentWorkflowJson: null,
- getState: () => state,
- setCheckpoint: (cp) => { _lastCheckpoint = cp; },
- getCheckpoint: () => _lastCheckpoint,
- setRunID: (id) => { _currentRunID = id; },
- };
- // Patch the global references
- global._editor._currentWorkflowJson = _currentWorkflowJson;
- })()`);
- const editor = global._editor;
- // ===== Test Runner =====
- let passed = 0, failed = 0;
- function test(name, fn) {
- try {
- fn();
- console.log(` ✓ ${name}`);
- passed++;
- } catch (e) {
- console.log(` ✗ ${name}: ${e.message}`);
- failed++;
- }
- }
- function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); }
- function assertEqual(a, b, msg) {
- if (a !== b) throw new Error(msg || `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
- }
- // ===== Test Workflow =====
- const testWorkflow = {
- name: 'TestWorkflow',
- version: '3.16',
- registry: {
- params: ['projectName:string'],
- vars: ['$plan:object', '$code:string', '$review:object'],
- docs: { 1: 'VL Syntax Rules', 2: 'Theme Spec' },
- services: [{ id: 'svc_compile', method: 'compile', desc: 'Compile VL' }],
- apis: [{ id: 'api_github', method: 'POST', url: 'https://api.github.com/repos', desc: 'GitHub API' }],
- },
- steps: [
- { id: 'LLM_Analyze', meta: { title: 'Analyze Request' }, in: { model: 'anthropic/claude-opus-4-6', messages: [{ role: 'user', content: 'hello' }], docs: [1, 2], max_tokens: 4096 }, out: { '$plan': '=_result' }, next: 'Branch_Route', if: '=$projectName != ""' },
- { id: 'Branch_Route', meta: { title: 'Route by plan' }, cases: { '=$plan.action == "add"': 'LLM_Generate', '=$plan.action == "fix"': 'Service_Fix' }, next: 'Stop_End' },
- { id: 'LLM_Generate', meta: { title: 'Generate Code' }, model: 'anthropic/claude-opus-4-6', in: { messages: [{ role: 'user', content: '=$plan' }] }, out: { '$code': '=_result' }, next: 'Loop_Review' },
- { id: 'Service_Fix', serviceId: 'svc_compile', in: { code: '=$code' }, out: { '$code': '=_result' }, next: 'Stop_End' },
- { id: 'Loop_Review', meta: { title: 'Review Loop' }, while: '=$review.approved != true', maxIterations: 5, mode: 'serial', children: ['LLM_ReviewStep'], next: 'Write_Output' },
- { id: 'LLM_ReviewStep', meta: { title: 'Review Step' }, in: { messages: [{ role: 'user', content: 'review' }] }, out: { '$review': '=_result' }, next: 'BREAK' },
- { id: 'Write_Output', target: '/output/code.vx', value: '=$code', mode: 'overwrite', next: 'Download_Assets' },
- { id: 'Download_Assets', meta: { title: 'Download Assets' }, source: { url: 'https://cdn.example.com/assets.zip', headers: { Authorization: 'Bearer xxx' } }, target: '/assets/', routeByExt: { '.png': 'Images/', '.css': 'Styles/' }, defaultDir: 'Other/', next: 'Unzip_Package' },
- { id: 'Unzip_Package', meta: { title: 'Unzip Package' }, source: '=$downloadPath', routeByExt: { '.vx': 'Apps/', '.sc': 'Sections/' }, defaultDir: 'Misc/', overwrite: true, next: 'Set_Status' },
- { id: 'Set_Status', target: '$status', value: '="completed"', next: 'API_Notify' },
- { id: 'API_Notify', apiId: 'api_github', in: { body: '=$plan' }, out: { '$response': '=_result' }, next: 'Pause_Approval' },
- { id: 'Pause_Approval', meta: { title: 'Wait for approval' }, reason: 'Needs manager approval', in: { message: 'Please approve', display: { plan: '=$plan' } }, out: { '$approved': '=_result.approved' }, next: 'Fork_Deploy' },
- { id: 'Fork_Deploy', source: '=$targets', children: ['Component_Deploy1', 'Component_Deploy2'], next: 'Stop_End' },
- { id: 'Component_Deploy1', componentId: 'comp_deploy', in: { env: 'staging' }, out: { '$deployResult': '=_result' }, next: 'RETURN' },
- { id: 'Component_Deploy2', componentId: 'comp_deploy', in: { env: 'production' }, next: 'RETURN' },
- { id: 'Stop_End', meta: { title: 'Done' } },
- ]
- };
- // ===== Tests =====
- console.log('\n── Workflow Parsing ──');
- test('parse workflow creates correct number of nodes', () => {
- editor.parseWorkflow(testWorkflow);
- const s = editor.getState();
- assertEqual(s.nodes.length, 16);
- });
- test('parse workflow creates correct number of connections', () => {
- const s = editor.getState();
- // Count expected: next connections (excluding RETURN, BREAK, Stop_End has no next) + children + cases
- // Let's just verify it's reasonable
- assert(s.connections.length > 0, 'Should have connections');
- assert(s.connections.length < 30, 'Should not have too many connections');
- });
- console.log('\n── BREAK/RETURN Handling (Bug Fix #1) ──');
- test('BREAK does not create phantom connection', () => {
- const s = editor.getState();
- const breakConns = s.connections.filter(c => c.to === 'BREAK');
- assertEqual(breakConns.length, 0, 'No connections should point to BREAK');
- });
- test('RETURN does not create phantom connection', () => {
- const s = editor.getState();
- const returnConns = s.connections.filter(c => c.to === 'RETURN');
- assertEqual(returnConns.length, 0, 'No connections should point to RETURN');
- });
- test('RESERVED_NEXT contains both RETURN and BREAK', () => {
- assert(editor.RESERVED_NEXT.has('RETURN'));
- assert(editor.RESERVED_NEXT.has('BREAK'));
- });
- test('LLM_ReviewStep has no outgoing serial connection (next is BREAK)', () => {
- const s = editor.getState();
- const reviewConns = s.connections.filter(c => c.from === 'LLM_ReviewStep' && c.type === 'serial');
- assertEqual(reviewConns.length, 0, 'BREAK should not produce a serial connection');
- });
- console.log('\n── Node Type Detection (Bug Fix #3) ──');
- test('KNOWN_TYPES includes all 13 types', () => {
- assertEqual(editor.KNOWN_TYPES.length, 13);
- assert(editor.KNOWN_TYPES.includes('Download'));
- assert(editor.KNOWN_TYPES.includes('Unzip'));
- });
- test('getStepType detects Download_Assets as Download', () => {
- assertEqual(editor.getStepType({ id: 'Download_Assets' }), 'Download');
- });
- test('getStepType detects Unzip_Package as Unzip', () => {
- assertEqual(editor.getStepType({ id: 'Unzip_Package' }), 'Unzip');
- });
- test('getStepType respects explicit type field', () => {
- assertEqual(editor.getStepType({ id: 'Custom_123', type: 'LLM' }), 'LLM');
- });
- test('getStepType falls back to LLM for unknown prefix', () => {
- assertEqual(editor.getStepType({ id: 'Unknown_123' }), 'LLM');
- });
- test('NODE_ICONS has Download and Unzip', () => {
- assertEqual(editor.NODE_ICONS.Download, 'DL');
- assertEqual(editor.NODE_ICONS.Unzip, 'UZ');
- });
- console.log('\n── Loop While Mode (Bug Fix #2) ──');
- test('renderLoopBody shows while expression for while-mode loop', () => {
- const body = editor.renderLoopBody({ while: '=$review.approved != true', maxIterations: 5, mode: 'serial' });
- assert(body.includes('while'), 'Should contain while label');
- assert(body.includes('=$review.approved'), 'Should contain while expression');
- assert(body.includes('5'), 'Should show maxIterations');
- assert(!body.includes('source'), 'Should NOT show source label');
- });
- test('renderLoopBody shows source for source-mode loop', () => {
- const body = editor.renderLoopBody({ source: '=$items', mode: 'parallel' });
- assert(body.includes('source'), 'Should contain source label');
- assert(body.includes('=$items'), 'Should contain source expression');
- assert(!body.includes('while'), 'Should NOT show while label');
- });
- test('renderLoopBody shows maxIterations for source-mode too', () => {
- const body = editor.renderLoopBody({ source: '=$items', mode: 'parallel', maxIterations: 100 });
- assert(body.includes('100'), 'Should show maxIterations');
- });
- console.log('\n── computeIO (Bug Fix #4) ──');
- test('computeIO extracts vars from while expression', () => {
- const io = editor.computeIO({ while: '=$review.approved != true', mode: 'serial', maxIterations: 5 });
- assert(io.varsIn.some(v => v.includes('$review')), 'Should extract $review from while expression');
- });
- test('computeIO extracts vars from source', () => {
- const io = editor.computeIO({ source: '=$items' });
- assert(io.varsIn.includes('$items'), 'Should extract $items from source');
- });
- test('computeIO extracts output vars', () => {
- const io = editor.computeIO({ out: { '$plan': '=_result', '/output.vx': '=$code' } });
- assert(io.varsOut.includes('$plan'), 'Should have $plan in varsOut');
- assert(io.files.includes('/output.vx'), 'Should have /output.vx in files');
- });
- test('computeIO extracts docs', () => {
- const io = editor.computeIO({ in: { docs: [1, 2], messages: [] } });
- assertEqual(io.docs.length, 2);
- });
- console.log('\n── Download/Unzip Renderers (Bug Fix #3) ──');
- test('renderDownloadBody shows source URL', () => {
- const body = editor.renderDownloadBody({
- source: { url: 'https://cdn.example.com/file.zip' },
- target: '/assets/',
- routeByExt: { '.png': 'Images/' },
- defaultDir: 'Other/'
- });
- // URL is inside a JSON.stringify'd object, then HTML-escaped and truncated
- // Check for key structural elements
- assert(body.includes('Download'), 'Should have Download title');
- assert(body.includes('source'), 'Should show source label');
- assert(body.includes('/assets/'), 'Should show target');
- assert(body.includes('1 ext rules'), 'Should show routeByExt count');
- assert(body.includes('Other/'), 'Should show defaultDir');
- });
- test('renderUnzipBody shows source and overwrite', () => {
- const body = editor.renderUnzipBody({
- source: '=$zipPath',
- routeByExt: { '.vx': 'Apps/', '.sc': 'Sections/' },
- defaultDir: 'Misc/',
- overwrite: true
- });
- assert(body.includes('$zipPath'), 'Should show source');
- assert(body.includes('2 ext rules'), 'Should show 2 ext rules');
- assert(body.includes('true'), 'Should show overwrite');
- });
- console.log('\n── LLM Body Renderer ──');
- test('renderLLMBody shows model from both data.model and data.in.model', () => {
- const body1 = editor.renderLLMBody({ model: 'claude-opus', in: {} });
- assert(body1.includes('claude-opus'), 'Should show model from data.model');
- const body2 = editor.renderLLMBody({ in: { model: 'gpt-4' } });
- assert(body2.includes('gpt-4'), 'Should show model from data.in.model');
- });
- test('renderLLMBody shows docs, tokens, messages', () => {
- const body = editor.renderLLMBody({
- in: { docs: [1], model: 'test', max_tokens: 4096, messages: [{ role: 'user', content: 'hi' }] }
- });
- assert(body.includes('4096'), 'Should show token count');
- assert(body.includes('1 messages'), 'Should show message count');
- });
- console.log('\n── Branch Body Renderer ──');
- test('renderBranchBody handles cases object', () => {
- const body = editor.renderBranchBody({ cases: { '=$a > 0': 'StepA', '=$a <= 0': 'StepB' } });
- assert(body.includes('$a > 0'), 'Should show case expression');
- assert(body.includes('StepA'), 'Should show target');
- });
- test('renderBranchBody handles branches array', () => {
- const body = editor.renderBranchBody({ branches: [['=$x', 'S1'], ['=$y', 'S2']] });
- assert(body.includes('$x'), 'Should show branch expression');
- });
- console.log('\n── Pause Body Renderer ──');
- test('renderPauseBody shows reason field (Spec 3.16)', () => {
- const body = editor.renderPauseBody({ reason: 'Manager approval needed' });
- assert(body.includes('Manager approval'), 'Should show reason as message');
- });
- console.log('\n── Connection Generation ──');
- test('serial connections are created for normal next', () => {
- const s = editor.getState();
- const conn = s.connections.find(c => c.from === 'LLM_Analyze' && c.to === 'Branch_Route');
- assert(conn, 'Should have LLM_Analyze → Branch_Route');
- assertEqual(conn.type, 'serial');
- });
- test('parallel connections are created for children', () => {
- const s = editor.getState();
- const conns = s.connections.filter(c => c.from === 'Fork_Deploy' && c.type === 'parallel');
- assertEqual(conns.length, 2, 'Fork should have 2 parallel children');
- });
- test('branch-case connections are created for cases', () => {
- const s = editor.getState();
- const conns = s.connections.filter(c => c.from === 'Branch_Route' && c.type === 'branch-case');
- assertEqual(conns.length, 2, 'Branch should have 2 case connections');
- assert(conns[0].label, 'Case connections should have labels');
- });
- test('Loop_Review children connection exists', () => {
- const s = editor.getState();
- const conn = s.connections.find(c => c.from === 'Loop_Review' && c.to === 'LLM_ReviewStep');
- assert(conn, 'Loop should connect to its children');
- assertEqual(conn.type, 'parallel');
- });
- console.log('\n── Auto Layout ──');
- test('autoLayout assigns positions to all nodes', () => {
- const s = editor.getState();
- const positioned = s.nodes.filter(n => n.x > 0 || n.y > 0);
- assertEqual(positioned.length, s.nodes.length, 'All nodes should have positions');
- });
- test('no two nodes overlap exactly', () => {
- const s = editor.getState();
- const positions = new Set();
- for (const n of s.nodes) {
- const key = `${n.x},${n.y}`;
- assert(!positions.has(key), `Nodes overlap at ${key}: ${n.id}`);
- positions.add(key);
- }
- });
- console.log('\n── Checkpoint Persistence ──');
- test('saveCheckpointToStorage saves to localStorage', () => {
- // parseWorkflow sets _currentWorkflowJson internally
- editor.parseWorkflow(testWorkflow);
- // Use the exported setCheckpoint and saveCheckpointToStorage
- editor.setCheckpoint({ currentStepID: 'LLM_Generate', variables: { '$plan': {} } });
- editor.setRunID('run_123');
- editor.saveCheckpointToStorage();
- const stored = localStorage.getItem('wf_cp_TestWorkflow');
- assert(stored, 'Should save to localStorage');
- const data = JSON.parse(stored);
- assertEqual(data.checkpoint.currentStepID, 'LLM_Generate');
- assertEqual(data.runID, 'run_123');
- });
- test('restoreFromStorage restores node statuses', () => {
- // Set some node statuses in storage
- const s = editor.getState();
- const storageData = {
- checkpoint: { currentStepID: 'LLM_Generate' },
- nodeStatuses: [
- { id: 'LLM_Analyze', status: 'done' },
- { id: 'Branch_Route', status: 'done' },
- { id: 'LLM_Generate', status: 'error' },
- ],
- runID: 'run_456',
- ts: Date.now()
- };
- localStorage.setItem('wf_cp_TestWorkflow', JSON.stringify(storageData));
- // Clear current statuses
- for (const n of s.nodes) n.status = null;
- const restored = editor.restoreFromStorage();
- assert(restored, 'Should return true');
- const analyze = s.nodes.find(n => n.id === 'LLM_Analyze');
- assertEqual(analyze.status, 'done');
- const generate = s.nodes.find(n => n.id === 'LLM_Generate');
- assertEqual(generate.status, 'error');
- });
- test('restoreFromStorage rejects expired checkpoints (>24h)', () => {
- const storageData = {
- checkpoint: { currentStepID: 'LLM_Generate' },
- nodeStatuses: [{ id: 'LLM_Analyze', status: 'done' }],
- runID: 'run_old',
- ts: Date.now() - 90000000 // >24h ago
- };
- localStorage.setItem('wf_cp_TestWorkflow', JSON.stringify(storageData));
- // Clear statuses
- for (const n of editor.getState().nodes) n.status = null;
- const restored = editor.restoreFromStorage();
- assertEqual(restored, false, 'Should reject expired checkpoint');
- });
- console.log('\n── Node Type Coverage ──');
- test('all 13 node types are in test workflow', () => {
- const s = editor.getState();
- const types = new Set(s.nodes.map(n => n.type));
- const expected = ['LLM', 'Branch', 'Service', 'Loop', 'Write', 'Download', 'Unzip', 'Set', 'API', 'Pause', 'Fork', 'Component', 'Stop'];
- for (const t of expected) {
- assert(types.has(t), `Missing type: ${t}`);
- }
- });
- test('all nodes have valid type from KNOWN_TYPES', () => {
- const s = editor.getState();
- for (const n of s.nodes) {
- assert(editor.KNOWN_TYPES.includes(n.type), `Node ${n.id} has unknown type: ${n.type}`);
- }
- });
- console.log('\n── Edge Cases ──');
- test('parse empty workflow does not crash', () => {
- editor.parseWorkflow({ steps: [] });
- assertEqual(editor.getState().nodes.length, 0);
- });
- test('parse workflow with no registry', () => {
- editor.parseWorkflow({ steps: [{ id: 'Stop_1' }] });
- assertEqual(editor.getState().nodes.length, 1);
- });
- test('parse workflow restores after re-parse', () => {
- editor.parseWorkflow(testWorkflow);
- assertEqual(editor.getState().nodes.length, 16);
- });
- test('computeIO handles null step gracefully', () => {
- const io = editor.computeIO(null);
- assertEqual(io.varsIn.length, 0);
- assertEqual(io.varsOut.length, 0);
- });
- test('computeIO handles step with no in/out', () => {
- const io = editor.computeIO({ id: 'Stop_1' });
- assertEqual(io.docs.length, 0);
- });
- // ===== Results =====
- console.log(`\n── Results ──\n\n ${passed} passed, ${failed} failed\n`);
- process.exit(failed > 0 ? 1 : 0);
|