feat(v5): Ein Smoke-Beweis, der KLICKT — Playwright durch das echte Programm

WAS: `npm run klick` startet das ECHTE Rippy (Electron, Haupt, Kern,
Fenster, Datenbank) mit eigenem Profil (--profil, --klick-smoke: ohne
Tray, Updater, Toasts), einem nachgebauten Laufwerk (RIPPY_FAKE_LAUFWERK)
und nachgebauten Werkzeugen (bau/klick/fake-*.cjs) — und klickt sich
durch: Rippen → Dialog → Sprachen je Titel (jpn statt deu) → Rollen auf
Folge → Staffel 1, ab Folge 3 → Vorschau → Rip → Warteschlange →
Kompression → Ablage als S01E03/S01E04 unter Serien\…\Season 01 →
Einstellungen/Roh-Dateien → Roh loeschen → Ereignisse. Bilder liegen in
beweise/klick/. Die Naht dafuer (kern/werkzeuge/aufruf.ts): Zeigt ein
Werkzeug-Pfad auf ein Node-Skript, laeuft es ueber die eigene Laufzeit
(ELECTRON_RUN_AS_NODE) — fuer EXE-Pfade aendert sich nichts.

WARUM: Beide Fehler vom 01.09.2026 waren Verkabelung; 261 Unit-Tests
sahen keinen. Der Klick-Beweis haette beide gefunden.

