1417 lines
119 KiB
Markdown
1417 lines
119 KiB
Markdown
# 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`)
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```js
|
||
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))`:
|
||
```js
|
||
// 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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```js
|
||
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**
|
||
|
||
```bash
|
||
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`.
|
||
|
||
```js
|
||
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)**
|
||
|
||
```bash
|
||
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`)
|
||
|
||
```js
|
||
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)**
|
||
|
||
```bash
|
||
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])))`). `fx` ist das Modul `${WT}/test/support/fixtures.js` (`customerCookie`, `kundenansichtCookie`, `seedFixtures`, `wartungDoc`), `config` das Portal-`config`, `rawGet` aus `testApp.js`, `pfade`, `VERTRAG`, `NICHT_PRUEFBAR` aus `vertrag-endpunkte.mjs`, `ENDPUNKTE` und `createBackend` aus dem Werkzeug.
|
||
|
||
```js
|
||
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)**
|
||
|
||
```bash
|
||
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)
|
||
|
||
```js
|
||
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)**
|
||
|
||
```bash
|
||
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`)
|
||
|
||
```js
|
||
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)**
|
||
|
||
```bash
|
||
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 über `run-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/`:
|
||
|
||
```js
|
||
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.
|
||
|
||
```js
|
||
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**
|
||
|
||
```bash
|
||
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'`.
|
||
|
||
```js
|
||
test('patche: Skripte raus in Reihenfolge, Kommentare bleiben, boot kommt in den Kopf', () => {
|
||
const html = '<!doctype html><html><head><title>t</title></head><body><!-- <script src="/kommentar.js"></script> --><script src="/a.js"></script><script type="module" src="/neu/b.js"></script><script>init();</script></body></html>'
|
||
const { html: aus, skripte } = patche(html, 'window.__boot = 1')
|
||
assert.deepEqual(skripte, [{ src: '/a.js' }, { type: 'module', src: '/neu/b.js' }, { text: 'init();' }])
|
||
assert.ok(!/<script[^>]*src="\/a\.js"/.test(aus) && aus.includes('<!-- <script src="/kommentar.js"></script> -->'))
|
||
assert.ok(aus.includes('<meta name="robots" content="noindex">') && aus.indexOf('window.__boot = 1') < aus.indexOf('</head>'))
|
||
const roh = /data-skripte="([^"]*)"/.exec(aus)[1].replaceAll('"', '"').replaceAll('<', '<').replaceAll('&', '&')
|
||
assert.deepEqual(JSON.parse(roh), skripte)
|
||
})
|
||
test('patche: </script im Inline-Text sprengt nichts; fehlender Kopf oder </script im Boot ist ein Fehler', () => {
|
||
const { html } = patche('<html><head></head><body><script>var s = "<\\/script>"</script></body></html>', 'x')
|
||
assert.equal((html.match(/<\/script>/g) || []).length, 1)
|
||
assert.throws(() => patche('<html><body></body></html>', 'x'), /head/)
|
||
assert.throws(() => patche('<html><head></head></html>', 'a</script>b'), /script/)
|
||
})
|
||
test('pruefeDist findet fehlende Dateien in HTML, JS-Importen und CSS (Query und Hash zählen nicht)', () => {
|
||
const d = `${KW}/tmp/dist-test`; mkdirSync(`${d}/neu`, { recursive: true })
|
||
writeFileSync(`${d}/a.html`, '<script type="module" src="/neu/x.js?v=1"></script><link rel="stylesheet" href="/s.css#h">')
|
||
writeFileSync(`${d}/neu/x.js`, "import { y } from '/neu/fehlt.js'\nimport('/neu/da.js')")
|
||
writeFileSync(`${d}/neu/da.js`, 'export const z = 1')
|
||
writeFileSync(`${d}/s.css`, 'a{background:url(/bild.png)}')
|
||
assert.deepEqual(pruefeDist(d).sort(), ['/bild.png', '/neu/fehlt.js'])
|
||
})
|
||
test('der Service-Worker-Eintrag bündelt für den Browser: kein node:, kein process', () => {
|
||
const aus = `${KW}/tmp/sw-test.js`
|
||
const r = spawnSync('systemd-run', ['--scope', '-q', '-p', 'MemoryMax=1000M', '-p', 'MemorySwapMax=0', ESBUILD, `${KW}/vorschau/sw-eintrag.mjs`, '--bundle', '--platform=browser', '--format=iife', '--target=es2020', '--define:process.env.FIXTURE_TODAY=undefined', `--outfile=${aus}`, '--log-level=warning'], { encoding: 'utf8' })
|
||
assert.equal(r.status, 0, r.stderr)
|
||
const code = readFileSync(aus, 'utf8')
|
||
assert.ok(!code.includes('node:') && !/\bprocess\./.test(code))
|
||
})
|
||
test('boot.js: kein </script, kein HTML-Kommentar, die vierzehn Kernfälle genannt, weitere Fälle nicht ausgesperrt', () => {
|
||
const boot = readFileSync(`${KW}/vorschau/boot.js`, 'utf8')
|
||
assert.ok(!boot.includes('</script') && !boot.includes('<!--'))
|
||
for (const n of FALL_NAMEN.slice(0, 14)) assert.ok(boot.includes(`'${n}'`), n) // nur die Kernfälle: spätere Pakete ändern boot.js nie
|
||
const muster = new RegExp(/FALL_MUSTER = \/(.+)\/;/.exec(boot)[1]) // jeder Name im Muster, den ein späteres Paket anhängt (m-*, privat-ohne-abo, …), kommt durch
|
||
assert.ok(['m-termine-aus', 'privat-ohne-abo', 'hosting-privat', 'leer'].every((n) => muster.test(n)) && ['../x', 'A B', '', 'x'.repeat(41)].every((n) => !muster.test(n)))
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Test laufen lassen, Fehlschlag prüfen**
|
||
|
||
Run: `cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/vorschau-bau.test.mjs`
|
||
Expected: FAIL mit `Cannot find module` auf `index-patchen.mjs`.
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
Nach den Interfaces. Vorbild (nur lesen): `vorschau/{sw-eintrag.mjs,boot.js,index-patchen.mjs}` im WOMS-Werkzeug. Abweichungen: Fall statt Daten/Szenario, kein Appwrite-Sitzungsstub, mehrere Seiten, Module und Inline-Skripte in Reihenfolge, Abfangen von `register('/sw.js')`.
|
||
|
||
- [ ] **Step 4: Test grün, dann ein Bau**
|
||
|
||
Run: wie Step 2. Expected: PASS, `fail 0`.
|
||
Run: `free -m`, dann `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/vorschau/bauen.sh`
|
||
Expected: Exit 0. In `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/vorschau-bau/` liegen `index.html`, gepatchte `dashboard.html` und `login.html` (ohne eigene `<script src>`, mit `data-skripte`), `vorschau-sw.js`, `vorschau-manifest.txt`, `vorschau-info.txt`. `sw.js` und `offline.html` fehlen dort.
|
||
|
||
- [ ] **Step 5: Commit (Werkzeug-Git)**
|
||
|
||
```bash
|
||
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()`).
|
||
|
||
```js
|
||
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)**
|
||
|
||
```bash
|
||
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: <Pfad>“), 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):
|
||
|
||
```js
|
||
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)**
|
||
|
||
```bash
|
||
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)**
|
||
|
||
```bash
|
||
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.
|