diff --git a/server/index.js b/server/index.js index defe08f..270c1b4 100644 --- a/server/index.js +++ b/server/index.js @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url' import { config, assertServerConfig, WOMS_DATABASE_ID } from './config.js' import { verifyDatabaseAccess } from './services/appwriteAdmin.js' import { syncPreviewTlsOnStartup } from './services/previewDeploy.js' +import { runReconcile } from './services/previewReconcile.js' import { requireSession, sessionMiddleware } from './middleware/session.js' import authRoutes from './routes/auth.js' import projectsRoutes from './routes/projects.js' @@ -172,6 +173,12 @@ const server = app.listen(config.port, '0.0.0.0', () => { ) setTimeout(repoAssign, 60 * 1000).unref() setInterval(repoAssign, 10 * 60 * 1000).unref() + + // Preview-Runner: Zustaende mit dem Runner abgleichen (alle 2 Minuten, nur wenn eingeschaltet) + if (config.previewRunner.enabled) { + setTimeout(runReconcile, 30 * 1000).unref() + setInterval(runReconcile, 2 * 60 * 1000).unref() + } }) server.on('error', (err) => { diff --git a/server/routes/webhook/gitea.js b/server/routes/webhook/gitea.js index ffb576f..185dadc 100644 --- a/server/routes/webhook/gitea.js +++ b/server/routes/webhook/gitea.js @@ -6,6 +6,7 @@ import { fetchPreviewConfig, defaultPreviewConfig, runDeploy, + deployKeepsStatus, buildPreviewUrl, } from '../../services/previewDeploy.js' import { ensureRepoPreviewConfig } from '../../services/giteaAdmin.js' @@ -143,7 +144,9 @@ router.post('/gitea', async (req, res) => { if (subdomain) projectPayload.subdomain = subdomain if (previewUrl) projectPayload.previewUrl = previewUrl - if (deployResult.deployed) { + if (deployKeepsStatus(deployResult) && hasPreview) { + // Preview-Runner (C1): Status/Bereitschaft bleiben unveraendert, der Verfolger setzt sie + } else if (deployResult.deployed) { projectPayload.status = 'deployed' projectPayload.provisioningStatus = 'ready' } else if (hasPreview) { diff --git a/server/services/appwriteAdmin.js b/server/services/appwriteAdmin.js index 2ca1576..0343c26 100644 --- a/server/services/appwriteAdmin.js +++ b/server/services/appwriteAdmin.js @@ -263,6 +263,8 @@ export async function upsertWebsiteProjectByRepo(repoFullName, data) { ...payload, customerId: payload.customerId ?? '', ticketId: payload.ticketId ?? '', + status: payload.status ?? 'pending', + provisioningStatus: payload.provisioningStatus ?? 'pending', projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName, createdAt: now, documentId: ID.unique(), diff --git a/server/services/giteaAdmin.js b/server/services/giteaAdmin.js index 1257706..a421568 100644 --- a/server/services/giteaAdmin.js +++ b/server/services/giteaAdmin.js @@ -8,6 +8,7 @@ import { } from './appwriteAdmin.js' import { runDeploy, + deployKeepsStatus, buildPreviewUrl, removeDeploy, syncPreviewTls, @@ -334,10 +335,14 @@ export async function createProjectFromTemplate({ subdomain: safeSubdomain, previewUrl, projectType: hasWebsite ? 'website' : 'repo', - status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created', - provisioningStatus: hasWebsite - ? (deployResult.deployed ? 'ready' : 'deploy_failed') - : 'ready', + ...(hasWebsite && deployKeepsStatus(deployResult) + ? {} // Preview-Runner (C1): Status setzt der Verfolger + : { + status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created', + provisioningStatus: hasWebsite + ? (deployResult.deployed ? 'ready' : 'deploy_failed') + : 'ready', + }), }) return { @@ -494,10 +499,14 @@ export async function updateWebsiteProject(projectId, { projectName, subdomain, projectType: nextType, isPublic, isTemplate, - status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created', - provisioningStatus: hasWebsite - ? (deployResult.deployed ? 'ready' : (deployResult.error ? 'deploy_failed' : 'pending')) - : 'ready', + ...(hasWebsite && deployKeepsStatus(deployResult) + ? {} // Preview-Runner (C1): Status setzt der Verfolger + : { + status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created', + provisioningStatus: hasWebsite + ? (deployResult.deployed ? 'ready' : (deployResult.error ? 'deploy_failed' : 'pending')) + : 'ready', + }), updatedAt: new Date().toISOString(), }) diff --git a/server/services/previewDeploy.js b/server/services/previewDeploy.js index a6c2962..472a177 100644 --- a/server/services/previewDeploy.js +++ b/server/services/previewDeploy.js @@ -84,7 +84,7 @@ export function devSubdomainsFromHostsFile(content) { async function getDevProjects() { try { const docs = await listDocuments(config.collections.websiteProjects, [ - Query.equal('previewState', ['sleeping', 'starting', 'online']), + Query.equal('previewState', ['sleeping', 'starting', 'online', 'building', 'failed']), Query.limit(500), ]) return docs.filter( @@ -463,7 +463,7 @@ export async function waitForDevRegistration() { let pendingProjects = null -async function registerDevRound(projects) { +export async function registerDevRound(projects) { const key = envKey() await Promise.allSettled( projects.map(async (project) => { @@ -541,14 +541,27 @@ const SHA_RE = /^[0-9a-f]{7,40}$/ const KEY_MISSING = 'Preview-Variablen nicht entschlüsselbar (PREVIEW_ENV_KEY fehlt)' const KEY_BROKEN = 'Preview-Variablen nicht entschlüsselbar' +/** Gibt es fuer das Repo einen laufenden Verfolger in diesem Prozess? (Abgleich, R18) */ +export const hasLiveFollower = (repoFullName) => latestRun.has(repoFullName) + /** Wartet auf alle laufenden Hintergrund-Verfolger (Tests, geordnetes Beenden). */ export async function waitForRunFollowers() { while (followers.size) await Promise.allSettled([...followers]) } +/** + * C1: Mit aktivem Runner liefert runDeploy nur "queued"/"skipped"/Fehler des Auftrags - das ist + * kein Deploy-Ergebnis. Aufrufer duerfen dann status/provisioningStatus NICHT anfassen + * (sonst sperrt auth.js "not_ready" ein bereits bereites Projekt aus). Der Verfolger setzt + * bei Erfolg 'deployed'/'ready'. + */ +export function deployKeepsStatus(deployResult) { + return Boolean(config.previewRunner.enabled && deployResult && !deployResult.deployed) +} + // previewDetected: static | build: | dev: (der Runner liefert kein Framework) export function detectedLabel(detected, result) { - const kind = detected?.kind || result + const kind = result || detected?.kind if (kind === 'static') return 'static' const pm = detected?.pm || 'npm' return kind === 'dev' ? `dev:${pm}` : `build:${pm}` @@ -567,7 +580,19 @@ async function findProject(repoFullName) { } } +// Attributgroessen (Appwrite lehnt laengere Werte ab und der Patch ginge verloren) +const ATTR_MAX = { previewError: 500, previewDetected: 40, previewBranch: 100 } + +function clampAttrs(data) { + const out = { ...data } + for (const [key, max] of Object.entries(ATTR_MAX)) { + if (typeof out[key] === 'string' && out[key].length > max) out[key] = out[key].slice(0, max) + } + return out +} + async function patchProject(repoFullName, project, data) { + data = clampAttrs(data) const doc = project?.$id ? project : await findProject(repoFullName) if (!doc?.$id) return null try { @@ -630,6 +655,9 @@ async function followRun({ repoFullName, project, token, runId, subdomain, job, previewError: warning || '', previewBranch: job.branch, previewBuiltSha: run.sha || job.sha, + // Wie der Legacy-Erfolg: Projekt ist bereit (C1). Ein Fehlschlag senkt nie herab. + status: 'deployed', + provisioningStatus: 'ready', } if (run.result === 'dev') { await patchProject(repoFullName, project, { ...base, previewState: 'sleeping' }) diff --git a/server/services/previewReconcile.js b/server/services/previewReconcile.js new file mode 100644 index 0000000..7f55050 --- /dev/null +++ b/server/services/previewReconcile.js @@ -0,0 +1,77 @@ +// R18: Abgleich der Preview-Zustaende mit dem Runner (alle 2 Minuten, nur mit PREVIEW_RUNNER=on). +// (a) 'building' ohne laufenden Verfolger und aelter als 70 Minuten => 'failed' +// (b) Dev-Projekte: Zustand laut Runner (laeuft => online/starting, sonst sleeping); Modus 'off' bleibt unberuehrt +// (c) Dev-Spezifikationen, die der Runner nicht kennt (z. B. nach Neustart), erneut hinterlegen +// Wirft nie: Fehler werden protokolliert und im Ergebnis gemeldet. +import { config } from '../config.js' +import { listDocuments, updateDocument, Query } from './appwriteAdmin.js' +import * as runnerClient from './previewRunnerClient.js' +import { hasLiveFollower, registerDevRound } from './previewDeploy.js' + +export const STALE_BUILD_MS = 70 * 60 * 1000 +const STALE_TEXT = 'Status unbekannt – bitte neu bauen' + +const isDev = (d) => String(d.previewDetected || '').startsWith('dev') + +export async function reconcilePreviews({ now = Date.now() } = {}) { + if (!config.previewRunner.enabled) return { skipped: true } + const result = { staleFailed: 0, stateUpdated: 0, reregistered: 0 } + let docs + try { + docs = await listDocuments(config.collections.websiteProjects, [ + Query.equal('previewState', ['building', 'sleeping', 'starting', 'online']), + Query.limit(500), + ]) + } catch (err) { + console.error('[previewReconcile] Projekte nicht lesbar:', err.message) + return { ...result, error: err.message } + } + + const patch = async (doc, data) => { + try { await updateDocument(config.collections.websiteProjects, doc.$id, data); return true } catch (err) { + console.error('[previewReconcile] Update fehlgeschlagen:', err.message) + return false + } + } + + // (a) + for (const doc of docs) { + if (doc.previewState !== 'building' || !doc.repoFullName) continue + if (hasLiveFollower(doc.repoFullName)) continue + const since = Date.parse(doc.$updatedAt || doc.$createdAt || '') + if (!Number.isFinite(since) || now - since <= STALE_BUILD_MS) continue + if (await patch(doc, { previewState: 'failed', previewError: STALE_TEXT })) result.staleFailed++ + } + + // (b) + (c) + const devDocs = docs.filter((d) => isDev(d) && d.previewMode !== 'off' && d.previewState !== 'building' && d.subdomain) + if (!devDocs.length) return result + let st + try { st = await runnerClient.status() } catch (err) { + console.error('[previewReconcile] Runner-Status nicht abrufbar:', err.message) + return { ...result, error: err.message } + } + const entries = new Map((st?.dev || []).map((e) => [e.sub, e])) + const known = new Set([...(st?.known || []), ...entries.keys()]) + const unknown = [] + for (const doc of devDocs) { + const entry = entries.get(doc.subdomain) + const want = entry?.state === 'online' ? 'online' : entry?.state === 'starting' ? 'starting' : 'sleeping' + if (doc.previewState !== want && (await patch(doc, { previewState: want }))) result.stateUpdated++ + if (!known.has(doc.subdomain)) unknown.push(doc) + } + if (unknown.length) { + try { await registerDevRound(unknown); result.reregistered = unknown.length } catch (err) { + console.error('[previewReconcile] Registrierung fehlgeschlagen:', err.message) + } + } + return result +} + +/** Fuer setInterval: nie werfen. */ +export function runReconcile() { + return reconcilePreviews().catch((err) => { + console.error('[previewReconcile] Lauf fehlgeschlagen:', err.message) + return { error: err.message } + }) +} diff --git a/test/previewDevRouter.test.js b/test/previewDevRouter.test.js index 4bd93a3..5d3d7f2 100644 --- a/test/previewDevRouter.test.js +++ b/test/previewDevRouter.test.js @@ -143,8 +143,9 @@ test('Dev-Projekt bekommt Runner-Service (auch ohne Ordner), static bleibt nginx assert.match(dev, /preview-sites-redirect@docker/) assert.match(routerBlock(content, 'statisch'), /preview-nginx@docker/) assert.doesNotMatch(routerBlock(content, 'statisch'), /preview-runner/) - assert.doesNotMatch(content, /devoff/) // Zustand failed => kein Dev-Host - assert.deepEqual([...pd.devSubdomainsFromHostsFile(content)], ['devapp']) + // I5: auch ein fehlgeschlagener/neu bauender Dev-Host bleibt beim Runner (kein Umschalten auf nginx) + assert.match(routerBlock(content, 'devoff'), /preview-runner@docker/) + assert.deepEqual([...pd.devSubdomainsFromHostsFile(content)].sort(), ['devapp', 'devoff']) }) test('Appwrite nicht erreichbar: Dev-Hosts aus der letzten Datei bleiben', async () => { @@ -159,7 +160,7 @@ test('Appwrite nicht erreichbar: Dev-Hosts aus der letzten Datei bleiben', async assert.equal(result.publicSource, 'previous-file') } finally { console.error = errors } await pd.waitForDevRegistration() - assert.deepEqual([...pd.devSubdomainsFromHostsFile(await read())], ['devapp']) + assert.deepEqual([...pd.devSubdomainsFromHostsFile(await read())].sort(), ['devapp', 'devoff']) assert.match(routerBlock(await read(), 'devapp'), /preview-runner@docker/) assert.equal(puts.length, 1) // nur die Runde mit Appwrite-Daten }) @@ -256,3 +257,14 @@ test('Registrierung: laeuft eine Runde, folgt genau eine Wiederholung', async () await pd.waitForDevRegistration() assert.equal(puts.length, 2) }) + +test('I5: Dev-Projekt im Zustand building bleibt beim Runner, static im Zustand building nicht', async () => { + const { createDocument } = await import('../server/services/appwriteAdmin.js') + await createDocument('websiteProjects', { previewMode: 'auto', subdomain: 'baut', repoFullName: 'JUSN/baut', previewState: 'building', previewDetected: 'dev:npm' }, 'p5') + await createDocument('websiteProjects', { previewMode: 'auto', subdomain: 'baustatic', repoFullName: 'JUSN/baustatic', previewState: 'building', previewDetected: 'static' }, 'p6') + await pd.syncPreviewTls() + await pd.waitForDevRegistration() + const content = await read() + assert.match(routerBlock(content, 'baut'), /preview-runner@docker/) + assert.ok(!pd.devSubdomainsFromHostsFile(content).has('baustatic')) +}) diff --git a/test/previewFinalFixes.test.js b/test/previewFinalFixes.test.js new file mode 100644 index 0000000..f139bbb --- /dev/null +++ b/test/previewFinalFixes.test.js @@ -0,0 +1,243 @@ +// 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: 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: 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('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 }) +})