conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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 });