- gelöscht: components/workers/engine/* (10 Dateien), WorkersStage, WorkersBoard, WorkerCard, WorkerList, WorkersKpis, die neun Engine-Tests samt workersSmoke (211 Tests) und die Helfer engineVm.mjs und fakeCtx.mjs (nur die Engine-Tests brauchten sie) - workers.css (310 -> 60 Zeilen) behält nur Einstellungen-Dialog und Interessenten-Panel; @font-face für Pixelify Sans steht jetzt in hustler/schrift.css - workersComponents: kein innerHTML und kein Kanvas rekursiv unter components/workers/**, jeder Knopf mit type, Links nur aus geprüften Feldern (externe mit noopener), keine Anmeldung in den Bildschirmen, alte Dateien weg und nirgends mehr importiert - workersCss: jede wk-Klasse im Code hat eine Regel, keine Regel ohne Nutzer; workersFont: Pixelify Sans genau einmal in schrift.css - hustlerAssets: Pixelify Sans in schrift.css erlaubt; hustlerDialog: Link-Ziele nur aus safeLink, Knöpfe mit type; hustlerSeite: Laden/Fehler/bereit am selben Einhängepunkt Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
167 lines
9.9 KiB
JavaScript
167 lines
9.9 KiB
JavaScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
// Regeln für alle React-Teile der Hustler-Seite, geprüft am Quelltext (Muster adminRoute.test.mjs): src/components/workers/** mit hochhaus/, pult/ und keller/.
|
|
// Das Verhalten der einzelnen Bildschirme prüfen die Komponententests (hustlerKopf, hustlerDialog, hustlerHochhausWelt, hustlerSeite, hustlerPult, hustlerKeller ...).
|
|
// Seit T11 gibt es die alte helle Oberfläche und die Kanvas-Engine nicht mehr; ihre Zusicherungen sind in diese Tests gewandert (Tabelle im Übergabebericht von T11).
|
|
const COMPONENTS = fileURLToPath(new URL('../../src/components/workers/', import.meta.url))
|
|
const SRC = fileURLToPath(new URL('../../src/', import.meta.url))
|
|
const read = (p) => readFileSync(path.join(COMPONENTS, p), 'utf8')
|
|
const readSrc = (p) => readFileSync(path.join(SRC, p), 'utf8')
|
|
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(path.join(dir, e.name)) : [path.join(dir, e.name)]))
|
|
const komponenten = () => walk(COMPONENTS).filter((f) => /\.(jsx?|mjs)$/.test(f))
|
|
const rel = (f) => path.relative(COMPONENTS, f)
|
|
// Kommentare weg: Block- und Zeilenkommentare (// nur am Zeilenanfang oder nach Leerraum, damit https:// in Zeichenketten bleibt)
|
|
const ohneKommentare = (code) => code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
|
|
|
|
// Öffnende Tags `<name …>` samt Attributen. Klammern {…} und Zeichenketten werden übersprungen, damit das `=>` eines onClick das Tag nicht beendet.
|
|
function oeffnendeTags(src, name) {
|
|
const tags = []
|
|
const re = new RegExp(`<${name}(?=[\\s/>])`, 'g')
|
|
for (let m; (m = re.exec(src)) !== null;) {
|
|
let tiefe = 0
|
|
let quote = ''
|
|
let i = m.index + m[0].length
|
|
for (; i < src.length; i++) {
|
|
const c = src[i]
|
|
if (quote) {
|
|
if (c === '\\') i += 1
|
|
else if (c === quote) quote = ''
|
|
continue
|
|
}
|
|
if (c === '"' || c === "'" || c === '`') quote = c
|
|
else if (c === '{') tiefe += 1
|
|
else if (c === '}') tiefe -= 1
|
|
else if (c === '>' && tiefe === 0) break
|
|
}
|
|
tags.push(src.slice(m.index, i + 1))
|
|
}
|
|
return tags
|
|
}
|
|
|
|
// Wert eines Attributs im Tag: Zeichenkette (mit Anführungszeichen) oder der Ausdruck in {…} ohne Klammern; null, wenn es das Attribut nicht gibt
|
|
function attribut(tag, name) {
|
|
const m = new RegExp(`(?<![\\w-])${name}=`).exec(tag)
|
|
if (!m) return null
|
|
const start = m.index + m[0].length
|
|
if (tag[start] === '"') return tag.slice(start, tag.indexOf('"', start + 1) + 1)
|
|
if (tag[start] !== '{') return null
|
|
let tiefe = 0
|
|
for (let i = start; i < tag.length; i++) {
|
|
if (tag[i] === '{') tiefe += 1
|
|
else if (tag[i] === '}') {
|
|
tiefe -= 1
|
|
if (tiefe === 0) return tag.slice(start + 1, i).trim()
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
test('kein innerHTML und keine Kanvas-Zeichnung in den Komponenten unter src/components/workers/** (rekursiv, Kommentare ignoriert)', () => {
|
|
const dateien = komponenten()
|
|
for (const dir of ['hochhaus', 'pult', 'keller']) assert.ok(dateien.some((f) => rel(f).startsWith(dir + path.sep)), `${dir}/ wird mitgelesen`)
|
|
assert.ok(dateien.length >= 15, `nur ${dateien.length} Dateien gelesen`)
|
|
for (const f of dateien) {
|
|
const code = ohneKommentare(readFileSync(f, 'utf8'))
|
|
assert.doesNotMatch(code, /dangerouslySetInnerHTML|innerHTML|outerHTML|insertAdjacentHTML|document\.write/, rel(f))
|
|
assert.doesNotMatch(code, /<canvas\b|getContext\s*\(/, `${rel(f)}: die Pixelwelt ist DOM + CSS, kein Kanvas`)
|
|
}
|
|
})
|
|
|
|
// Frühere Prüfung des Boards: Rechte und Nutzer kommen als Props von der Seite (isAdmin, me), die Bildschirme greifen nie selbst auf Anmeldung oder Appwrite zu.
|
|
test('Komponenten ohne Anmeldungsbezug: kein useAuth und kein appwrite unter src/components/workers/**', () => {
|
|
for (const f of komponenten()) assert.doesNotMatch(ohneKommentare(readFileSync(f, 'utf8')), /useAuth|appwrite/i, rel(f))
|
|
})
|
|
|
|
test('die alte Oberfläche und die Kanvas-Engine sind weg, und nichts unter src/ verweist noch darauf (T11)', () => {
|
|
for (const alt of ['engine', 'WorkersStage.jsx', 'WorkersBoard.jsx', 'WorkerCard.jsx', 'WorkerList.jsx', 'WorkersKpis.jsx']) assert.equal(existsSync(path.join(COMPONENTS, alt)), false, alt)
|
|
const verweis = /['"][^'"\n]*(?:\/engine\/[\w.-]+|\/(?:WorkersStage|WorkersBoard|WorkerCard|WorkerList|WorkersKpis))(?:\.jsx?)?['"]/
|
|
let gelesen = 0
|
|
for (const f of walk(SRC).filter((x) => /\.jsx?$/.test(x))) {
|
|
gelesen += 1
|
|
assert.doesNotMatch(ohneKommentare(readFileSync(f, 'utf8')), verweis, path.relative(SRC, f))
|
|
}
|
|
assert.ok(gelesen > 100, `nur ${gelesen} Dateien gelesen`)
|
|
})
|
|
|
|
// Frühere Prüfung der Bühne und des Boards (PF-08): der WOMS-Button hat keinen Standard-type, ein <button> ohne type schickt in einem Formular ab.
|
|
test('jeder Knopf (<button>, <Button>) hat ein ausdrückliches type="button"; „submit“ gibt es nur im Formular der Einstellungen', () => {
|
|
let anzahl = 0
|
|
for (const f of komponenten().filter((x) => x.endsWith('.jsx'))) {
|
|
const code = ohneKommentare(readFileSync(f, 'utf8'))
|
|
for (const name of ['button', 'Button']) {
|
|
const tags = oeffnendeTags(code, name)
|
|
assert.equal(tags.length, (code.match(new RegExp(`<${name}(?=[\\s/>])`, 'g')) || []).length, `${rel(f)}: ${name}-Tags vollständig gelesen`)
|
|
for (const tag of tags) {
|
|
anzahl += 1
|
|
const erlaubt = rel(f) === 'WorkersSettingsDialog.jsx' ? /\btype="(?:button|submit)"/ : /\btype="button"/
|
|
assert.match(tag, erlaubt, `${rel(f)}: ${tag.replace(/\s+/g, ' ').slice(0, 90)}`)
|
|
}
|
|
}
|
|
}
|
|
assert.ok(anzahl >= 35, `nur ${anzahl} Knöpfe gelesen`)
|
|
// „submit“ kommt genau einmal vor: „Speichern“ im Formular
|
|
assert.equal((ohneKommentare(read('WorkersSettingsDialog.jsx')).match(/type="submit"/g) || []).length, 1)
|
|
})
|
|
|
|
// Frühere Prüfung der Worker-Karte (Review Focus 5): Ziele von Links kommen nur aus geprüften Feldern, externe öffnen mit noopener.
|
|
test('Links: jedes Ziel (href, to) kommt aus einem geprüften Feld oder ist fest; externe Ziele öffnen im neuen Tab mit rel="noopener noreferrer"', () => {
|
|
// safeLink (Spur im Dialog) · sourceHref/previewHref (workersPult, botleadsView, workersProspect über safeExternalUrl/safeHref/previewUrl) · mailtoHref · feste Pfade der Etagen
|
|
const ERLAUBT = new Set(['safe.href', 'row.sourceHref', 'row.previewHref', 'view.sourceHref', 'e.sourceHref', 'mail', '`/hustler?tab=${link.tab}`'])
|
|
let links = 0
|
|
for (const f of komponenten().filter((x) => x.endsWith('.jsx'))) {
|
|
const code = ohneKommentare(readFileSync(f, 'utf8'))
|
|
for (const [name, ziel] of [['a', 'href'], ['Link', 'to']]) {
|
|
for (const tag of oeffnendeTags(code, name)) {
|
|
links += 1
|
|
const wert = attribut(tag, ziel)
|
|
assert.ok(wert !== null, `${rel(f)}: ${name} ohne ${ziel}: ${tag.slice(0, 80)}`)
|
|
assert.ok(wert.startsWith('"') || ERLAUBT.has(wert), `${rel(f)}: ${ziel}={${wert}} ist kein geprüftes Ziel`)
|
|
if (attribut(tag, 'target') === '"_blank"') assert.equal(attribut(tag, 'rel'), '"noopener noreferrer"', `${rel(f)}: ${tag.slice(0, 80)}`)
|
|
if (wert.startsWith('"')) assert.doesNotMatch(wert, /javascript:|data:/i)
|
|
}
|
|
}
|
|
}
|
|
assert.ok(links >= 8, `nur ${links} Links gelesen`)
|
|
// die Felder selbst entstehen in den Ansichten nur über die Prüfer
|
|
assert.match(readSrc('lib/workersPult.js'), /sourceHref: freigabe \? null : safeExternalUrl\(item\.sourceUrl\)/)
|
|
assert.match(readSrc('lib/workersPult.js'), /previewHref: freigabe \? previewUrl\(item\.sourceUrl\) : null/)
|
|
assert.match(readSrc('lib/workersProspect.js'), /sourceHref: safeExternalUrl\(prospect\.sourceUrl\)/)
|
|
assert.match(readSrc('lib/botleadsView.js'), /sourceHref: safeHref\(it\.sourceUrl\)/)
|
|
assert.match(readSrc('lib/workersPult.js'), /return `mailto:\$\{to\}/)
|
|
// die Etagen-Links des Hochhauses sind feste Pfade
|
|
assert.match(read('hochhaus/HochhausWelt.jsx'), /tab: 'freigabe'[\s\S]*tab: 'botleads'/)
|
|
})
|
|
|
|
test('Einstellungs-Dialog: Dialog-Baustein, Formularlogik, kein paused, Admin-Beschriftungen, Notbremsen-Block', () => {
|
|
const s = read('WorkersSettingsDialog.jsx')
|
|
assert.match(s, /from '\.\.\/ui\/Dialog'/); assert.match(s, /formToPatch\(/); assert.match(s, /validateForm\(/); assert.match(s, /api\.saveSettings\(/)
|
|
assert.doesNotMatch(s, /paused/)
|
|
for (const t of ['Hustler-Einstellungen', 'Tagesgrenze (USD)', 'Monatsgrenze (USD)', 'Unverlangte Mails pro Tag', 'Angebotstext', 'Regionen', 'Speichern', 'Abbrechen']) assert.ok(s.includes(t), t)
|
|
assert.match(s, /disabled=\{/)
|
|
// H4/A21: Block „Versand gestoppt“ mit Freigabe, außerhalb des Formular-Patches
|
|
assert.match(s, /sendingHalted/); assert.ok(s.includes('Versand gestoppt')); assert.ok(s.includes('Versand wieder freigeben'))
|
|
assert.match(s, /api\.saveSettings\(\{ sendingHalted: false \}\)/); assert.match(s, /sendingHaltedReason/)
|
|
assert.doesNotMatch(s, /formToPatch\([^)]*sendingHalted/)
|
|
})
|
|
|
|
// ---- P3-T21: Abmelden setzt das JWT zurück ----
|
|
|
|
test('Abmelden: AuthContext ruft resetWorkersJwt() auf, workersClient setzt JWT-Cache und Sperre zurück (P3-T21)', () => {
|
|
const auth = readSrc('context/AuthContext.jsx')
|
|
assert.match(auth, /import \{ resetWorkersJwt \} from '\.\.\/lib\/workersClient'/)
|
|
const start = auth.indexOf('async function logout()')
|
|
assert.ok(start > 0)
|
|
const logout = auth.slice(start, auth.indexOf('const isAdmin', start))
|
|
assert.match(logout, /resetWorkersJwt\(\)/)
|
|
// im finally: auch ein fehlgeschlagenes deleteSessions lässt das JWT des alten Nutzers nicht im Cache
|
|
assert.ok(logout.indexOf('finally') > 0 && logout.indexOf('resetWorkersJwt()') > logout.indexOf('finally'))
|
|
const client = readSrc('lib/workersClient.js')
|
|
assert.match(client, /export function resetWorkersJwt\(\)/)
|
|
const fn = client.slice(client.indexOf('export function resetWorkersJwt()'))
|
|
assert.match(fn, /jwtCache\.invalidate\(\)/); assert.match(fn, /workersApi\.resetAuth\(\)/)
|
|
})
|