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:
co-authored by
Claude Fable 5.1
parent
e3ce8220f1
commit
6dd1b0bc7a
@@ -0,0 +1,192 @@
|
||||
// Der Klick-Beweis (5.4.0, Punkt 7 der Übergabe vom 01.09.2026): startet
|
||||
// das ECHTE Rippy (Electron, Haupt, Kern, Fenster, Datenbank) mit einem
|
||||
// frischen Profil, einem nachgebauten Laufwerk und nachgebauten Werkzeugen
|
||||
// — und KLICKT sich durch: Rippen → Titel-Dialog → Sprachen je Titel →
|
||||
// Rollen auf Folge → Staffel/Folge → Rip → Warteschlange → Kompression →
|
||||
// Ablage → Roh-Dateien → löschen.
|
||||
//
|
||||
// Warum: Beide Fehler vom 01.09.2026 waren Verkabelung (Nachrichtentyp
|
||||
// nicht im Prüfer, zwei gleichzeitige Titel-Läufe). 261 Unit-Tests sahen
|
||||
// keinen davon; ein Klick hätte beide gefunden. Playwright treibt Electron
|
||||
// (Doku »Electron« bei playwright.dev, _electron.launch).
|
||||
//
|
||||
// npm run klick (baut vorher)
|
||||
// Bilder: ../beweise/klick/*.png — Ausgang 0 (grün) oder 2.
|
||||
import { _electron as electron } from 'playwright'
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const wurzel = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const profil = join(tmpdir(), 'rippy-klick')
|
||||
const ablage = join(profil, 'ablage')
|
||||
const bilder = resolve(wurzel, '..', 'beweise', 'klick')
|
||||
|
||||
rmSync(profil, { recursive: true, force: true })
|
||||
mkdirSync(profil, { recursive: true })
|
||||
mkdirSync(bilder, { recursive: true })
|
||||
|
||||
// Das Profil vorbereiten: Einrichtung erledigt, Ablage im Profil, die
|
||||
// Werkzeuge sind die Nachbauten (kern/werkzeuge/aufruf.ts startet Skripte
|
||||
// über die eigene Node-Laufzeit). Die Datenbank legt der Kern selbst an —
|
||||
// hier nur die Einstellungen (Tabelle wie in kern/speicher/db.ts).
|
||||
{
|
||||
const db = new DatabaseSync(join(profil, 'rippy.db'))
|
||||
db.exec('CREATE TABLE IF NOT EXISTS einstellungen (schluessel TEXT PRIMARY KEY, wert TEXT NOT NULL)')
|
||||
const setzen = db.prepare('INSERT OR REPLACE INTO einstellungen (schluessel, wert) VALUES (?, ?)')
|
||||
setzen.run('setup.done', 'true')
|
||||
setzen.run('ablage', ablage)
|
||||
setzen.run('werkzeug.makemkv', join(wurzel, 'bau', 'klick', 'fake-makemkvcon.cjs'))
|
||||
setzen.run('werkzeug.handbrake', join(wurzel, 'bau', 'klick', 'fake-handbrake.cjs'))
|
||||
setzen.run('audioSprachen', 'deu,eng')
|
||||
setzen.run('untertitelSprachen', 'deu')
|
||||
setzen.run('rohAufbewahrung', 'behalten')
|
||||
setzen.run('autoAuswurf', 'false')
|
||||
db.close()
|
||||
}
|
||||
|
||||
const app = await electron.launch({
|
||||
executablePath: join(wurzel, 'node_modules', 'electron', 'dist', 'electron.exe'),
|
||||
args: [wurzel, `--profil=${profil}`, '--klick-smoke'],
|
||||
env: { ...process.env, RIPPY_FAKE_LAUFWERK: 'Q', RIPPY_OHNE_SCHLUESSEL: '1' },
|
||||
timeout: 60_000,
|
||||
})
|
||||
const seite = await app.firstWindow()
|
||||
seite.setDefaultTimeout(30_000)
|
||||
seite.on('console', (m) => {
|
||||
if (m.type() === 'error') console.log(`KLICK fenster: ${m.text().slice(0, 200)}`)
|
||||
})
|
||||
|
||||
let nr = 0
|
||||
async function bild(name) {
|
||||
nr += 1
|
||||
const pfad = join(bilder, `${String(nr).padStart(2, '0')}-${name}.png`)
|
||||
await seite.screenshot({ path: pfad, fullPage: true })
|
||||
console.log(`KLICK bild: ${pfad}`)
|
||||
}
|
||||
|
||||
async function schritt(name, tun) {
|
||||
try {
|
||||
await tun()
|
||||
console.log(`KLICK ✓ ${name}`)
|
||||
} catch (fehler) {
|
||||
console.error(`KLICK ✗ ${name}: ${String(fehler.message ?? fehler).split('\n')[0]}`)
|
||||
await seite.screenshot({ path: join(bilder, `fehler-${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.png`), fullPage: true }).catch(() => {})
|
||||
await app.close().catch(() => {})
|
||||
process.exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
const dialog = () => seite.locator('div.fixed.inset-0').last()
|
||||
|
||||
await schritt('Fenster offen, nachgebautes Laufwerk Q: sichtbar', async () => {
|
||||
await seite.getByText('LAUFWERK Q:').waitFor()
|
||||
await seite.getByText('NACHGEBAUT (KLICK-BEWEIS)').waitFor()
|
||||
})
|
||||
await bild('uebersicht')
|
||||
|
||||
await schritt('Rippen → Dialog öffnet sich', async () => {
|
||||
await seite.getByRole('button', { name: 'Rippen', exact: true }).click()
|
||||
await seite.getByText('Was soll gerippt werden?').waitFor()
|
||||
})
|
||||
|
||||
await schritt('Titel-Lauf liefert drei Titel', async () => {
|
||||
await dialog().getByText('Titel 0', { exact: true }).waitFor()
|
||||
await dialog().getByText('Titel 1', { exact: true }).waitFor()
|
||||
await dialog().getByText('Titel 2', { exact: true }).waitFor()
|
||||
})
|
||||
|
||||
await schritt('Sprachen je Titel: jpn/deu Ton, deu/eng Untertitel als Chips', async () => {
|
||||
await dialog().getByRole('button', { name: /jpn/ }).first().waitFor()
|
||||
await dialog().getByRole('button', { name: /✓ deu/ }).first().waitFor()
|
||||
})
|
||||
|
||||
await schritt('Titel 0 bekommt japanischen Ton (deu abwählen, jpn anwählen)', async () => {
|
||||
const chips = dialog().locator('button', { hasText: /^✓ deu$|^deu$/ })
|
||||
await chips.first().click()
|
||||
await dialog().getByRole('button', { name: /^jpn$/ }).first().click()
|
||||
await dialog().getByRole('button', { name: /^✓ jpn$/ }).first().waitFor()
|
||||
})
|
||||
|
||||
await schritt('„Hauptinhalt + Extras" wählt beide langen Titel, der Schnipsel bleibt draußen', async () => {
|
||||
// Rippy wählt bei zwei fast gleich langen Titeln nur EINEN vor („bitte
|
||||
// nachsehen") — der Knopf holt den zweiten dazu, Titel 2 (35 s) nicht.
|
||||
await dialog().getByRole('button', { name: 'Hauptinhalt + Extras' }).click()
|
||||
await dialog().getByText(/2 von 3 gewählt/).waitFor()
|
||||
})
|
||||
|
||||
await schritt('Rollen auf Folge → Staffel/Folge-Felder erscheinen', async () => {
|
||||
const rollen = dialog().locator('select')
|
||||
await rollen.nth(0).selectOption('folge')
|
||||
await rollen.nth(1).selectOption('folge')
|
||||
await dialog().getByText('AB FOLGE').waitFor()
|
||||
await dialog().getByPlaceholder('automatisch').fill('3')
|
||||
})
|
||||
|
||||
await schritt('Vorschau nennt Serien\\…\\Season 01 und S01E03', async () => {
|
||||
await dialog().getByText(/Serien\\/).waitFor()
|
||||
await dialog().getByText(/Season 01/).waitFor()
|
||||
await dialog().getByText(/S01E03/).waitFor()
|
||||
})
|
||||
await bild('dialog')
|
||||
|
||||
await schritt('Rip starten → Phase RIPPT', async () => {
|
||||
await dialog().getByRole('button', { name: /Titel rippen/ }).click()
|
||||
await seite.getByText(/RIPPT/).first().waitFor()
|
||||
})
|
||||
|
||||
await schritt('Rip fertig, Kompression eingereiht, Laufwerk frei', async () => {
|
||||
await seite.getByText(/Kompression eingereiht/).waitFor({ timeout: 60_000 })
|
||||
await seite.getByRole('button', { name: 'Rippen', exact: true }).waitFor()
|
||||
})
|
||||
|
||||
await schritt('Kompressions-Karte läuft und die Ablage endet', async () => {
|
||||
await seite.getByText('KOMPRESSION', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
await seite.getByText(/Komprimiert und abgelegt/).waitFor({ timeout: 60_000 })
|
||||
})
|
||||
await bild('fertig')
|
||||
|
||||
await schritt('Ergebnis liegt als S01E03/S01E04 unter Serien\\…\\Season 01', async () => {
|
||||
const serien = join(ablage, 'Serien')
|
||||
if (!existsSync(serien)) throw new Error(`kein Ordner ${serien}`)
|
||||
const gefunden = []
|
||||
const gehen = (o) => {
|
||||
for (const e of readdirSync(o, { withFileTypes: true })) {
|
||||
const p = join(o, e.name)
|
||||
if (e.isDirectory()) gehen(p)
|
||||
else if (e.name.endsWith('.mkv') && statSync(p).size > 0) gefunden.push(p)
|
||||
}
|
||||
}
|
||||
gehen(serien)
|
||||
const namen = gefunden.map((p) => p.split(/[\\/]/).pop())
|
||||
if (!namen.some((n) => n.endsWith('S01E03.mkv')) || !namen.some((n) => n.endsWith('S01E04.mkv'))) {
|
||||
throw new Error(`gefunden: ${namen.join(', ')}`)
|
||||
}
|
||||
if (!gefunden[0].includes('Season 01')) throw new Error(`kein Season-01-Ordner: ${gefunden[0]}`)
|
||||
console.log(`KLICK dateien: ${gefunden.join(' | ')}`)
|
||||
})
|
||||
|
||||
await schritt('Einstellungen → Roh-Dateien zeigt den Vorgang mit Belegung', async () => {
|
||||
await seite.getByRole('button', { name: 'Einstellungen' }).click()
|
||||
await seite.locator('#roh').waitFor()
|
||||
await seite.getByText(/Belegung jetzt/).waitFor()
|
||||
await seite.getByText(/Roh: \d/).first().waitFor()
|
||||
})
|
||||
await bild('roh-dateien')
|
||||
|
||||
await schritt('Roh-Dateien löschen → „gelöscht"', async () => {
|
||||
await seite.getByRole('button', { name: 'Roh-Dateien löschen' }).first().click()
|
||||
await seite.getByText(/Roh: gelöscht/).first().waitFor()
|
||||
})
|
||||
|
||||
await schritt('Ereignisse nennen die Kompression', async () => {
|
||||
await seite.getByRole('button', { name: 'Übersicht' }).click()
|
||||
await seite.getByText(/Kompression „/).first().waitFor()
|
||||
})
|
||||
await bild('ereignisse')
|
||||
|
||||
console.log('KLICK: OK — Rippen, Dialog, Sprachen, Folgen, Rip, Warteschlange, Kompression, Ablage, Roh-Dateien: alles geklickt.')
|
||||
await app.close().catch(() => {})
|
||||
process.exit(0)
|
||||
@@ -0,0 +1,90 @@
|
||||
// Nachgebautes HandBrakeCLI für den Klick-Beweis (bau/klick-smoke.mjs).
|
||||
// Antwortet so, wie encoder.ts, scan.ts und handbrake.ts es am echten
|
||||
// HandBrake 1.11 gemessen haben:
|
||||
// --help → Encoder-Block „-e, --encoder" bis zur nächsten Option
|
||||
// --preset-list → Preset-Namen mit genau vier Leerzeichen Einrückung
|
||||
// --scan --json → „JSON Title Set:" + Objekt + „HandBrake has exited."
|
||||
// Encode → „Encoding: task 1 of 1, x %" in Stufen, Datei kopiert
|
||||
const fs = require('node:fs')
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const out = (zeile) => process.stdout.write(zeile + '\n')
|
||||
const wert = (schalter) => {
|
||||
const i = args.indexOf(schalter)
|
||||
return i >= 0 ? args[i + 1] : undefined
|
||||
}
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
out('Usage: HandBrakeCLI [options] -i <source> -o <destination>')
|
||||
out('')
|
||||
out('Video Options -----------------------------------------------------------------')
|
||||
out('')
|
||||
out(' -e, --encoder <string> Select video encoder:')
|
||||
out(' x264')
|
||||
out(' x265')
|
||||
out(' nvenc_h264')
|
||||
out(' -q, --quality <float> Set video quality (e.g. 22.0)')
|
||||
out('')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (args.includes('--preset-list')) {
|
||||
out('General/')
|
||||
out(' Very Fast 1080p30')
|
||||
out(' Fast 1080p30')
|
||||
out(' HQ 1080p30 Surround')
|
||||
out('')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (args.includes('--scan')) {
|
||||
const eingabe = wert('--input') ?? wert('-i') ?? 'quelle'
|
||||
out('[12:00:00] hb_init: starting libhb thread')
|
||||
out('Scanning title 1 of 1, preview 1, 100.00 %')
|
||||
out('JSON Title Set: {')
|
||||
out('"MainFeature": 0,')
|
||||
out('"TitleList": [')
|
||||
out('{')
|
||||
out('"AudioList": [')
|
||||
out('{ "Description": "Japanese (AAC LC, 2.0 ch)", "Language": "Japanese", "LanguageCode": "jpn", "TrackNumber": 1 },')
|
||||
out('{ "Description": "Deutsch (AAC LC, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 }')
|
||||
out('],')
|
||||
out('"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },')
|
||||
out('"Index": 1,')
|
||||
out(`"Name": ${JSON.stringify(eingabe)},`)
|
||||
out('"SubtitleList": [')
|
||||
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "SourceName": "PGS", "TrackNumber": 1 },')
|
||||
out('{ "Format": "bitmap", "Language": "English", "LanguageCode": "eng", "SourceName": "PGS", "TrackNumber": 2 }')
|
||||
out(']')
|
||||
out('}')
|
||||
out(']')
|
||||
out('}')
|
||||
out('HandBrake has exited.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const eingabe = wert('--input') ?? wert('-i')
|
||||
const ausgabe = wert('--output') ?? wert('-o')
|
||||
if (eingabe === undefined || ausgabe === undefined) {
|
||||
out('Missing input or output file name. Run HandBrakeCLI --help for syntax.')
|
||||
process.exit(1)
|
||||
}
|
||||
out('[12:00:00] hb_init: starting libhb thread')
|
||||
out('Scanning title 1 of 1, preview 10, 100.00 %')
|
||||
let schritt = 0
|
||||
const stufen = [5, 30, 55, 80, 100]
|
||||
const tick = () => {
|
||||
out(`Encoding: task 1 of 1, ${stufen[schritt].toFixed(2)} %`)
|
||||
schritt += 1
|
||||
if (schritt < stufen.length) {
|
||||
setTimeout(tick, 300)
|
||||
return
|
||||
}
|
||||
fs.copyFileSync(eingabe, ausgabe)
|
||||
out('[12:00:05] libhb: work result = 0')
|
||||
out('')
|
||||
out('Encode done!')
|
||||
out('HandBrake has exited.')
|
||||
process.exit(0)
|
||||
}
|
||||
setTimeout(tick, 200)
|
||||
@@ -0,0 +1,79 @@
|
||||
// Nachgebautes makemkvcon für den Klick-Beweis (bau/klick-smoke.mjs).
|
||||
// Spricht den Robot-Modus so, wie der Parser (kern/rip/parser.ts) ihn an
|
||||
// echten Discs gemessen hat: TINFO (8 Kapitel, 9 Dauer, 11 Bytes), SINFO
|
||||
// (1 Typ, 3 Sprachcode, 4 Sprachname), PRGV (aktuell,gesamt,max), MSG.
|
||||
//
|
||||
// info dev:X: → drei Titel: zwei Folgen (52:47 / 54:09, jpn+deu
|
||||
// Ton, deu+eng Untertitel) und ein Logo-Schnipsel
|
||||
// mkv dev:X: <nr> <ziel> → Fortschritt in Stufen, dann title_t0N.mkv
|
||||
//
|
||||
// Läuft über die eigene Node-Laufzeit (kern/werkzeuge/aufruf.ts) — kein EXE.
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const befehl = args.find((a) => a === 'info' || a === 'mkv')
|
||||
const out = (zeile) => process.stdout.write(zeile + '\n')
|
||||
|
||||
out('MSG:1005,0,1,"MakeMKV v1.18.4 win(x86-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x86-release)"')
|
||||
|
||||
const TITEL = [
|
||||
{ nr: 0, dauer: '0:52:47', bytes: 16283000000, kapitel: 7 },
|
||||
{ nr: 1, dauer: '0:54:09', bytes: 16670000000, kapitel: 7 },
|
||||
{ nr: 2, dauer: '0:00:35', bytes: 20000000, kapitel: 1 },
|
||||
]
|
||||
|
||||
if (befehl === 'info') {
|
||||
out(`TCOUNT:${TITEL.length}`)
|
||||
for (const t of TITEL) {
|
||||
out(`TINFO:${t.nr},8,0,"${t.kapitel}"`)
|
||||
out(`TINFO:${t.nr},9,0,"${t.dauer}"`)
|
||||
out(`TINFO:${t.nr},11,0,"${t.bytes}"`)
|
||||
out(`SINFO:${t.nr},0,1,6201,"Video"`)
|
||||
if (t.nr < 2) {
|
||||
out(`SINFO:${t.nr},1,1,6202,"Audio"`)
|
||||
out(`SINFO:${t.nr},1,3,0,"jpn"`)
|
||||
out(`SINFO:${t.nr},1,4,0,"Japanese"`)
|
||||
out(`SINFO:${t.nr},2,1,6202,"Audio"`)
|
||||
out(`SINFO:${t.nr},2,3,0,"deu"`)
|
||||
out(`SINFO:${t.nr},2,4,0,"German"`)
|
||||
out(`SINFO:${t.nr},3,1,6203,"Subtitles"`)
|
||||
out(`SINFO:${t.nr},3,3,0,"deu"`)
|
||||
out(`SINFO:${t.nr},3,4,0,"German"`)
|
||||
out(`SINFO:${t.nr},4,1,6203,"Subtitles"`)
|
||||
out(`SINFO:${t.nr},4,3,0,"eng"`)
|
||||
out(`SINFO:${t.nr},4,4,0,"English"`)
|
||||
}
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (befehl === 'mkv') {
|
||||
const i = args.indexOf('mkv')
|
||||
const nr = Number(args[i + 2])
|
||||
const ziel = args[i + 3]
|
||||
if (!Number.isInteger(nr) || typeof ziel !== 'string') {
|
||||
out('MSG:5010,0,0,"Falscher Aufruf"')
|
||||
process.exit(1)
|
||||
}
|
||||
let schritt = 0
|
||||
const stufen = [10, 35, 60, 85, 100]
|
||||
const tick = () => {
|
||||
out(`PRGV:${stufen[schritt]},${stufen[schritt]},100`)
|
||||
schritt += 1
|
||||
if (schritt < stufen.length) {
|
||||
setTimeout(tick, 250)
|
||||
return
|
||||
}
|
||||
fs.mkdirSync(ziel, { recursive: true })
|
||||
const datei = path.join(ziel, `title_t${String(nr).padStart(2, '0')}.mkv`)
|
||||
// 1 MB „Roh-Material" — groß genug, dass Größen sichtbar sind.
|
||||
fs.writeFileSync(datei, Buffer.alloc(1024 * 1024, nr + 1))
|
||||
out(`MSG:5003,0,1,"1 Titel gespeichert","%1 Titel gespeichert","1"`)
|
||||
process.exit(0)
|
||||
}
|
||||
setTimeout(tick, 200)
|
||||
} else {
|
||||
out('MSG:5010,0,0,"Unbekannter Befehl"')
|
||||
process.exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user