mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-20 17:01:24 -07:00
* 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
218 lines
8.7 KiB
TypeScript
218 lines
8.7 KiB
TypeScript
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, /天気/);
|
|
},
|
|
);
|