pipeline.test.js (12186B)
1 // test/pipeline.test.js - parsing, validation and expansion of .conductor.yml 2 3 import test from 'node:test'; 4 import assert from 'node:assert/strict'; 5 import { compilePipeline, parsePipeline, PipelineError } from '../src/lib/pipeline/index.js'; 6 7 const MINIMAL = ` 8 version: 1 9 tasks: 10 build: 11 image: alpine 12 script: [make] 13 `; 14 15 function errorPaths(fn) { 16 try { 17 fn(); 18 } catch (e) { 19 assert.ok(e instanceof PipelineError, `expected PipelineError, got ${e}`); 20 return e.errors.map((x) => x.path); 21 } 22 throw new Error('expected the pipeline to be rejected'); 23 } 24 25 function byName(pipeline) { 26 return Object.fromEntries(pipeline.tasks.map((j) => [j.name, j])); 27 } 28 29 test('a minimal pipeline compiles', () => { 30 const p = compilePipeline(MINIMAL); 31 assert.equal(p.tasks.length, 1); 32 assert.equal(p.tasks[0].name, 'build'); 33 assert.deepEqual(p.tasks[0].script, ['make']); 34 assert.equal(p.tasks[0].depth, 0); 35 }); 36 37 test('version is required and pinned', () => { 38 assert.deepEqual(errorPaths(() => compilePipeline('tasks:\n a:\n image: x\n script: [y]\n')), ['version']); 39 assert.deepEqual(errorPaths(() => compilePipeline('version: 2\ntasks:\n a:\n image: x\n script: [y]\n')), ['version']); 40 }); 41 42 test('empty and malformed documents are rejected clearly', () => { 43 assert.throws(() => compilePipeline(''), /file is empty/); 44 assert.throws(() => compilePipeline('- a\n- b\n'), /expected a mapping at the top level/); 45 assert.throws(() => compilePipeline('version: 1\ntasks: {\n'), /not valid YAML/); 46 }); 47 48 test('image and script are required', () => { 49 const paths = errorPaths(() => compilePipeline('version: 1\ntasks:\n a: {}\n')); 50 assert.deepEqual(paths.sort(), ['tasks.a.image', 'tasks.a.script']); 51 }); 52 53 test('image may come from defaults', () => { 54 const p = compilePipeline('version: 1\ndefaults:\n image: alpine\ntasks:\n a:\n script: [x]\n'); 55 assert.equal(p.tasks[0].image, 'alpine'); 56 }); 57 58 test('unknown keys are rejected, with a suggestion when close', () => { 59 try { 60 compilePipeline('version: 1\ntasks:\n a:\n image: x\n script: [y]\n scripts: [z]\n'); 61 throw new Error('expected rejection'); 62 } catch (e) { 63 assert.match(e.message, /tasks\.a\.scripts: unknown key, did you mean script/); 64 } 65 }); 66 67 test('all problems are reported at once', () => { 68 const paths = errorPaths(() => compilePipeline(` 69 version: 1 70 tasks: 71 a: 72 script: [x] 73 b: 74 image: y 75 `)); 76 assert.ok(paths.includes('tasks.a.image')); 77 assert.ok(paths.includes('tasks.b.script')); 78 }); 79 80 test('durations accept seconds, units and composites', () => { 81 const p = compilePipeline(` 82 version: 1 83 tasks: 84 a: { image: x, script: [y], timeout: 90 } 85 b: { image: x, script: [y], timeout: 30m } 86 c: { image: x, script: [y], timeout: 1h30m } 87 `); 88 const tasks = byName(p); 89 assert.equal(tasks.a.timeout, 90); 90 assert.equal(tasks.b.timeout, 1800); 91 assert.equal(tasks.c.timeout, 5400); 92 }); 93 94 test('durations accept day and week units', () => { 95 const p = compilePipeline(` 96 version: 1 97 tasks: 98 a: 99 image: x 100 script: [y] 101 artifacts: 102 paths: [d] 103 expire: 30d 104 b: 105 image: x 106 script: [y] 107 artifacts: 108 paths: [d] 109 expire: 2w 110 `); 111 const tasks = byName(p); 112 assert.equal(tasks.a.artifacts.expire, 30 * 86400); 113 assert.equal(tasks.b.artifacts.expire, 14 * 86400); 114 }); 115 116 test('a malformed duration is rejected', () => { 117 assert.deepEqual( 118 errorPaths(() => compilePipeline('version: 1\ntasks:\n a: { image: x, script: [y], timeout: soon }\n')), 119 ['tasks.a.timeout'] 120 ); 121 }); 122 123 test('arch expands into one task per architecture', () => { 124 const p = compilePipeline('version: 1\ntasks:\n b:\n image: x\n script: [y]\n arch: [x86_64, aarch64]\n'); 125 assert.deepEqual(p.tasks.map((j) => j.name).sort(), ['b:arch=aarch64', 'b:arch=x86_64']); 126 assert.equal(byName(p)['b:arch=x86_64'].env.ARCH, 'x86_64'); 127 }); 128 129 test('a matrix expands to the cartesian product', () => { 130 const p = compilePipeline(` 131 version: 1 132 tasks: 133 p: 134 image: x 135 script: [y] 136 matrix: 137 pkg: [musl, busybox] 138 mode: [debug, release] 139 `); 140 assert.equal(p.tasks.length, 4); 141 // Dimensions keep their declaration order, so names are predictable. 142 assert.deepEqual(p.tasks.map((j) => j.name).sort(), [ 143 'p:pkg=busybox,mode=debug', 144 'p:pkg=busybox,mode=release', 145 'p:pkg=musl,mode=debug', 146 'p:pkg=musl,mode=release', 147 ]); 148 const one = byName(p)['p:pkg=musl,mode=debug']; 149 assert.equal(one.env.MATRIX_PKG, 'musl'); 150 assert.equal(one.env.MATRIX_MODE, 'debug'); 151 }); 152 153 test('needs match on shared dimensions rather than fanning out', () => { 154 const p = compilePipeline(` 155 version: 1 156 tasks: 157 build: 158 image: x 159 script: [y] 160 arch: [x86_64, aarch64] 161 package: 162 image: x 163 script: [y] 164 arch: [x86_64, aarch64] 165 matrix: 166 pkg: [musl] 167 needs: [build] 168 `); 169 const tasks = byName(p); 170 assert.deepEqual(tasks['package:arch=x86_64,pkg=musl'].needs, ['build:arch=x86_64']); 171 assert.deepEqual(tasks['package:arch=aarch64,pkg=musl'].needs, ['build:arch=aarch64']); 172 }); 173 174 test('a task without the shared dimension depends on every instance', () => { 175 const p = compilePipeline(` 176 version: 1 177 tasks: 178 build: 179 image: x 180 script: [y] 181 arch: [x86_64, aarch64] 182 publish: 183 image: x 184 script: [y] 185 needs: [build] 186 `); 187 assert.deepEqual(byName(p).publish.needs, ['build:arch=aarch64', 'build:arch=x86_64']); 188 }); 189 190 test('match all opts out of dimension matching', () => { 191 const p = compilePipeline(` 192 version: 1 193 tasks: 194 build: 195 image: x 196 script: [y] 197 arch: [x86_64, aarch64] 198 check: 199 image: x 200 script: [y] 201 arch: [x86_64, aarch64] 202 needs: 203 - task: build 204 match: all 205 `); 206 assert.deepEqual(byName(p)['check:arch=x86_64'].needs, ['build:arch=aarch64', 'build:arch=x86_64']); 207 }); 208 209 test('needs may name one concrete instance', () => { 210 const p = compilePipeline(` 211 version: 1 212 tasks: 213 build: 214 image: x 215 script: [y] 216 arch: [x86_64, aarch64] 217 docs: 218 image: x 219 script: [y] 220 needs: ['build:arch=x86_64'] 221 `); 222 assert.deepEqual(byName(p).docs.needs, ['build:arch=x86_64']); 223 }); 224 225 test('an unsatisfiable dimension match is reported, not silently dropped', () => { 226 try { 227 compilePipeline(` 228 version: 1 229 tasks: 230 build: 231 image: x 232 script: [y] 233 arch: [x86_64] 234 package: 235 image: x 236 script: [y] 237 arch: [riscv64] 238 needs: [build] 239 `); 240 throw new Error('expected rejection'); 241 } catch (e) { 242 assert.match(e.message, /no instance of "build" matches package:arch=riscv64 on arch/); 243 assert.match(e.message, /match: all/); 244 } 245 }); 246 247 test('needs on an unknown task lists what is defined', () => { 248 try { 249 compilePipeline('version: 1\ntasks:\n a:\n image: x\n script: [y]\n needs: [nope]\n'); 250 throw new Error('expected rejection'); 251 } catch (e) { 252 assert.match(e.message, /refers to unknown task "nope"/); 253 } 254 }); 255 256 test('self dependency is rejected', () => { 257 assert.throws( 258 () => compilePipeline('version: 1\ntasks:\n a:\n image: x\n script: [y]\n needs: [a]\n'), 259 /cannot depend on itself/ 260 ); 261 }); 262 263 test('a dependency cycle names the cycle', () => { 264 assert.throws(() => compilePipeline(` 265 version: 1 266 tasks: 267 a: { image: x, script: [s], needs: [c] } 268 b: { image: x, script: [s], needs: [a] } 269 c: { image: x, script: [s], needs: [b] } 270 `), /dependency cycle: (a -> c -> b -> a|b -> a -> c -> b|c -> b -> a -> c)/); 271 }); 272 273 test('interpolation substitutes arch and matrix values', () => { 274 const p = compilePipeline(` 275 version: 1 276 tasks: 277 b: 278 image: 'builder:\${{ arch }}' 279 arch: [x86_64] 280 matrix: 281 pkg: [musl] 282 script: ['build \${{ matrix.pkg }} for \${{ arch }}'] 283 env: 284 TAG: '\${{ matrix.pkg }}-\${{ arch }}' 285 `); 286 const task = p.tasks[0]; 287 assert.equal(task.image, 'builder:x86_64'); 288 assert.deepEqual(task.script, ['build musl for x86_64']); 289 assert.equal(task.env.TAG, 'musl-x86_64'); 290 }); 291 292 test('interpolating an undefined dimension is an error', () => { 293 try { 294 compilePipeline('version: 1\ntasks:\n a:\n image: x\n script: ["\${{ matrix.nope }}"]\n'); 295 throw new Error('expected rejection'); 296 } catch (e) { 297 assert.match(e.message, /matrix\.nope \}\} which the task does not define/); 298 } 299 }); 300 301 test('referring to arch without declaring one is an error', () => { 302 assert.throws( 303 () => compilePipeline('version: 1\ntasks:\n a:\n image: "x:\${{ arch }}"\n script: [y]\n'), 304 /the task declares no arch/ 305 ); 306 }); 307 308 test('matrix values must be safe for use in a task name', () => { 309 assert.deepEqual( 310 errorPaths(() => compilePipeline('version: 1\ntasks:\n a:\n image: x\n script: [y]\n matrix:\n k: ["has space"]\n')), 311 ['tasks.a.matrix.k[0]'] 312 ); 313 }); 314 315 test('arch may not also be a matrix dimension', () => { 316 assert.deepEqual( 317 errorPaths(() => compilePipeline('version: 1\ntasks:\n a:\n image: x\n script: [y]\n matrix:\n arch: [x86_64]\n')), 318 ['tasks.a.matrix.arch'] 319 ); 320 }); 321 322 test('services get a derived alias and reject duplicates', () => { 323 const p = compilePipeline(` 324 version: 1 325 tasks: 326 a: 327 image: x 328 script: [y] 329 services: 330 - docker:dind 331 - image: registry.example.com/team/postgres:16 332 alias: db 333 `); 334 assert.deepEqual(p.tasks[0].services.map((s) => s.alias), ['docker', 'db']); 335 336 assert.throws(() => compilePipeline(` 337 version: 1 338 tasks: 339 a: 340 image: x 341 script: [y] 342 services: [docker:dind, docker:24-dind] 343 `), /duplicate service alias/); 344 }); 345 346 test('artifacts accept a bare list and reject absolute paths', () => { 347 const p = compilePipeline('version: 1\ntasks:\n a:\n image: x\n script: [y]\n artifacts: [dist/**]\n'); 348 assert.deepEqual(p.tasks[0].artifacts.paths, ['dist/**']); 349 assert.equal(p.tasks[0].artifacts.when, 'on_success'); 350 351 assert.deepEqual( 352 errorPaths(() => compilePipeline('version: 1\ntasks:\n a:\n image: x\n script: [y]\n artifacts: [/etc/passwd]\n')), 353 ['tasks.a.artifacts.paths[0]'] 354 ); 355 }); 356 357 test('artifact when is constrained', () => { 358 assert.deepEqual( 359 errorPaths(() => compilePipeline('version: 1\ntasks:\n a:\n image: x\n script: [y]\n artifacts:\n paths: [d]\n when: maybe\n')), 360 ['tasks.a.artifacts.when'] 361 ); 362 }); 363 364 test('defaults are inherited and overridden per task', () => { 365 const p = compilePipeline(` 366 version: 1 367 defaults: 368 image: base 369 timeout: 10m 370 env: 371 SHARED: yes 372 requires: [docker] 373 tasks: 374 a: 375 script: [x] 376 b: 377 image: custom 378 timeout: 1h 379 script: [x] 380 env: 381 OWN: no 382 `); 383 const tasks = byName(p); 384 assert.equal(tasks.a.image, 'base'); 385 assert.equal(tasks.a.timeout, 600); 386 assert.deepEqual(tasks.a.requires, ['docker']); 387 assert.equal(tasks.b.image, 'custom'); 388 assert.equal(tasks.b.timeout, 3600); 389 assert.equal(tasks.b.env.SHARED, 'yes'); 390 assert.equal(tasks.b.env.OWN, 'no'); 391 }); 392 393 test('env values must be scalars with valid names', () => { 394 const paths = errorPaths(() => compilePipeline(` 395 version: 1 396 tasks: 397 a: 398 image: x 399 script: [y] 400 env: 401 'bad name': 1 402 GOOD: { nested: true } 403 `)); 404 assert.deepEqual(paths.sort(), ['tasks.a.env.GOOD', 'tasks.a.env.bad name']); 405 }); 406 407 test('parsePipeline normalizes without expanding', () => { 408 const doc = parsePipeline(MINIMAL); 409 assert.equal(doc.version, 1); 410 assert.deepEqual(Object.keys(doc.tasks), ['build']); 411 assert.deepEqual(doc.tasks.build.needs, []); 412 }); 413 414 test('a realistic distribution pipeline produces the expected graph', () => { 415 const p = compilePipeline(` 416 version: 1 417 defaults: 418 image: debian:bookworm-slim 419 tasks: 420 lint: 421 script: [make lint] 422 build: 423 arch: [x86_64, aarch64] 424 script: ['./mk/build.sh $ARCH'] 425 package: 426 needs: [build] 427 arch: [x86_64, aarch64] 428 matrix: 429 pkg: [musl, busybox] 430 requires: [sign-key] 431 script: ['./mk/pkg.sh $MATRIX_PKG $ARCH'] 432 publish: 433 needs: [package, lint] 434 script: [./mk/publish.sh] 435 `); 436 437 assert.equal(p.tasks.length, 1 + 2 + 4 + 1); 438 const tasks = byName(p); 439 assert.equal(tasks.publish.needs.length, 5); 440 assert.equal(tasks.publish.depth, 2); 441 assert.deepEqual(tasks['package:arch=aarch64,pkg=musl'].requires, ['sign-key']); 442 });