119 KiB
Kundenansicht neu, P0 Werkzeug, Pilot-Schalter, Vorschau: Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Der Pilot-Schalter (V1) liefert je Sitzung die neue oder die alte Kundenseite aus. Ein Werkzeug mit Testdaten-Backend, Prüfungen und Vorschau-Bau macht die neue Oberfläche prüfbar, bevor sie live ist.
Architecture:
- Portal: Der Schalter ist ein reines Modul. Die Weiche für
/dashboard.htmlist eine Handler-Fabrik, dieserver/index.jsvorexpress.staticeinhängt und ohne Server-Start testbar ist. - Werkzeug: außerhalb des Repos mit eigenem Git. Backend und Fälle sind reine ES-Module ohne
node:-Importe und laufen im Playwright-Lauf (Gates) wie im Service Worker der Vorschau. - Ausliefern: ein Skript mit festem Ziel, nur
cp, Entfernen nur über das Manifest des letzten Laufs, Datei für Datei.
Tech Stack: Node 24 (Host) mit node --test, Express 4, Playwright-Core 1.62.1 mit chromium-1234, esbuild 0.21.5 aus dem WOMS-build/, Bash mit flock und systemd-run.
Spec: /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht/docs/superpowers/specs/2026-10-15-kundenansicht-neu-design.md (5.10, E16, E19; Code-Karten unter /root/reports/kundenansicht-neu-2026-10-15/karten/)
Übersicht/Verträge: /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht/docs/superpowers/plans/2026-10-16-kundenansicht-00-uebersicht.md (gilt wörtlich)
Worktree und Branch: /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht, Branch feature/kundenansicht-neu, Tag kn-p0. Das Werkzeug liegt in /root/reports/kundenansicht-neu-2026-10-15/werkzeug/ (eigenes Git, Branch main, kein Remote, ebenfalls Tag kn-p0).
Kürzel: WT = der Portal-Worktree, KW = der Werkzeug-Ordner. In Befehlen stehen die Pfade ausgeschrieben.
Global Constraints
Alle Global Constraints der Übersicht gelten wörtlich (alte Ansicht unverändert, Schalter off | pilot | on mit Vorgabe off, Testbefehle, Playwright nur über das Werkzeug, Sicherheitsblock, kein Push, kein Merge, nichts in build/). Paketeigene Zusätze:
- Commits: Dateien einzeln mit
git add, nie-A. Trailer wörtlichCo-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>als zweites-m. Werkzeug-Commits mitgit -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug. - Bestehenden Code ändert dieses Paket nur in
server/config.js,server/index.js,server/routes/auth.js(siehe Dateiübersicht). In neuen Dateien unterserver/undpublic/steht nie der Textadmin.htmloderkalkulation.html(Pintest/adminPanelGone.test.js:54-67). - Werkzeug-Skripte:
rm -rfnur auf Pfade unterKW/tmp/, nur hinter einercase-Prüfung auf den genauen Pfad. Keine Zielpfade aus Parametern ohne Guard. - Vorschau-Skript: Der Agent ruft
vorschau-kundenansicht.shnur mit--pruefenauf. Die Hilfsfunktionen des Guards dürfen mit Testpfaden unterKW/tmp/laufen, nie mit Pfaden, die..enthalten. - Service-Worker-tauglich:
backend.mjsundfixtures/*.mjsimportieren nichts ausnode:und lesenprocess.envnur alsprocess.env.FIXTURE_TODAY. - Testdaten: fester Tag
FIXTURE_TODAY=2026-10-16, alles erfunden, Adressen enden auf.invalid, URLs sind relativ oder enden auf.invalid. - Browser: ein Browser, immer über
run-browser.sh, niefullPage, Zeitlimit 180 s je Operation,hasTouch: truefür Handy, Standalone nur permatchMedia-Patch, Uhr persetFixedTime. Vor schweren Läufenfree -m(die Skripte prüfen selbst, Schwelle 2000 MB). Danach zeigtls -l /dev/nullcrw-rw-rw-. - Texte: Außer den Platzhalter- und Hinweisseiten der Vorschau entstehen keine Kundentexte. Diese sind in du-Form. Einzige weitere Ausnahme: die Testtexte im Katalog der Fixtures (
katalog[].consent,katalog[].hinweis, Task 6). Sie sind kurz, folgen sinngemäß V5 und Spec 5.6 und sind kein Rechtswortlaut (den liefert später der Server aus P5 und P8). - Fälle der Fixtures: Die ersten vierzehn Namen von
FALL_NAMENsind der Vertrag von P0 und stehen fest. Spätere Pakete hängen eigene Fälle nur in eigenen Dateien hinten an (Task 6, README). Pin undboot.jsbleiben dafür unberührt.
Review Focus
- Pilot-Schalter an den Rändern: Groß-/Kleinschreibung und Leerraum der E-Mail,
*, leere Liste,offmit*, E-Mail ohne Text, Sitzung ohne Kunde, alte Mitarbeiter-Sitzung (role: 'admin'ohneviewId). Erwartet: nur Treffer und Mitarbeiter-Kundenansicht sehen neu. Dieselben Listenregeln gelten für Jobs und Dienste ohne Sitzung (neueAnsichtFuerKunde). Tests: Task 2, dieselben Fälle über HTTP in Task 3 und 4. - Weiche liefert die falsche Seite oder falsch gecacht: Query (
?abo=success), unsigniertes Cookie, Direktaufruf von/neu/dashboard.html, Reihenfolge vorexpress.static,Cache-Control. Erwartet: Nicht-Pilot bekommt bytegleich die alte Seite. Test: Task 3. - Fixtures weichen von den echten Antworten ab: Das Werkzeug zeigt Grün, die echte Seite bricht. Erwartet: Schlüsselmengen stimmen mit den echten Routern überein, jede Ausnahme ist begründet, keine Ausnahme verwaist, keine echten Hosts und Adressen. Tests: Task 7 und 6.
- Auslieferung zerstört oder überschreibt Fremdes: falsches Ziel, Elternordner, Symlink,
..oder absolute Pfade im Manifest. Erwartet: Ablehnung vor dem ersten Kopieren, entfernt wird nur, was im eigenen Manifest steht. Tests: Task 13. - Werkzeuglauf frisst den Speicher oder läuft still leer: zweiter Browser,
fullPage, kein Zeitlimit, zu wenig freier Speicher. In der Vorschau ein leeres Dashboard, weil der Service Worker nicht übernimmt oder nginx für eine fehlende Datei HTML mit 200 liefert. Tests: Task 5 (Sperre, Schwelle, Browser nur über das Skript), 10 (Kontextoptionen, keinfullPage), 11 und 12 (Dateien-Abschluss, Start der Vorschau).
Dateiübersicht
| Datei | Aufgabe | Task |
|---|---|---|
server/config.js:35 und Abschnitt nach zeiten (um :1116) |
kundenansichtNeu |
2 |
server/services/kundenansicht/schalter.js, test/kundenansichtSchalter.test.js (neu) |
V1 Schalter | 2 |
server/services/kundenansicht/dashboardWeiche.js, public/neu/dashboard.html (Platzhalter, ab P1 gehört sie P1), test/kundenansichtDashboard.test.js (neu) |
Weiche (Vertragsfrage 1) | 3 |
server/index.js (Import, zwei Zeilen zwischen :153 und :154) |
Weiche vor express.static |
3 |
server/routes/auth.js (Import, GET /me in den zwei Antworten authenticated: true bei :405 und :464), test/kundenansichtMe.test.js (neu) |
Feld ansicht |
4 |
Pins test/adminPanelGone.test.js:93-95, test/legalDocs.test.js:443-448 (nur Lauf, keine Änderung) |
Reihenfolge vor express.static |
1, 3 |
test/ui/kundenansicht-neu.ui.mjs (neu) |
Einstieg für Sichtprüfungen | 10 |
KW/ .gitignore, package.json, env.sh, check.sh, run-browser.sh, browser.mjs |
Gerüst | 5 |
KW/ fixtures/{helfer,basis,faelle,endpunkte,index}.mjs, backend.mjs |
Fälle, Backend | 6 |
KW/ vertrag-endpunkte.mjs |
Vertragstest | 7 |
KW/ checks.mjs, konsole.mjs |
Prüfungen | 8 |
KW/ views.mjs, gates.mjs |
Ansichten, Gates | 9 |
KW/ statik-server.mjs, shots.mjs, shots.sh, vorschau/seiten.mjs |
Läufer | 10 |
KW/vorschau/ sw-eintrag.mjs, boot.js, index-patchen.mjs, pruefe-dist.mjs, bauen.sh, hinweis.html, demo-baeckerei.html |
Vorschau-Bau | 11 |
KW/vorschau/ ziel-pruefen.sh, ausliefern-lib.sh, manifest.mjs, vorschau-kundenansicht.sh, plan-zeigen.sh, test-ziel.sh |
Auslieferung | 13 |
KW/test/ geruest, backend, vertrag, gates, statik, vorschau-bau, manifest (.test.mjs) und checks, shots, vorschau (.selftest.mjs) |
Tests des Werkzeugs | 5 bis 13 |
KW/README.md, KW/UEBERGABE-P0.md |
Bedienung, Auftrag an den Inhaber | 14 |
Task 1: Worktree vorbereiten
Files: kein Commit. Es entsteht nur der unversionierte Symlink WT/node_modules.
Interfaces:
-
Produces: ein Worktree, in dem Portal-Tests laufen, und die Basislinie (
pass-Zahl) für Task 3. -
Step 1: Zustand prüfen
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && git branch --show-current && git status --short
Expected: feature/kundenansicht-neu, Status leer. Steht nur ?? docs/superpowers/plans/2026-10-16-kundenansicht-01-werkzeug-vorschau.md da, committe genau diese Datei (git add dieser Datei, Nachricht Plan: Kundenansicht neu P0 Werkzeug, Schalter, Vorschau).
- Step 2: Symlink setzen
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && ln -s ../build/node_modules node_modules && git status --short && git check-ignore -v node_modules
Expected: Status bleibt leer, check-ignore nennt die Zeile node_modules aus .gitignore.
- Step 3: Basislinie laufen lassen
Run: free -m (bei available mindestens 2000 MB), dann cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/zeitenSchalter.test.js test/adminPanelGone.test.js test/legalDocs.test.js
Expected: fail 0. Notiere die pass-Zahl. Danach ls -l /dev/null mit crw-rw-rw-.
Task 2: Pilot-Schalter (V1)
Files:
- Create:
server/services/kundenansicht/schalter.js - Modify:
server/config.js:35(neue ZeilekundenansichtNeuEnabled), neuer AbschnittkundenansichtNeuim Objektconfigdirekt nachzeiten(um:1116) - Test:
test/kundenansichtSchalter.test.js
Interfaces:
-
Consumes:
parsePortalSession(req)ausserver/middleware/session.js,commaList(raw, fallback)ausserver/config.js. -
Produces (V1, wörtlich):
config.kundenansichtNeu: { mode: 'off'|'pilot'|'on', pilotEmails: string[] }ausKUNDENANSICHT_NEUundKUNDENANSICHT_NEU_PILOT_EMAILSneueAnsichtModus(): 'off'|'pilot'|'on'neueAnsichtFuerSession(session): booleanneueAnsicht(req): boolean(=neueAnsichtFuerSession(req.session ?? parsePortalSession(req)))neueAnsichtFuerKunde({ customerId, email }): boolean(Ergänzung zu V1 für Jobs und Dienste ohne Cookie, etwa P5aboModellFuerKunde, P5runAboCheckund P6; die Übersicht V1 bekommt die Zeile, siehe Vertragsfrage 10). Gleiche Modus- und Listenregeln wieneueAnsichtFuerSession, aber ohne Sitzungsprüfung und ohne Mitarbeiter-Regel:offergibt false. IstcustomerIdkein nicht-leerer Text (getrimmt) oder die Eingabe kein Objekt, ergibt es false.onergibt true.pilotergibt true bei'*'in der Liste oder wenn die getrimmte, klein geschriebeneemailin der Liste steht (eineemail, die kein Text ist, zählt als leer).neueAnsichtFuerSessionundneueAnsichtFuerKundeteilen sich eine private Hilfsfunktion für den Listenvergleich.
-
Step 1: Failing test schreiben (
test/kundenansichtSchalter.test.js)
import { test, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { config } from '../server/config.js'
import { neueAnsicht, neueAnsichtFuerKunde, neueAnsichtFuerSession, neueAnsichtModus } from '../server/services/kundenansicht/schalter.js'
let original
beforeEach(() => { original = { ...config.kundenansichtNeu } })
afterEach(() => { Object.assign(config.kundenansichtNeu, original) })
const stelle = (mode, pilotEmails = []) => Object.assign(config.kundenansichtNeu, { mode, pilotEmails })
const MAIL = 'k@example.invalid'
const kunde = (email = MAIL) => ({ customerId: 'c1', appwriteUserId: 'u1', name: 'K', email })
const ansicht = (extra = {}) => ({ role: 'admin', viewAs: true, viewId: 'a'.repeat(32), customerId: 'c1', appwriteUserId: 'u_staff', email: 'andrej@webklar.com', ...extra })
const UNGUELTIG = [null, undefined, {}, 'x', 42, [], { role: 'admin', appwriteUserId: 'u', email: 'kenso@webklar.com' }, ansicht({ viewId: undefined }), ansicht({ viewId: '' }), ansicht({ viewAs: false }), { customerId: 'c1' }, { appwriteUserId: 'u1' }]
const ENV = ['KUNDENANSICHT_NEU', 'KUNDENANSICHT_NEU_PILOT_EMAILS']
let zaehler = 0
async function frischeConfig(env = {}) { // neue config.js-Instanz mit genau diesen Variablen (Muster zeitenSchalter.test.js)
const alt = Object.fromEntries(ENV.map((k) => [k, process.env[k]]))
for (const k of ENV) delete process.env[k]
Object.assign(process.env, env)
try { zaehler += 1; return (await import(`../server/config.js?kn-schalter=${zaehler}`)).config } finally {
for (const k of ENV) { if (alt[k] === undefined) delete process.env[k]; else process.env[k] = alt[k] }
}
}
test('Konfiguration: Vorgabe off, Unbekanntes ist off, Werte werden bereinigt', async () => {
assert.deepEqual((await frischeConfig()).kundenansichtNeu, { mode: 'off', pilotEmails: [] })
for (const wert of ['', ' ', 'banane', 'true', '1']) assert.equal((await frischeConfig({ KUNDENANSICHT_NEU: wert })).kundenansichtNeu.mode, 'off', wert)
assert.equal((await frischeConfig({ KUNDENANSICHT_NEU: ' ON ' })).kundenansichtNeu.mode, 'on')
const f = await frischeConfig({ KUNDENANSICHT_NEU: ' Pilot ', KUNDENANSICHT_NEU_PILOT_EMAILS: ' Kenso.Gri@Gmail.com, ,justin@webklar.com ' })
assert.deepEqual([f.kundenansichtNeu.mode, f.kundenansichtNeu.pilotEmails], ['pilot', ['kenso.gri@gmail.com', 'justin@webklar.com']])
})
test('Regeln: [Modus, Liste, Sitzung, erwartet]', () => {
const TABELLE = [
['off', ['*', MAIL], kunde(), false], ['off', ['*'], ansicht(), false],
['on', [], kunde(), true], ['on', [], kunde(''), true], ['on', [], ansicht(), true],
['pilot', [MAIL], kunde(), true], ['pilot', [MAIL], kunde(' K@Example.Invalid '), true],
['pilot', [MAIL], kunde('k@example.invalid.evil'), false], ['pilot', [MAIL], kunde('anderer@example.invalid'), false], ['pilot', [MAIL], kunde(''), false],
['pilot', [MAIL], { ...kunde(), email: undefined }, false], ['pilot', [MAIL], { ...kunde(), email: 42 }, false], ['pilot', [MAIL], { ...kunde(), email: [MAIL] }, false],
['pilot', ['*'], kunde(), true], ['pilot', ['*'], ansicht(), true],
['pilot', [], kunde(), false], ['pilot', [], ansicht(), true], // leere Liste: nur die Mitarbeiter-Kundenansicht
]
for (const [modus, liste, sitzung, soll] of TABELLE) { stelle(modus, liste); assert.equal(neueAnsichtFuerSession(sitzung), soll, `${modus} ${liste} ${JSON.stringify(sitzung)}`) }
})
test('ungültige Sitzungen sind in jedem Modus und bei jeder Liste false', () => {
for (const modus of ['off', 'on', 'pilot']) for (const liste of [[], ['*'], [MAIL, 'kenso@webklar.com']]) { stelle(modus, liste); for (const s of UNGUELTIG) assert.equal(neueAnsichtFuerSession(s), false, `${modus} ${liste} ${JSON.stringify(s)}`) }
})
test('neueAnsicht(req): req.session hat Vorrang, sonst das signierte Cookie; kaputt oder leer ist false', () => {
stelle('pilot', [MAIL])
const cookie = (s) => ({ signedCookies: { [config.cookieName]: typeof s === 'string' ? s : JSON.stringify(s) } })
const fremd = kunde('x@example.invalid')
assert.deepEqual([neueAnsicht({ session: kunde() }), neueAnsicht({ session: fremd }), neueAnsicht(cookie(kunde())), neueAnsicht({ session: null, ...cookie(kunde()) })], [true, false, true, true])
assert.deepEqual([neueAnsicht({ session: fremd, ...cookie(kunde()) }), neueAnsicht(cookie('{kaputt')), neueAnsicht({})], [false, false, false])
})
test('der Modus wird bei jedem Aufruf gelesen; Unbekanntes gilt als off', () => {
stelle('off'); assert.equal(neueAnsichtFuerSession(kunde()), false)
stelle('on'); assert.equal(neueAnsichtFuerSession(kunde()), true)
stelle('banane'); assert.equal(neueAnsichtModus(), 'off')
})
test('neueAnsichtFuerKunde (Jobs ohne Cookie): [Modus, Liste, Eingabe, erwartet]', () => {
const k = (email) => ({ customerId: 'c1', email })
const TABELLE = [
['off', ['*', MAIL], k(MAIL), false], ['off', ['*'], k(undefined), false],
['on', [], k(MAIL), true], ['on', [], k(''), true], ['on', [], { customerId: 'c1' }, true],
['pilot', [MAIL], k(MAIL), true], ['pilot', [MAIL], k(' K@Example.Invalid '), true],
['pilot', [MAIL], k('k@example.invalid.evil'), false], ['pilot', [MAIL], k('anderer@example.invalid'), false],
['pilot', [MAIL], k(''), false], ['pilot', [MAIL], k(undefined), false], ['pilot', [MAIL], k(42), false], ['pilot', [MAIL], k([MAIL]), false],
['pilot', ['*'], k(MAIL), true], ['pilot', ['*'], k(undefined), true],
['pilot', [], k(MAIL), false], // leere Liste: niemand, denn ohne Sitzung gibt es keine Mitarbeiter-Kundenansicht
]
for (const [modus, liste, eingabe, soll] of TABELLE) { stelle(modus, liste); assert.equal(neueAnsichtFuerKunde(eingabe), soll, `${modus} ${liste} ${JSON.stringify(eingabe)}`) }
})
test('neueAnsichtFuerKunde: leere customerId und Unbrauchbares sind in jedem Modus false; Listenregeln wie bei Sitzungen', () => {
const UNBRAUCHBAR = [undefined, null, 'x', 42, [], {}, { email: MAIL }, { customerId: '', email: MAIL }, { customerId: ' ', email: MAIL }, { customerId: 42, email: MAIL }, { customerId: null, email: MAIL }, { customerId: ['c1'], email: MAIL }]
for (const modus of ['off', 'on', 'pilot']) for (const liste of [[], ['*'], [MAIL]]) {
stelle(modus, liste)
for (const e of UNBRAUCHBAR) assert.equal(neueAnsichtFuerKunde(e), false, `${modus} ${liste} ${JSON.stringify(e)}`)
for (const mail of [MAIL, ' K@Example.Invalid ', 'x@example.invalid', '', undefined, 42]) // gleiche Antwort wie für eine gültige Kunden-Sitzung mit derselben E-Mail
assert.equal(neueAnsichtFuerKunde({ customerId: 'c1', email: mail }), neueAnsichtFuerSession({ ...kunde(), email: mail }), `${modus} ${liste} ${JSON.stringify(mail)}`)
}
})
- Step 2: Test laufen lassen, Fehlschlag prüfen
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenansichtSchalter.test.js
Expected: FAIL mit Cannot find module auf schalter.js.
-
Step 3: Implementieren
-
server/config.js:35:const kundenansichtNeuEnabled = (process.env.KUNDENANSICHT_NEU || '').trim().toLowerCase(). -
Abschnitt nach
zeiten(Musterzeiten, deutscher Kommentar):kundenansichtNeu: { mode: ['pilot', 'on'].includes(kundenansichtNeuEnabled) ? kundenansichtNeuEnabled : 'off', pilotEmails: commaList(process.env.KUNDENANSICHT_NEU_PILOT_EMAILS, []) }. -
schalter.jsmit den vier Exports (Musterserver/services/zeiten/schalter.js). Ablauf vonneueAnsichtFuerSession:- Modus
offergibt false. - Ungültige Sitzung ergibt false. Gültig ist ein Objekt (kein Array) mit Kundenansicht (
viewAs === trueundviewIdgesetzt) oder Kunde (customerIdundappwriteUserIdgesetzt,role !== 'admin'). onergibt true.pilotergibt true bei Kundenansicht, bei'*'in der Liste oder wenn die getrimmte, klein geschriebenesession.emailin der Liste steht. Eineemail, die kein Text ist, zählt als leer.
- Modus
-
neueAnsichtFuerKunde({ customerId, email })folgt den Interfaces: dieselben Schritte 1, 3 und 4, an Stelle von Schritt 2 die Prüfung auf eine nicht-leerecustomerId. Den Listenvergleich aus Schritt 4 ('*'oder getrimmte, klein geschriebene E-Mail in der Liste) übernimmt eine private Hilfsfunktion, die beide Exports nutzen. -
Step 4: Test grün
Run: wie Step 2. Expected: PASS, fail 0.
- Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht
git add server/config.js server/services/kundenansicht/schalter.js test/kundenansichtSchalter.test.js
git commit -m "Kundenansicht neu: Pilot-Schalter (off/pilot/on) mit Randfall-Tests" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 3: Dashboard-Weiche und Platzhalter
Files:
- Create:
server/services/kundenansicht/dashboardWeiche.js,public/neu/dashboard.html - Modify:
server/index.js(Import bei den Service-Imports, zwei Zeilen zwischenapp.use('/kalkulation.html', …)undapp.use(express.static(publicDir))) - Test:
test/kundenansichtDashboard.test.js
Interfaces:
-
Consumes:
neueAnsicht(req)(Task 2). -
Produces:
dashboardWeiche({ publicDir }): (req, res, next) => void. Gilt die neue Ansicht:Cache-Control: no-cachesetzen, dannres.sendFile(publicDir/neu/dashboard.html). Sonstnext().neuDirektWeiche(): (req, res, next) => voidfürGET /neu/dashboard.html: neue Ansicht ergibtnext(), sonstres.redirect(302, '/dashboard.html').public/neu/dashboard.html(Platzhalter):<body class="kn">,main[data-kn-platzhalter]mit Überschrift „Kundenansicht neu“ und Text „Platzhalter bis P1“,<span id="kn-platzhalter-ansicht">, dazu ein klassisches Inline-Skript, dasGET /api/auth/meliest und pertextContentme.ansicht(beiauthenticated: falseden Textabgemeldet, bei FehlerFehler) einträgt. Weißer Hintergrund, Systemschrift. P1 ersetzt die Datei.
-
Step 1: Failing test schreiben (
test/kundenansichtDashboard.test.js)
Kopf wie test/kundenansichtEnde.test.js:1-19 (Mock mit customerViewLog, startFakeStripe, useTestEnv, damit testApp.js und fixtures.js laden), dann:
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import express from 'express'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
// … Mock, Stripe, useTestEnv (wie im Muster), dann dynamisch:
const { config } = await import('../server/config.js')
const { startTestApp, rawGet } = await import('./support/testApp.js')
const { customerCookie, kundenansichtCookie, adminCookie, viewAsCookie } = await import('./support/fixtures.js')
const { dashboardWeiche, neuDirektWeiche } = await import('../server/services/kundenansicht/dashboardWeiche.js')
const publicDir = fileURLToPath(new URL('../public/', import.meta.url))
const router = express.Router() // gleiche Reihenfolge wie server/index.js
router.get('/dashboard.html', dashboardWeiche({ publicDir }))
router.get('/neu/dashboard.html', neuDirektWeiche())
router.use(express.static(publicDir))
const app = await startTestApp({ '/': router })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
const stelle = (mode, pilotEmails = []) => Object.assign(config.kundenansichtNeu, { mode, pilotEmails })
const ALT = readFileSync(path.join(publicDir, 'dashboard.html'), 'utf8')
const istNeu = (r) => r.status === 200 && String(r.body).includes('data-kn-platzhalter') && String(r.body).includes('<body class="kn">')
const istAlt = (r) => r.status === 200 && r.body === ALT
const MAIL = 'k@example.invalid' // E-Mail von c1; c2 hat k2@example.invalid
const UNSIGNIERT = `${config.cookieName}=${encodeURIComponent(JSON.stringify({ customerId: 'c1', appwriteUserId: 'u1', email: MAIL }))}`
const FAELLE = [ // [Name, Modus, Liste, Cookie, Ergebnis]
['Pilot, Kunde auf der Liste', 'pilot', [MAIL], customerCookie('c1'), 'neu'],
['Pilot, Kunde daneben', 'pilot', [MAIL], customerCookie('c2'), 'alt'],
['Pilot, Mitarbeiter-Kundenansicht', 'pilot', [], kundenansichtCookie(), 'neu'],
['off mit Stern, Kunde', 'off', ['*'], customerCookie('c1'), 'alt'],
['off mit Stern, Kundenansicht', 'off', ['*'], kundenansichtCookie(), 'alt'],
['on, Kunde', 'on', [], customerCookie('c1'), 'neu'],
['on, alte Mitarbeiter-Sitzung', 'on', [], adminCookie(), 'alt'],
['on, Kundenansicht ohne viewId', 'on', [], viewAsCookie('c1'), 'alt'],
['Pilot mit Stern, ohne Sitzung (200 wie heute, kein Redirect)', 'pilot', ['*'], undefined, 'alt'],
['Pilot mit Stern, unsigniertes Cookie', 'pilot', ['*'], UNSIGNIERT, 'alt'],
]
for (const [name, modus, liste, cookie, soll] of FAELLE) test(name, async () => {
stelle(modus, liste)
const r = await rawGet('/dashboard.html', { cookie })
assert.ok(soll === 'neu' ? istNeu(r) : istAlt(r), `${r.status} ${String(r.body).slice(0, 80)}`)
})
test('neue Seite: HTML und Cache-Control no-cache; Query bleibt erhalten', async () => {
stelle('pilot', [MAIL])
const r = await rawGet('/dashboard.html?abo=success', { cookie: customerCookie('c1') })
assert.ok(istNeu(r)); assert.equal(r.headers.get('cache-control'), 'no-cache'); assert.match(r.headers.get('content-type'), /^text\/html/)
})
test('Direktaufruf /neu/dashboard.html: Pilot ja, sonst 302 auf /dashboard.html', async () => {
stelle('pilot', [MAIL])
assert.ok(istNeu(await rawGet('/neu/dashboard.html', { cookie: customerCookie('c1') })))
for (const cookie of [customerCookie('c2'), undefined]) {
const r = await rawGet('/neu/dashboard.html', { cookie })
assert.deepEqual([r.status, r.headers.get('location')], [302, '/dashboard.html'])
}
})
test('andere Seiten bleiben unberührt', async () => {
stelle('pilot', ['*'])
for (const p of ['/login.html', '/manifest.webmanifest']) assert.equal((await rawGet(p, { cookie: customerCookie('c1') })).status, 200, p)
})
test('server/index.js hängt die Weiche zwischen /kalkulation.html und express.static ein', () => {
const index = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8')
const stelle_ = (s) => index.indexOf(s)
const reihe = ["app.use('/kalkulation.html'", "app.get('/dashboard.html', dashboardWeiche({ publicDir }))", "app.get('/neu/dashboard.html', neuDirektWeiche())", 'app.use(express.static(publicDir))'].map(stelle_)
assert.ok(reihe[0] > -1 && reihe.every((n, i) => i === 0 || n > reihe[i - 1]), reihe.join(','))
})
- Step 2: Test laufen lassen, Fehlschlag prüfen
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenansichtDashboard.test.js
Expected: FAIL mit Cannot find module auf dashboardWeiche.js.
-
Step 3: Implementieren
-
dashboardWeiche.jsundpublic/neu/dashboard.htmlwie in den Interfaces.res.set('Cache-Control', 'no-cache')steht vorsendFile, damit Express den Header nicht überschreibt. Fehler vonsendFilegehen annext(err). -
server/index.js: Import{ dashboardWeiche, neuDirektWeiche }aus./services/kundenansicht/dashboardWeiche.jsund direkt vorapp.use(express.static(publicDir)):// Kundenansicht neu: je Sitzung die neue oder die alte Seite (Schalter KUNDENANSICHT_NEU), vor den statischen Dateien app.get('/dashboard.html', dashboardWeiche({ publicDir })) app.get('/neu/dashboard.html', neuDirektWeiche())Der Kommentar enthält den Text
express.static(publicDir)nicht (die Pins nutzenindexOf). Der tote alte Handlerapp.get('/dashboard.html', …)hinterstaticbleibt unverändert. -
Step 4: Tests grün, dazu die Pins
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenansichtDashboard.test.js test/adminPanelGone.test.js test/legalDocs.test.js test/kundenansichtGuard.test.js
Expected: PASS, fail 0.
- Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht
git add server/services/kundenansicht/dashboardWeiche.js public/neu/dashboard.html server/index.js test/kundenansichtDashboard.test.js
git commit -m "Kundenansicht neu: Dashboard-Weiche vor den statischen Dateien, Platzhalter-Seite" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 4: Feld ansicht in /api/auth/me
Files:
- Modify:
server/routes/auth.js(Import,GET /me: Antwort der Kundenansicht bei:405, Antwort des Kunden bei:464) - Test:
test/kundenansichtMe.test.js
Interfaces:
-
Consumes:
neueAnsichtFuerSession(session)(Task 2)./mehat keinreq.session, nur die lokale Variablesession. -
Produces:
GET /api/auth/meliefert in beidenauthenticated: true-Antworten zusätzlichansicht: 'neu' | 'alt'. Unauthentifizierte Antworten bleiben unverändert. -
Step 1: Failing test schreiben (
test/kundenansichtMe.test.js)
Kopf wie kundenansichtEnde.test.js:1-19, startTestApp({ '/': kundenansichtGuard, '/api/auth': authRoutes }), Imports rawGet, customers, customerCookie, kundenansichtCookie, adminCookie, seedFixtures. Weil /me bei Kunden Status und Portalzugang prüft:
seedFixtures(aw)
for (const id of ['c1', 'c2']) {
aw.seed('customers', { ...customers[id], customerStatus: 'active' })
aw.seed('customerPortalAccess', { $id: `pa_${id}`, customerId: id, enabled: true })
}
const stelle = (mode, pilotEmails = []) => Object.assign(config.kundenansichtNeu, { mode, pilotEmails })
const me = async (cookie) => (await rawGet('/api/auth/me', { cookie })).body
const ansichtVon = async (cookie) => (await me(cookie)).ansicht
test('Kunde: Pilot-Liste, off mit Stern, on', async () => {
const f = [['pilot', ['k@example.invalid'], customerCookie('c1'), 'neu'], ['pilot', ['k@example.invalid'], customerCookie('c2'), 'alt'],
['off', ['*'], customerCookie('c1'), 'alt'], ['on', [], customerCookie('c2'), 'neu']]
for (const [modus, liste, cookie, soll] of f) { stelle(modus, liste); assert.equal(await ansichtVon(cookie), soll, `${modus} ${liste}`) }
})
test('Mitarbeiter-Kundenansicht: im Pilot neu, mit off alt; viewAs und viewId bleiben', async () => {
stelle('pilot', []); const b = await me(kundenansichtCookie({ viewId: 'c'.repeat(32) }))
assert.deepEqual([b.authenticated, b.viewAs, b.viewId, b.ansicht], [true, true, 'c'.repeat(32), 'neu'])
stelle('off', ['*']); assert.equal(await ansichtVon(kundenansichtCookie()), 'alt')
})
test('Spitzenschlüssel bleiben; unauthentifizierte Antworten tragen kein ansicht', async () => {
stelle('on')
assert.deepEqual(Object.keys(await me(customerCookie('c1'))).sort(), ['ansicht', 'authenticated', 'customer', 'role']) // customer darf wachsen (P5)
assert.deepEqual(await me(), { authenticated: false })
const abgelaufen = await me(kundenansichtCookie({ viewExpiresAt: Date.now() - 1000 }))
const alt = await me(adminCookie())
assert.deepEqual([abgelaufen.reason, 'ansicht' in abgelaufen, alt.reason, 'ansicht' in alt], ['view_expired', false, 'staff', false])
})
- Step 2: Test laufen lassen, Fehlschlag prüfen
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenansichtMe.test.js
Expected: FAIL (ansicht ist undefined).
- Step 3: Implementieren
Import aus ../services/kundenansicht/schalter.js und in beiden res.json({ authenticated: true, … }) der Schlüssel ansicht: neueAnsichtFuerSession(session) ? 'neu' : 'alt'. Sonst nichts in auth.js ändern (sanitizeCustomer gehört P5).
- Step 4: Tests grün, dazu die Nachbarn
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenansichtMe.test.js test/kundenansichtEnde.test.js test/staffLogin.test.js
Expected: PASS, fail 0.
- Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht
git add server/routes/auth.js test/kundenansichtMe.test.js
git commit -m "Kundenansicht neu: /api/auth/me meldet ansicht neu oder alt" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 5: Werkzeug-Gerüst (Umgebung, Speicherbremse, Browser-Zugang)
Files:
- Create in
KW:.gitignore(dist/ shots/ tmp/ sperren/ node_modules/ strang.env),package.json(private,type: module, keine Abhängigkeiten),env.sh,check.sh,run-browser.sh,browser.mjs - Test:
KW/test/geruest.test.mjs
Interfaces:
-
Produces:
env.sh(nur persource): exportiertKW,WT(Vorgabe/home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht, überschreibbar durchKW/strang.envbzw. die Datei ausKN_STRANG_ENV),TMPDIR=KW/tmp(wird angelegt),FIXTURE_TODAY(Vorgabe2026-10-16),PLAYWRIGHT_CORE=/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs,CHROMIUM=/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome.return 2, wennWT/server/index.jsfehlt.pipefailan, keincd.schwer_sperren:flockaufKW/sperren/schwer.lock(Deskriptor 8), setztKN_SPERRE=1. IstKN_SPERREgesetzt, kehrt sie sofort zurück.speicher_pruefen: liestMemAvailableaus${KN_MEMINFO:-/proc/meminfo}. Unter${KN_MIN_FREI_MB:-2000}MB: stderr „zu wenig freier Speicher, warten und den Controller melden“, Rückgabe 3.dev_null_pruefen: Rückgabe 4 mit Meldung, wennls -l /dev/nullnicht mitcrw-rw-rw-beginnt.check.sh <test/datei.test.js>… | --alle: arbeitet inWT. Ohne Argument, fehlende Datei oder unbekannte Option: Exit 2. Danachspeicher_pruefen || exit 3,schwer_sperren.- Dateien:
timeout 900 nice -n 10 systemd-run --scope -q -p MemoryMax=2000M -p MemorySwapMax=0 node --max-old-space-size=1024 --test --test-concurrency=1 <Dateien>. --alle:timeout 2400 nice -n 10 systemd-run --scope -q -p MemoryMax=2500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1024 node --test --test-concurrency=2 test/*.test.js.- Ausgabe: die Zeilen
# tests N,# pass N,# fail N(beide Reporter-Formate,ℹwird zu#), Protokoll inKW/tmp/test.log. Exit 1 ohne Zählzeile, sonst der Exit des Laufs. Am Endedev_null_pruefen.
- Dateien:
run-browser.sh <skript.mjs> [args…]: Exit 2 ohne oder mit fehlendem Skript (Pfad vorsource env.shauflösen). Dannspeicher_pruefen || exit 3,schwer_sperren,exec systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1024 timeout 1800 node "$skript" "$@".browser.mjs:export async function starteBrowser(extraArgs = []): Promise<Browser>. Chromium ausCHROMIUMmit--no-sandbox --disable-dev-shm-usage --disable-gpu --host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE 127.0.0.1. OhneKN_SPERRE=1Exit 2 mit „nur über run-browser.sh starten“. Fehlt Playwright-Core 1.62.1 oderCHROMIUM: Exit 2.
-
Step 1: Ordner und Git anlegen, Failing test schreiben (
KW/test/geruest.test.mjs)
Zuerst: mkdir -p /root/reports/kundenansicht-neu-2026-10-15/werkzeug/test && git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug init -b main.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { spawn, spawnSync } from 'node:child_process'
import { mkdirSync, writeFileSync } from 'node:fs'
const KW = new URL('..', import.meta.url).pathname.replace(/\/$/, '')
const WT = '/home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht'
mkdirSync(`${KW}/tmp/fake-wt/server`, { recursive: true })
const sh = (script, env = {}) => spawnSync('bash', ['-c', script], { encoding: 'utf8', env: { ...process.env, ...env }, timeout: 20000 })
test('env.sh setzt KW, WT, TMPDIR und den festen Tag', () => {
const r = sh(`source ${KW}/env.sh && printf '%s\\n' "$KW" "$WT" "$TMPDIR" "$FIXTURE_TODAY"`)
assert.deepEqual([r.status, r.stdout.trim().split('\n')], [0, [KW, WT, `${KW}/tmp`, '2026-10-16']])
})
test('strang.env überschreibt WT; ein WT ohne server/index.js ist Exit 2', () => {
writeFileSync(`${KW}/tmp/fake-wt/server/index.js`, '')
writeFileSync(`${KW}/tmp/strang-gut.env`, `export WT=${KW}/tmp/fake-wt\n`)
writeFileSync(`${KW}/tmp/strang-schlecht.env`, `export WT=${KW}/tmp\n`)
assert.equal(sh(`source ${KW}/env.sh && echo $WT`, { KN_STRANG_ENV: `${KW}/tmp/strang-gut.env` }).stdout.trim(), `${KW}/tmp/fake-wt`)
assert.equal(sh(`source ${KW}/env.sh`, { KN_STRANG_ENV: `${KW}/tmp/strang-schlecht.env` }).status, 2)
})
test('speicher_pruefen: unter 2000 MB Exit 3 mit Meldung, genug ist 0', () => {
writeFileSync(`${KW}/tmp/meminfo-knapp`, 'MemAvailable: 1000000 kB\n')
writeFileSync(`${KW}/tmp/meminfo-genug`, 'MemAvailable: 4000000 kB\n')
const knapp = sh(`source ${KW}/env.sh && speicher_pruefen`, { KN_MEMINFO: `${KW}/tmp/meminfo-knapp` })
assert.deepEqual([knapp.status, /zu wenig freier Speicher/.test(knapp.stderr)], [3, true])
assert.equal(sh(`source ${KW}/env.sh && speicher_pruefen`, { KN_MEMINFO: `${KW}/tmp/meminfo-genug` }).status, 0)
})
test('schwer_sperren ist wiedereintrittsfest und hält andere Prozesse draußen', async () => {
assert.match(sh(`source ${KW}/env.sh && schwer_sperren && schwer_sperren && echo ok`, { KN_SPERRE: '' }).stdout, /ok/)
const halter = spawn('bash', ['-c', `source ${KW}/env.sh && schwer_sperren && sleep 3`], { env: { ...process.env, KN_SPERRE: '' } })
await new Promise((r) => setTimeout(r, 700))
assert.equal(spawnSync('flock', ['-n', `${KW}/sperren/schwer.lock`, 'true']).status, 1)
await new Promise((r) => halter.on('exit', r))
})
test('Fehlbedienung von check.sh und run-browser.sh ist Exit 2', () => {
for (const cmd of [`${KW}/check.sh`, `${KW}/check.sh test/gibt-es-nicht.test.js`, `${KW}/check.sh --unbekannt`, `${KW}/run-browser.sh`, `${KW}/run-browser.sh ${KW}/tmp/gibt-es-nicht.mjs`])
assert.equal(sh(cmd, { KN_SPERRE: '1' }).status, 2, cmd)
})
test('starteBrowser ohne Sperre endet mit Exit 2 und startet nichts', () => {
const r = spawnSync('node', ['-e', `import('${KW}/browser.mjs').then((m) => m.starteBrowser())`], { encoding: 'utf8', env: { ...process.env, KN_SPERRE: '' } })
assert.deepEqual([r.status, /run-browser\.sh/.test(r.stderr)], [2, true])
})
- Step 2: Test laufen lassen, Fehlschlag prüfen
Run: cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/geruest.test.mjs
Expected: FAIL (env.sh fehlt).
- Step 3: Implementieren
Dateien nach den Interfaces. Vorbild (nur lesen, nie ausführen): env.sh, check.sh, run-browser.sh, browser.mjs unter /root/reports/ticketsystem-design-2026-10-04/werkzeug/. Abweichungen stehen in den Interfaces.
- Step 4: Test grün, dann
check.shan einer echten Portal-Testdatei
Run: cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/geruest.test.mjs
Expected: PASS, fail 0.
Run: /root/reports/kundenansicht-neu-2026-10-15/werkzeug/check.sh test/kundenansichtSchalter.test.js
Expected: Exit 0 mit # tests 7, # pass 7, # fail 0.
- Step 5: Commit (Werkzeug-Git)
KW=/root/reports/kundenansicht-neu-2026-10-15/werkzeug
git -C $KW add .gitignore package.json env.sh check.sh run-browser.sh browser.mjs test/geruest.test.mjs
git -C $KW commit -m "Werkzeug: Umgebung, Speicherbremse, Sperre, Browser nur über run-browser.sh" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 6: Fälle und Testdaten-Backend
Files:
- Create in
KW:fixtures/helfer.mjs,fixtures/basis.mjs,fixtures/faelle.mjs,fixtures/endpunkte.mjs,fixtures/index.mjs,backend.mjs - Test:
KW/test/backend.test.mjs
Interfaces:
- Produces:
helfer.mjs:heute(): Date(UTC-Mitternacht ausprocess.env.FIXTURE_TODAY, sonst echter Tag, ungültiger Wert wirft „ungültig“),jetzt(): Date(fester Tag 12:00 UTC, sonstnew Date()),tag(offset): Date,ymd(d): string,iso(offset, hhmm = '12:00'): string.faelle.mjs:FALL_NAMEN: string[],ladeFall(name): Fall(tiefe Kopie, unbekannter Name wirft).Fall = { name, titel, sitzung: 'kunde'|'kundenansicht'|'keine', schreibsperre: boolean, fehler: string[], daten: { [endpunktname]: json } }.- Die ersten vierzehn Einträge von
FALL_NAMENsind die Kernfälle der Tabelle unten, in genau dieser Reihenfolge. Das ist der Vertrag von P0. - Weitere Fälle hängen spätere Pakete hinter die Kernfälle an, jedes Paket in einer eigenen Datei (P4
fixtures/module.mjs, P7fixtures/faelle-p7.mjs, sonstfixtures/faelle-p<N>.mjs) mit genau einer Importzeile infaelle.mjs. Der Pin im Test undboot.jsbleiben dabei unberührt (siehe Test und Task 11).
- Die ersten vierzehn Einträge von
endpunkte.mjs:ENDPUNKTE: Array<{ name, methode, muster, schreibend?: true, antwort?(ctx): { status?, json?, text?, type? } }>.musterkennt:param.ctx = { fall, zustand, params, query, body, methode, pfad }. Ohneantwortliefert ein GETctx.zustand[name].index.mjsre-exportiertFALL_NAMEN,ladeFall,ENDPUNKTE.backend.mjs:createBackend({ fixtures, fall }): { handle(anfrage), calls, violations, zustand }mitanfrage = { method, url, headers?, body? }und Antwort{ status, headers, body: string }. Pfade außerhalb von/api/ergebennull.- Unbekannter
/api/*-Pfad: 404{ error, code: 'vorschau_unbekannt' }und Verstoß{ kind: 'unknown-api', method, pfad }. Ausnahme im Handler: 500 und{ kind: 'backend-error', … }. - Sitzung
keine: alles außerauth.me,auth.login,auth.login-status,auth.logoutergibt 401{ error: 'Nicht angemeldet' }, bisauth.loginerfolgreich war. schreibsperre: jedes Nicht-GET außerauth.endeundauth.logoutergibt 403{ code: 'view_blocked', error: 'In der Kundenansicht nicht möglich.' }.fehler: die genannten Endpunkte ergeben 500{ error: 'Test: absichtlicher Fehler' }.- Schreibende Endpunkte ändern nur
zustand(Kopie je Backend). Checkout- und Wechsel-Antworten tragenurl: '/vorschau-hinweis.html'. - Antworten tragen
content-type(JSON mitcharset=utf-8) undcache-control: no-store.
- Unbekannter
Endpunkte (Formen aus dem Code geprüft: routes/customerData.js, projects.js, chat.js, features.js, auth.js, Karte B4):
| Name | Methode Muster | Form |
|---|---|---|
auth.me |
GET /api/auth/me |
wie auth.js plus ansicht: 'neu', customer.kundenart, customer.kundenartWirksam (V4) |
auth.login, auth.login-status, auth.logout, auth.password, auth.ende |
/api/auth/login, /login-status, /logout, /password, /kundenansicht/ende |
{ success, role, customer }, { blocked, retryAfterSeconds }, { success }, { success }, { redirect } |
contact, activity, invoices, auftraege, avatar |
GET /api/contact, /activity, /invoices, /auftraege, /avatar.svg |
{ name, responseTime }, { tickets }, { invoices, incomplete } (Beträge in Euro), { aktiv, auftraege }, SVG-Text |
projects, projects.git, projects.files, projects.work |
GET /api/projects[/:id/git|files|work] |
{ projects }, { changes }, { totalFiles, totalSizeBytes, truncated, topLevel }, { order, entries, totalMinutes } |
features, chat.unread, chat.messages, chat.read, chat.send |
/api/features, /api/chat/unread-count, /messages (GET, POST), /read |
{ features } (nur enabled), { unread }, { messages, unread }, { updated }, { message } |
abo, abo.takeover, abo.checkout, abo.switch-preview, abo.switch, abo.storage-preview, abo.storage, abo.public-cancel, abo.public-withdraw |
/api/abo/… |
abo: Form von GET /api/abo; takeover: { requests, conditionsVersion, limits }; Schreibende: { url } bzw. { ok: true, receivedAt } |
funktionen, funktionen.checkout, funktionen.zeiten-einschalten |
/api/funktionen[/…] |
Form von GET /api/funktionen plus V5 (modell, kundenart, abo, katalog samt katalog[].consent und katalog[].hinweis, siehe unter den Fällen); anfrageStatus steht in projects[].features.<shop|unterkunft> (Vertragsfrage 9) |
termine, shop, unterkunft, zeiten, news |
GET /api/termine/einstellungen, /api/shop/status, /api/unterkunft/status, /api/zeiten/:projectId, /api/news |
Formen aus Karte B4 |
fristen |
GET /api/fristen |
V6 { fristen, fruehestesEnde, anzeigen } |
Fälle, in dieser Reihenfolge als FALL_NAMEN; faelle.mjs leitet sie aus basis() ab. Basis = „Betrieb mit Wartung“: Kundin Berta Berg, Bäckerei Berg, berta@example.invalid, Wartung seit 40 Tagen mit ledger.balanceMinutes: 120, ein Projekt Bäckerei Berg mit previewUrl: '/vorschau/demo-baeckerei.html', zwei bezahlte Rechnungen, News aktiv, leere fristen.
| Fall | Abweichung von der Basis |
|---|---|
betrieb-wartung |
Basis |
hosting |
subscription.productKey: 'hosting', ledger: null |
ohne-abo |
subscription: null, ledger: null, funktionen.abo.produkt: null |
privat-frist |
kundenart: 'privat', Abo seit 5 Tagen, withdrawals: [{ type: 'abschluss', until: heute+9 }], withdrawalOpen: true, fristen.anzeigen: true, fruehestesEnde: heute+9 |
kundenart-leer |
kundenart: '', kundenartWirksam: 'privat', sonst wie privat-frist |
rechnung-offen |
eine Rechnung RE-2026-0042 mit amount: 50, balance: 50 |
chat-ungelesen |
unread: 3, drei Teamnachrichten |
funktionen-alle |
zwei Projekte mit allen Zuständen (off, ordered, setup, active, disabled) und allen anfrageStatus (offen, angenommen, abgelehnt, zurueckgezogen); Katalog mit Analyse als sechstem Eintrag |
vorschuss |
ledger.balanceMinutes: 0, vorschussMinutes: 150, erste usage mit einrichtung: true (V8) |
mehrere-websites |
drei Projekte |
mitarbeiter |
sitzung: 'kundenansicht', schreibsperre: true, auth.me als Kundenansicht (viewAs, viewId aus 32 mal a) |
abgemeldet |
sitzung: 'keine' |
fehler |
fehler: ['abo', 'invoices', 'funktionen'] |
leer |
keine Projekte, Rechnungen, Features, Nachrichten, kein Abo |
Katalog in funktionen (basis(), gilt für jeden Fall). Zusätzlich zu den Feldern aus V5 trägt jeder Katalogeintrag das Zustimmungs-Register, das P5 liefert und P3 (Knopf „Einschalten“, Gate-Ansichten kn-funktionen-einschalten*) und P4 (Zusammenfassung) verlangen. Fehlt es, bleibt der Knopf gesperrt, und die Gates dieser Pakete können nicht grün werden.
-
Jeder Eintrag außer
analyseträgtconsent: { version: '2026-10-20', knopf: 'Einschalten', items: [ { id: 'einschalten-zeile', text: 'Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung 2026-10-05).', haken: false }, { id: 'beginn', text: 'Bitte beginnt sofort mit der Einrichtung. Mir ist klar: Mit der vollständigen Einrichtung erlischt mein Widerrufsrecht, und wenn ich vorher widerrufe, zahle ich den bis dahin erbrachten Teil.', haken: true, nurPrivatInFrist: true } ] }. Beianalysestehtconsent: null(die Analyse nutztPOST /api/funktionen/analyse/activate). -
termine,shopundunterkunfttragen zusätzlichhinweis(ein Satz, du-Form, Testtext, zum BeispielDu bist für deinen Webshop und die Zahlungen selbst verantwortlich.).news,zeitenundanalysetragen kein Feldhinweis. -
Alles bleibt Testtext. Der echte Wortlaut kommt von P8 über den Server.
-
Step 1: Failing test schreiben (
KW/test/backend.test.mjs)
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
process.env.FIXTURE_TODAY = '2026-10-16'
const { createBackend } = await import('../backend.mjs')
const fixtures = await import('../fixtures/index.mjs')
const { FALL_NAMEN, ladeFall } = fixtures
const neu = (fall) => createBackend({ fixtures, fall })
async function ruf(b, method, pfad, body) {
const r = await b.handle({ method, url: `https://vorschau.test${pfad}`, headers: {}, body: body === undefined ? null : JSON.stringify(body) })
let json = null
try { json = JSON.parse(r.body) } catch { /* kein JSON */ }
return { status: r.status, json, headers: r.headers }
}
const zustaende = (f) => [...new Set(f.projects.flatMap((p) => Object.values(p.features).map((x) => x.state)))].sort()
const anfragen = (f) => [...new Set(f.projects.flatMap((p) => Object.values(p.features).map((x) => x.anfrageStatus).filter(Boolean)))].sort()
test('die vierzehn Kernfälle stehen vorn in fester Reihenfolge; jeder Name lädt; unbekannter Fall wirft', () => {
// Pin nur auf den Anfang: spätere Pakete hängen eigene Fälle hinten an (README, Regel „Neue Fälle“). Diesen Pin nie anpassen.
assert.deepEqual(FALL_NAMEN.slice(0, 14), ['betrieb-wartung', 'hosting', 'ohne-abo', 'privat-frist', 'kundenart-leer', 'rechnung-offen', 'chat-ungelesen', 'funktionen-alle', 'vorschuss', 'mehrere-websites', 'mitarbeiter', 'abgemeldet', 'fehler', 'leer'])
assert.equal(new Set(FALL_NAMEN).size, FALL_NAMEN.length)
for (const n of FALL_NAMEN) assert.doesNotThrow(() => ladeFall(n), n)
assert.throws(() => ladeFall('gibt-es-nicht'))
})
test('jeder Fall beantwortet die Startaufrufe mit dem richtigen Status und ohne Verstoß', async () => {
const START = ['/api/auth/me', '/api/contact', '/api/abo', '/api/invoices', '/api/auftraege', '/api/activity', '/api/projects', '/api/features', '/api/chat/unread-count', '/api/fristen', '/api/funktionen']
for (const fall of FALL_NAMEN) {
const b = neu(fall)
for (const p of START) {
const soll = fall === 'abgemeldet' && p !== '/api/auth/me' ? 401 : fall === 'fehler' && ['/api/abo', '/api/invoices', '/api/funktionen'].includes(p) ? 500 : 200
assert.equal((await ruf(b, 'GET', p)).status, soll, `${fall} ${p}`)
}
assert.deepEqual(b.violations, [], fall)
}
})
const WERTE = [ // [Fall, Pfad, Auswahl aus der JSON-Antwort, erwarteter Wert]
['betrieb-wartung', '/api/auth/me', (j) => [j.ansicht, j.customer.name, j.customer.companyName, j.customer.kundenart, j.customer.kundenartWirksam], ['neu', 'Berta Berg', 'Bäckerei Berg', 'unternehmen', 'unternehmen']],
['betrieb-wartung', '/api/abo', (j) => [j.subscription.productKey, j.ledger.balanceMinutes], ['wartung', 120]],
['betrieb-wartung', '/api/fristen', (j) => j, { fristen: [], fruehestesEnde: null, anzeigen: false }],
['betrieb-wartung', '/api/projects', (j) => j.projects.length, 1],
['betrieb-wartung', '/api/funktionen', (j) => j.katalog.map((k) => k.key), ['news', 'termine', 'shop', 'unterkunft', 'zeiten']],
['hosting', '/api/abo', (j) => [j.subscription.productKey, j.ledger], ['hosting', null]],
['ohne-abo', '/api/abo', (j) => j.subscription, null],
['privat-frist', '/api/fristen', (j) => [j.anzeigen, j.fruehestesEnde.slice(0, 10), j.fristen[0].quelle], [true, '2026-10-25', 'abschluss']],
['privat-frist', '/api/abo', (j) => [j.subscription.withdrawalOpen, j.subscription.withdrawals[0].type, j.subscription.withdrawals[0].until.slice(0, 10)], [true, 'abschluss', '2026-10-25']],
['kundenart-leer', '/api/auth/me', (j) => [j.customer.kundenart, j.customer.kundenartWirksam], ['', 'privat']],
['rechnung-offen', '/api/invoices', (j) => j.invoices.filter((i) => i.balance > 0).map((i) => [i.number, i.balance]), [['RE-2026-0042', 50]]],
['chat-ungelesen', '/api/chat/unread-count', (j) => j.unread, 3],
['vorschuss', '/api/abo', (j) => [j.ledger.balanceMinutes, j.ledger.vorschussMinutes, j.ledger.usage[0].einrichtung], [0, 150, true]],
['funktionen-alle', '/api/funktionen', (j) => [zustaende(j), anfragen(j), j.katalog.map((k) => k.key).at(-1)], [['active', 'disabled', 'off', 'ordered', 'setup'], ['abgelehnt', 'angenommen', 'offen', 'zurueckgezogen'], 'analyse']],
['mehrere-websites', '/api/projects', (j) => j.projects.length, 3],
['mitarbeiter', '/api/auth/me', (j) => [j.viewAs, j.viewId.length], [true, 32]],
['abgemeldet', '/api/auth/me', (j) => j, { authenticated: false }],
['fehler', '/api/abo', (j) => j, { error: 'Test: absichtlicher Fehler' }],
['leer', '/api/projects', (j) => j, { projects: [] }],
['leer', '/api/invoices', (j) => j, { invoices: [], incomplete: false }],
]
for (const [fall, pfad, wahl, soll] of WERTE) test(`${fall} ${pfad}`, async () => assert.deepEqual(wahl((await ruf(neu(fall), 'GET', pfad)).json), soll))
test('Katalog trägt das Zustimmungs-Register (V5): consent außer bei analyse, hinweis nur bei termine, shop, unterkunft', async () => {
for (const fall of ['betrieb-wartung', 'funktionen-alle', 'privat-frist']) {
const { katalog } = (await ruf(neu(fall), 'GET', '/api/funktionen')).json
for (const k of katalog) {
if (k.key === 'analyse') { assert.equal(k.consent, null, `${fall} analyse`); continue }
assert.deepEqual([k.consent.knopf, k.consent.items.map((i) => i.id), k.consent.items.map((i) => i.haken), k.consent.items[1].nurPrivatInFrist], ['Einschalten', ['einschalten-zeile', 'beginn'], [false, true], true], `${fall} ${k.key}`)
assert.ok(typeof k.consent.version === 'string' && k.consent.items.every((i) => i.text.length > 10), `${fall} ${k.key}`)
}
assert.deepEqual(katalog.filter((k) => Object.hasOwn(k, 'hinweis')).map((k) => k.key), ['termine', 'shop', 'unterkunft'], fall)
assert.ok(katalog.filter((k) => Object.hasOwn(k, 'hinweis')).every((k) => k.hinweis.length > 10 && !k.hinweis.includes('\n')), fall)
}
})
test('Schreiben ändert nur den Zustand dieses Backends', async () => {
const b = neu('chat-ungelesen')
await ruf(b, 'POST', '/api/chat/read', {})
assert.equal((await ruf(b, 'GET', '/api/chat/unread-count')).json.unread, 0)
assert.equal((await ruf(neu('chat-ungelesen'), 'GET', '/api/chat/unread-count')).json.unread, 3)
})
test('mitarbeiter: Schreibsperre mit view_blocked, Beenden erlaubt; abgemeldet: erst Login macht die Sitzung', async () => {
const m = neu('mitarbeiter')
const r = await ruf(m, 'POST', '/api/chat/messages', { text: 'hallo' })
assert.deepEqual([r.status, r.json.code, (await ruf(m, 'POST', '/api/auth/kundenansicht/ende', {})).status], [403, 'view_blocked', 200])
const a = neu('abgemeldet')
assert.equal((await ruf(a, 'GET', '/api/abo')).status, 401)
assert.equal((await ruf(a, 'POST', '/api/auth/login', { email: 'x@example.invalid', password: 'egal' })).json.success, true)
assert.equal((await ruf(a, 'GET', '/api/auth/me')).json.authenticated, true)
})
test('unbekannte API-Pfade: JSON-404 samt Verstoß; Nicht-API ist null; Checkout nie zu Stripe', async () => {
const b = neu('betrieb-wartung')
const r = await ruf(b, 'GET', '/api/gibt-es-nicht')
assert.deepEqual([r.status, r.json.code, b.violations], [404, 'vorschau_unbekannt', [{ kind: 'unknown-api', method: 'GET', pfad: '/api/gibt-es-nicht' }]])
assert.equal(await b.handle({ method: 'GET', url: 'https://vorschau.test/login.html' }), null)
assert.equal((await ruf(b, 'POST', '/api/abo/checkout', {})).json.url, '/vorschau-hinweis.html')
})
test('Fixtures enthalten nur Erfundenes: .invalid-Adressen, keine fremden Hosts', () => {
for (const name of FALL_NAMEN) {
const text = JSON.stringify(ladeFall(name))
for (const m of text.matchAll(/[\w.+-]+@([\w.-]+)/g)) assert.match(m[1], /\.invalid$/, `${name}: ${m[0]}`)
for (const m of text.matchAll(/https?:\/\/[^"\\]+/g)) assert.match(m[0], /\.invalid(\/|$)/, `${name}: ${m[0]}`)
}
})
test('heute ist fest, ein ungültiger Tag wirft; Backend und Fixtures importieren nichts aus node:', async () => {
const { heute } = await import('../fixtures/helfer.mjs')
assert.equal(heute().toISOString().slice(0, 10), '2026-10-16')
process.env.FIXTURE_TODAY = 'morgen'; assert.throws(() => heute(), /ungültig/); process.env.FIXTURE_TODAY = '2026-10-16'
for (const f of ['backend.mjs', ...readdirSync(new URL('../fixtures/', import.meta.url)).map((n) => `fixtures/${n}`)]) assert.ok(!/from\s+['"]node:|require\(/.test(readFileSync(new URL(`../${f}`, import.meta.url), 'utf8')), f)
})
- Step 2: Test laufen lassen, Fehlschlag prüfen
Run: cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/backend.test.mjs
Expected: FAIL mit Cannot find module auf backend.mjs.
- Step 3: Implementieren
Dateien nach Interfaces und Tabellen. Routing: Muster in einen Regex übersetzen, die erste Übereinstimmung von Methode und Pfad gewinnt. basis() baut jede Antwort in der Form der echten Router (Quellen in der Tabelle). Die Endpunkte, die erst V5, V6, V8 einführen, folgen wörtlich der Übersicht, der Katalog zusätzlich dem Absatz „Katalog in funktionen“ (V5 kennt consent und hinweis nicht, P5 liefert sie). Alle Zeitangaben laufen über helfer.mjs.
- Step 4: Test grün
Run: wie Step 2. Expected: PASS, fail 0.
- Step 5: Commit (Werkzeug-Git)
KW=/root/reports/kundenansicht-neu-2026-10-15/werkzeug
git -C $KW add fixtures/helfer.mjs fixtures/basis.mjs fixtures/faelle.mjs fixtures/endpunkte.mjs fixtures/index.mjs backend.mjs test/backend.test.mjs
git -C $KW commit -m "Werkzeug: vierzehn Fälle und Testdaten-Backend für alle Portal-Endpunkte" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 7: Vertragstest der Fixtures gegen die echten Router
Files:
- Create in
KW:vertrag-endpunkte.mjs - Test:
KW/test/vertrag.test.mjs
Interfaces:
-
Consumes:
ladeFall,ENDPUNKTE,createBackend(Task 6); Portal-Module per absolutem Import ausWT(process.env.WT, sonst der Vorgabewert):test/support/{appwriteMock,fakeStripe,testEnv,testApp,fixtures,schema}.js,server/routes/<datei>. -
Produces:
pfade(wert, p = '', aus = new Set()): Set<string>: Schlüsselpfade (.a.b,.liste[],.liste[].x).vertrag-endpunkte.mjs:VERTRAG: Array<{ name, fall, router /*Datei unter server/routes*/, mount, anfrage: { pfad, cookie: 'kunde'|'kundenansicht' }, schema: string[] /*schemaKeys-Namen*/, seed?(aw, fx), vorher?(config): () => void /*gibt die Rücksetz-Funktion zurück*/, nurImEchten: string[], nurInFixture: string[], vertragNeu: string[] }>undNICHT_PRUEFBAR: Array<{ name, grund }>. In den drei Listen steht*für beliebige Zeichen. Jeder Eintrag trägt einen Kommentar mit dem Grund.- Erste Welle:
auth.me(Kunde, Seeds wie Task 4),auth.meals Kundenansicht (Fallmitarbeiter),contact,projects(einwebsiteProjects-Dokument),features(einportalFeatures-Dokument mitenabled),chat.unread,chat.messages(eine Nachricht),abo(Fallbetrieb-wartung,wartungDoc),funktionen(vorhersetztconfig.termine.mode,config.shop.mode,config.unterkunft.mode,config.zeiten.modeauf'on', damit die echte Antwort die Obermenge der Schlüssel zeigt). vertragNeuenthält, was die Übersicht in V4 bis V8 als neu nennt und der Server noch nicht liefert (.customer.kundenart*,.modell,.abo*,.katalog*mit ausdrücklich.katalog[].consent*und.katalog[].hinweis, die V5 nicht nennt und P5 liefert,.projects[].features.*.anfrageStatus,.projects[].features.*.unlockedBy,.ledger.vorschussMinutes,.ledger.usage[].einrichtung*). Sobald P5 und P6 gemergt sind, meldet der Test diese Ausnahmen als überholt, und sie werden gestrichen. Dann prüft er die Schlüssel gegen den echten Server.NICHT_PRUEFBARmit Grund:invoices(Stripe, InvoiceNinja),auftraege(Schalter),activity(Elementform braucht Ticket- und Arbeitsblatt-Seeds, P1 ergänzt),projects.git,projects.files(Gitea),projects.work,abo.takeover,termine,shop,unterkunft,zeiten,news(P4 nimmt sie auf),fristen(Route entsteht erst mit P5),avatar(SVG), alle schreibenden Endpunkte und dieauth.*-Schreibenden.
-
Step 1: Failing test schreiben (
KW/test/vertrag.test.mjs)
Aufbau wie kundenansichtGuard.test.js:1-30: Mock mit der Vereinigung aller schema-Namen, startFakeStripe, useTestEnv, Imports per import(\${WT}/…`). Eine Test-App mit allen Routern (startTestApp(Object.fromEntries(VERTRAG.map((e) => [e.mount, router])))). fxist das Modul${WT}/test/support/fixtures.js (customerCookie, kundenansichtCookie, seedFixtures, wartungDoc), config das Portal-config, rawGetaustestApp.js, pfade, VERTRAG, NICHT_PRUEFBARausvertrag-endpunkte.mjs, ENDPUNKTEundcreateBackend` aus dem Werkzeug.
const passt = (muster, p) => new RegExp(`^${muster.replace(/[.[\]]/g, '\\$&').replace(/\*/g, '.*')}$`).test(p)
const erlaubt = (liste, p) => liste.some((m) => passt(m, p))
const cookies = { kunde: customerCookie('c1'), kundenansicht: kundenansichtCookie() }
for (const e of VERTRAG) test(`Vertrag ${e.name} (${e.fall})`, async () => {
await e.seed?.(aw, fx)
const zurueck = e.vorher?.(config)
const echtRes = await rawGet(e.anfrage.pfad, { cookie: cookies[e.anfrage.cookie] })
zurueck?.()
assert.equal(echtRes.status, 200, e.name)
const fixRes = await createBackend({ fixtures, fall: e.fall }).handle({ method: 'GET', url: `https://vorschau.test${e.anfrage.pfad}` })
const echt = pfade(echtRes.body); const fix = pfade(JSON.parse(fixRes.body))
const neuErlaubt = [...e.nurInFixture, ...e.vertragNeu]
assert.deepEqual([...echt].filter((p) => !fix.has(p) && !erlaubt(e.nurImEchten, p)), [], 'echte Schlüssel fehlen in der Fixture')
assert.deepEqual([...fix].filter((p) => !echt.has(p) && !erlaubt(neuErlaubt, p)), [], 'Fixture erfindet Schlüssel')
const verwaist = [...e.nurImEchten.filter((m) => ![...echt].some((p) => !fix.has(p) && passt(m, p))), ...neuErlaubt.filter((m) => ![...fix].some((p) => !echt.has(p) && passt(m, p)))]
assert.deepEqual(verwaist, [], 'Ausnahme deckt keine Abweichung (mehr): streichen')
})
test('jeder Endpunkt der Fixtures ist geprüft oder begründet ausgenommen', () => {
const bekannt = new Set([...VERTRAG.map((e) => e.name), ...NICHT_PRUEFBAR.map((e) => e.name)])
assert.deepEqual(ENDPUNKTE.filter((e) => !bekannt.has(e.name)).map((e) => e.name), [])
for (const e of NICHT_PRUEFBAR) assert.ok(e.grund.length > 10, e.name)
})
test('pfade(): Objekte, Arrays, Blätter', () => {
assert.deepEqual([...pfade({ a: { b: 1 }, l: [{ x: 1 }, { y: 2 }], leer: [] })].sort(), ['.a', '.a.b', '.l', '.l[]', '.l[].x', '.l[].y', '.leer', '.leer[]'])
})
- Step 2: Test laufen lassen, Fehlschlag prüfen
Run: cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/vertrag.test.mjs
Expected: FAIL mit Cannot find module auf vertrag-endpunkte.mjs.
- Step 3: Implementieren
pfade rekursiv: Arrays fügen ${p}[] hinzu und vereinigen ihre Elemente, Objekte fügen ${p}.${key} hinzu. vertrag-endpunkte.mjs mit den Einträgen der ersten Welle. Zeigt der Test Abweichungen, korrigiere zuerst die Fixture. Ausnahmen nur mit Grund (zum Beispiel „Elementform braucht Seed“). Echte Aufrufe und Seeds richten sich nach test/aboRoutes.test.js:1-45, test/funktionenRoutes.test.js:1-45 und Task 4.
- Step 4: Test grün
Run: wie Step 2. Expected: PASS, fail 0. Prüfe von Hand: Jede Ausnahmezeile hat einen Kommentar mit Grund.
- Step 5: Commit (Werkzeug-Git)
KW=/root/reports/kundenansicht-neu-2026-10-15/werkzeug
git -C $KW add vertrag-endpunkte.mjs test/vertrag.test.mjs
git -C $KW commit -m "Werkzeug: Vertragstest der Fixtures gegen die echten Portal-Router" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 8: Prüfungen und Beobachter
Files:
- Create in
KW:checks.mjs,konsole.mjs - Test:
KW/test/checks.selftest.mjs(Browser, läuft mitrun-browser.sh)
Interfaces:
-
Consumes:
starteBrowser()(Task 5). -
Produces:
checks.mjs:SEITEN_PRUEFUNGEN = ['dark-surface', 'low-contrast', 'overflow-x', 'lila'],runChecks(seite, { ignorieren = {} } = {}): Promise<Array<{ check, selector, detail, ignored?: true }>>.ignorierenbildet Prüfname auf CSS-Selektoren ab. Treffer in oder unter einem solchen Element bleiben im Ergebnis (ignored: true).dark-surface: sichtbares Element mit eigenem Hintergrund (Alpha ab 0,5) und relativer Helligkeit unter 0,5. Ausgenommen: Border-Boxen bis 12 mal 12 px und die Farbergb(17, 17, 17)(#111, Akzent) bei Elementen bis 25 % der Viewport-Fläche.low-contrast: Textkontrast unter 3:1 gegen den nächsten deckenden Hintergrund. Ausgenommen:disabled,aria-disabled, Platzhalter.overflow-x:documentElement.scrollWidth > documentElement.clientWidth + 1.lila: sichtbares Element mit berechneter Farbe (Text, Hintergrund, Rand, Umriss, Schatten,fill,stroke)rgb(157, 61, 254)oderrgba(157, 61, 254, …); dazu der Text aller geladenen Stylesheets mit#9d3dfeoderrgba(157,61,254(Leerzeichen und Groß-/Kleinschreibung egal), auch in ungenutzten Regeln.
konsole.mjs:beobachte(seite, { fall = '' } = {}): { ende(): { 'console-error': string[], 'external-request': string[] } }. Sie sammeltconsole.errorundpageerror(gefiltert durcherwartetKonsole) und jede Anfrage mit Schemahttp(s)oderws(s), deren Host nicht127.0.0.1ist (Detail = URL).erwartetKonsole(text, fall): booleanist wahr für Chromiums „Failed to load resource: the server responded with a status of 500“ nur im Fallfehler.
-
Step 1: Failing test schreiben (
KW/test/checks.selftest.mjs; ein Browser inbefore, inaftergeschlossen)
import { test, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { starteBrowser } from '../browser.mjs'
import { runChecks } from '../checks.mjs'
import { beobachte, erwartetKonsole } from '../konsole.mjs'
let browser, seite
before(async () => { browser = await starteBrowser(); seite = await (await browser.newContext({ viewport: { width: 390, height: 844 } })).newPage(); seite.setDefaultTimeout(180000) })
after(async () => { await browser?.close() })
const KOPF = '<!doctype html><meta name="viewport" content="width=device-width"><body style="margin:0;background:#fff;color:#1a1f36">'
const FAELLE = [ // [Prüfung, Name, HTML, schlägt an]
['dark-surface', 'dunkle Fläche', '<div style="width:300px;height:300px;background:#000"></div>', true],
['dark-surface', '#111 als kleiner Knopf', '<button style="background:#111;color:#fff;width:120px;height:40px">Ok</button>', false],
['dark-surface', '#111 über die ganze Seite', '<div style="position:fixed;inset:0;background:#111"></div>', true],
['low-contrast', 'hellgrauer Text', '<p style="color:#c9ced6">Hallo Welt</p>', true],
['low-contrast', 'Zweittext #5e6678', '<p style="color:#5e6678">Hallo Welt</p>', false],
['overflow-x', 'zu breit', '<div style="width:2000px;height:10px"></div>', true],
['overflow-x', 'passt', '<div style="width:100%;height:10px"></div>', false],
['lila', 'Textfarbe', '<p style="color:#9d3dfe">x</p>', true],
['lila', 'Schatten in unbenutzter Regel', '<style>.nie{box-shadow:0 0 0 1px rgba(157, 61, 254, .3)}</style><p>x</p>', true],
['lila', 'neutrales Blau', '<p style="color:#075985">x</p>', false],
]
for (const [check, name, html, schlaegtAn] of FAELLE) test(`${check}: ${name}`, async () => {
await seite.setContent(KOPF + html)
const treffer = (await runChecks(seite)).filter((t) => t.check === check && !t.ignored)
assert.equal(treffer.length > 0, schlaegtAn, JSON.stringify(treffer))
})
test('ignorieren markiert Treffer, statt sie zu löschen', async () => {
await seite.setContent(KOPF + '<div id="x" style="width:300px;height:300px;background:#000"></div>')
const t = (await runChecks(seite, { ignorieren: { 'dark-surface': ['#x'] } })).filter((r) => r.check === 'dark-surface')
assert.ok(t.length > 0 && t.every((r) => r.ignored === true))
})
test('console-error zählt; der absichtliche 500er ist nur im Fall fehler erwartet', async () => {
const text = 'Failed to load resource: the server responded with a status of 500 (Internal Server Error)'
assert.deepEqual([erwartetKonsole(text, 'fehler'), erwartetKonsole(text, 'betrieb-wartung')], [true, false])
const b = beobachte(seite, { fall: 'betrieb-wartung' })
await seite.evaluate(() => console.error('boom'))
assert.equal(b.ende()['console-error'].length, 1)
})
test('external-request meldet fremde Hosts', async () => {
await seite.route('**/*', (r) => r.abort())
const b = beobachte(seite)
await seite.setContent(KOPF + '<img src="https://bild.example.invalid/x.png">')
await seite.waitForTimeout(300)
assert.deepEqual(b.ende()['external-request'], ['https://bild.example.invalid/x.png'])
await seite.unroute('**/*')
})
- Step 2: Test laufen lassen, Fehlschlag prüfen
Run: free -m, dann /root/reports/kundenansicht-neu-2026-10-15/werkzeug/run-browser.sh /root/reports/kundenansicht-neu-2026-10-15/werkzeug/test/checks.selftest.mjs
Expected: FAIL mit Cannot find module auf checks.mjs.
- Step 3: Implementieren
Nach den Interfaces. Vorbild (nur lesen): checks.mjs, konsole.mjs im WOMS-Werkzeug. Die Farbrechnung läuft in page.evaluate und benutzt nichts aus dem Modul. lila ist neu.
- Step 4: Test grün
Run: wie Step 2. Expected: PASS, fail 0; danach ls -l /dev/null mit crw-rw-rw-.
- Step 5: Commit (Werkzeug-Git)
KW=/root/reports/kundenansicht-neu-2026-10-15/werkzeug
git -C $KW add checks.mjs konsole.mjs test/checks.selftest.mjs
git -C $KW commit -m "Werkzeug: Seitenprüfungen (dunkel, Kontrast, Überlauf, Lila), Konsole und fremde Requests" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 9: Ansichten, Geräte und Gates
Files:
- Create in
KW:views.mjs,gates.mjs - Test:
KW/test/gates.test.mjs
Interfaces:
-
Consumes:
SEITEN_PRUEFUNGEN(Task 8),FALL_NAMEN(Task 6). -
Produces:
views.mjs:GERAETE = { handy: { width: 390, height: 844, hasTouch: true, isMobile: true }, 'handy-quer': { width: 844, height: 390, hasTouch: true, isMobile: true }, tablet: { width: 820, height: 1180, hasTouch: true, isMobile: true }, desktop: { width: 1440, height: 900, hasTouch: false, isMobile: false } },ZEITLIMIT_MS = 180000.ANSICHTEN: Array<{ id, fall, adresse, geraete: string[], warte: string /*CSS*/, schritte?(seite), bewegung?, standalone?, tastatur?: boolean, uhr?: 'fest'|'echt', zeitlimit?: number, ignorieren?: { [pruefung]: string[] } }>. P0 liefertplatzhalter(Fallbetrieb-wartung,/dashboard.html,handyunddesktop,warte: '#kn-platzhalter-ansicht:not(:empty)'),platzhalter-abgemeldet(Fallabgemeldet,handy) undlogin-alt(Fallabgemeldet,/login.html,handyunddesktop,warte: 'form'; die alte Seite ist dunkel und lila und zählt nur im Gatep0).kontextOptionen(geraet, { bewegung = false, standalone = false, sw = 'block' } = {})liefert{ viewport, hasTouch, isMobile, deviceScaleFactor: 1, reducedMotion: bewegung ? 'no-preference' : 'reduce', colorScheme: 'light', locale: 'de-DE', timezoneId: 'Europe/Berlin', serviceWorkers: sw }. Ein unbekanntes Gerät wirft.waehleAnsichten(auswahl: string[], ansichten = ANSICHTEN): Einträge sind exakte IDs, Präfix mit*am Ende oder Ausschluss mit!. Eine Liste nur aus Ausschlüssen gilt als['*', …]. Unbekannte ID, toter Eintrag oder leere Auswahl wirft; die Meldung nennt den Eintrag, bei leerer Auswahl das Wort „leer“.
gates.mjs:ALWAYS_ON = ['step-failed', 'external-request', 'console-error', 'unknown-api', 'backend-error'];GATES = { p0: { checks: ALWAYS_ON, ansichten: ['*'] } }(spätere Pakete ergänzen ihrp<N>, Vertragsfrage 5);pruefeGate(gate, ansichten = ANSICHTEN)wirft bei unbekannter Prüfung (erlaubt sindALWAYS_ONundSEITEN_PRUEFUNGEN) sowie bei toter oder leerer Auswahl;gateErgebnis(bericht, gate): { ok, verstoesse: Array<{ ansicht, geraet, check }> }. Bericht:Array<{ ansicht, geraet, issues: Array<{ check, ignored?: true }> }>. Gezählt wird, was inALWAYS_ONodergate.checkssteht, zur Auswahl gehört (ohne zu werfen) und nichtignoredist.
-
Step 1: Failing test schreiben (
KW/test/gates.test.mjs)
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { GERAETE, ANSICHTEN, ZEITLIMIT_MS, kontextOptionen, waehleAnsichten } from '../views.mjs'
import { ALWAYS_ON, GATES, gateErgebnis, pruefeGate } from '../gates.mjs'
import { FALL_NAMEN } from '../fixtures/index.mjs'
test('Geräte und Zeitlimit', () => {
assert.deepEqual(GERAETE.handy, { width: 390, height: 844, hasTouch: true, isMobile: true })
assert.ok(GERAETE['handy-quer'].height <= 500 && GERAETE['handy-quer'].hasTouch) // trifft die Handy-Abfrage der Hülle
assert.deepEqual([GERAETE.desktop.width, GERAETE.desktop.hasTouch, ZEITLIMIT_MS], [1440, false, 180000])
})
test('kontextOptionen: bewegungsarm, Service Worker gesperrt, feste Zone; unbekanntes Gerät wirft', () => {
const o = kontextOptionen('handy')
assert.deepEqual([o.viewport, o.hasTouch, o.reducedMotion, o.serviceWorkers, o.timezoneId, o.deviceScaleFactor], [{ width: 390, height: 844 }, true, 'reduce', 'block', 'Europe/Berlin', 1])
assert.deepEqual([kontextOptionen('desktop', { bewegung: true }).reducedMotion, kontextOptionen('desktop', { sw: 'allow' }).serviceWorkers], ['no-preference', 'allow'])
assert.throws(() => kontextOptionen('fernseher'))
})
test('Ansichten: eindeutige IDs, bekannte Fälle und Geräte, P0-Ansichten zuerst', () => {
assert.equal(new Set(ANSICHTEN.map((a) => a.id)).size, ANSICHTEN.length)
for (const a of ANSICHTEN) { assert.ok(FALL_NAMEN.includes(a.fall), a.id); for (const g of a.geraete) assert.ok(g in GERAETE, a.id) }
assert.deepEqual(ANSICHTEN.map((a) => a.id).slice(0, 3), ['platzhalter', 'platzhalter-abgemeldet', 'login-alt'])
})
test('waehleAnsichten: ID, Präfix, Ausschluss; Tippfehler werfen', () => {
const liste = [{ id: 'a' }, { id: 'tab-1' }, { id: 'tab-2' }, { id: 'b' }]
const ids = (auswahl) => waehleAnsichten(auswahl, liste).map((a) => a.id)
assert.deepEqual([ids(['tab-*', 'b']), ids(['*', '!tab-*']), ids(['!a'])], [['tab-1', 'tab-2', 'b'], ['a', 'b'], ['tab-1', 'tab-2', 'b']])
for (const [schlecht, muster] of [[['nichtda'], /nichtda/], [['xx-*'], /xx-/], [['!nichtda'], /nichtda/], [[], /leer/]]) assert.throws(() => waehleAnsichten(schlecht, liste), muster)
})
test('Gate p0 zählt nur die immer aktiven Prüfungen; ignored zählt nie; Auswahl wirft nicht', () => {
assert.deepEqual(GATES.p0.checks, ALWAYS_ON)
const bericht = [
{ ansicht: 'login-alt', geraet: 'handy', issues: [{ check: 'dark-surface' }, { check: 'lila' }] },
{ ansicht: 'platzhalter', geraet: 'handy', issues: [{ check: 'external-request' }, { check: 'console-error', ignored: true }] },
]
assert.deepEqual(gateErgebnis(bericht, GATES.p0), { ok: false, verstoesse: [{ ansicht: 'platzhalter', geraet: 'handy', check: 'external-request' }] })
assert.equal(gateErgebnis(bericht, { checks: [...ALWAYS_ON, 'dark-surface', 'lila'], ansichten: ['*', '!login-alt'] }).verstoesse.length, 1)
assert.equal(gateErgebnis(bericht, { checks: [...ALWAYS_ON, 'dark-surface'], ansichten: ['*'] }).verstoesse.length, 2)
assert.equal(gateErgebnis(bericht, { checks: ALWAYS_ON, ansichten: ['gibt-es-nicht-*'] }).ok, true)
})
test('pruefeGate wirft bei unbekannter Prüfung und toter Auswahl', () => {
assert.throws(() => pruefeGate({ checks: ['gibt-es-nicht'], ansichten: ['*'] }), /gibt-es-nicht/)
assert.throws(() => pruefeGate({ checks: ALWAYS_ON, ansichten: ['tot-*'] }), /tot-/)
assert.doesNotThrow(() => pruefeGate(GATES.p0))
})
- Step 2: Test laufen lassen, Fehlschlag prüfen
Run: cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/gates.test.mjs
Expected: FAIL mit Cannot find module auf views.mjs.
- Step 3: Implementieren
Beide Module nach den Interfaces, reine Funktionen ohne Browser.
- Step 4: Test grün
Run: wie Step 2. Expected: PASS, fail 0.
- Step 5: Commit (Werkzeug-Git)
KW=/root/reports/kundenansicht-neu-2026-10-15/werkzeug
git -C $KW add views.mjs gates.mjs test/gates.test.mjs
git -C $KW commit -m "Werkzeug: Ansichten, Geräte, Kontextoptionen und Gates" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 10: Statik-Server, Läufer shots.sh und Einstieg im Portal
Files:
- Create in
KW:statik-server.mjs,vorschau/seiten.mjs,shots.mjs,shots.sh - Create in
WT:test/ui/kundenansicht-neu.ui.mjs - Test:
KW/test/statik.test.mjs,KW/test/shots.selftest.mjs
Interfaces:
-
Consumes: Tasks 5, 6, 8, 9.
-
Produces:
vorschau/seiten.mjs(eine Quelle für Gates und Vorschau):SEITEN = [{ ziel: 'dashboard.html', quellen: ['neu/dashboard.html'] }, { ziel: 'login.html', quellen: ['neu/oeffentlich/login.html', 'login.html'] }, { ziel: 'kuendigen.html', quellen: ['neu/oeffentlich/kuendigen.html', 'kuendigen.html'] }, { ziel: 'widerrufen.html', quellen: ['neu/oeffentlich/widerrufen.html', 'widerrufen.html'] }];OHNE=sw.js,offline.htmlund alle Ziele und Quellen ausSEITEN;zuordnung(wurzel): Map<ziel, absolute Quelle>(die erste vorhandene Quelle gewinnt, fehlt keine ganz, fehlt das Ziel in der Map). Befehlszeilenode seiten.mjs <wurzel>: ZeilenP <ziel>\t<Quelle>je Zuordnung undO <relativ>jeOHNE-Eintrag (fürbauen.sh).statik-server.mjs:starteStatik({ wurzel, port = 0, abbilden = false, nginxFallback = false }): Promise<{ url, port, anfragen: string[], stop() }>, lauscht nur auf127.0.0.1.abbilden(Quellbaumpublic/): die Ziele ausSEITENkommen auszuordnung,/ergibt 302 auf/login.html.nginxFallback(gebauter Ordner): ein unbekannter Pfad liefertindex.htmlmit 200, auch mit Dateiendung (wie die Vorschau-nginx). Ohne die Option ergibt er 404 als Text, nie HTML.
shots.mjs:STANDALONE_PATCH: string(Skript füraddInitScript, dasmatchMedia('(display-mode: standalone)')wahr macht),lauf({ ansichten, gate, ausgabe, geraete, wurzel }): Promise<{ bericht, exitCode }>(gateist ein Name ausGATESoder ein Gate-Objekt; ohnewurzelgiltWT/publicmitabbilden: true, mitwurzelwird der Ordner unverändert ausgeliefert) undmain(argv): Promise<number>mit<ausgabe> [--only id,…] [--gate pN] [--geraete a,b](Exit 0, 1 bei verletztem Gate, 2 bei Fehlbedienung, schreibt<ausgabe>/report.json).- Je Ansicht und Gerät: neuer Kontext mit
kontextOptionen, frischescreateBackend,setFixedTime(jetzt())außer beiuhr: 'echt', beistandaloneder Patch,route('**/*')(same-origin/api/ans Backend, anderes same-origincontinue, Fremdesabortund alsexternal-requestgemeldet),beobachte,goto,waitForSelector(warte),schritte, beitastatur300 px weniger Höhe,runChecks, Screenshot ohnefullPagenach<ausgabe>/<w>x<h>/<id>.png, Kontext schließen. - Scheitern Warten oder Schritt, entsteht
step-failedohne Absturz. Die Ergebnisse vonbeobachteund die Backend-Verstöße (unknown-api,backend-error) kommen als{ check, detail }inissuesdes Berichts (detailist beiexternal-requestdie URL). Alle Operationen laufen mitzeitlimitder Ansicht, sonstZEITLIMIT_MS. Genau einstarteBrowser-Aufruf je Lauf.
- Je Ansicht und Gerät: neuer Kontext mit
shots.sh <ausgabe> [Optionen]: löst<ausgabe>relativ zum Aufrufer auf und führtrun-browser.sh shots.mjs …aus.test/ui/kundenansicht-neu.ui.mjs:const { main } = await import(\${process.env.KW}/shots.mjs`); process.exit(await main(process.argv.slice(2)))mit Kommentar zum Aufruf überrun-browser.sh`.
-
Step 1: Failing tests schreiben
KW/test/statik.test.mjs (node:test, kein Browser). Kopf: node:test, node:assert/strict, node:fs (mkdirSync, writeFileSync), starteStatik, zuordnung, SEITEN, OHNE; KW und WT wie in Task 5; Testordner unter KW/tmp/:
test('abbilden: /dashboard.html ist die neue Seite, / führt auf den Login, Fehlendes ist 404 als Text', async () => {
const s = await starteStatik({ wurzel: `${WT}/public`, abbilden: true })
const g = (p) => fetch(s.url + p, { redirect: 'manual' })
assert.match(await (await g('/dashboard.html')).text(), /data-kn-platzhalter/)
const w = await g('/'); assert.deepEqual([w.status, w.headers.get('location')], [302, '/login.html'])
assert.equal((await g('/login.html')).status, 200)
const weg = await g('/neu/gibt-es-nicht.js'); assert.deepEqual([weg.status, weg.headers.get('content-type')], [404, 'text/plain; charset=utf-8'])
await s.stop()
})
test('nginxFallback: fehlende Datei mit Endung liefert index.html mit 200, ohne die Option 404', async () => {
const d = `${KW}/tmp/statik-test`; mkdirSync(d, { recursive: true }); writeFileSync(`${d}/index.html`, '<p>INDEX</p>')
const mit = await starteStatik({ wurzel: d, nginxFallback: true }); const ohne = await starteStatik({ wurzel: d })
const a = await fetch(mit.url + '/neu/fehlt.js'); const b = await fetch(ohne.url + '/neu/fehlt.js')
assert.deepEqual([a.status, await a.text(), b.status], [200, '<p>INDEX</p>', 404])
await Promise.all([mit.stop(), ohne.stop()])
})
test('zuordnung: die neue öffentliche Seite hat Vorrang; OHNE enthält alle Ziele und Quellen', () => {
const d = `${KW}/tmp/seiten-test`; mkdirSync(`${d}/neu/oeffentlich`, { recursive: true })
for (const f of ['login.html', 'neu/oeffentlich/login.html', 'neu/dashboard.html']) writeFileSync(`${d}/${f}`, 'x')
const z = zuordnung(d)
assert.deepEqual([z.get('login.html'), z.get('dashboard.html'), z.has('kuendigen.html')], [`${d}/neu/oeffentlich/login.html`, `${d}/neu/dashboard.html`, false])
for (const s of SEITEN) for (const q of [s.ziel, ...s.quellen]) assert.ok(OHNE.includes(q), q)
assert.ok(OHNE.includes('sw.js') && OHNE.includes('offline.html'))
})
KW/test/shots.selftest.mjs (Browser, läuft mit run-browser.sh; Seiten als Dateien in KW/tmp/shots-seiten/). Kopf: node:test (test, before), node:assert/strict, node:fs (mkdirSync, writeFileSync, statSync, readFileSync), lauf und STANDALONE_PATCH aus shots.mjs, starteStatik, starteBrowser, kontextOptionen, ALWAYS_ON. Der Test hält nie zwei Browser gleichzeitig offen: lauf startet und schließt seinen eigenen, der Kontext-Test startet seinen erst im Testrumpf und schließt ihn dort.
const SD = `${KW}/tmp/shots-seiten`
const SEITEN_HTML = {
'gut.html': '<h1>Hallo</h1>', 'extern.html': '<h1>x</h1><img src="https://bild.example.invalid/x.png">',
'dunkel.html': '<h1 style="color:#fff">x</h1><div style="width:100%;height:600px;background:#000"></div>',
}
const ansicht = (id, extra = {}) => ({ id, fall: 'betrieb-wartung', adresse: `/${id}.html`, geraete: ['handy'], warte: 'h1', ...extra })
const starte = (ansichten, gate, geraete = ['handy']) => lauf({ wurzel: SD, ansichten, gate, geraete, ausgabe: `${KW}/tmp/shots-test` })
before(() => { mkdirSync(SD, { recursive: true }); for (const [n, h] of Object.entries(SEITEN_HTML)) writeFileSync(`${SD}/${n}`, `<!doctype html><meta charset="utf-8"><body style="margin:0;background:#fff">${h}`) })
test('gute Seite: Exit 0, PNG nicht leer, ein Bericht je Gerät', async () => {
const r = await starte([ansicht('gut', { geraete: ['handy', 'desktop'] })], 'p0', ['handy', 'desktop'])
assert.deepEqual([r.exitCode, r.bericht.map((b) => b.geraet)], [0, ['handy', 'desktop']])
assert.ok(statSync(`${KW}/tmp/shots-test/390x844/gut.png`).size > 1000)
})
test('fremder Request wird abgebrochen und gemeldet; das Gate schlägt an', async () => {
const r = await starte([ansicht('extern')], 'p0')
assert.equal(r.exitCode, 1)
assert.deepEqual(r.bericht[0].issues.filter((i) => i.check === 'external-request').map((i) => i.detail), ['https://bild.example.invalid/x.png'])
})
test('dunkle Seite: p0 grün, Gate mit dark-surface rot', async () => {
assert.equal((await starte([ansicht('dunkel')], 'p0')).exitCode, 0)
assert.equal((await starte([ansicht('dunkel')], { checks: [...ALWAYS_ON, 'dark-surface'], ansichten: ['*'] })).exitCode, 1)
})
test('fehlendes Warten: step-failed statt Absturz', async () => {
const r = await starte([ansicht('gut', { warte: '#gibt-es-nicht', zeitlimit: 1500 })], 'p0')
assert.deepEqual([r.exitCode, r.bericht[0].issues.some((i) => i.check === 'step-failed')], [1, true])
})
test('Kontexte: Handy ist coarse, Querformat trifft die Handy-Abfrage, Standalone nur per Patch', async () => {
const statik = await starteStatik({ wurzel: SD }); const browser = await starteBrowser()
const frage = async (geraet, opts = {}) => {
const k = await browser.newContext(kontextOptionen(geraet, opts)); const s = await k.newPage()
if (opts.standalone) await s.addInitScript(STANDALONE_PATCH)
await s.goto(`${statik.url}/gut.html`)
const r = await s.evaluate(() => ['(pointer: coarse)', '(pointer: coarse) and (max-height: 500px)', '(display-mode: standalone)'].map((q) => matchMedia(q).matches))
await k.close(); return r
}
try {
assert.deepEqual([await frage('handy'), await frage('handy-quer'), await frage('desktop'), await frage('handy', { standalone: true })], [[true, false, false], [true, true, false], [false, false, false], [true, false, true]])
} finally { await browser.close(); await statik.stop() }
})
test('Quelltext von shots.mjs: kein fullPage, Zeitlimit, genau ein starteBrowser', () => {
const src = readFileSync(`${KW}/shots.mjs`, 'utf8')
assert.deepEqual([/fullPage/.test(src), /ZEITLIMIT_MS/.test(src), (src.match(/starteBrowser\(/g) || []).length], [false, true, 1])
})
- Step 2: Tests laufen lassen, Fehlschlag prüfen
Run: cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/statik.test.mjs
Expected: FAIL mit Cannot find module auf statik-server.mjs.
- Step 3: Implementieren
Alle Dateien nach den Interfaces. Vorbild (nur lesen): statik-server.mjs, shots.mjs, shots.sh im WOMS-Werkzeug. Die Fallstricke aus Karte huelle-tests § 4 stecken in kontextOptionen und lauf: kein fullPage, reducedMotion: 'reduce' als Vorgabe, hasTouch für Handy, matchMedia-Patch, 180 s, ein Browser.
- Step 4: Tests grün
Run: cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/statik.test.mjs, dann free -m und /root/reports/kundenansicht-neu-2026-10-15/werkzeug/run-browser.sh /root/reports/kundenansicht-neu-2026-10-15/werkzeug/test/shots.selftest.mjs
Expected: PASS, fail 0.
Dann der Einstieg aus dem Portal: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && /root/reports/kundenansicht-neu-2026-10-15/werkzeug/run-browser.sh test/ui/kundenansicht-neu.ui.mjs /root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/shots-p0 --gate p0
Expected: Exit 0, report.json mit fünf Einträgen (platzhalter und login-alt je Handy und Desktop, platzhalter-abgemeldet Handy), Bilder unter 390x844/ und 1440x900/. Sieh dir platzhalter.png an: weiße Seite, „Kundenansicht neu“, „Ansicht: neu“.
- Step 5: Commits
KW=/root/reports/kundenansicht-neu-2026-10-15/werkzeug
git -C $KW add statik-server.mjs vorschau/seiten.mjs shots.mjs shots.sh test/statik.test.mjs test/shots.selftest.mjs
git -C $KW commit -m "Werkzeug: Statik-Server und Screenshot-Läufer mit Gates" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht
git add test/ui/kundenansicht-neu.ui.mjs
git commit -m "Kundenansicht neu: Einstieg für Sichtprüfungen über das Werkzeug" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 11: Vorschau-Bau (Service Worker, Start-Skript, Seiten-Patch, Abschlussprüfung)
Files:
- Create in
KW/vorschau/:sw-eintrag.mjs,boot.js,index-patchen.mjs,pruefe-dist.mjs,bauen.sh,hinweis.html,demo-baeckerei.html - Test:
KW/test/vorschau-bau.test.mjs
Interfaces:
-
Consumes:
createBackend,fixtures/index.mjs(Task 6),SEITEN,OHNE,zuordnungund die Befehlszeile vonseiten.mjs(Task 10). -
Produces:
sw-eintrag.mjs: Service Worker. Liest?fall=aus der eigenen Adresse und prüft den Namen gegenFALL_NAMEN(die Kernfälle und alle später angehängten; unbekannt ergibtbetrieb-wartung), hält ein Backend je Lebensdauer. Same-origin/api/geht ans Backend, fremde Origins (außer Navigationen) beantwortet er mitResponse.error(), sonstiges bleibt unberührt.installruftskipWaiting,activateruftclients.claim, die Nachrichtclaimholt eine ungesteuerte Seite.index-patchen.mjs:patche(html, boot): { html, skripte: Array<{ src?, type?, text? }> }und Befehlszeilenode index-patchen.mjs <quelle.html> <boot.js> <ausgabe.html>. Alle<script>-Elemente außerhalb von HTML-Kommentaren fliegen heraus (Kommentare vorher durch Platzhalter ersetzen), in Dokumentreihenfolge. Das Ergebnis trägt<meta name="robots" content="noindex">und im<head>das Inline-Skriptboot.jsmit Attributdata-skripte(JSON, als Attributwert maskiert). Fehler, wenn<head>fehlt oderboot.jsdie Zeichenfolge</scriptoder<!--enthält.boot.js(klassisch, läuft zuerst):- Fall lesen aus
?fall=, sonstlocalStorage['kn-vorschau'], sonstwindow.name, sonstbetrieb-wartung; jeder Zugriff intry/catch.boot.jsführt keine eigene Liste zur Prüfung: Ein Name wird weitergereicht, wenn er der Zeileconst FALL_MUSTER = /^[a-z0-9-]{1,40}$/;entspricht (genau diese Zeile, ein Muster pro Zeile), sonst giltbetrieb-wartung. Ob der Fall existiert, entscheidet der Worker. navigator.serviceWorker.registerumhüllen:/sw.jsliefert eine Stub-Registrierung{ scope: '/' }ohne Netzzugriff./vorschau-sw.js?fall=<fall>mit Scope/registrieren, aufcontrollerchangewarten (8 s bei neuem, 1,5 s bei aktivem Worker), danach genau einmal neu laden, abgesichert durchsessionStorage.- Die Skripte aus
data-skripteder Reihe nach laden (mitsrc, auchtype=module, je mitawait onload; Inline pertext), danach einmalDOMContentLoaded(aufdocument) undload(aufwindow) feuern. - Leiste „Vorschau · Testdaten“ im Shadow-DOM (
#kn-vorschau-leiste) mit Auswahl aus den vierzehn Kernfällen (FALL_NAMEN.slice(0, 14), die Namen stehen als Zeichenketten in einfachen Anführungszeichen inboot.js). Weitere Fälle späterer Pakete stehen nicht in der Leiste, sind aber über?fall=<name>erreichbar (Punkt 1); ist der aktive Fall keiner der vierzehn, zeigt die Leiste ihn zusätzlich als letzte Option. Ein Wechsel speichert den Fall, registriert den passenden Worker und lädt neu. Alle 20 s einpingan den Worker.
- Fall lesen aus
pruefe-dist.mjs:pruefeDist(ordner): string[](fehlende Ziele aussrc/hrefmit führendem/in HTML,import … from '/…'undimport('/…')in JS,url(/…)und@importin CSS, ohne Query und Hash). Befehlszeile: Exit 1 bei nicht leerer Liste.bauen.sh: baut nachSTAGE=$TMPDIR/vorschau-bau. Ein altesSTAGEfällt nur perrm -rfhintercase "$STAGE" in "$KW"/tmp/vorschau-bau). Schritte:schwer_sperren; Kopie vonWT/publicohneOHNE(Liste ausseiten.mjs); jeP-Zeile die Quelle durchindex-patchen.mjsnachSTAGE/<ziel>;index.html(mit<head>, Weiterleitung auf/login.html, ebenfalls gepatcht);hinweis.htmlalsvorschau-hinweis.html;demo-baeckerei.htmlalsvorschau/demo-baeckerei.html;vorschau-sw.jsper/home/webklar/apps/ticket.webklar.com/build/node_modules/.bin/esbuild sw-eintrag.mjs --bundle --platform=browser --format=iife --target=es2020 --minify --legal-comments=none --define:process.env.FIXTURE_TODAY=undefineduntersystemd-run --scope -q -p MemoryMax=1000M -p MemorySwapMax=0;vorschau-info.txt(Portal-Commit und Tag, Werkzeug-Commit, Zeit UTC);vorschau-manifest.txt(alle Dateien relativ, sortiert, eine je Zeile, ohne sich selbst);pruefe-dist.mjs STAGE. Fehltesbuild: Exit 2.hinweis.html(„Vorschau: Hier würde die Zahlung beginnen. Es wird nichts bestellt.“) unddemo-baeckerei.html(kleine weiße Beispielseite „Bäckerei Berg“): statisch, ohne externe Ressourcen.
-
Step 1: Failing test schreiben (
KW/test/vorschau-bau.test.mjs,node:test)
Kopf: node:test, node:assert/strict, node:fs (mkdirSync, writeFileSync, readFileSync), spawnSync, patche aus index-patchen.mjs, pruefeDist aus pruefe-dist.mjs, FALL_NAMEN, KW wie in Task 5 und const ESBUILD = '/home/webklar/apps/ticket.webklar.com/build/node_modules/.bin/esbuild'.
test('patche: Skripte raus in Reihenfolge, Kommentare bleiben, boot kommt in den Kopf', () => {
const html = '<!doctype html><html><head><title>t</title></head><body><!-- <script src="/kommentar.js"></script> --><script src="/a.js"></script><script type="module" src="/neu/b.js"></script><script>init();</script></body></html>'
const { html: aus, skripte } = patche(html, 'window.__boot = 1')
assert.deepEqual(skripte, [{ src: '/a.js' }, { type: 'module', src: '/neu/b.js' }, { text: 'init();' }])
assert.ok(!/<script[^>]*src="\/a\.js"/.test(aus) && aus.includes('<!-- <script src="/kommentar.js"></script> -->'))
assert.ok(aus.includes('<meta name="robots" content="noindex">') && aus.indexOf('window.__boot = 1') < aus.indexOf('</head>'))
const roh = /data-skripte="([^"]*)"/.exec(aus)[1].replaceAll('"', '"').replaceAll('<', '<').replaceAll('&', '&')
assert.deepEqual(JSON.parse(roh), skripte)
})
test('patche: </script im Inline-Text sprengt nichts; fehlender Kopf oder </script im Boot ist ein Fehler', () => {
const { html } = patche('<html><head></head><body><script>var s = "<\\/script>"</script></body></html>', 'x')
assert.equal((html.match(/<\/script>/g) || []).length, 1)
assert.throws(() => patche('<html><body></body></html>', 'x'), /head/)
assert.throws(() => patche('<html><head></head></html>', 'a</script>b'), /script/)
})
test('pruefeDist findet fehlende Dateien in HTML, JS-Importen und CSS (Query und Hash zählen nicht)', () => {
const d = `${KW}/tmp/dist-test`; mkdirSync(`${d}/neu`, { recursive: true })
writeFileSync(`${d}/a.html`, '<script type="module" src="/neu/x.js?v=1"></script><link rel="stylesheet" href="/s.css#h">')
writeFileSync(`${d}/neu/x.js`, "import { y } from '/neu/fehlt.js'\nimport('/neu/da.js')")
writeFileSync(`${d}/neu/da.js`, 'export const z = 1')
writeFileSync(`${d}/s.css`, 'a{background:url(/bild.png)}')
assert.deepEqual(pruefeDist(d).sort(), ['/bild.png', '/neu/fehlt.js'])
})
test('der Service-Worker-Eintrag bündelt für den Browser: kein node:, kein process', () => {
const aus = `${KW}/tmp/sw-test.js`
const r = spawnSync('systemd-run', ['--scope', '-q', '-p', 'MemoryMax=1000M', '-p', 'MemorySwapMax=0', ESBUILD, `${KW}/vorschau/sw-eintrag.mjs`, '--bundle', '--platform=browser', '--format=iife', '--target=es2020', '--define:process.env.FIXTURE_TODAY=undefined', `--outfile=${aus}`, '--log-level=warning'], { encoding: 'utf8' })
assert.equal(r.status, 0, r.stderr)
const code = readFileSync(aus, 'utf8')
assert.ok(!code.includes('node:') && !/\bprocess\./.test(code))
})
test('boot.js: kein </script, kein HTML-Kommentar, die vierzehn Kernfälle genannt, weitere Fälle nicht ausgesperrt', () => {
const boot = readFileSync(`${KW}/vorschau/boot.js`, 'utf8')
assert.ok(!boot.includes('</script') && !boot.includes('<!--'))
for (const n of FALL_NAMEN.slice(0, 14)) assert.ok(boot.includes(`'${n}'`), n) // nur die Kernfälle: spätere Pakete ändern boot.js nie
const muster = new RegExp(/FALL_MUSTER = \/(.+)\/;/.exec(boot)[1]) // jeder Name im Muster, den ein späteres Paket anhängt (m-*, privat-ohne-abo, …), kommt durch
assert.ok(['m-termine-aus', 'privat-ohne-abo', 'hosting-privat', 'leer'].every((n) => muster.test(n)) && ['../x', 'A B', '', 'x'.repeat(41)].every((n) => !muster.test(n)))
})
- Step 2: Test laufen lassen, Fehlschlag prüfen
Run: cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/vorschau-bau.test.mjs
Expected: FAIL mit Cannot find module auf index-patchen.mjs.
- Step 3: Implementieren
Nach den Interfaces. Vorbild (nur lesen): vorschau/{sw-eintrag.mjs,boot.js,index-patchen.mjs} im WOMS-Werkzeug. Abweichungen: Fall statt Daten/Szenario, kein Appwrite-Sitzungsstub, mehrere Seiten, Module und Inline-Skripte in Reihenfolge, Abfangen von register('/sw.js').
- Step 4: Test grün, dann ein Bau
Run: wie Step 2. Expected: PASS, fail 0.
Run: free -m, dann /root/reports/kundenansicht-neu-2026-10-15/werkzeug/vorschau/bauen.sh
Expected: Exit 0. In /root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/vorschau-bau/ liegen index.html, gepatchte dashboard.html und login.html (ohne eigene <script src>, mit data-skripte), vorschau-sw.js, vorschau-manifest.txt, vorschau-info.txt. sw.js und offline.html fehlen dort.
- Step 5: Commit (Werkzeug-Git)
KW=/root/reports/kundenansicht-neu-2026-10-15/werkzeug
git -C $KW add vorschau/sw-eintrag.mjs vorschau/boot.js vorschau/index-patchen.mjs vorschau/pruefe-dist.mjs vorschau/bauen.sh vorschau/hinweis.html vorschau/demo-baeckerei.html test/vorschau-bau.test.mjs
git -C $KW commit -m "Werkzeug: Vorschau-Bau mit Service Worker, Start-Skript und Dateien-Abschlussprüfung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 12: Vorschau im Browser prüfen
Files:
- Test:
KW/test/vorschau.selftest.mjs(Browser, läuft mitrun-browser.sh) - Änderungen an
boot.js,sw-eintrag.mjsoderbauen.shnur, wenn der Test sie begründet verlangt.
Interfaces:
-
Consumes:
bauen.sh(Task 11),starteStatik({ wurzel: STAGE, nginxFallback: true })(Task 10),starteBrowser(Task 5),kontextOptionen('desktop', { sw: 'allow' })(Task 9),patche(Task 11). -
Produces: nichts Neues. Der Test belegt das Verhalten der Vorschau so, wie nginx sie ausliefert.
-
Step 1: Failing test schreiben (
KW/test/vorschau.selftest.mjs)
Kopf: node:test (test, before, after), node:assert/strict, node:fs (writeFileSync, readFileSync), spawnSync, starteStatik, starteBrowser, kontextOptionen, patche. before: bauen.sh per spawnSync (die geerbte KN_SPERRE verhindert ein zweites Warten), Statik-Server auf STAGE mit nginxFallback: true, ein Browser, ein Kontext mit kontextOptionen('desktop', { sw: 'allow' }). Der Kontext sammelt alle Request-URLs (anfragen), alle Konsolenfehler (fehler) und zählt Navigationen (navigationen()).
const warteAuf = (wert) => seite.waitForFunction(([w]) => document.getElementById('kn-platzhalter-ansicht')?.textContent === w, [wert], { timeout: 30000 })
test('die Wurzel führt auf den Login', async () => { await seite.goto(s.url + '/'); await seite.waitForURL('**/login.html') })
test('Dashboard startet mit dem Service Worker des Falls: ansicht neu', async () => {
await seite.goto(s.url + '/dashboard.html?fall=betrieb-wartung'); await warteAuf('neu')
assert.match(await seite.evaluate(() => navigator.serviceWorker.controller.scriptURL), /\/vorschau-sw\.js\?fall=betrieb-wartung$/)
})
test('Fall-Wechsel per Adresse: abgemeldet, dann privat-frist; die Leiste ist da', async () => {
await seite.goto(s.url + '/dashboard.html?fall=abgemeldet'); await warteAuf('abgemeldet')
await seite.goto(s.url + '/dashboard.html?fall=privat-frist'); await warteAuf('neu')
assert.equal(await seite.evaluate(() => Boolean(document.querySelector('#kn-vorschau-leiste')?.shadowRoot)), true)
})
test('unbekannte API-Pfade: JSON-404, nicht die HTML-Seite des nginx-Fallbacks', async () => {
const r = await seite.evaluate(async () => { const x = await fetch('/api/gibt-es-nicht'); return [x.status, x.headers.get('content-type'), (await x.json()).code] })
assert.deepEqual([r[0], r[2]], [404, 'vorschau_unbekannt']); assert.match(r[1], /json/)
})
test('nie /sw.js angefragt, nie ein fremder Host, keine Konsolenfehler', () => {
assert.ok(!anfragen.some((u) => new URL(u).pathname === '/sw.js'))
assert.deepEqual(anfragen.filter((u) => /^https?:/.test(u) && new URL(u).hostname !== '127.0.0.1'), [])
assert.deepEqual(fehler, [])
})
test('Neuladen bei aktivem Worker: höchstens zwei zusätzliche Navigationen', async () => {
const vorher = navigationen(); await seite.reload(); await warteAuf('neu')
assert.ok(navigationen() - vorher <= 2)
})
test('künstliche Ereignisse: DOMContentLoaded und load erreichen Skripte, die nach dem Start laden', async () => {
const quelle = '<!doctype html><html><head><title>t</title></head><body><script>document.addEventListener("DOMContentLoaded", function () { document.title += "-dcl" }); window.addEventListener("load", function () { document.title += "-load" })</script></body></html>'
writeFileSync(`${STAGE}/ereignisse.html`, patche(quelle, readFileSync(`${KW}/vorschau/boot.js`, 'utf8')).html)
await seite.goto(s.url + '/ereignisse.html')
await seite.waitForFunction(() => document.title === 't-dcl-load', null, { timeout: 30000 })
})
- Step 2: Test laufen lassen, Fehlschlag prüfen
Run: free -m, dann /root/reports/kundenansicht-neu-2026-10-15/werkzeug/run-browser.sh /root/reports/kundenansicht-neu-2026-10-15/werkzeug/test/vorschau.selftest.mjs
Expected: FAIL, solange boot.js oder der Worker nicht arbeiten. Ist schon alles richtig, nimm eine Aussage kurz heraus, sieh den Fehlschlag und stelle sie wieder her.
- Step 3: Beheben, was der Test zeigt
Korrigiere boot.js, sw-eintrag.mjs oder bauen.sh nur dort, wo der Test es verlangt.
- Step 4: Test grün
Run: wie Step 2. Expected: PASS, fail 0; danach ls -l /dev/null mit crw-rw-rw-.
- Step 5: Commit (Werkzeug-Git)
KW=/root/reports/kundenansicht-neu-2026-10-15/werkzeug
git -C $KW add test/vorschau.selftest.mjs
git -C $KW commit -m "Werkzeug: Selbsttest der Vorschau im Browser (Worker, Fall-Wechsel, nginx-Fallback)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
(Wurden boot.js, sw-eintrag.mjs oder bauen.sh angepasst, gehören sie einzeln mit git add in diesen Commit.)
Task 13: Auslieferung mit Guard, Manifest und Selbsttest
Files:
- Create in
KW/vorschau/:ziel-pruefen.sh,ausliefern-lib.sh,manifest.mjs,vorschau-kundenansicht.sh,plan-zeigen.sh,test-ziel.sh - Test:
KW/test/manifest.test.mjs,KW/vorschau/test-ziel.sh
Interfaces:
-
Consumes:
STAGEundbauen.sh(Task 11). -
Produces:
ziel-pruefen.sh(nur persource):ZIEL_FEST=/var/www/previews/kundenansicht-neu,ziel_pruefen_gegen <kandidat> <fest>(0 in Ordnung, 2 abgelehnt, Grund auf stderr),ziel_pruefen(=ziel_pruefen_gegen "$ZIEL_FEST" /var/www/previews/kundenansicht-neumit dem Literal). Regeln:readlink -mdes Kandidaten ist genaureadlink -mvonfest.festist kein Symlink, sein Elternordner ein echter Ordner (readlink -fgleich dem Pfad).- Mindestens vier Pfadebenen.
- Existiert
fest, ist es ein Ordner und leer oder enthältvorschau-info.txt. Fremder Inhalt nur mitKN_ERSTLAUF_FREMDES_OK=ja; dann werden gleichnamige Dateien überschrieben und alles andere bleibt.
ausliefern-lib.sh(nur persource):pfad_im_ziel <ziel> <rel>: 0, wenn<ziel>/<rel>nachreadlink -munter<ziel>/liegt, kein Bestandteil unterhalb von<ziel>ein Symlink und ein vorhandenes Ziel kein Verzeichnis ist; sonst 1.manifest.mjs:bereinige(rel): string|null(null bei leer, führendem/, Segment..oder.,//, Rückwärtsstrich, Steuerzeichen oder NUL, Länge über 300),plane({ neu: string[], altesManifest: string[] }): { kopieren, entfernen, verworfen }.kopierensind die bereinigten neuen Dateien (vorschau-info.txtundvorschau-manifest.txtzuletzt).entfernensind bereinigte Einträge des alten Manifests, die nicht mehr inneustehen.verworfensind unsaubere Einträge aus beiden Listen. Befehlszeilenode manifest.mjs plan <neu-datei> <altes-manifest-datei>gibt ZeilenK <rel>,E <rel>,X <rel>aus.vorschau-kundenansicht.sh: kennt nur--pruefenund den Aufruf ohne Argument. Jedes andere Argument ergibt Exit 2 mit Aufrufhilfe, bevor etwas geschieht. Das Ziel ist fest.--pruefenruft nurziel_pruefenauf („Zielordner in Ordnung: “), baut und kopiert nichts. Ohne Argument:ziel_pruefen,bauen.sh,manifest.mjs plan, jeK-Zeilepfad_im_ziel, dannmkdir -pdes Elternordners undcp -- "$STAGE/$rel" "$ZIEL/$rel", jeE-Zeile[ -f ] && [ ! -L ]undpfad_im_ziel, dannrm -f --. Nierm -rf,rsyncoder--delete, nie das Entfernen eines Ordners.vorschau-info.txtundvorschau-manifest.txtwerden zuletzt kopiert; bricht ein Lauf ab, bleibt das alte Manifest stehen.plan-zeigen.sh: ohne Argumente, ruftbauen.sh, liest das Manifest des Ziels nur lesend und gibt den Plan (K/E/X-Zeilen, Summen) aus. Ändert nichts außerhalb vonKW/tmp.test-ziel.sh: ruft nurziel_pruefen_gegenundpfad_im_zielmit Pfaden unterKW/tmp/ziel-test/auf, nie das Auslieferungs-Skript mit einem Ziel. Aufräumen vonKW/tmp/ziel-testam Ende hinter einercase-Prüfung auf den genauen Pfad.
-
Step 1: Failing tests schreiben
KW/test/manifest.test.mjs (node:test, node:assert/strict, bereinige und plane aus vorschau/manifest.mjs; nur Zeichenketten, nichts im Dateisystem):
test('bereinige: saubere relative Pfade bleiben, Gefährliches wird null', () => {
for (const ok of ['index.html', 'neu/tokens.css', 'vorschau/demo-baeckerei.html', 'a b/c.txt']) assert.equal(bereinige(ok), ok)
for (const schlecht of ['', '/etc/passwd', '../x', 'a/../../x', 'a/./b', './a', 'a//b', 'a\\b', 'a\nb', 'a\0b', 'x'.repeat(301), '..', '.']) assert.equal(bereinige(schlecht), null, JSON.stringify(schlecht))
})
test('plane: neue kopieren, veraltete aus dem alten Manifest entfernen, Unsauberes verwerfen', () => {
const p = plane({ neu: ['a.html', 'vorschau-manifest.txt', 'b.js', 'vorschau-info.txt', '../boese'], altesManifest: ['a.html', 'alt.js', '../../etc/passwd', '/root/x', 'b.js'] })
assert.deepEqual(p.kopieren, ['a.html', 'b.js', 'vorschau-info.txt', 'vorschau-manifest.txt'])
assert.deepEqual(p.entfernen, ['alt.js'])
assert.deepEqual([...p.verworfen].sort(), ['../../etc/passwd', '../boese', '/root/x'])
})
test('plane: leeres altes Manifest entfernt nichts', () => {
const p = plane({ neu: ['a.html'], altesManifest: [] })
assert.deepEqual([p.kopieren, p.entfernen, p.verworfen], [['a.html'], [], []])
})
KW/vorschau/test-ziel.sh (Aufbau nach dem WOMS-Vorbild, mit Funktionsaufrufen statt Skriptaufrufen; Ausgabe am Ende test-ziel ok (N Fälle), Exit 1 bei einem falschen Ergebnis). Pflichtfälle, jeder mit erwartetem Ergebnis:
-
Konstante:
ZIEL_FESTist wörtlich/var/www/previews/kundenansicht-neu;ziel_pruefen_gegenmit diesem Pfad gegen sich selbst ist nicht abgelehnt wegen Pfadform. -
Abgelehnt, weil nicht der feste Pfad:
/var/www/previews,/var/www/previews/kundenansicht-neu/sub,/var/www/previews/kundenansicht-neu2,/var/www/previews/demo,/root,/. -
In Ordnung gegen einen Testordner
$KW/tmp/ziel-test/fest: Pfad mit doppeltem Schrägstrich und/./, der auf ihn zeigt (wird normalisiert). -
Gegen Testordner:
festals Symlink abgelehnt; Elternordner als Symlink abgelehnt;festals Datei abgelehnt;festmit fremdem Inhalt ohnevorschau-info.txtabgelehnt, mitKN_ERSTLAUF_FREMDES_OK=jain Ordnung;festleer in Ordnung;festmitvorschau-info.txtin Ordnung;festfehlt in Ordnung; Pfad mit weniger als vier Ebenen abgelehnt. -
pfad_im_ziel: normale Datei in Ordnung; Datei hinter einem Symlink-Ordner (z/link/x.txt,linkzeigt nach außen) abgelehnt;relist ein Verzeichnis abgelehnt;relist ein Symlink abgelehnt. -
Das Auslieferungs-Skript nur mit
--pruefen(Exit 0 oder 2, nie etwas anderes), mit--unbekanntund mit--pruefen zusatz(beide Exit 2, ohne etwas zu tun). -
Step 2: Tests laufen lassen, Fehlschlag prüfen
Run: cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/manifest.test.mjs
Expected: FAIL mit Cannot find module auf manifest.mjs.
- Step 3: Implementieren
Alle Dateien nach den Interfaces. Vorbild für den Guard (nur lesen): ziel-pruefen.sh und test-ziel.sh im WOMS-Werkzeug, mit festem Ziel statt Parameter.
- Step 4: Tests grün
Run: cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/manifest.test.mjs
Expected: PASS, fail 0.
Run: /root/reports/kundenansicht-neu-2026-10-15/werkzeug/vorschau/test-ziel.sh
Expected: test-ziel ok (N Fälle), Exit 0.
Run: /root/reports/kundenansicht-neu-2026-10-15/werkzeug/vorschau/vorschau-kundenansicht.sh --pruefen
Expected: Exit 0 mit „Zielordner in Ordnung: /var/www/previews/kundenansicht-neu“ oder Exit 2 mit klarem Grund (zum Beispiel fremder Inhalt). Beides ist gültig und kommt in die Übergabe. Nie das Skript ohne --pruefen starten.
- Step 5: Commit (Werkzeug-Git)
KW=/root/reports/kundenansicht-neu-2026-10-15/werkzeug
git -C $KW add vorschau/ziel-pruefen.sh vorschau/ausliefern-lib.sh vorschau/manifest.mjs vorschau/vorschau-kundenansicht.sh vorschau/plan-zeigen.sh vorschau/test-ziel.sh test/manifest.test.mjs
git -C $KW commit -m "Werkzeug: Auslieferung mit festem Ziel, Guard, Manifest und Selbsttest" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 14: Bedienung und Übergabe an den Inhaber
Files:
- Create in
KW:README.md,UEBERGABE-P0.md
Interfaces:
-
Produces: die Anleitung, nach der P1 bis P4 und P7 eigene Ansichten, Fixtures, Endpunkte und Gates ergänzen, und der Arbeitsauftrag an den Inhaber.
-
Step 1:
README.mdschreiben, jeweils kurz und mit dem genauen Befehl:
- Aufruf:
run-browser.sh,shots.sh <ausgabe> --only id --gate pN --geraete a,b,check.sh <datei>… | --alle,strang.env(export WT=…) für andere Worktrees. Eine Instanz zur Zeit, die Sperre ist gemeinsam. - Eine Ansicht ergänzen: Eintrag in
views.mjs(Felder, Fall, Geräte,warte, Beispiele fürschritte,tastatur,standalone,bewegung). - Einen Endpunkt ergänzen: eigene Datei
fixtures/endpunkte-p<N>.mjsund ein Import infixtures/endpunkte.mjs, danach Eintrag invertrag-endpunkte.mjs(VERTRAGoderNICHT_PRUEFBARmit Grund). Unbekannte/api/*meldenunknown-api. Neue Fälle (feste Regel): Die vierzehn Kernfälle stehen vorn inFALL_NAMENund bleiben fest. Weitere Fälle nur in einer eigenen Datei anhängen (fixtures/module.mjsfür P4,fixtures/faelle-p7.mjsfür P7, sonstfixtures/faelle-p<N>.mjs), mit genau einer Importzeile infixtures/faelle.mjs. Den Pin intest/backend.test.mjs(er prüft nurFALL_NAMEN.slice(0, 14)) undvorschau/boot.js(die Leiste zeigt nur die Kernfälle,?fall=<name>nimmt jeden Namen ausFALL_NAMEN) nie ändern. Eine rote Meldung dort heißt: Der Kernfall wurde verändert. Stoppen und dem Controller melden. - Ein Gate ergänzen:
GATES.p<N>ingates.mjs, mit Beispiel fürignorierenund der Regel zu#111. - Fallstricke: kein
fullPage,hasTouchfür Handy, Standalone nur per Patch, 180 s, nur ein Browser, danachls -l /dev/null. - Vorschau:
bauen.shbaut nachtmp/vorschau-bau,plan-zeigen.shzeigt den Plan,vorschau-kundenansicht.shliefert aus (nur der Controller mit OK des Inhabers, Agenten nur--pruefen). Das Portal löscht den Zielordner bei jedem Push in das Repo des Projekts und beim Archivieren oder Umbenennen (server/services/previewDeploy.js:deployStatic,removeDeploy). Deshalb nie in dieses Repo pushen und das Projekt nie archivieren.
- Step 2:
UEBERGABE-P0.mdschreiben (du-Form, für den Inhaber, kurz)
- Einmalig in WOMS: „Neues Projekt“, Typ Website, interner Kunde (WEBklar), Subdomain
kundenansicht-neu, Name „Kundenansicht neu (Vorschau)“, das Repo bleibt leer. Danach das Projekt nie archivieren, die Subdomain nie ändern, nichts in das Repo pushen. - Danach meldest du dich beim Controller. Er prüft
ls -A /var/www/previews/kundenansicht-neu(der erste Deploy des leeren Repos kann Dateien anlegen), ruftvorschau-kundenansicht.sh --pruefenauf und liefert erst nach deinem OK aus. - Der Schalter bleibt aus.
KUNDENANSICHT_NEUist nicht gesetzt, alsooff. Einschalten später in der Compose-Umgebung:KUNDENANSICHT_NEU=pilotundKUNDENANSICHT_NEU_PILOT_EMAILS=kenso.gri@gmail.com,…. - Offene Frage für den Controller:
isPreviewProjectReadyverlangtreadyoderdeployed(server/services/previewReadiness.js). Wie ein Projekt ohne Repo-Inhalt diesen Status bekommt, klärt der Controller mit dir, sonst zeigt die Vorschaunot_ready.
- Step 3: Plan prüfen
Run: /root/reports/kundenansicht-neu-2026-10-15/werkzeug/vorschau/plan-zeigen.sh
Expected: Exit 0, K-Zeilen, keine X-Zeilen. git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug status --short zeigt nur README.md und UEBERGABE-P0.md.
- Step 4: Commit (Werkzeug-Git)
KW=/root/reports/kundenansicht-neu-2026-10-15/werkzeug
git -C $KW add README.md UEBERGABE-P0.md
git -C $KW commit -m "Werkzeug: Bedienung und Übergabe an den Inhaber" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 15: Selbstprüfung
Files: keine neuen. Tags kn-p0 im Portal und im Werkzeug.
Interfaces:
-
Produces: das Tag
kn-p0im Portal (aus ihm entstehen die Stränge der Phase B) und die Übergabe an den Controller. -
Step 1: Portal-Tests (eigene Dateien und Pins)
Run: free -m, dann /root/reports/kundenansicht-neu-2026-10-15/werkzeug/check.sh test/kundenansichtSchalter.test.js test/kundenansichtDashboard.test.js test/kundenansichtMe.test.js test/adminPanelGone.test.js test/legalDocs.test.js test/kundenansichtGuard.test.js test/kundenansichtEnde.test.js test/zeitenSchalter.test.js
Expected: Exit 0, # fail 0.
- Step 2: Werkzeug-Tests ohne Browser
Run: cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/geruest.test.mjs test/backend.test.mjs test/vertrag.test.mjs test/gates.test.mjs test/statik.test.mjs test/vorschau-bau.test.mjs test/manifest.test.mjs
Expected: fail 0.
- Step 3: Browser-Selbsttests, nacheinander
Run, einzeln und mit free -m davor: /root/reports/kundenansicht-neu-2026-10-15/werkzeug/run-browser.sh /root/reports/kundenansicht-neu-2026-10-15/werkzeug/test/checks.selftest.mjs, dasselbe mit test/shots.selftest.mjs und test/vorschau.selftest.mjs.
Expected: je fail 0, danach ls -l /dev/null mit crw-rw-rw-.
- Step 4: Skripte und Sichtprüfung
Run: /root/reports/kundenansicht-neu-2026-10-15/werkzeug/vorschau/test-ziel.sh (Expected: test-ziel ok), /root/reports/kundenansicht-neu-2026-10-15/werkzeug/vorschau/vorschau-kundenansicht.sh --pruefen (Expected: Exit 0 oder 2 mit Grund, beides in den Bericht), dann von WT aus /root/reports/kundenansicht-neu-2026-10-15/werkzeug/run-browser.sh test/ui/kundenansicht-neu.ui.mjs /root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/shots-p0 --gate p0 (Expected: Exit 0).
-
Step 5: Verträge und Zuständigkeit abhaken
-
V1:
config.kundenansichtNeu(Task 2),schalter.jsmit vier Exports (neueAnsichtModus,neueAnsichtFuerSession,neueAnsicht,neueAnsichtFuerKunde) und allen Regeln (Task 2), Route vorexpress.staticmitno-cache(Task 3), Feldansicht(Task 4), Platzhalterpublic/neu/dashboard.html(Task 3). -
Alte Ansicht unverändert:
git -C /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht diff 08dd766..HEAD -- public/app.js public/dashboard.html public/login.htmlist leer. -
Zuständigkeit:
git -C /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht diff --stat 08dd766..HEADnennt nurserver/config.js,server/index.js,server/routes/auth.js,server/services/kundenansicht/*,public/neu/dashboard.html, die drei neuen Tests,test/ui/kundenansicht-neu.ui.mjsund die Plan-Datei. -
Ablaufhinweis 1 der Übersicht:
UEBERGABE-P0.mdenthält den Arbeitsauftrag an den Inhaber. -
Step 6: Sauberkeit und Tags
Run: git -C /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht status --short und git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug status --short
Expected: beide leer (node_modules und tmp/ sind ignoriert). Dann git -C /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht tag kn-p0 und git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug tag kn-p0.
- Step 7: Bericht an den Controller
Letzte Nachricht des Agenten: Portal-Tag mit Commit-Hash, Zahl der grünen Tests je Gruppe, Ergebnis von --pruefen, die Punkte aus UEBERGABE-P0.md (Auftrag an den Inhaber, offene Frage zum Projektstatus) und der Stand der Vertragsfragen unten.
Vertragsfragen an den Controller
Alle Vorschläge sind im Plan schon umgesetzt. Bitte bestätigen oder korrigieren, bevor P1 und P5 starten.
- Zusätzliche Datei im P0-Verzeichnis:
server/services/kundenansicht/dashboardWeiche.jsenthält die Weiche als Fabrik, weilserver/index.jsbeim Import den Server startet und sich nicht testen lässt. Vorschlag: P0 besitztserver/services/kundenansicht/ganz, andere Pakete legen dort nichts ab. - Direktaufruf
/neu/dashboard.html: Wer die neue Ansicht nicht hat, wird mit 302 auf/dashboard.htmlgeleitet. Das schließt „neue Seite per erratener Adresse“ im Modusoff. Folge für P1: Die neue Seite bleibt unter genau diesem Dateinamen, ihre Module und CSS-Dateien bleiben unter/neu/frei abrufbar. - „Gültige Sitzung“ in V1 präzisiert: Kunde =
customerIdundappwriteUserIdohnerole: 'admin'. Kundenansicht =viewAs === truemitviewId. Alte Mitarbeiter-Sitzungen (role: 'admin'ohneviewId) sind in jedem Modusfalse, auch bei*. Eineemail, die kein Text ist, zählt als leer. Für Jobs und Dienste ohne Cookie gilt nicht diese Regel, sondernneueAnsichtFuerKunde(Frage 10). ansichtin/api/auth/mesteht nur in den beidenauthenticated: true-Antworten. P5 ändert in derselben Datei nursanitizeCustomer(Z. 47–59).kundenansichtMe.test.jspinnt nur die Schlüssel der obersten Ebene, damit P5customererweitern darf.- Werkzeug-Erweiterungen durch P1 bis P4 und P7: erlaubt sind
views.mjs(Ansichten),gates.mjs(ihr Gatep<N>),fixtures/endpunkte-p<N>.mjsmit einem Import infixtures/endpunkte.mjs,fixtures/faelle-p<N>.mjs(P4:fixtures/module.mjs) mit einem Import infixtures/faelle.mjsfür weitere Fälle hinter den vierzehn Kernfällen, undvertrag-endpunkte.mjs. Mehr nicht. Der Pin „Kernfälle in fester Reihenfolge“ (FALL_NAMEN.slice(0, 14)) undvorschau/boot.jsbleiben unberührt, auch für P4 (17 Fällem-*) und P7 (privat-ohne-abo,hosting-privat); P2 legt laut seiner Vertragsfrage 7 keine Fälle an. Ändert ein Paket eine Antwortform, ergänzt es den Vertragseintrag im selben Commit. Fixtures für Endpunkte aus V5, V6, V8 folgen wörtlich der Übersicht. - Schwarze Flächen:
dark-surfaceerlaubt genau#111(Akzent der Übersicht) für Elemente bis 25 % der Viewport-Fläche. P1 baut Balken, Knopf und Zähler in#111. Andere dunkle Flächen brauchen eine Ausnahme je Ansicht überignorieren. - Seiten der Vorschau: Die Vorschau zeigt
public/neu/dashboard.htmlals/dashboard.html. Fürlogin.html,kuendigen.html,widerrufen.htmlhatpublic/neu/oeffentlich/<seite>Vorrang vorpublic/<seite>, sobald es sie gibt. Skripte der neuen Seiten dürfen klassisch odertype="module"sein und laufen in Dokumentreihenfolge.boot.jsfeuert nach dem Laden einmalDOMContentLoadedundload; Code sollte trotzdemdocument.readyStateprüfen. - Offene Betriebsfrage:
isPreviewProjectReadyverlangtstatusoderprovisioningStatusreadyoderdeployed. Wie das Projekt „kundenansicht-neu“ ohne Repo-Inhalt diesen Status bekommt, ist offen (Memory „Preview-Projekte“). Außerdem kann der erste Deploy des leeren Repos Dateien in den Zielordner legen. Das Skript nimmt sie nur mitKN_ERSTLAUF_FREMDES_OK=jaan und überschreibt dann nur gleichnamige Dateien. anfrageStatus(V5) steht inprojects[].features.<shop|unterkunft>der Antwort vonGET /api/funktionen, weil die Übersicht „je Projekt und Funktion“ sagt, aber keinen Pfad nennt. P3 und P5 bestätigen oder nennen den Pfad. Die Fixtures undvertragNeuziehen dann mit.- V1 um
neueAnsichtFuerKunde({ customerId, email })ergänzt (Befund der Prüfer, wichtig). Nach der Regel „gültige Sitzung“ (Frage 3) istneueAnsichtFuerSession({ customerId, email })ohne Cookie und ohneappwriteUserIdin jedem Modusfalse. P5 Task 13 (aboModellFuerKunde), P5runAboCheckund damit P6 (Team-Aktivierung, Widerruf des Wechsels, Tagesjob-Kopplung) wären still tot. Deshalb liefertschalter.jseinen vierten Export: dieselben Modus- und Listenregeln, aber ohne Sitzungsprüfung, bei leerercustomerIdfalse(Task 2, Tests dort). Zu tun: Die Übersicht V1 bekommt die Signaturexport function neueAnsichtFuerKunde({ customerId, email }) // boolean, für Jobs und Dienste ohne Cookiesamt Regeln. P5 Task 13 und P5 Vertragsfrage 6 (Annahme zu P0) stellen aufneueAnsichtFuerKundeum, Annahme bestätigt mit dieser Funktion stattneueAnsichtFuerSession. - Katalog der Fixtures mit
consentundhinweis: V5 nenntkatalog[].consentundkatalog[].hinweisnicht, P5 liefertconsent(P3 und P4 verlangen es), und der Hinweis steht bei P5 als Text für Terminplaner, Webshop und Buchungskalender. Die Fixtures tragen beides (Task 6),vertragNeudeckt beides ab (Task 7). P5 bestätigt Form und Namen, oder die Fixtures ziehen mit. Die Übersicht V5 sollte beide Felder aufnehmen.