fix(ci): restore coverage lane compatibility

This commit is contained in:
2026-03-29 00:33:59 -07:00
parent 4ff8529744
commit 2fc4fabde7
3 changed files with 111 additions and 23 deletions

View File

@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #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
<!-- AC:END -->

View File

@@ -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<void> {
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<string> | 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);
}

View File

@@ -18,9 +18,9 @@ function createSetupWindowHandler<TWindow>(
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,