diff --git a/.agents/skills/subminer-change-verification/SKILL.md b/.agents/skills/subminer-change-verification/SKILL.md index 24f44f23..8379c743 100644 --- a/.agents/skills/subminer-change-verification/SKILL.md +++ b/.agents/skills/subminer-change-verification/SKILL.md @@ -27,6 +27,7 @@ Do not use hidden wrapper commands. Verification commands are owned by `package. - Launcher or mpv plugin: `bun run test:launcher` or `bun run test:env`, based on the behavior changed - Runtime compatibility or dist-sensitive wiring: `bun run test:runtime:compat` - Stats dashboard: `bun run test:stats` +- Overlay, renderer, mpv, or mining behavior in the running app: `bun run build`, then `bun run test:e2e` - Build/release scripts: `bun run test:scripts` For substantial changes, use the full gate documented in `AGENTS.md` and `docs/workflow/verification.md`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30aa1f68..501855eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,3 +9,79 @@ on: jobs: build-test-audit: uses: ./.github/workflows/quality-gate.yml + + # Boots the built app with a real mpv on each platform. Kept outside the + # reusable quality gate so releases do not depend on it while the lane is new. + e2e: + name: E2E (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + permissions: + contents: read + defaults: + run: + shell: bash + env: + SUBMINER_E2E_KEEP: '1' + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + - name: Point temp files at an uploadable directory + run: | + # os.tmpdir() follows these, so the kept session logs land under runner.temp. + dir="$RUNNER_TEMP/e2e" + mkdir -p "$dir" + { echo "TMPDIR=$dir"; echo "TEMP=$dir"; echo "TMP=$dir"; } >> "$GITHUB_ENV" + + - name: Install mpv and ffmpeg (Linux) + if: runner.os == 'Linux' + run: | + # Only Ubuntu sources are needed; unrelated runner repositories can be unavailable. + apt_sources=(-o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources -o Dir::Etc::sourceparts=-) + sudo apt-get "${apt_sources[@]}" update + sudo apt-get "${apt_sources[@]}" install -y mpv ffmpeg xvfb + + - name: Install mpv and ffmpeg (macOS) + if: runner.os == 'macOS' + run: brew install mpv ffmpeg + + - name: Install mpv and ffmpeg (Windows) + if: runner.os == 'Windows' + run: | + choco install mpv ffmpeg -y --no-progress + # The mpv package adds no shim, so put its directory on PATH for later steps. + mpv_exe="$(find /c/ProgramData/chocolatey/lib /c/ProgramData/chocolatey/bin "/c/Program Files" /c/tools -maxdepth 6 -iname mpv.exe 2>/dev/null | head -n 1)" + test -n "$mpv_exe" + cygpath -w "$(dirname "$mpv_exe")" >> "$GITHUB_PATH" + + - name: Install dependencies + run: | + bun install --frozen-lockfile + cd stats && bun install --frozen-lockfile + + - name: Build + run: bun run build + + - name: End-to-end suite + run: bun run test:e2e + + - name: Upload session logs (on failure) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-logs-${{ matrix.os }} + path: ${{ runner.temp }}/e2e/subminer-e2e-*/*.log + if-no-files-found: ignore diff --git a/AGENTS.md b/AGENTS.md index 516d7a8f..540806bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ Start here, then leave this file. - Launcher/plugin: `bun run test:launcher` or `bun run test:env` - Runtime-compat / dist-sensitive: `bun run test:runtime:compat` - Stats dashboard UI (`stats/`): `bun run test:stats` +- Overlay / renderer / mpv / mining behavior in the running app: `bun run build`, then `bun run test:e2e` (headless, sandboxed; `bun run e2e start` for exploratory checks) - Build/release scripts (`scripts/**`): `bun run test:scripts` - Internal docs, `AGENTS.md`, or repo skills: `bun run test:docs:kb` - User-facing `docs-site/`: `bun run docs:test`, then `bun run docs:build` diff --git a/changes/e2e-harness.md b/changes/e2e-harness.md new file mode 100644 index 00000000..be395a81 --- /dev/null +++ b/changes/e2e-harness.md @@ -0,0 +1,4 @@ +type: internal +area: testing + +- Added a headless end-to-end harness (`bun run test:e2e`, `bun run e2e`) that boots the built app with a real mpv, a synthetic clip, a fixture Yomitan dictionary, and a fake AnkiConnect on an offscreen display. diff --git a/docs/workflow/verification.md b/docs/workflow/verification.md index cf0a2cb3..a6773274 100644 --- a/docs/workflow/verification.md +++ b/docs/workflow/verification.md @@ -3,7 +3,7 @@ # Verification Status: active -Last verified: 2026-08-13 +Last verified: 2026-10-05 Owner: Kyle Yasuda Read when: selecting the right verification lane for a change @@ -58,6 +58,8 @@ bun run docs:build - Launcher/plugin: `bun run test:launcher` or `bun run test:env` - Runtime-compat / compiled behavior after `bun run build`: `bun run test:runtime:compat` - Stats dashboard UI: `bun run test:stats` +- Overlay, renderer, mpv, or mining behavior that unit tests cannot show: `bun run build`, + then `bun run test:e2e` (see [Headless End-to-End Harness](#headless-end-to-end-harness)) - Build/release scripts (`scripts/**`): `bun run test:scripts` - Packaging: build the platform package, then run `bun run test:package `. On headless Linux: `xvfb-run -a bun run test:package release/linux-unpacked/resources`. @@ -93,6 +95,54 @@ bun run docs:build - This is not a full Electron UI startup check. It does not require a display and makes no claims about renderer, window, tray, or mpv behavior. +## Headless End-to-End Harness + +`bun run test:e2e` boots the built app against a real mpv and asserts on what the +overlay renders and what reaches AnkiConnect. Use it instead of a live desktop test +whenever the behavior does not depend on a specific compositor. + +- Needs an existing `bun run build`, plus `mpv` and `ffmpeg` on `PATH` (and `Xvfb` on + Linux). It is not part of `test:fast` or the handoff gate. The `e2e` job in + `.github/workflows/ci.yml` runs it on Linux, macOS, and Windows for pull requests and + `main`, outside the reusable quality gate, and uploads the session logs when it fails. +- Every run is sandboxed in a temp dir: its own config and Electron profile, mpv IPC + socket, and a fake AnkiConnect on a free port. It never touches the user's running + SubMiner, Anki, or stats database. It is not offline: the app still asks AniList for + cover art matching the clip's title. +- Media is synthetic: an ffmpeg test-pattern clip, a three-cue Japanese `.srt`, and a + tiny Yomitan dictionary covering those cues (`e2e/harness/fixtures.ts`). The harness + imports the dictionary on a first boot, then restarts the app for the scenario. +- Display: Linux runs everything inside a private Xvfb server, so no window reaches the + desktop. macOS and Windows have no offscreen display server, so windows open in the + current session there; `SUBMINER_E2E_DISPLAY=host` forces the same on Linux. The + CI job runs all three. On macOS and Windows the app pre-creates a hidden modal window + that loads the same renderer document, so window matching must include the + `layer=visible` query (`OVERLAY_PAGE`). +- The app is driven over the Chrome DevTools Protocol (DOM queries, input events, + per-window screenshots), mpv over its JSON IPC socket, and app commands through the + normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...). +- Scenarios live in `e2e/*.e2e.test.ts` and share one session per file through + `startE2eSession()`. Pass `config` to override settings for a scenario file. +- On a failed startup the temp dir is kept and its path is in the error. Set + `SUBMINER_E2E_KEEP=1` to keep it after any run (`app-stdio.log`, `app.log`, `mpv.log`). + +For exploratory checks, keep one session up and poke at it: + +```bash +bun run e2e start & # prints the session JSON, stays up until stopped +bun run e2e mpv seek 2 absolute+exact +bun run e2e eval overlay 'document.getElementById("subtitleRoot").textContent' +bun run e2e shot overlay /tmp/overlay.png +bun run e2e app --mine-sentence +bun run e2e anki # AnkiConnect requests received so far +bun run e2e stop +``` + +Out of scope: compositor-specific behavior (Hyprland placement, stacking, fullscreen, +click-through), real GPU video output, and global shortcuts. Xvfb runs without a window +manager or compositor, so the transparent overlay is not composited over the video; +check overlay visuals with per-window screenshots, and use the live desktop for the rest. + ## Dependency Audit Policy - `bun audit --audit-level high` blocks the reusable quality gate. diff --git a/e2e/cli.ts b/e2e/cli.ts new file mode 100644 index 00000000..fc0d0bf3 --- /dev/null +++ b/e2e/cli.ts @@ -0,0 +1,160 @@ +// Keeps one isolated e2e session running so a change can be poked at by hand +// (or by an agent) without touching the real desktop: +// +// bun run e2e start boot and stay up until `stop` or Ctrl-C +// bun run e2e eval evaluate JS in a window (or "main" for the main process) +// bun run e2e shot save a screenshot of a window +// bun run e2e mpv [args...] send an mpv IPC command (args parsed as JSON) +// bun run e2e app run a SubMiner CLI command against the instance +// bun run e2e anki print the AnkiConnect requests received so far +// bun run e2e stop +// +// is a URL fragment: "overlay" is shorthand for the overlay window. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { connectCdp, connectCdpPage, listCdpTargets } from './harness/cdp'; +import { connectMpv } from './harness/mpv'; +import { OVERLAY_PAGE, runAppCommand, startE2eSession, type AppTarget } from './harness/session'; +import { waitUntil } from './harness/wait'; + +type SessionState = { + pid: number; + root: string; + display: string; + cdpPort: number; + inspectorUrl: string; + mpvSocketPath: string; + ankiUrl: string; + target: AppTarget; +}; + +const stateFile = path.join(os.tmpdir(), 'subminer-e2e-session.json'); + +function readState(): SessionState { + if (!fs.existsSync(stateFile)) { + throw new Error('No e2e session is running. Start one with `bun run e2e start`.'); + } + return JSON.parse(fs.readFileSync(stateFile, 'utf8')) as SessionState; +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function parseJsonOrString(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return value; + } +} + +async function connectNamedPage(state: SessionState, pageName: string) { + if (pageName === 'main') return connectCdp(state.inspectorUrl, 'main process'); + const urlPart = pageName === 'overlay' ? OVERLAY_PAGE : pageName; + const targets = await listCdpTargets(state.cdpPort); + const target = targets.find((candidate) => candidate.url.includes(urlPart)); + if (!target) { + const open = targets.map((candidate) => ` ${candidate.type} ${candidate.url.slice(0, 100)}`); + throw new Error(`No window matches "${pageName}". Open targets:\n${open.join('\n')}`); + } + return connectCdpPage(target); +} + +async function withPage( + state: SessionState, + pageName: string, + use: (page: Awaited>) => Promise, +): Promise { + const page = await connectNamedPage(state, pageName); + try { + return await use(page); + } finally { + page.close(); + } +} + +async function start(): Promise { + if (fs.existsSync(stateFile) && isAlive(readState().pid)) { + throw new Error('An e2e session is already running. Stop it with `bun run e2e stop`.'); + } + const session = await startE2eSession(); + const state: SessionState = { + pid: process.pid, + root: session.root, + display: session.display, + cdpPort: session.cdpPort, + inspectorUrl: session.inspectorUrl, + mpvSocketPath: session.mpvSocketPath, + ankiUrl: session.anki.url, + target: session.target, + }; + // The state file grants main-process code evaluation via inspectorUrl, so keep it + // owner-only. 'wx' creates it exclusively: it fails rather than follow a symlink + // planted in the shared temp dir after the stale file is removed. + fs.rmSync(stateFile, { force: true }); + fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), { mode: 0o600, flag: 'wx' }); + console.log(JSON.stringify(state, null, 2)); + + await new Promise((resolve) => { + process.once('SIGINT', resolve); + process.once('SIGTERM', resolve); + }); + fs.rmSync(stateFile, { force: true }); + await session.dispose(); +} + +async function main(): Promise { + const [command, ...args] = process.argv.slice(2); + if (command === 'start') return start(); + + const state = readState(); + switch (command) { + case 'eval': { + const [pageName, expression] = args; + if (!pageName || !expression) throw new Error('Usage: eval '); + const value = await withPage(state, pageName, (page) => page.evaluate(expression)); + console.log(JSON.stringify(value, null, 2)); + return; + } + case 'shot': { + const [pageName, outputPath] = args; + if (!pageName || !outputPath) throw new Error('Usage: shot '); + fs.writeFileSync(outputPath, await withPage(state, pageName, (page) => page.screenshot())); + return; + } + case 'mpv': { + if (args.length === 0) throw new Error('Usage: mpv [args...]'); + const mpv = await connectMpv(state.mpvSocketPath); + try { + console.log(JSON.stringify((await mpv.command(...args.map(parseJsonOrString))) ?? null)); + } finally { + mpv.close(); + } + return; + } + case 'app': + await runAppCommand(state.target, args); + return; + case 'anki': + console.log(JSON.stringify(await (await fetch(state.ankiUrl)).json(), null, 2)); + return; + case 'stop': + process.kill(state.pid, 'SIGTERM'); + await waitUntil(() => !isAlive(state.pid), { description: 'e2e session to exit' }); + return; + default: + throw new Error(`Unknown command "${command ?? ''}". See the header of e2e/cli.ts.`); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/e2e/harness/cdp.ts b/e2e/harness/cdp.ts new file mode 100644 index 00000000..a49a9edd --- /dev/null +++ b/e2e/harness/cdp.ts @@ -0,0 +1,133 @@ +import { waitUntil, type Truthy, type WaitOptions } from './wait'; + +export type CdpTarget = { + type: string; + title: string; + url: string; + webSocketDebuggerUrl: string; +}; + +export type CdpPage = { + /** Sends a raw DevTools protocol command, for anything the helpers below do not cover. */ + send: (method: string, params?: Record) => Promise>; + /** Evaluates an expression in the page, awaiting promises, and returns its JSON value. */ + evaluate: (expression: string) => Promise; + /** Polls an expression until it is truthy and returns that value. */ + waitFor: (expression: string, options: WaitOptions) => Promise>; + /** Console output and uncaught exceptions seen since connecting, oldest first. */ + console: string[]; + /** PNG of the page as rendered by Chromium; needs no OS screen-capture permission. */ + screenshot: () => Promise; + close: () => void; +}; + +type CdpReply = { + id?: number; + method?: string; + params?: Record; + error?: { message: string }; + result?: Record; +}; + +type RemoteObject = { value?: unknown; description?: string }; + +// Renders a console/exception event as one log line. +function formatConsoleEvent(method: string, params: Record): string | null { + if (method === 'Runtime.consoleAPICalled') { + const args = Array.isArray(params.args) ? (params.args as RemoteObject[]) : []; + const text = args.map((arg) => arg.description ?? JSON.stringify(arg.value)).join(' '); + return `console.${String(params.type)}: ${text}`; + } + if (method === 'Runtime.exceptionThrown') { + const details = params.exceptionDetails as + | { text?: string; exception?: RemoteObject } + | undefined; + return `exception: ${details?.exception?.description ?? details?.text ?? 'unknown'}`; + } + if (method === 'Log.entryAdded') { + const entry = params.entry as { level?: string; source?: string; text?: string } | undefined; + return `${String(entry?.source)}.${String(entry?.level)}: ${String(entry?.text)}`; + } + return null; +} + +type EvaluateResult = { + result: { value?: unknown }; + exceptionDetails?: { text: string; exception?: { description?: string } }; +}; + +export async function listCdpTargets(port: number): Promise { + const response = await fetch(`http://127.0.0.1:${port}/json/list`); + return (await response.json()) as CdpTarget[]; +} + +export function connectCdpPage(target: CdpTarget): Promise { + return connectCdp(target.webSocketDebuggerUrl, target.url); +} + +/** Connects to any DevTools endpoint: a renderer page or Electron's main-process inspector. */ +export async function connectCdp(webSocketDebuggerUrl: string, label: string): Promise { + const socket = new WebSocket(webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + socket.addEventListener('open', () => resolve(), { once: true }); + socket.addEventListener('error', () => reject(new Error(`CDP connection to ${label} failed`)), { + once: true, + }); + }); + + const pending = new Map void>(); + const consoleLines: string[] = []; + let nextId = 1; + socket.addEventListener('message', (event) => { + const reply: CdpReply = JSON.parse(String(event.data)); + if (reply.id === undefined) { + if (reply.method) { + const line = formatConsoleEvent(reply.method, reply.params ?? {}); + if (line) consoleLines.push(line); + } + return; + } + pending.get(reply.id)?.(reply); + pending.delete(reply.id); + }); + + const send = (method: string, params: Record = {}) => + new Promise>((resolve, reject) => { + const id = nextId++; + pending.set(id, (reply) => { + if (reply.error) reject(new Error(`CDP ${method} failed: ${reply.error.message}`)); + else resolve(reply.result ?? {}); + }); + socket.send(JSON.stringify({ id, method, params })); + }); + + const evaluate = async (expression: string): Promise => { + const reply = (await send('Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + })) as EvaluateResult; + if (reply.exceptionDetails) { + const { text, exception } = reply.exceptionDetails; + throw new Error(`Page evaluation threw: ${exception?.description ?? text}`); + } + return reply.result.value as T; + }; + + await send('Runtime.enable'); + // The Node inspector behind Electron's main process has no Log domain. + await send('Log.enable').catch(() => undefined); + + return { + send, + evaluate, + console: consoleLines, + waitFor: (expression: string, options: WaitOptions) => + waitUntil(() => evaluate(expression), options), + screenshot: async () => { + const reply = await send('Page.captureScreenshot', { format: 'png' }); + return Buffer.from(String(reply.data), 'base64'); + }, + close: () => socket.close(), + }; +} diff --git a/e2e/harness/display.ts b/e2e/harness/display.ts new file mode 100644 index 00000000..eee787eb --- /dev/null +++ b/e2e/harness/display.ts @@ -0,0 +1,75 @@ +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { stopProcess, type EnvDelta } from './process'; + +export const E2E_SCREEN = { width: 1280, height: 720 } as const; + +export type E2eDisplay = { + kind: 'xvfb' | 'host'; + env: EnvDelta; + stop: () => Promise; +}; + +// Linux gets a private Xvfb server so no window ever reaches the real desktop. +// macOS and Windows have no offscreen display server, so windows open in the +// current GUI session there. SUBMINER_E2E_DISPLAY=host forces that on Linux too. +export async function startDisplay(): Promise { + const requested = process.env.SUBMINER_E2E_DISPLAY?.trim().toLowerCase(); + if (requested && requested !== 'host' && requested !== 'xvfb') { + throw new Error(`SUBMINER_E2E_DISPLAY must be "host" or "xvfb", got "${requested}"`); + } + const kind = requested ?? (process.platform === 'linux' ? 'xvfb' : 'host'); + if (kind === 'host') { + return { kind, env: { set: {}, unset: [] }, stop: async () => {} }; + } + return startXvfb(); +} + +async function startXvfb(): Promise { + // -displayfd makes Xvfb pick a free display number and write it to fd 3 once + // it accepts connections, so there is no port guessing and no startup sleep. + const child = spawn( + 'Xvfb', + [ + '-displayfd', + '3', + '-screen', + '0', + `${E2E_SCREEN.width}x${E2E_SCREEN.height}x24`, + '-nolisten', + 'tcp', + ], + { stdio: ['ignore', 'ignore', 'ignore', 'pipe'] }, + ); + const displayFd = child.stdio[3]; + if (!displayFd) throw new Error('Xvfb did not expose its display fd'); + + const displayNumber = await Promise.race([ + once(displayFd, 'data').then(([chunk]) => String(chunk).trim()), + once(child, 'error').then(([error]) => { + throw new Error( + `Could not start Xvfb (${String(error)}). Install it or set SUBMINER_E2E_DISPLAY=host.`, + ); + }), + once(child, 'exit').then(([code]) => { + throw new Error(`Xvfb exited during startup with code ${String(code)}`); + }), + ]); + + return { + kind: 'xvfb', + env: { + set: { DISPLAY: `:${displayNumber}`, XDG_SESSION_TYPE: 'x11' }, + // Without these the app and mpv would pick the real Wayland session, or + // take the compositor-specific code paths of the desktop running the tests. + unset: [ + 'WAYLAND_DISPLAY', + 'HYPRLAND_INSTANCE_SIGNATURE', + 'SWAYSOCK', + 'XDG_CURRENT_DESKTOP', + 'XDG_SESSION_DESKTOP', + ], + }, + stop: () => stopProcess(child), + }; +} diff --git a/e2e/harness/fake-anki.ts b/e2e/harness/fake-anki.ts new file mode 100644 index 00000000..c848e82b --- /dev/null +++ b/e2e/harness/fake-anki.ts @@ -0,0 +1,106 @@ +import { once } from 'node:events'; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; + +export type AnkiRequest = { action: string; params: Record }; + +export type FakeAnki = { + url: string; + /** Every AnkiConnect action received so far, with `multi` batches flattened. */ + requests: AnkiRequest[]; + stop: () => Promise; +}; + +export const FAKE_ANKI_DECK = 'E2E'; +export const FAKE_ANKI_MODEL = 'Lapis'; + +const MODEL_FIELDS = [ + 'Expression', + 'ExpressionFurigana', + 'ExpressionReading', + 'ExpressionAudio', + 'SelectionText', + 'MainDefinition', + 'Sentence', + 'SentenceFurigana', + 'SentenceAudio', + 'Picture', + 'Glossary', + 'IsWordAndSentenceCard', + 'IsClickCard', + 'IsSentenceCard', + 'IsAudioCard', + 'MiscInfo', +]; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +// A recording AnkiConnect stand-in: an empty collection with one deck and one +// Lapis-shaped note type. Scenarios assert on `requests`, not on stored notes. +export async function startFakeAnki(): Promise { + const requests: AnkiRequest[] = []; + let nextNoteId = 1000; + + const handle = (body: unknown): unknown => { + if (!isRecord(body) || typeof body.action !== 'string') return null; + const params = isRecord(body.params) ? body.params : {}; + if (body.action === 'multi') { + return Array.isArray(params.actions) ? params.actions.map(handle) : []; + } + requests.push({ action: body.action, params }); + switch (body.action) { + case 'version': + return 6; + case 'deckNames': + return [FAKE_ANKI_DECK]; + case 'modelNames': + return [FAKE_ANKI_MODEL]; + case 'modelFieldNames': + return MODEL_FIELDS; + case 'addNote': + return nextNoteId++; + case 'findNotes': + case 'notesInfo': + return []; + case 'storeMediaFile': + return params.filename ?? null; + default: + return null; + } + }; + + const server = http.createServer((request, response) => { + response.setHeader('content-type', 'application/json'); + // GET exposes the request log so the session CLI can read it from outside. + if (request.method === 'GET') { + response.end(JSON.stringify(requests)); + return; + } + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + request.on('end', () => { + let body: unknown = null; + try { + body = JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + // Malformed bodies get the same null result AnkiConnect gives unknown actions. + } + response.end(JSON.stringify({ result: handle(body), error: null })); + }); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const { port } = server.address() as AddressInfo; + + return { + url: `http://127.0.0.1:${port}`, + requests, + stop: async () => { + server.closeAllConnections(); + server.close(); + await once(server, 'close'); + }, + }; +} diff --git a/e2e/harness/fixtures.ts b/e2e/harness/fixtures.ts new file mode 100644 index 00000000..40331112 --- /dev/null +++ b/e2e/harness/fixtures.ts @@ -0,0 +1,92 @@ +import { execFile } from 'node:child_process'; +import fs from 'node:fs'; +import { promisify } from 'node:util'; +import { writeStoredZipAsync } from '../../src/shared/stored-zip'; + +const execFileAsync = promisify(execFile); + +export const FIXTURE_DICTIONARY_TITLE = 'SubMiner E2E Fixture'; +export const FIXTURE_CLIP_SECONDS = 15; + +/** Cues of the synthetic clip. Seek to `at` to land inside a cue. */ +export const FIXTURE_CUES = [ + { start: 1, end: 4, at: 2, text: '今日はいい天気ですね。' }, + { start: 5, end: 8, at: 6, text: '猫が好きです。' }, + { start: 9, end: 12, at: 10, text: '明日は学校に行きます。' }, +] as const; + +// [term, reading, part of speech, gloss] for every content word in FIXTURE_CUES. +const FIXTURE_TERMS = [ + ['今日', 'きょう', 'n', 'today'], + ['いい', 'いい', 'adj-i', 'good'], + ['天気', 'てんき', 'n', 'weather'], + ['猫', 'ねこ', 'n', 'cat'], + ['好き', 'すき', 'adj-na', 'liked'], + ['明日', 'あした', 'n', 'tomorrow'], + ['学校', 'がっこう', 'n', 'school'], + ['行く', 'いく', 'v5', 'to go'], +] as const; + +function srtTimestamp(seconds: number): string { + return `00:00:${String(seconds).padStart(2, '0')},000`; +} + +export function writeFixtureSubtitles(outputPath: string): void { + const blocks = FIXTURE_CUES.map( + (cue, index) => + `${index + 1}\n${srtTimestamp(cue.start)} --> ${srtTimestamp(cue.end)}\n${cue.text}\n`, + ); + fs.writeFileSync(outputPath, blocks.join('\n'), 'utf8'); +} + +/** Renders a deterministic test-pattern clip with a sine tone, so no real media is needed. */ +export async function generateFixtureClip(outputPath: string): Promise { + await execFileAsync('ffmpeg', [ + '-nostdin', + '-v', + 'error', + '-y', + '-f', + 'lavfi', + '-i', + `testsrc2=size=640x360:rate=24:duration=${FIXTURE_CLIP_SECONDS}`, + '-f', + 'lavfi', + '-i', + `sine=frequency=440:duration=${FIXTURE_CLIP_SECONDS}`, + '-c:v', + 'libx264', + '-preset', + 'ultrafast', + '-pix_fmt', + 'yuv420p', + '-c:a', + 'aac', + outputPath, + ]); +} + +/** Writes a minimal Yomitan term dictionary so the tokenizer has real entries to match. */ +export async function buildFixtureDictionary(outputPath: string): Promise { + const index = { + title: FIXTURE_DICTIONARY_TITLE, + revision: '1', + format: 3, + author: 'SubMiner', + description: 'Synthetic dictionary for the e2e harness.', + }; + const termBank = FIXTURE_TERMS.map(([term, reading, partOfSpeech, gloss], sequence) => [ + term, + reading, + '', + partOfSpeech, + 0, + [gloss], + sequence + 1, + '', + ]); + await writeStoredZipAsync(outputPath, [ + { name: 'index.json', data: Buffer.from(JSON.stringify(index), 'utf8') }, + { name: 'term_bank_1.json', data: Buffer.from(JSON.stringify(termBank), 'utf8') }, + ]); +} diff --git a/e2e/harness/mpv.ts b/e2e/harness/mpv.ts new file mode 100644 index 00000000..d9b3b9a2 --- /dev/null +++ b/e2e/harness/mpv.ts @@ -0,0 +1,95 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import fs from 'node:fs'; +import net from 'node:net'; +import type { E2eDisplay } from './display'; +import { E2E_SCREEN } from './display'; +import { applyEnvDelta } from './process'; +import { waitUntil } from './wait'; + +export type MpvClient = { + /** Sends one mpv JSON IPC command and resolves with its `data` field. */ + command: (...args: unknown[]) => Promise; + close: () => void; +}; + +type MpvReply = { request_id?: number; error?: string; data?: unknown }; + +export async function startMpv(options: { + socketPath: string; + mediaPath: string; + subtitlePath: string; + logPath: string; + display: E2eDisplay; +}): Promise { + const log = fs.openSync(options.logPath, 'w'); + const child = spawn( + 'mpv', + [ + '--no-config', + '--no-terminal', + '--pause', + '--keep-open=yes', + '--ao=null', + `--geometry=${E2E_SCREEN.width}x${E2E_SCREEN.height}+0+0`, + // Xvfb has no GPU; the software X11 output always works there. + ...(options.display.kind === 'xvfb' ? ['--vo=x11'] : []), + `--input-ipc-server=${options.socketPath}`, + `--sub-file=${options.subtitlePath}`, + options.mediaPath, + ], + { env: applyEnvDelta(process.env, options.display.env), stdio: ['ignore', log, log] }, + ); + fs.closeSync(log); + await Promise.race([ + once(child, 'spawn'), + once(child, 'error').then(([error]) => { + throw new Error(`Could not start mpv (${String(error)}). Is it on PATH?`); + }), + ]); + return child; +} + +function tryConnect(socketPath: string): Promise { + return new Promise((resolve) => { + const socket = net.connect(socketPath); + socket.once('connect', () => resolve(socket)); + socket.once('error', () => resolve(null)); + }); +} + +export async function connectMpv(socketPath: string): Promise { + const socket = await waitUntil(() => tryConnect(socketPath), { + description: `mpv IPC socket ${socketPath}`, + }); + const pending = new Map void>(); + let nextRequestId = 1; + let buffer = ''; + + socket.setEncoding('utf8'); + socket.on('data', (chunk: string) => { + buffer += chunk; + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + for (const line of lines) { + if (!line.trim()) continue; + const reply: MpvReply = JSON.parse(line); + if (reply.request_id === undefined) continue; + pending.get(reply.request_id)?.(reply); + pending.delete(reply.request_id); + } + }); + + return { + command: (...args) => + new Promise((resolve, reject) => { + const requestId = nextRequestId++; + pending.set(requestId, (reply) => { + if (reply.error === 'success') resolve(reply.data); + else reject(new Error(`mpv ${JSON.stringify(args)} failed: ${reply.error}`)); + }); + socket.write(`${JSON.stringify({ command: args, request_id: requestId })}\n`); + }), + close: () => socket.destroy(), + }; +} diff --git a/e2e/harness/process.ts b/e2e/harness/process.ts new file mode 100644 index 00000000..15d4a36d --- /dev/null +++ b/e2e/harness/process.ts @@ -0,0 +1,24 @@ +import type { ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; + +export type EnvDelta = { set: Record; unset: string[] }; + +export function applyEnvDelta(base: NodeJS.ProcessEnv, ...deltas: EnvDelta[]): NodeJS.ProcessEnv { + const env = { ...base }; + for (const delta of deltas) { + for (const name of delta.unset) delete env[name]; + Object.assign(env, delta.set); + } + return env; +} + +/** Terminates a child and waits for it to exit, escalating to SIGKILL after `graceMs`. */ +export async function stopProcess(child: ChildProcess, graceMs = 5_000): Promise { + // A child that never spawned (missing binary) has no pid and never emits exit. + if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return; + const exited = once(child, 'exit'); + child.kill(); + const timer = setTimeout(() => child.kill('SIGKILL'), graceMs); + await exited; + clearTimeout(timer); +} diff --git a/e2e/harness/session.ts b/e2e/harness/session.ts new file mode 100644 index 00000000..cc153491 --- /dev/null +++ b/e2e/harness/session.ts @@ -0,0 +1,332 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { connectCdp, connectCdpPage, listCdpTargets, type CdpPage } from './cdp'; +import { startDisplay, type E2eDisplay } from './display'; +import { FAKE_ANKI_DECK, FAKE_ANKI_MODEL, startFakeAnki, type FakeAnki } from './fake-anki'; +import { buildFixtureDictionary, generateFixtureClip, writeFixtureSubtitles } from './fixtures'; +import { connectMpv, startMpv, type MpvClient } from './mpv'; +import { applyEnvDelta, stopProcess, type EnvDelta } from './process'; +import { waitUntil } from './wait'; +import { + shouldForceX11ElectronBackend, + X11_ELECTRON_BOOTSTRAP_ENV, +} from '../../src/core/utils/electron-backend'; + +const repoRoot = path.resolve(__dirname, '..', '..'); + +/** URL fragments identifying the app's windows among the CDP targets. The modal + * window loads the same document with `layer=modal`, so the layer is required. */ +export const OVERLAY_PAGE = 'renderer/index.html?layer=visible'; +const YOMITAN_SETTINGS_PAGE = 'settings.html'; + +type JsonObject = { [key: string]: JsonValue }; +type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject; + +export type E2eSessionOptions = { + /** Deep-merged over the harness config, for scenarios that need other settings. */ + config?: JsonObject; +}; + +export type AppTarget = { + /** Environment changes that point a process at the isolated instance. */ + env: EnvDelta; + /** Electron/Chromium switches every launch needs. */ + electronArgs: string[]; + /** Receives stdout and stderr of every launch. */ + logPath: string; +}; + +export type E2eSession = { + /** Temp dir holding the isolated config, fixtures, sockets, and logs. */ + root: string; + display: E2eDisplay['kind']; + cdpPort: number; + /** DevTools endpoint of the Electron main process (Node inspector). */ + inspectorUrl: string; + mpvSocketPath: string; + /** How to start a process that talks to this isolated instance. */ + target: AppTarget; + mpv: MpvClient; + anki: FakeAnki; + /** Connects to the first window or frame whose URL contains `urlPart`, waiting for it to exist. */ + openPage: (urlPart: string) => Promise; + /** Evaluates in the main process; reach modules via `process.mainModule.require`. */ + mainProcess: () => Promise; + /** Runs a CLI command (e.g. `--mine-sentence`) against the running instance. */ + runAppCommand: (...args: string[]) => Promise; + dispose: () => Promise; +}; + +function isJsonObject(value: JsonValue | undefined): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function mergeConfig(base: JsonObject, overrides: JsonObject): JsonObject { + const merged = { ...base }; + for (const [key, value] of Object.entries(overrides)) { + const existing = merged[key]; + merged[key] = + isJsonObject(existing) && isJsonObject(value) ? mergeConfig(existing, value) : value; + } + return merged; +} + +// Everything that could reach outside the sandbox (network services, the real +// Anki, ports the user's own SubMiner holds) is off or pointed at a fake. +function buildConfig(mpvSocketPath: string, ankiUrl: string): JsonObject { + return { + mpv: { socketPath: mpvSocketPath, aniskipEnabled: false }, + ankiConnect: { + url: ankiUrl, + deck: FAKE_ANKI_DECK, + proxy: { enabled: false }, + isLapis: { enabled: true, sentenceCardModel: FAKE_ANKI_MODEL }, + }, + stats: { autoStartServer: false }, + updates: { enabled: false }, + discordPresence: { enabled: false }, + websocket: { enabled: false }, + annotationWebsocket: { enabled: false }, + texthooker: { launchAtStartup: false }, + }; +} + +// Outside Electron the package's entry point is the path of its binary, and +// resolving it downloads the binary when an install skipped that step (CI). +function resolveElectronBinary(): string { + const binary: unknown = require('electron'); + if (typeof binary !== 'string') throw new Error('Could not resolve the Electron binary path'); + return binary; +} + +// Electron announces its main-process inspector on stderr; `offset` skips earlier launches. +function readInspectorUrl(logPath: string, byteOffset: number): string | null { + const tail = fs.readFileSync(logPath).subarray(byteOffset).toString('utf8'); + return /Debugger listening on (ws:\/\/\S+)/.exec(tail)?.[1] ?? null; +} + +function readDevToolsPort(portFile: string): number | null { + try { + const port = Number(fs.readFileSync(portFile, 'utf8').split('\n')[0]); + return port > 0 ? port : null; + } catch { + return null; + } +} + +function spawnApp(target: AppTarget, electronArgs: string[], args: string[]): ChildProcess { + const log = fs.openSync(target.logPath, 'a'); + const child = spawn( + resolveElectronBinary(), + [...target.electronArgs, ...electronArgs, repoRoot, ...args], + { cwd: repoRoot, env: applyEnvDelta(process.env, target.env), stdio: ['ignore', log, log] }, + ); + fs.closeSync(log); + return child; +} + +/** Runs a CLI command against the running instance and waits for it to finish. */ +export async function runAppCommand(target: AppTarget, args: string[]): Promise { + const child = spawnApp(target, [], args); + const timer = setTimeout(() => child.kill('SIGKILL'), 30_000); + const [code] = await once(child, 'exit'); + clearTimeout(timer); + if (code !== 0) { + throw new Error( + `SubMiner ${args.join(' ')} exited with ${String(code)}; see ${target.logPath}`, + ); + } +} + +async function openPage(cdpPort: number, urlPart: string): Promise { + const target = await waitUntil( + async () => + (await listCdpTargets(cdpPort)).find((candidate) => candidate.url.includes(urlPart)), + { description: `app window matching "${urlPart}"`, timeoutMs: 30_000 }, + ); + const page = await connectCdpPage(target); + // A target is listed before its document exists; callers expect a usable DOM. + await page.waitFor(`document.readyState === "complete"`, { + description: `"${urlPart}" document to load`, + }); + return page; +} + +export async function startE2eSession(options: E2eSessionOptions = {}): Promise { + if (!fs.existsSync(path.join(repoRoot, 'dist', 'main-entry.js'))) { + throw new Error('dist/ is missing. Run `bun run build` before the e2e harness.'); + } + + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-e2e-')); + const configHome = path.join(root, 'config'); + const userDataDir = path.join(configHome, 'SubMiner'); + const mediaPath = path.join(root, 'clip.mkv'); + const subtitlePath = path.join(root, 'clip.srt'); + const dictionaryPath = path.join(root, 'fixture-dictionary.zip'); + const mpvSocketPath = + process.platform === 'win32' + ? `\\\\.\\pipe\\${path.basename(root)}-mpv` + : path.join(root, 'mpv.sock'); + + // Teardown steps, run in reverse. Also used to unwind a failed startup. + const teardown: Array<() => Promise | void> = []; + const dispose = async (): Promise => { + for (const step of teardown.reverse()) await step(); + teardown.length = 0; + if (process.env.SUBMINER_E2E_KEEP !== '1') { + fs.rmSync(root, { recursive: true, force: true }); + } + }; + + try { + fs.mkdirSync(userDataDir, { recursive: true }); + writeFixtureSubtitles(subtitlePath); + await Promise.all([generateFixtureClip(mediaPath), buildFixtureDictionary(dictionaryPath)]); + + const display = await startDisplay(); + teardown.push(display.stop); + const anki = await startFakeAnki(); + teardown.push(anki.stop); + + const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {}); + fs.writeFileSync(path.join(userDataDir, 'config.jsonc'), JSON.stringify(config, null, 2)); + + const env: EnvDelta = { + set: { + ...display.env.set, + // The app derives its config dir, Electron profile, and control socket from these. + ...(process.platform === 'win32' + ? { APPDATA: configHome } + : { XDG_CONFIG_HOME: configHome, XDG_DATA_HOME: path.join(root, 'data') }), + SUBMINER_APP_LOG: path.join(root, 'app.log'), + // Stay attached instead of re-spawning detached, so the harness owns the process. + SUBMINER_BACKGROUND_CHILD: '1', + }, + unset: [...display.env.unset, 'ELECTRON_RUN_AS_NODE'], + }; + // --enable-logging routes renderer console output into the app log too. + const electronArgs = ['--password-store=basic', '--enable-logging=stderr']; + // Outside Hyprland/Sway the app re-spawns itself detached onto the X11 + // backend. Start it there directly so the process we spawn is the app. + if (shouldForceX11ElectronBackend(applyEnvDelta(process.env, env))) { + env.set[X11_ELECTRON_BOOTSTRAP_ENV] = '1'; + electronArgs.push('--ozone-platform=x11'); + } + // CI containers and runners lack the user namespaces Chromium's sandbox needs. + if (process.platform === 'linux' && process.env.CI) electronArgs.push('--no-sandbox'); + const target: AppTarget = { env, electronArgs, logPath: path.join(root, 'app-stdio.log') }; + + const launchApp = async ( + args: string[], + ): Promise<{ child: ChildProcess; cdpPort: number; inspectorUrl: string }> => { + // With port 0 Chromium and Node each pick a free port; Chromium records + // its port in the profile dir, Node prints its endpoint to stderr. + const portFile = path.join(userDataDir, 'DevToolsActivePort'); + fs.rmSync(portFile, { force: true }); + const logOffset = fs.existsSync(target.logPath) ? fs.statSync(target.logPath).size : 0; + const child = spawnApp( + target, + ['--remote-debugging-port=0', '--inspect=0'], + [...args, '--log-level', 'debug'], + ); + const stop = () => stopProcess(child); + teardown.push(stop); + const cdpPort = await waitUntil( + () => { + if (child.exitCode !== null) { + throw new Error(`SubMiner exited with code ${child.exitCode} during startup`); + } + return readDevToolsPort(portFile); + }, + { description: 'SubMiner DevTools port', timeoutMs: 30_000 }, + ); + const inspectorUrl = await waitUntil(() => readInspectorUrl(target.logPath, logOffset), { + description: 'SubMiner main-process inspector endpoint', + }); + return { child, cdpPort, inspectorUrl }; + }; + + // First boot: import the fixture dictionary through Yomitan's settings page. + // Without one the tokenizer finds nothing and first-run setup takes over. + const seeder = await launchApp(['--yomitan']); + const settings = await openPage(seeder.cdpPort, YOMITAN_SETTINGS_PAGE); + await settings.waitFor('globalThis.__subminerYomitanSettingsAutomation?.ready === true', { + description: 'Yomitan settings automation bridge', + }); + const archive = fs.readFileSync(dictionaryPath).toString('base64'); + await settings.evaluate( + `globalThis.__subminerYomitanSettingsAutomation + .importDictionaryArchiveBase64(${JSON.stringify(archive)}, "fixture-dictionary.zip") + .then(() => true)`, + ); + settings.close(); + await stopProcess(seeder.child); + + const mpvProcess = await startMpv({ + socketPath: mpvSocketPath, + mediaPath, + subtitlePath, + logPath: path.join(root, 'mpv.log'), + display, + }); + teardown.push(() => stopProcess(mpvProcess)); + const mpv = await connectMpv(mpvSocketPath); + teardown.push(mpv.close); + + const app = await launchApp(['--start']); + // mpv starts paused and the app resumes it once the overlay and tokenizer + // are ready, so that resume marks the end of startup. + await waitUntil(async () => (await mpv.command('get_property', 'pause')) === false, { + description: 'SubMiner to resume playback after overlay startup', + timeoutMs: 30_000, + }); + // Hand scenarios a paused player. The app retries its resume for a moment, + // so keep pausing until the pause has held for longer than that retry window. + let pausedSince: number | null = null; + await waitUntil( + async () => { + if ((await mpv.command('get_property', 'pause')) !== true) { + pausedSince = null; + await mpv.command('set_property', 'pause', true); + return false; + } + pausedSince ??= Date.now(); + return Date.now() - pausedSince >= 1_500; + }, + { description: 'playback to stay paused after startup', timeoutMs: 40_000 }, + ); + const pages: CdpPage[] = []; + teardown.push(() => pages.forEach((page) => page.close())); + + return { + root, + display: display.kind, + cdpPort: app.cdpPort, + inspectorUrl: app.inspectorUrl, + mpvSocketPath, + target, + mpv, + anki, + openPage: async (urlPart) => { + const page = await openPage(app.cdpPort, urlPart); + pages.push(page); + return page; + }, + mainProcess: async () => { + const page = await connectCdp(app.inspectorUrl, 'main process'); + pages.push(page); + return page; + }, + runAppCommand: (...args) => runAppCommand(target, args), + dispose, + }; + } catch (error) { + // Keep the logs: a failed startup is exactly when they are needed. + process.env.SUBMINER_E2E_KEEP = '1'; + await dispose(); + throw new Error(`E2E session failed to start (logs kept in ${root})`, { cause: error }); + } +} diff --git a/e2e/harness/wait.ts b/e2e/harness/wait.ts new file mode 100644 index 00000000..a13221b4 --- /dev/null +++ b/e2e/harness/wait.ts @@ -0,0 +1,23 @@ +export type WaitOptions = { + description: string; + timeoutMs?: number; + intervalMs?: number; +}; + +export type Truthy = Exclude; + +/** Polls `probe` until it returns a truthy value, then returns that value. */ +export async function waitUntil( + probe: () => T | Promise, + { description, timeoutMs = 15_000, intervalMs = 100 }: WaitOptions, +): Promise> { + const deadline = Date.now() + timeoutMs; + for (;;) { + const value = await probe(); + if (value) return value as Truthy; + if (Date.now() >= deadline) { + throw new Error(`Timed out after ${timeoutMs}ms waiting for ${description}`); + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } +} diff --git a/e2e/playback.e2e.test.ts b/e2e/playback.e2e.test.ts new file mode 100644 index 00000000..fb8b88b4 --- /dev/null +++ b/e2e/playback.e2e.test.ts @@ -0,0 +1,217 @@ +import assert from 'node:assert/strict'; +import test, { after } from 'node:test'; +import { connectCdpPage, listCdpTargets, type CdpPage } from './harness/cdp'; +import { FAKE_ANKI_DECK, FAKE_ANKI_MODEL } from './harness/fake-anki'; +import { FIXTURE_CUES } from './harness/fixtures'; +import { OVERLAY_PAGE, startE2eSession, type E2eSession } from './harness/session'; +import { waitUntil } from './harness/wait'; + +type Started = { session: E2eSession; overlay: CdpPage }; + +// Bun caps node:test hooks at 5s whatever their timeout option says, so the +// session boots inside whichever test runs first and every test budgets for it. +const TEST_TIMEOUT_MS = 120_000; +let started: Promise | undefined; + +function useSession(): Promise { + started ??= (async () => { + const session = await startE2eSession(); + try { + return { session, overlay: await session.openPage(OVERLAY_PAGE) }; + } catch (error) { + await session.dispose(); + throw error; + } + })(); + return started; +} + +after(async () => { + const running = await started?.catch(() => undefined); + await running?.session.dispose(); +}); + +type RenderedToken = { surface: string; headword: string | null }; + +// Parks playback inside a cue and waits for the overlay to show that line. +async function showCue(cue: (typeof FIXTURE_CUES)[number]): Promise { + const { session, overlay } = await useSession(); + await session.mpv.command('seek', cue.at, 'absolute+exact'); + // One expression reads the line and its tokens together, so a re-render + // between two round trips cannot hand back a half-updated subtitle. + return overlay + .waitFor( + `(() => { + const root = document.getElementById('subtitleRoot'); + const words = Array.from(root.querySelectorAll('.word'), (word) => ({ + surface: word.textContent, + headword: word.getAttribute('data-headword'), + })); + return root.textContent === ${JSON.stringify(cue.text)} && words.length > 0 && words; + })()`, + { description: `overlay to render "${cue.text}" tokenized` }, + ) + .catch(async (error: unknown) => { + // Say what the overlay was showing, what main thinks the line is, and + // what the renderer logged, so a dropped update can be placed. + const state = await overlay.evaluate( + `(async () => JSON.stringify({ + visibility: document.visibilityState, + text: document.getElementById('subtitleRoot').textContent, + html: document.getElementById('subtitleRoot').innerHTML.slice(0, 200), + mainLine: (await window.electronAPI.getCurrentSubtitle())?.text, + errorToast: document.getElementById('overlayErrorToast')?.textContent, + }))()`, + ); + // Diagnostics must not replace the cue failure if the inspector is gone too. + const windows = await session + .mainProcess() + .then((main) => + main.evaluate( + `JSON.stringify(process.mainModule.require('electron').BrowserWindow.getAllWindows().map((w) => ({ + id: w.id, + title: w.getTitle(), + url: w.webContents.getURL().split('/').pop(), + visible: w.isVisible(), + loading: w.webContents.isLoading(), + documentLoaded: w.__subminerOverlayDocumentLoaded, + contentReady: w.__subminerOverlayContentReady, + })))`, + ), + ) + .catch((inspectorError: unknown) => `unavailable (${String(inspectorError)})`); + const targets = (await listCdpTargets(session.cdpPort)) + .filter((t) => t.type === 'page') + .map((t) => t.url.split('/').pop()); + const log = overlay.console.slice(-20).join('\n'); + throw new Error( + `Overlay state: ${state}\nMain windows: ${windows}\nPage targets: ${JSON.stringify(targets)}\nRenderer console:\n${log}`, + { cause: error }, + ); + }); +} + +function hasToken(tokens: RenderedToken[], surface: string, headword: string): boolean { + return tokens.some((token) => token.surface === surface && token.headword === headword); +} + +test( + 'overlay renders the current mpv subtitle line as dictionary tokens', + { timeout: TEST_TIMEOUT_MS }, + async () => { + const tokens = await showCue(FIXTURE_CUES[0]); + + assert.ok(hasToken(tokens, '今日', '今日'), JSON.stringify(tokens)); + assert.ok(hasToken(tokens, '天気', '天気'), JSON.stringify(tokens)); + }, +); + +test( + 'seeking to another cue replaces the line and deinflects verbs to their headword', + { timeout: TEST_TIMEOUT_MS }, + async () => { + const tokens = await showCue(FIXTURE_CUES[2]); + + assert.ok(hasToken(tokens, '行きます', '行く'), JSON.stringify(tokens)); + }, +); + +test( + 'mining a sentence card sends the line with generated audio and image to Anki', + { timeout: TEST_TIMEOUT_MS }, + async () => { + const { session } = await useSession(); + const cue = FIXTURE_CUES[1]; + await showCue(cue); + session.anki.requests.length = 0; + + await session.runAppCommand('--mine-sentence'); + const update = await waitUntil( + () => session.anki.requests.find((request) => request.action === 'updateNoteFields'), + { description: 'media fields to be written to the mined note', timeoutMs: 30_000 }, + ); + + const added = session.anki.requests.filter((request) => request.action === 'addNote'); + assert.equal(added.length, 1); + assert.partialDeepStrictEqual(added[0]?.params.note, { + deckName: FAKE_ANKI_DECK, + modelName: FAKE_ANKI_MODEL, + fields: { Sentence: cue.text }, + }); + + const stored = session.anki.requests + .filter((request) => request.action === 'storeMediaFile') + .map((request) => String(request.params.filename)); + assert.ok( + stored.some((name) => name.endsWith('.mp3')), + `no audio in ${stored.join(', ')}`, + ); + assert.ok( + stored.some((name) => name.endsWith('.jpg')), + `no image in ${stored.join(', ')}`, + ); + + const updatedFields = JSON.stringify(update.params); + for (const filename of stored) assert.ok(updatedFields.includes(filename), filename); + }, +); + +// Runs last: it leaves the lookup popup open over the overlay. +test( + 'holding Shift over a word opens the Yomitan popup with its dictionary entry', + { timeout: TEST_TIMEOUT_MS }, + async () => { + const { session, overlay } = await useSession(); + await showCue(FIXTURE_CUES[0]); + const word = await overlay.evaluate<{ x: number; y: number }>( + `(() => { + const words = Array.from(document.querySelectorAll('#subtitleRoot .word')); + const box = words.find((word) => word.textContent === '天気').getBoundingClientRect(); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + })()`, + ); + + // Yomitan's default scan modifier is Shift, and it scans on mouse movement, + // so approach the word, hold Shift, then move across it. + const shift = { key: 'Shift', code: 'ShiftLeft', windowsVirtualKeyCode: 16 }; + const move = (x: number, modifiers = 0) => + overlay.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y: word.y, modifiers }); + await move(word.x - 30); + await overlay.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', ...shift, modifiers: 8 }); + await move(word.x - 2, 8); + await move(word.x, 8); + + await overlay.waitFor( + `document.querySelector('[data-subminer-yomitan-popup-visible="true"]') !== null`, + { description: 'overlay to mark the Yomitan popup visible' }, + ); + // Yomitan keeps more than one popup frame around, so read whichever + // one holds the entry instead of trusting target order. + let seen: string[] = []; + const entry = await waitUntil( + async () => { + const targets = (await listCdpTargets(session.cdpPort)).filter((target) => + target.url.includes('popup.html'), + ); + seen = []; + for (const target of targets) { + // A frame can go away between listing and connecting; skip it. + const frame = await connectCdpPage(target).catch(() => undefined); + if (!frame) continue; + // textContent, not innerText: the entry counts once it is in the DOM, + // even where a GPU-less runner never lays the frame out. + const text = await frame.evaluate('document.body?.textContent ?? ""'); + frame.close(); + seen.push(text); + } + return seen.find((text) => text.includes('weather')); + }, + { description: 'a popup frame to show the fixture dictionary entry' }, + ).catch((error: unknown) => { + const summary = seen.map((text) => text.replace(/\s+/g, ' ').trim().slice(0, 200)); + throw new Error(`Popup frames held: ${JSON.stringify(summary)}`, { cause: error }); + }); + await overlay.send('Input.dispatchKeyEvent', { type: 'keyUp', ...shift }); + assert.match(entry, /天気/); + }, +); diff --git a/package.json b/package.json index 22bccc97..eaa783c9 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,8 @@ "test:launcher:unit:src": "bun scripts/run-test-lane.mjs bun-launcher-unit", "test:scripts": "bun scripts/run-test-lane.mjs scripts", "test:stats": "bun scripts/run-test-lane.mjs stats", + "test:e2e": "bun scripts/run-test-lane.mjs e2e --jobs 1", + "e2e": "bun e2e/cli.ts", "test:env": "bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src", "test:runtime:compat": "bun run test:smoke:dist", "test": "bun run test:fast", diff --git a/scripts/prettier-scope.sh b/scripts/prettier-scope.sh index 023af77a..adfc3528 100644 --- a/scripts/prettier-scope.sh +++ b/scripts/prettier-scope.sh @@ -12,6 +12,7 @@ paths=( ".prettierrc.json" ".github" "build" + "e2e" "launcher" "scripts" "src" diff --git a/scripts/test-lanes.ts b/scripts/test-lanes.ts index d3cd0064..39d869b4 100644 --- a/scripts/test-lanes.ts +++ b/scripts/test-lanes.ts @@ -35,6 +35,12 @@ export const testLanes: Record = { roots: ['scripts'], include: ['.test.ts'], }, + // Boots the built app, mpv, and a fake AnkiConnect on an offscreen display. + // Slow and dependent on mpv/ffmpeg/Xvfb, so it stays out of test:fast. + e2e: { + roots: ['e2e'], + include: ['.e2e.test.ts'], + }, stats: { roots: ['stats/src'], include: ['.test.ts', '.test.tsx'], diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index e887982b..7e91144e 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -7,6 +7,6 @@ "declarationMap": false, "sourceMap": false }, - "include": ["src/**/*", "launcher/**/*.ts", "scripts/*.ts"], + "include": ["src/**/*", "launcher/**/*.ts", "scripts/*.ts", "e2e/**/*.ts"], "exclude": ["node_modules", "dist", "vendor", "scripts/*.test.ts"] }