Compare commits

...
3 Commits
Author SHA1 Message Date
ClaudeandClaude Sonnet 5.5 c1818fadcf Preview: Block ausserhalb des Formulars, Validierung und Feinschliff
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 21:08:52 +00:00
ClaudeandClaude Sonnet 5.5 2de846436b Preview: Block im WOMS
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 21:06:53 +00:00
ClaudeandClaude Sonnet 5.5 0f884e41e8 Preview: Anzeige-Logik und API-Client
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 21:04:31 +00:00
9 changed files with 594 additions and 1 deletions

No files matched your search

+20
View File
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { FaCodeBranch } from 'react-icons/fa' import { FaCodeBranch } from 'react-icons/fa'
import { FaLinkSlash } from 'react-icons/fa6' import { FaLinkSlash } from 'react-icons/fa6'
import PreviewBlock from './PreviewBlock'
import GiteaLinkButton from './GiteaLinkButton' import GiteaLinkButton from './GiteaLinkButton'
import PreviewLinkButton from './PreviewLinkButton' import PreviewLinkButton from './PreviewLinkButton'
import { suggestSubdomain } from '../lib/subdomain' import { suggestSubdomain } from '../lib/subdomain'
@@ -39,6 +40,7 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
const [previewMessage, setPreviewMessage] = useState('') const [previewMessage, setPreviewMessage] = useState('')
const [pushBusy, setPushBusy] = useState(false) const [pushBusy, setPushBusy] = useState(false)
const [pushMessage, setPushMessage] = useState('') const [pushMessage, setPushMessage] = useState('')
const [showPreviewBlock, setShowPreviewBlock] = useState(false)
const openPreviewInput = () => { const openPreviewInput = () => {
setSubdomain(suggestSubdomain(customerName, project.projectName)) setSubdomain(suggestSubdomain(customerName, project.projectName))
@@ -110,6 +112,24 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
<StatusBadge label="Provisioning" value={project.provisioningStatus} /> <StatusBadge label="Provisioning" value={project.provisioningStatus} />
</div> </div>
{showActions && project.subdomain && (
<div style={{ marginTop: 10 }}>
<button
type="button"
className="btn btn-sm"
aria-expanded={showPreviewBlock}
onClick={() => setShowPreviewBlock((v) => !v)}
>
Preview {showPreviewBlock ? '▴' : '▾'}
</button>
{showPreviewBlock && (
<div style={{ marginTop: 8 }}>
<PreviewBlock key={project.$id} projectId={project.$id} />
</div>
)}
</div>
)}
{showActions && ( {showActions && (
<div style={{ marginTop: 10 }}> <div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
+297
View File
@@ -0,0 +1,297 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { MODE_OPTIONS, previewStatus, modeHint } from '../lib/previewView'
import { isValidEnvName, buildEnvPayload, isDevPreview, dotColor, envValueTooLong, MAX_ENV_VARS } from '../lib/previewEnv'
import {
fetchPreview,
setPreviewMode,
savePreviewEnv,
rebuildPreview,
startPreviewDev,
stopPreviewDev,
fetchPreviewLog,
} from '../lib/previewApi'
const STATE_OF_COLOR = { green: 'online', yellow: 'building', red: 'failed', grey: 'off' }
const NOT_ACTIVE = 'Preview-Runner ist nicht aktiv'
const logDate = (at) => {
const d = new Date(at)
if (!at || Number.isNaN(d.getTime())) return String(at || '')
return d.toLocaleString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
export default function PreviewBlock({ projectId }) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [inactive, setInactive] = useState(false)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [logText, setLogText] = useState('')
const [logFile, setLogFile] = useState('')
const [showLogs, setShowLogs] = useState(false)
// Variablen: Änderungen werden gesammelt und mit "Variablen speichern" übertragen
const [pendingSet, setPendingSet] = useState({})
const [pendingRemove, setPendingRemove] = useState([])
const [newName, setNewName] = useState('')
const [newValue, setNewValue] = useState('')
const [editName, setEditName] = useState('')
const [editValue, setEditValue] = useState('')
const [envError, setEnvError] = useState('')
const alive = useRef(true)
useEffect(() => {
alive.current = true
return () => { alive.current = false }
}, [])
const handleError = useCallback((err) => {
if (!alive.current) return
if (err?.status === 409 && err.message === NOT_ACTIVE) {
setInactive(true)
return
}
setError(err?.message || 'Unbekannter Fehler')
}, [])
const load = useCallback(async () => {
try {
const r = await fetchPreview(projectId)
if (!alive.current) return
setData(r)
setInactive(false)
setError('')
} catch (err) {
handleError(err)
} finally {
if (alive.current) setLoading(false)
}
}, [projectId, handleError])
useEffect(() => { load() }, [load])
// Solange gebaut/gestartet wird: alle 5 s nachladen
const state = data?.state
useEffect(() => {
if (state !== 'building' && state !== 'starting') return undefined
const t = setInterval(load, 5000)
return () => clearInterval(t)
}, [state, load])
const run = async (fn) => {
setBusy(true)
setError('')
try {
await fn()
await load()
} catch (err) {
handleError(err)
} finally {
if (alive.current) setBusy(false)
}
}
const openLog = (runFile) => {
setLogFile(runFile)
setLogText('')
if (!runFile) return
setBusy(true)
setError('')
fetchPreviewLog(projectId, runFile)
.then((r) => {
if (alive.current) setLogText(Array.isArray(r.lines) ? r.lines.join('\n') : String(r.log ?? r.text ?? ''))
})
.catch((err) => {
handleError(err)
if (alive.current) setLogText(`Fehler: ${err?.message || 'Protokoll konnte nicht geladen werden'}`)
})
.finally(() => { if (alive.current) setBusy(false) })
}
const addPending = (name, value) => {
setPendingSet((p) => ({ ...p, [name]: value }))
setPendingRemove((r) => r.filter((x) => x !== name))
}
const handleAdd = () => {
if (!isValidEnvName(newName)) {
setEnvError('Name nur mit A–Z, 0–9 und _ (nicht mit einer Ziffer beginnen).')
return
}
if (!newValue) {
setEnvError('Bitte einen Wert eingeben.')
return
}
if (envValueTooLong(newValue)) {
setEnvError('Der Wert ist zu lang (max. 4096 Byte).')
return
}
if (!names.includes(newName) && names.length >= MAX_ENV_VARS) {
setEnvError(`Maximal ${MAX_ENV_VARS} Variablen möglich.`)
return
}
setEnvError('')
addPending(newName, newValue)
setNewName('')
setNewValue('')
}
const handleRemove = (name) => {
setPendingSet((p) => {
const { [name]: _drop, ...rest } = p
return rest
})
if ((data?.envNames || []).includes(name)) setPendingRemove((r) => [...new Set([...r, name])])
}
const handleChange = () => {
if (!editValue) {
setEnvError('Bitte einen neuen Wert eingeben.')
return
}
if (envValueTooLong(editValue)) {
setEnvError('Der Wert ist zu lang (max. 4096 Byte).')
return
}
setEnvError('')
addPending(editName, editValue)
setEditName('')
setEditValue('')
}
const handleSaveEnv = () => run(async () => {
await savePreviewEnv(projectId, buildEnvPayload(pendingSet, pendingRemove))
setPendingSet({})
setPendingRemove([])
})
const enter = (fn) => (e) => {
if (e.key === 'Enter') {
e.preventDefault()
fn()
}
}
if (loading) return <div className="text-grey" style={{ fontSize: 12 }}>Lädt …</div>
const view = data ? { ...data } : { state: 'off' }
const status = previewStatus(view)
const mode = data?.mode || 'auto'
const hint = modeHint(mode, data?.detected)
const names = [...new Set([...(data?.envNames || []), ...Object.keys(pendingSet)])].filter((n) => !pendingRemove.includes(n))
const dirty = Object.keys(pendingSet).length > 0 || pendingRemove.length > 0
const dev = isDevPreview(data)
const logs = [...(data?.logs || [])].sort((a, b) => new Date(b.at) - new Date(a.at)).slice(0, 5)
const small = { fontSize: 12 }
return (
<div style={{ fontSize: 13 }}>
{data && (
<>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<label className="text-grey" htmlFor={`pv-mode-${projectId}`} style={small}>Modus</label>
<select
id={`pv-mode-${projectId}`}
className="form-control"
style={{ maxWidth: 200 }}
value={mode}
disabled={busy || inactive}
onChange={(e) => run(() => setPreviewMode(projectId, e.target.value))}
>
{MODE_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
{hint && <div className="text-grey" style={{ ...small, marginTop: 4 }}>{hint}</div>}
<div style={{ marginTop: 8, display: 'flex', gap: 6, alignItems: 'flex-start' }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dotColor(STATE_OF_COLOR[status.color]), display: 'inline-block', flexShrink: 0, marginTop: 4 }} />
<span style={{ overflowWrap: 'anywhere' }}>{status.text}</span>
</div>
{data.state === 'failed' && data.error && (
<div style={{ color: '#f87171', ...small, marginTop: 4, overflowWrap: 'anywhere' }}>{data.error}</div>
)}
</>
)}
{inactive && <div className="text-grey" style={{ ...small, marginTop: 8 }}>{NOT_ACTIVE}.</div>}
{error && <div style={{ color: '#f87171', ...small, marginTop: 8, overflowWrap: 'anywhere' }}>{error}</div>}
{data && !inactive && (
<>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 10 }}>
<button type="button" className="btn btn-sm" disabled={busy || state === 'building'} onClick={() => run(() => rebuildPreview(projectId))}>
Neu bauen
</button>
<button type="button" className="btn btn-sm" disabled={busy || logs.length === 0} onClick={() => setShowLogs((v) => !v)}>
Protokoll ansehen
</button>
{dev && (
<>
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => run(() => startPreviewDev(projectId))}>
Jetzt starten
</button>
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => run(() => stopPreviewDev(projectId))}>
Stoppen
</button>
</>
)}
</div>
{showLogs && (
<div style={{ marginTop: 8 }}>
<select
className="form-control"
style={{ maxWidth: '100%' }}
aria-label="Lauf wählen"
value={logFile}
disabled={busy}
onChange={(e) => openLog(e.target.value)}
>
<option value="">Lauf wählen …</option>
{logs.map((l) => <option key={l.runFile} value={l.runFile}>{logDate(l.at)}</option>)}
</select>
{logFile && (
<pre style={{ marginTop: 6, maxHeight: 260, overflow: 'auto', background: 'rgba(0,0,0,0.35)', padding: 8, borderRadius: 6, fontFamily: 'monospace', fontSize: 11, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{logText || '…'}
</pre>
)}
</div>
)}
<div style={{ marginTop: 14 }}>
<strong style={small}>Variablen</strong>
<div style={{ marginTop: 6 }}>
{names.length === 0 && <div className="text-grey" style={small}>Keine Variablen.</div>}
{names.map((n) => (
<div key={n} style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', padding: '4px 0', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<code style={{ flex: '1 1 140px', overflowWrap: 'anywhere' }}>{n}</code>
<span className="text-grey">•••</span>
{editName === n ? (
<>
<input className="form-control" style={{ flex: '1 1 120px', minWidth: 0 }} type="password" autoComplete="off" placeholder="Neuer Wert" value={editValue} onKeyDown={enter(handleChange)} onChange={(e) => setEditValue(e.target.value)} />
<button type="button" className="btn btn-sm" onClick={handleChange}>Übernehmen</button>
</>
) : (
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => { setEditName(n); setEditValue(''); setEnvError('') }}>Ändern</button>
)}
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => handleRemove(n)}>Entfernen</button>
</div>
))}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', marginTop: 8 }}>
<input type="text" className="form-control" style={{ flex: '1 1 140px', minWidth: 0 }} placeholder="NAME" onKeyDown={enter(handleAdd)} value={newName} onChange={(e) => setNewName(e.target.value)} />
<input type="password" autoComplete="off" className="form-control" style={{ flex: '1 1 140px', minWidth: 0 }} placeholder="Wert" onKeyDown={enter(handleAdd)} value={newValue} onChange={(e) => setNewValue(e.target.value)} />
<button type="button" className="btn btn-sm" disabled={busy} onClick={handleAdd}>Hinzufügen</button>
</div>
{envError && <div style={{ color: '#f87171', ...small, marginTop: 4 }}>{envError}</div>}
{dirty && (
<div style={{ marginTop: 8 }}>
<button type="button" className="btn btn-sm" disabled={busy} onClick={handleSaveEnv}>Variablen speichern</button>
</div>
)}
<div className="text-grey" style={{ ...small, marginTop: 6 }}>Alles mit VITE_ / NEXT_PUBLIC_ ist später im Browser sichtbar.</div>
</div>
</div>
</>
)}
</div>
)
}
+56
View File
@@ -0,0 +1,56 @@
import { account } from './appwrite'
const PROJECT_ADMIN_URL =
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
// Wie kindsFetch in projectKindsApi.js: der Fehler trägt .status und .data
// (z. B. 409 { error: 'Preview-Runner ist nicht aktiv' }, 503 bei Überlast).
async function previewFetch(path, options = {}) {
const jwt = await account.createJWT()
const response = await fetch(`${PROJECT_ADMIN_URL}${path}`, {
...options,
headers: {
Authorization: `Bearer ${jwt.jwt}`,
'Content-Type': 'application/json',
...options.headers,
},
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const error = new Error(data.error || `API-Fehler ${response.status}`)
error.status = response.status
error.data = data
throw error
}
return data
}
const base = (projectId) => `/api/admin/website-projects/${encodeURIComponent(projectId)}/preview`
export function fetchPreview(projectId) {
return previewFetch(base(projectId))
}
export function setPreviewMode(projectId, mode) {
return previewFetch(base(projectId), { method: 'PATCH', body: JSON.stringify({ mode }) })
}
export function savePreviewEnv(projectId, { set = {}, remove = [] } = {}) {
return previewFetch(`${base(projectId)}/env`, { method: 'PUT', body: JSON.stringify({ set, remove }) })
}
export function rebuildPreview(projectId) {
return previewFetch(`${base(projectId)}/rebuild`, { method: 'POST' })
}
export function startPreviewDev(projectId) {
return previewFetch(`${base(projectId)}/dev/start`, { method: 'POST' })
}
export function stopPreviewDev(projectId) {
return previewFetch(`${base(projectId)}/dev/stop`, { method: 'POST' })
}
export function fetchPreviewLog(projectId, runFile) {
return previewFetch(`${base(projectId)}/logs/${encodeURIComponent(runFile)}`)
}
+29
View File
@@ -0,0 +1,29 @@
// Hilfen für den Preview-Block (Variablen, Status-Punkt, Dev-Erkennung)
export function isValidEnvName(name) {
return /^[A-Z_][A-Z0-9_]*$/.test(String(name ?? ''))
}
// Änderungen ⇒ Body für PUT /preview/env; entfernte Namen werden nicht zugleich gesetzt
export function buildEnvPayload(setMap = {}, removeList = []) {
const remove = [...new Set(removeList)]
const set = {}
for (const [k, v] of Object.entries(setMap)) if (!remove.includes(k)) set[k] = v
return { set, remove }
}
const DOT_COLORS = { online: '#10b981', building: '#f59e0b', sleeping: '#f59e0b', starting: '#f59e0b', failed: '#ef4444' }
export function dotColor(state) {
return DOT_COLORS[state] || '#a0aec0'
}
export function isDevPreview(p) {
if (!p) return false
if (p.mode === 'dev') return true
return (p.mode || 'auto') === 'auto' && typeof p.detected === 'string' && p.detected.startsWith('dev:')
}
export const MAX_ENV_VARS = 30
export function envValueTooLong(value) {
return new TextEncoder().encode(String(value ?? '')).length > 4096
}
+66
View File
@@ -0,0 +1,66 @@
// Anzeige-Logik für den Block "Preview" (Status, Erkennung, Modi)
export const MODE_OPTIONS = [
{ value: 'auto', label: 'Automatisch' },
{ value: 'build', label: 'Build' },
{ value: 'dev', label: 'Dev-Server' },
{ value: 'off', label: 'Aus' },
]
const dateFormat = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
})
// "TT.MM. HH:MM" in Europe/Berlin
function formatBerlin(value) {
const d = new Date(value)
if (!value || Number.isNaN(d.getTime())) return ''
const parts = Object.fromEntries(dateFormat.formatToParts(d).map((p) => [p.type, p.value]))
return `${parts.day}.${parts.month}. ${parts.hour}:${parts.minute}`
}
export function previewStatus(p) {
const state = p?.state || 'off'
switch (state) {
case 'online': {
const at = formatBerlin(p.builtAt)
const sha = p.builtSha ? String(p.builtSha).slice(0, 7) : ''
if (!at) return { color: 'green', text: 'Online' }
return { color: 'green', text: `Online seit ${at}${sha ? ` (Build ${sha})` : ''}` }
}
case 'building':
return { color: 'yellow', text: 'Baut gerade …' }
case 'starting':
return { color: 'yellow', text: 'Dev-Server startet …' }
case 'sleeping':
return { color: 'yellow', text: 'Dev-Server schläft, startet beim nächsten Aufruf' }
case 'failed':
return { color: 'red', text: 'Build fehlgeschlagen – letzte funktionierende Version ist online' }
default:
return { color: 'grey', text: 'Keine Preview' }
}
}
// 'static' | 'build:<pm>' | 'dev:<pm>' | 'build:<framework>:<pm>' ⇒ Anzeigetext
export function detectedLabel(detected) {
if (!detected || typeof detected !== 'string') return ''
if (detected === 'static') return 'Statisch'
const [kind, ...rest] = detected.split(':')
const base = kind === 'build' ? 'Build' : kind === 'dev' ? 'Dev-Server' : ''
if (!base || !rest.length || rest.some((x) => !x)) return ''
const pm = rest[rest.length - 1]
const fw = rest.length > 1 ? rest[0] : ''
const fwLabel = fw ? fw.charAt(0).toUpperCase() + fw.slice(1) : ''
return `${base} (${fwLabel ? `${fwLabel}, ` : ''}${pm})`
}
export function modeHint(mode, detected) {
if ((mode || 'auto') !== 'auto') return ''
const label = detectedLabel(detected)
return label ? `Automatisch → ${label}` : ''
}
+7
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import PreviewBlock from '../components/PreviewBlock'
import { import {
FaFolder, FaFolder,
FaPlus, FaPlus,
@@ -751,6 +752,12 @@ export default function ProjectsPage() {
</button> </button>
</div> </div>
</form> </form>
{editForm.projectType !== 'project' && (
<div className="form-group" style={{ marginTop: 16 }}>
<label className="form-label">Preview</label>
<PreviewBlock key={editProject.$id} projectId={editProject.$id} />
</div>
)}
</div> </div>
</div> </div>
)} )}
+12 -1
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { FaSpinner } from 'react-icons/fa6' import { FaSpinner } from 'react-icons/fa6'
import { dotColor } from '../lib/previewEnv'
import { fetchServices } from '../lib/projectKindsApi' import { fetchServices } from '../lib/projectKindsApi'
import { servicesCards } from '../lib/servicesView' import { servicesCards } from '../lib/servicesView'
import { PageHeader, Card, Button, Badge } from '../components/ui' import { PageHeader, Card, Button, Badge } from '../components/ui'
@@ -52,7 +53,17 @@ export default function ServicesPage() {
{c.projects.map((p) => ( {c.projects.map((p) => (
<div key={p.id} className="list-row" style={{ flexWrap: 'wrap' }}> <div key={p.id} className="list-row" style={{ flexWrap: 'wrap' }}>
<div className="grow" style={{ minWidth: 0, overflowWrap: 'anywhere' }}> <div className="grow" style={{ minWidth: 0, overflowWrap: 'anywhere' }}>
<div style={{ fontWeight: 700 }}>{p.projectName || p.repoFullName}</div> <div style={{ fontWeight: 700 }}>
{p.previewState && (
<span
title={`Preview: ${p.previewState}`}
style={{ display: 'inline-block', width: 9, height: 9, borderRadius: '50%', background: dotColor(p.previewState), marginRight: 6 }}
>
<span style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap' }}>{`Preview: ${p.previewState}`}</span>
</span>
)}
{p.projectName || p.repoFullName}
</div>
<div className="muted"> <div className="muted">
{[p.customerName, p.previewUrl && 'Preview'].filter(Boolean).join(' · ') || 'Intern'} {[p.customerName, p.previewUrl && 'Preview'].filter(Boolean).join(' · ') || 'Intern'}
</div> </div>
+44
View File
@@ -0,0 +1,44 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { isValidEnvName, buildEnvPayload, dotColor, isDevPreview, envValueTooLong, MAX_ENV_VARS } = await loadModule('src/lib/previewEnv.js')
test('isValidEnvName', () => {
assert.equal(isValidEnvName('VITE_API_URL'), true)
assert.equal(isValidEnvName('_X1'), true)
assert.equal(isValidEnvName('vite_x'), false)
assert.equal(isValidEnvName('1ABC'), false)
assert.equal(isValidEnvName(''), false)
assert.equal(isValidEnvName('A-B'), false)
})
test('buildEnvPayload: set und remove, set gewinnt nicht gegen remove', () => {
const p = buildEnvPayload({ A: '1', B: '2' }, ['B', 'C'])
assert.deepEqual(p, { set: { A: '1' }, remove: ['B', 'C'] })
})
test('dotColor', () => {
assert.equal(dotColor('online'), '#10b981')
assert.equal(dotColor('building'), '#f59e0b')
assert.equal(dotColor('sleeping'), '#f59e0b')
assert.equal(dotColor('starting'), '#f59e0b')
assert.equal(dotColor('failed'), '#ef4444')
assert.equal(dotColor('off'), '#a0aec0')
assert.equal(dotColor(undefined), '#a0aec0')
})
test('isDevPreview', () => {
assert.equal(isDevPreview({ mode: 'dev' }), true)
assert.equal(isDevPreview({ mode: 'auto', detected: 'dev:npm' }), true)
assert.equal(isDevPreview({ mode: 'auto', detected: 'build:npm' }), false)
assert.equal(isDevPreview({ mode: 'build', detected: 'dev:npm' }), false)
assert.equal(isDevPreview(null), false)
})
test('envValueTooLong zählt Bytes', () => {
assert.equal(envValueTooLong('a'.repeat(4096)), false)
assert.equal(envValueTooLong('a'.repeat(4097)), true)
assert.equal(envValueTooLong('ä'.repeat(2049)), true)
assert.equal(MAX_ENV_VARS, 30)
})
+63
View File
@@ -0,0 +1,63 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { previewStatus, detectedLabel, modeHint, MODE_OPTIONS } = await loadModule('src/lib/previewView.js')
test('previewStatus online: Datum in Europe/Berlin und sha7', () => {
const s = previewStatus({ state: 'online', builtAt: '2026-09-30T13:05:00Z', builtSha: 'abcdef1234567890' })
assert.deepEqual(s, { color: 'green', text: 'Online seit 30.09. 15:05 (Build abcdef1)' })
})
test('previewStatus online im Winter (MEZ)', () => {
const s = previewStatus({ state: 'online', builtAt: '2026-01-05T08:07:00Z', builtSha: 'abcdef1' })
assert.equal(s.text, 'Online seit 05.01. 09:07 (Build abcdef1)')
})
test('previewStatus online ohne Datum/sha bleibt lesbar', () => {
assert.deepEqual(previewStatus({ state: 'online' }), { color: 'green', text: 'Online' })
})
test('previewStatus weitere Zustände', () => {
assert.deepEqual(previewStatus({ state: 'building' }), { color: 'yellow', text: 'Baut gerade …' })
assert.deepEqual(previewStatus({ state: 'starting' }), { color: 'yellow', text: 'Dev-Server startet …' })
assert.deepEqual(previewStatus({ state: 'sleeping' }), { color: 'yellow', text: 'Dev-Server schläft, startet beim nächsten Aufruf' })
assert.deepEqual(previewStatus({ state: 'failed' }), { color: 'red', text: 'Build fehlgeschlagen – letzte funktionierende Version ist online' })
assert.deepEqual(previewStatus({ state: 'off' }), { color: 'grey', text: 'Keine Preview' })
})
test('previewStatus leer/unbekannt/null ⇒ grau', () => {
const grey = { color: 'grey', text: 'Keine Preview' }
assert.deepEqual(previewStatus({ state: '' }), grey)
assert.deepEqual(previewStatus({}), grey)
assert.deepEqual(previewStatus(null), grey)
assert.deepEqual(previewStatus({ state: 'xyz' }), grey)
})
test('detectedLabel', () => {
assert.equal(detectedLabel('static'), 'Statisch')
assert.equal(detectedLabel('build:npm'), 'Build (npm)')
assert.equal(detectedLabel('dev:pnpm'), 'Dev-Server (pnpm)')
assert.equal(detectedLabel('build:vite:npm'), 'Build (Vite, npm)')
assert.equal(detectedLabel('dev:npm'), 'Dev-Server (npm)')
assert.equal(detectedLabel(''), '')
assert.equal(detectedLabel(null), '')
assert.equal(detectedLabel('foo:npm'), '')
})
test('modeHint', () => {
assert.equal(modeHint('auto', 'build:npm'), 'Automatisch → Build (npm)')
assert.equal(modeHint('auto', 'static'), 'Automatisch → Statisch')
assert.equal(modeHint('', 'dev:pnpm'), 'Automatisch → Dev-Server (pnpm)')
assert.equal(modeHint('auto', ''), '')
assert.equal(modeHint('build', 'build:npm'), '')
})
test('MODE_OPTIONS', () => {
assert.deepEqual(MODE_OPTIONS, [
{ value: 'auto', label: 'Automatisch' },
{ value: 'build', label: 'Build' },
{ value: 'dev', label: 'Dev-Server' },
{ value: 'off', label: 'Aus' },
])
})