Files
platform/src/servers/app-store/installer.test.ts
T
pastilhasandClaude Opus 5 4a03b9f84e app store: the install step machine, resumable and testable without docker
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>
2026-08-10 13:44:30 +00:00

211 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 steps 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 callers 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');
});
});