test-editor.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  1. /**
  2. * Workflow Editor HTML — Logic Tests
  3. * Tests the JavaScript logic extracted from workflow-editor.html
  4. * without requiring a browser DOM.
  5. */
  6. const fs = require('fs');
  7. const path = require('path');
  8. // Extract JS from the HTML file
  9. const html = fs.readFileSync(path.join(__dirname, '../ui/workflow-editor.html'), 'utf8');
  10. const scriptMatch = html.match(/<script>([\s\S]*)<\/script>/);
  11. if (!scriptMatch) { console.error('Cannot extract <script> from workflow-editor.html'); process.exit(1); }
  12. const jsCode = scriptMatch[1];
  13. // ===== Minimal DOM shim =====
  14. const elements = {};
  15. function $(id) { return elements[id] || (elements[id] = createEl(id)); }
  16. function createEl(id) {
  17. return {
  18. id, textContent: '', style: { display: '', left: '', top: '' },
  19. innerHTML: '', className: '', value: '',
  20. classList: { add(){}, remove(){}, contains(){ return false; } },
  21. offsetLeft: 100, offsetTop: 100, offsetWidth: 240, offsetHeight: 120,
  22. scrollIntoView() {},
  23. addEventListener() {},
  24. querySelectorAll() { return []; },
  25. setAttribute() {},
  26. appendChild() {},
  27. click() {},
  28. getContext() {
  29. return {
  30. clearRect(){}, beginPath(){}, moveTo(){}, lineTo(){}, stroke(){},
  31. fillRect(){}, fill(){}, fillText(){}, arc(){}, bezierCurveTo(){},
  32. quadraticCurveTo(){}, closePath(){}, setLineDash(){}, strokeRect(){},
  33. scale(){}, set globalAlpha(v){}, get globalAlpha(){ return 1; },
  34. set fillStyle(v){}, get fillStyle(){ return ''; },
  35. set strokeStyle(v){}, get strokeStyle(){ return ''; },
  36. set lineWidth(v){}, set font(v){}, set textAlign(v){},
  37. };
  38. },
  39. get width() { return 360; }, set width(v) {},
  40. get height() { return 200; }, set height(v) {},
  41. get offsetWidth() { return 180; },
  42. get clientWidth() { return 800; },
  43. get clientHeight() { return 600; },
  44. get scrollLeft() { return 0; },
  45. get scrollTop() { return 0; },
  46. get files() { return []; },
  47. };
  48. }
  49. // Patch globals
  50. global.document = {
  51. getElementById: (id) => $(id),
  52. createElement: (tag) => createEl(tag),
  53. createElementNS: (ns, tag) => createEl(tag),
  54. addEventListener() {},
  55. };
  56. global.window = {
  57. parent: { postMessage() {} },
  58. addEventListener() {},
  59. innerWidth: 1200,
  60. innerHeight: 800,
  61. };
  62. global.navigator = { clipboard: { writeText() {} } };
  63. global.localStorage = {
  64. _store: {},
  65. getItem(k) { return this._store[k] || null; },
  66. setItem(k, v) { this._store[k] = v; },
  67. removeItem(k) { delete this._store[k]; },
  68. };
  69. global.URL = { createObjectURL() { return 'blob:test'; } };
  70. global.Blob = class Blob { constructor() {} };
  71. global.requestAnimationFrame = (fn) => fn();
  72. global.setTimeout = (fn, ms) => fn();
  73. global.fetch = async () => ({ ok: true, json: async () => ({}), body: { getReader() { return { read: async () => ({ done: true }) }; } } });
  74. // Evaluate the editor JS in this context
  75. const fn = new Function('$', jsCode);
  76. // We need $ to be our DOM lookup
  77. // But the code defines its own $ — so we need to let it run
  78. eval(`(function(){
  79. ${jsCode}
  80. // Export to global for testing
  81. global._editor = {
  82. parseWorkflow, getStepType, autoLayout, computeIO, state,
  83. RESERVED_NEXT, KNOWN_TYPES, NODE_ICONS,
  84. renderLoopBody, renderDownloadBody, renderUnzipBody,
  85. renderLLMBody, renderBranchBody, renderPauseBody,
  86. rerunFromStep, saveCheckpointToStorage, restoreFromStorage,
  87. _currentWorkflowJson: null,
  88. getState: () => state,
  89. setCheckpoint: (cp) => { _lastCheckpoint = cp; },
  90. getCheckpoint: () => _lastCheckpoint,
  91. setRunID: (id) => { _currentRunID = id; },
  92. };
  93. // Patch the global references
  94. global._editor._currentWorkflowJson = _currentWorkflowJson;
  95. })()`);
  96. const editor = global._editor;
  97. // ===== Test Runner =====
  98. let passed = 0, failed = 0;
  99. function test(name, fn) {
  100. try {
  101. fn();
  102. console.log(` ✓ ${name}`);
  103. passed++;
  104. } catch (e) {
  105. console.log(` ✗ ${name}: ${e.message}`);
  106. failed++;
  107. }
  108. }
  109. function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); }
  110. function assertEqual(a, b, msg) {
  111. if (a !== b) throw new Error(msg || `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
  112. }
  113. // ===== Test Workflow =====
  114. const testWorkflow = {
  115. name: 'TestWorkflow',
  116. version: '3.16',
  117. registry: {
  118. params: ['projectName:string'],
  119. vars: ['$plan:object', '$code:string', '$review:object'],
  120. docs: { 1: 'VL Syntax Rules', 2: 'Theme Spec' },
  121. services: [{ id: 'svc_compile', method: 'compile', desc: 'Compile VL' }],
  122. apis: [{ id: 'api_github', method: 'POST', url: 'https://api.github.com/repos', desc: 'GitHub API' }],
  123. },
  124. steps: [
  125. { 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 != ""' },
  126. { id: 'Branch_Route', meta: { title: 'Route by plan' }, cases: { '=$plan.action == "add"': 'LLM_Generate', '=$plan.action == "fix"': 'Service_Fix' }, next: 'Stop_End' },
  127. { 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' },
  128. { id: 'Service_Fix', serviceId: 'svc_compile', in: { code: '=$code' }, out: { '$code': '=_result' }, next: 'Stop_End' },
  129. { id: 'Loop_Review', meta: { title: 'Review Loop' }, while: '=$review.approved != true', maxIterations: 5, mode: 'serial', children: ['LLM_ReviewStep'], next: 'Write_Output' },
  130. { id: 'LLM_ReviewStep', meta: { title: 'Review Step' }, in: { messages: [{ role: 'user', content: 'review' }] }, out: { '$review': '=_result' }, next: 'BREAK' },
  131. { id: 'Write_Output', target: '/output/code.vx', value: '=$code', mode: 'overwrite', next: 'Download_Assets' },
  132. { 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' },
  133. { id: 'Unzip_Package', meta: { title: 'Unzip Package' }, source: '=$downloadPath', routeByExt: { '.vx': 'Apps/', '.sc': 'Sections/' }, defaultDir: 'Misc/', overwrite: true, next: 'Set_Status' },
  134. { id: 'Set_Status', target: '$status', value: '="completed"', next: 'API_Notify' },
  135. { id: 'API_Notify', apiId: 'api_github', in: { body: '=$plan' }, out: { '$response': '=_result' }, next: 'Pause_Approval' },
  136. { 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' },
  137. { id: 'Fork_Deploy', source: '=$targets', children: ['Component_Deploy1', 'Component_Deploy2'], next: 'Stop_End' },
  138. { id: 'Component_Deploy1', componentId: 'comp_deploy', in: { env: 'staging' }, out: { '$deployResult': '=_result' }, next: 'RETURN' },
  139. { id: 'Component_Deploy2', componentId: 'comp_deploy', in: { env: 'production' }, next: 'RETURN' },
  140. { id: 'Stop_End', meta: { title: 'Done' } },
  141. ]
  142. };
  143. // ===== Tests =====
  144. console.log('\n── Workflow Parsing ──');
  145. test('parse workflow creates correct number of nodes', () => {
  146. editor.parseWorkflow(testWorkflow);
  147. const s = editor.getState();
  148. assertEqual(s.nodes.length, 16);
  149. });
  150. test('parse workflow creates correct number of connections', () => {
  151. const s = editor.getState();
  152. // Count expected: next connections (excluding RETURN, BREAK, Stop_End has no next) + children + cases
  153. // Let's just verify it's reasonable
  154. assert(s.connections.length > 0, 'Should have connections');
  155. assert(s.connections.length < 30, 'Should not have too many connections');
  156. });
  157. console.log('\n── BREAK/RETURN Handling (Bug Fix #1) ──');
  158. test('BREAK does not create phantom connection', () => {
  159. const s = editor.getState();
  160. const breakConns = s.connections.filter(c => c.to === 'BREAK');
  161. assertEqual(breakConns.length, 0, 'No connections should point to BREAK');
  162. });
  163. test('RETURN does not create phantom connection', () => {
  164. const s = editor.getState();
  165. const returnConns = s.connections.filter(c => c.to === 'RETURN');
  166. assertEqual(returnConns.length, 0, 'No connections should point to RETURN');
  167. });
  168. test('RESERVED_NEXT contains both RETURN and BREAK', () => {
  169. assert(editor.RESERVED_NEXT.has('RETURN'));
  170. assert(editor.RESERVED_NEXT.has('BREAK'));
  171. });
  172. test('LLM_ReviewStep has no outgoing serial connection (next is BREAK)', () => {
  173. const s = editor.getState();
  174. const reviewConns = s.connections.filter(c => c.from === 'LLM_ReviewStep' && c.type === 'serial');
  175. assertEqual(reviewConns.length, 0, 'BREAK should not produce a serial connection');
  176. });
  177. console.log('\n── Node Type Detection (Bug Fix #3) ──');
  178. test('KNOWN_TYPES includes all 13 types', () => {
  179. assertEqual(editor.KNOWN_TYPES.length, 13);
  180. assert(editor.KNOWN_TYPES.includes('Download'));
  181. assert(editor.KNOWN_TYPES.includes('Unzip'));
  182. });
  183. test('getStepType detects Download_Assets as Download', () => {
  184. assertEqual(editor.getStepType({ id: 'Download_Assets' }), 'Download');
  185. });
  186. test('getStepType detects Unzip_Package as Unzip', () => {
  187. assertEqual(editor.getStepType({ id: 'Unzip_Package' }), 'Unzip');
  188. });
  189. test('getStepType respects explicit type field', () => {
  190. assertEqual(editor.getStepType({ id: 'Custom_123', type: 'LLM' }), 'LLM');
  191. });
  192. test('getStepType preserves unknown custom prefix', () => {
  193. assertEqual(editor.getStepType({ id: 'Unknown_123' }), 'Unknown');
  194. });
  195. test('NODE_ICONS has Download and Unzip', () => {
  196. assertEqual(editor.NODE_ICONS.Download, 'DL');
  197. assertEqual(editor.NODE_ICONS.Unzip, 'UZ');
  198. });
  199. console.log('\n── Loop While Mode (Bug Fix #2) ──');
  200. test('renderLoopBody shows while expression for while-mode loop', () => {
  201. const body = editor.renderLoopBody({ while: '=$review.approved != true', maxIterations: 5, mode: 'serial' });
  202. assert(body.includes('while'), 'Should contain while label');
  203. assert(body.includes('=$review.approved'), 'Should contain while expression');
  204. assert(body.includes('5'), 'Should show maxIterations');
  205. assert(!body.includes('source'), 'Should NOT show source label');
  206. });
  207. test('renderLoopBody shows source for source-mode loop', () => {
  208. const body = editor.renderLoopBody({ source: '=$items', mode: 'parallel' });
  209. assert(body.includes('source'), 'Should contain source label');
  210. assert(body.includes('=$items'), 'Should contain source expression');
  211. assert(!body.includes('while'), 'Should NOT show while label');
  212. });
  213. test('renderLoopBody shows maxIterations for source-mode too', () => {
  214. const body = editor.renderLoopBody({ source: '=$items', mode: 'parallel', maxIterations: 100 });
  215. assert(body.includes('100'), 'Should show maxIterations');
  216. });
  217. console.log('\n── computeIO (Bug Fix #4) ──');
  218. test('computeIO extracts vars from while expression', () => {
  219. const io = editor.computeIO({ while: '=$review.approved != true', mode: 'serial', maxIterations: 5 });
  220. assert(io.varsIn.some(v => v.includes('$review')), 'Should extract $review from while expression');
  221. });
  222. test('computeIO extracts vars from source', () => {
  223. const io = editor.computeIO({ source: '=$items' });
  224. assert(io.varsIn.includes('$items'), 'Should extract $items from source');
  225. });
  226. test('computeIO extracts output vars', () => {
  227. const io = editor.computeIO({ out: { '$plan': '=_result', '/output.vx': '=$code' } });
  228. assert(io.varsOut.includes('$plan'), 'Should have $plan in varsOut');
  229. assert(io.files.includes('/output.vx'), 'Should have /output.vx in files');
  230. });
  231. test('computeIO extracts docs', () => {
  232. const io = editor.computeIO({ in: { docs: [1, 2], messages: [] } });
  233. assertEqual(io.docs.length, 2);
  234. });
  235. console.log('\n── Download/Unzip Renderers (Bug Fix #3) ──');
  236. test('renderDownloadBody shows source URL', () => {
  237. const body = editor.renderDownloadBody({
  238. source: { url: 'https://cdn.example.com/file.zip' },
  239. target: '/assets/',
  240. routeByExt: { '.png': 'Images/' },
  241. defaultDir: 'Other/'
  242. });
  243. // URL is inside a JSON.stringify'd object, then HTML-escaped and truncated
  244. // Check for key structural elements
  245. assert(body.includes('Download'), 'Should have Download title');
  246. assert(body.includes('source'), 'Should show source label');
  247. assert(body.includes('/assets/'), 'Should show target');
  248. assert(body.includes('1 ext rules'), 'Should show routeByExt count');
  249. assert(body.includes('Other/'), 'Should show defaultDir');
  250. });
  251. test('renderUnzipBody shows source and overwrite', () => {
  252. const body = editor.renderUnzipBody({
  253. source: '=$zipPath',
  254. routeByExt: { '.vx': 'Apps/', '.sc': 'Sections/' },
  255. defaultDir: 'Misc/',
  256. overwrite: true
  257. });
  258. assert(body.includes('$zipPath'), 'Should show source');
  259. assert(body.includes('2 ext rules'), 'Should show 2 ext rules');
  260. assert(body.includes('true'), 'Should show overwrite');
  261. });
  262. console.log('\n── LLM Body Renderer ──');
  263. test('renderLLMBody shows model from both data.model and data.in.model', () => {
  264. const body1 = editor.renderLLMBody({ model: 'claude-opus', in: {} });
  265. assert(body1.includes('claude-opus'), 'Should show model from data.model');
  266. const body2 = editor.renderLLMBody({ in: { model: 'gpt-4' } });
  267. assert(body2.includes('gpt-4'), 'Should show model from data.in.model');
  268. });
  269. test('renderLLMBody shows docs, tokens, messages', () => {
  270. const body = editor.renderLLMBody({
  271. in: { docs: [1], model: 'test', max_tokens: 4096, messages: [{ role: 'user', content: 'hi' }] }
  272. });
  273. assert(body.includes('4096'), 'Should show token count');
  274. assert(body.includes('1 messages'), 'Should show message count');
  275. });
  276. console.log('\n── Branch Body Renderer ──');
  277. test('renderBranchBody handles cases object', () => {
  278. const body = editor.renderBranchBody({ cases: { '=$a > 0': 'StepA', '=$a <= 0': 'StepB' } });
  279. assert(body.includes('$a > 0'), 'Should show case expression');
  280. assert(body.includes('StepA'), 'Should show target');
  281. });
  282. test('renderBranchBody handles branches array', () => {
  283. const body = editor.renderBranchBody({ branches: [['=$x', 'S1'], ['=$y', 'S2']] });
  284. assert(body.includes('$x'), 'Should show branch expression');
  285. });
  286. console.log('\n── Pause Body Renderer ──');
  287. test('renderPauseBody shows reason field (Spec 3.16)', () => {
  288. const body = editor.renderPauseBody({ reason: 'Manager approval needed' });
  289. assert(body.includes('Manager approval'), 'Should show reason as message');
  290. });
  291. console.log('\n── Connection Generation ──');
  292. test('serial connections are created for normal next', () => {
  293. const s = editor.getState();
  294. const conn = s.connections.find(c => c.from === 'LLM_Analyze' && c.to === 'Branch_Route');
  295. assert(conn, 'Should have LLM_Analyze → Branch_Route');
  296. assertEqual(conn.type, 'serial');
  297. });
  298. test('parallel connections are created for children', () => {
  299. const s = editor.getState();
  300. const conns = s.connections.filter(c => c.from === 'Fork_Deploy' && c.type === 'parallel');
  301. assertEqual(conns.length, 2, 'Fork should have 2 parallel children');
  302. });
  303. test('branch-case connections are created for cases', () => {
  304. const s = editor.getState();
  305. const conns = s.connections.filter(c => c.from === 'Branch_Route' && c.type === 'branch-case');
  306. assertEqual(conns.length, 2, 'Branch should have 2 case connections');
  307. assert(conns[0].label, 'Case connections should have labels');
  308. });
  309. test('Loop_Review children connection exists', () => {
  310. const s = editor.getState();
  311. const conn = s.connections.find(c => c.from === 'Loop_Review' && c.to === 'LLM_ReviewStep');
  312. assert(conn, 'Loop should connect to its children');
  313. assertEqual(conn.type, 'parallel');
  314. });
  315. console.log('\n── Auto Layout ──');
  316. test('autoLayout assigns positions to all nodes', () => {
  317. const s = editor.getState();
  318. const positioned = s.nodes.filter(n => n.x > 0 || n.y > 0);
  319. assertEqual(positioned.length, s.nodes.length, 'All nodes should have positions');
  320. });
  321. test('no two nodes overlap exactly', () => {
  322. const s = editor.getState();
  323. const positions = new Set();
  324. for (const n of s.nodes) {
  325. const key = `${n.x},${n.y}`;
  326. assert(!positions.has(key), `Nodes overlap at ${key}: ${n.id}`);
  327. positions.add(key);
  328. }
  329. });
  330. console.log('\n── Checkpoint Persistence ──');
  331. test('saveCheckpointToStorage saves to localStorage', () => {
  332. // parseWorkflow sets _currentWorkflowJson internally
  333. editor.parseWorkflow(testWorkflow);
  334. // Use the exported setCheckpoint and saveCheckpointToStorage
  335. editor.setCheckpoint({ currentStepID: 'LLM_Generate', variables: { '$plan': {} } });
  336. editor.setRunID('run_123');
  337. editor.saveCheckpointToStorage();
  338. const stored = localStorage.getItem('wf_cp_TestWorkflow');
  339. assert(stored, 'Should save to localStorage');
  340. const data = JSON.parse(stored);
  341. assertEqual(data.checkpoint.currentStepID, 'LLM_Generate');
  342. assertEqual(data.runID, 'run_123');
  343. });
  344. test('restoreFromStorage restores node statuses', () => {
  345. // Set some node statuses in storage
  346. const s = editor.getState();
  347. const storageData = {
  348. checkpoint: { currentStepID: 'LLM_Generate' },
  349. nodeStatuses: [
  350. { id: 'LLM_Analyze', status: 'done' },
  351. { id: 'Branch_Route', status: 'done' },
  352. { id: 'LLM_Generate', status: 'error' },
  353. ],
  354. runID: 'run_456',
  355. ts: Date.now()
  356. };
  357. localStorage.setItem('wf_cp_TestWorkflow', JSON.stringify(storageData));
  358. // Clear current statuses
  359. for (const n of s.nodes) n.status = null;
  360. const restored = editor.restoreFromStorage();
  361. assert(restored, 'Should return true');
  362. const analyze = s.nodes.find(n => n.id === 'LLM_Analyze');
  363. assertEqual(analyze.status, 'done');
  364. const generate = s.nodes.find(n => n.id === 'LLM_Generate');
  365. assertEqual(generate.status, 'error');
  366. });
  367. test('restoreFromStorage rejects expired checkpoints (>24h)', () => {
  368. const storageData = {
  369. checkpoint: { currentStepID: 'LLM_Generate' },
  370. nodeStatuses: [{ id: 'LLM_Analyze', status: 'done' }],
  371. runID: 'run_old',
  372. ts: Date.now() - 90000000 // >24h ago
  373. };
  374. localStorage.setItem('wf_cp_TestWorkflow', JSON.stringify(storageData));
  375. // Clear statuses
  376. for (const n of editor.getState().nodes) n.status = null;
  377. const restored = editor.restoreFromStorage();
  378. assertEqual(restored, false, 'Should reject expired checkpoint');
  379. });
  380. console.log('\n── Node Type Coverage ──');
  381. test('all 13 node types are in test workflow', () => {
  382. const s = editor.getState();
  383. const types = new Set(s.nodes.map(n => n.type));
  384. const expected = ['LLM', 'Branch', 'Service', 'Loop', 'Write', 'Download', 'Unzip', 'Set', 'API', 'Pause', 'Fork', 'Component', 'Stop'];
  385. for (const t of expected) {
  386. assert(types.has(t), `Missing type: ${t}`);
  387. }
  388. });
  389. test('all nodes have valid type from KNOWN_TYPES', () => {
  390. const s = editor.getState();
  391. for (const n of s.nodes) {
  392. assert(editor.KNOWN_TYPES.includes(n.type), `Node ${n.id} has unknown type: ${n.type}`);
  393. }
  394. });
  395. console.log('\n── Edge Cases ──');
  396. test('parse empty workflow does not crash', () => {
  397. editor.parseWorkflow({ steps: [] });
  398. assertEqual(editor.getState().nodes.length, 0);
  399. });
  400. test('parse workflow with no registry', () => {
  401. editor.parseWorkflow({ steps: [{ id: 'Stop_1' }] });
  402. assertEqual(editor.getState().nodes.length, 1);
  403. });
  404. test('parse workflow restores after re-parse', () => {
  405. editor.parseWorkflow(testWorkflow);
  406. assertEqual(editor.getState().nodes.length, 16);
  407. });
  408. test('computeIO handles null step gracefully', () => {
  409. const io = editor.computeIO(null);
  410. assertEqual(io.varsIn.length, 0);
  411. assertEqual(io.varsOut.length, 0);
  412. });
  413. test('computeIO handles step with no in/out', () => {
  414. const io = editor.computeIO({ id: 'Stop_1' });
  415. assertEqual(io.docs.length, 0);
  416. });
  417. // ===== Results =====
  418. console.log(`\n── Results ──\n\n ${passed} passed, ${failed} failed\n`);
  419. process.exit(failed > 0 ? 1 : 0);