NEBENBEFUND: Die CSP blockte eingebettete Schrift-Teile (data:-Fonts der
fontsource-Pakete) — Konsolenfehler im gebauten Fenster, jetzt font-src.
Dazu test/messung.spuren.test.ts: HandBrake-Scan einer echten Roh-Datei
auf Verlangen (RIPPY_MESSUNG_MKV).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-01 22:22:04 +02:00
co-authored by Claude Fable 5.1
parent e3ce8220f1
commit 6dd1b0bc7a
24 changed files with 558 additions and 47 deletions
+1 -1
View File
@@ -571,7 +571,7 @@ function TitelWahlDialog({
ersteFolge: folgeZahl,
hauptTitel,
extras,
vorgangsName: `${typ}-<Zeit>`,
vorgangsName: `${typ}-<Zeitstempel>`,
})
return (
+17 -6
View File
@@ -21,14 +21,25 @@ import { trayErstellen, trayHinweis } from './tray'
import { updateEinrichten, updateInstallierenJetzt, updatePruefenJetzt, updateStand } from './update'
const smokeModus = process.argv.includes('--smoke')
// Der Klick-Beweis (bau/klick-smoke.mjs, 5.4.0): das ECHTE Programm mit
// eigenem Profil und nachgebauten Werkzeugen — ohne Tray, Updater und
// Toasts, aber mit allem, was klickbar ist. Beendet wird er von außen.
const klickSmoke = process.argv.includes('--klick-smoke')
const profilArg = process.argv.find((a) => a.startsWith('--profil='))
/** Smoke UND Klick-Beweis: keine Nebenwirkungen auf dem Rechner. */
const ohneNebenwirkungen = smokeModus || klickSmoke
// Der Smoke-Lauf fasst das echte Profil nicht an — er hinterlässt nichts.
if (smokeModus) {
app.setPath('userData', join(app.getPath('temp'), 'rippy-smoke'))
} else if (profilArg !== undefined && profilArg.length > '--profil='.length) {
app.setPath('userData', profilArg.slice('--profil='.length))
}
// Nur EIN Rippy je Benutzer (§ 4.2: Einzelinstanz-Sperre). Die zweite
// Instanz beendet sich; die erste holt ihr Fenster nach vorn.
// Instanz beendet sich; die erste holt ihr Fenster nach vorn. Die Sperre
// hängt am Profil — ein Beweislauf mit eigenem Profil stört das laufende
// Rippy nicht und wird von ihm nicht gestört.
if (!app.requestSingleInstanceLock()) {
app.exit(0)
} else {
@@ -77,7 +88,7 @@ function start(): void {
} else if (nachricht.art === 'einstellungen') {
// Einmalig das Selbst-Update einrichten — die Adresse kommt aus
// der EINEN Einstellungs-Quelle im Kern (§ 6.7/§ 6.8).
if (!updateGestartet && !smokeModus) {
if (!updateGestartet && !ohneNebenwirkungen) {
updateGestartet = true
updateEinrichten(
nachricht.werte['updateUrl'] ?? '',
@@ -98,9 +109,9 @@ function start(): void {
// 5.4.0: Die Kompression läuft OHNE Laufwerk weiter — Updates
// warten auch auf sie, und die Taskleiste zeigt ihren Fortschritt.
kompressionVerarbeiten(nachricht.stand.kompression.laeuft)
} else if (nachricht.art === 'kompression-ergebnis' && !smokeModus) {
} else if (nachricht.art === 'kompression-ergebnis' && !ohneNebenwirkungen) {
toast(nachricht.ok ? 'Rippy ist fertig' : 'Rippy: Kompression fehlgeschlagen', `${nachricht.titel}: ${nachricht.text}`)
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !smokeModus) {
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !ohneNebenwirkungen) {
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
toast(
'Disc erkannt',
@@ -162,7 +173,7 @@ function start(): void {
}
letzterRip = status
anzeigeAktualisieren()
if (smokeModus) return
if (ohneNebenwirkungen) return
if (status.phase === 'fertig') {
toast('Rippy: Rip fertig', status.text)
} else if (status.phase === 'fehler') {
@@ -333,7 +344,7 @@ function start(): void {
void app.whenReady().then(() => {
kernVerwaltung.starten()
if (!smokeModus) {
if (!ohneNebenwirkungen) {
trayErstellen(fensterZeigen, beenden)
}
fenster = fensterErstellen()
+2 -2
View File
@@ -29,7 +29,7 @@ import { abfragen, albumOrdner, kennungAusSpuren, tagsJeSpur } from '../metadate
import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd'
import { isoSichern } from '../rip/iso'
import { rippen } from '../rip/makemkv'
import { spawn } from 'node:child_process'
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { mkdtempSync, createWriteStream } from 'node:fs'
import { tmpdir } from 'node:os'
import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag'
@@ -605,7 +605,7 @@ export function halbeDateienEntfernen(ordner: string, vorher: ReadonlySet<string
function lauf(programm: string, args: string[]): Promise<{ code: number | null; fehler: string }> {
return new Promise((fertig) => {
const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] })
const kind = werkzeugSpawn(programm, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] })
const stuecke: Buffer[] = []
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
kind.on('error', (fehler) => fertig({ code: null, fehler: String(fehler) }))
+62 -27
View File
@@ -20,10 +20,12 @@ import {
istHauptNachricht,
type DbStatus,
type DiscInfoStand,
type GeraeteInfo,
type KernNachricht,
type MetaVorschlag,
type VorgaengeStand,
type VorgangEintrag,
type WacheEreignis,
} from '../gemeinsam/nachrichten'
import { komprimierenUndAblegen, type AblageUmgebung } from './ablauf/ablegen'
import { auftragAusJson, auftragZuJson } from './ablauf/auftrag'
@@ -260,8 +262,54 @@ async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<vo
}
}
// Das nachgebaute Laufwerk des Klick-Beweises (bau/klick-smoke.mjs): Ein
// Buchstabe, den es nicht gibt (Q) — die Disc-Merkmale finden dann nichts,
// und makemkvcon/HandBrake sind Node-Skripte (werkzeuge/aufruf.ts). Leer im
// normalen Betrieb; die Win32-Schicht bleibt dann der einzige Weg.
const FAKE_LAUFWERK = (process.env['RIPPY_FAKE_LAUFWERK'] ?? '').replace(/:$/, '').toUpperCase()
function fakeGeraet(): GeraeteInfo {
return {
id: FAKE_LAUFWERK,
name: `Laufwerk ${FAKE_LAUFWERK}:`,
typ: 'bluray',
pfad: `\\\\.\\${FAKE_LAUFWERK}:`,
status: 'ready',
modell: 'NACHGEBAUT (KLICK-BEWEIS)',
seriennummer: 'KLICK',
groesseBytes: 33_759_100_928,
grund: '',
}
}
function wacheMeldung(meldung: { laufwerke: GeraeteInfo[]; ereignisse: WacheEreignis[] }): void {
anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
for (const ereignis of meldung.ereignisse) {
if (ereignis.art === 'disc-eingelegt') {
void discErmitteln(ereignis.geraet.id, ereignis.geraet.pfad, ereignis.geraet.groesseBytes)
} else if (ereignis.art === 'disc-entnommen') {
discZuordnungen.delete(ereignis.geraet.id)
discFingerprints.delete(ereignis.geraet.id)
discMerkmale.delete(ereignis.geraet.id)
anHauptUndFenster({ art: 'disc-info', info: discInfoStand(ereignis.geraet.id, null) })
}
}
}
function wacheFehler(fehler: unknown): void {
// R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte
// Stand im Fenster bleibt stehen (R2), die Wache läuft weiter.
console.error(`[kern] Disc-Wache: Runde fehlgeschlagen: ${String(fehler)}`)
anFenster({ art: 'kern-fehler', text: `Disc-Wache: ${String(fehler)}` })
}
const laufwerkeBereit: Promise<void> = (async () => {
try {
if (FAKE_LAUFWERK.length > 0) {
wache = new DiscWache(() => [fakeGeraet()], wacheMeldung, wacheFehler)
wache.starten()
return
}
if (process.platform !== 'win32') {
throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).')
}
@@ -269,25 +317,8 @@ const laufwerkeBereit: Promise<void> = (async () => {
laufwerkApi = win32Api()
wache = new DiscWache(
() => listOptischeLaufwerke(laufwerkApi!).map((g) => geraeteInfo(laufwerkApi!, g)),
(meldung) => {
anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
for (const ereignis of meldung.ereignisse) {
if (ereignis.art === 'disc-eingelegt') {
void discErmitteln(ereignis.geraet.id, ereignis.geraet.pfad, ereignis.geraet.groesseBytes)
} else if (ereignis.art === 'disc-entnommen') {
discZuordnungen.delete(ereignis.geraet.id)
discFingerprints.delete(ereignis.geraet.id)
discMerkmale.delete(ereignis.geraet.id)
anHauptUndFenster({ art: 'disc-info', info: discInfoStand(ereignis.geraet.id, null) })
}
}
},
(fehler) => {
// R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte
// Stand im Fenster bleibt stehen (R2), die Wache läuft weiter.
console.error(`[kern] Disc-Wache: Runde fehlgeschlagen: ${String(fehler)}`)
anFenster({ art: 'kern-fehler', text: `Disc-Wache: ${String(fehler)}` })
},
wacheMeldung,
wacheFehler,
)
wache.starten()
} catch (fehler) {
@@ -296,6 +327,14 @@ const laufwerkeBereit: Promise<void> = (async () => {
}
})()
/** Der Geräte-Stand zu einer Kennung — echt oder nachgebaut. */
function geraetInfoVon(id: string): GeraeteInfo | null {
if (FAKE_LAUFWERK.length > 0) return id === FAKE_LAUFWERK ? fakeGeraet() : null
const geraet = geraetVon(id)
if (geraet === null || laufwerkApi === null) return null
return geraeteInfo(laufwerkApi, geraet)
}
// ── Titel der Disc lesen (§ 6.2) ────────────────────────────────────────
// Der Info-Lauf dauert 20120 s — deshalb geschieht er erst auf Zuruf und
// nicht schon beim Einlegen (der 30.08.-Fund: „das Erkennen dauert sehr
@@ -316,12 +355,12 @@ async function titelLesen(id: string): Promise<void> {
anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: 'Auf diesem Laufwerk läuft schon ein Vorgang.' } })
return
}
const geraet = geraetVon(id)
if (geraet === null || laufwerkApi === null) {
const geraet = geraetInfoVon(id)
if (geraet === null) {
anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: `Laufwerk ${id}: gibt es nicht.` } })
return
}
const geraetepfad = geraeteInfo(laufwerkApi, geraet).pfad
const geraetepfad = geraet.pfad
const programm = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
if (programm.length === 0) {
anFenster({
@@ -751,11 +790,7 @@ const ripVerwaltung = new Pipeline({
if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
return audioSpurLesen(laufwerkApi, geraet, start, sektoren, schreiben, fortschritt)
},
geraet: (id) => {
if (laufwerkApi === null) return null
const geraet = listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id)
return geraet === undefined ? null : geraeteInfo(laufwerkApi, geraet)
},
geraet: geraetInfoVon,
ablage: ablageOrdner,
einstellungen: einstellungenLaden,
melden: (status) => {
@@ -7,7 +7,7 @@
// Einrückung — Namen stehen mit genau vier Leerzeichen).
// - Hardware nach FAMILIE unterscheiden (nvenc/qsv/vce/vaapi), sonst
// läuft AMD unter einem Intel-Namen (Commander-PC: RX 9070 XT).
import { spawn } from 'node:child_process'
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { textVon } from '../rip/parser'
const HB_ENCODER_KOPF = /^-e,\s*--encoder\b/
@@ -67,7 +67,7 @@ export function parsePresetListe(text: string): string[] {
function lauf(programm: string, args: string[], timeoutMs: number): Promise<string> {
return new Promise((fertig) => {
const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const kind = werkzeugSpawn(programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const stuecke: Buffer[] = []
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
@@ -15,7 +15,7 @@
// im Fehlerfall die einzige Auskunft.
// - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung —
// sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus.
import { spawn } from 'node:child_process'
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { existsSync } from 'node:fs'
// win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux.
import { win32 as pfad } from 'node:path'
@@ -253,7 +253,7 @@ export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
return new Promise((fertig) => {
const args = buildHandbrakeArgs(optionen)
args.push(...(optionen.extraArgs ?? []))
const kind = spawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const kind = werkzeugSpawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const auswertung = new HbAuswertung()
const leser = new HbZeilenLeser()
+2 -2
View File
@@ -15,7 +15,7 @@
// Objekt endet also NICHT am Textende — es wird über die Klammern
// ausgeschnitten. Darin: TitleList[0].AudioList[].LanguageCode (ISO-639-2,
// z. B. „jpn"), .SubtitleList[].LanguageCode, .Duration.{Hours,Minutes,Seconds}.
import { spawn } from 'node:child_process'
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { textVon } from '../rip/parser'
export interface HbSpuren {
@@ -87,7 +87,7 @@ export function spurenScannen(programm: string, datei: string, timeoutMs = 120_0
fertig(null)
return
}
const kind = spawn(programm, buildScanArgs(datei), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const kind = werkzeugSpawn(programm, buildScanArgs(datei), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const stuecke: Buffer[] = []
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
+3 -3
View File
@@ -11,7 +11,7 @@
// sich trotzdem mit 0 beenden (30.08.2026, Spartacus Disc 2).
// - Kritische MSG-NUMMERN einsammeln — die letzte Zeile ist fast immer
// nur „Failed to open disc", die Ursache steht Zeilen davor.
import { spawn } from 'node:child_process'
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { mkdirSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import {
@@ -160,7 +160,7 @@ export function titelInfoLesen(
abbruch?: AbortSignal,
): Promise<DiscInfo> {
return new Promise((fertig, scheitert) => {
const kind = spawn(programm, buildInfoArgs(geraetepfad), {
const kind = werkzeugSpawn(programm, buildInfoArgs(geraetepfad), {
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
})
@@ -231,7 +231,7 @@ export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> {
return new Promise((fertig) => {
mkdirSync(optionen.zielOrdner, { recursive: true })
const kind = spawn(
const kind = werkzeugSpawn(
optionen.programm,
buildRipArgs(optionen.geraetepfad, optionen.zielOrdner, optionen.titel),
{ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] },
@@ -0,0 +1,37 @@
// EINE Stelle, an der Werkzeuge gestartet werden (5.4.0) — mit einer Naht
// für den Klick-Beweis (bau/klick-smoke.mjs): Zeigt der eingestellte
// Werkzeug-Pfad auf ein Node-Skript (.cjs/.js), läuft es über die eigene
// Node-Laufzeit (ELECTRON_RUN_AS_NODE, Electron-Doku »Environment
// Variables«) statt als EXE. So klickt der Beweis durch das ECHTE Programm
// — Fenster, Kern, Datenbank, Warteschlange — nur makemkvcon und HandBrake
// sind nachgebaut. Für echte EXE-Pfade ändert sich nichts.
//
// Warum nicht einfach eine .cmd: Node weigert sich seit 18.20/20.12,
// .bat/.cmd ohne Shell zu starten (CVE-2024-27980) — und mit Shell wären
// Pfade mit Leerzeichen wieder eine Falle.
import { spawn } from 'node:child_process'
export function istSkript(programm: string): boolean {
const p = programm.toLowerCase()
return p.endsWith('.cjs') || p.endsWith('.mjs') || p.endsWith('.js')
}
/** Programm + Argumente, so wie spawn sie bekommen soll. */
export function werkzeugStart(
programm: string,
args: readonly string[],
umgebung: NodeJS.ProcessEnv = process.env,
): { programm: string; args: string[]; env: NodeJS.ProcessEnv } {
if (!istSkript(programm)) return { programm, args: [...args], env: umgebung }
return {
programm: process.execPath,
args: [programm, ...args],
env: { ...umgebung, ELECTRON_RUN_AS_NODE: '1' },
}
}
/** spawn mit der Naht — dieselbe Signatur, dieselben Typen. */
export const werkzeugSpawn: typeof spawn = ((programm: string, args: readonly string[], optionen: Record<string, unknown>) => {
const start = werkzeugStart(programm, args)
return spawn(start.programm, start.args, { ...optionen, env: start.env })
}) as typeof spawn