Files
Webklar-Kundenbereich/test/buildLegalHtml.test.js
T
2026-10-05 03:03:40 +00:00

338 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
import express, { Router } from 'express'
import { useTestEnv } from './support/testEnv.js'
// HTML-Generator der Rechtstexte (scripts/build-legal-html.mjs) und die ausgelieferten Dateien des Terminplaners (Task F6).
// Die Markdown-Quellen stehen unter docs/rechtliches/, die Seiten und PDFs unter public/rechtliches/<slug>-<Fassung>.html|pdf.
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { startTestApp } = await import('./support/testApp.js')
const { legalPaths, legalPdfFiles, legalPdfAttachments, legalRouter } = await import('../server/services/legalDocs.js')
const { render, parse, lauf, termineDokumente, MarkdownFehler } = await import('../scripts/build-legal-html.mjs')
const V = config.termine.avvVersion
const ROOT = fileURLToPath(new URL('..', import.meta.url))
const lies = (rel) => readFileSync(join(ROOT, rel), 'utf8')
const DOKUMENTE = termineDokumente(V)
const META = { fassung: '2026-10-05', titel: 'Prüfdokument', seitenkopf: 'Prüfdokument', nav: [{ href: '/dashboard.html', text: 'Portal' }] }
const KOPF = '# Titel\n\n**Fassung 2026-10-05**\n\n'
/** HTML in Klartext: Tags weg, Entitäten zurück, Leerraum normalisiert. */
const klartext = (html) =>
html
.replace(/<style>[\s\S]*?<\/style>/g, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
.replace(/\s+/g, ' ')
.trim()
test('termineDokumente: lb, avv, widerruf mit Quelle, Ziel und Navigation auf die Fassung', () => {
assert.deepEqual(DOKUMENTE.map((d) => d.doc), ['lb', 'avv', 'widerruf'])
assert.deepEqual(
DOKUMENTE.map((d) => [d.md, d.html]),
[
['docs/rechtliches/leistungsbeschreibung-terminplaner.md', `public/rechtliches/leistungsbeschreibung-terminplaner-${V}.html`],
['docs/rechtliches/avv-terminplaner.md', `public/rechtliches/avv-terminplaner-${V}.html`],
['docs/rechtliches/widerrufsbelehrung-funktionen.md', `public/rechtliches/widerrufsbelehrung-funktionen-${V}.html`],
],
)
const adressen = Object.values(legalPaths('termine', V))
for (const d of DOKUMENTE) {
assert.equal(d.meta.fassung, V)
const hrefs = d.meta.nav.map((l) => l.href)
for (const a of adressen) assert.ok(hrefs.includes(a), `Navigation von ${d.doc} verlinkt ${a}`)
}
})
test('html_gleich_generator: die eingecheckten Seiten sind genau render(Markdown) und werden bei zweitem Lauf gleich', () => {
for (const d of DOKUMENTE) {
const md = lies(d.md)
const html = render(md, d.meta)
assert.equal(lies(d.html), html, `${d.html} weicht vom Generator ab (node scripts/build-legal-html.mjs termine)`)
assert.equal(render(md, d.meta), html, 'deterministisch: zweiter Lauf gleiche Bytes')
// keine Datumsangabe außer der Fassung
assert.doesNotMatch(html.replaceAll(V, ''), /\b\d{4}-\d{2}-\d{2}\b/, `${d.html}: Datum außer der Fassung`)
}
// --check: Exit 0 bei gleichem Stand
const lauf = spawnSync(process.execPath, ['--max-old-space-size=512', 'scripts/build-legal-html.mjs', 'termine', '--check'], { cwd: ROOT, encoding: 'utf8', timeout: 60000 })
assert.equal(lauf.status, 0, lauf.stdout + lauf.stderr)
assert.equal([...lauf.stdout.matchAll(/^ok /gm)].length, 3)
})
test('html_gleich_generator: --check meldet eine abweichende oder fehlende Seite mit Exit 1 und schreibt nichts; ohne --check wird geschrieben; falscher Aufruf: Exit 2', () => {
const dir = mkdtempSync(join(tmpdir(), 'legalhtml-'))
try {
mkdirSync(join(dir, 'docs/rechtliches'), { recursive: true })
mkdirSync(join(dir, 'public/rechtliches'), { recursive: true })
for (const d of DOKUMENTE) cpSync(join(ROOT, d.md), join(dir, d.md))
const zeilen = []
const ruf = (...args) => lauf(args, { root: dir, log: (z) => zeilen.push(z), fehler: (z) => zeilen.push(z) })
// noch keine Seiten: --check meldet alle drei und schreibt nichts
assert.equal(ruf('termine', '--check'), 1)
assert.equal(zeilen.filter((z) => z.startsWith('ABWEICHT')).length, 3)
assert.equal(existsSync(join(dir, DOKUMENTE[0].html)), false, '--check schreibt nichts')
// schreiben, dann stimmt --check
assert.equal(ruf('termine'), 0)
assert.equal(ruf('termine', '--check'), 0)
for (const d of DOKUMENTE) assert.equal(readFileSync(join(dir, d.html), 'utf8'), lies(d.html), 'dieselben Bytes wie die eingecheckte Seite')
// eine Seite von Hand geändert (oder die Quelle geändert): Abweichung, Datei bleibt wie sie ist
const ziel = join(dir, DOKUMENTE[1].html)
const handgemacht = readFileSync(ziel, 'utf8').replace('<h2>7. Unterauftragsverarbeiter</h2>', '<h2>7. Unterauftragsverarbeiter (von Hand)</h2>')
writeFileSync(ziel, handgemacht)
zeilen.length = 0
assert.equal(ruf('termine', '--check'), 1)
assert.deepEqual(zeilen.filter((z) => z.startsWith('ABWEICHT')), [`ABWEICHT ${DOKUMENTE[1].html}`])
assert.equal(readFileSync(ziel, 'utf8'), handgemacht, '--check ändert die Datei nicht')
// Quelle geändert, Seite nicht nachgezogen
writeFileSync(ziel, lies(DOKUMENTE[1].html))
writeFileSync(join(dir, DOKUMENTE[1].md), `${lies(DOKUMENTE[1].md)}\n## 13. Neu\n\n(1) Neuer Absatz.\n`)
assert.equal(ruf('termine', '--check'), 1)
// falscher Aufruf
assert.equal(ruf(), 2)
assert.equal(ruf('analyse'), 2)
assert.equal(ruf('termine', '--schreiben'), 2)
} finally {
rmSync(dir, { recursive: true, force: true })
}
// als Programm: Exit 0 auf dem eingecheckten Stand, Exit 2 ohne Argumente
const prog = (...args) => spawnSync(process.execPath, ['--max-old-space-size=512', 'scripts/build-legal-html.mjs', ...args], { cwd: ROOT, encoding: 'utf8', timeout: 60000 })
assert.equal(prog('termine', '--check').status, 0)
assert.equal(prog().status, 2)
})
test('huelle_gleich_analyse: dieselbe Seitenhülle wie die Seiten der Website-Analyse (bis auf die meta-Zeile mit der Fassung)', () => {
const analyse = termineDokumenteAnalyse()
for (const a of analyse) {
const neu = render(lies(a.md), a.meta).replace(/^ {2}<meta name="webklar-fassung"[^\n]*\n/m, '')
assert.equal(neu, lies(a.html), `${a.html}: Seitenhülle weicht ab`)
}
})
function termineDokumenteAnalyse() {
const nav = [
{ href: '/dashboard.html', text: '← Kundenportal' },
{ href: '/abo-bedingungen.html', text: 'Abo-Bedingungen' },
{ href: '/rechtliches/avv-website-analyse-2026-10-02.html', text: 'AVV Website-Analyse' },
{ href: '/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html', text: 'Leistungsbeschreibung Website-Analyse' },
{ href: '/datenschutz.html', text: 'Datenschutzerklärung' },
{ href: 'https://webklar.com/impressum', text: 'Impressum' },
]
return [
{ md: 'docs/rechtliches/avv-website-analyse.md', html: 'public/rechtliches/avv-website-analyse-2026-10-02.html', meta: { fassung: '2026-10-02', titel: 'Auftragsverarbeitungsvertrag Website-Analyse', seitenkopf: 'AVV Website-Analyse', nav } },
{ md: 'docs/rechtliches/leistungsbeschreibung-website-analyse.md', html: 'public/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html', meta: { fassung: '2026-10-02', titel: 'Leistungsbeschreibung Website-Analyse', seitenkopf: 'Leistungsbeschreibung Website-Analyse', nav } },
]
}
test('html_text_gleich_markdown: der sichtbare Text jeder Seite ist genau der Text der Quelle (plus Navigation und Marke)', () => {
for (const d of DOKUMENTE) {
const md = lies(d.md)
// Text der Quelle in Lesereihenfolge: Markierungen weg, Tabellenzellen einzeln, Trennzeile weg
const aus = []
for (const zeile of md.replace(/\r/g, '').split('\n')) {
if (zeile.trim() === '' || /^\|[-| ]+\|$/.test(zeile)) continue
if (zeile.startsWith('|')) aus.push(...zeile.slice(1, -1).split('|'))
else aus.push(zeile.replace(/^#{1,3} /, '').replace(/^- /, ''))
}
const erwartet = aus.join(' ').replace(/\*\*|`/g, '').replace(/\s+/g, ' ').trim()
const html = lies(d.html)
const main = html.slice(html.indexOf('<main>'), html.indexOf('</main>')).replace(/<nav class="top">[\s\S]*?<\/nav>/, '').replace('<div class="brand">WEBKLAR</div>', '')
assert.equal(klartext(main), erwartet, `${d.html}: sichtbarer Text weicht vom Markdown ab`)
// jeder Absatz einzeln (aussagekräftige Fehlermeldung)
const text = klartext(main)
for (const stueck of aus.map((z) => z.replace(/\*\*|`/g, '').replace(/\s+/g, ' ').trim()).filter(Boolean)) {
assert.ok(text.includes(stueck), `${d.html}: fehlt im HTML: ${stueck.slice(0, 80)}`)
}
}
})
test('html_sauber: keine Kommentare, Entwurfsmarken, Skripte oder Ereignis-Attribute; Fassung im Kopf; Links nur relativ oder https', () => {
for (const d of DOKUMENTE) {
const html = lies(d.html)
assert.doesNotMatch(html, /<!--|Entwurf|ENTWURF|Prüfpunkt|<script|<iframe|<img|<form|<link|\sstyle=/, `${d.html}: verbotener Inhalt`)
assert.doesNotMatch(html, /\son[a-z]+\s*=/i, `${d.html}: Ereignis-Attribut`)
assert.doesNotMatch(html, /⚖|\bQ\d+\b|\{\{|\}\}|undefined|NaN/, `${d.html}: Marker oder Platzhalter`)
assert.ok(html.includes(`<strong>Fassung ${V}</strong>`), `${d.html}: "Fassung ${V}" fehlt im Kopf`)
assert.ok(html.includes(`<meta name="webklar-fassung" content="${V}">`))
assert.match(html, /<html lang="de">/)
assert.match(html, /<title>[^<]+ – WEBklar<\/title>/)
const hrefs = [...html.matchAll(/\shref="([^"]*)"/g)].map((m) => m[1])
assert.ok(hrefs.length >= 6)
for (const h of hrefs) assert.ok(/^\/(?!\/)/.test(h) || /^https:\/\//.test(h), `${d.html}: Link ${h}`)
assert.doesNotMatch(html, /\ssrc=/)
// @page-Kopf und Seitenzähler wie die Analyse-Seiten
assert.ok(html.includes(`content: "WEBklar · ${d.meta.seitenkopf}"`))
assert.ok(html.includes('content: "Seite " counter(page) " von " counter(pages)'))
// nur ein h1, jede Seite hat die Marke
assert.equal([...html.matchAll(/<h1>/g)].length, 1)
assert.ok(html.includes('<div class="brand">WEBKLAR</div>'))
}
})
test('generator_lehnt_unbekannte_syntax_ab: Codeblock, Zitat, Bild, Link, rohes HTML, nummerierte Liste und mehr brechen laut ab', () => {
assert.doesNotThrow(() => render(`${KOPF}## Abschnitt\n\n(1) Text mit **fett** und \`code\`.\n- Punkt\n- Punkt zwei\n\n| A | B |\n|---|---|\n| 1 | 2 |\n`, META))
const schlecht = {
Codeblock: '```js\nx\n```',
'Codeblock (Tilde)': '~~~\nx\n~~~',
Zitat: '> zitiert',
Bild: '![Bild](a.png)',
Link: 'ein [Link](https://example.com) im Text',
'rohes HTML': '<div>x</div>',
'rohes HTML im Satz': 'Text <b>fett</b>',
Kommentar: '<!-- x -->',
'nummerierte Liste': '1. Eins\n2. Zwei',
'Liste mit Stern': '* Punkt',
'Liste mit Plus': '+ Punkt',
Trennlinie: '---',
'Überschrift ab Ebene 4': '#### Tief',
'Überschrift ohne Leerzeichen': '##Tief',
Einrückung: ' eingerückt',
'Einrückung der Liste': '- Punkt\n - Unterpunkt',
'offenes fett': 'ein **offenes fett',
'offener Code': 'ein `offener Code',
'leerer Listenpunkt': '- ',
'zweites h1': '# Noch ein Titel',
'Tabelle ohne Trennzeile': '| A | B |\n| 1 | 2 |',
'Tabelle mit anderer Spaltenzahl': '| A | B |\n|---|---|\n| 1 | 2 | 3 |',
'Tabelle mit Ausrichtung': '| A | B |\n|:--|---|\n| 1 | 2 |',
}
for (const [name, text] of Object.entries(schlecht)) {
assert.throws(() => render(`${KOPF}${text}\n`, META), MarkdownFehler, `${name} muss abbrechen`)
}
// Fehlermeldung nennt Zeile und Grund
assert.throws(() => render(`${KOPF}## A\n\n> Zitat\n`, META), /Zeile 7: nicht unterstützt \(Zitat\)/)
// Kopf: erste Zeile Titel, danach der Fassungsabsatz mit der Fassung der Meta
assert.throws(() => render('## ohne Titel\n', META), MarkdownFehler)
assert.throws(() => render('# Titel\n\nText ohne Fassung\n', META), /Fassung 2026-10-05/)
assert.throws(() => render('# Titel\n\n**Fassung 2026-10-06**\n', META), /Fassung 2026-10-05/)
// Meta: Fassung im Format, Adressen der Navigation nur relativ oder https
assert.throws(() => render(`${KOPF}Text\n`, { ...META, fassung: '5.10.2026' }), /fassung/)
for (const href of ['javascript:alert(1)', '//evil.example/x', 'http://example.com', '/x"onmouseover="y', 'data:text/html,x']) {
assert.throws(() => render(`${KOPF}Text\n`, { ...META, nav: [{ href, text: 'x' }] }), /Adresse in der Navigation/, href)
}
assert.throws(() => parse(42), TypeError)
})
test('generator_escaped: <, & und " in Text, Code, Tabellen, Titel und Attributen werden maskiert', () => {
const md = `${KOPF}## Wert < 5 & "zitiert" > 3\n\nEin Absatz mit a < b & c, "Anführung" und 'Apostroph'.\n\n- Punkt mit < und &\n- **fett & kühn** und \`a<b && c\`\n\n| Kopf & "Z" | B < 1 |\n|---|---|\n| x & y | "z" > 0 |\n`
const html = render(md, { ...META, titel: 'Titel & "Mehr" <b>', seitenkopf: 'Kopf "x" \\ y', nav: [{ href: '/x?a=1&b=2', text: 'Link <1> & "2"' }] })
assert.ok(html.includes('<h2>Wert &lt; 5 &amp; &quot;zitiert&quot; &gt; 3</h2>'))
assert.ok(html.includes('<p>Ein Absatz mit a &lt; b &amp; c, &quot;Anführung&quot; und &#39;Apostroph&#39;.</p>'))
assert.ok(html.includes('<li>Punkt mit &lt; und &amp;</li>'))
assert.ok(html.includes('<li><strong>fett &amp; kühn</strong> und <code>a&lt;b &amp;&amp; c</code></li>'))
assert.ok(html.includes('<th>Kopf &amp; &quot;Z&quot;</th><th>B &lt; 1</th>'))
assert.ok(html.includes('<td>x &amp; y</td><td>&quot;z&quot; &gt; 0</td>'))
// Attribute und Titel
assert.ok(html.includes('<a href="/x?a=1&amp;b=2">Link &lt;1&gt; &amp; &quot;2&quot;</a>'))
assert.ok(html.includes('<title>Titel &amp; &quot;Mehr&quot; &lt;b&gt; – WEBklar</title>'))
assert.ok(html.includes('content: "WEBklar · Kopf \\"x\\" \\\\ y"'), 'CSS-Zeichenkette escaped')
// kein ungeschütztes Tag aus dem Text im Ergebnis
assert.doesNotMatch(html, /<b>|<1>/)
})
test('pdf_dateien: je Dokument ein PDF unter dem Namen aus legalPdfFiles, größer als 20 KB', () => {
const dateien = legalPdfFiles('termine', V)
assert.deepEqual(Object.keys(dateien), ['lb', 'avv', 'widerruf'])
for (const [doc, f] of Object.entries(dateien)) {
assert.match(f.path, new RegExp(`/public/rechtliches/[a-z-]+-${V}\\.pdf$`))
assert.equal(f.filename.endsWith(`-${V}.pdf`), true)
const bytes = readFileSync(f.path)
assert.equal(bytes.subarray(0, 5).toString('latin1'), '%PDF-', `${doc}: kein PDF`)
assert.ok(bytes.length > 20 * 1024, `${doc}: PDF mit ${bytes.length} Bytes ist zu klein`)
assert.ok(bytes.subarray(-1024).toString('latin1').includes('%%EOF'), `${doc}: PDF ist abgeschnitten`)
// mehr als eine Seite bei LB und AVV, mindestens eine bei der Belehrung
const seiten = bytes.toString('latin1').match(/\/Type\s*\/Page[^s]/g) ?? []
assert.ok(seiten.length >= (doc === 'widerruf' ? 1 : 2), `${doc}: ${seiten.length} Seiten`)
// das PDF gehört zur HTML-Seite derselben Fassung: gleicher Name bis auf die Endung
const d = DOKUMENTE.find((x) => x.doc === doc)
assert.equal(f.path, join(ROOT, d.html.replace(/\.html$/, '.pdf')))
}
})
test('anhaenge_drei: legalPdfAttachments("termine", F) liefert lb, avv, widerruf als application/pdf', async () => {
const anhaenge = await legalPdfAttachments('termine', V)
assert.deepEqual(anhaenge.map((a) => a.filename), [
`Leistungsbeschreibung-Terminplaner-${V}.pdf`,
`AVV-Terminplaner-${V}.pdf`,
`Widerrufsbelehrung-Muster-Widerrufsformular-${V}.pdf`,
])
const dateien = legalPdfFiles('termine', V)
for (const [i, doc] of ['lb', 'avv', 'widerruf'].entries()) {
assert.equal(anhaenge[i].contentType, 'application/pdf')
assert.ok(Buffer.isBuffer(anhaenge[i].content))
assert.deepEqual(anhaenge[i].content, readFileSync(dateien[doc].path))
assert.equal(anhaenge[i].content.subarray(0, 5).toString('latin1'), '%PDF-')
}
})
// Router wie in server/index.js: erst die Adressen ohne Fassung (legalRouter), dann die Dateien selbst (express.static)
const rechtliches = Router()
rechtliches.use(legalRouter())
rechtliches.use(express.static(join(ROOT, 'public', 'rechtliches')))
const app = await startTestApp({ '/rechtliches': rechtliches })
after(() => app.close())
test('router_alias: /rechtliches/<slug>.html liefert die Bytes der Fassungsdatei; unbekannte Fassung und alte Adressen: 404', async () => {
for (const d of DOKUMENTE) {
const slug = /\/([a-z-]+)-\d{4}-\d{2}-\d{2}\.html$/.exec(d.html)[1]
const alias = await fetch(`${app.url}/rechtliches/${slug}.html`)
assert.equal(alias.status, 200)
assert.match(alias.headers.get('content-type'), /text\/html/)
assert.deepEqual(Buffer.from(await alias.arrayBuffer()), readFileSync(join(ROOT, d.html)), `${slug}: Alias liefert nicht die Datei der Fassung`)
const fest = await fetch(`${app.url}/rechtliches/${slug}-${V}.html`)
assert.equal(fest.status, 200)
assert.deepEqual(Buffer.from(await fest.arrayBuffer()), readFileSync(join(ROOT, d.html)))
const pdf = await fetch(`${app.url}/rechtliches/${slug}-${V}.pdf`)
assert.equal(pdf.status, 200)
assert.match(pdf.headers.get('content-type'), /application\/pdf/)
const alt = await fetch(`${app.url}/rechtliches/${slug}-1999-01-01.html`)
assert.equal(alt.status, 404, `${slug}-1999-01-01.html darf es nicht geben`)
}
// Fassung der Konstante ohne Datei: der Alias antwortet 404
const vorher = config.termine.avvVersion
config.termine.avvVersion = '1999-01-01'
try {
assert.equal((await fetch(`${app.url}/rechtliches/avv-terminplaner.html`)).status, 404)
} finally {
config.termine.avvVersion = vorher
}
assert.equal((await fetch(`${app.url}/rechtliches/avv-terminplaner.html`)).status, 200)
})
test('script_modus: build-legal-pdfs.sh kennt termine und datenschutz, lässt die all-Zeile unverändert und begrenzt Chrome auf 120 Sekunden', () => {
const sh = lies('scripts/build-legal-pdfs.sh')
assert.match(sh, /^termine\)$/m)
assert.match(sh, /^datenschutz\)$/m)
assert.match(sh, /^analyse\)$/m)
assert.match(sh, /^all\)$/m)
assert.ok(sh.includes('TERMINE_VERSION'))
assert.ok(sh.includes('config.termine.avvVersion'), 'die Vorgabe der Fassung kommt aus der Konstante des Servers')
// die unveränderte Zeile von all (und derselbe Aufruf im Modus datenschutz)
const all = sh.slice(sh.indexOf('\nall)'), sh.indexOf('\n*)'))
assert.ok(all.includes(' pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf\n'))
assert.ok(all.includes(' pdf docs/leitfaden-empfehlungsgeber.html docs/WEBklar-Leitfaden-Empfehlungsgeber.pdf\n'))
assert.ok(all.includes(' pdf public/abo-bedingungen.html public/WEBklar-Abo-Vertragsunterlagen.pdf\n'))
const datenschutz = sh.slice(sh.indexOf('\ndatenschutz)'), sh.indexOf('\nall)'))
assert.ok(datenschutz.includes('pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf'))
// die drei Dokumente des Modus termine sind die Slugs der Registry
const termine = sh.slice(sh.indexOf('\ntermine)'), sh.indexOf('\ndatenschutz)'))
for (const f of Object.values(legalPdfFiles('termine', V))) {
const slug = /\/([a-z-]+)-\d{4}-\d{2}-\d{2}\.pdf$/.exec(f.path)[1]
assert.ok(termine.includes(slug), `Modus termine baut ${slug}`)
}
// Chrome höchstens 120 Sekunden je Aufruf; Aufbau und Aufrufe nacheinander
assert.match(sh, /^ {2}timeout 120 "\$CHROME" /m)
assert.ok(!/ &$|^\s*wait\b|xargs -P/m.test(sh), 'keine parallelen Läufe')
// bash versteht das Skript, und ein unbekannter Modus liefert die Usage mit allen Modi
const syntax = spawnSync('bash', ['-n', 'scripts/build-legal-pdfs.sh'], { cwd: ROOT, encoding: 'utf8' })
assert.equal(syntax.status, 0, syntax.stderr)
assert.ok(sh.includes('Usage: $0 [all|analyse|termine|datenschutz]'))
})