diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index aa4289e..639c2f9 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -438,6 +438,40 @@ test('stats command aborts pending response wait when app exits before startup r assert.equal(aborted, true); }); +test('stats command aborts pending response wait when attached app fails to spawn', async () => { + const context = createContext(); + context.args.stats = true; + const spawnError = new Error('spawn failed'); + let aborted = false; + + await assert.rejects( + async () => { + await runStatsCommand(context, { + createTempDir: () => '/tmp/subminer-stats-test', + joinPath: (...parts) => parts.join('/'), + runAppCommandAttached: async () => { + throw spawnError; + }, + waitForStatsResponse: async (_responsePath, signal) => + await new Promise((resolve) => { + signal?.addEventListener( + 'abort', + () => { + aborted = true; + resolve({ ok: false, error: 'aborted' }); + }, + { once: true }, + ); + }), + removeDir: () => {}, + }); + }, + (error: unknown) => error === spawnError, + ); + + assert.equal(aborted, true); +}); + test('stats cleanup command aborts pending response wait when app exits before startup response', async () => { const context = createContext(); context.args.stats = true; diff --git a/launcher/commands/stats-command.ts b/launcher/commands/stats-command.ts index 6edb6a2..b8e98a4 100644 --- a/launcher/commands/stats-command.ts +++ b/launcher/commands/stats-command.ts @@ -34,6 +34,11 @@ type StatsResponseWait = { promise: Promise<{ kind: 'response'; response: StatsCommandResponse }>; }; +type StatsStartupResult = + | { kind: 'response'; response: StatsCommandResponse } + | { kind: 'exit'; status: number } + | { kind: 'spawn-error'; error: unknown }; + const defaultDeps: StatsCommandDeps = { createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)), joinPath: (...parts) => path.join(...parts), @@ -72,11 +77,19 @@ async function performStartupHandshake( attachedExitPromise: Promise, ): Promise { const responseWait = createResponseWait(); - const startupResult = await Promise.race([ + const startupResult = await Promise.race([ responseWait.promise, - attachedExitPromise.then((status) => ({ kind: 'exit' as const, status })), + attachedExitPromise.then( + (status) => ({ kind: 'exit' as const, status }), + (error) => ({ kind: 'spawn-error' as const, error }), + ), ]); + if (startupResult.kind === 'spawn-error') { + responseWait.controller.abort(); + throw startupResult.error; + } + if (startupResult.kind === 'exit') { if (startupResult.status !== 0) { responseWait.controller.abort(); diff --git a/src/anki-integration/known-word-cache.test.ts b/src/anki-integration/known-word-cache.test.ts index 25bf14d..aacf46b 100644 --- a/src/anki-integration/known-word-cache.test.ts +++ b/src/anki-integration/known-word-cache.test.ts @@ -263,6 +263,41 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited } }); +test('KnownWordCacheManager skips malformed note info without fields', async () => { + const config: AnkiConnectConfig = { + fields: { + word: 'Word', + }, + knownWords: { + highlightEnabled: true, + }, + }; + const { manager, clientState, cleanup } = createKnownWordCacheHarness(config); + + try { + clientState.findNotesResult = [1, 2]; + clientState.notesInfoResult = [ + { + noteId: 1, + fields: undefined as unknown as Record, + }, + { + noteId: 2, + fields: { + Word: { value: '猫' }, + }, + }, + ]; + + await manager.refresh(true); + + assert.equal(manager.isKnownWord('猫'), true); + assert.equal(manager.isKnownWord('犬'), false); + } finally { + cleanup(); + } +}); + test('KnownWordCacheManager preserves cache state key captured before refresh work', async () => { const config: AnkiConnectConfig = { fields: { diff --git a/src/anki-integration/known-word-cache.ts b/src/anki-integration/known-word-cache.ts index 176364b..24433d3 100644 --- a/src/anki-integration/known-word-cache.ts +++ b/src/anki-integration/known-word-cache.ts @@ -478,7 +478,14 @@ export class KnownWordCacheManager { const notesInfoResult = (await this.deps.client.notesInfo(chunk)) as unknown[]; const chunkInfos = notesInfoResult as KnownWordCacheNoteInfo[]; for (const noteInfo of chunkInfos) { - if (!noteInfo || !Number.isInteger(noteInfo.noteId) || noteInfo.noteId <= 0) { + if ( + !noteInfo || + !Number.isInteger(noteInfo.noteId) || + noteInfo.noteId <= 0 || + typeof noteInfo.fields !== 'object' || + noteInfo.fields === null || + Array.isArray(noteInfo.fields) + ) { continue; } noteInfos.push(noteInfo);