Files
rippy/rippy-windows/bau/klick-smoke.mjs
T
HitonabiandClaude Opus 5 e7e33e9021 feat(v5): jede Spur einzeln anhaken — Codec und Kanäle je Tonspur, Untertitel genauso
WAS: Der Auswahl-Dialog zeigt je Titel jede Ton- und Untertitelspur, die
der Info-Lauf gemessen hat („German · DTS-HD MA · Surround 5.1", „German ·
DD · Stereo (Kommentar)", „German · PGS (nur erzwungene)"), vorbelegt wie
bisher (je Wunschsprache die erste Tonspur, alle Untertitel der Wunsch-
sprachen) — angehakt wird einzeln. Die Untertitel-Vorwahl je Extra ist
jetzt eine Spur. Der Parser liest dazu SINFO-Attribute 2 (Anordnung),
22 (Flags) und markiert abgeleitete Spuren (Bit 0x800, an der Kizuna-
Disc gemessen): DTS-Kerne werden nicht angeboten, „nur erzwungene"-
Untertitel schon. Die Ablage scannt die Roh-Datei und findet die
gewählten MakeMKV-Spuren gestuft wieder (Sprache + Codec-Familie +
Kanäle → … → Sprache), HandBrake bekommt `--audio 1,3 --subtitle 2,1
--subtitle-default=1`; Fehlendes wird gemeldet, scheitert der Scan,
gelten die Sprachen der gewählten Spuren als Liste. MakeMKVs Auswahl-
regel lässt Stereo-Doppel jetzt stehen (nur -sel:havecore); Rippys alte
Regel wird in der Registry ersetzt, eine fremde bleibt.
Dazu F31 (Durchsicht): `--aencoder copy` ohne `--audio-copy-mask`
verengt HandBrakeCLI 1.11.2 auf „copy:aac" — AC3/DTS/TrueHD wurden seit
5.0 in AAC umgerechnet. Beide Wege setzen die Maske jetzt ausdrücklich
(gemessen 12.09.2026: AC3 bleibt AC3, Passthru-Zeile nennt alle Codecs).
WARUM: Commander-Entscheid 12.09.2026 („Jede Spur einzeln anhaken") —
bisher ließ sich je Sprache nur „alles oder nichts" wählen, und der
Kommentar-Ton fehlte in der Roh-Datei (havemulti). Gemessen am
mitgelieferten HandBrakeCLI mit ffmpeg-gebauter Quelle
(test/messung.spuren.test.ts, RIPPY_MESSUNG=1): --audio 1,3 → jpn und
deu-Kommentar, --subtitle 2,1 --subtitle-default=1 → eng (default) vor
deu, --subtitle none → keine Spur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:11:27 +02:00

385 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.
// 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')
// 5.5.0: Arbeitsordner getrennt von der Ablage — Roh und Encode liegen
// lokal, nur das Fertige wandert (hier: von Ordner zu Ordner im Profil).
const arbeit = join(profil, 'arbeit')
const bilder = resolve(wurzel, '..', 'beweise', 'klick')
rmSync(profil, { recursive: true, force: true })
mkdirSync(profil, { recursive: true })
mkdirSync(bilder, { recursive: true })
// Alte Bilder weg — sonst bleiben Nummern früherer Fassungen liegen.
for (const name of readdirSync(bilder)) if (name.endsWith('.png')) rmSync(join(bilder, name))
// 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')
setzen.run('arbeitsordner', arbeit)
// Die Automatik bleibt aus: Der Beweis klickt selbst.
setzen.run('automatik', 'aus')
db.close()
}
const start = (env) =>
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', ...env },
timeout: 60_000,
})
let app = await start({})
async function fenster() {
const s = await app.firstWindow()
s.setDefaultTimeout(30_000)
s.on('console', (m) => {
if (m.type() === 'error') console.log(`KLICK fenster: ${m.text().slice(0, 200)}`)
})
return s
}
let seite = await fenster()
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('Speicher-Karte misst Arbeitsordner und Ablage getrennt, die Fußleiste zeigt FREI', async () => {
await seite.getByText('SPEICHER UND BILANZ').waitFor()
await seite.getByText('ARBEITSORDNER (LOKAL)').waitFor()
await seite.getByText('ABLAGE (SERVER)').waitFor()
await seite.getByText(/^FREI [0-9]/).waitFor()
})
await schritt('Keine Bibliothek mehr — Übersicht und Einstellungen sind die Bereiche', async () => {
const n = await seite.getByRole('button', { name: 'Bibliothek' }).count()
if (n !== 0) throw new Error('Bibliothek-Knopf ist noch da')
})
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('5.7.0: Spuren je Titel einzeln — Codec und Kanäle, der DTS-Kern bleibt unsichtbar', async () => {
await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().waitFor()
await dialog().getByLabel('German · DD · Surround 5.1').first().waitFor()
await dialog().getByLabel('German · DD · Stereo (Kommentar)').first().waitFor()
await dialog().getByLabel('German · PGS (nur erzwungene)').first().waitFor()
if ((await dialog().getByLabel('Japanese · DTS · Surround 5.1').count()) !== 0) throw new Error('der abgeleitete DTS-Kern wird angeboten')
// Vorbelegt: je Wunschsprache (deu,eng) die erste Tonspur → nur German DD 5.1;
// Untertitel: alle deutschen (auch „nur erzwungene"), englisch nicht.
if (!(await dialog().getByLabel('German · DD · Surround 5.1').first().isChecked())) throw new Error('German 5.1 nicht vorbelegt')
if (await dialog().getByLabel('German · DD · Stereo (Kommentar)').first().isChecked()) throw new Error('Kommentar-Spur vorbelegt')
if (await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().isChecked()) throw new Error('Japanisch vorbelegt, obwohl Deutsch da ist')
if (!(await dialog().getByLabel('German · PGS (nur erzwungene)').first().isChecked())) throw new Error('erzwungene Untertitel nicht vorbelegt')
if (await dialog().getByLabel('English · PGS').first().isChecked()) throw new Error('englische Untertitel vorbelegt')
})
await schritt('Titel 0 bekommt japanischen Ton (German 5.1 abhaken, Japanese anhaken)', async () => {
await dialog().getByLabel('German · DD · Surround 5.1').first().uncheck()
await dialog().getByText('keine Tonspur gewählt — HandBrake nimmt die erste Spur').waitFor()
await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().check()
if ((await dialog().getByText('keine Tonspur gewählt').count()) !== 0) throw new Error('Warnung bleibt trotz japanischer Spur')
})
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('5.6.0: Untertitel-Vorwahl erscheint NUR bei Extras, „anderer Film …" je Titel', async () => {
// Rippys Vorschlag macht bei zwei langen Titeln den zweiten zum Extra —
// erst beide auf Hauptfilm, dann ist klar: keine Vorwahl bei Hauptfilmen.
const rollen = dialog().locator('select')
await rollen.nth(0).selectOption('hauptfilm')
await rollen.nth(1).selectOption('hauptfilm')
if ((await dialog().getByText('BEIM ABSPIELEN AN').count()) !== 0) throw new Error('Vorwahl schon bei Hauptfilmen sichtbar')
await rollen.nth(1).selectOption('extra')
await dialog().getByText('BEIM ABSPIELEN AN').waitFor()
const vorwahl = dialog().locator('select').nth(2)
// 5.7.0: Rippys Regel nennt die Spur (japanischer Ton → deutsche
// Untertitel), die Optionen sind Spuren — gewählt wird per Nummer (5).
await vorwahl.selectOption('5')
const knoepfe = await dialog().getByRole('button', { name: 'anderer Film …' }).count()
if (knoepfe < 2) throw new Error(`nur ${knoepfe} „anderer Film"-Knöpfe`)
await dialog().getByRole('button', { name: 'anderer Film …' }).first().click()
await dialog().getByPlaceholder('Filmtitel suchen …').waitFor()
await dialog().getByRole('button', { name: 'anderer Film …' }).first().click()
await rollen.nth(1).selectOption('hauptfilm')
if ((await dialog().getByText('BEIM ABSPIELEN AN').count()) !== 0) throw new Error('Vorwahl bleibt nach Rollenwechsel sichtbar')
})
await bild('dialog-vorwahl')
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 schritt('Platz-Prüfung im Dialog: der Arbeitsordner reicht', async () => {
await dialog().getByText(/^Platz: .* frei im Arbeitsordner/).waitFor()
})
await bild('dialog')
await schritt('Rip starten → Phase RIPPT, der Automatik-Kasten ist weg', async () => {
await dialog().getByRole('button', { name: /Titel rippen/ }).click()
await seite.getByText(/RIPPT/).first().waitFor()
if ((await seite.getByText('Automatik gestoppt').count()) !== 0) throw new Error('„Automatik gestoppt" steht noch unter dem laufenden Rip')
})
await schritt('5.6.0: Vorgangs-Leiste in der Kachel — TITEL LESEN fertig, RIPPEN aktiv, KOMPRIMIEREN und ABLEGEN offen', async () => {
await seite.getByText('TITEL LESEN', { exact: true }).first().waitFor()
await seite.getByText('RIPPEN', { exact: true }).first().waitFor()
await seite.getByText('KOMPRIMIEREN', { exact: true }).first().waitFor()
await seite.getByText('ABLEGEN', { exact: true }).first().waitFor()
if ((await seite.getByText(/PHASE [0-9]\/4/).count()) !== 0) throw new Error('alte PHASE-x/4-Texte sind noch da')
})
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 mit Restzeit (ETA aus HandBrakes Zeile), die Kachel zeigt nur eine Kurzzeile', async () => {
await seite.getByText('KOMPRESSION', { exact: true }).waitFor({ timeout: 30_000 })
await seite.getByText(/^noch /).first().waitFor({ timeout: 20_000 })
// Die Leiste in der Karte: zwei Stationen fertig (✓), KOMPRIMIEREN aktiv.
if ((await seite.getByText('KOMPRIMIEREN', { exact: true }).count()) < 1) throw new Error('keine Leiste in der Kompressions-Karte')
if ((await seite.getByText(/KOMPRESSION LÄUFT/).count()) !== 0) throw new Error('Kachel zeigt noch den vollen Kompressions-Block')
await seite.getByText(/Kompression fertig — das Ergebnis steht im Verlauf/).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('Arbeitsordner: Roh liegt lokal, der Encode-Zwischenordner ist aufgeräumt', async () => {
const roh = join(arbeit, 'roh')
if (!existsSync(roh) || readdirSync(roh).length === 0) throw new Error(`kein Roh-Ordner unter ${roh}`)
const fertig = join(arbeit, 'fertig')
if (existsSync(fertig) && readdirSync(fertig).length > 0) throw new Error(`Zwischenordner nicht geräumt: ${readdirSync(fertig).join(', ')}`)
const rohInAblage = join(ablage, 'roh')
if (existsSync(rohInAblage)) throw new Error('Roh-Ordner liegt in der Ablage statt im Arbeitsordner')
})
await schritt('Verlauf in der Übersicht nennt den Vorgang als fertig', async () => {
await seite.getByText('VERLAUF', { exact: true }).waitFor()
await seite.getByText(/die letzten 1 Vorgänge/).waitFor()
await seite.getByRole('button', { name: 'Ordner öffnen' }).first().waitFor()
})
await schritt('Bilanz zählt eine Disc', async () => {
await seite.getByText('DISCS', { exact: true }).waitFor()
await seite.getByText('1 fertig', { exact: true }).waitFor()
})
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('Kompression: kein Allgemeines Preset, Sprachen als Chips (Deutsch, Englisch)', async () => {
if ((await seite.getByText(/Allgemeines Preset/).count()) !== 0) throw new Error('Allgemeines Preset ist noch da')
await seite.locator('#kompression').getByText('Deutsch', { exact: true }).first().waitFor()
await seite.locator('#kompression').getByText('Englisch', { exact: true }).first().waitFor()
await seite.locator('#kompression').locator('select').first().waitFor()
})
await schritt('Werkzeuge haben Durchsuchen, Allgemein und Update sind eigene Karten, MakeMKV-Karte trägt den Schlüssel', async () => {
const n = await seite.locator('#werkzeuge').getByRole('button', { name: /Durchsuchen/ }).count()
if (n < 3) throw new Error(`nur ${n} Durchsuchen-Knöpfe`)
await seite.locator('#allgemein').waitFor()
await seite.locator('#update').waitFor()
await seite.locator('#automatik').waitFor()
await seite.locator('#benachrichtigung').waitFor()
await seite.locator('#makemkv').getByText(/Eigener Schlüssel/).waitFor()
await seite.getByRole('button', { name: 'Protokoll öffnen' }).waitFor()
})
await bild('einstellungen')
await schritt('Roh-Dateien löschen → Rückfrage → „Ja, löschen" → „gelöscht"', async () => {
await seite.getByRole('button', { name: 'Roh-Dateien löschen …' }).first().click()
await seite.getByRole('button', { name: 'Ja, löschen' }).first().click()
await seite.getByText(/Roh: gelöscht/).first().waitFor()
})
await schritt('5.6.0: Hell-Modus über Einstellungen → Allgemein, data-design wechselt', async () => {
await seite.locator('#allgemein').getByLabel('Erscheinungsbild').selectOption('hell')
await seite.locator('html[data-design="hell"]').waitFor()
})
await bild('hell-modus')
await schritt('zurück in den Kinosaal (dunkel)', async () => {
await seite.locator('#allgemein').getByLabel('Erscheinungsbild').selectOption('dunkel')
await seite.locator('html[data-design="dunkel"]').waitFor()
})
await schritt('5.7.0: „Titel lesen — höchstens" steht auf 15 Minuten, 30 lässt sich wählen', async () => {
const regler = seite.locator('#allgemein').getByLabel('Titel lesen — höchstens')
if ((await regler.inputValue()) !== '15') throw new Error(`Vorgabe ist ${await regler.inputValue()}, nicht 15`)
await regler.selectOption('30')
// Der Wert geht zum Kern und kommt als Einstellung zurück — erst dann
// zeigt das (kontrollierte) Feld 30.
await regler.locator('option[value="30"]:checked').waitFor({ state: 'attached' })
})
await schritt('Ereignisse nennen die Kompression', async () => {
await seite.getByRole('button', { name: 'Übersicht' }).click()
await seite.getByText(/Kompression „/).first().waitFor()
})
await bild('ereignisse')
// ── Zweiter Start: die Erst-Einrichtung (5.5.0) ─────────────────────────
// RIPPY_START_BEREICH=einrichtung erzwingt den Assistenten (#einrichtung),
// auch wenn setup.done schon gesetzt ist — derselbe Weg wie im Smoke.
await app.close().catch(() => {})
app = await start({ RIPPY_START_BEREICH: 'einrichtung' })
seite = await fenster()
const weiter = () => seite.getByRole('button', { name: 'Weiter', exact: true }).click()
await schritt('Erst-Einrichtung: Willkommen vor der Poster-Kulisse (7 Spalten Platzhalter)', async () => {
await seite.getByText('Willkommen bei Rippy').waitFor()
const spalten = await seite.locator('.poster-spalte').count()
if (spalten !== 7) throw new Error(`${spalten} Poster-Spalten`)
})
await bild('einrichtung-willkommen')
await schritt('Prüfung: MakeMKV (Nachbau) gefunden, HandBrake dabei, Laufwerk Q: gesehen', async () => {
await weiter()
await seite.getByText(/MakeMKV gefunden/).waitFor()
await seite.getByText(/HandBrakeCLI dabei/).waitFor()
await seite.getByText(/1 optisches Laufwerk gefunden/).waitFor()
})
await schritt('Ordner: Ablage und Arbeitsordner mit Durchsuchen und freiem Platz', async () => {
await weiter()
await seite.getByText('Ablage-Ordner', { exact: true }).waitFor()
await seite.getByText('Arbeitsordner (lokal, optional)').waitFor()
const n = await seite.getByRole('button', { name: /Durchsuchen/ }).count()
if (n < 2) throw new Error(`nur ${n} Durchsuchen-Knöpfe`)
await seite.getByText(/frei von/).first().waitFor()
})
await schritt('Qualität: gemessene Encoder, Empfehlung je Disc-Typ, Sprach-Chips', async () => {
await weiter()
await seite.getByText('AUF DIESER MASCHINE GEMESSEN:').waitFor()
await seite.getByText('4K-UHD', { exact: true }).waitFor()
await seite.getByText('Deutsch', { exact: true }).first().waitFor()
})
await bild('einrichtung-qualitaet')
await schritt('Titel, Meldungen, Automatik: TMDb-Schlüssel, Discord/Telegram mit Testknopf, Countdown und Roh-Regel', async () => {
await weiter()
await seite.getByText('TMDb-API-Key', { exact: true }).waitFor()
await weiter()
await seite.getByText('Discord-Webhook', { exact: true }).waitFor()
await seite.getByRole('button', { name: 'Testnachricht schicken' }).waitFor()
await weiter()
await seite.getByText('Countdown', { exact: true }).waitFor()
await seite.getByText(/Roh-Dateien nach gelungener Kompression/).waitFor()
})
await schritt('Fertig: der Knopf Los gehts', async () => {
await weiter()
await seite.getByRole('button', { name: "Los geht's" }).waitFor()
})
await bild('einrichtung-fertig')
console.log('KLICK: OK — Speicher, Rippen, Dialog, Spuren einzeln, Folgen, Platz, Rip, Warteschlange, Kompression, Arbeitsordner, Ablage, Verlauf, Bilanz, Roh-Dateien, Einstellungen, Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit, Hell-Modus, Erst-Einrichtung: alles geklickt.')
await app.close().catch(() => {})
process.exit(0)