pipeline.test.js (18782B)
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 }); 443 444 // --- after --- 445 446 test('after resolves to task names in its own list, separate from needs', () => { 447 const p = compilePipeline(` 448 version: 1 449 defaults: 450 image: x 451 tasks: 452 build: 453 script: [y] 454 test: 455 script: [y] 456 needs: [build] 457 notify: 458 script: [y] 459 after: [test] 460 `); 461 const tasks = byName(p); 462 assert.deepEqual(tasks.notify.needs, []); 463 assert.deepEqual(tasks.notify.after, ['test']); 464 // It still sits downstream, so the graph reads in running order. 465 assert.equal(tasks.notify.depth, 2); 466 }); 467 468 test('after matches shared dimensions exactly as needs does', () => { 469 const p = compilePipeline(` 470 version: 1 471 defaults: 472 image: x 473 tasks: 474 build: 475 script: [y] 476 arch: [x86_64, aarch64] 477 cleanup: 478 script: [y] 479 arch: [x86_64, aarch64] 480 after: [build] 481 report: 482 script: [y] 483 after: [build] 484 `); 485 const tasks = byName(p); 486 assert.deepEqual(tasks['cleanup:arch=x86_64'].after, ['build:arch=x86_64']); 487 assert.deepEqual(tasks['cleanup:arch=aarch64'].after, ['build:arch=aarch64']); 488 // No shared dimension, so it waits for every instance. 489 assert.deepEqual(tasks.report.after, ['build:arch=aarch64', 'build:arch=x86_64']); 490 }); 491 492 test('after accepts the mapping form too', () => { 493 const p = compilePipeline(` 494 version: 1 495 defaults: 496 image: x 497 tasks: 498 build: 499 script: [y] 500 arch: [x86_64, aarch64] 501 cleanup: 502 script: [y] 503 arch: [x86_64, aarch64] 504 after: 505 - task: build 506 match: all 507 `); 508 assert.deepEqual( 509 byName(p)['cleanup:arch=x86_64'].after, 510 ['build:arch=aarch64', 'build:arch=x86_64'] 511 ); 512 }); 513 514 test('a dependency named both ways keeps only the stricter one', () => { 515 // needs already implies after, so recording both would be a contradiction 516 // waiting to be read the wrong way round. 517 const p = compilePipeline(` 518 version: 1 519 defaults: 520 image: x 521 tasks: 522 build: 523 script: [y] 524 deploy: 525 script: [y] 526 needs: [build] 527 after: [build] 528 `); 529 const deploy = byName(p).deploy; 530 assert.deepEqual(deploy.needs, ['build']); 531 assert.deepEqual(deploy.after, []); 532 }); 533 534 test('after is validated like needs', () => { 535 assert.throws( 536 () => compilePipeline('version: 1\ntasks:\n a:\n image: x\n script: [y]\n after: [a]\n'), 537 /cannot depend on itself/ 538 ); 539 assert.throws( 540 () => compilePipeline('version: 1\ntasks:\n a:\n image: x\n script: [y]\n after: [ghost]\n'), 541 /tasks\.a\.after[\s\S]*unknown task "ghost"/ 542 ); 543 assert.throws( 544 () => compilePipeline(` 545 version: 1 546 tasks: 547 a: { image: x, script: [s], after: [b] } 548 b: { image: x, script: [s], after: [a] } 549 `), /dependency cycle/); 550 }); 551 552 // --- task outputs --- 553 554 test('reading an output implies needing the task, and is left for dispatch', () => { 555 const p = compilePipeline(` 556 version: 1 557 defaults: 558 image: x 559 tasks: 560 build: 561 script: [make] 562 deploy: 563 script: ['ship \${{ tasks.build.outputs.tag }}'] 564 `); 565 const deploy = byName(p).deploy; 566 assert.deepEqual(deploy.needs, ['build'], 'the reference is a reason to wait'); 567 assert.deepEqual(deploy.output_refs, { build: ['build'] }); 568 assert.deepEqual( 569 deploy.script, 570 ['ship ${{ tasks.build.outputs.tag }}'], 571 'the value is not known yet, so the reference survives compilation' 572 ); 573 assert.equal(deploy.depth, 1); 574 }); 575 576 test('an implied need is not repeated when it is also declared', () => { 577 const p = compilePipeline(` 578 version: 1 579 defaults: 580 image: x 581 tasks: 582 build: 583 script: [make] 584 deploy: 585 script: ['ship \${{ tasks.build.outputs.tag }}'] 586 needs: [build] 587 `); 588 assert.deepEqual(byName(p).deploy.needs, ['build']); 589 }); 590 591 test('reading an output of a matrix task waits for every instance of it', () => { 592 const p = compilePipeline(` 593 version: 1 594 defaults: 595 image: x 596 tasks: 597 build: 598 arch: [x86_64, aarch64] 599 script: ['echo "::set-output pkg-\${{ arch }}=/out/\${{ arch }}.tar::public"'] 600 index: 601 script: ['index \${{ tasks.build.outputs.pkg-x86_64 }} \${{ tasks.build.outputs.pkg-aarch64 }}'] 602 `); 603 const index = byName(p).index; 604 assert.deepEqual(index.needs, ['build:arch=aarch64', 'build:arch=x86_64']); 605 assert.deepEqual(index.output_refs, { build: ['build:arch=aarch64', 'build:arch=x86_64'] }); 606 }); 607 608 test('a shared dimension still narrows an output reference to its own instance', () => { 609 const p = compilePipeline(` 610 version: 1 611 defaults: 612 image: x 613 tasks: 614 build: 615 arch: [x86_64, aarch64] 616 script: [make] 617 sign: 618 arch: [x86_64, aarch64] 619 script: ['sign \${{ tasks.build.outputs.path }}'] 620 `); 621 const tasks = byName(p); 622 assert.deepEqual(tasks['sign:arch=x86_64'].output_refs, { build: ['build:arch=x86_64'] }); 623 assert.deepEqual(tasks['sign:arch=aarch64'].needs, ['build:arch=aarch64']); 624 }); 625 626 test('an output name may carry dots and dashes', () => { 627 const p = compilePipeline(` 628 version: 1 629 defaults: 630 image: x 631 tasks: 632 cook: 633 script: [make] 634 eat: 635 script: ['eat \${{ tasks.cook.outputs.pizza.calzone }} \${{ tasks.cook.outputs.side-dish }}'] 636 `); 637 assert.deepEqual(byName(p).eat.needs, ['cook']); 638 }); 639 640 test('outputs cannot be read where a worker is chosen', () => { 641 assert.throws( 642 () => compilePipeline(` 643 version: 1 644 defaults: 645 image: x 646 tasks: 647 build: 648 script: [make] 649 deploy: 650 script: [ship] 651 requires: ['\${{ tasks.build.outputs.feature }}'] 652 `), 653 /tasks\.deploy\.requires\[0\][\s\S]*not known until the task is dispatched/ 654 ); 655 }); 656 657 test('an output reference is validated like any other dependency', () => { 658 assert.throws( 659 () => compilePipeline(` 660 version: 1 661 tasks: 662 a: { image: x, script: ['\${{ tasks.ghost.outputs.v }}'] } 663 `), 664 /unknown task "ghost"/ 665 ); 666 assert.throws( 667 () => compilePipeline(` 668 version: 1 669 tasks: 670 a: { image: x, script: ['\${{ tasks.a.outputs.v }}'] } 671 `), 672 /cannot depend on itself/ 673 ); 674 }); 675 676 test('an output reference can close a cycle', () => { 677 assert.throws( 678 () => compilePipeline(` 679 version: 1 680 defaults: 681 image: x 682 tasks: 683 a: 684 script: ['\${{ tasks.b.outputs.v }}'] 685 b: 686 script: ['\${{ tasks.a.outputs.v }}'] 687 `), 688 /dependency cycle/ 689 ); 690 }); 691 692 test('an unknown expression names task outputs as an option', () => { 693 assert.throws( 694 () => compilePipeline('version: 1\ntasks:\n a: { image: x, script: ["${{ nonsense }}"] }\n'), 695 /expected arch, matrix\.<name> or tasks\.<task>\.outputs\.<name>/ 696 ); 697 }); 698 699 test('the pipeline cannot set visibility; it is a project setting', () => { 700 for (const value of ['public', 'private']) { 701 assert.deepEqual( 702 errorPaths(() => compilePipeline(`version: 1\nvisibility: ${value}\n${MINIMAL.slice(MINIMAL.indexOf('tasks:'))}`)), 703 ['visibility'] 704 ); 705 } 706 });