import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createJobs } from '../src/jobs.js'; import { validateJob } from '../src/validate.js'; function setup({ files, dockerImpl }) { const root = mkdtempSync(join(tmpdir(), 'idx-')); const config = { giteaUrl: 'https://git.x', giteaToken: 'tok_SECRET_123456', previewsRoot: join(root, 'previews'), workRoot: join(root, 'work'), cacheRoot: join(root, 'cache'), minFreeBytes: 1000, buildTimeoutMs: 900000 }; const 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 docker = { run: async (args) => (dockerImpl ? dockerImpl(args) : { code: 0 }) }; const jobs = createJobs({ config, docker, git, mem: () => 1e10, fs: { chownR: async () => {} }, setTimer: () => {} }); return { config, jobs, out: join(config.previewsRoot, 'demo') }; } 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]; test('Doener-Fall: nur Doener_Nation_Website.html ⇒ index.html ist Byte-Kopie, Original bleibt', async () => { const html = Buffer.from('Döneräö\n'); const s = setup({ files: { 'Doener_Nation_Website.html': html, '.webklar-preview.json': '{}' } }); const r = await wait(s.jobs, s.jobs.submit(job())); assert.equal(r.state, 'done'); assert.deepEqual(readFileSync(join(s.out, 'index.html')), html); assert.deepEqual(readFileSync(join(s.out, 'Doener_Nation_Website.html')), html); assert.equal(statSync(join(s.out, 'index.html')).mode & 0o777, 0o644); }); test('vorhandene index.html bleibt unberührt', async () => { const s = setup({ files: { 'index.html': 'ORIG', 'home.html': 'HOME' } }); await wait(s.jobs, s.jobs.submit(job())); assert.equal(readFileSync(join(s.out, 'index.html'), 'utf8'), 'ORIG'); }); test('bevorzugter Name gewinnt, sonst größte Datei', async () => { let s = setup({ files: { 'a.html': 'aaaa', 'Main.html': 'm', 'zz.html': 'zzzzzzzz' } }); await wait(s.jobs, s.jobs.submit(job())); assert.equal(readFileSync(join(s.out, 'index.html'), 'utf8'), 'm'); s = setup({ files: { 'a.html': 'aaaa', 'b.htm': 'bbbbbbbbb', 'c.html': 'c' } }); await wait(s.jobs, s.jobs.submit(job())); assert.equal(readFileSync(join(s.out, 'index.html'), 'utf8'), 'bbbbbbbbb'); }); test('index-Unterordner wird Webroot; fehlender Ordner ⇒ Repo-Wurzel', async () => { let s = setup({ files: { 'public/index.html': 'PUB', 'x.html': 'root' } }); let r = await wait(s.jobs, s.jobs.submit(job({ index: 'public' }))); assert.equal(r.state, 'done'); assert.deepEqual(readdirSync(s.out), ['index.html']); s = setup({ files: { 'x.html': 'root' } }); await wait(s.jobs, s.jobs.submit(job({ index: 'gibtsnicht' }))); assert.equal(readFileSync(join(s.out, 'index.html'), 'utf8'), 'root'); }); test('kein HTML ⇒ Platzhalter mit Anzeigename (maskiert)', async () => { const s = setup({ files: { 'readme.txt': 'x' } }); await wait(s.jobs, s.jobs.submit(job({ displayName: 'A & ' }))); const h = readFileSync(join(s.out, 'index.html'), 'utf8'); assert.match(h, /

A & <B><\/h1>/); assert.match(h, /A & <B> – wird vorbereitet<\/title>/); const s2 = setup({ files: { 'readme.txt': 'x' } }); await wait(s2.jobs, s2.jobs.submit(job())); assert.match(readFileSync(join(s2.out, 'index.html'), 'utf8'), /<h1>demo<\/h1>/); }); test('Build ohne index.html im Ausgabeordner ⇒ Quellstand statisch + Kandidat/Platzhalter', async () => { const s = setup({ files: { 'package.json': JSON.stringify({ scripts: { build: 'x' } }), 'seite.html': 'SEITE' }, dockerImpl: (args) => { if (args.includes('build')) { mkdirSync(join(workOf(args), 'dist'), { recursive: true }); writeFileSync(join(workOf(args), 'dist', 'app.js'), 'x'); mkdirSync(join(workOf(args), 'node_modules'), { recursive: true }); writeFileSync(join(workOf(args), 'node_modules', 'm.js'), 'x'); } return { code: 0 }; }, }); const r = await wait(s.jobs, s.jobs.submit(job())); assert.equal(r.state, 'done'); assert.equal(r.result, 'static'); assert.equal(readFileSync(join(s.out, 'index.html'), 'utf8'), 'SEITE'); assert.ok(!existsSync(join(s.out, 'node_modules'))); const s2 = setup({ files: { 'package.json': JSON.stringify({ scripts: { build: 'x' } }) }, dockerImpl: (args) => { if (args.includes('build')) mkdirSync(join(workOf(args), 'dist'), { recursive: true }); return { code: 0 }; }, }); await wait(s2.jobs, s2.jobs.submit(job({ displayName: 'Shop' }))); assert.match(readFileSync(join(s2.out, 'index.html'), 'utf8'), /<h1>Shop<\/h1>/); }); test('Build-Ausgabe mit index.html wird unverändert veröffentlicht', async () => { const s = setup({ files: { 'package.json': JSON.stringify({ scripts: { build: 'x' } }) }, dockerImpl: (args) => { if (args.includes('build')) { mkdirSync(join(workOf(args), 'dist'), { recursive: true }); writeFileSync(join(workOf(args), 'dist', 'index.html'), 'B'); writeFileSync(join(workOf(args), 'dist', 'o.html'), 'O'); } return { code: 0 }; }, }); const r = await wait(s.jobs, s.jobs.submit(job())); assert.equal(r.result, 'build'); assert.deepEqual(readdirSync(s.out).sort(), ['index.html', 'o.html']); }); const BUILD_PKG = JSON.stringify({ scripts: { build: 'x' } }); const failBuild = (args) => ({ code: args.includes('build') ? 1 : 0 }); test('bestehender <sub>-Ordner + fehlgeschlagener Build ⇒ failed, alter Stand unverändert', async () => { const s = setup({ files: { 'package.json': BUILD_PKG, 'x.html': 'QUELLE' }, dockerImpl: failBuild }); mkdirSync(s.out, { recursive: true }); writeFileSync(join(s.out, 'index.html'), 'ALT'); const r = await wait(s.jobs, s.jobs.submit(job())); assert.equal(r.state, 'failed'); assert.equal(readFileSync(join(s.out, 'index.html'), 'utf8'), 'ALT'); }); test('erstes Deploy + fehlgeschlagener Build ⇒ Quellstand statisch (Kandidat), result static, Hinweis', async () => { const s = setup({ files: { 'package.json': BUILD_PKG, 'x.html': 'QUELLE' }, dockerImpl: failBuild }); const r = await wait(s.jobs, s.jobs.submit(job())); assert.equal(r.state, 'done'); assert.equal(r.result, 'static'); assert.match(r.hint, /Build fehlgeschlagen/); assert.equal(readFileSync(join(s.out, 'index.html'), 'utf8'), 'QUELLE'); assert.ok(r.log.some((l) => /erstes Deploy/.test(l))); }); test('erstes Deploy + Installationsfehler ⇒ statischer Quellstand', async () => { const s = setup({ files: { 'package.json': BUILD_PKG }, dockerImpl: () => ({ code: 1 }) }); const r = await wait(s.jobs, s.jobs.submit(job({ displayName: 'Shop' }))); assert.equal(r.result, 'static'); assert.match(readFileSync(join(s.out, 'index.html'), 'utf8'), /<h1>Shop<\/h1>/); }); test('R21: json type static + index.html + build-Skript ⇒ static, kein Container', async () => { const s = setup({ files: { 'package.json': BUILD_PKG, 'index.html': 'S', '.webklar-preview.json': '{"type":"static"}' }, dockerImpl: () => { throw new Error('kein Container'); } }); const r = await wait(s.jobs, s.jobs.submit(job())); assert.equal(r.state, 'done'); assert.equal(r.result, 'static'); }); test('R21: json type node_build ⇒ build (auch mit index.html); kein Dev-Rückfall', async () => { const mk = (extra) => setup({ files: { 'package.json': JSON.stringify({ scripts: { build: 'x', dev: 'y' } }), 'index.html': 'S', '.webklar-preview.json': '{"type":"node_build"}', ...extra }, dockerImpl: (args) => { if (args.includes('build')) { mkdirSync(join(workOf(args), 'dist'), { recursive: true }); writeFileSync(join(workOf(args), 'dist', 'index.html'), 'B'); } return { code: 0 }; } }); let s = mk(); let r = await wait(s.jobs, s.jobs.submit(job())); assert.equal(r.result, 'build'); assert.equal(readFileSync(join(s.out, 'index.html'), 'utf8'), 'B'); s = setup({ files: { 'package.json': JSON.stringify({ scripts: { build: 'x', dev: 'y' } }), '.webklar-preview.json': '{"type":"node_build"}' }, dockerImpl: failBuild }); mkdirSync(s.out, { recursive: true }); r = await wait(s.jobs, s.jobs.submit(job())); assert.equal(r.state, 'failed'); }); test('R21: ohne json-Typ + index.html + build-Skript ⇒ build', async () => { const s = setup({ files: { 'package.json': BUILD_PKG, 'index.html': 'S' }, dockerImpl: (args) => { if (args.includes('build')) { mkdirSync(join(workOf(args), 'dist'), { recursive: true }); writeFileSync(join(workOf(args), 'dist', 'index.html'), 'B'); } return { code: 0 }; } }); const r = await wait(s.jobs, s.jobs.submit(job())); assert.equal(r.result, 'build'); }); test('R21: Modus build aus dem Portal schlägt json type static', async () => { const s = setup({ files: { 'package.json': BUILD_PKG, 'index.html': 'S', '.webklar-preview.json': '{"type":"static"}' }, dockerImpl: (args) => { if (args.includes('build')) { mkdirSync(join(workOf(args), 'dist'), { recursive: true }); writeFileSync(join(workOf(args), 'dist', 'index.html'), 'B'); } return { code: 0 }; } }); const r = await wait(s.jobs, s.jobs.submit(job({ mode: 'build' }))); assert.equal(r.result, 'build'); }); test('json index-Unterordner wird beim Veröffentlichen genutzt (static und Build-Rückfall)', async () => { let s = setup({ files: { 'public/index.html': 'PUB', 'root.html': 'r', '.webklar-preview.json': '{"index":"public"}' } }); await wait(s.jobs, s.jobs.submit(job())); assert.deepEqual(readdirSync(s.out), ['index.html']); s = setup({ files: { 'package.json': BUILD_PKG, 'public/home.html': 'H', '.webklar-preview.json': '{"index":"public"}' }, dockerImpl: failBuild }); const r = await wait(s.jobs, s.jobs.submit(job())); assert.equal(r.result, 'static'); assert.equal(readFileSync(join(s.out, 'index.html'), 'utf8'), 'H'); assert.ok(!existsSync(join(s.out, 'package.json'))); }); test('validateJob: displayName optional, ≤100 Zeichen, keine Steuerzeichen', () => { const good = { sub: 'a', repo: 'o/r', sha: 'abcdef1', branch: 'main' }; assert.equal(validateJob({ ...good, displayName: 'Döner Nation' }).job.displayName, 'Döner Nation'); assert.equal('displayName' in validateJob(good).job, false); assert.equal(validateJob({ ...good, displayName: 'x'.repeat(101) }).ok, false); assert.equal(validateJob({ ...good, displayName: 'a\nb' }).ok, false); assert.equal(validateJob({ ...good, displayName: 5 }).ok, false); });