import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { createRunner } from '../src/runner.js'; import { WORKFLOW_PATH, WORKFLOWS_DIR } from '../src/workflow.js'; /** @type {string[]} */ const created = []; afterEach(async () => { await Promise.all( created.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), ); }); /** * A checkout that produces a tree holding the named files. A string is * shorthand for one workflow at `.pdsjs/ci.json`; null leaves the tree empty. * @param {Record|string|null} files */ function fakeCheckout(files) { const tree = typeof files === 'string' ? { [WORKFLOW_PATH]: files } : (files ?? {}); return async () => { const dir = await mkdtemp(join(tmpdir(), 'git-ci-test-')); created.push(dir); for (const [path, content] of Object.entries(tree)) { await mkdir(join(dir, dirname(path)), { recursive: true }); await writeFile(join(dir, path), content); } return dir; }; } function fakePublisher() { /** @type {Array>} */ const finished = []; let started = 0; return { finished, startCount: () => started, publisher: { async start() { started += 1; return { rkey: '3ktestrkey123', cid: 'cid-1' }; }, /** @param {Record} run */ async finish(run) { finished.push(run); return 'at://did:plc:runner/dev.pdsjs.git.check/3ktestrkey123'; }, }, }; } const REQUEST = { service: 'https://pds.example.com', did: 'did:plc:abc', repoName: 'proj', repoRkey: '3lprojkey', subject: { uri: 'at://did:plc:abc/dev.pdsjs.git.repo/3lprojkey', cid: 'bafyreiaaa', }, ref: 'refs/heads/main', sha: 'a'.repeat(40), }; const WORKFLOW = JSON.stringify({ steps: [{ name: 'test', run: 'true' }] }); describe('createRunner', () => { it('publishes the outcome of a run', async () => { const { publisher, finished, startCount } = fakePublisher(); const runCheck = createRunner({ executor: async () => [{ name: 'test', exitCode: 0, durationMs: 1 }], publisher, checkout: fakeCheckout(WORKFLOW), discardCheckout: async () => {}, }); expect(await runCheck(REQUEST)).toBe('success'); expect(startCount()).toBe(1); expect(finished[0].status).toBe('success'); expect(finished[0].sha).toBe(REQUEST.sha); expect(finished[0].workflow).toBe('ci'); }); it('reports a failing step as a failure', async () => { const { publisher, finished } = fakePublisher(); const runCheck = createRunner({ executor: async () => [{ name: 'test', exitCode: 1, durationMs: 1 }], publisher, checkout: fakeCheckout(WORKFLOW), discardCheckout: async () => {}, }); expect(await runCheck(REQUEST)).toBe('failure'); expect(finished[0].status).toBe('failure'); }); it('records an executor that threw as an error, with the message logged', async () => { const { publisher, finished } = fakePublisher(); const runCheck = createRunner({ executor: async () => { throw new Error('docker is not running'); }, publisher, checkout: fakeCheckout(WORKFLOW), discardCheckout: async () => {}, }); expect(await runCheck(REQUEST)).toBe('error'); expect(finished[0].status).toBe('error'); expect(finished[0].logs).toContain('docker is not running'); }); it('publishes nothing when the repository has no workflow', async () => { const { publisher, startCount } = fakePublisher(); const runCheck = createRunner({ executor: async () => [], publisher, checkout: fakeCheckout(null), discardCheckout: async () => {}, }); expect(await runCheck(REQUEST)).toBe('skipped'); expect(startCount()).toBe(0); }); it('publishes nothing when the workflow does not name this ref', async () => { const { publisher, startCount } = fakePublisher(); const runCheck = createRunner({ executor: async () => [], publisher, checkout: fakeCheckout( JSON.stringify({ steps: [{ run: 'true' }], refs: ['refs/tags/*'] }), ), discardCheckout: async () => {}, }); expect(await runCheck(REQUEST)).toBe('skipped'); expect(startCount()).toBe(0); }); it('skips the run when the checkout fails', async () => { const { publisher, startCount } = fakePublisher(); /** @type {string[]} */ const notices = []; const runCheck = createRunner({ executor: async () => [], publisher, checkout: async () => { throw new Error('repository not found'); }, discardCheckout: async () => {}, onNotice: (message) => notices.push(message), }); expect(await runCheck(REQUEST)).toBe('skipped'); expect(startCount()).toBe(0); expect(notices.join('\n')).toContain('repository not found'); }); it('discards the working tree after a run', async () => { const { publisher } = fakePublisher(); /** @type {string[]} */ const discarded = []; const runCheck = createRunner({ executor: async () => [{ name: 'test', exitCode: 0, durationMs: 1 }], publisher, checkout: fakeCheckout(WORKFLOW), discardCheckout: async (dir) => { discarded.push(dir); }, }); await runCheck(REQUEST); expect(discarded).toHaveLength(1); }); it('runs every workflow in the directory that names the ref', async () => { const { publisher, finished, startCount } = fakePublisher(); const runCheck = createRunner({ // The step command carries the outcome, so one executor serves // workflows that pass and workflows that fail. executor: async (request) => [ { name: request.steps[0].name, exitCode: request.steps[0].run === 'fail' ? 1 : 0, durationMs: 1, }, ], publisher, checkout: fakeCheckout({ [`${WORKFLOWS_DIR}/checks.json`]: JSON.stringify({ steps: [{ name: 'a', run: 'fail' }], }), [`${WORKFLOWS_DIR}/mirror.json`]: JSON.stringify({ steps: [{ name: 'b', run: 'pass' }], }), [`${WORKFLOWS_DIR}/release.json`]: JSON.stringify({ steps: [{ name: 'c', run: 'pass' }], refs: ['refs/tags/v*'], }), }), discardCheckout: async () => {}, }); // The worst outcome across the workflows that ran. expect(await runCheck(REQUEST)).toBe('failure'); expect(startCount()).toBe(2); expect(finished.map((run) => [run.workflow, run.status])).toEqual([ ['checks', 'failure'], ['mirror', 'success'], ]); }); it('ignores ci.json when the workflows directory exists', async () => { const { publisher, finished } = fakePublisher(); const runCheck = createRunner({ executor: async () => [{ name: 'test', exitCode: 0, durationMs: 1 }], publisher, checkout: fakeCheckout({ [WORKFLOW_PATH]: WORKFLOW, [`${WORKFLOWS_DIR}/only.json`]: JSON.stringify({ steps: [{ run: 'true' }], }), }), discardCheckout: async () => {}, }); expect(await runCheck(REQUEST)).toBe('success'); expect(finished.map((run) => run.workflow)).toEqual(['only']); }); it('reads yml, yaml and json from the workflows directory', async () => { const { publisher, finished } = fakePublisher(); const runCheck = createRunner({ executor: async () => [{ name: 'test', exitCode: 0, durationMs: 1 }], publisher, checkout: fakeCheckout({ [`${WORKFLOWS_DIR}/one.yml`]: 'steps:\n - run: "true"\n', [`${WORKFLOWS_DIR}/two.yaml`]: 'steps:\n - run: "true"\n', [`${WORKFLOWS_DIR}/three.json`]: JSON.stringify({ steps: [{ run: 'true' }], }), // Neither extension, so the reader passes over it. The workflows // directory is where a repository documents its own pipeline. [`${WORKFLOWS_DIR}/README.md`]: '# not a workflow', }), discardCheckout: async () => {}, }); expect(await runCheck(REQUEST)).toBe('success'); expect(finished.map((run) => run.workflow).sort()).toEqual([ 'one', 'three', 'two', ]); }); it('abandons the run when two workflows share a name', async () => { const { publisher, startCount } = fakePublisher(); /** @type {string[]} */ const notices = []; const runCheck = createRunner({ executor: async () => [], publisher, checkout: fakeCheckout({ [`${WORKFLOWS_DIR}/a.json`]: JSON.stringify({ name: 'ci', steps: [{ run: 'true' }], }), [`${WORKFLOWS_DIR}/b.json`]: JSON.stringify({ name: 'ci', steps: [{ run: 'true' }], }), }), discardCheckout: async () => {}, onNotice: (message) => notices.push(message), }); expect(await runCheck(REQUEST)).toBe('error'); expect(startCount()).toBe(0); expect(notices.join('\n')).toContain('named "ci"'); }); it('injects the secrets the workflow declares', async () => { const { publisher } = fakePublisher(); /** @type {Record} */ let env = {}; const runCheck = createRunner({ executor: async (request) => { env = request.env; return [{ name: 'test', exitCode: 0, durationMs: 1 }]; }, publisher, secrets: (did, repoRkey, names) => { expect(did).toBe(REQUEST.did); expect(repoRkey).toBe(REQUEST.repoRkey); expect(names).toEqual(['NPM_TOKEN']); return { NPM_TOKEN: 'npm-abc' }; }, checkout: fakeCheckout( JSON.stringify({ steps: [{ name: 'test', run: 'true' }], secrets: ['NPM_TOKEN'], }), ), discardCheckout: async () => {}, }); expect(await runCheck(REQUEST)).toBe('success'); expect(env.NPM_TOKEN).toBe('npm-abc'); }); it('exposes the commit under test to the steps', async () => { const { publisher } = fakePublisher(); /** @type {Record} */ let env = {}; const runCheck = createRunner({ executor: async (request) => { env = request.env; return [{ name: 'test', exitCode: 0, durationMs: 1 }]; }, publisher, checkout: fakeCheckout(WORKFLOW), discardCheckout: async () => {}, }); await runCheck(REQUEST); expect(env.PDSJS_CI).toBe('true'); expect(env.PDSJS_CI_SHA).toBe(REQUEST.sha); expect(env.PDSJS_CI_REF).toBe('refs/heads/main'); expect(env.PDSJS_CI_REPO).toBe('did:plc:abc/proj'); }); });