Files
platform/src/servers/plugins/update.test.ts
T
pastilhasandClaude Opus 5 16dd67a3ca plugins: enabling pulls first, so a plugin off for a week comes back current
A disabled plugin keeps its directory, and the directory is what the next start runs. The longer it
stays off the more certainly it is stale, and enabling would quietly run whatever was current on the
day it was switched off — with every step reporting success, which is exactly how the jellyfin
install on alpha re-applied a two-day-old checkout.

Deleting no longer has that problem, since it takes the directory with it and installing is always a
fresh clone. That left disable/enable as the one remaining way to run old code without being told.

The pull is best-effort and the enable is not. `updatePlugin` returns ok:false when a pull is
refused, and it is refused for reasons that say nothing about whether the plugin can run: a dirty
working tree — every plugin directory on this machine is one — an unreachable forge, or a directory
copied in rather than cloned. Enable is the reversible half of a pair with disable, and a switch that
will not turn back on because a network is down is worse than one running last week's code. So the
refresh reports which of those happened and the enable proceeds regardless; a re-apply that fails
falls back to the code already on disk, which was running before it was disabled.

Tested by the refusal path rather than the happy one: a directory that is not a checkout comes back
having reported "not refreshed", and the error that surfaces is about not being installed rather than
about git — which is only possible if the refusal was stepped past instead of returned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:34:38 +00:00

80 lines
3.6 KiB
TypeScript

import { describe, expect, test, afterAll } from 'bun:test';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { PLUGINS_DIR } from './discover';
import { pullPlugin, enablePlugin } from './update';
// The REFUSALS are what this test is for. A pull that works is easy to believe; a pull that quietly
// discarded somebody's uncommitted work is the failure that matters, and every plugin directory on a
// developer's machine is a live checkout — this repository's own plugins are edited in place.
const tmp = (name: string) => join(PLUGINS_DIR, name);
const made: string[] = [];
const scratch = (name: string) => {
const dir = tmp(name);
mkdirSync(dir, { recursive: true });
made.push(dir);
return dir;
};
afterAll(() => made.forEach((d) => rmSync(d, { recursive: true, force: true })));
describe('pullPlugin refuses rather than guesses', () => {
test('a plugin that is not on this machine', async () => {
const r = await pullPlugin('definitely-not-installed-xyz');
expect(r.ok).toBe(false);
expect(r.error).toContain('not on this machine');
});
test('a directory that is not a git checkout — copied in by hand, or bundled', async () => {
scratch('test-update-nogit');
const r = await pullPlugin('test-update-nogit');
expect(r.ok).toBe(false);
expect(r.error).toContain('not a git checkout');
// Not phrased as a git error: there is nothing wrong here, there is simply nothing to pull.
expect(r.updated).toBe(false);
});
test('a checkout with uncommitted work — the one that would destroy something', async () => {
const dir = scratch('test-update-dirty');
Bun.spawnSync(['git', 'init', '-q'], { cwd: dir });
writeFileSync(join(dir, 'manifest.ts'), 'export const manifest = {};\n');
const r = await pullPlugin('test-update-dirty');
expect(r.ok).toBe(false);
expect(r.error).toContain('uncommitted');
expect(r.updated).toBe(false);
});
test('a refusal never reports having updated anything', async () => {
for (const name of ['definitely-not-installed-xyz', 'test-update-nogit', 'test-update-dirty']) {
const r = await pullPlugin(name);
expect(r.ok).toBe(false);
expect(r.updated).toBe(false);
}
});
});
// ── Enabling must survive a refusal to pull ──
//
// `enablePlugin` refreshes the checkout before turning a plugin on, so that a plugin which sat disabled
// for a week does not come back running the code it was switched off with. The risk in that is obvious:
// the pull is refused for reasons that say nothing about whether the plugin can run — a dirty tree, an
// unreachable forge, a directory that was copied in rather than cloned — and enabling is meant to be the
// reversible half of a pair with disable.
//
// So this asserts the pull refusal is REPORTED and then stepped past. The enable itself still fails here,
// because a scratch directory has no install row — and that is the point: the failure that comes back is
// about being installed, not about git, which is only possible if the refusal did not short-circuit.
describe('enablePlugin steps past a pull it cannot do', () => {
test('a directory that is not a git checkout still reaches the enable', async () => {
// Named without the word "git" in it, so the last assertion below is about the error and not about
// the fixture's own name.
scratch('test-enable-plain');
const r = await enablePlugin('test-enable-plain');
expect(r.steps.some((s) => s.includes('not refreshed') && s.includes('not a git checkout'))).toBe(true);
expect(r.error).toContain('not installed');
expect(r.error).not.toContain('git');
});
});