mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-20 17:01:24 -07:00
test: add headless end-to-end harness (#286)
* test: add headless end-to-end harness and CI job Boot the built app against a real mpv, a synthetic clip, a fixture Yomitan dictionary, and a fake AnkiConnect inside a private Xvfb display, then assert on the overlay DOM and the AnkiConnect requests. Adds the test:e2e lane, an e2e session CLI for exploratory checks, and a three-platform CI job. * ci: set e2e temp dir from a step * test: make e2e session startup robust on slow machines Bun caps node:test hooks at 5s, so boot the session inside the first test. Keep pausing until the app's resume retries stop, read cue tokens in one round trip, and resolve Electron through its package entry so CI downloads the binary. * test: harden e2e harness for CI runners Fail fast when mpv cannot be spawned, read every Yomitan popup frame instead of trusting target order, and put the chocolatey mpv directory on PATH. * test: add e2e failure diagnostics and mark Windows job experimental Report what the overlay showed when a cue never renders, dispose the session when the overlay cannot be opened, skip popup frames that vanish mid-poll, and turn off AniSkip lookups in the harness config. * test: read the lookup popup entry from textContent * test: capture renderer console and main-side subtitle state in e2e failures * test: expose the Electron main-process inspector to the e2e harness Adds session.mainProcess() and 'e2e eval main', and reports main's window list alongside the overlay state when a cue never renders. * test: tap subtitle sends and receipts in e2e failure diagnostics * test: probe alternate main-to-renderer paths in e2e failure diagnostics * test: select the visible overlay layer, not the primed modal window Windows and macOS pre-create a hidden modal window that loads the same renderer document, and the harness could attach to it instead of the visible overlay. Drops the delivery taps that found this. * ci: run the Windows e2e job as a normal matrix entry * test: address review on e2e harness Slice the app log at a byte offset before decoding, create the session state file owner-only since it carries the inspector URL, and keep the cue failure when inspector diagnostics are unavailable. * test: create the e2e session state file exclusively
This commit is contained in:
1 parent
b48083ddd8
commit
78eca52b7e
19 files changed
+1400
-2
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
@@ -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 <resources-directory>`.
|
||||
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.
|
||||
|
||||
+160
@@ -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 <page> <expression> evaluate JS in a window (or "main" for the main process)
|
||||
// bun run e2e shot <page> <file.png> save a screenshot of a window
|
||||
// bun run e2e mpv <command> [args...] send an mpv IPC command (args parsed as JSON)
|
||||
// bun run e2e app <args...> run a SubMiner CLI command against the instance
|
||||
// bun run e2e anki print the AnkiConnect requests received so far
|
||||
// bun run e2e stop
|
||||
//
|
||||
// <page> 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<T>(
|
||||
state: SessionState,
|
||||
pageName: string,
|
||||
use: (page: Awaited<ReturnType<typeof connectCdpPage>>) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const page = await connectNamedPage(state, pageName);
|
||||
try {
|
||||
return await use(page);
|
||||
} finally {
|
||||
page.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function start(): Promise<void> {
|
||||
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<void>((resolve) => {
|
||||
process.once('SIGINT', resolve);
|
||||
process.once('SIGTERM', resolve);
|
||||
});
|
||||
fs.rmSync(stateFile, { force: true });
|
||||
await session.dispose();
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
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 <page> <expression>');
|
||||
const value = await withPage(state, pageName, (page) => page.evaluate<unknown>(expression));
|
||||
console.log(JSON.stringify(value, null, 2));
|
||||
return;
|
||||
}
|
||||
case 'shot': {
|
||||
const [pageName, outputPath] = args;
|
||||
if (!pageName || !outputPath) throw new Error('Usage: shot <page> <file.png>');
|
||||
fs.writeFileSync(outputPath, await withPage(state, pageName, (page) => page.screenshot()));
|
||||
return;
|
||||
}
|
||||
case 'mpv': {
|
||||
if (args.length === 0) throw new Error('Usage: mpv <command> [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);
|
||||
});
|
||||
@@ -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<string, unknown>) => Promise<Record<string, unknown>>;
|
||||
/** Evaluates an expression in the page, awaiting promises, and returns its JSON value. */
|
||||
evaluate: <T>(expression: string) => Promise<T>;
|
||||
/** Polls an expression until it is truthy and returns that value. */
|
||||
waitFor: <T>(expression: string, options: WaitOptions) => Promise<Truthy<T>>;
|
||||
/** 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<Buffer>;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
type CdpReply = {
|
||||
id?: number;
|
||||
method?: string;
|
||||
params?: Record<string, unknown>;
|
||||
error?: { message: string };
|
||||
result?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type RemoteObject = { value?: unknown; description?: string };
|
||||
|
||||
// Renders a console/exception event as one log line.
|
||||
function formatConsoleEvent(method: string, params: Record<string, unknown>): 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<CdpTarget[]> {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/json/list`);
|
||||
return (await response.json()) as CdpTarget[];
|
||||
}
|
||||
|
||||
export function connectCdpPage(target: CdpTarget): Promise<CdpPage> {
|
||||
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<CdpPage> {
|
||||
const socket = new WebSocket(webSocketDebuggerUrl);
|
||||
await new Promise<void>((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<number, (reply: CdpReply) => 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<string, unknown> = {}) =>
|
||||
new Promise<Record<string, unknown>>((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 <T>(expression: string): Promise<T> => {
|
||||
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: <T>(expression: string, options: WaitOptions) =>
|
||||
waitUntil(() => evaluate<T>(expression), options),
|
||||
screenshot: async () => {
|
||||
const reply = await send('Page.captureScreenshot', { format: 'png' });
|
||||
return Buffer.from(String(reply.data), 'base64');
|
||||
},
|
||||
close: () => socket.close(),
|
||||
};
|
||||
}
|
||||
@@ -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<void>;
|
||||
};
|
||||
|
||||
// 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<E2eDisplay> {
|
||||
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<E2eDisplay> {
|
||||
// -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),
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown> };
|
||||
|
||||
export type FakeAnki = {
|
||||
url: string;
|
||||
/** Every AnkiConnect action received so far, with `multi` batches flattened. */
|
||||
requests: AnkiRequest[];
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
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<string, unknown> {
|
||||
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<FakeAnki> {
|
||||
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');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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') },
|
||||
]);
|
||||
}
|
||||
@@ -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<unknown>;
|
||||
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<ChildProcess> {
|
||||
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<net.Socket | null> {
|
||||
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<MpvClient> {
|
||||
const socket = await waitUntil(() => tryConnect(socketPath), {
|
||||
description: `mpv IPC socket ${socketPath}`,
|
||||
});
|
||||
const pending = new Map<number, (reply: MpvReply) => 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(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
|
||||
export type EnvDelta = { set: Record<string, string>; 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<void> {
|
||||
// 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);
|
||||
}
|
||||
@@ -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<CdpPage>;
|
||||
/** Evaluates in the main process; reach modules via `process.mainModule.require`. */
|
||||
mainProcess: () => Promise<CdpPage>;
|
||||
/** Runs a CLI command (e.g. `--mine-sentence`) against the running instance. */
|
||||
runAppCommand: (...args: string[]) => Promise<void>;
|
||||
dispose: () => Promise<void>;
|
||||
};
|
||||
|
||||
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<void> {
|
||||
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<CdpPage> {
|
||||
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<E2eSession> {
|
||||
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> | void> = [];
|
||||
const dispose = async (): Promise<void> => {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type WaitOptions = {
|
||||
description: string;
|
||||
timeoutMs?: number;
|
||||
intervalMs?: number;
|
||||
};
|
||||
|
||||
export type Truthy<T> = Exclude<T, false | 0 | '' | null | undefined>;
|
||||
|
||||
/** Polls `probe` until it returns a truthy value, then returns that value. */
|
||||
export async function waitUntil<T>(
|
||||
probe: () => T | Promise<T>,
|
||||
{ description, timeoutMs = 15_000, intervalMs = 100 }: WaitOptions,
|
||||
): Promise<Truthy<T>> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const value = await probe();
|
||||
if (value) return value as Truthy<T>;
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for ${description}`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
}
|
||||
@@ -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<Started> | undefined;
|
||||
|
||||
function useSession(): Promise<Started> {
|
||||
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<RenderedToken[]> {
|
||||
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<RenderedToken[] | false>(
|
||||
`(() => {
|
||||
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<string>(
|
||||
`(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<string>(
|
||||
`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<string>('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, /天気/);
|
||||
},
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -12,6 +12,7 @@ paths=(
|
||||
".prettierrc.json"
|
||||
".github"
|
||||
"build"
|
||||
"e2e"
|
||||
"launcher"
|
||||
"scripts"
|
||||
"src"
|
||||
|
||||
@@ -35,6 +35,12 @@ export const testLanes: Record<string, TestLane> = {
|
||||
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'],
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user