Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-16-kundenansicht-01-werkzeug-vorschau.md

119 KiB
Raw Permalink Blame History

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.html ist eine Handler-Fabrik, die server/index.js vor express.static einhä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örtlich Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> als zweites -m. Werkzeug-Commits mit git -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 unter server/ und public/ steht nie der Text admin.html oder kalkulation.html (Pin test/adminPanelGone.test.js:54-67).
  • Werkzeug-Skripte: rm -rf nur auf Pfade unter KW/tmp/, nur hinter einer case-Prüfung auf den genauen Pfad. Keine Zielpfade aus Parametern ohne Guard.
  • Vorschau-Skript: Der Agent ruft vorschau-kundenansicht.sh nur mit --pruefen auf. Die Hilfsfunktionen des Guards dürfen mit Testpfaden unter KW/tmp/ laufen, nie mit Pfaden, die .. enthalten.
  • Service-Worker-tauglich: backend.mjs und fixtures/*.mjs importieren nichts aus node: und lesen process.env nur als process.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, nie fullPage, Zeitlimit 180 s je Operation, hasTouch: true für Handy, Standalone nur per matchMedia-Patch, Uhr per setFixedTime. Vor schweren Läufen free -m (die Skripte prüfen selbst, Schwelle 2000 MB). Danach zeigt ls -l /dev/null crw-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_NAMEN sind 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 und boot.js bleiben dafür unberührt.

Review Focus

  1. Pilot-Schalter an den Rändern: Groß-/Kleinschreibung und Leerraum der E-Mail, *, leere Liste, off mit *, E-Mail ohne Text, Sitzung ohne Kunde, alte Mitarbeiter-Sitzung (role: 'admin' ohne viewId). 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.
  2. Weiche liefert die falsche Seite oder falsch gecacht: Query (?abo=success), unsigniertes Cookie, Direktaufruf von /neu/dashboard.html, Reihenfolge vor express.static, Cache-Control. Erwartet: Nicht-Pilot bekommt bytegleich die alte Seite. Test: Task 3.
  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.
  4. 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.
  5. 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, kein fullPage), 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 Zeile kundenansichtNeuEnabled), neuer Abschnitt kundenansichtNeu im Objekt config direkt nach zeiten (um :1116)
  • Test: test/kundenansichtSchalter.test.js

Interfaces:

  • Consumes: parsePortalSession(req) aus server/middleware/session.js, commaList(raw, fallback) aus server/config.js.

  • Produces (V1, wörtlich):

    • config.kundenansichtNeu: { mode: 'off'|'pilot'|'on', pilotEmails: string[] } aus KUNDENANSICHT_NEU und KUNDENANSICHT_NEU_PILOT_EMAILS
    • neueAnsichtModus(): 'off'|'pilot'|'on'
    • neueAnsichtFuerSession(session): boolean
    • neueAnsicht(req): boolean (= neueAnsichtFuerSession(req.session ?? parsePortalSession(req)))
    • neueAnsichtFuerKunde({ customerId, email }): boolean (Ergänzung zu V1 für Jobs und Dienste ohne Cookie, etwa P5 aboModellFuerKunde, P5 runAboCheck und P6; die Übersicht V1 bekommt die Zeile, siehe Vertragsfrage 10). Gleiche Modus- und Listenregeln wie neueAnsichtFuerSession, aber ohne Sitzungsprüfung und ohne Mitarbeiter-Regel: off ergibt false. Ist customerId kein nicht-leerer Text (getrimmt) oder die Eingabe kein Objekt, ergibt es false. on ergibt true. pilot ergibt true bei '*' in der Liste oder wenn die getrimmte, klein geschriebene email in der Liste steht (eine email, die kein Text ist, zählt als leer). neueAnsichtFuerSession und neueAnsichtFuerKunde teilen 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 (Muster zeiten, deutscher Kommentar): kundenansichtNeu: { mode: ['pilot', 'on'].includes(kundenansichtNeuEnabled) ? kundenansichtNeuEnabled : 'off', pilotEmails: commaList(process.env.KUNDENANSICHT_NEU_PILOT_EMAILS, []) }.

  • schalter.js mit den vier Exports (Muster server/services/zeiten/schalter.js). Ablauf von neueAnsichtFuerSession:

    1. Modus off ergibt false.
    2. Ungültige Sitzung ergibt false. Gültig ist ein Objekt (kein Array) mit Kundenansicht (viewAs === true und viewId gesetzt) oder Kunde (customerId und appwriteUserId gesetzt, role !== 'admin').
    3. on ergibt true.
    4. pilot ergibt true bei Kundenansicht, bei '*' in der Liste oder wenn die getrimmte, klein geschriebene session.email in der Liste steht. Eine email, die kein Text ist, zählt als leer.
  • neueAnsichtFuerKunde({ customerId, email }) folgt den Interfaces: dieselben Schritte 1, 3 und 4, an Stelle von Schritt 2 die Prüfung auf eine nicht-leere customerId. 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 zwischen app.use('/kalkulation.html', …) und app.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-cache setzen, dann res.sendFile(publicDir/neu/dashboard.html). Sonst next().
    • neuDirektWeiche(): (req, res, next) => void für GET /neu/dashboard.html: neue Ansicht ergibt next(), sonst res.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, das GET /api/auth/me liest und per textContent me.ansicht (bei authenticated: false den Text abgemeldet, bei Fehler Fehler) 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.js und public/neu/dashboard.html wie in den Interfaces. res.set('Cache-Control', 'no-cache') steht vor sendFile, damit Express den Header nicht überschreibt. Fehler von sendFile gehen an next(err).

  • server/index.js: Import { dashboardWeiche, neuDirektWeiche } aus ./services/kundenansicht/dashboardWeiche.js und direkt vor app.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 nutzen indexOf). Der tote alte Handler app.get('/dashboard.html', …) hinter static bleibt 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). /me hat kein req.session, nur die lokale Variable session.

  • Produces: GET /api/auth/me liefert in beiden authenticated: true-Antworten zusätzlich ansicht: '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 per source): exportiert KW, WT (Vorgabe /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht, überschreibbar durch KW/strang.env bzw. die Datei aus KN_STRANG_ENV), TMPDIR=KW/tmp (wird angelegt), FIXTURE_TODAY (Vorgabe 2026-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, wenn WT/server/index.js fehlt. pipefail an, kein cd.
    • schwer_sperren: flock auf KW/sperren/schwer.lock (Deskriptor 8), setzt KN_SPERRE=1. Ist KN_SPERRE gesetzt, kehrt sie sofort zurück.
    • speicher_pruefen: liest MemAvailable aus ${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, wenn ls -l /dev/null nicht mit crw-rw-rw- beginnt.
    • check.sh <test/datei.test.js>… | --alle: arbeitet in WT. Ohne Argument, fehlende Datei oder unbekannte Option: Exit 2. Danach speicher_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 in KW/tmp/test.log. Exit 1 ohne Zählzeile, sonst der Exit des Laufs. Am Ende dev_null_pruefen.
    • run-browser.sh <skript.mjs> [args…]: Exit 2 ohne oder mit fehlendem Skript (Pfad vor source env.sh auflösen). Dann speicher_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 aus CHROMIUM mit --no-sandbox --disable-dev-shm-usage --disable-gpu --host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE 127.0.0.1. Ohne KN_SPERRE=1 Exit 2 mit „nur über run-browser.sh starten“. Fehlt Playwright-Core 1.62.1 oder CHROMIUM: 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.sh an 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 aus process.env.FIXTURE_TODAY, sonst echter Tag, ungültiger Wert wirft „ungültig“), jetzt(): Date (fester Tag 12:00 UTC, sonst new 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_NAMEN sind 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, P7 fixtures/faelle-p7.mjs, sonst fixtures/faelle-p<N>.mjs) mit genau einer Importzeile in faelle.mjs. Der Pin im Test und boot.js bleiben dabei unberührt (siehe Test und Task 11).
    • endpunkte.mjs: ENDPUNKTE: Array<{ name, methode, muster, schreibend?: true, antwort?(ctx): { status?, json?, text?, type? } }>. muster kennt :param. ctx = { fall, zustand, params, query, body, methode, pfad }. Ohne antwort liefert ein GET ctx.zustand[name]. index.mjs re-exportiert FALL_NAMEN, ladeFall, ENDPUNKTE.
    • backend.mjs: createBackend({ fixtures, fall }): { handle(anfrage), calls, violations, zustand } mit anfrage = { method, url, headers?, body? } und Antwort { status, headers, body: string }. Pfade außerhalb von /api/ ergeben null.
      • 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ßer auth.me, auth.login, auth.login-status, auth.logout ergibt 401 { error: 'Nicht angemeldet' }, bis auth.login erfolgreich war.
      • schreibsperre: jedes Nicht-GET außer auth.ende und auth.logout ergibt 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 tragen url: '/vorschau-hinweis.html'.
      • Antworten tragen content-type (JSON mit charset=utf-8) und cache-control: no-store.

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 analyse trägt consent: { 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 } ] }. Bei analyse steht consent: null (die Analyse nutzt POST /api/funktionen/analyse/activate).

  • termine, shop und unterkunft tragen zusätzlich hinweis (ein Satz, du-Form, Testtext, zum Beispiel Du bist für deinen Webshop und die Zahlungen selbst verantwortlich.). news, zeiten und analyse tragen kein Feld hinweis.

  • 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 aus WT (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[] }> und NICHT_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.me als Kundenansicht (Fall mitarbeiter), contact, projects (ein websiteProjects-Dokument), features (ein portalFeatures-Dokument mit enabled), chat.unread, chat.messages (eine Nachricht), abo (Fall betrieb-wartung, wartungDoc), funktionen (vorher setzt config.termine.mode, config.shop.mode, config.unterkunft.mode, config.zeiten.mode auf 'on', damit die echte Antwort die Obermenge der Schlüssel zeigt).
    • vertragNeu enthä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_PRUEFBAR mit 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 die auth.*-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 mit run-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 }>>. ignorieren bildet 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 Farbe rgb(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) oder rgba(157, 61, 254, …); dazu der Text aller geladenen Stylesheets mit #9d3dfe oder rgba(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 sammelt console.error und pageerror (gefiltert durch erwartetKonsole) und jede Anfrage mit Schema http(s) oder ws(s), deren Host nicht 127.0.0.1 ist (Detail = URL). erwartetKonsole(text, fall): boolean ist wahr für Chromiums „Failed to load resource: the server responded with a status of 500“ nur im Fall fehler.
  • Step 1: Failing test schreiben (KW/test/checks.selftest.mjs; ein Browser in before, in after geschlossen)

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 liefert platzhalter (Fall betrieb-wartung, /dashboard.html, handy und desktop, warte: '#kn-platzhalter-ansicht:not(:empty)'), platzhalter-abgemeldet (Fall abgemeldet, handy) und login-alt (Fall abgemeldet, /login.html, handy und desktop, warte: 'form'; die alte Seite ist dunkel und lila und zählt nur im Gate p0).
      • 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 ihr p<N>, Vertragsfrage 5); pruefeGate(gate, ansichten = ANSICHTEN) wirft bei unbekannter Prüfung (erlaubt sind ALWAYS_ON und SEITEN_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 in ALWAYS_ON oder gate.checks steht, zur Auswahl gehört (ohne zu werfen) und nicht ignored ist.
  • 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.html und alle Ziele und Quellen aus SEITEN; zuordnung(wurzel): Map<ziel, absolute Quelle> (die erste vorhandene Quelle gewinnt, fehlt keine ganz, fehlt das Ziel in der Map). Befehlszeile node seiten.mjs <wurzel>: Zeilen P <ziel>\t<Quelle> je Zuordnung und O <relativ> je OHNE-Eintrag (für bauen.sh).
    • statik-server.mjs: starteStatik({ wurzel, port = 0, abbilden = false, nginxFallback = false }): Promise<{ url, port, anfragen: string[], stop() }>, lauscht nur auf 127.0.0.1.
      • abbilden (Quellbaum public/): die Ziele aus SEITEN kommen aus zuordnung, / ergibt 302 auf /login.html.
      • nginxFallback (gebauter Ordner): ein unbekannter Pfad liefert index.html mit 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ür addInitScript, das matchMedia('(display-mode: standalone)') wahr macht), lauf({ ansichten, gate, ausgabe, geraete, wurzel }): Promise<{ bericht, exitCode }> (gate ist ein Name aus GATES oder ein Gate-Objekt; ohne wurzel gilt WT/public mit abbilden: true, mit wurzel wird der Ordner unverändert ausgeliefert) und main(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, frisches createBackend, setFixedTime(jetzt()) außer bei uhr: 'echt', bei standalone der Patch, route('**/*') (same-origin /api/ ans Backend, anderes same-origin continue, Fremdes abort und als external-request gemeldet), beobachte, goto, waitForSelector(warte), schritte, bei tastatur 300 px weniger Höhe, runChecks, Screenshot ohne fullPage nach <ausgabe>/<w>x<h>/<id>.png, Kontext schließen.
      • Scheitern Warten oder Schritt, entsteht step-failed ohne Absturz. Die Ergebnisse von beobachte und die Backend-Verstöße (unknown-api, backend-error) kommen als { check, detail } in issues des Berichts (detail ist bei external-request die URL). Alle Operationen laufen mit zeitlimit der Ansicht, sonst ZEITLIMIT_MS. Genau ein starteBrowser-Aufruf je Lauf.
    • shots.sh <ausgabe> [Optionen]: löst <ausgabe> relativ zum Aufrufer auf und führt run-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, zuordnung und die Befehlszeile von seiten.mjs (Task 10).

  • Produces:

    • sw-eintrag.mjs: Service Worker. Liest ?fall= aus der eigenen Adresse und prüft den Namen gegen FALL_NAMEN (die Kernfälle und alle später angehängten; unbekannt ergibt betrieb-wartung), hält ein Backend je Lebensdauer. Same-origin /api/ geht ans Backend, fremde Origins (außer Navigationen) beantwortet er mit Response.error(), sonstiges bleibt unberührt. install ruft skipWaiting, activate ruft clients.claim, die Nachricht claim holt eine ungesteuerte Seite.
    • index-patchen.mjs: patche(html, boot): { html, skripte: Array<{ src?, type?, text? }> } und Befehlszeile node 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-Skript boot.js mit Attribut data-skripte (JSON, als Attributwert maskiert). Fehler, wenn <head> fehlt oder boot.js die Zeichenfolge </script oder <!-- enthält.
    • boot.js (klassisch, läuft zuerst):
      1. Fall lesen aus ?fall=, sonst localStorage['kn-vorschau'], sonst window.name, sonst betrieb-wartung; jeder Zugriff in try/catch. boot.js führt keine eigene Liste zur Prüfung: Ein Name wird weitergereicht, wenn er der Zeile const FALL_MUSTER = /^[a-z0-9-]{1,40}$/; entspricht (genau diese Zeile, ein Muster pro Zeile), sonst gilt betrieb-wartung. Ob der Fall existiert, entscheidet der Worker.
      2. navigator.serviceWorker.register umhüllen: /sw.js liefert eine Stub-Registrierung { scope: '/' } ohne Netzzugriff.
      3. /vorschau-sw.js?fall=<fall> mit Scope / registrieren, auf controllerchange warten (8 s bei neuem, 1,5 s bei aktivem Worker), danach genau einmal neu laden, abgesichert durch sessionStorage.
      4. Die Skripte aus data-skripte der Reihe nach laden (mit src, auch type=module, je mit await onload; Inline per text), danach einmal DOMContentLoaded (auf document) und load (auf window) feuern.
      5. 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 in boot.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 ein ping an den Worker.
    • pruefe-dist.mjs: pruefeDist(ordner): string[] (fehlende Ziele aus src/href mit führendem / in HTML, import … from '/…' und import('/…') in JS, url(/…) und @import in CSS, ohne Query und Hash). Befehlszeile: Exit 1 bei nicht leerer Liste.
    • bauen.sh: baut nach STAGE=$TMPDIR/vorschau-bau. Ein altes STAGE fällt nur per rm -rf hinter case "$STAGE" in "$KW"/tmp/vorschau-bau). Schritte: schwer_sperren; Kopie von WT/public ohne OHNE (Liste aus seiten.mjs); je P-Zeile die Quelle durch index-patchen.mjs nach STAGE/<ziel>; index.html (mit <head>, Weiterleitung auf /login.html, ebenfalls gepatcht); hinweis.html als vorschau-hinweis.html; demo-baeckerei.html als vorschau/demo-baeckerei.html; vorschau-sw.js per /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=undefined unter systemd-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. Fehlt esbuild: Exit 2.
    • hinweis.html („Vorschau: Hier würde die Zahlung beginnen. Es wird nichts bestellt.“) und demo-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('&quot;', '"').replaceAll('&lt;', '<').replaceAll('&amp;', '&')
  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 mit run-browser.sh)
  • Änderungen an boot.js, sw-eintrag.mjs oder bauen.sh nur, 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: STAGE und bauen.sh (Task 11).

  • Produces:

    • ziel-pruefen.sh (nur per source): 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-neu mit dem Literal). Regeln:
      1. readlink -m des Kandidaten ist genau readlink -m von fest.
      2. fest ist kein Symlink, sein Elternordner ein echter Ordner (readlink -f gleich dem Pfad).
      3. Mindestens vier Pfadebenen.
      4. Existiert fest, ist es ein Ordner und leer oder enthält vorschau-info.txt. Fremder Inhalt nur mit KN_ERSTLAUF_FREMDES_OK=ja; dann werden gleichnamige Dateien überschrieben und alles andere bleibt.
    • ausliefern-lib.sh (nur per source): pfad_im_ziel <ziel> <rel>: 0, wenn <ziel>/<rel> nach readlink -m unter <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 }. kopieren sind die bereinigten neuen Dateien (vorschau-info.txt und vorschau-manifest.txt zuletzt). entfernen sind bereinigte Einträge des alten Manifests, die nicht mehr in neu stehen. verworfen sind unsaubere Einträge aus beiden Listen. Befehlszeile node manifest.mjs plan <neu-datei> <altes-manifest-datei> gibt Zeilen K <rel>, E <rel>, X <rel> aus.
    • vorschau-kundenansicht.sh: kennt nur --pruefen und den Aufruf ohne Argument. Jedes andere Argument ergibt Exit 2 mit Aufrufhilfe, bevor etwas geschieht. Das Ziel ist fest. --pruefen ruft nur ziel_pruefen auf („Zielordner in Ordnung: “), baut und kopiert nichts. Ohne Argument: ziel_pruefen, bauen.sh, manifest.mjs plan, je K-Zeile pfad_im_ziel, dann mkdir -p des Elternordners und cp -- "$STAGE/$rel" "$ZIEL/$rel", je E-Zeile [ -f ] && [ ! -L ] und pfad_im_ziel, dann rm -f --. Nie rm -rf, rsync oder --delete, nie das Entfernen eines Ordners. vorschau-info.txt und vorschau-manifest.txt werden zuletzt kopiert; bricht ein Lauf ab, bleibt das alte Manifest stehen.
    • plan-zeigen.sh: ohne Argumente, ruft bauen.sh, liest das Manifest des Ziels nur lesend und gibt den Plan (K/E/X-Zeilen, Summen) aus. Ändert nichts außerhalb von KW/tmp.
    • test-ziel.sh: ruft nur ziel_pruefen_gegen und pfad_im_ziel mit Pfaden unter KW/tmp/ziel-test/ auf, nie das Auslieferungs-Skript mit einem Ziel. Aufräumen von KW/tmp/ziel-test am Ende hinter einer case-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_FEST ist wörtlich /var/www/previews/kundenansicht-neu; ziel_pruefen_gegen mit 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: fest als Symlink abgelehnt; Elternordner als Symlink abgelehnt; fest als Datei abgelehnt; fest mit fremdem Inhalt ohne vorschau-info.txt abgelehnt, mit KN_ERSTLAUF_FREMDES_OK=ja in Ordnung; fest leer in Ordnung; fest mit vorschau-info.txt in Ordnung; fest fehlt 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, link zeigt nach außen) abgelehnt; rel ist ein Verzeichnis abgelehnt; rel ist ein Symlink abgelehnt.

  • Das Auslieferungs-Skript nur mit --pruefen (Exit 0 oder 2, nie etwas anderes), mit --unbekannt und 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.md schreiben, jeweils kurz und mit dem genauen Befehl:

  1. 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.
  2. Eine Ansicht ergänzen: Eintrag in views.mjs (Felder, Fall, Geräte, warte, Beispiele für schritte, tastatur, standalone, bewegung).
  3. Einen Endpunkt ergänzen: eigene Datei fixtures/endpunkte-p<N>.mjs und ein Import in fixtures/endpunkte.mjs, danach Eintrag in vertrag-endpunkte.mjs (VERTRAG oder NICHT_PRUEFBAR mit Grund). Unbekannte /api/* melden unknown-api. Neue Fälle (feste Regel): Die vierzehn Kernfälle stehen vorn in FALL_NAMEN und bleiben fest. Weitere Fälle nur in einer eigenen Datei anhängen (fixtures/module.mjs für P4, fixtures/faelle-p7.mjs für P7, sonst fixtures/faelle-p<N>.mjs), mit genau einer Importzeile in fixtures/faelle.mjs. Den Pin in test/backend.test.mjs (er prüft nur FALL_NAMEN.slice(0, 14)) und vorschau/boot.js (die Leiste zeigt nur die Kernfälle, ?fall=<name> nimmt jeden Namen aus FALL_NAMEN) nie ändern. Eine rote Meldung dort heißt: Der Kernfall wurde verändert. Stoppen und dem Controller melden.
  4. Ein Gate ergänzen: GATES.p<N> in gates.mjs, mit Beispiel für ignorieren und der Regel zu #111.
  5. Fallstricke: kein fullPage, hasTouch für Handy, Standalone nur per Patch, 180 s, nur ein Browser, danach ls -l /dev/null.
  6. Vorschau: bauen.sh baut nach tmp/vorschau-bau, plan-zeigen.sh zeigt den Plan, vorschau-kundenansicht.sh liefert 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.md schreiben (du-Form, für den Inhaber, kurz)
  1. 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.
  2. 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), ruft vorschau-kundenansicht.sh --pruefen auf und liefert erst nach deinem OK aus.
  3. Der Schalter bleibt aus. KUNDENANSICHT_NEU ist nicht gesetzt, also off. Einschalten später in der Compose-Umgebung: KUNDENANSICHT_NEU=pilot und KUNDENANSICHT_NEU_PILOT_EMAILS=kenso.gri@gmail.com,….
  4. Offene Frage für den Controller: isPreviewProjectReady verlangt ready oder deployed (server/services/previewReadiness.js). Wie ein Projekt ohne Repo-Inhalt diesen Status bekommt, klärt der Controller mit dir, sonst zeigt die Vorschau not_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-p0 im 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.js mit vier Exports (neueAnsichtModus, neueAnsichtFuerSession, neueAnsicht, neueAnsichtFuerKunde) und allen Regeln (Task 2), Route vor express.static mit no-cache (Task 3), Feld ansicht (Task 4), Platzhalter public/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.html ist leer.

  • Zuständigkeit: git -C /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht diff --stat 08dd766..HEAD nennt nur server/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.mjs und die Plan-Datei.

  • Ablaufhinweis 1 der Übersicht: UEBERGABE-P0.md enthä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.

  1. Zusätzliche Datei im P0-Verzeichnis: server/services/kundenansicht/dashboardWeiche.js enthält die Weiche als Fabrik, weil server/index.js beim Import den Server startet und sich nicht testen lässt. Vorschlag: P0 besitzt server/services/kundenansicht/ ganz, andere Pakete legen dort nichts ab.
  2. Direktaufruf /neu/dashboard.html: Wer die neue Ansicht nicht hat, wird mit 302 auf /dashboard.html geleitet. Das schließt „neue Seite per erratener Adresse“ im Modus off. Folge für P1: Die neue Seite bleibt unter genau diesem Dateinamen, ihre Module und CSS-Dateien bleiben unter /neu/ frei abrufbar.
  3. „Gültige Sitzung“ in V1 präzisiert: Kunde = customerId und appwriteUserId ohne role: 'admin'. Kundenansicht = viewAs === true mit viewId. Alte Mitarbeiter-Sitzungen (role: 'admin' ohne viewId) sind in jedem Modus false, auch bei *. Eine email, die kein Text ist, zählt als leer. Für Jobs und Dienste ohne Cookie gilt nicht diese Regel, sondern neueAnsichtFuerKunde (Frage 10).
  4. ansicht in /api/auth/me steht nur in den beiden authenticated: true-Antworten. P5 ändert in derselben Datei nur sanitizeCustomer (Z. 47–59). kundenansichtMe.test.js pinnt nur die Schlüssel der obersten Ebene, damit P5 customer erweitern darf.
  5. Werkzeug-Erweiterungen durch P1 bis P4 und P7: erlaubt sind views.mjs (Ansichten), gates.mjs (ihr Gate p<N>), fixtures/endpunkte-p<N>.mjs mit einem Import in fixtures/endpunkte.mjs, fixtures/faelle-p<N>.mjs (P4: fixtures/module.mjs) mit einem Import in fixtures/faelle.mjs für weitere Fälle hinter den vierzehn Kernfällen, und vertrag-endpunkte.mjs. Mehr nicht. Der Pin „Kernfälle in fester Reihenfolge“ (FALL_NAMEN.slice(0, 14)) und vorschau/boot.js bleiben unberührt, auch für P4 (17 Fälle m-*) 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.
  6. Schwarze Flächen: dark-surface erlaubt 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 über ignorieren.
  7. Seiten der Vorschau: Die Vorschau zeigt public/neu/dashboard.html als /dashboard.html. Für login.html, kuendigen.html, widerrufen.html hat public/neu/oeffentlich/<seite> Vorrang vor public/<seite>, sobald es sie gibt. Skripte der neuen Seiten dürfen klassisch oder type="module" sein und laufen in Dokumentreihenfolge. boot.js feuert nach dem Laden einmal DOMContentLoaded und load; Code sollte trotzdem document.readyState prüfen.
  8. Offene Betriebsfrage: isPreviewProjectReady verlangt status oder provisioningStatus ready oder deployed. 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 mit KN_ERSTLAUF_FREMDES_OK=ja an und überschreibt dann nur gleichnamige Dateien.
  9. anfrageStatus (V5) steht in projects[].features.<shop|unterkunft> der Antwort von GET /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 und vertragNeu ziehen dann mit.
  10. V1 um neueAnsichtFuerKunde({ customerId, email }) ergänzt (Befund der Prüfer, wichtig). Nach der Regel „gültige Sitzung“ (Frage 3) ist neueAnsichtFuerSession({ customerId, email }) ohne Cookie und ohne appwriteUserId in jedem Modus false. P5 Task 13 (aboModellFuerKunde), P5 runAboCheck und damit P6 (Team-Aktivierung, Widerruf des Wechsels, Tagesjob-Kopplung) wären still tot. Deshalb liefert schalter.js einen vierten Export: dieselben Modus- und Listenregeln, aber ohne Sitzungsprüfung, bei leerer customerId false (Task 2, Tests dort). Zu tun: Die Übersicht V1 bekommt die Signatur export function neueAnsichtFuerKunde({ customerId, email }) // boolean, für Jobs und Dienste ohne Cookie samt Regeln. P5 Task 13 und P5 Vertragsfrage 6 (Annahme zu P0) stellen auf neueAnsichtFuerKunde um, Annahme bestätigt mit dieser Funktion statt neueAnsichtFuerSession.
  11. Katalog der Fixtures mit consent und hinweis: V5 nennt katalog[].consent und katalog[].hinweis nicht, P5 liefert consent (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), vertragNeu deckt beides ab (Task 7). P5 bestätigt Form und Namen, oder die Fixtures ziehen mit. Die Übersicht V5 sollte beide Felder aufnehmen.