diff --git a/backlog/tasks/task-242 - Fix-stats-server-Bun-fallback-in-coverage-lane.md b/backlog/tasks/task-242 - Fix-stats-server-Bun-fallback-in-coverage-lane.md new file mode 100644 index 0000000..319fa16 --- /dev/null +++ b/backlog/tasks/task-242 - Fix-stats-server-Bun-fallback-in-coverage-lane.md @@ -0,0 +1,28 @@ +--- +id: TASK-242 +title: Fix stats server Bun fallback in coverage lane +status: In Progress +assignee: [] +created_date: '2026-03-29 07:31' +labels: + - ci + - bug +milestone: cleanup +dependencies: [] +references: + - 'PR #36' +priority: high +--- + +## Description + + +Coverage CI fails when `startStatsServer` reaches the Bun server seam under the maintained source lane. Add a runtime fallback that works when `Bun.serve` is unavailable and keep the stats-server startup path testable. + + +## Acceptance Criteria + +- [ ] #1 `bun run test:coverage:src` passes in GitHub CI +- [ ] #2 `startStatsServer` uses `Bun.serve` when present and a Node server fallback otherwise +- [ ] #3 Regression coverage exists for the fallback startup path + diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index 591657b..986a951 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -1,8 +1,9 @@ import { Hono } from 'hono'; -import { serve } from '@hono/node-server'; import type { ImmersionTrackerService } from './immersion-tracker-service.js'; +import http, { type IncomingMessage, type ServerResponse } from 'node:http'; import { basename, extname, resolve, sep } from 'node:path'; import { readFileSync, existsSync, statSync } from 'node:fs'; +import { Readable } from 'node:stream'; import { MediaGenerator } from '../../media-generator.js'; import { AnkiConnectClient } from '../../anki-connect.js'; import type { AnkiConnectConfig } from '../../types.js'; @@ -60,6 +61,71 @@ function resolveStatsNoteFieldName( return null; } +function toFetchHeaders(headers: IncomingMessage['headers']): Headers { + const fetchHeaders = new Headers(); + for (const [name, value] of Object.entries(headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const entry of value) { + fetchHeaders.append(name, entry); + } + continue; + } + fetchHeaders.set(name, value); + } + return fetchHeaders; +} + +function toFetchRequest(req: IncomingMessage): Request { + const method = req.method ?? 'GET'; + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); + const init: RequestInit & { duplex?: 'half' } = { + method, + headers: toFetchHeaders(req.headers), + }; + + if (method !== 'GET' && method !== 'HEAD') { + init.body = Readable.toWeb(req) as BodyInit; + init.duplex = 'half'; + } + + return new Request(url, init); +} + +async function writeFetchResponse(res: ServerResponse, response: Response): Promise { + res.statusCode = response.status; + response.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + + const body = await response.arrayBuffer(); + res.end(Buffer.from(body)); +} + +function startNodeHttpServer( + app: Hono, + config: StatsServerConfig, +): { close: () => void } { + const server = http.createServer((req, res) => { + void (async () => { + try { + await writeFetchResponse(res, await app.fetch(toFetchRequest(req))); + } catch { + res.statusCode = 500; + res.end('Internal Server Error'); + } + })(); + }); + + server.listen(config.port, '127.0.0.1'); + + return { + close: () => { + server.close(); + }, + }; +} + /** Load known words cache from disk into a Set. Returns null if unavailable. */ function loadKnownWordsSet(cachePath: string | undefined): Set | null { if (!cachePath || !existsSync(cachePath)) return null; @@ -1017,25 +1083,19 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void }; }; - const server = bunRuntime.Bun?.serve - ? bunRuntime.Bun.serve({ - fetch: app.fetch, - port: config.port, - hostname: '127.0.0.1', - }) - : serve({ - fetch: app.fetch, - port: config.port, - hostname: '127.0.0.1', - }); + if (bunRuntime.Bun?.serve) { + const server = bunRuntime.Bun.serve({ + fetch: app.fetch, + port: config.port, + hostname: '127.0.0.1', + }); - return { - close: () => { - if ('stop' in server) { + return { + close: () => { server.stop(); - } else { - server.close(); - } - }, - }; + }, + }; + } + + return startNodeHttpServer(app, config); } diff --git a/src/main/runtime/setup-window-factory.ts b/src/main/runtime/setup-window-factory.ts index bdccd49..18d4002 100644 --- a/src/main/runtime/setup-window-factory.ts +++ b/src/main/runtime/setup-window-factory.ts @@ -18,9 +18,9 @@ function createSetupWindowHandler( title: config.title, show: true, autoHideMenuBar: true, - resizable: config.resizable, - minimizable: config.minimizable, - maximizable: config.maximizable, + ...(config.resizable === undefined ? {} : { resizable: config.resizable }), + ...(config.minimizable === undefined ? {} : { minimizable: config.minimizable }), + ...(config.maximizable === undefined ? {} : { maximizable: config.maximizable }), webPreferences: { nodeIntegration: false, contextIsolation: true,