chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
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, string>|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<Record<string, *>>} */
const finished = [];
let started = 0;
return {
finished,
startCount: () => started,
publisher: {
async start() {
started += 1;
return { rkey: '3ktestrkey123', cid: 'cid-1' };
},
/** @param {Record<string, *>} 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<string, string>} */
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<string, string>} */
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');
});
});