Files
Webklar-Kundenbereich/test/previewFinalFixes.test.js
T
2026-10-01 11:10:54 +00:00

292 lines
16 KiB
JavaScript

// Abschluss-Fixes Preview-Runner: C1 (Status bleibt), I1 (Label), I3/I4/I6 (Abgleich), Laengen
import { test, before, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import http from 'node:http'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import crypto from 'node:crypto'
import { createHmac } from 'node:crypto'
import express from 'express'
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
import { startFakeGitea } from './helpers/fakeGitea.js'
let aw, runner, gitea, tmp, pd, rec, config, baseUrl, server
const key = crypto.randomBytes(32)
const SHA = 'a'.repeat(40)
const SECRET = 's3cret'
const rs = { calls: [], runs: new Map(), next: 0, script: null, status: { dev: [], known: [], queue: 0 }, statusDown: false }
const done = (over = {}) => (job) => ({ state: 'done', detected: { kind: 'static', pm: 'npm' }, sha: job.sha, log: ['ok'], error: null, result: 'static', ...over })
before(async () => {
aw = await startFakeAppwrite({ seed: { websiteProjects: [] } })
runner = http.createServer((req, res) => {
let raw = ''
req.on('data', (c) => (raw += c))
req.on('end', () => {
const body = raw ? JSON.parse(raw) : null
rs.calls.push({ method: req.method, url: req.url, body })
res.setHeader('Content-Type', 'application/json')
const send = (code, obj) => { res.statusCode = code; res.end(JSON.stringify(obj)) }
if (req.method === 'GET' && req.url === '/status') return rs.statusDown ? send(500, { error: 'kaputt' }) : send(200, rs.status)
if (req.method === 'POST' && req.url === '/jobs/build') {
const runId = `run-${++rs.next}`
rs.runs.set(runId, rs.script(body))
return send(200, { runId })
}
const m = req.url.match(/^\/jobs\/(.+)$/)
if (m && req.method === 'GET') return rs.runs.has(m[1]) ? send(200, rs.runs.get(m[1])) : send(404, { error: 'x' })
if (req.method === 'PUT' && req.url.startsWith('/dev/')) return send(200, { state: 'sleeping' })
send(404, {})
})
})
await new Promise((r) => runner.listen(0, '127.0.0.1', r))
gitea = await startFakeGitea({
repos: [{ full_name: 'WEBklar/site-x' }, { full_name: 'WEBklar/site-y' }],
contents: {
'WEBklar/site-x:.webklar-preview.json': JSON.stringify({ enabled: true, subdomain: 'site-x', type: 'static', branch: 'main' }),
'WEBklar/site-y:.webklar-preview.json': JSON.stringify({ enabled: true, subdomain: 'site-y', type: 'static', branch: 'main' }),
},
})
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-final-'))
Object.assign(process.env, {
APPWRITE_ENDPOINT: aw.endpoint,
APPWRITE_API_KEY: 'test-key',
PREVIEW_DEPLOY_ROOT: path.join(tmp, 'previews'),
TRAEFIK_DYNAMIC_DIR: path.join(tmp, 'dynamic'),
GITEA_BASE_URL: gitea.baseUrl,
GITEA_API_TOKEN: 'tok',
GITEA_WEBHOOK_TOKEN: SECRET,
PREVIEW_RUNNER_URL: `http://127.0.0.1:${runner.address().port}`,
PREVIEW_RUNNER_TOKEN: 'sekret',
PREVIEW_ENV_KEY: key.toString('base64'),
})
;({ config } = await import('../server/config.js'))
config.previewRunner.enabled = true
config.previewRunner.logDir = path.join(tmp, 'logs')
pd = await import('../server/services/previewDeploy.js')
pd.runnerTiming.pollMs = 10
pd.runnerTiming.maxMs = 2000
rec = await import('../server/services/previewReconcile.js')
const { default: routes } = await import('../server/routes/webhook/gitea.js')
const app = express()
app.use(express.json({ limit: '2mb', verify: (req, _res, buf) => { req.rawBody = buf } }))
app.use('/webhook', routes)
server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)) })
baseUrl = `http://127.0.0.1:${server.address().port}`
})
after(async () => {
await new Promise((r) => server.close(r))
await new Promise((r) => runner.close(r))
await gitea.close()
await aw.close()
await fs.rm(tmp, { recursive: true, force: true })
})
beforeEach(() => {
rs.calls = []; rs.runs = new Map(); rs.script = done(); rs.status = { dev: [], known: [], queue: 0 }; rs.statusDown = false
config.previewRunner.enabled = true
})
async function push(repo, sha = SHA) {
const payload = {
ref: 'refs/heads/main',
repository: { full_name: repo, name: repo.split('/')[1], html_url: `${gitea.baseUrl}/${repo}` },
commits: [{ id: sha, message: 'x', author: { name: 'nico' } }],
pusher: { username: 'nico' },
after: sha,
}
const text = JSON.stringify(payload)
const sig = createHmac('sha256', SECRET).update(text).digest('hex')
const res = await fetch(`${baseUrl}/webhook/gitea`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Gitea-Signature': sig }, body: text })
return res.status
}
const byRepo = (repo) => aw.all('websiteProjects').find((d) => d.repoFullName === repo)
// ---- C1 ----
test('C1: Push mit Runner an behält status/provisioningStatus eines bereiten Projekts (auch bei Build-Fehler)', async () => {
const { createDocument } = await import('../server/services/appwriteAdmin.js')
await createDocument('websiteProjects', { repoFullName: 'WEBklar/site-x', projectName: 'X', subdomain: 'site-x', status: 'deployed', provisioningStatus: 'ready', previewState: 'online' }, 'cx')
rs.script = done({ state: 'failed', result: null, error: 'Build kaputt' })
assert.equal(await push('WEBklar/site-x'), 200)
let d = byRepo('WEBklar/site-x')
assert.equal(d.status, 'deployed')
assert.equal(d.provisioningStatus, 'ready')
await pd.waitForRunFollowers()
d = byRepo('WEBklar/site-x')
assert.equal(d.previewState, 'failed')
assert.equal(d.status, 'deployed') // alte Preview bleibt online: nie herabstufen
assert.equal(d.provisioningStatus, 'ready')
})
test('C1: neues Projekt per Push wird nach Verfolger-Erfolg bereit', async () => {
assert.equal(await push('WEBklar/site-y'), 200)
let d = byRepo('WEBklar/site-y')
assert.equal(d.provisioningStatus, 'pending')
await pd.waitForRunFollowers()
d = byRepo('WEBklar/site-y')
assert.equal(d.previewState, 'online')
assert.equal(d.status, 'deployed')
assert.equal(d.provisioningStatus, 'ready')
})
test('C1: Verfolger ist vor der Anlage des Projektdokuments fertig -> Ergebnis geht nicht verloren', async () => {
const { createDocument } = await import('../server/services/appwriteAdmin.js')
rs.script = done()
await pd.runDeploy('WEBklar/spaet', 'main', { subdomain: 'spaet', type: 'static', branch: 'main' }, { sha: SHA })
// Webhook legt das Dokument erst nach dem Lauf an (simulierte Verzoegerung, Lauf ist nach ~10 ms fertig)
await new Promise((r) => setTimeout(r, 80))
await createDocument('websiteProjects', { repoFullName: 'WEBklar/spaet', projectName: 'S', subdomain: 'spaet', status: 'pending', provisioningStatus: 'pending' }, 'spaet')
await pd.waitForRunFollowers()
const d = aw.get('websiteProjects', 'spaet')
assert.equal(d.provisioningStatus, 'ready')
assert.equal(d.status, 'deployed')
assert.equal(d.previewState, 'online')
})
test('C1: deployKeepsStatus nur mit Runner an', () => {
assert.equal(pd.deployKeepsStatus({ deployed: false, queued: true }), true)
assert.equal(pd.deployKeepsStatus({ deployed: false, skipped: true }), true)
assert.equal(pd.deployKeepsStatus({ deployed: true }), false)
config.previewRunner.enabled = false
assert.equal(pd.deployKeepsStatus({ deployed: false, queued: true }), false)
})
// ---- I1 ----
test('I1: Build fehlgeschlagen -> dev-Rückfall wird als dev:<pm> gespeichert', async () => {
const { createDocument } = await import('../server/services/appwriteAdmin.js')
const doc = await createDocument('websiteProjects', { repoFullName: 'WEBklar/auto-dev', projectName: 'A' }, 'i1')
rs.script = done({ result: 'dev', detected: { kind: 'build', pm: 'pnpm' } })
await pd.runDeploy('WEBklar/auto-dev', 'main', { subdomain: 'auto-dev', type: 'static', branch: 'main' }, { project: doc, sha: SHA })
await pd.waitForRunFollowers()
assert.equal(aw.get('websiteProjects', 'i1').previewDetected, 'dev:pnpm')
assert.equal(aw.get('websiteProjects', 'i1').previewState, 'sleeping')
assert.equal(pd.detectedLabel({ kind: 'build', pm: 'npm' }, 'dev'), 'dev:npm')
assert.equal(pd.detectedLabel({ kind: 'build', pm: 'npm' }, 'build'), 'build:npm')
assert.equal(pd.detectedLabel({ kind: 'static' }, undefined), 'static')
})
// ---- Laengen ----
test('previewError wird auf 500 Zeichen gekürzt (Patch geht nicht verloren)', async () => {
const { createDocument } = await import('../server/services/appwriteAdmin.js')
const doc = await createDocument('websiteProjects', { repoFullName: 'WEBklar/lang', projectName: 'L' }, 'lang')
rs.script = done({ state: 'failed', result: null, error: 'x'.repeat(2000) })
await pd.runDeploy('WEBklar/lang', 'main', { subdomain: 'lang', type: 'static', branch: 'main' }, { project: doc, sha: SHA })
await pd.waitForRunFollowers()
const d = aw.get('websiteProjects', 'lang')
assert.equal(d.previewState, 'failed')
assert.equal(d.previewError.length, 500)
})
// ---- R18 Abgleich ----
test('I3: building ohne Verfolger und > 70 min => failed; jung oder mit Verfolger bleibt', async () => {
const { createDocument } = await import('../server/services/appwriteAdmin.js')
await createDocument('websiteProjects', { repoFullName: 'WEBklar/r-alt', subdomain: 'r-alt', previewState: 'building' }, 'r1')
await createDocument('websiteProjects', { repoFullName: 'WEBklar/r-jung', subdomain: 'r-jung', previewState: 'building' }, 'r2')
const res = await rec.reconcilePreviews({ now: Date.now() + 80 * 60 * 1000 }) // "jetzt" in 80 min: beide dann aelter als 70 min
assert.equal(res.staleFailed >= 2, true)
assert.equal(aw.get('websiteProjects', 'r1').previewState, 'failed')
assert.match(aw.get('websiteProjects', 'r1').previewError, /Status unbekannt/)
})
test('I3: junges building bleibt, building mit laufendem Verfolger bleibt', async () => {
const { createDocument } = await import('../server/services/appwriteAdmin.js')
await createDocument('websiteProjects', { repoFullName: 'WEBklar/j1', subdomain: 'j1', previewState: 'building' }, 'j1')
await rec.reconcilePreviews()
assert.equal(aw.get('websiteProjects', 'j1').previewState, 'building')
// Verfolger laeuft (Lauf haengt in 'running')
const { createDocument: cd } = await import('../server/services/appwriteAdmin.js')
const doc = await cd('websiteProjects', { repoFullName: 'WEBklar/j2', subdomain: 'j2', projectName: 'J' }, 'j2')
rs.script = () => ({ state: 'running', log: [] })
await pd.runDeploy('WEBklar/j2', 'main', { subdomain: 'j2', type: 'static', branch: 'main' }, { project: doc, sha: SHA })
assert.equal(pd.hasLiveFollower('WEBklar/j2'), true)
const res = await rec.reconcilePreviews({ now: Date.now() + 80 * 60 * 1000 })
assert.equal(aw.get('websiteProjects', 'j2').previewState, 'building')
assert.ok(res.staleFailed >= 0)
// aufraeumen: Lauf beenden
rs.runs.set('run-' + rs.next, { state: 'done', detected: { kind: 'static' }, sha: SHA, log: [], result: 'static' })
await pd.waitForRunFollowers()
})
test('I6: Dev-Zustand folgt dem Runner; Modus off bleibt unberührt', async () => {
const { createDocument } = await import('../server/services/appwriteAdmin.js')
const base = { previewDetected: 'dev:npm', previewBranch: 'main', previewBuiltSha: SHA, previewMode: 'auto' }
await createDocument('websiteProjects', { ...base, repoFullName: 'WEBklar/d-on', subdomain: 'd-on', previewState: 'starting' }, 'd1')
await createDocument('websiteProjects', { ...base, repoFullName: 'WEBklar/d-gone', subdomain: 'd-gone', previewState: 'online' }, 'd2')
await createDocument('websiteProjects', { ...base, repoFullName: 'WEBklar/d-off', subdomain: 'd-off', previewState: 'sleeping', previewMode: 'off' }, 'd3')
await createDocument('websiteProjects', { ...base, repoFullName: 'WEBklar/d-fail', subdomain: 'd-fail', previewState: 'online' }, 'd4')
rs.status = { dev: [{ sub: 'd-on', state: 'online' }, { sub: 'd-fail', state: 'failed' }], known: ['d-on', 'd-gone', 'd-off', 'd-fail'], queue: 0 }
const res = await rec.reconcilePreviews()
assert.equal(aw.get('websiteProjects', 'd1').previewState, 'online')
assert.equal(aw.get('websiteProjects', 'd2').previewState, 'sleeping')
assert.equal(aw.get('websiteProjects', 'd3').previewState, 'sleeping')
assert.equal(aw.get('websiteProjects', 'd4').previewState, 'sleeping')
assert.equal(res.reregistered, 0)
})
test('Abgleich: oeffentliche auto-Seite mit altem dev:* wird nicht angefasst und nicht registriert', async () => {
const { createDocument } = await import('../server/services/appwriteAdmin.js')
await createDocument('websiteProjects', {
previewDetected: 'dev:npm', previewBranch: 'main', previewBuiltSha: SHA, isPublic: true, previewMode: 'auto',
repoFullName: 'WEBklar/d-pub', subdomain: 'd-pub', previewState: 'online',
}, 'dpub')
rs.status = { dev: [], known: [], queue: 0 }
rs.calls = []
const res = await rec.reconcilePreviews()
assert.equal(aw.get('websiteProjects', 'dpub').previewState, 'online')
assert.equal(rs.calls.filter((c) => c.method === 'PUT' && c.url === '/dev/d-pub').length, 0)
assert.ok(res.reregistered === 0 || !rs.calls.some((c) => c.url === '/dev/d-pub'))
})
test('I4: dem Runner unbekannte Dev-Spezifikation wird erneut hinterlegt', async () => {
const { createDocument } = await import('../server/services/appwriteAdmin.js')
await createDocument('websiteProjects', {
previewDetected: 'dev:npm', previewBranch: 'main', previewBuiltSha: SHA, previewMode: 'dev',
repoFullName: 'WEBklar/d-neu', subdomain: 'd-neu', previewState: 'sleeping',
previewEnv: (await import('../server/services/previewEnv.js')).encryptEnv({ K: 'wert1234' }, key),
}, 'd5')
rs.status = { dev: [], known: [], queue: 0 }
const res = await rec.reconcilePreviews()
const put = rs.calls.filter((c) => c.method === 'PUT' && c.url === '/dev/d-neu')
assert.equal(put.length, 1)
assert.deepEqual(put[0].body, { sub: 'd-neu', repo: 'WEBklar/d-neu', sha: SHA, branch: 'main', mode: 'dev', env: { K: 'wert1234' } })
assert.ok(res.reregistered >= 1)
assert.equal(aw.get('websiteProjects', 'd5').previewState, 'sleeping')
})
test('Abgleich: Runner nicht erreichbar / Schalter aus wirft nie und ändert nichts', async () => {
const errors = console.error
console.error = () => {}
try {
rs.statusDown = true
const r1 = await rec.reconcilePreviews()
assert.ok(r1.error)
assert.equal(aw.get('websiteProjects', 'd2').previewState, 'sleeping')
assert.equal(await rec.runReconcile().then((r) => typeof r), 'object')
} finally { console.error = errors }
config.previewRunner.enabled = false
assert.deepEqual(await rec.reconcilePreviews(), { skipped: true })
})
// ---- R19 ----
test('R19: Verfolger meldet dev immer mit mode dev; registerDevRound liefert identischen Auftrag', async () => {
const { createDocument } = await import('../server/services/appwriteAdmin.js')
const envLib = await import('../server/services/previewEnv.js')
const doc = await createDocument('websiteProjects', {
repoFullName: 'WEBklar/r19', projectName: 'R', subdomain: 'r19', previewMode: 'auto',
previewEnv: envLib.encryptEnv({ K: 'wert1234' }, key),
}, 'r19')
rs.script = done({ result: 'dev', detected: { kind: 'build', pm: 'npm' } })
await pd.runDeploy('WEBklar/r19', 'main', { subdomain: 'r19', type: 'static', branch: 'main' }, { project: doc, sha: SHA })
await pd.waitForRunFollowers()
assert.equal(rs.calls.find((c) => c.method === 'POST' && c.url === '/jobs/build').body.mode, 'auto')
const first = rs.calls.find((c) => c.method === 'PUT' && c.url === '/dev/r19').body
assert.equal(first.mode, 'dev')
rs.calls = []
await pd.registerDevRound([aw.get('websiteProjects', 'r19')])
const second = rs.calls.find((c) => c.method === 'PUT' && c.url === '/dev/r19').body
assert.deepEqual(second, first)
})