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

293 lines
18 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, statSync, utimesSync, symlinkSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import { createJobs, createGit } from '../src/jobs.js';
const TOKEN = 'tok_SECRET_123456';
const B64 = Buffer.from(`x-access-token:${TOKEN}`).toString('base64');
function setup({ files, dockerImpl, mem = () => 10 * 1024 ** 3, cfg = {}, git } = {}) {
const root = mkdtempSync(join(tmpdir(), 'jobs-'));
const config = { giteaUrl: 'https://git.x', giteaToken: TOKEN, previewsRoot: join(root, 'previews'), workRoot: join(root, 'work'), cacheRoot: join(root, 'cache'), minFreeBytes: 1000, buildTimeoutMs: 900000, ...cfg };
mkdirSync(config.previewsRoot, { recursive: true });
const calls = [];
const chowned = [];
const timers = [];
const docker = { run: async (args, opts) => { calls.push({ args, opts }); return dockerImpl ? dockerImpl(args, opts) : { code: 0 }; } };
const gitFake = git || (async ({ dir }) => { for (const [f, c] of Object.entries(files || {})) { mkdirSync(join(dir, f, '..'), { recursive: true }); writeFileSync(join(dir, f), c); } mkdirSync(join(dir, '.git'), { recursive: true }); writeFileSync(join(dir, '.git', 'config'), 'x'); });
const jobs = createJobs({ config, docker, git: gitFake, mem, fs: { chownR: async (p) => { chowned.push(p); } }, setTimer: (fn, ms) => timers.push({ fn, ms }) });
return { root, config, calls, chowned, timers, jobs };
}
const job = (o = {}) => ({ sub: 'demo', repo: 'own/rep', sha: 'abcdef1', branch: 'main', mode: 'auto', env: {}, ...o });
const wait = async (jobs, id) => { for (let i = 0; i < 400; i++) { const r = jobs.get(id); if (r.state === 'done' || r.state === 'failed') return r; await new Promise((r2) => setTimeout(r2, 5)); } throw new Error('timeout'); };
const workOf = (args) => args.find((a) => a.endsWith(':/work')).split(':')[0];
const PKG = (extra = {}) => JSON.stringify({ scripts: { build: 'vite build', ...extra } });
test('static: kopiert ohne .git, Rechte 755/644', async () => {
const s = setup({ files: { 'index.html': 'hi', 'a/b.css': 'x' } });
const r = await wait(s.jobs, s.jobs.submit(job()));
assert.equal(r.state, 'done'); assert.equal(r.result, 'static');
const t = join(s.config.previewsRoot, 'demo');
assert.equal(readFileSync(join(t, 'index.html'), 'utf8'), 'hi');
assert.ok(!existsSync(join(t, '.git')));
assert.equal(statSync(join(t, 'a')).mode & 0o777, 0o755);
assert.equal(statSync(join(t, 'a/b.css')).mode & 0o777, 0o644);
assert.ok(!existsSync(join(s.config.workRoot, 'build', r.runId)));
assert.equal(s.calls.length, 0);
});
test('build: Analyse wird in den gebauten Ausgabeordner eingesetzt (wie bei static), Dev nicht', async () => {
const analytics = { siteKey: 'abcdefghij0123456789', color: '#2b6cb0', privacy: { company: 'Muster GmbH', address: 'Weg 1, 12345 Ort', email: 'info@muster.de' } };
const s = setup({
cfg: { analyticsPublicBase: 'https://project.webklar.com/a/' },
files: { 'package.json': PKG() },
dockerImpl: (args) => { if (args.includes('build')) { mkdirSync(join(workOf(args), 'dist'), { recursive: true }); writeFileSync(join(workOf(args), 'dist', 'index.html'), '<html><head></head><body>NEU</body></html>'); } return { code: 0 }; },
});
const r = await wait(s.jobs, s.jobs.submit(job({ mode: 'build', analytics })));
assert.equal(r.state, 'done'); assert.equal(r.result, 'build');
const t = join(s.config.previewsRoot, 'demo');
assert.match(readFileSync(join(t, 'index.html'), 'utf8'), /<script data-wk-analytics [^>]*data-site="abcdefghij0123456789"/);
assert.ok(existsSync(join(t, 'datenschutz-analyse.html')));
const d = setup({ files: { 'package.json': PKG({ dev: 'vite' }) }, dockerImpl: () => ({ code: 0 }) });
const dr = await wait(d.jobs, d.jobs.submit(job({ mode: 'dev', analytics })));
assert.equal(dr.result, 'dev');
assert.ok(!existsSync(join(d.config.previewsRoot, 'demo')), 'dev veröffentlicht nichts, also keine Einsetzung');
});
test('build: Install+Build im Container (corepack für pnpm), Erfolg ersetzt Ordner atomar', async () => {
const s = setup({
files: { 'package.json': PKG(), 'pnpm-lock.yaml': '' },
dockerImpl: (args) => { if (args.includes('build')) { mkdirSync(join(workOf(args), 'dist'), { recursive: true }); writeFileSync(join(workOf(args), 'dist', 'index.html'), 'NEU'); } return { code: 0 }; },
});
mkdirSync(join(s.config.previewsRoot, 'demo')); writeFileSync(join(s.config.previewsRoot, 'demo', 'index.html'), 'ALT');
const r = await wait(s.jobs, s.jobs.submit(job({ env: { K: 'wert1234' } })));
assert.equal(r.state, 'done'); assert.equal(r.result, 'build');
assert.equal(readFileSync(join(s.config.previewsRoot, 'demo', 'index.html'), 'utf8'), 'NEU');
assert.deepEqual(readdirSync(s.config.previewsRoot), ['demo']);
assert.equal(s.calls.length, 2);
assert.deepEqual(s.calls[0].args.slice(-4), ['corepack', 'pnpm', 'install', '--frozen-lockfile']);
assert.deepEqual(s.calls[1].args.slice(-4), ['corepack', 'pnpm', 'run', 'build']);
assert.deepEqual(s.calls[0].opts.env ?? {}, {}); assert.deepEqual(s.calls[1].opts.env, { K: 'wert1234' });
assert.ok(s.chowned.some((p) => p.endsWith('own__rep')) && s.chowned.some((p) => p.includes('/build/')));
assert.ok(s.calls[0].args.includes(join(s.config.cacheRoot, 'own__rep') + ':/cache'));
});
test('Build-Fehler: alter Ordner byte-gleich, failed + deutscher Text', async () => {
const s = setup({ files: { 'package.json': PKG() }, dockerImpl: (args) => ({ code: args.includes('build') ? 2 : 0 }) });
mkdirSync(join(s.config.previewsRoot, 'demo')); writeFileSync(join(s.config.previewsRoot, 'demo', 'index.html'), 'ALT');
const r = await wait(s.jobs, s.jobs.submit(job()));
assert.equal(r.state, 'failed'); assert.match(r.error, /Build fehlgeschlagen \(Exit-Code 2\)/);
assert.equal(readFileSync(join(s.config.previewsRoot, 'demo', 'index.html'), 'utf8'), 'ALT');
assert.deepEqual(readdirSync(s.config.previewsRoot), ['demo']);
});
test('Zeitlimit (124) ⇒ failed mit Zeitlimit-Text', async () => {
const s = setup({ files: { 'package.json': PKG() }, dockerImpl: (args) => ({ code: args.includes('build') ? 124 : 0 }) });
const r = await wait(s.jobs, s.jobs.submit(job({ mode: 'build' })));
assert.match(r.error, /Zeitlimit/);
});
test('auto mit dev-Skript nach Build-Fehler ⇒ result dev, kein Publish', async () => {
const s = setup({ files: { 'package.json': PKG({ dev: 'vite' }) }, dockerImpl: (args) => ({ code: args.includes('build') ? 1 : 0 }) });
const r = await wait(s.jobs, s.jobs.submit(job()));
assert.equal(r.state, 'done'); assert.equal(r.result, 'dev');
assert.deepEqual(readdirSync(s.config.previewsRoot), []);
const b = await wait(s.jobs, s.jobs.submit(job({ mode: 'build' })));
assert.equal(b.state, 'failed');
});
test('auto + Build-Fehler + dev-Skript + bestehender Ordner ⇒ failed, Ordner unveraendert', async () => {
const s = setup({ files: { 'package.json': PKG({ dev: 'vite' }) }, dockerImpl: (args) => ({ code: args.includes('build') ? 1 : 0 }) });
mkdirSync(join(s.config.previewsRoot, 'demo')); writeFileSync(join(s.config.previewsRoot, 'demo', 'index.html'), 'ALT');
const r = await wait(s.jobs, s.jobs.submit(job()));
assert.equal(r.state, 'failed'); assert.notEqual(r.result, 'dev');
assert.equal(readFileSync(join(s.config.previewsRoot, 'demo', 'index.html'), 'utf8'), 'ALT');
});
test('Exit 137 im Build: Speicherlimit-Hinweis im Log, Build-Container mit 2g', async () => {
const s = setup({ files: { 'package.json': PKG() }, dockerImpl: (args) => ({ code: args.includes('build') ? 137 : 0 }) });
const r = await wait(s.jobs, s.jobs.submit(job({ mode: 'build' })));
assert.equal(r.state, 'failed');
assert.ok(r.log.some((l) => l.includes('Build am Speicherlimit abgebrochen (Exit 137)')));
const b = s.calls.find((c) => c.args.includes('preview-build-' + r.runId + '-build'));
assert.ok(b && b.args[b.args.indexOf('--memory') + 1] === '2g');
});
test('erzwungener Modus mit Erkennungsfehler ⇒ failed', async () => {
const s = setup({ files: { 'index.html': 'x' } });
const r = await wait(s.jobs, s.jobs.submit(job({ mode: 'build' })));
assert.equal(r.state, 'failed'); assert.match(r.error, /build-Skript/);
});
test('Ausgabeordner als Symlink nach außen wird ignoriert', async () => {
const s = setup({ files: { 'package.json': PKG() }, dockerImpl: (args) => { if (args.includes('build')) symlinkSync('/etc', join(workOf(args), 'dist')); return { code: 0 }; } });
const r = await wait(s.jobs, s.jobs.submit(job({ mode: 'build' })));
assert.equal(r.state, 'failed'); assert.match(r.error, /Ausgabeordner/);
});
test('Token nie im Log (auch base64), Log auf 300 Zeilen gekürzt', async () => {
const s = setup({
files: { 'package.json': PKG() },
git: async ({ onLine }) => { onLine(`fatal: url https://x-access-token:${TOKEN}@git.x/own/rep`); onLine(`Authorization: Basic ${B64}`); onLine(`raw ${B64}`); throw new Error(`clone ${TOKEN} kaputt`); },
});
const r = await wait(s.jobs, s.jobs.submit(job()));
assert.equal(r.state, 'failed');
const all = r.log.join('\n') + r.error;
assert.ok(!all.includes(TOKEN) && !all.includes(B64));
const s2 = setup({ files: { 'package.json': PKG() }, dockerImpl: (a, o) => { for (let i = 0; i < 1000; i++) o.onLine(`zeile ${i}`, 'out'); return { code: 1 }; } });
const r2 = await wait(s2.jobs, s2.jobs.submit(job({ mode: 'build' })));
assert.equal(r2.log.length, 300);
assert.equal(r2.log.filter((l) => l === 'zeile 999').length >= 1, true);
});
test('zwei Aufträge laufen nacheinander', async () => {
let running = 0; let maxRunning = 0;
const s = setup({ files: { 'index.html': 'x' }, git: async ({ dir }) => { running++; maxRunning = Math.max(maxRunning, running); await new Promise((r) => setTimeout(r, 20)); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'index.html'), 'x'); running--; } });
const a = s.jobs.submit(job({ sub: 'a' })); const b = s.jobs.submit(job({ sub: 'b' }));
assert.equal(s.jobs.queueLength(), 2);
await wait(s.jobs, a); await wait(s.jobs, b);
assert.equal(maxRunning, 1);
assert.ok(existsSync(join(s.config.previewsRoot, 'a')) && existsSync(join(s.config.previewsRoot, 'b')));
});
test('wenig Speicher ⇒ bleibt queued, Wiederholung nach 30 s', async () => {
let free = 10; // zu wenig: minFreeBytes 1000
const s2 = setup({ files: { 'index.html': 'x' }, mem: () => free, cfg: { minFreeBytes: 1000 } });
const id = s2.jobs.submit(job());
assert.equal(s2.jobs.get(id).state, 'queued');
assert.equal(s2.timers.length, 1); assert.equal(s2.timers[0].ms, 30000);
free = 5000; s2.timers[0].fn();
assert.equal((await wait(s2.jobs, id)).state, 'done');
});
test('prepareWorkspace: Klon + Installation in angegebenem workDir, bleibt bestehen', async () => {
const s = setup({ files: { 'package.json': PKG({ dev: 'vite' }) } });
const wd = join(s.config.workRoot, 'dev', 'demo');
const r = await s.jobs.prepareWorkspace(job(), { workDir: wd });
assert.equal(r.workDir, wd); assert.equal(r.detected.kind, 'build');
assert.ok(existsSync(join(wd, 'package.json')));
assert.equal(s.calls.length, 1);
assert.ok(s.calls[0].args.includes('install'));
});
test('prepareWorkspace: Installationsfehler ⇒ reject', async () => {
const s = setup({ files: { 'package.json': PKG() }, dockerImpl: () => ({ code: 1 }) });
await assert.rejects(s.jobs.prepareWorkspace(job(), { workDir: join(s.config.workRoot, 'dev', 'demo') }), /Installation fehlgeschlagen/);
});
test('erledigte Läufe werden nach 1 h verworfen', async () => {
let t = 1000;
const root = mkdtempSync(join(tmpdir(), 'jobs-'));
const config = { giteaUrl: 'x', giteaToken: 't', previewsRoot: join(root, 'p'), workRoot: join(root, 'w'), cacheRoot: join(root, 'c'), minFreeBytes: 0, buildTimeoutMs: 1000 };
const jobs = createJobs({ config, docker: {}, git: async ({ dir }) => { mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'index.html'), 'x'); }, mem: () => 1e12, now: () => t, fs: { chownR: async () => {} } });
const id = jobs.submit(job());
await wait(jobs, id);
t += 3600e3 - 1; assert.ok(jobs.get(id));
t += 2; assert.equal(jobs.get(id), null);
});
test('pruneCache: > 14 Tage weg, dann älteste bis <= Limit', async () => {
const s = setup({ cfg: { cacheMaxBytes: 250 } });
const mk = (n, bytes, ageDays) => { const d = join(s.config.cacheRoot, n); mkdirSync(d, { recursive: true }); writeFileSync(join(d, 'f'), Buffer.alloc(bytes)); const t = (Date.now() - ageDays * 864e5) / 1000; utimesSync(d, t, t); };
mk('old__r', 10, 20); mk('a__r', 100, 5); mk('b__r', 100, 3); mk('c__r', 100, 1);
await s.jobs.pruneCache();
assert.deepEqual(readdirSync(s.config.cacheRoot).sort(), ['b__r', 'c__r']);
rmSync(s.root, { recursive: true, force: true });
});
function fakeSpawn() {
const calls = [];
const fn = (cmd, args, opts) => {
const c = new EventEmitter(); c.stdout = new PassThrough(); c.stderr = new PassThrough(); c.killed = null;
c.kill = (sig) => { c.killed = sig; };
calls.push({ cmd, args, opts, child: c });
return c;
};
return { fn, calls };
}
test('createGit: Token nicht in argv/URL, Umgebung minimal + GIT_CONFIG_*', async () => {
const sp = fakeSpawn();
const git = createGit({ giteaUrl: 'https://git.x/', giteaToken: TOKEN }, { spawnFn: sp.fn });
const p = git({ repo: 'own/rep', branch: 'main', dir: '/w/x' });
const { args, opts, child } = sp.calls[0];
assert.ok(!args.join(' ').includes(TOKEN) && !args.join(' ').includes(B64));
assert.ok(args.includes('https://git.x/own/rep.git'));
assert.deepEqual(args.slice(args.indexOf('clone')), ['clone', '--depth', '1', '--branch', 'main', '--', 'https://git.x/own/rep.git', '/w/x']);
assert.ok(args.includes('http.lowSpeedLimit=1000') && args.includes('http.lowSpeedTime=60'));
assert.deepEqual(Object.keys(opts.env).sort(), ['GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_0', 'GIT_CONFIG_NOSYSTEM', 'GIT_CONFIG_VALUE_0', 'GIT_TERMINAL_PROMPT', 'HOME', 'PATH']);
assert.equal(opts.env.GIT_CONFIG_KEY_0, 'http.extraHeader');
assert.equal(opts.env.GIT_CONFIG_VALUE_0, `Authorization: Basic ${B64}`);
child.emit('close', 0);
await p;
});
test('createGit: Zeitlimit beendet git mit SIGKILL und lehnt ab', async () => {
const sp = fakeSpawn();
const git = createGit({ giteaUrl: 'https://git.x', giteaToken: TOKEN }, { spawnFn: sp.fn });
await assert.rejects(git({ repo: 'own/rep', branch: 'main', dir: '/w/x', timeoutMs: 1000 }), /Zeitlimit/);
assert.equal(sp.calls[0].child.killed, 'SIGKILL');
});
test('publish: fehlt <sub> und nur .old existiert, wird .old zurückgeholt (Build-Fehler danach)', async () => {
const s = setup({ files: { 'package.json': PKG() }, dockerImpl: (args) => ({ code: args.includes('build') ? 2 : 0 }) });
// Zustand nach Absturz: nur demo.old; Erfolg-Pfad über static
mkdirSync(join(s.config.previewsRoot, 'demo.old')); writeFileSync(join(s.config.previewsRoot, 'demo.old', 'index.html'), 'GUT');
const s2 = setup({ files: { 'index.html': 'NEU' } });
mkdirSync(join(s2.config.previewsRoot, 'demo.old')); writeFileSync(join(s2.config.previewsRoot, 'demo.old', 'index.html'), 'GUT');
const r = await wait(s2.jobs, s2.jobs.submit(job()));
assert.equal(r.state, 'done');
assert.equal(readFileSync(join(s2.config.previewsRoot, 'demo', 'index.html'), 'utf8'), 'NEU');
});
test('publish: Aufräumfehler nach dem Tausch lässt den Lauf nicht scheitern', async () => {
const root = mkdtempSync(join(tmpdir(), 'jobs-'));
const config = { giteaUrl: 'x', giteaToken: 't', previewsRoot: join(root, 'p'), workRoot: join(root, 'w'), cacheRoot: join(root, 'c'), minFreeBytes: 0, buildTimeoutMs: 1000 };
mkdirSync(join(config.previewsRoot, 'demo'), { recursive: true });
const jobs = createJobs({ config, docker: {}, git: async ({ dir }) => { mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'index.html'), 'N'); }, mem: () => 1e12,
fs: { chownR: async () => {}, rm: async (p, o) => { if (p.endsWith('demo.old') && existsSync(p)) throw new Error('boom'); return (await import('node:fs/promises')).rm(p, o); } } });
const r = await wait(jobs, jobs.submit(job()));
assert.equal(r.state, 'done');
assert.equal(readFileSync(join(config.previewsRoot, 'demo', 'index.html'), 'utf8'), 'N');
assert.ok(r.log.some((l) => /Aufräumen fehlgeschlagen/.test(l)));
});
test('mem() wirft ⇒ wie wenig Speicher; Cache während des Laufs vor pruneCache geschützt', async () => {
let boom = true; let s; let during;
s = setup({ files: { 'package.json': PKG() }, mem: () => { if (boom) throw new Error('x'); return 1e12; },
dockerImpl: async () => { const d = join(s.config.cacheRoot, 'own__rep'); const t = (Date.now() - 40 * 864e5) / 1000; utimesSync(d, t, t); await s.jobs.pruneCache(); during = existsSync(d); return { code: 1 }; } });
const id = s.jobs.submit(job({ mode: 'build' }));
assert.equal(s.jobs.get(id).state, 'queued');
boom = false; s.timers[0].fn();
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 }) });
mkdirSync(join(s.config.previewsRoot, 'demo'));
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);
});