feat(plugins): add JavaScript plugin system with overlay panel (#303)

This commit is contained in:
sudacode authored and GitHub committed 2026-10-31 19:09:54 -07:00
1 parent 90fc7cf940
commit a9a4c4f083
110 files changed
+5897 -344

No files matched your search

+39 -21
View File
@@ -28,6 +28,8 @@ 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;
/** Files written before launch, keyed by path relative to the config dir (e.g. plugins). */
files?: Record<string, string>;
};
export type AppTarget = {
@@ -57,6 +59,8 @@ export type E2eSession = {
mainProcess: () => Promise<CdpPage>;
/** Runs a CLI command (e.g. `--mine-sentence`) against the running instance. */
runAppCommand: (...args: string[]) => Promise<void>;
/** After loading a file: waits for the app's autoplay resume, then leaves mpv paused. */
settleAfterResume: () => Promise<void>;
dispose: () => Promise<void>;
};
@@ -155,6 +159,30 @@ async function openPage(cdpPort: number, urlPart: string): Promise<CdpPage> {
return page;
}
// mpv starts paused and the app resumes each newly loaded file once the overlay
// and tokenizer are ready, so that resume marks the file as ready. Scenarios
// expect a paused player, and the app retries its resume for a moment, so keep
// pausing until the pause has held for longer than that retry window.
async function settleAfterResume(mpv: MpvClient): Promise<void> {
await waitUntil(async () => (await mpv.command('get_property', 'pause')) === false, {
description: 'SubMiner to resume playback once the file is ready',
timeoutMs: 30_000,
});
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 the resume', timeoutMs: 40_000 },
);
}
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.');
@@ -193,6 +221,15 @@ export async function startE2eSession(options: E2eSessionOptions = {}): Promise<
const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {});
fs.writeFileSync(path.join(userDataDir, 'config.jsonc'), JSON.stringify(config, null, 2));
for (const [relativePath, content] of Object.entries(options.files ?? {})) {
const filePath = path.resolve(userDataDir, relativePath);
const inside = path.relative(userDataDir, filePath);
if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) {
throw new Error(`E2E file path must stay inside the config dir: ${relativePath}`);
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
const env: EnvDelta = {
set: {
@@ -287,27 +324,7 @@ export async function startE2eSession(options: E2eSessionOptions = {}): Promise<
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 },
);
await settleAfterResume(mpv);
const pages: CdpPage[] = [];
teardown.push(() => pages.forEach((page) => page.close()));
@@ -331,6 +348,7 @@ export async function startE2eSession(options: E2eSessionOptions = {}): Promise<
return page;
},
runAppCommand: (...args) => runAppCommand(target, args),
settleAfterResume: () => settleAfterResume(mpv),
dispose,
};
} catch (error) {