Files
SubMiner/scripts/check-hachidori-parser.cjs
sudacode c2dac537da fix(overlay): keep popup pause working with Hachidori 0.4.0
Hachidori 0.4.0 closed its popup shadow root, so isHachidoriPopupOpen always
returned false. The popup never took over the subtitle hover pause, and moving
off the subtitle resumed playback with the popup open. Popup key and controller
routing read the same check. The sidebar kept working because its hold follows
the attention event instead of the pane.

The fork now publishes the pane state on the host element, and the renderer
reads that attribute instead of upstream's shadow root and CSS class. The unit
stubs modelled an open root and hid this, so test:dictionary:electron now drives
the real reader and asserts the attribute follows the pane.
2026-11-04 22:46:25 -08:00

296 lines
11 KiB
JavaScript

// Run after bun run build. This uses a temporary profile and small local ZIPs.
const { app, BrowserWindow, protocol, session } = require('electron');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const http = require('node:http');
const root = path.resolve(__dirname, '..');
const parser = require(path.join(root, 'dist/core/services/tokenizer/yomitan-parser-runtime.js'));
const { writeStoredZip } = require(path.join(root, 'dist/shared/stored-zip.js'));
const { HACHIDORI_POPUP_OPEN_SELECTOR } = require(
path.join(root, 'dist/renderer/yomitan-popup.js'),
);
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-hachidori-parser-'));
protocol.registerSchemesAsPrivileged([
{
scheme: 'chrome-extension',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
bypassCSP: true,
},
},
]);
app.setPath('userData', profile);
app.setAppPath(root);
app.disableHardwareAcceleration();
app.on('window-all-closed', () => {});
const deadline = setTimeout(() => {
console.error('Hachidori parser verification timed out', profile);
app.exit(1);
}, 120_000);
function fixture(name, title, bankName, entries, frequencyMode = 'rank-based') {
const zipPath = path.join(profile, name + '.zip');
writeStoredZip(zipPath, [
{
name: 'index.json',
data: Buffer.from(JSON.stringify({ title, revision: '1', format: 3, frequencyMode })),
},
{ name: bankName, data: Buffer.from(JSON.stringify(entries)) },
]);
return zipPath;
}
// The renderer's popup auto-pause and key routing read this marker because the
// reader's shadow root is closed. Drives the real reader with real input, since
// stubbed DOM tests cannot notice an upstream merge that changes what it exposes.
async function assertPopupOpenMarkerFollowsPane(targetSession) {
const server = http.createServer((_request, response) => {
response.setHeader('Content-Type', 'text/html; charset=utf-8');
response.end(
'<!doctype html><p id="word" style="font-size:48px;margin:300px 0 0 100px">食べた</p>',
);
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const page = new BrowserWindow({
width: 900,
height: 600,
show: true,
webPreferences: { session: targetSession, contextIsolation: true, nodeIntegration: false },
});
try {
await page.loadURL(`http://127.0.0.1:${server.address().port}/`);
const { webContents } = page;
const word = await webContents.executeJavaScript(
`(() => { const { x, y, height } = document.getElementById('word').getBoundingClientRect(); return { x, y, height }; })()`,
);
const repeatUntilMarker = async (expected, act) => {
const query = `document.querySelector(${JSON.stringify(HACHIDORI_POPUP_OPEN_SELECTOR)}) !== null`;
for (let attempt = 0; attempt < 100; attempt += 1) {
// Content scripts load asynchronously, so keep acting until the reader reacts.
act(attempt);
await new Promise((resolve) => setTimeout(resolve, 100));
if ((await webContents.executeJavaScript(query)) === expected) return;
}
assert.fail(`Hachidori popup-open marker never became ${expected}`);
};
page.focus();
webContents.focus();
await repeatUntilMarker(true, (attempt) =>
webContents.sendInputEvent({
type: 'mouseMove',
x: Math.round(word.x + 24) + (attempt % 2),
y: Math.round(word.y + word.height / 2),
}),
);
await repeatUntilMarker(false, () => {
webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' });
});
} finally {
page.destroy();
await new Promise((resolve) => server.close(resolve));
}
}
app
.whenReady()
.then(async () => {
const targetSession = session.fromPartition('persist:hachidori-check');
const extension = await targetSession.extensions.loadExtension(
path.join(root, 'dist/hachidori'),
{ allowFileAccess: true },
);
// Keep a host window alive while the dictionary importer opens/closes its
// temporary settings windows, matching the running app's window lifecycle.
const host = new BrowserWindow({
show: false,
webPreferences: { session: targetSession, contextIsolation: true, nodeIntegration: false },
});
await host.loadURL(`chrome-extension://${extension.id}/settings.html`);
let parserWindow = null;
let readyPromise = null;
let initPromise = null;
const deps = {
getYomitanExt: () => extension,
getYomitanSession: () => targetSession,
getYomitanParserWindow: () => parserWindow,
setYomitanParserWindow: (value) => {
parserWindow = value;
},
getYomitanParserReadyPromise: () => readyPromise,
setYomitanParserReadyPromise: (value) => {
readyPromise = value;
},
getYomitanParserInitPromise: () => initPromise,
setYomitanParserInitPromise: (value) => {
initPromise = value;
},
};
const errors = [];
const logger = {
error: (...args) => {
errors.push(args);
console.error(...args);
},
};
assert.deepEqual(await parser.getYomitanDictionaryInfo(deps, logger), []);
assert.equal(
await parser.syncYomitanDefaultAnkiServer('http://127.0.0.1:18766', deps, logger, {
forceOverride: true,
deck: 'Test Mining',
}),
true,
);
// Exercise discovery and revisioned options writes in the real extension,
// without creating a note or changing the user's Anki collection.
const fields = ['Term', 'Reading', 'Definition', 'Context', 'Pronunciation', 'Image'];
const metadataServer = http.createServer((request, response) => {
let body = '';
request.on('data', (chunk) => {
body += chunk;
});
request.on('end', () => {
const message = JSON.parse(body);
assert.equal(message.action, 'multi');
const result = message.params.actions.map(({ action }) => ({
result:
action === 'deckNames'
? ['Test Mining']
: action === 'modelNames'
? ['Custom Japanese']
: fields,
error: null,
}));
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify({ result, error: null }));
});
});
await new Promise((resolve) => metadataServer.listen(0, '127.0.0.1', resolve));
try {
const url = `http://127.0.0.1:${metadataServer.address().port}`;
const ankiConfig = {
tags: ['SubMiner', 'Autofill'],
fields: {
word: 'Term',
sentence: 'Context',
wordAudio: 'Pronunciation',
image: 'Image',
},
};
assert.equal(
await parser.syncYomitanDefaultAnkiServer(url, deps, logger, {
forceOverride: true,
deck: 'Test Mining',
ankiConfig,
}),
true,
);
const anki = await parserWindow.webContents.executeJavaScript(
`(async () => (await chrome.storage.local.get('options')).options.anki)()`,
);
assert.equal(anki.model, 'Custom Japanese');
assert.equal(anki.deck, 'Test Mining');
assert.deepEqual(anki.tags, [...ankiConfig.tags, 'hachidori']);
assert.equal(anki.fieldTemplates.Term.value, '{expression}');
assert.equal(anki.fieldTemplates.Pronunciation.value, '{audio}');
assert.equal(anki.fieldTemplates.Context.value, '{sentence}');
assert.deepEqual(anki.templates[0].fieldTemplates, anki.fieldTemplates);
console.log('Hachidori Anki auto-population passed with an empty dictionary library');
} finally {
await new Promise((resolve) => metadataServer.close(resolve));
}
const archives = [
fixture('terms', 'SubMiner Test Terms', 'term_bank_1.json', [
['食べる', 'たべる', '', 'v1', 0, ['to eat'], 1, ''],
]),
fixture('names', 'SubMiner Character Dictionary (AniList 1)', 'term_bank_1.json', [
['ミナト', 'みなと', '', 'n', 0, ['name'], 1, ''],
]),
fixture('frequency', 'SubMiner Test Frequency', 'term_meta_bank_1.json', [
['食べる', 'freq', { reading: 'たべる', frequency: 42 }],
['頻度だけ', 'freq', { reading: 'ひんどだけ', frequency: 120 }],
['頻度だけ', 'freq', { reading: 'べつのよみ', frequency: 250 }],
['頻度だけ', 'freq', 17],
]),
fixture(
'occurrences',
'SubMiner Test Occurrences',
'term_meta_bank_1.json',
[['頻度だけ', 'freq', 9000]],
'occurrence-based',
),
];
for (const archive of archives)
assert.equal(await parser.importYomitanDictionaryFromZip(archive, deps, logger), true);
const dictionaries = await parser.getYomitanDictionaryInfo(deps, logger);
assert.equal(dictionaries.length, 4);
parser.clearYomitanParserCachesForWindow(parserWindow);
const tokens = await parser.requestYomitanScanTokens('ミナト 食べた', deps, logger, {
includeNameMatchMetadata: true,
currentCharacterDictionaryMediaId: 1,
});
assert.equal(tokens[0].isNameMatch, true);
assert.equal(tokens[1].headword, '食べる');
assert.equal(tokens[1].reading, 'たべた');
assert.equal(tokens[1].startPos, 4);
assert.equal(tokens[1].endPos, 7);
assert.equal(tokens[1].frequencyRank, 42);
assert.deepEqual(tokens[1].wordClasses, ['v1']);
const exact = await parser.requestYomitanTermFrequencies(
[{ term: '食べる', reading: 'たべる' }],
deps,
logger,
);
assert.equal(exact.length, 1);
assert.equal(exact[0].frequency, 42);
assert.equal(exact[0].reading, 'たべる');
assert.equal(exact[0].hasReading, false);
const otherReading = await parser.requestYomitanTermFrequencies(
[{ term: '食べる', reading: 'べつのよみ' }],
deps,
logger,
);
// The shared frequency pipeline retries a missing reading as a term-only query.
assert.equal(otherReading[0]?.frequency, 42);
const unmatched = await parser.requestYomitanTermFrequencies(
[{ term: '頻度だけ', reading: null }],
deps,
logger,
);
assert.deepEqual(unmatched, []);
assert.equal(await parser.getYomitanCurrentAnkiDeckName(deps, logger), 'Test Mining');
assert.equal((await targetSession.extensions.getAllExtensions()).length, 1);
await assertPopupOpenMarkerFollowsPane(targetSession);
assert.equal(
await parser.syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', deps, logger),
true,
);
const directSettings = await parser.getYomitanSettingsFull(deps, logger);
assert.equal(directSettings.profiles[0].options.anki.server, 'http://127.0.0.1:8765');
const proxyState = await host.webContents.executeJavaScript(
"chrome.storage.local.get('subminerAnkiProxyUrl')",
);
assert.equal(proxyState.subminerAnkiProxyUrl, null);
for (const entry of await parser.getYomitanDictionaryInfo(deps, logger)) {
assert.equal(await parser.deleteYomitanDictionaryByTitle(entry.title, deps, logger), true);
}
assert.deepEqual(await parser.getYomitanDictionaryInfo(deps, logger), []);
assert.equal(errors.length, 0);
console.log(
'PASS Hachidori native import, scanner, character names, term-entry API frequencies, settings and removal',
);
clearTimeout(deadline);
app.exit(0);
})
.catch((error) => {
console.error(error);
clearTimeout(deadline);
app.exit(1);
});