Install spans a container start, a health wait, an upstream API call and a process start. Any can fail, and one of them — a token only a human can mint — is EXPECTED to stop the run. A straight-line function has two bad options there: unwind everything, or leave a half-installed service that neither works nor uninstalls, which is the state users cannot get out of. So each step is named, completion is persisted, and running install again resumes. planSteps is a pure function of (entry, mode) and the effects are injected, which makes ordering, resume, blocking and failure testable with no Docker, Postgres, PM2 or Immich in sight. 15 tests cover exactly the behaviour that only appears when something goes wrong. Two rules are enforced by the plan rather than remembered at call sites: 'existing' never provisions, so pointing at an instance the user already runs cannot start a container; and the members step is omitted entirely for a service with no user concept, so a Transmission install does not report a step that did nothing — which reads as a silent failure to anyone debugging a member's access. `blocked` is a first-class outcome, not an error. For Immich the container is up and healthy and only its own UI can mint a key; calling that a failure would make a normal install look broken and invite the user to tear down a working container. The blocking step is deliberately NOT recorded as complete, so a resume re-runs the step the human just answered. Results feed forward — provision discovers the URL that connect writes down two steps later — over a copy of the caller's values, so a failure halfway cannot rewrite what an earlier attempt achieved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
211 lines
8.4 KiB
TypeScript
211 lines
8.4 KiB
TypeScript
import { describe, expect, it } from 'bun:test';
|
||
import { planSteps, runInstall, type InstallEffects, type StepName } from './installer';
|
||
import { byId } from './catalogue';
|
||
|
||
// The whole install machine, tested without Docker, Postgres, PM2 or an Immich to talk to — which is
|
||
// the reason the effects are injected. What is being pinned here is the behaviour that only shows up
|
||
// when something goes wrong: resume, blocking, and not doing work twice.
|
||
|
||
const photos = byId('photos')!; // existing|provisioned, members: 'accounts'
|
||
const transmission = byId('transmission')!; // existing|provisioned, members: 'none'
|
||
const email = byId('email')!; // config only
|
||
|
||
/** Records what was actually called, so a test can assert on absence as well as presence. */
|
||
function spyEffects(over: Partial<InstallEffects> = {}) {
|
||
const calls: string[] = [];
|
||
const effects: InstallEffects = {
|
||
preflight: async () => {
|
||
calls.push('preflight');
|
||
return { ok: true };
|
||
},
|
||
provision: async () => {
|
||
calls.push('provision');
|
||
return { results: { url: 'http://127.0.0.1:18091' }, composeDir: '/root/dockers/x' };
|
||
},
|
||
connect: async () => {
|
||
calls.push('connect');
|
||
return { status: 'done' };
|
||
},
|
||
applySchema: async () => void calls.push('schema'),
|
||
startProcess: async () => void calls.push('process'),
|
||
provisionMembers: async () => void calls.push('members'),
|
||
...over,
|
||
};
|
||
return { effects, calls };
|
||
}
|
||
|
||
describe('planSteps', () => {
|
||
it('never provisions when pointing at an instance the user already runs', () => {
|
||
// The guarantee that matters: choosing 'existing' cannot start a container. Enforced here rather
|
||
// than remembered at each call site.
|
||
expect(planSteps(photos, 'existing')).not.toContain('provision');
|
||
expect(planSteps(photos, 'provisioned')).toContain('provision');
|
||
});
|
||
|
||
it('omits the members step for a service with no user concept', () => {
|
||
// Otherwise a Transmission install reports a members step that did nothing, which reads as a
|
||
// silent failure to anyone debugging why a member has no access.
|
||
expect(planSteps(transmission, 'provisioned')).not.toContain('members');
|
||
expect(planSteps(photos, 'provisioned')).toContain('members');
|
||
});
|
||
|
||
it('has nothing to connect for a config-only install', () => {
|
||
expect(planSteps(email, 'config')).toEqual(['preflight', 'schema', 'process']);
|
||
});
|
||
|
||
it('always starts with preflight', () => {
|
||
// Checking the host before anything is written is the whole reason a half-install is avoidable.
|
||
for (const mode of ['existing', 'provisioned', 'config'] as const) {
|
||
expect(planSteps(photos, mode)[0]).toBe('preflight');
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('a clean run', () => {
|
||
it('executes the plan in order and reports installed', async () => {
|
||
const { effects, calls } = spyEffects();
|
||
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
|
||
|
||
expect(out.status).toBe('installed');
|
||
expect(calls).toEqual(['preflight', 'provision', 'connect', 'schema', 'process', 'members']);
|
||
});
|
||
|
||
it('feeds one step’s results forward to the next', async () => {
|
||
// `provision` discovers the URL that `connect` writes down two steps later. Without this the
|
||
// installer would have to ask the user for something it already knows.
|
||
let seen: Record<string, string> = {};
|
||
const { effects } = spyEffects({
|
||
connect: async (ctx) => {
|
||
seen = { ...ctx.values };
|
||
return { status: 'done' };
|
||
},
|
||
});
|
||
|
||
await runInstall({ entry: photos, mode: 'provisioned', values: { given: 'yes' }, effects });
|
||
|
||
expect(seen.url).toBe('http://127.0.0.1:18091');
|
||
expect(seen.composeDir).toBe('/root/dockers/x');
|
||
expect(seen.given).toBe('yes');
|
||
});
|
||
|
||
it('does not mutate the caller’s values', async () => {
|
||
const values = { given: 'yes' };
|
||
const { effects } = spyEffects();
|
||
await runInstall({ entry: photos, mode: 'provisioned', values, effects });
|
||
expect(values).toEqual({ given: 'yes' });
|
||
});
|
||
});
|
||
|
||
describe('resuming', () => {
|
||
it('skips what an earlier attempt already did', async () => {
|
||
// The point of persisting completedSteps: a resume must not provision a second container.
|
||
const { effects, calls } = spyEffects();
|
||
const done: StepName[] = ['preflight', 'provision', 'connect'];
|
||
|
||
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, completed: done, effects });
|
||
|
||
expect(out.status).toBe('installed');
|
||
expect(calls).toEqual(['schema', 'process', 'members']);
|
||
expect(calls).not.toContain('provision');
|
||
});
|
||
|
||
it('reports every completed step, including the ones it skipped', async () => {
|
||
const { effects } = spyEffects();
|
||
const out = await runInstall({
|
||
entry: photos,
|
||
mode: 'provisioned',
|
||
values: {},
|
||
completed: ['preflight'],
|
||
effects,
|
||
});
|
||
expect(out.completed).toEqual(['preflight', 'provision', 'connect', 'schema', 'process', 'members']);
|
||
});
|
||
});
|
||
|
||
describe('blocking on a human', () => {
|
||
it('stops without failing, and does NOT mark the blocking step done', async () => {
|
||
// Immich: the container is up and healthy, and only its own UI can mint an API key. Recording
|
||
// `connect` as complete would mean a resume skipped the very step that is waiting.
|
||
const { effects, calls } = spyEffects({
|
||
connect: async () => ({ status: 'blocked', reason: 'Needs an API key', completeAt: 'http://x/keys' }),
|
||
});
|
||
|
||
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
|
||
|
||
expect(out.status).toBe('blocked');
|
||
if (out.status !== 'blocked') throw new Error('unreachable');
|
||
expect(out.at).toBe('connect');
|
||
expect(out.completeAt).toBe('http://x/keys');
|
||
expect(out.completed).toEqual(['preflight', 'provision']);
|
||
// Everything after the block is untouched — no process started against a service we cannot reach.
|
||
expect(calls).not.toContain('process');
|
||
});
|
||
|
||
it('re-runs the blocking step on resume, once the human has answered', async () => {
|
||
const { effects, calls } = spyEffects();
|
||
const out = await runInstall({
|
||
entry: photos,
|
||
mode: 'provisioned',
|
||
values: { secret: 'now-provided' },
|
||
completed: ['preflight', 'provision'],
|
||
effects,
|
||
});
|
||
|
||
expect(out.status).toBe('installed');
|
||
expect(calls).toContain('connect');
|
||
});
|
||
});
|
||
|
||
describe('failing', () => {
|
||
it('stops at the failing step and keeps what came before', async () => {
|
||
const { effects, calls } = spyEffects({
|
||
applySchema: async () => {
|
||
throw new Error('relation already exists');
|
||
},
|
||
});
|
||
|
||
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
|
||
|
||
expect(out.status).toBe('failed');
|
||
if (out.status !== 'failed') throw new Error('unreachable');
|
||
expect(out.at).toBe('schema');
|
||
expect(out.error).toBe('relation already exists');
|
||
// Resumable: the three that worked are recorded, so a retry does not redo them.
|
||
expect(out.completed).toEqual(['preflight', 'provision', 'connect']);
|
||
expect(calls).not.toContain('process');
|
||
});
|
||
|
||
it('treats a failed preflight as a failure before anything is written', async () => {
|
||
const { effects, calls } = spyEffects({
|
||
preflight: async () => {
|
||
calls.push('preflight');
|
||
return { ok: false, reason: 'Docker is not installed.', remedy: 'Install it.' };
|
||
},
|
||
});
|
||
|
||
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
|
||
|
||
expect(out.status).toBe('failed');
|
||
if (out.status !== 'failed') throw new Error('unreachable');
|
||
expect(out.at).toBe('preflight');
|
||
// The remedy travels with the reason: "Docker is not installed" without "install it" is a dead end.
|
||
expect(out.error).toContain('Install it.');
|
||
// Nothing provisioned, so there is nothing to unwind — the entire reason preflight goes first.
|
||
expect(calls).toEqual(['preflight']);
|
||
});
|
||
|
||
it('turns a thrown effect into a recorded failure rather than an escape', async () => {
|
||
// An effect that throws is a bug in that effect. If it escaped, the row would be stranded in
|
||
// `installing` with nothing to resume from.
|
||
const { effects } = spyEffects({
|
||
startProcess: async () => {
|
||
throw new Error('pm2 not found');
|
||
},
|
||
});
|
||
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
|
||
expect(out.status).toBe('failed');
|
||
if (out.status !== 'failed') throw new Error('unreachable');
|
||
expect(out.at).toBe('process');
|
||
});
|
||
});
|