fix(runner): Dev-Neustart bei geänderten Variablen, Liveness-Prüfung, npm-ci-Fallback

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-09-30 21:54:01 +00:00
1 parent cd9a6e0cf1
commit 96c26fb9b8
8 files changed
+139 -10

No files matched your search

+54 -1
View File
@@ -5,7 +5,7 @@ import { createDev } from '../src/dev.js';
const MIN = 60e3;
const GB = 1024 ** 3;
function setup({ mem = 10 * GB, connect, detected, prep, rmImpl } = {}) {
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 = [];
@@ -14,6 +14,7 @@ function setup({ mem = 10 * GB, connect, detected, prep, rmImpl } = {}) {
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 } }; },
@@ -291,3 +292,55 @@ test('register: schlafender sub mit neuem sha startet nicht, wake nutzt das neue
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');
});
+15 -1
View File
@@ -156,7 +156,7 @@ test('ps/stop/rm', async () => {
};
const docker = createDocker({ exec });
assert.deepEqual(await docker.ps('webklar.preview'), ['preview-dev-a', 'preview-dev-b']);
assert.deepEqual(calls[0], ['ps', '--filter', 'label=webklar.preview', '--format', '{{.Names}}']);
assert.deepEqual(calls[0], ['ps', '-a', '--filter', 'label=webklar.preview', '--format', '{{.Names}}']);
await docker.stop('preview-dev-a');
await docker.rm('preview-dev-a');
assert.deepEqual(calls[1], ['rm', '-f', 'preview-dev-a']);
@@ -207,3 +207,17 @@ test('Zeitlimit mit env-file: Datei wird entfernt', async () => {
assert.deepEqual(await docker.run(['run', '--name', 'j', 'node:20'], { env: { K: 'v' }, timeoutMs: 10 }), { code: 124 });
assert.equal(existsSync(filePath), false);
});
test('ps: mit -a, damit beendete Container aufgeräumt werden', async () => {
const calls = [];
const exec = (args) => { calls.push(args); const c = fakeChild(); setImmediate(() => { c.stdout.end(); c.stderr.end(); c.emit('close', 0); }); return c; };
await createDocker({ exec }).ps('webklar.preview');
assert.deepEqual(calls[0].slice(0, 2), ['ps', '-a']);
});
test('running: inspect true/false/fehlend', async () => {
const mk = (out, code) => () => { const c = fakeChild(); setImmediate(() => { if (out) c.stdout.write(out); c.stdout.end(); c.stderr.end(); c.emit('close', code); }); return c; };
assert.equal(await createDocker({ exec: mk('true\n', 0) }).running('preview-dev-a'), true);
assert.equal(await createDocker({ exec: mk('false\n', 0) }).running('preview-dev-a'), false);
assert.equal(await createDocker({ exec: mk('', 1) }).running('preview-dev-a'), false);
});
+23
View File
@@ -231,3 +231,26 @@ test('mem() wirft ⇒ wie wenig Speicher; Cache während des Laufs vor pruneCach
await wait(s.jobs, id);
assert.equal(during, true);
});
test('I8: npm ci scheitert ⇒ Wiederholung mit npm install (Protokoll, Build läuft weiter)', async () => {
const s = setup({
files: { 'package.json': PKG(), 'package-lock.json': '{}' },
dockerImpl: (args) => {
if (args.includes('ci')) return { code: 1 };
if (args.includes('build')) { mkdirSync(join(workOf(args), 'dist'), { recursive: true }); writeFileSync(join(workOf(args), 'dist', 'index.html'), 'N'); }
return { code: 0 };
},
});
const r = await wait(s.jobs, s.jobs.submit(job()));
assert.equal(r.state, 'done'); assert.equal(r.result, 'build');
assert.equal(s.calls.length, 3);
assert.deepEqual(s.calls[1].args.slice(-5), ['npm', 'install', '--include=dev', '--no-audit', '--no-fund']);
assert.ok(r.log.some((l) => l.includes('npm install')));
});
test('I8: auch npm install scheitert ⇒ Installationsfehler', async () => {
const s = setup({ files: { 'package.json': PKG(), 'package-lock.json': '{}' }, dockerImpl: () => ({ code: 1 }) });
const r = await wait(s.jobs, s.jobs.submit(job()));
assert.equal(r.state, 'failed'); assert.match(r.error, /Installation fehlgeschlagen/);
assert.equal(s.calls.length, 2);
});
+11
View File
@@ -221,3 +221,14 @@ test('WebSocket-Daten touchen höchstens einmal pro Minute', async () => {
}, { now: () => clock.t });
} finally { t.srv.closeAllConnections?.(); await new Promise((r) => t.srv.close(r)); }
});
test('I7: Upstream ECONNREFUSED ⇒ dev.markDead + 502', async () => {
const dead = [];
const srv = http.createServer(); await new Promise((r) => srv.listen(0, '127.0.0.1', r));
const port = srv.address().port; await new Promise((r) => srv.close(r)); // Port ist jetzt zu
await withProxy({ wake: async () => ({ state: 'online', target: `http://127.0.0.1:${port}` }), markDead: async (s) => { dead.push(s); } }, async ({ port: p }) => {
const { res } = await get(p, 'demo.project.webklar.com');
assert.equal(res.statusCode, 502);
assert.deepEqual(dead, ['demo']);
});
});