fix: stabilize failing test regressions across src and launcher lanes

- Fix log pruning cutoff math using BigInt `mtimeNs` to avoid Bun mtime precision loss
- Fix stats CLI lifetime rebuild timestamp units in tests and log output; add `formatLoggedNumber` guard
- Use `performance.now()` in subtitle sidebar auto-follow to isolate from test time injection
- Harden renderer global cleanup tests with descriptor save/restore instead of assuming globals absent
- Isolate `node:http` fallback in stats-server test with stub and assertion
- Fix AniSkip fallback title: cleaned basename beats generic parent dirs; episode-only filenames still prefer series directory
This commit is contained in:
2026-04-03 22:04:52 -07:00
parent 864f4124ae
commit e4137d9760
10 changed files with 224 additions and 33 deletions

View File

@@ -1,6 +1,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { createStatsApp, startStatsServer } from '../stats-server.js';
@@ -1172,7 +1173,23 @@ describe('stats server API routes', () => {
const bun = globalThis as typeof globalThis & BunRuntime;
const originalServe = bun.Bun.serve;
const originalCreateServer = http.createServer;
let listenedWith: { port: number; hostname: string } | null = null;
let closeCalls = 0;
bun.Bun.serve = undefined;
(
http as typeof http & {
createServer: typeof http.createServer;
}
).createServer = (() =>
({
listen: (port: number, hostname: string) => {
listenedWith = { port, hostname };
},
close: () => {
closeCalls += 1;
},
}) as unknown as ReturnType<typeof http.createServer>) as typeof http.createServer;
try {
const server = startStatsServer({
@@ -1181,9 +1198,16 @@ describe('stats server API routes', () => {
tracker: createMockTracker(),
});
assert.deepEqual(listenedWith, { port: 0, hostname: '127.0.0.1' });
server.close();
assert.equal(closeCalls, 1);
} finally {
bun.Bun.serve = originalServe;
(
http as typeof http & {
createServer: typeof http.createServer;
}
).createServer = originalCreateServer;
}
});
});

View File

