mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-04 15:37:29 -07:00
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:
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
id: TASK-274
|
||||||
|
title: Stabilize current failing test regressions
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- codex
|
||||||
|
created_date: '2026-04-04 04:40'
|
||||||
|
updated_date: '2026-04-04 05:01'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
documentation:
|
||||||
|
- docs/workflow/verification.md
|
||||||
|
- docs/architecture/README.md
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Investigate and fix the current src/test failures across stats CLI lifetime rebuild handling, immersion tracker lifetime rebuild idempotency, renderer test environment cleanup helpers, subtitle sidebar auto-follow behavior, and log retention pruning so the maintained test lanes pass again.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Stats CLI lifetime rebuild behavior passes the current regression coverage.
|
||||||
|
- [x] #2 Immersion tracker lifetime rebuild backfill remains idempotent under the existing runtime test.
|
||||||
|
- [x] #3 Renderer modal test helpers restore injected globals exactly to prior state.
|
||||||
|
- [x] #4 Log pruning removes files older than the configured retention window deterministically.
|
||||||
|
- [x] #5 Relevant targeted test files pass after the fixes.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Reproduce the failing specs in isolation to separate deterministic regressions from suite-order pollution.
|
||||||
|
2. Fix source or test-helper logic for the three isolated failures: log retention cutoff, stats CLI lifetime rebuild timestamp handling, and subtitle sidebar initial jump behavior.
|
||||||
|
3. Harden renderer modal cleanup regressions so tests verify descriptor restoration without assuming global window/document start absent.
|
||||||
|
4. Re-run the targeted failing files, then the required verification gate for the touched areas and record results.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Targeted regressions fixed in log pruning, stats CLI lifetime logging/tests, subtitle sidebar auto-follow timing, and renderer global cleanup test isolation.
|
||||||
|
|
||||||
|
Verification: `bun test src/main/runtime/stats-cli-command.test.ts src/shared/log-files.test.ts src/renderer/modals/playlist-browser.test.ts src/renderer/modals/youtube-track-picker.test.ts src/renderer/modals/subtitle-sidebar.test.ts` passed.
|
||||||
|
|
||||||
|
Verification: `bun run test:src` still exits non-zero because of unrelated existing errors in `src/core/services/anilist/anilist-token-store.test.ts` (`Bun.serve is not a function`) plus one remaining non-task failure elsewhere; the originally reported regressions are green in the maintained lane.
|
||||||
|
|
||||||
|
User reported `test:full` still failing after the first regression pass. Reopened to clear the remaining `test:src` fail plus the existing unhandled test errors before handoff.
|
||||||
|
|
||||||
|
Verified final gate with `bun run test:launcher:unit:src` and `bun run test:full`; both pass after fixing the launcher AniSkip fallback title regression and the earlier src-lane regressions.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Stabilized the failing test regressions across source and launcher lanes. Fixed log pruning cutoff math under Bun BigInt mtimes, subtitle sidebar auto-follow timing, renderer global cleanup test isolation, stats CLI lifetime rebuild logging/tests, stats-server node:http fallback isolation, and launcher AniSkip fallback title resolution so basename titles beat generic parent directories while episode-only filenames still prefer the series directory. Verification passed with `bun test launcher/aniskip-metadata.test.ts`, `bun run test:launcher:unit:src`, and `bun run test:full`.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -125,6 +125,12 @@ function titleOverlapScore(expectedTitle: string, candidateTitle: string): numbe
|
|||||||
if (!expected || !candidate) return 0;
|
if (!expected || !candidate) return 0;
|
||||||
|
|
||||||
if (candidate.includes(expected)) return 120;
|
if (candidate.includes(expected)) return 120;
|
||||||
|
if (
|
||||||
|
candidate.split(' ').length >= 2 &&
|
||||||
|
` ${expected} `.includes(` ${candidate} `)
|
||||||
|
) {
|
||||||
|
return 90;
|
||||||
|
}
|
||||||
|
|
||||||
const expectedTokens = tokenizeMatchWords(expectedTitle);
|
const expectedTokens = tokenizeMatchWords(expectedTitle);
|
||||||
if (expectedTokens.length === 0) return 0;
|
if (expectedTokens.length === 0) return 0;
|
||||||
@@ -339,6 +345,12 @@ function isSeasonDirectoryName(value: string): boolean {
|
|||||||
return /^(?:season|s)[\s._-]*\d{1,2}$/i.test(value.trim());
|
return /^(?:season|s)[\s._-]*\d{1,2}$/i.test(value.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEpisodeOnlyBaseName(value: string): boolean {
|
||||||
|
return /^(?:[Ss]\d{1,2}[Ee]\d{1,3}|[Ee][Pp]?[\s._-]*\d{1,3}|\d{1,3})(?:$|[\s._-])/.test(
|
||||||
|
value.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function inferTitleFromPath(mediaPath: string): string {
|
function inferTitleFromPath(mediaPath: string): string {
|
||||||
const directory = path.dirname(mediaPath);
|
const directory = path.dirname(mediaPath);
|
||||||
const segments = directory.split(/[\\/]+/).filter((segment) => segment.length > 0);
|
const segments = directory.split(/[\\/]+/).filter((segment) => segment.length > 0);
|
||||||
@@ -445,8 +457,11 @@ export function inferAniSkipMetadataForFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseName = path.basename(mediaPath, path.extname(mediaPath));
|
const baseName = path.basename(mediaPath, path.extname(mediaPath));
|
||||||
|
const cleanedBaseName = cleanupTitle(baseName);
|
||||||
const pathTitle = inferTitleFromPath(mediaPath);
|
const pathTitle = inferTitleFromPath(mediaPath);
|
||||||
const fallbackTitle = pathTitle || cleanupTitle(baseName) || baseName;
|
const fallbackTitle = isEpisodeOnlyBaseName(baseName)
|
||||||
|
? pathTitle || cleanedBaseName || baseName
|
||||||
|
: cleanedBaseName || pathTitle || baseName;
|
||||||
return {
|
return {
|
||||||
title: fallbackTitle,
|
title: fallbackTitle,
|
||||||
season: detectSeasonFromNameOrDir(mediaPath),
|
season: detectSeasonFromNameOrDir(mediaPath),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { createStatsApp, startStatsServer } from '../stats-server.js';
|
import { createStatsApp, startStatsServer } from '../stats-server.js';
|
||||||
@@ -1172,7 +1173,23 @@ describe('stats server API routes', () => {
|
|||||||
|
|
||||||
const bun = globalThis as typeof globalThis & BunRuntime;
|
const bun = globalThis as typeof globalThis & BunRuntime;
|
||||||
const originalServe = bun.Bun.serve;
|
const originalServe = bun.Bun.serve;
|
||||||
|
const originalCreateServer = http.createServer;
|
||||||
|
let listenedWith: { port: number; hostname: string } | null = null;
|
||||||
|
let closeCalls = 0;
|
||||||
bun.Bun.serve = undefined;
|
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 {
|
try {
|
||||||
const server = startStatsServer({
|
const server = startStatsServer({
|
||||||
@@ -1181,9 +1198,16 @@ describe('stats server API routes', () => {
|
|||||||
tracker: createMockTracker(),
|
tracker: createMockTracker(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(listenedWith, { port: 0, hostname: '127.0.0.1' });
|
||||||
server.close();
|
server.close();
|
||||||
|
assert.equal(closeCalls, 1);
|
||||||
} finally {
|
} finally {
|
||||||
bun.Bun.serve = originalServe;
|
bun.Bun.serve = originalServe;
|
||||||
|
(
|
||||||
|
http as typeof http & {
|
||||||
|
createServer: typeof http.createServer;
|
||||||
|
}
|
||||||
|
).createServer = originalCreateServer;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ test('stats cli command runs lifetime rebuild when cleanup lifetime mode is requ
|
|||||||
getImmersionTracker: () => ({
|
getImmersionTracker: () => ({
|
||||||
rebuildLifetimeSummaries: async () => ({
|
rebuildLifetimeSummaries: async () => ({
|
||||||
appliedSessions: 4,
|
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, [
|
assert.deepEqual(calls, [
|
||||||
'ensureImmersionTrackerStarted',
|
'ensureImmersionTrackerStarted',
|
||||||
'info:Stats lifetime rebuild complete: appliedSessions=4 rebuiltAtMs=1710000000000',
|
'info:Stats lifetime rebuild complete: appliedSessions=4 rebuiltAtMs=1710000000',
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(responses, [
|
assert.deepEqual(responses, [
|
||||||
{
|
{
|
||||||
@@ -285,6 +285,7 @@ async function waitForPendingAnimeMetadata(
|
|||||||
|
|
||||||
test('tracker rebuildLifetimeSummaries backfills retained sessions and is idempotent', async () => {
|
test('tracker rebuildLifetimeSummaries backfills retained sessions and is idempotent', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
|
const previousNowMs = globalThis.__subminerTestNowMs;
|
||||||
let tracker:
|
let tracker:
|
||||||
| import('../../core/services/immersion-tracker-service').ImmersionTrackerService
|
| import('../../core/services/immersion-tracker-service').ImmersionTrackerService
|
||||||
| null = null;
|
| null = null;
|
||||||
@@ -298,19 +299,23 @@ test('tracker rebuildLifetimeSummaries backfills retained sessions and is idempo
|
|||||||
const { Database } = await import('../../core/services/immersion-tracker/sqlite');
|
const { Database } = await import('../../core/services/immersion-tracker/sqlite');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
globalThis.__subminerTestNowMs = 1_700_000_000;
|
||||||
tracker = new ImmersionTrackerService({ dbPath });
|
tracker = new ImmersionTrackerService({ dbPath });
|
||||||
tracker.handleMediaChange('/tmp/Frieren S01E01.mkv', 'Episode 1');
|
tracker.handleMediaChange('/tmp/Frieren S01E01.mkv', 'Episode 1');
|
||||||
await waitForPendingAnimeMetadata(tracker);
|
await waitForPendingAnimeMetadata(tracker);
|
||||||
tracker.recordCardsMined(2);
|
tracker.recordCardsMined(2);
|
||||||
tracker.recordSubtitleLine('first line', 0, 1);
|
tracker.recordSubtitleLine('first line', 0, 1);
|
||||||
|
globalThis.__subminerTestNowMs = 1_700_001_000;
|
||||||
tracker.destroy();
|
tracker.destroy();
|
||||||
tracker = null;
|
tracker = null;
|
||||||
|
|
||||||
|
globalThis.__subminerTestNowMs = 1_700_002_000;
|
||||||
tracker2 = new ImmersionTrackerService({ dbPath });
|
tracker2 = new ImmersionTrackerService({ dbPath });
|
||||||
tracker2.handleMediaChange('/tmp/Frieren S01E02.mkv', 'Episode 2');
|
tracker2.handleMediaChange('/tmp/Frieren S01E02.mkv', 'Episode 2');
|
||||||
await waitForPendingAnimeMetadata(tracker2);
|
await waitForPendingAnimeMetadata(tracker2);
|
||||||
tracker2.recordCardsMined(1);
|
tracker2.recordCardsMined(1);
|
||||||
tracker2.recordSubtitleLine('second line', 0, 1);
|
tracker2.recordSubtitleLine('second line', 0, 1);
|
||||||
|
globalThis.__subminerTestNowMs = 1_700_003_000;
|
||||||
tracker2.destroy();
|
tracker2.destroy();
|
||||||
tracker2 = null;
|
tracker2 = null;
|
||||||
|
|
||||||
@@ -357,8 +362,10 @@ test('tracker rebuildLifetimeSummaries backfills retained sessions and is idempo
|
|||||||
`);
|
`);
|
||||||
beforeDb.close();
|
beforeDb.close();
|
||||||
|
|
||||||
|
globalThis.__subminerTestNowMs = 1_700_004_000;
|
||||||
tracker3 = new ImmersionTrackerService({ dbPath });
|
tracker3 = new ImmersionTrackerService({ dbPath });
|
||||||
const firstRebuild = await tracker3.rebuildLifetimeSummaries();
|
const firstRebuild = await tracker3.rebuildLifetimeSummaries();
|
||||||
|
globalThis.__subminerTestNowMs = 1_700_005_000;
|
||||||
const secondRebuild = await tracker3.rebuildLifetimeSummaries();
|
const secondRebuild = await tracker3.rebuildLifetimeSummaries();
|
||||||
|
|
||||||
const rebuiltDb = new Database(dbPath);
|
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.equal(secondRebuild.appliedSessions, firstRebuild.appliedSessions);
|
||||||
assert.ok(secondRebuild.rebuiltAtMs >= firstRebuild.rebuiltAtMs);
|
assert.ok(secondRebuild.rebuiltAtMs >= firstRebuild.rebuiltAtMs);
|
||||||
} finally {
|
} finally {
|
||||||
|
globalThis.__subminerTestNowMs = previousNowMs;
|
||||||
tracker?.destroy();
|
tracker?.destroy();
|
||||||
tracker2?.destroy();
|
tracker2?.destroy();
|
||||||
tracker3?.destroy();
|
tracker3?.destroy();
|
||||||
@@ -417,7 +425,7 @@ test('stats cli command runs lifetime rebuild when requested', async () => {
|
|||||||
getImmersionTracker: () => ({
|
getImmersionTracker: () => ({
|
||||||
rebuildLifetimeSummaries: async () => ({
|
rebuildLifetimeSummaries: async () => ({
|
||||||
appliedSessions: 4,
|
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, [
|
assert.deepEqual(calls, [
|
||||||
'ensureImmersionTrackerStarted',
|
'ensureImmersionTrackerStarted',
|
||||||
'info:Stats lifetime rebuild complete: appliedSessions=4 rebuiltAtMs=1710000000000',
|
'info:Stats lifetime rebuild complete: appliedSessions=4 rebuiltAtMs=1710000000',
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(responses, [
|
assert.deepEqual(responses, [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ type BackgroundStatsStopResult = {
|
|||||||
stale: boolean;
|
stale: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function formatLoggedNumber(value: number): string {
|
||||||
|
return Number.isFinite(value) ? value.toString() : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function writeStatsCliCommandResponse(
|
export function writeStatsCliCommandResponse(
|
||||||
responsePath: string,
|
responsePath: string,
|
||||||
payload: StatsCliCommandResponse,
|
payload: StatsCliCommandResponse,
|
||||||
@@ -143,7 +147,7 @@ export function createRunStatsCliCommandHandler(deps: {
|
|||||||
}
|
}
|
||||||
const result = await tracker.rebuildLifetimeSummaries();
|
const result = await tracker.rebuildLifetimeSummaries();
|
||||||
deps.logInfo(
|
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 });
|
writeResponseSafe(args.statsResponsePath, { ok: true });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -302,22 +302,28 @@ function setupPlaylistBrowserModalTest(options?: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('playlist browser test cleanup must delete injected globals that were originally absent', () => {
|
test('playlist browser test cleanup must delete injected globals that were originally absent', () => {
|
||||||
|
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, 'window'), false);
|
||||||
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
|
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
|
||||||
|
|
||||||
const env = setupPlaylistBrowserModalTest();
|
const env = setupPlaylistBrowserModalTest();
|
||||||
|
|
||||||
try {
|
|
||||||
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), true);
|
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, 'document'), true);
|
||||||
} finally {
|
|
||||||
env.restore();
|
env.restore();
|
||||||
}
|
|
||||||
|
|
||||||
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
|
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, 'document'), false);
|
||||||
assert.equal(typeof globalThis.window, 'undefined');
|
assert.equal(typeof globalThis.window, 'undefined');
|
||||||
assert.equal(typeof globalThis.document, '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 () => {
|
test('playlist browser modal opens with playlist-focused current item selection', async () => {
|
||||||
|
|||||||
@@ -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', () => {
|
test('findActiveSubtitleCueIndex prefers timing match before text fallback', () => {
|
||||||
const cues = [
|
const cues = [
|
||||||
{ startTime: 1, endTime: 2, text: 'same' },
|
{ startTime: 1, endTime: 2, text: 'same' },
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ const CLICK_SEEK_OFFSET_SEC = 0.08;
|
|||||||
const SNAPSHOT_POLL_INTERVAL_MS = 80;
|
const SNAPSHOT_POLL_INTERVAL_MS = 80;
|
||||||
const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240;
|
const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240;
|
||||||
const EMBEDDED_SIDEBAR_MAX_RATIO = 0.45;
|
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 {
|
function subtitleCueListsEqual(a: SubtitleCue[], b: SubtitleCue[]): boolean {
|
||||||
if (a.length !== b.length) {
|
if (a.length !== b.length) {
|
||||||
return false;
|
return false;
|
||||||
@@ -294,7 +302,7 @@ export function createSubtitleSidebarModal(
|
|||||||
!ctx.state.subtitleSidebarAutoScroll ||
|
!ctx.state.subtitleSidebarAutoScroll ||
|
||||||
ctx.state.subtitleSidebarActiveCueIndex < 0 ||
|
ctx.state.subtitleSidebarActiveCueIndex < 0 ||
|
||||||
(!force && ctx.state.subtitleSidebarActiveCueIndex === previousActiveCueIndex) ||
|
(!force && ctx.state.subtitleSidebarActiveCueIndex === previousActiveCueIndex) ||
|
||||||
Date.now() < ctx.state.subtitleSidebarManualScrollUntilMs
|
nowForUiTiming() < ctx.state.subtitleSidebarManualScrollUntilMs
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -547,7 +555,7 @@ export function createSubtitleSidebarModal(
|
|||||||
seekToCue(cue);
|
seekToCue(cue);
|
||||||
});
|
});
|
||||||
ctx.dom.subtitleSidebarList.addEventListener('wheel', () => {
|
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 () => {
|
ctx.dom.subtitleSidebarContent.addEventListener('mouseenter', async () => {
|
||||||
subtitleSidebarHovered = true;
|
subtitleSidebarHovered = true;
|
||||||
|
|||||||
@@ -92,6 +92,17 @@ function restoreGlobalProp<K extends keyof typeof globalThis>(
|
|||||||
Reflect.deleteProperty(globalThis, key);
|
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?: {
|
function setupYoutubePickerTestEnv(options?: {
|
||||||
windowValue?: YoutubePickerTestWindow;
|
windowValue?: YoutubePickerTestWindow;
|
||||||
customEventValue?: unknown;
|
customEventValue?: unknown;
|
||||||
@@ -153,6 +164,12 @@ function setupYoutubePickerTestEnv(options?: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('youtube picker test env restore deletes injected globals that were originally absent', () => {
|
test('youtube picker test env restore deletes injected globals that were originally absent', () => {
|
||||||
|
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, 'window'), false);
|
||||||
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
|
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
|
||||||
|
|
||||||
@@ -167,6 +184,10 @@ test('youtube picker test env restore deletes injected globals that were origina
|
|||||||
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
|
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
|
||||||
assert.equal(typeof globalThis.window, 'undefined');
|
assert.equal(typeof globalThis.window, 'undefined');
|
||||||
assert.equal(typeof globalThis.document, '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', () => {
|
test('youtube track picker close restores focus and mouse-ignore state', () => {
|
||||||
|
|||||||
@@ -9,6 +9,36 @@ export const DEFAULT_LOG_MAX_BYTES = 10 * 1024 * 1024;
|
|||||||
|
|
||||||
const TRUNCATED_MARKER = '[truncated older log content]\n';
|
const TRUNCATED_MARKER = '[truncated older log content]\n';
|
||||||
const prunedDirectories = new Set<string>();
|
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?: {
|
export function resolveLogBaseDir(options?: {
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
@@ -52,16 +82,20 @@ export function pruneLogFiles(
|
|||||||
return;
|
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) {
|
for (const entry of entries) {
|
||||||
const candidate = path.join(logsDir, entry);
|
const candidate = path.join(logsDir, entry);
|
||||||
let stats: fs.Stats;
|
let stats: fs.BigIntStats;
|
||||||
try {
|
try {
|
||||||
stats = fs.statSync(candidate);
|
stats = fs.statSync(candidate, { bigint: true });
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!stats.isFile() || !entry.endsWith('.log') || stats.mtimeMs >= cutoffMs) continue;
|
if (!stats.isFile() || !entry.endsWith('.log') || stats.mtimeNs >= cutoffNs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
fs.rmSync(candidate, { force: true });
|
fs.rmSync(candidate, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user