dag.test.js (6191B)
1 // test/dag.test.js - dependency graph traversal 2 // 3 // These cover the two scheduling defects carried over from the prototype: 4 // serialized dispatch, and a failure cascade that only skipped direct 5 // dependents. 6 7 import test from 'node:test'; 8 import assert from 'node:assert/strict'; 9 import { topologicalOrder, depths, transitiveDependents, runnable, CycleError } from '../src/lib/pipeline/dag.js'; 10 11 // a -> b -> d, a -> c -> d, plus an unrelated island. 12 const GRAPH = [ 13 { name: 'a', needs: [] }, 14 { name: 'b', needs: ['a'] }, 15 { name: 'c', needs: ['a'] }, 16 { name: 'd', needs: ['b', 'c'] }, 17 { name: 'island', needs: [] }, 18 ]; 19 20 function states(overrides = {}) { 21 const map = new Map(GRAPH.map((j) => [j.name, 'queued'])); 22 for (const [k, v] of Object.entries(overrides)) map.set(k, v); 23 return map; 24 } 25 26 test('topological order places dependencies first', () => { 27 const order = topologicalOrder(GRAPH).map((j) => j.name); 28 assert.ok(order.indexOf('a') < order.indexOf('b')); 29 assert.ok(order.indexOf('a') < order.indexOf('c')); 30 assert.ok(order.indexOf('b') < order.indexOf('d')); 31 assert.ok(order.indexOf('c') < order.indexOf('d')); 32 }); 33 34 test('topological order is deterministic', () => { 35 const first = topologicalOrder(GRAPH).map((j) => j.name); 36 const shuffled = [...GRAPH].reverse(); 37 assert.deepEqual(topologicalOrder(shuffled).map((j) => j.name), first); 38 }); 39 40 test('a cycle is detected and named', () => { 41 const cyclic = [ 42 { name: 'x', needs: ['z'] }, 43 { name: 'y', needs: ['x'] }, 44 { name: 'z', needs: ['y'] }, 45 ]; 46 assert.throws(() => topologicalOrder(cyclic), CycleError); 47 try { 48 topologicalOrder(cyclic); 49 } catch (e) { 50 // The reported path returns to where it started. 51 assert.equal(e.cycle[0], e.cycle[e.cycle.length - 1]); 52 } 53 }); 54 55 test('depth reflects the longest path, not insertion order', () => { 56 const d = depths(GRAPH); 57 assert.equal(d.get('a'), 0); 58 assert.equal(d.get('island'), 0); 59 assert.equal(d.get('b'), 1); 60 assert.equal(d.get('d'), 2); 61 }); 62 63 test('independent tasks are runnable at the same time', () => { 64 // The prototype could only ever return one task here. 65 const ready = runnable(GRAPH, states()).map((j) => j.name); 66 assert.deepEqual(ready.sort(), ['a', 'island']); 67 }); 68 69 test('a task becomes runnable only once every dependency succeeds', () => { 70 assert.deepEqual( 71 runnable(GRAPH, states({ a: 'success', b: 'success' })).map((j) => j.name).sort(), 72 ['c', 'island'] 73 ); 74 assert.deepEqual( 75 runnable(GRAPH, states({ a: 'success', b: 'success', c: 'success', island: 'success' })).map((j) => j.name), 76 ['d'] 77 ); 78 }); 79 80 test('a running dependency does not release its dependents', () => { 81 assert.equal(runnable(GRAPH, states({ a: 'running' })).some((j) => j.name === 'b'), false); 82 }); 83 84 test('failure skips transitive dependents, not just direct ones', () => { 85 // This is the prototype bug: it would have skipped only b and c, leaving 86 // d queued and dispatchable with its inputs missing. 87 const skipped = transitiveDependents(GRAPH, ['a']); 88 assert.deepEqual([...skipped].sort(), ['b', 'c', 'd']); 89 assert.equal(skipped.has('island'), false); 90 }); 91 92 test('a failed dependency never releases a dependent', () => { 93 assert.equal(runnable(GRAPH, states({ a: 'failed' })).some((j) => j.name === 'b'), false); 94 }); 95 96 test('a skipped dependency releases a dependent, since a skip is not a failure', () => { 97 assert.equal(runnable(GRAPH, states({ a: 'skipped' })).some((j) => j.name === 'b'), true); 98 }); 99 100 test('allowed failures can satisfy a dependency when asked to', () => { 101 const ready = runnable(GRAPH, states({ a: 'failed' }), { satisfied: ['success', 'failed'] }); 102 assert.deepEqual(ready.map((j) => j.name).sort(), ['b', 'c', 'island']); 103 }); 104 105 test('transitive dependents of a leaf is empty', () => { 106 assert.equal(transitiveDependents(GRAPH, ['d']).size, 0); 107 }); 108 109 test('graph helpers tolerate dependencies outside the set', () => { 110 const partial = [{ name: 'only', needs: ['absent'] }]; 111 assert.deepEqual(topologicalOrder(partial).map((j) => j.name), ['only']); 112 assert.equal(depths(partial).get('only'), 0); 113 }); 114 115 // --- after --- 116 117 // notify waits for report to finish, but not for it to have worked. 118 const AFTER_GRAPH = [ 119 { name: 'build', needs: [], after: [] }, 120 { name: 'report', needs: ['build'], after: [] }, 121 { name: 'notify', needs: [], after: ['report'] }, 122 ]; 123 124 function afterStates(overrides = {}) { 125 const map = new Map(AFTER_GRAPH.map((j) => [j.name, 'queued'])); 126 for (const [k, v] of Object.entries(overrides)) map.set(k, v); 127 return map; 128 } 129 130 test('after orders a task without putting it downstream of a failure', () => { 131 const order = topologicalOrder(AFTER_GRAPH).map((j) => j.name); 132 assert.ok(order.indexOf('report') < order.indexOf('notify')); 133 134 // The whole point: a build failure carries report off with it, and leaves 135 // notify to run. 136 const skipped = transitiveDependents(AFTER_GRAPH, ['build']); 137 assert.deepEqual([...skipped], ['report']); 138 }); 139 140 test('a cycle through after is still a cycle', () => { 141 // after waits just as needs does, so a loop of them deadlocks the same way. 142 const cyclic = [ 143 { name: 'x', needs: [], after: ['y'] }, 144 { name: 'y', needs: ['x'], after: [] }, 145 ]; 146 assert.throws(() => topologicalOrder(cyclic), CycleError); 147 }); 148 149 test('an after dependency releases its dependent whatever it finished as', () => { 150 for (const state of ['success', 'failed', 'skipped', 'cancelled']) { 151 const ready = runnable(AFTER_GRAPH, afterStates({ build: 'success', report: state })); 152 assert.ok(ready.some((j) => j.name === 'notify'), `should run after a ${state} dependency`); 153 } 154 }); 155 156 test('an unfinished after dependency still holds its dependent back', () => { 157 for (const state of ['queued', 'running']) { 158 const ready = runnable(AFTER_GRAPH, afterStates({ report: state })); 159 assert.equal(ready.some((j) => j.name === 'notify'), false, `should wait on a ${state} dependency`); 160 } 161 }); 162 163 test('depth counts after edges, since the task still runs later', () => { 164 const d = depths(AFTER_GRAPH); 165 assert.equal(d.get('build'), 0); 166 assert.equal(d.get('report'), 1); 167 assert.equal(d.get('notify'), 2); 168 });