mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
test(playback): drive embedded MPV controller specs on a fake clock (#1434)
* test(playback): drive embedded MPV controller specs on a fake clock The `waitFor` helper in the three embedded-mpv-session-controller specs polled real timers against a `Date.now() + 1s` deadline. Under parallel Jest workers the controller's async startup chain (support load -> waitForStartupPaint -> session create -> update subscription) did not always settle inside that budget, so a different pair of tests failed on each run. Bound the drain loop by rounds on a fake clock instead of elapsed wall time, so machine load can no longer change the outcome. Each round advances 1ms rather than 0ms: `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 fires the outer hop and strands the inner one forever. Assertions and behavior under test are unchanged. Fake timers also stop the stalled tracker's 30s timeout leaking into the real timer queue, which removes the "worker process failed to exit gracefully" warning. Verified with 5x `nx test ui-playback --skip-nx-cache` and 5x the same suite at --maxWorkers=32 (the condition that reproduced the flake): 975/975 tests passing each time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(playback): scope the fake-clock claim in the MPV controller spec The comment said machine load "no longer changes the outcome", which overclaims. Removing the spec's own `Date.now()` deadline means the spec imposes no budget of its own, but Jest's per-test timeout still measures wall time — at extreme worker oversubscription these specs can still hit it, as every spec can. Comment only; no behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7952e6444d
commit
c723093890
3 files changed
+92
-18
No files matched your search
+15
-3
@@ -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<void> {
|
||||
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}`);
|
||||
}
|
||||
+28
-9
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
await drainRound();
|
||||
}
|
||||
}
|
||||
+49
-6
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
await drainRound();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user