mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
feat(stats): add live-action library support and harden stats runtime
- Add TMDB metadata, linking, filtering, and live-action library merging - Harden stats request validation, lifecycle handling, and compiled runtime coverage - Fix Anki media synchronization and configuration validation
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Release builds carry a project-owned TMDB key so live-action lookups work
|
||||
* without user setup. The key is injected from the SUBMINER_TMDB_API_KEY
|
||||
* environment variable at build time (a GitHub Actions secret in CI) and never
|
||||
* lives in the repository. The runtime reader is
|
||||
* src/core/services/tmdb/bundled-api-key.ts; keep the file name in sync.
|
||||
*/
|
||||
export const BUNDLED_INTEGRATION_KEYS_FILENAME = 'bundled-integration-keys.json';
|
||||
export const TMDB_API_KEY_ENV = 'SUBMINER_TMDB_API_KEY';
|
||||
|
||||
/**
|
||||
* Write the bundled keys file into `distDir`, or remove a stale one when no
|
||||
* key is present so a keyless build never ships an older key by accident.
|
||||
* Returns the names of the keys staged.
|
||||
*/
|
||||
export function stageBundledIntegrationKeys(distDir, env = process.env) {
|
||||
const outputPath = path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME);
|
||||
const tmdbApiKey = env[TMDB_API_KEY_ENV]?.trim() ?? '';
|
||||
if (!tmdbApiKey) {
|
||||
fs.rmSync(outputPath, { force: true });
|
||||
return [];
|
||||
}
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify({ tmdbApiKey })}\n`, { mode: 0o644 });
|
||||
return ['tmdb'];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
BUNDLED_INTEGRATION_KEYS_FILENAME,
|
||||
stageBundledIntegrationKeys,
|
||||
} from './bundled-integration-keys.mjs';
|
||||
|
||||
function withDistDir(work: (distDir: string) => void): void {
|
||||
const distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-bundled-keys-'));
|
||||
try {
|
||||
work(distDir);
|
||||
} finally {
|
||||
fs.rmSync(distDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('stageBundledIntegrationKeys writes the TMDB key from the environment', () => {
|
||||
withDistDir((distDir) => {
|
||||
const staged = stageBundledIntegrationKeys(distDir, { SUBMINER_TMDB_API_KEY: ' abc123 ' });
|
||||
assert.deepEqual(staged, ['tmdb']);
|
||||
const written = JSON.parse(
|
||||
fs.readFileSync(path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME), 'utf8'),
|
||||
);
|
||||
assert.deepEqual(written, { tmdbApiKey: 'abc123' });
|
||||
});
|
||||
});
|
||||
|
||||
test('stageBundledIntegrationKeys removes a stale file when the variable is unset', () => {
|
||||
withDistDir((distDir) => {
|
||||
const outputPath = path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME);
|
||||
fs.writeFileSync(outputPath, '{"tmdbApiKey":"old"}');
|
||||
assert.deepEqual(stageBundledIntegrationKeys(distDir, {}), []);
|
||||
assert.equal(fs.existsSync(outputPath), false);
|
||||
assert.deepEqual(stageBundledIntegrationKeys(distDir, { SUBMINER_TMDB_API_KEY: ' ' }), []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { createServer } from 'node:net';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const START_TIMEOUT_MS = 15_000;
|
||||
const STOP_TIMEOUT_MS = 5_000;
|
||||
const POLL_INTERVAL_MS = 25;
|
||||
const MAX_CHILD_OUTPUT_BYTES = 64 * 1024;
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRootArgIndex = process.argv.indexOf('--repo-root');
|
||||
const repoRoot = resolve(
|
||||
repoRootArgIndex === -1 ? join(scriptDir, '..') : (process.argv[repoRootArgIndex + 1] ?? ''),
|
||||
);
|
||||
const paths = {
|
||||
mainEntry: join(repoRoot, 'dist', 'main-entry.js'),
|
||||
daemonEntry: join(repoRoot, 'dist', 'stats-daemon-runner.js'),
|
||||
statsServer: join(repoRoot, 'dist', 'core', 'services', 'stats-server.js'),
|
||||
tracker: join(repoRoot, 'dist', 'core', 'services', 'immersion-tracker-service.js'),
|
||||
statsIndex: join(repoRoot, 'stats', 'dist', 'index.html'),
|
||||
};
|
||||
|
||||
function requireCompiledArtifacts() {
|
||||
const missing = Object.values(paths).filter((artifactPath) => !existsSync(artifactPath));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Compiled runtime artifacts are missing. Run \`bun run build\` before this check:\n${missing
|
||||
.map((artifactPath) => ` - ${artifactPath}`)
|
||||
.join('\n')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requireElectronNodeRuntime() {
|
||||
if (!process.versions.electron || process.env.ELECTRON_RUN_AS_NODE !== '1') {
|
||||
throw new Error(
|
||||
'This check must run with Electron in Node mode. Use `bun run test:smoke:dist`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
||||
}
|
||||
|
||||
function listen(server, port = 0) {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const onError = (error) => {
|
||||
server.off('listening', onListening);
|
||||
reject(error);
|
||||
};
|
||||
const onListening = () => {
|
||||
server.off('error', onError);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
reject(new Error('Could not resolve the stats smoke port.'));
|
||||
return;
|
||||
}
|
||||
resolvePromise(address.port);
|
||||
};
|
||||
server.once('error', onError);
|
||||
server.once('listening', onListening);
|
||||
server.listen(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolvePromise();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function findAvailablePort() {
|
||||
const reservation = createServer();
|
||||
const port = await listen(reservation);
|
||||
await closeServer(reservation);
|
||||
return port;
|
||||
}
|
||||
|
||||
function writeSmokeConfig(userDataPath, port) {
|
||||
writeFileSync(
|
||||
join(userDataPath, 'config.json'),
|
||||
`${JSON.stringify({ stats: { serverPort: port } })}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function spawnDaemon(userDataPath, responsePath) {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
paths.daemonEntry,
|
||||
'--stats-user-data-path',
|
||||
userDataPath,
|
||||
'--stats-response-path',
|
||||
responsePath,
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
let output = '';
|
||||
const appendOutput = (chunk) => {
|
||||
if (output.length >= MAX_CHILD_OUTPUT_BYTES) return;
|
||||
output += String(chunk);
|
||||
if (output.length > MAX_CHILD_OUTPUT_BYTES) {
|
||||
output = `${output.slice(0, MAX_CHILD_OUTPUT_BYTES)}\n[child output truncated]\n`;
|
||||
}
|
||||
};
|
||||
child.stdout.on('data', appendOutput);
|
||||
child.stderr.on('data', appendOutput);
|
||||
let exited = false;
|
||||
const exit = new Promise((resolvePromise) => {
|
||||
child.once('error', (error) => {
|
||||
exited = true;
|
||||
resolvePromise({ code: null, signal: null, error });
|
||||
});
|
||||
child.once('exit', (code, signal) => {
|
||||
exited = true;
|
||||
resolvePromise({ code, signal, error: null });
|
||||
});
|
||||
});
|
||||
return { child, exit, hasExited: () => exited, getOutput: () => output };
|
||||
}
|
||||
|
||||
async function waitForResponse(responsePath, daemon) {
|
||||
const deadline = Date.now() + START_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (existsSync(responsePath)) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(responsePath, 'utf8'));
|
||||
} catch {
|
||||
// The daemon may still be finishing the response file write.
|
||||
}
|
||||
}
|
||||
if (daemon.hasExited()) {
|
||||
const result = await daemon.exit;
|
||||
throw new Error(
|
||||
`Stats daemon exited before writing a startup response (${formatExit(result)}).\n${daemon.getOutput()}`,
|
||||
);
|
||||
}
|
||||
await delay(POLL_INTERVAL_MS);
|
||||
}
|
||||
throw new Error(`Timed out waiting for stats daemon startup.\n${daemon.getOutput()}`);
|
||||
}
|
||||
|
||||
function formatExit(result) {
|
||||
if (result.error) return result.error.message;
|
||||
if (result.signal) return `signal ${result.signal}`;
|
||||
return `exit code ${result.code}`;
|
||||
}
|
||||
|
||||
async function waitForExit(daemon, timeoutMs) {
|
||||
return await Promise.race([daemon.exit, delay(timeoutMs).then(() => null)]);
|
||||
}
|
||||
|
||||
async function stopDaemon(daemon) {
|
||||
if (daemon.hasExited()) {
|
||||
return await daemon.exit;
|
||||
}
|
||||
daemon.child.kill('SIGTERM');
|
||||
const result = await waitForExit(daemon, STOP_TIMEOUT_MS);
|
||||
if (result) return result;
|
||||
daemon.child.kill('SIGKILL');
|
||||
await daemon.exit;
|
||||
throw new Error(`Stats daemon did not stop after SIGTERM.\n${daemon.getOutput()}`);
|
||||
}
|
||||
|
||||
async function assertPortCanBind(port) {
|
||||
const server = createServer();
|
||||
try {
|
||||
await listen(server, port);
|
||||
} finally {
|
||||
if (server.listening) await closeServer(server);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOverview(url, daemon) {
|
||||
const deadline = Date.now() + START_TIMEOUT_MS;
|
||||
let lastError = null;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
return await fetch(`${url}/api/stats/overview`, {
|
||||
signal: AbortSignal.timeout(START_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
if (daemon.hasExited()) {
|
||||
const result = await daemon.exit;
|
||||
throw new Error(
|
||||
`Stats daemon exited before accepting HTTP requests (${formatExit(result)}).\n${daemon.getOutput()}`,
|
||||
);
|
||||
}
|
||||
await delay(POLL_INTERVAL_MS);
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for the stats HTTP server: ${lastError instanceof Error ? lastError.message : String(lastError)}\n${daemon.getOutput()}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runHealthyStartup(userDataPath, port, responseName) {
|
||||
writeSmokeConfig(userDataPath, port);
|
||||
const responsePath = join(userDataPath, responseName);
|
||||
const statePath = join(userDataPath, 'stats-daemon.json');
|
||||
const databasePath = join(userDataPath, 'immersion.sqlite');
|
||||
const daemon = spawnDaemon(userDataPath, responsePath);
|
||||
let shutdownResult;
|
||||
let unfinishedRequest;
|
||||
|
||||
try {
|
||||
const startup = await waitForResponse(responsePath, daemon);
|
||||
assert.deepEqual(startup, { ok: true, url: `http://127.0.0.1:${port}` });
|
||||
|
||||
const response = await fetchOverview(startup.url, daemon);
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers.get('content-type') ?? '', /^application\/json\b/);
|
||||
const overview = await response.json();
|
||||
assert.equal(typeof overview, 'object');
|
||||
assert.ok(overview !== null);
|
||||
assert.ok(Array.isArray(overview.sessions));
|
||||
assert.ok(Array.isArray(overview.rollups));
|
||||
assert.equal(typeof overview.hints, 'object');
|
||||
|
||||
const dashboard = await fetch(`${startup.url}/?overlay=1`, {
|
||||
signal: AbortSignal.timeout(START_TIMEOUT_MS),
|
||||
});
|
||||
assert.equal(dashboard.status, 200);
|
||||
assert.match(dashboard.headers.get('content-type') ?? '', /^text\/html\b/);
|
||||
assert.match(await dashboard.text(), /id="root"/);
|
||||
|
||||
assert.ok(existsSync(databasePath), 'The compiled tracker did not create its SQLite database.');
|
||||
assert.ok(
|
||||
statSync(databasePath).size > 0,
|
||||
'The compiled tracker created an empty SQLite file.',
|
||||
);
|
||||
|
||||
// Leave a real request body unfinished so shutdown must bound its drain wait.
|
||||
unfinishedRequest = httpRequest(new URL('/api/stats/anki/notesInfo', startup.url), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': '1000',
|
||||
Expect: '100-continue',
|
||||
},
|
||||
signal: AbortSignal.timeout(START_TIMEOUT_MS),
|
||||
});
|
||||
await new Promise((resolvePromise, reject) => {
|
||||
unfinishedRequest.on('error', reject);
|
||||
unfinishedRequest.once('continue', () => {
|
||||
unfinishedRequest.write('{');
|
||||
resolvePromise();
|
||||
});
|
||||
unfinishedRequest.flushHeaders();
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
shutdownResult = await stopDaemon(daemon);
|
||||
} finally {
|
||||
unfinishedRequest?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(shutdownResult.code, 0, `Stats daemon shutdown failed.\n${daemon.getOutput()}`);
|
||||
assert.equal(existsSync(statePath), false, 'Stats daemon state remained after shutdown.');
|
||||
await assertPortCanBind(port);
|
||||
}
|
||||
|
||||
async function runConflictRecovery() {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'subminer-compiled-conflict-'));
|
||||
const reservation = createServer();
|
||||
const port = await listen(reservation);
|
||||
const responsePath = join(userDataPath, 'conflict-response.json');
|
||||
const statePath = join(userDataPath, 'stats-daemon.json');
|
||||
writeSmokeConfig(userDataPath, port);
|
||||
const daemon = spawnDaemon(userDataPath, responsePath);
|
||||
const failures = [];
|
||||
|
||||
try {
|
||||
const response = await waitForResponse(responsePath, daemon);
|
||||
if (response.ok !== false) {
|
||||
failures.push('The stats daemon reported success while its configured port was occupied.');
|
||||
}
|
||||
const result = await waitForExit(daemon, STOP_TIMEOUT_MS);
|
||||
if (!result) {
|
||||
failures.push('The stats daemon did not exit after its configured port failed to bind.');
|
||||
} else if (result.code === 0) {
|
||||
failures.push(
|
||||
'The stats daemon exited successfully after its configured port failed to bind.',
|
||||
);
|
||||
}
|
||||
if (existsSync(statePath)) {
|
||||
failures.push(
|
||||
'The stats daemon left ownership state behind after its configured port failed to bind.',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await closeServer(reservation);
|
||||
if (!daemon.hasExited()) {
|
||||
await stopDaemon(daemon);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await runHealthyStartup(userDataPath, port, 'recovery-response.json');
|
||||
} finally {
|
||||
rmSync(userDataPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
assert.deepEqual(failures, []);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
requireCompiledArtifacts();
|
||||
requireElectronNodeRuntime();
|
||||
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'subminer-compiled-runtime-'));
|
||||
try {
|
||||
await runHealthyStartup(userDataPath, await findAvailablePort(), 'startup-response.json');
|
||||
} finally {
|
||||
rmSync(userDataPath, { recursive: true, force: true });
|
||||
}
|
||||
await runConflictRecovery();
|
||||
|
||||
process.stdout.write(
|
||||
`Compiled runtime smoke passed with Electron ${process.versions.electron}, Node ${process.versions.node}, HTTP, and native SQLite.\n`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
test('compiled runtime smoke fails clearly when build artifacts are missing', () => {
|
||||
const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-compiled-missing-'));
|
||||
try {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['scripts/compiled-runtime-smoke.mjs', '--repo-root', emptyRepo],
|
||||
{
|
||||
cwd: path.resolve(import.meta.dir, '..'),
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /Compiled runtime artifacts are missing/);
|
||||
assert.match(result.stderr, /dist\/main-entry\.js/);
|
||||
assert.match(result.stderr, /dist\/stats-daemon-runner\.js/);
|
||||
assert.doesNotMatch(result.stderr, /must run with Electron/);
|
||||
} finally {
|
||||
fs.rmSync(emptyRepo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { stageBundledIntegrationKeys, TMDB_API_KEY_ENV } from './bundled-integration-keys.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
@@ -111,6 +112,17 @@ function buildMacosHelper() {
|
||||
}
|
||||
}
|
||||
|
||||
// Only the key names are logged, never the values: CI masks secrets, but a
|
||||
// stray echo would still leak them into local build logs.
|
||||
function stageIntegrationKeys() {
|
||||
const staged = stageBundledIntegrationKeys(path.join(repoRoot, 'dist'));
|
||||
process.stdout.write(
|
||||
staged.length > 0
|
||||
? `Staged bundled integration keys: ${staged.join(', ')}\n`
|
||||
: `No bundled integration keys (${TMDB_API_KEY_ENV} unset)\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(repoRoot, 'dist', 'fonts'), {
|
||||
recursive: true,
|
||||
@@ -121,6 +133,7 @@ function main() {
|
||||
copySyncUiAssets();
|
||||
copyAnimeUiAssets();
|
||||
buildMacosHelper();
|
||||
stageIntegrationKeys();
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolve } from 'node:path';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { mergeLcovReports, resolveCoverageDir } from './run-coverage-lane';
|
||||
import { mergeLcovReports, resolveCoverageDir, runCoverageLane } from './run-coverage-lane';
|
||||
|
||||
test('mergeLcovReports combines duplicate source-file counters across shard outputs', () => {
|
||||
const merged = mergeLcovReports([
|
||||
@@ -72,3 +74,33 @@ test('resolveCoverageDir keeps coverage output inside the repository', () => {
|
||||
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '../escape']));
|
||||
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '/tmp/escape']));
|
||||
});
|
||||
|
||||
test('runCoverageLane returns a failure when a discovered test fails', () => {
|
||||
const repoRoot = mkdtempSync(join(tmpdir(), 'subminer-coverage-failure-'));
|
||||
try {
|
||||
mkdirSync(join(repoRoot, 'src'));
|
||||
writeFileSync(
|
||||
join(repoRoot, 'src', 'failure.test.ts'),
|
||||
[
|
||||
"import assert from 'node:assert/strict';",
|
||||
"import test from 'node:test';",
|
||||
'',
|
||||
"test('intentional coverage failure', () => {",
|
||||
" assert.fail('coverage runner must propagate this failure');",
|
||||
'});',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.notEqual(
|
||||
runCoverageLane({
|
||||
repoRootDir: repoRoot,
|
||||
argv: ['bun-src-full', '--coverage-dir', 'coverage/test-src'],
|
||||
stdio: 'pipe',
|
||||
}),
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
rmSync(repoRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -201,14 +201,18 @@ export function mergeLcovReports(reports: string[]): string {
|
||||
return chunks.length > 0 ? `${chunks.join('\n')}\n` : '';
|
||||
}
|
||||
|
||||
function runCoverageLane(): number {
|
||||
const laneName = process.argv[2];
|
||||
export function runCoverageLane(
|
||||
options: { repoRootDir?: string; argv?: string[]; stdio?: 'inherit' | 'pipe' } = {},
|
||||
): number {
|
||||
const repoRootDir = options.repoRootDir ?? repoRoot;
|
||||
const argv = options.argv ?? process.argv.slice(2);
|
||||
const laneName = argv[0];
|
||||
if (laneName === undefined) {
|
||||
process.stderr.write('Missing coverage lane name\n');
|
||||
return 1;
|
||||
}
|
||||
|
||||
const coverageDir = resolveCoverageDir(repoRoot, process.argv.slice(3));
|
||||
const coverageDir = resolveCoverageDir(repoRootDir, argv.slice(1));
|
||||
const shardRoot = join(coverageDir, '.shards');
|
||||
mkdirSync(coverageDir, { recursive: true });
|
||||
rmSync(shardRoot, { recursive: true, force: true });
|
||||
@@ -216,7 +220,7 @@ function runCoverageLane(): number {
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = collectLaneFiles(repoRoot, laneName);
|
||||
files = collectLaneFiles(repoRootDir, laneName);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
|
||||
return 1;
|
||||
@@ -230,8 +234,8 @@ function runCoverageLane(): number {
|
||||
'bun',
|
||||
['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
cwd: repoRootDir,
|
||||
stdio: options.stdio ?? 'inherit',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -253,7 +257,7 @@ function runCoverageLane(): number {
|
||||
|
||||
writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
|
||||
process.stdout.write(
|
||||
`Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`,
|
||||
`Merged LCOV written to ${relative(repoRootDir, join(coverageDir, 'lcov.info'))}\n`,
|
||||
);
|
||||
return 0;
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user