@@ -236,7 +236,7 @@ test('stats cli command runs lifetime rebuild when cleanup lifetime mode is requ
getImmersionTracker: () => ({
rebuildLifetimeSummaries: async () => ({
appliedSessions: 4,
rebuiltAtMs: 1_710_000_000_000,
rebuiltAtMs: 1_710_000_000,
}),
}),
});
@@ -252,7 +252,7 @@ test('stats cli command runs lifetime rebuild when cleanup lifetime mode is requ
assert.deepEqual(calls, [
'ensureImmersionTrackerStarted',
'info:Stats lifetime rebuild complete: appliedSessions=4 rebuiltAtMs=1710000000000',
'info:Stats lifetime rebuild complete: appliedSessions=4 rebuiltAtMs=1710000000',
]);
assert.deepEqual(responses, [
{
@@ -285,6 +285,7 @@ async function waitForPendingAnimeMetadata(
test('tracker rebuildLifetimeSummaries backfills retained sessions and is idempotent', async () => {
const dbPath = makeDbPath();
const previousNowMs = globalThis.__subminerTestNowMs;
let tracker:
| import('../../core/services/immersion-tracker-service').ImmersionTrackerService
| null = null;
@@ -298,19 +299,23 @@ test('tracker rebuildLifetimeSummaries backfills retained sessions and is idempo
const { Database } = await import('../../core/services/immersion-tracker/sqlite');
try {
globalThis.__subminerTestNowMs = 1_700_000_000;
tracker = new ImmersionTrackerService({ dbPath });
tracker.handleMediaChange('/tmp/Frieren S01E01.mkv', 'Episode 1');
await waitForPendingAnimeMetadata(tracker);
tracker.recordCardsMined(2);
tracker.recordSubtitleLine('first line', 0, 1);
globalThis.__subminerTestNowMs = 1_700_001_000;
tracker.destroy();
tracker = null;
globalThis.__subminerTestNowMs = 1_700_002_000;
tracker2 = new ImmersionTrackerService({ dbPath });
tracker2.handleMediaChange('/tmp/Frieren S01E02.mkv', 'Episode 2');
await waitForPendingAnimeMetadata(tracker2);
tracker2.recordCardsMined(1);
tracker2.recordSubtitleLine('second line', 0, 1);
globalThis.__subminerTestNowMs = 1_700_003_000;
tracker2.destroy();
tracker2 = null;
@@ -357,8 +362,10 @@ test('tracker rebuildLifetimeSummaries backfills retained sessions and is idempo
`);
beforeDb.close();
globalThis.__subminerTestNowMs = 1_700_004_000;
tracker3 = new ImmersionTrackerService({ dbPath });
const firstRebuild = await tracker3.rebuildLifetimeSummaries();
globalThis.__subminerTestNowMs = 1_700_005_000;
const secondRebuild = await tracker3.rebuildLifetimeSummaries();
const rebuiltDb = new Database(dbPath);
@@ -405,6 +412,7 @@ test('tracker rebuildLifetimeSummaries backfills retained sessions and is idempo
assert.equal(secondRebuild.appliedSessions, firstRebuild.appliedSessions);
assert.ok(secondRebuild.rebuiltAtMs >= firstRebuild.rebuiltAtMs);
} finally {
globalThis.__subminerTestNowMs = previousNowMs;
tracker?.destroy();
tracker2?.destroy();
tracker3?.destroy();
@@ -417,7 +425,7 @@ test('stats cli command runs lifetime rebuild when requested', async () => {
getImmersionTracker: () => ({
rebuildLifetimeSummaries: async () => ({
appliedSessions: 4,
rebuiltAtMs: 1_710_000_000_000,
rebuiltAtMs: 1_710_000_000,
}),
}),
});
@@ -433,7 +441,7 @@ test('stats cli command runs lifetime rebuild when requested', async () => {
assert.deepEqual(calls, [
'ensureImmersionTrackerStarted',
'info:Stats lifetime rebuild complete: appliedSessions=4 rebuiltAtMs=1710000000000',
'info:Stats lifetime rebuild complete: appliedSessions=4 rebuiltAtMs=1710000000',
]);
assert.deepEqual(responses, [
{

View File

@@ -32,6 +32,10 @@ type BackgroundStatsStopResult = {
stale: boolean;
};
function formatLoggedNumber(value: number): string {
return Number.isFinite(value) ? value.toString() : String(value);
}
export function writeStatsCliCommandResponse(
responsePath: string,
payload: StatsCliCommandResponse,
@@ -143,7 +147,7 @@ export function createRunStatsCliCommandHandler(deps: {
}
const result = await tracker.rebuildLifetimeSummaries();
deps.logInfo(
`Stats lifetime rebuild complete: appliedSessions=${result.appliedSessions} rebuiltAtMs=${result.rebuiltAtMs}`,
`Stats lifetime rebuild complete: appliedSessions=${formatLoggedNumber(result.appliedSessions)} rebuiltAtMs=${formatLoggedNumber(result.rebuiltAtMs)}`,
);
writeResponseSafe(args.statsResponsePath, { ok: true });
return;

View File

@@ -302,22 +302,28 @@ function setupPlaylistBrowserModalTest(options?: {
}
test('playlist browser test cleanup must delete injected globals that were originally absent', () => {
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
const env = setupPlaylistBrowserModalTest();
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
try {
Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'document');
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
const env = setupPlaylistBrowserModalTest();
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), true);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), true);
} finally {
env.restore();
}
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
assert.equal(typeof globalThis.window, 'undefined');
assert.equal(typeof globalThis.document, 'undefined');
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
assert.equal(typeof globalThis.window, 'undefined');
assert.equal(typeof globalThis.document, 'undefined');
} finally {
restoreGlobalDescriptor('window', previousWindowDescriptor);
restoreGlobalDescriptor('document', previousDocumentDescriptor);
}
});
test('playlist browser modal opens with playlist-focused current item selection', async () => {

View File

@@ -74,6 +74,18 @@ function createListStub() {
};
}
test.afterEach(() => {
if (Object.prototype.hasOwnProperty.call(globalThis, 'window') && globalThis.window === undefined) {
Reflect.deleteProperty(globalThis, 'window');
}
if (
Object.prototype.hasOwnProperty.call(globalThis, 'document') &&
globalThis.document === undefined
) {
Reflect.deleteProperty(globalThis, 'document');
}
});
test('findActiveSubtitleCueIndex prefers timing match before text fallback', () => {
const cues = [
{ startTime: 1, endTime: 2, text: 'same' },

View File

@@ -8,6 +8,14 @@ const CLICK_SEEK_OFFSET_SEC = 0.08;
const SNAPSHOT_POLL_INTERVAL_MS = 80;
const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240;
const EMBEDDED_SIDEBAR_MAX_RATIO = 0.45;
function nowForUiTiming(): number {
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
return performance.now();
}
return Date.now();
}
function subtitleCueListsEqual(a: SubtitleCue[], b: SubtitleCue[]): boolean {
if (a.length !== b.length) {
return false;
@@ -294,7 +302,7 @@ export function createSubtitleSidebarModal(
!ctx.state.subtitleSidebarAutoScroll ||
ctx.state.subtitleSidebarActiveCueIndex < 0 ||
(!force && ctx.state.subtitleSidebarActiveCueIndex === previousActiveCueIndex) ||
Date.now() < ctx.state.subtitleSidebarManualScrollUntilMs
nowForUiTiming() < ctx.state.subtitleSidebarManualScrollUntilMs
) {
return;
}
@@ -547,7 +555,7 @@ export function createSubtitleSidebarModal(
seekToCue(cue);
});
ctx.dom.subtitleSidebarList.addEventListener('wheel', () => {
ctx.state.subtitleSidebarManualScrollUntilMs = Date.now() + MANUAL_SCROLL_HOLD_MS;
ctx.state.subtitleSidebarManualScrollUntilMs = nowForUiTiming() + MANUAL_SCROLL_HOLD_MS;
});
ctx.dom.subtitleSidebarContent.addEventListener('mouseenter', async () => {
subtitleSidebarHovered = true;

View File

@@ -92,6 +92,17 @@ function restoreGlobalProp<K extends keyof typeof globalThis>(
Reflect.deleteProperty(globalThis, key);
}
function restoreGlobalDescriptor<K extends keyof typeof globalThis>(
key: K,
descriptor: PropertyDescriptor | undefined,
) {
if (descriptor) {
Object.defineProperty(globalThis, key, descriptor);
return;
}
Reflect.deleteProperty(globalThis, key);
}
function setupYoutubePickerTestEnv(options?: {
windowValue?: YoutubePickerTestWindow;
customEventValue?: unknown;
@@ -153,20 +164,30 @@ function setupYoutubePickerTestEnv(options?: {
}
test('youtube picker test env restore deletes injected globals that were originally absent', () => {
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
const env = setupYoutubePickerTestEnv();
try {
Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'document');
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), true);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), true);
const env = setupYoutubePickerTestEnv();
env.restore();
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), true);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), true);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
assert.equal(typeof globalThis.window, 'undefined');
assert.equal(typeof globalThis.document, 'undefined');
env.restore();
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
assert.equal(typeof globalThis.window, 'undefined');
assert.equal(typeof globalThis.document, 'undefined');
} finally {
restoreGlobalDescriptor('window', previousWindowDescriptor);
restoreGlobalDescriptor('document', previousDocumentDescriptor);
}
});
test('youtube track picker close restores focus and mouse-ignore state', () => {

View File

@@ -9,6 +9,36 @@ export const DEFAULT_LOG_MAX_BYTES = 10 * 1024 * 1024;
const TRUNCATED_MARKER = '[truncated older log content]\n';
const prunedDirectories = new Set<string>();
const NS_PER_MS = 1_000_000n;
const MS_PER_DAY = 86_400_000n;
function floorDiv(left: number, right: number): number {
return Math.floor(left / right);
}
function daysFromCivil(year: number, month: number, day: number): bigint {
const adjustedYear = year - (month <= 2 ? 1 : 0);
const era = floorDiv(adjustedYear >= 0 ? adjustedYear : adjustedYear - 399, 400);
const yearOfEra = adjustedYear - era * 400;
const monthIndex = month + (month > 2 ? -3 : 9);
const dayOfYear = floorDiv(153 * monthIndex + 2, 5) + day - 1;
const dayOfEra =
yearOfEra * 365 + floorDiv(yearOfEra, 4) - floorDiv(yearOfEra, 100) + dayOfYear;
return BigInt(era * 146097 + dayOfEra - 719468);
}
function dateToEpochMs(date: Date): bigint {
const dayCount = daysFromCivil(
date.getUTCFullYear(),
date.getUTCMonth() + 1,
date.getUTCDate(),
);
const timeOfDayMs = BigInt(
((date.getUTCHours() * 60 + date.getUTCMinutes()) * 60 + date.getUTCSeconds()) * 1000 +
date.getUTCMilliseconds(),
);
return dayCount * MS_PER_DAY + timeOfDayMs;
}
export function resolveLogBaseDir(options?: {
platform?: NodeJS.Platform;
@@ -52,16 +82,20 @@ export function pruneLogFiles(
return;
}
const cutoffMs = (options?.now ?? new Date()).getTime() - retentionDays * 24 * 60 * 60 * 1000;
const cutoffDate = new Date(options?.now ?? new Date());
cutoffDate.setUTCDate(cutoffDate.getUTCDate() - retentionDays);
const cutoffNs = dateToEpochMs(cutoffDate) * NS_PER_MS;
for (const entry of entries) {
const candidate = path.join(logsDir, entry);
let stats: fs.Stats;
let stats: fs.BigIntStats;
try {
stats = fs.statSync(candidate);
stats = fs.statSync(candidate, { bigint: true });
} catch {
continue;
}
if (!stats.isFile() || !entry.endsWith('.log') || stats.mtimeMs >= cutoffMs) continue;
if (!stats.isFile() || !entry.endsWith('.log') || stats.mtimeNs >= cutoffNs) {
continue;
}
try {
fs.rmSync(candidate, { force: true });
} catch {