diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.dpr.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.dpr.spec.ts index 79cf74df6..8a4058089 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.dpr.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.dpr.spec.ts @@ -50,6 +50,9 @@ describe('EmbeddedMpvSessionController devicePixelRatio watch', () => { let mediaQueries: FakeMediaQueryList[]; beforeEach(() => { + // See `waitFor`: the startup chain is drained on a virtual clock so a + // loaded machine cannot change the outcome. + jest.useFakeTimers(); electron = { platform: 'win32', getEmbeddedMpvSupport: jest @@ -110,6 +113,7 @@ describe('EmbeddedMpvSessionController devicePixelRatio watch', () => { TestBed.resetTestingModule(); delete (window as unknown as { electron?: unknown }).electron; delete (window as unknown as { matchMedia?: unknown }).matchMedia; + jest.useRealTimers(); jest.restoreAllMocks(); }); @@ -197,17 +201,25 @@ function createSession(): EmbeddedMpvSession { }; } +/** + * Settle the controller's async startup chain on the fake clock, bounded by + * drain rounds rather than wall-clock time — under parallel Jest workers a + * real-timer deadline expired before the chain settled and failed at random. + * + * One millisecond per round, never zero: `waitForStartupPaint` nests rAF + * inside rAF, and a zero-delay timer scheduled from inside a timer callback is + * clamped to the next millisecond, so a 0ms advance strands the inner hop. + */ async function waitFor( condition: () => boolean, description: string ): Promise { - const deadline = Date.now() + 1_000; - while (Date.now() < deadline) { + for (let round = 0; round < 100; round += 1) { if (condition()) { return; } await Promise.resolve(); - await new Promise((resolve) => window.setTimeout(resolve, 0)); + await jest.advanceTimersByTimeAsync(1); } throw new Error(`Timed out waiting for ${description}`); } diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts index f708cdb7d..c6ed2d88a 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts @@ -27,6 +27,9 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => { }); beforeEach(() => { + // See `waitFor`: the startup chain is drained on a virtual clock so a + // loaded machine cannot change the outcome. + jest.useFakeTimers(); electron = { platform: 'darwin', getEmbeddedMpvSupport: jest @@ -167,7 +170,6 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => { it('flags a stalled session after 30s of loading and clears it on playback', async () => { const controller = TestBed.inject(EmbeddedMpvSessionController); - jest.useFakeTimers(); controller.session.set(createSession({ status: 'loading' })); TestBed.tick(); @@ -243,8 +245,7 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => { teardown(); resolveLoad?.(); - await Promise.resolve(); - await new Promise((resolve) => window.setTimeout(resolve, 0)); + await flush(); expect(electron.attachEmbeddedMpvFrameView).not.toHaveBeenCalled(); expect(controller.session()).toBeNull(); @@ -288,8 +289,7 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => { 'requestAnimationFrame' ); resolveAttach?.(true); - await Promise.resolve(); - await new Promise((resolve) => window.setTimeout(resolve, 0)); + await flush(); expect(requestAnimationFrame).not.toHaveBeenCalled(); expect(controller.session()).toBeNull(); @@ -377,17 +377,36 @@ function createSession( }; } +async function drainRound(): Promise { + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(1); +} + +/** + * Settle the controller's async startup chain on the fake clock, bounded by + * drain rounds rather than wall-clock time — under parallel Jest workers a + * real-timer deadline expired before the chain settled and failed at random. + * + * One millisecond per round, never zero: `waitForStartupPaint` nests rAF + * inside rAF, and a zero-delay timer scheduled from inside a timer callback is + * clamped to the next millisecond, so a 0ms advance strands the inner hop. + */ async function waitFor( condition: () => boolean, description: string ): Promise { - const deadline = Date.now() + 1_000; - while (Date.now() < deadline) { + for (let round = 0; round < 100; round += 1) { if (condition()) { return; } - await Promise.resolve(); - await new Promise((resolve) => window.setTimeout(resolve, 0)); + await drainRound(); } throw new Error(`Timed out waiting for ${description}`); } + +/** Settle everything pending where there is no condition to poll for. */ +async function flush(): Promise { + for (let round = 0; round < 5; round += 1) { + await drainRound(); + } +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts index b70512184..09fd45449 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts @@ -24,6 +24,10 @@ describe('EmbeddedMpvSessionController', () => { let testingModuleDestroyed: boolean; beforeEach(() => { + // Fake timers before anything else: the controller's startup chain is + // driven from here (see `waitFor`), and the rAF shim below defers + // through `setTimeout`, so it has to resolve to the faked one. + jest.useFakeTimers(); testingModuleDestroyed = false; sessionUpdate = null; unsubscribeSessionUpdate = jest.fn(); @@ -98,6 +102,9 @@ describe('EmbeddedMpvSessionController', () => { TestBed.resetTestingModule(); } delete (window as unknown as { electron?: unknown }).electron; + // After teardown has cancelled the controller's timers, so the stalled + // tracker's 30s timeout never leaks into the real timer queue. + jest.useRealTimers(); jest.restoreAllMocks(); }); @@ -218,7 +225,7 @@ describe('EmbeddedMpvSessionController', () => { controller.session.set(newer); rejectPrepare?.(new Error('native module missing')); - await new Promise((resolve) => window.setTimeout(resolve, 0)); + await flush(); expect(controller.session()).toBe(newer); expect(controller.sessionId()).toBe('mpv-2'); @@ -363,19 +370,55 @@ function createSession( }; } +/** + * Advance the fake clock by one drain round: flush pending microtasks (the IPC + * promises the startup chain awaits), then run every timer now due — which is + * how the rAF shim's continuations get to run. + * + * One millisecond, never zero: a zero-delay timer scheduled from *inside* a + * timer callback is clamped to the next millisecond, and `waitForStartupPaint` + * nests exactly that way (rAF inside rAF). Advancing by 0 fires the outer hop + * and strands the inner one forever. + */ +async function drainRound(): Promise { + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(1); +} + +/** + * Settle the controller's async startup chain, then assert. + * + * The bound is a number of drain rounds, not elapsed wall-clock time. Polling + * real timers against a `Date.now()` deadline made these specs load-sensitive: + * under parallel Jest workers the budget expired before the chain settled, so + * a different pair of tests failed on each run. With the clock virtual the + * spec imposes no deadline of its own — the rounds only ever advance when this + * loop says so — so settling stops being a race against the machine. Jest's + * own per-test timeout still applies, as it does to every spec. + */ async function waitFor( condition: () => boolean, description: string ): Promise { - const deadline = Date.now() + 1_000; - - while (Date.now() < deadline) { + // Generous next to the ~4 rounds the longest chain needs, while still + // failing fast (and reporting `description`) if it never settles. + for (let round = 0; round < 100; round += 1) { if (condition()) { return; } - await Promise.resolve(); - await new Promise((resolve) => window.setTimeout(resolve, 0)); + await drainRound(); } throw new Error(`Timed out waiting for ${description}`); } + +/** + * Settle everything pending with no condition to poll for — used by the + * negative assertions, where the point is that a late continuation runs and + * still changes nothing. + */ +async function flush(): Promise { + for (let round = 0; round < 5; round += 1) { + await drainRound(); + } +}