Files
SubMiner/src/core/services/tokenizer/yomitan-scan-test-harness.ts
T
sudacode e6dc9dfec5 test: clone VM results across Electron boundaries
- Clone tokenizer VM results into the host realm
- Expect null-prototype profile maps in IPC validation tests
2026-09-11 01:55:32 -07:00

169 lines
5.5 KiB
TypeScript

// Shared harness for the Yomitan parser-runtime and scan-runtime tests: fake
// parser-window deps whose injected scripts run in a vm context, plus the
// backend stubs the scanner tests drive them with. Kept out of the test files
// so the runtime tests and the in-page scanner tests can share one setup.
import * as vm from 'node:vm';
export function createDeps(
executeJavaScript: (script: string) => Promise<unknown>,
options?: {
createYomitanExtensionWindow?: (pageName: string) => Promise<unknown>;
},
) {
const parserWindow = {
isDestroyed: () => false,
webContents: {
executeJavaScript: async (script: string) => await executeJavaScript(script),
},
};
return {
getYomitanExt: () => ({ id: 'ext-id' }) as never,
getYomitanParserWindow: () => parserWindow as never,
setYomitanParserWindow: () => undefined,
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => undefined,
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => undefined,
createYomitanExtensionWindow: options?.createYomitanExtensionWindow as never,
};
}
function createYomitanScriptSandbox(handler: (action: string, params: unknown) => unknown) {
return {
chrome: {
runtime: {
lastError: null,
sendMessage: (
payload: { action?: string; params?: unknown },
callback: (response: { result?: unknown; error?: { message?: string } }) => void,
) => {
try {
callback({ result: handler(payload.action ?? '', payload.params) });
} catch (error) {
callback({ error: { message: (error as Error).message } });
}
},
},
},
Array,
Error,
JSON,
Map,
Math,
Number,
Object,
Promise,
RegExp,
Set,
String,
};
}
export async function runInjectedYomitanScript(
script: string,
handler: (action: string, params: unknown) => unknown,
): Promise<unknown> {
// Clone results into the host realm, matching Electron's process boundary.
return structuredClone(await vm.runInNewContext(script, createYomitanScriptSandbox(handler)));
}
// Persistent page context shared across executeJavaScript calls, matching the
// real parser window: the scan runtime is installed once via
// globalThis.__subminerYomitanScan and per-line calls reuse it (and its
// cross-line termsFind cache).
function createPersistentYomitanScriptRunner(
handler: (action: string, params: unknown) => unknown,
): (script: string) => Promise<unknown> {
const context = vm.createContext(createYomitanScriptSandbox(handler));
// Clone results into the host realm, matching Electron's process boundary.
return async (script: string) => structuredClone(await vm.runInContext(script, context));
}
// Deps whose parser window executes every injected script (profile metadata,
// scan runtime install, per-line scan calls, parseText fallback) inside one
// persistent vm context, dispatching backend actions to `handler`.
export function createScanDeps(
handler: (action: string, params: unknown) => unknown,
options?: { onScript?: (script: string) => void },
) {
const runScript = createPersistentYomitanScriptRunner(handler);
return createDeps(async (script) => {
options?.onScript?.(script);
return await runScript(script);
});
}
export function countTermsFindLookups(lookups: string[], prefix: string): number {
return lookups.filter((lookupText) => lookupText.startsWith(prefix)).length;
}
// Backend stub for the greedy name pre-pass: one character name (ミナト) in a
// line of ordinary words, with the SubMiner character dictionary enabled.
export const NAME_SCAN_WORDS: Array<[string, string, string, boolean]> = [
['ミナト', 'ミナト', 'みなと', true],
['は', 'は', 'は', false],
['まだ', 'まだ', 'まだ', false],
['学校', '学校', 'がっこう', false],
['に', 'に', 'に', false],
['いない', 'いる', 'いる', false],
];
export function createNameScanDeps(
lookups: string[],
words: Array<[string, string, string, boolean]> = NAME_SCAN_WORDS,
) {
return createScanDeps((action, params) => {
if (action === 'optionsGetFull') {
return {
profileCurrent: 0,
profiles: [
{
options: {
scanning: { length: 40 },
dictionaries: [
{ name: 'JMdict', enabled: true, id: 0 },
{
name: 'SubMiner Character Dictionary (AniList 1)',
enabled: true,
id: 1,
},
],
},
},
],
};
}
if (action === 'getDictionaryInfo') {
return [];
}
if (action !== 'termsFind') {
throw new Error(`unexpected action: ${action}`);
}
const text = (params as { text?: string } | undefined)?.text ?? '';
lookups.push(text);
for (const [surface, term, reading, isName] of words) {
if (text.startsWith(surface)) {
return {
originalTextLength: surface.length,
dictionaryEntries: [
{
headwords: [
{
term,
reading,
sources: [{ originalText: surface, isPrimary: true, matchType: 'exact' }],
},
],
definitions: [
{ dictionary: isName ? 'SubMiner Character Dictionary (AniList 1)' : 'JMdict' },
],
},
],
};
}
}
return { originalTextLength: 0, dictionaryEntries: [] };
});
}