Files
platform/src/servers/app-store/run-script.test.ts
T
pastilhasandClaude Opus 5 ba71dc1957 app store: make it work — pm2, install state, and the routes
Email installs end to end now, which was the point of picking it as tier one: no container, no external
wiring, so the machinery is exercised without the provisioning half.

Verified against the running system, not asserted:

  POST /api/app-store/email/install  -> {"status":"installed","completed":["preflight","schema","process"]}
  row                                -> email mode=config status=installed enabled=true
  pm2                                -> officer-email online
  second install                     -> all three steps skipped, process not restarted
  disable                            -> stopped

The server boots with the new router, which is the real test of the capability entry: totality.ts throws
before serve() if a mounted router has none, so booting IS the check passing.

pm2.ts shells out rather than importing pm2 as a library. PM2 is already the supervisor and the
ecosystem file is already the definition of how each process runs; a second thing in charge of that
means two supervisors disagreeing. It also means an owner can undo anything the app store did with a
command they already know. The one fact that matters: `pm2 start <name>` fails for a process PM2 has
never seen, so a first install starts from the ecosystem file with --only, and everything after goes by
name. Callers cannot know which case they are in, so startProcess decides.

Disable stops rather than deletes: a stopped process still shows in `pm2 list`, which is the honest
picture. Deleting would make a disabled sidecar indistinguishable from one never installed.

beginInstall returns the existing row instead of replacing it — that is what makes a retry a resume
rather than a re-provision — and clears lastError on the way in, so a UI never shows a stale failure
beside a working service.

The container half of enable/disable/uninstall is deliberately absent rather than stubbed silently: a
disable that leaves Immich running is a different thing from one that stops it, and the difference is
memory on the user's machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:55:11 +00:00

58 lines
2.5 KiB
TypeScript

import { describe, expect, it } from 'bun:test';
import { parseResults } from './run-script';
// The line protocol between a setup script and the installer. Pure, so it is tested without spawning
// anything — and worth testing precisely, because a script's output is interleaved with `docker
// compose`'s and a loose parser would pick up things that merely look like results.
describe('parseResults', () => {
it('reads the values a script hands back', () => {
const out = parseResults(
[
'==> Starting',
'OFFICER_RESULT_URL=http://127.0.0.1:18091',
'OFFICER_RESULT_PATH=/transmission/rpc',
'==> Done',
].join('\n'),
);
expect(out).toEqual({ url: 'http://127.0.0.1:18091', path: '/transmission/rpc' });
});
it('ignores everything that is not a result line', () => {
// The same stream is the user's live log. Narration must never be mistaken for a value.
const out = parseResults(
['Container officer-transmission Started', 'note: OFFICER_RESULT_URL is printed at the end', ''].join('\n'),
);
// The middle line MENTIONS the prefix but does not start with it, which is the realistic near-miss.
expect(out).toEqual({});
});
it('keeps an empty value, because blank is a real answer', () => {
// Transmission with no RPC auth returns exactly this, and it means "no username", which is
// different from the key being absent.
expect(parseResults('OFFICER_RESULT_USERNAME=')).toEqual({ username: '' });
});
it('keeps everything after the first `=`', () => {
// Tokens and URLs contain `=`; splitting on every one would silently truncate a credential.
expect(parseResults('OFFICER_RESULT_SECRET=abc=def==')).toEqual({ secret: 'abc=def==' });
});
it('lets a later line win', () => {
// A script that reports twice has changed its mind — a retry inside it that finally worked.
expect(parseResults(['OFFICER_RESULT_URL=http://first', 'OFFICER_RESULT_URL=http://second'].join('\n'))).toEqual({
url: 'http://second',
});
});
it('skips a prefix with no assignment rather than storing a blank key', () => {
// A script bug. Storing `{'': ''}` would look like a deliberate empty value downstream.
expect(parseResults('OFFICER_RESULT_')).toEqual({});
expect(parseResults('OFFICER_RESULT_=novalue')).toEqual({});
});
it('tolerates indentation, since stderr lines arrive prefixed', () => {
expect(parseResults(' OFFICER_RESULT_URL=http://x ')).toEqual({ url: 'http://x' });
});
});