'use strict'; // Tests für deploy/deploy.sh (das echte Skript, mit einem lokalen git-Remote und einem npm-Stub). // Aufruf: node --test deploy/deploy.test.js // Voraussetzung: git und sh auf dem Host. Der Stub-npm legt dist/index.html an und schreibt seine Umgebung weg. const { test, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { spawn, spawnSync } = require('node:child_process'); const DEPLOY_SH = path.join(__dirname, 'deploy.sh'); // Standard: das sh des Hosts. Zum Gegenprüfen mit busybox (wie im node:alpine-Container): DEPLOY_TEST_SH="busybox sh". const [SHELL_CMD, ...SHELL_ARGS] = (process.env.DEPLOY_TEST_SH || 'sh').split(' '); const GIT_ENV = { GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@example.invalid', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@example.invalid', }; const tmpDirs = []; const children = []; afterEach(() => { for (const child of children.splice(0)) { // Prozessgruppe beenden: auch der wartende npm-Stub unter dem Skript. if (child.exitCode === null && child.signalCode === null) process.kill(-child.pid, 'SIGKILL'); } for (const dir of tmpDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); }); const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function waitFor(condition, what, timeoutMs = 5000) { const deadline = Date.now() + timeoutMs; while (!condition()) { if (Date.now() > deadline) throw new Error(`Zeitüberschreitung beim Warten auf: ${what}`); await pause(20); } } function git(args, cwd) { const res = spawnSync('git', args, { cwd, env: { ...process.env, ...GIT_ENV }, encoding: 'utf8' }); assert.equal(res.status, 0, `git ${args.join(' ')}: ${res.stderr}`); return res.stdout; } /** Ein Arbeitsverzeichnis mit Remote (file://), Stub-npm und optional Stub-timeout. */ function setup({ withTimeoutStub = false } = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aircard-deploy-test-')); tmpDirs.push(dir); const bare = path.join(dir, 'remote', 'u', 'r.git'); fs.mkdirSync(bare, { recursive: true }); git(['init', '--bare', '--quiet', '-b', 'main', bare]); const work = path.join(dir, 'seed'); git(['clone', '--quiet', bare, work]); fs.mkdirSync(path.join(work, 'web')); fs.writeFileSync(path.join(work, 'web', 'package.json'), '{}\n'); git(['add', '-A'], work); git(['commit', '--quiet', '-m', 'seed'], work); git(['push', '--quiet', 'origin', 'HEAD:main'], work); const bin = path.join(dir, 'bin'); fs.mkdirSync(bin); const npmLog = path.join(dir, 'npm.log'); const release = path.join(dir, 'release'); fs.writeFileSync( path.join(bin, 'npm'), [ '#!/bin/sh', `echo "npm $* | WEBHOOK=\${AIRCARD_WEBHOOK_TOKEN-unset} GITEA=\${GITEA_TOKEN-unset} VITE=\${VITE_TEST_VAR-unset}" >> "${npmLog}"`, // Blockiert, bis die Testdatei "release" existiert (für den Test mit zwei gleichzeitigen Läufen). `if [ -n "\${STUB_NPM_BLOCK:-}" ]; then i=0; while [ ! -f "${release}" ] && [ "$i" -lt 400 ]; do sleep 0.05; i=$((i+1)); done; fi`, 'if [ -n "${STUB_NPM_FAIL:-}" ]; then echo "npm kaputt" >&2; exit 1; fi', 'if [ "$1" = run ]; then mkdir -p dist && echo "ok" > dist/index.html; fi', 'exit 0', '', ].join('\n'), { mode: 0o755 }, ); const timeoutLog = path.join(dir, 'timeout.log'); if (withTimeoutStub) { fs.writeFileSync( path.join(bin, 'timeout'), ['#!/bin/sh', `echo "timeout $*" >> "${timeoutLog}"`, 'shift', 'exec "$@"', ''].join('\n'), { mode: 0o755 }, ); } const repoDir = path.join(dir, 'app'); const outDir = path.join(dir, 'out'); fs.mkdirSync(repoDir); fs.mkdirSync(outDir); const env = { ...process.env, ...GIT_ENV, PATH: `${bin}:${process.env.PATH}`, GITEA_URL: `file://${path.join(dir, 'remote')}`, GITEA_USER: 'u', GITEA_REPO: 'r', GITEA_TOKEN: '', REPO_DIR: repoDir, OUT_DIR: outDir, DEPLOY_BRANCH: 'main', DEPLOY_TRIGGER: 'webhook', AIRCARD_WEBHOOK_TOKEN: 'geheim-webhook', VITE_TEST_VAR: 'bleibt', }; const lockDir = path.join(repoDir, '.git', 'aircard-deploy.lock'); const npmCalls = () => (fs.existsSync(npmLog) ? fs.readFileSync(npmLog, 'utf8').trim().split('\n') : []); const timeoutCalls = () => (fs.existsSync(timeoutLog) ? fs.readFileSync(timeoutLog, 'utf8').trim().split('\n') : []); return { dir, env, repoDir, outDir, lockDir, release, npmCalls, timeoutCalls }; } function runSync(ctx, extraEnv = {}) { const res = spawnSync(SHELL_CMD, [...SHELL_ARGS, DEPLOY_SH], { env: { ...ctx.env, ...extraEnv }, encoding: 'utf8', timeout: 60000 }); return { status: res.status, out: `${res.stdout}${res.stderr}` }; } function runAsync(ctx, extraEnv = {}) { const child = spawn(SHELL_CMD, [...SHELL_ARGS, DEPLOY_SH], { env: { ...ctx.env, ...extraEnv }, stdio: ['ignore', 'pipe', 'pipe'], detached: true, }); children.push(child); const result = { out: '', status: null, done: null }; child.stdout.on('data', (d) => (result.out += d)); child.stderr.on('data', (d) => (result.out += d)); result.done = new Promise((resolve) => child.once('exit', (code) => { result.status = code; resolve(result); }), ); return result; } test('ein Lauf baut, kopiert nach out/ und gibt das Webhook-Geheimnis nicht an npm weiter', () => { const ctx = setup(); const res = runSync(ctx); assert.equal(res.status, 0, res.out); assert.match(res.out, /Deployment abgeschlossen/); assert.equal(fs.readFileSync(path.join(ctx.outDir, 'index.html'), 'utf8').trim(), 'ok'); const calls = ctx.npmCalls(); assert.equal(calls.length, 2, calls.join('\n')); for (const call of calls) { assert.match(call, /WEBHOOK=unset/, `AIRCARD_WEBHOOK_TOKEN darf npm nicht erreichen: ${call}`); assert.match(call, /GITEA=unset/); assert.match(call, /VITE=bleibt/, 'andere Variablen (VITE_*) bleiben erhalten'); } assert.ok(!res.out.includes('geheim-webhook')); }); test('das Sperrverzeichnis wird nach einem erfolgreichen Lauf entfernt', () => { const ctx = setup(); assert.equal(runSync(ctx).status, 0); assert.equal(fs.existsSync(ctx.lockDir), false); }); test('das Sperrverzeichnis wird auch nach einem fehlgeschlagenen Build entfernt', () => { const ctx = setup(); const res = runSync(ctx, { STUB_NPM_FAIL: '1' }); assert.equal(res.status, 1); assert.match(res.out, /ERROR: /); assert.equal(fs.existsSync(ctx.lockDir), false); }); test('ein zweiter gleichzeitiger Lauf meldet „läuft bereits“, beendet mit 0 und baut nicht', async () => { const ctx = setup(); const first = runAsync(ctx, { STUB_NPM_BLOCK: '1' }); await waitFor(() => ctx.npmCalls().length >= 1, 'npm des ersten Laufs'); assert.equal(fs.existsSync(ctx.lockDir), true); const second = runSync(ctx); assert.equal(second.status, 0, second.out); assert.match(second.out, /läuft bereits/); assert.doesNotMatch(second.out, /Deployment abgeschlossen/); assert.equal(ctx.npmCalls().length, 1, 'der zweite Lauf darf npm nicht starten'); assert.equal(fs.existsSync(ctx.lockDir), true, 'der zweite Lauf darf die Sperre des ersten nicht freigeben'); fs.writeFileSync(ctx.release, ''); await first.done; assert.equal(first.status, 0, first.out); assert.match(first.out, /Deployment abgeschlossen/); assert.equal(fs.existsSync(ctx.lockDir), false); }); test('eine verwaiste Sperre (Prozess gibt es nicht mehr) wird übernommen', () => { const ctx = setup(); // Erster Lauf legt das Repo an. assert.equal(runSync(ctx).status, 0); fs.mkdirSync(ctx.lockDir); fs.writeFileSync(path.join(ctx.lockDir, 'pid'), '2147483646\n'); const res = runSync(ctx); assert.equal(res.status, 0, res.out); assert.match(res.out, /Deployment abgeschlossen/); assert.equal(fs.existsSync(ctx.lockDir), false); }); test('eine sehr alte Sperre ohne lebenden Besitzer-Prozess-Nachweis gilt als verwaist', () => { const ctx = setup(); assert.equal(runSync(ctx).status, 0); fs.mkdirSync(ctx.lockDir); // Kein pid-Eintrag, aber alt genug (über 60 Minuten). const old = new Date(Date.now() - 2 * 60 * 60 * 1000); fs.utimesSync(ctx.lockDir, old, old); const res = runSync(ctx); assert.equal(res.status, 0, res.out); assert.match(res.out, /Deployment abgeschlossen/); }); const busyboxApplets = SHELL_CMD === 'busybox' && 'busybox löst timeout als eigenes Applet auf, der Stub wird nicht gerufen'; test('npm ci und npm run build laufen unter timeout 900, wenn timeout verfügbar ist', { skip: busyboxApplets }, () => { const ctx = setup({ withTimeoutStub: true }); const res = runSync(ctx); assert.equal(res.status, 0, res.out); const calls = ctx.timeoutCalls(); assert.equal(calls.length, 2, calls.join('\n')); assert.match(calls[0], /^timeout 900 npm ci /); assert.match(calls[1], /^timeout 900 npm run build/); }); test('ein Build-Fehler unter timeout bleibt ein ERROR mit Exit-Code 1', () => { const ctx = setup({ withTimeoutStub: true }); const res = runSync(ctx, { STUB_NPM_FAIL: '1' }); assert.equal(res.status, 1); assert.match(res.out, /ERROR: npm ci fehlgeschlagen/); });