only.test.js (6678B)
1 // test/only.test.js - restricting tasks to particular refs 2 3 import test from 'node:test'; 4 import assert from 'node:assert/strict'; 5 6 import { compilePipeline, refMatches, PipelineError } from '../src/lib/pipeline/index.js'; 7 8 const PIPELINE = ` 9 version: 1 10 defaults: 11 image: alpine:3 12 tasks: 13 build: 14 script: ['make'] 15 publish: 16 needs: [build] 17 only: 18 refs: [refs/heads/main, 'refs/tags/v*'] 19 script: ['make publish'] 20 `; 21 22 function names(text, ref) { 23 return compilePipeline(text, { ref }).tasks.map((j) => j.name).sort(); 24 } 25 26 test('a pattern without a star matches exactly', () => { 27 assert.ok(refMatches('refs/heads/main', 'refs/heads/main')); 28 assert.ok(!refMatches('refs/heads/main', 'refs/heads/main-2')); 29 assert.ok(!refMatches('refs/heads/main', 'refs/heads/mai')); 30 31 // A pattern is matched against the whole ref, so a branch name on its 32 // own is not enough. 33 assert.ok(!refMatches('main', 'refs/heads/main')); 34 }); 35 36 test('a star matches any job of characters', () => { 37 assert.ok(refMatches('refs/tags/v*', 'refs/tags/v1.2.0')); 38 assert.ok(refMatches('refs/tags/v*', 'refs/tags/v')); 39 assert.ok(!refMatches('refs/tags/v*', 'refs/heads/v1')); 40 assert.ok(refMatches('*', 'refs/heads/anything')); 41 assert.ok(refMatches('refs/*/main', 'refs/heads/main')); 42 }); 43 44 test('the rest of a pattern is literal, not a regular expression', () => { 45 // A dot is a dot. Were this compiled straight to a regular expression, 46 // v1.2.0 would also match v1x2x0. 47 assert.ok(refMatches('refs/tags/v1.2.0', 'refs/tags/v1.2.0')); 48 assert.ok(!refMatches('refs/tags/v1.2.0', 'refs/tags/v1x2x0')); 49 50 // And these would be quantifiers rather than characters. 51 assert.ok(refMatches('refs/heads/fix+', 'refs/heads/fix+')); 52 assert.ok(!refMatches('refs/heads/fix+', 'refs/heads/fixx')); 53 }); 54 55 test('a restricted task is left out of jobs for other refs', () => { 56 assert.deepEqual(names(PIPELINE, 'refs/heads/main'), ['build', 'publish']); 57 assert.deepEqual(names(PIPELINE, 'refs/tags/v1.2.0'), ['build', 'publish']); 58 assert.deepEqual(names(PIPELINE, 'refs/heads/feature'), ['build']); 59 }); 60 61 test('it is left out rather than recorded as skipped', () => { 62 // A skipped task satisfies needs, so recording it that way would let the 63 // steps below a never-intended publish run anyway. 64 const { tasks } = compilePipeline(PIPELINE, { ref: 'refs/heads/feature' }); 65 assert.ok(!tasks.some((j) => j.name === 'publish')); 66 }); 67 68 test('tasks depending on an excluded task are excluded too', () => { 69 const text = ` 70 version: 1 71 defaults: 72 image: alpine:3 73 tasks: 74 build: 75 script: ['make'] 76 publish: 77 needs: [build] 78 only: 79 refs: [refs/heads/main] 80 script: ['make publish'] 81 announce: 82 needs: [publish] 83 script: ['make announce'] 84 `; 85 // announce carries no rule of its own, but running it without publish 86 // would mean running it without something it declared it needed. 87 assert.deepEqual(names(text, 'refs/heads/feature'), ['build']); 88 assert.deepEqual(names(text, 'refs/heads/main'), ['announce', 'build', 'publish']); 89 }); 90 91 test('depth and ordering describe the job that will happen', () => { 92 const { tasks } = compilePipeline(PIPELINE, { ref: 'refs/heads/feature' }); 93 assert.equal(tasks.length, 1); 94 assert.equal(tasks[0].name, 'build'); 95 assert.equal(tasks[0].depth, 0); 96 }); 97 98 test('without a ref nothing is filtered, so a pipeline can still be validated', () => { 99 const { tasks } = compilePipeline(PIPELINE); 100 assert.deepEqual(tasks.map((j) => j.name).sort(), ['build', 'publish']); 101 }); 102 103 test('a restricted task cannot match a job that has no ref', () => { 104 assert.deepEqual(names(PIPELINE, ''), ['build']); 105 }); 106 107 test('a malformed rule is reported rather than ignored', () => { 108 const cases = [ 109 ['only: refs/heads/main', 'must be a mapping'], 110 ['only:\n branches: [main]', 'unknown key'], 111 ['only:\n refs: []', 'at least one'], 112 ['only: {}', 'needs a refs list'], 113 ]; 114 115 for (const [fragment, expected] of cases) { 116 const text = ` 117 version: 1 118 defaults: 119 image: alpine:3 120 tasks: 121 publish: 122 script: ['make publish'] 123 ${fragment} 124 `; 125 assert.throws( 126 () => compilePipeline(text, { ref: 'refs/heads/main' }), 127 (error) => { 128 assert.ok(error instanceof PipelineError, `${fragment}: expected PipelineError`); 129 const text = error.errors.map((e) => e.message).join('; '); 130 assert.match(text, new RegExp(expected), `${fragment}: got ${text}`); 131 return true; 132 }, 133 `expected ${JSON.stringify(fragment)} to be rejected`, 134 ); 135 } 136 }); 137 138 test('the rule is not inherited from defaults', () => { 139 // Inheriting it would restrict every task at once, which is a hard 140 // mistake to spot when the symptom is an empty job. 141 const text = ` 142 version: 1 143 defaults: 144 image: alpine:3 145 only: 146 refs: [refs/heads/main] 147 tasks: 148 build: 149 script: ['make'] 150 `; 151 assert.throws( 152 () => compilePipeline(text, { ref: 'refs/heads/main' }), 153 (error) => { 154 assert.ok(error instanceof PipelineError); 155 const message = error.errors.map((e) => `${e.path}: ${e.message}`).join('; '); 156 assert.match(message, /defaults\.only/, `expected defaults.only to be rejected, got ${message}`); 157 return true; 158 }, 159 ); 160 }); 161 162 test('a task that only comes after an excluded one still runs', () => { 163 const text = ` 164 version: 1 165 defaults: 166 image: alpine:3 167 tasks: 168 build: 169 script: ['make'] 170 publish: 171 needs: [build] 172 only: 173 refs: [refs/heads/main] 174 script: ['make publish'] 175 notify: 176 after: [publish] 177 script: ['make notify'] 178 `; 179 // notify asked to run after publish, not to be fed by it. On a branch 180 // where publish never happens there is nothing to wait for, so the edge 181 // goes and notify stays. 182 assert.deepEqual(names(text, 'refs/heads/feature'), ['build', 'notify']); 183 assert.deepEqual(names(text, 'refs/heads/main'), ['build', 'notify', 'publish']); 184 }); 185 186 test('an after edge into an excluded task is pruned, not left dangling', () => { 187 // The scheduler resolves these names to task ids, so one pointing at a 188 // task that is not in the job would be written as a broken edge. 189 const { tasks } = compilePipeline(` 190 version: 1 191 defaults: 192 image: alpine:3 193 tasks: 194 build: 195 script: ['make'] 196 publish: 197 only: 198 refs: [refs/heads/main] 199 script: ['make publish'] 200 notify: 201 after: [build, publish] 202 script: ['make notify'] 203 `, { ref: 'refs/heads/feature' }); 204 205 const present = new Set(tasks.map((t) => t.name)); 206 const notify = tasks.find((t) => t.name === 'notify'); 207 assert.deepEqual(notify.after, ['build']); 208 for (const dep of notify.after) assert.ok(present.has(dep), `${dep} should be in the job`); 209 });