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'), /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'), /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 -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'), /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);
});