~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
338 lines · 10.5 KB Raw History
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');
  });
});