Files
Webklar-Kundenbereich/runner/test/dev.test.js
T

347 lines
16 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createDev } from '../src/dev.js';
const MIN = 60e3;
const GB = 1024 ** 3;
function setup({ mem = 10 * GB, connect, detected, prep, rmImpl, alive } = {}) {
const config = { workRoot: '/w/work', cacheRoot: '/w/cache', baseHost: 'project.webklar.com', minFreeBytes: 1.5 * GB, devIdleMs: 20 * MIN, devMax: 3, devReadyMs: 180e3, giteaToken: 'tok_GEHEIM' };
const clock = { t: 1_000_000 };
const rmDirs = []; const runs = []; const removed = []; const held = new Map(); const prepared = [];
let psResult = [];
const docker = {
run: async (args, opts) => { if (args[0] === 'run') runs.push({ args, opts }); else opts?.onLine?.('container-log-zeile', 'out'); return { code: 0 }; },
rm: async (n) => { removed.push(n); if (rmImpl) await rmImpl(n); return { code: 0, lines: [] }; },
ps: async () => psResult,
...(alive ? { running: async (n) => alive(n) } : {}),
};
const jobs = {
prepareWorkspace: async (job, o) => { prepared.push({ job, o }); if (prep) return prep(job, o); o.onLine?.('npm install ok'); return { workDir: o.workDir, detected: detected ?? { pm: 'npm', devCmd: ['npm', 'run', 'dev', '--', '--host', '0.0.0.0', '--port', '5173'], devPort: 5173 } }; },
retainCache: (repo) => { held.set(repo, (held.get(repo) || 0) + 1); let d = false; return () => { if (!d) { d = true; held.set(repo, held.get(repo) - 1); } }; },
};
const connects = [];
const dev = createDev({
config, docker, jobs, mem: () => (typeof mem === 'function' ? mem() : mem), now: () => clock.t,
connect: connect ?? (async (h, p) => { connects.push([h, p]); return true; }),
setTimer: (fn, ms) => { clock.t += ms; queueMicrotask(fn); return 0; },
prepareDirs: async () => {},
rmDir: async (p) => { rmDirs.push(p); },
});
return { rmDirs, dev, clock, runs, removed, held, prepared, connects, config, setPs: (v) => { psResult = v; } };
}
const job = (sub, o = {}) => ({ sub, repo: `own/${sub}`, sha: 'abcdef1', branch: 'main', mode: 'dev', env: { API_KEY: 'geheim-wert' }, ...o });
test('start: starting sofort, dann online mit Docker-DNS-Ziel; Container mit Fixed-Env per env-Datei', async () => {
const s = setup();
assert.deepEqual(await s.dev.ensure('a', job('a')), { state: 'starting' });
await s.dev.idle();
assert.deepEqual(s.connects[0], ['preview-dev-a', 5173]);
assert.equal(s.runs.length, 1);
const { args, opts } = s.runs[0];
assert.ok(args.includes('preview-dev-a') && args.includes('512m') && args.includes('-d'));
assert.ok(args.includes('/w/work/dev/a:/work'));
assert.equal(opts.env.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS, '.project.webklar.com');
assert.equal(opts.env.API_KEY, 'geheim-wert');
assert.ok(!args.join(' ').includes('geheim-wert'));
assert.deepEqual(await s.dev.ensure('a', job('a')), { state: 'online', target: 'http://preview-dev-a:5173' });
assert.equal(s.runs.length, 1);
});
test('gleicher sub zweimal während Start ⇒ ein Container', async () => {
const s = setup();
const [r1, r2] = await Promise.all([s.dev.ensure('a', job('a')), s.dev.ensure('a', job('a'))]);
assert.equal(r1.state, 'starting'); assert.equal(r2.state, 'starting');
await s.dev.idle();
assert.equal(s.runs.length, 1); assert.equal(s.prepared.length, 1);
});
test('pnpm läuft über corepack, bun nutzt bun-Image', async () => {
const s = setup({ detected: { pm: 'pnpm', devCmd: ['pnpm', 'run', 'dev'], devPort: 4000 } });
await s.dev.ensure('a', job('a')); await s.dev.idle();
const a = s.runs[0].args;
assert.deepEqual(a.slice(-4), ['corepack', 'pnpm', 'run', 'dev']);
assert.deepEqual(s.connects[0], ['preview-dev-a', 4000]);
const b = setup({ detected: { pm: 'bun', devCmd: ['bun', 'run', 'dev'], devPort: 5173 } });
await b.dev.ensure('a', job('a')); await b.dev.idle();
assert.ok(b.runs[0].args.includes('oven/bun:1'));
});
test('4. Start verdrängt den am längsten ungenutzten', async () => {
const s = setup();
for (const n of ['a', 'b', 'c']) { await s.dev.ensure(n, job(n)); await s.dev.idle(); s.clock.t += MIN; }
s.dev.touch('a'); // b ist jetzt der Älteste
s.clock.t += MIN;
await s.dev.ensure('d', job('d')); await s.dev.idle();
assert.ok(s.removed.includes('preview-dev-b'));
assert.ok(!s.removed.includes('preview-dev-a'));
assert.equal(s.held.get('own/b'), 0);
const states = Object.fromEntries(s.dev.list().map((e) => [e.sub, e.state]));
assert.equal(states.d, 'online'); assert.equal(states.b, 'sleeping'); assert.equal(states.a, 'online');
assert.equal(s.dev.list().filter((e) => e.state === 'online').length, 3);
});
test('sweep: nach 21 min gestoppt, nach 19 min nicht', async () => {
const s = setup();
await s.dev.ensure('a', job('a')); await s.dev.idle();
s.clock.t += 19 * MIN; await s.dev.sweep();
assert.equal(s.dev.status('a').state, 'online'); assert.equal(s.removed.length, 0);
s.clock.t += 2 * MIN; await s.dev.sweep();
assert.equal(s.dev.status('a').state, 'sleeping'); assert.deepEqual(s.removed, ['preview-dev-a']);
assert.equal(s.held.get('own/a'), 0);
});
test('touch verlängert die Leerlaufzeit', async () => {
const s = setup();
await s.dev.ensure('a', job('a')); await s.dev.idle();
s.clock.t += 19 * MIN; s.dev.touch('a'); s.clock.t += 19 * MIN; await s.dev.sweep();
assert.equal(s.dev.status('a').state, 'online');
});
test('wenig Speicher ⇒ Fehler Server ausgelastet (503), nichts gestartet', async () => {
const s = setup({ mem: 1 * GB });
await assert.rejects(() => s.dev.ensure('a', job('a')), (e) => e.message === 'Server ausgelastet' && e.status === 503);
assert.equal(s.prepared.length, 0); assert.equal(s.runs.length, 0);
});
test('Speicherwächter gilt nicht für bereits laufenden sub', async () => {
let m = 10 * GB;
const s = setup({ mem: () => m });
await s.dev.ensure('a', job('a')); await s.dev.idle();
m = 1 * GB;
assert.equal((await s.dev.ensure('a', job('a'))).state, 'online');
});
test('neuer sha ⇒ Neustart mit neuem Code', async () => {
const s = setup();
await s.dev.ensure('a', job('a')); await s.dev.idle();
assert.deepEqual(await s.dev.ensure('a', job('a', { sha: '1234567' })), { state: 'starting' });
await s.dev.idle();
assert.equal(s.runs.length, 2); assert.equal(s.prepared.length, 2);
assert.ok(s.removed.includes('preview-dev-a'));
assert.equal(s.dev.status('a').sha, '1234567'); assert.equal(s.dev.status('a').state, 'online');
assert.equal(s.held.get('own/a'), 1);
});
test('Bereitschafts-Zeitlimit ⇒ failed, Container entfernt, Log vorhanden, Cache frei', async () => {
const s = setup({ connect: async () => { throw new Error('ECONNREFUSED'); } });
await s.dev.ensure('a', job('a')); await s.dev.idle();
const st = s.dev.status('a');
assert.equal(st.state, 'failed');
assert.ok(s.removed.includes('preview-dev-a'));
assert.ok(st.log.some((l) => l.includes('180')));
assert.ok(st.log.includes('container-log-zeile'));
assert.equal(s.held.get('own/a'), 0);
});
test('ohne dev-Skript ⇒ failed „Kein dev-Skript“', async () => {
const s = setup({ detected: { pm: 'npm', devCmd: null, devPort: 5173 } });
await s.dev.ensure('a', job('a')); await s.dev.idle();
assert.equal(s.dev.status('a').state, 'failed');
assert.ok(s.dev.status('a').log.some((l) => l.includes('Kein dev-Skript')));
assert.equal(s.runs.length, 0); assert.equal(s.held.get('own/a'), 0);
});
test('Vorbereitungsfehler ⇒ failed, Cache frei', async () => {
const s = setup({ prep: async () => { throw new Error('Installation fehlgeschlagen (Exit-Code 1)'); } });
await s.dev.ensure('a', job('a')); await s.dev.idle();
assert.equal(s.dev.status('a').state, 'failed');
assert.equal(s.held.get('own/a'), 0);
});
test('Cache wird gehalten solange der Container läuft, stop gibt frei', async () => {
const s = setup();
await s.dev.ensure('a', job('a')); await s.dev.idle();
assert.equal(s.held.get('own/a'), 1);
await s.dev.stop('a');
assert.equal(s.held.get('own/a'), 0);
assert.deepEqual(s.removed.filter((n) => n === 'preview-dev-a').length >= 1, true);
assert.equal(s.dev.status('a').state, 'off');
assert.deepEqual(s.dev.list(), []);
});
test('stop während des Starts: nie online, kein Container', async () => {
const s = setup();
await s.dev.ensure('a', job('a'));
await s.dev.stop('a'); await s.dev.idle();
assert.equal(s.dev.status('a').state, 'off');
assert.equal(s.held.get('own/a'), 0);
assert.equal(s.runs.length, 0); // prepare läuft hier evtl. schon (nicht abbrechbar), der Container nie
});
test('Protokoll maskiert Variablenwerte und ist auf 300 Zeilen begrenzt', async () => {
const s = setup({ prep: async (j, o) => { for (let i = 0; i < 400; i++) o.onLine(`zeile ${i} geheim-wert`); return { workDir: o.workDir, detected: { pm: 'npm', devCmd: ['npm', 'run', 'dev'], devPort: 5173 } }; } });
await s.dev.ensure('a', job('a')); await s.dev.idle();
const log = s.dev.status('a').log;
assert.equal(log.length, 300);
assert.ok(log.some((l) => l.includes('zeile 399 •••')));
assert.ok(!log.some((l) => l.includes('geheim-wert')));
});
test('Start: verwaiste preview-dev-*-Container werden entfernt', async () => {
const s = setup();
s.setPs(['preview-dev-x', 'preview-dev-y', 'preview-build-zzz', 'anderer']);
await s.dev.cleanupOrphans();
assert.deepEqual(s.removed, ['preview-dev-x', 'preview-dev-y']);
});
const tick = () => new Promise((r) => setImmediate(r));
test('stop während Verdrängungs-rm: kein Cache-Leck, kein prepare, Arbeitsordner entfernt', async () => {
let gate; const opened = new Promise((r) => { gate = r; });
let slow = false;
const s = setup({ rmImpl: async () => { if (slow) await opened; } });
for (const n of ['a', 'b', 'c']) { await s.dev.ensure(n, job(n)); await s.dev.idle(); s.clock.t += MIN; }
slow = true;
await s.dev.ensure('d', job('d')); // verdrängt a, wartet auf dessen rm
await tick();
const stopping = s.dev.stop('d');
gate(); await stopping; await s.dev.idle();
assert.equal(s.held.get('own/d'), 0);
assert.ok(!s.prepared.some((p) => p.job.sub === 'd'));
assert.equal(s.dev.status('d').state, 'off');
assert.ok(s.rmDirs.includes('/w/work/dev/d'));
});
test('sha-Wechsel während des Starts: genau ein Container des neuen sha, alter entfernt neuen nie', async () => {
let release; const gate = new Promise((r) => { release = r; });
let first = true;
const s = setup({ prep: async (j, o) => { if (first) { first = false; await gate; } return { workDir: o.workDir, detected: { pm: 'npm', devCmd: ['npm', 'run', 'dev'], devPort: 5173 } }; } });
await s.dev.ensure('a', job('a'));
await s.dev.ensure('a', job('a', { sha: '1234567' }));
release(); await s.dev.idle();
assert.equal(s.runs.length, 1);
assert.equal(s.removed.length, 0); // alter Eintrag hat nie einen Container angelegt
assert.equal(s.dev.status('a').sha, '1234567'); assert.equal(s.dev.status('a').state, 'online');
assert.equal(s.held.get('own/a'), 1);
});
test('gleichzeitig sweep + ensure(neuer sha): ein Container, neuer bleibt online', async () => {
const s = setup();
await s.dev.ensure('a', job('a')); await s.dev.idle();
s.clock.t += 21 * MIN;
const p = Promise.all([s.dev.sweep(), s.dev.ensure('a', job('a', { sha: '1234567' }))]);
await p; await s.dev.idle();
assert.equal(s.runs.length, 2);
assert.equal(s.dev.status('a').state, 'online'); assert.equal(s.dev.status('a').sha, '1234567');
assert.equal(s.held.get('own/a'), 1);
// das letzte rm gehört dem alten Container und liegt vor dem zweiten run
assert.ok(s.removed.every((n) => n === 'preview-dev-a'));
});
test('failed-Eintrag mit gleichem sha: Neuversuch frühestens nach 60 s', async () => {
let ok = false;
const s = setup({ connect: async () => { if (!ok) throw new Error('nein'); return true; } });
await s.dev.ensure('a', job('a')); await s.dev.idle();
assert.equal(s.dev.status('a').state, 'failed');
const prepared = s.prepared.length;
assert.deepEqual(await s.dev.ensure('a', job('a')), { state: 'failed' });
assert.equal(s.prepared.length, prepared);
s.clock.t += 61e3; ok = true;
assert.deepEqual(await s.dev.ensure('a', job('a')), { state: 'starting' });
await s.dev.idle();
assert.equal(s.dev.status('a').state, 'online');
});
test('cleanupOrphans: ein fehlschlagendes rm stoppt die übrigen nicht', async () => {
const s = setup({ rmImpl: async (n) => { if (n === 'preview-dev-x') throw new Error('kaputt'); } });
s.setPs(['preview-dev-x', 'preview-dev-y']);
const orig = console.error; console.error = () => {};
try { await s.dev.cleanupOrphans(); } finally { console.error = orig; }
assert.deepEqual(s.removed, ['preview-dev-x', 'preview-dev-y']);
});
test('register: speichert Spezifikation ohne Start (sleeping); wake startet damit', async () => {
const s = setup();
assert.deepEqual(await s.dev.register('a', job('a')), { state: 'sleeping' });
assert.equal(s.runs.length, 0); assert.equal(s.prepared.length, 0);
assert.deepEqual(await s.dev.wake('a'), { state: 'starting' });
await s.dev.idle();
assert.equal(s.runs.length, 1);
assert.deepEqual(await s.dev.wake('a'), { state: 'online', target: 'http://preview-dev-a:5173' });
});
test('wake: unbekannter sub ⇒ unknown, nach stop ebenfalls', async () => {
const s = setup();
assert.deepEqual(await s.dev.wake('x'), { state: 'unknown' });
await s.dev.register('a', job('a'));
await s.dev.stop('a');
assert.deepEqual(await s.dev.wake('a'), { state: 'unknown' });
});
test('register: läuft mit gleichem sha ⇒ unverändert; mit anderem sha ⇒ Neustart; wake nutzt neue Spezifikation', async () => {
const s = setup();
await s.dev.ensure('a', job('a')); await s.dev.idle();
assert.deepEqual(await s.dev.register('a', job('a')), { state: 'online' });
assert.equal(s.runs.length, 1);
assert.deepEqual(await s.dev.register('a', job('a', { sha: '1234567' })), { state: 'starting' });
await s.dev.idle();
assert.equal(s.runs.length, 2);
assert.equal(s.prepared[1].job.sha, '1234567');
assert.ok(s.removed.includes('preview-dev-a'));
});
test('register: schlafender sub mit neuem sha startet nicht, wake nutzt das neue sha', async () => {
const s = setup();
await s.dev.ensure('a', job('a')); await s.dev.idle();
s.clock.t += 21 * MIN; await s.dev.sweep();
assert.equal(s.dev.status('a').state, 'sleeping');
assert.deepEqual(await s.dev.register('a', job('a', { sha: '7654321' })), { state: 'sleeping' });
assert.equal(s.runs.length, 1);
await s.dev.wake('a'); await s.dev.idle();
assert.equal(s.runs.length, 2);
assert.equal(s.prepared[1].job.sha, '7654321');
});
test('I2: gleicher sha, geänderte Variablen ⇒ register/ensure starten neu', async () => {
const s = setup();
await s.dev.ensure('a', job('a')); await s.dev.idle();
assert.equal(s.runs.length, 1);
// gleiche Variablen (auch andere Reihenfolge): kein Neustart
assert.equal((await s.dev.register('a', job('a', { env: { API_KEY: 'geheim-wert' } }))).state, 'online');
assert.equal(s.runs.length, 1);
assert.equal((await s.dev.register('a', job('a', { env: { API_KEY: 'neuer-wert' } }))).state, 'starting');
await s.dev.idle();
assert.equal(s.runs.length, 2);
assert.ok(s.removed.includes('preview-dev-a'));
assert.equal(s.runs[1].opts.env.API_KEY, 'neuer-wert');
// Branch-Wechsel ebenfalls
await s.dev.ensure('a', job('a', { env: { API_KEY: 'neuer-wert' }, branch: 'dev' })); await s.dev.idle();
assert.equal(s.runs.length, 3);
});
test('I7: beendeter Container ⇒ sleeping, entfernt und per wake neu gestartet', async () => {
let up = true;
const s = setup({ alive: () => up });
await s.dev.wake('a').catch(() => {}); // unbekannt
await s.dev.register('a', job('a'));
await s.dev.wake('a'); await s.dev.idle();
assert.equal(s.runs.length, 1);
up = false;
s.clock.t += 11e3;
assert.deepEqual(await s.dev.wake('a'), { state: 'starting' });
await s.dev.idle();
assert.ok(s.removed.includes('preview-dev-a'));
assert.equal(s.runs.length, 2);
});
test('I7: Lebendigkeitsprüfung höchstens alle 10 s', async () => {
let checks = 0;
const s = setup({ alive: () => { checks++; return true; } });
await s.dev.ensure('a', job('a')); await s.dev.idle();
for (let i = 0; i < 5; i++) await s.dev.ensure('a', job('a'));
assert.equal(checks, 0);
s.clock.t += 11e3;
await s.dev.ensure('a', job('a')); await s.dev.ensure('a', job('a'));
assert.equal(checks, 1);
});
test('I7: markDead entfernt den Container und setzt sleeping', async () => {
const s = setup();
await s.dev.ensure('a', job('a')); await s.dev.idle();
await s.dev.markDead('a');
assert.ok(s.removed.includes('preview-dev-a'));
assert.equal(s.dev.list().find((e) => e.sub === 'a').state, 'sleeping');
assert.equal((await s.dev.wake('a')).state, 'starting');
});