import { once } from 'node:events'; import http from 'node:http'; import type { AddressInfo } from 'node:net'; export type AnkiRequest = { action: string; params: Record }; export type FakeAnki = { url: string; /** Every AnkiConnect action received so far, with `multi` batches flattened. */ requests: AnkiRequest[]; stop: () => Promise; }; export const FAKE_ANKI_DECK = 'E2E'; export const FAKE_ANKI_MODEL = 'Lapis'; const MODEL_FIELDS = [ 'Expression', 'ExpressionFurigana', 'ExpressionReading', 'ExpressionAudio', 'SelectionText', 'MainDefinition', 'Sentence', 'SentenceFurigana', 'SentenceAudio', 'Picture', 'Glossary', 'IsWordAndSentenceCard', 'IsClickCard', 'IsSentenceCard', 'IsAudioCard', 'MiscInfo', ]; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } // A recording AnkiConnect stand-in: one deck and one Lapis-shaped note type. // Added notes (with later `updateNoteFields` writes) are echoed back by `notesInfo` // so the app can resolve their fields; scenarios still assert on `requests`. export async function startFakeAnki(): Promise { const requests: AnkiRequest[] = []; const addedNotes = new Map>(); let nextNoteId = 1000; const handle = (body: unknown): unknown => { if (!isRecord(body) || typeof body.action !== 'string') return null; const params = isRecord(body.params) ? body.params : {}; if (body.action === 'multi') { return Array.isArray(params.actions) ? params.actions.map(handle) : []; } requests.push({ action: body.action, params }); switch (body.action) { case 'version': return 6; case 'deckNames': return [FAKE_ANKI_DECK]; case 'modelNames': return [FAKE_ANKI_MODEL]; case 'modelFieldNames': return MODEL_FIELDS; case 'addNote': { const noteId = nextNoteId++; const note = isRecord(params.note) ? params.note : {}; addedNotes.set(noteId, isRecord(note.fields) ? { ...note.fields } : {}); return noteId; } case 'updateNoteFields': { const note = isRecord(params.note) ? params.note : {}; const fields = typeof note.id === 'number' ? addedNotes.get(note.id) : undefined; if (fields && isRecord(note.fields)) Object.assign(fields, note.fields); return null; } case 'notesInfo': { // Like AnkiConnect: one entry per requested id, `{}` for unknown notes. const ids = Array.isArray(params.notes) ? params.notes : []; return ids.map((id) => { const fields = typeof id === 'number' ? addedNotes.get(id) : undefined; if (!fields) return {}; return { noteId: id, modelName: FAKE_ANKI_MODEL, tags: [], fields: Object.fromEntries( MODEL_FIELDS.map((name, order) => [ name, { value: String(fields[name] ?? ''), order }, ]), ), }; }); } case 'findNotes': return []; case 'storeMediaFile': return params.filename ?? null; default: return null; } }; const server = http.createServer((request, response) => { response.setHeader('content-type', 'application/json'); // GET exposes the request log so the session CLI can read it from outside. if (request.method === 'GET') { response.end(JSON.stringify(requests)); return; } const chunks: Buffer[] = []; request.on('data', (chunk: Buffer) => chunks.push(chunk)); request.on('end', () => { let body: unknown = null; try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { // Malformed bodies get the same null result AnkiConnect gives unknown actions. } response.end(JSON.stringify({ result: handle(body), error: null })); }); }); server.listen(0, '127.0.0.1'); await once(server, 'listening'); const { port } = server.address() as AddressInfo; return { url: `http://127.0.0.1:${port}`, requests, stop: async () => { server.closeAllConnections(); server.close(); await once(server, 'close'); }, }; }