Merge remote-tracking branch 'origin/main' into feat/141-media-browse
This commit is contained in:
+37
-33
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { App } from './App';
|
||||
import { Button, Checkbox, Input, ProgressBar, Switch, Tabs, Toast, Tooltip } from './components';
|
||||
@@ -1274,19 +1274,17 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
expect(fetchCount('/api/libraries/scan-status')).toBe(1);
|
||||
|
||||
const sourceFetchesBeforePoll = fetchCount('/api/media-sources');
|
||||
intervalHandlers.forEach((handler) => handler());
|
||||
await runPollTick(intervalHandlers);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchCount('/api/libraries/scan-status')).toBeGreaterThan(1);
|
||||
});
|
||||
expect(fetchCount('/api/libraries/scan-status')).toBeGreaterThan(1);
|
||||
expect(fetchCount('/api/media-sources')).toBe(sourceFetchesBeforePoll);
|
||||
});
|
||||
|
||||
it('stops Libraries scan polling and refreshes sources once when scans complete', async () => {
|
||||
const intervalHandlers: Array<() => void> = [];
|
||||
let clearCount = 0;
|
||||
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler) => {
|
||||
if (typeof handler === 'function') {
|
||||
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
|
||||
if (typeof handler === 'function' && (timeout ?? 0) >= 10000) {
|
||||
intervalHandlers.push(handler as () => void);
|
||||
}
|
||||
|
||||
@@ -1311,11 +1309,9 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
expect(await screen.findByText('75%')).toBeInTheDocument();
|
||||
|
||||
const sourceFetchesBeforeCompletion = fetchCount('/api/media-sources');
|
||||
intervalHandlers.forEach((handler) => handler());
|
||||
await runPollTick(intervalHandlers);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('75%')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('75%')).not.toBeInTheDocument();
|
||||
expect(fetchCount('/api/media-sources')).toBe(sourceFetchesBeforeCompletion + 1);
|
||||
expect(clearCount).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -1390,14 +1386,12 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
|
||||
const sourceFetchesBeforeCompletion = fetchCount('/api/media-sources');
|
||||
|
||||
intervalHandlers.forEach((handler) => handler());
|
||||
expect(await screen.findByText('30%')).toBeInTheDocument();
|
||||
await runPollTick(intervalHandlers);
|
||||
expect(screen.getByText('30%')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
|
||||
|
||||
intervalHandlers.forEach((handler) => handler());
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('30%')).not.toBeInTheDocument();
|
||||
});
|
||||
await runPollTick(intervalHandlers);
|
||||
expect(screen.queryByText('30%')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
|
||||
expect(fetchCount('/api/media-sources')).toBe(sourceFetchesBeforeCompletion + 1);
|
||||
expect(clearCount).toBeGreaterThan(0);
|
||||
@@ -1440,17 +1434,13 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
expect(intervalHandlers.length).toBeGreaterThan(0);
|
||||
|
||||
// The id never shows up in scan-status. It survives a couple of ticks...
|
||||
intervalHandlers.forEach((handler) => handler());
|
||||
await waitFor(() => {
|
||||
expect(fetchCount('/api/libraries/scan-status')).toBe(3);
|
||||
});
|
||||
await runPollTick(intervalHandlers);
|
||||
expect(fetchCount('/api/libraries/scan-status')).toBe(3);
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
|
||||
|
||||
// ...but expires once the grace window runs out, freeing the button and the poll.
|
||||
intervalHandlers.forEach((handler) => handler());
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
|
||||
});
|
||||
await runPollTick(intervalHandlers);
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
|
||||
expect(clearCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -1496,18 +1486,14 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
expect(intervalHandlers.length).toBeGreaterThan(0);
|
||||
|
||||
// scan-status keeps erroring on every poll tick...
|
||||
intervalHandlers.forEach((handler) => handler());
|
||||
await waitFor(() => {
|
||||
expect(fetchCount('/api/libraries/scan-status')).toBe(3);
|
||||
});
|
||||
await runPollTick(intervalHandlers);
|
||||
expect(fetchCount('/api/libraries/scan-status')).toBe(3);
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
|
||||
|
||||
// ...but the same grace budget burns down on failures too, so persistent failure
|
||||
// eventually frees the button and stops the interval instead of polling forever.
|
||||
intervalHandlers.forEach((handler) => handler());
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
|
||||
});
|
||||
await runPollTick(intervalHandlers);
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
|
||||
expect(clearCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -3833,6 +3819,24 @@ function mockDashboardApi({
|
||||
});
|
||||
}
|
||||
|
||||
// Deterministically fires one captured poll tick for the mocked-setInterval polling tests.
|
||||
//
|
||||
// The Libraries poll interval is registered in a passive useEffect that only runs after
|
||||
// an out-of-act state commit (the async fetch that flips hasActiveScans), so `findBy*`
|
||||
// queries - which resolve on the DOM mutation, via MutationObserver - can win the race
|
||||
// against React's effect flush and observe an empty handler list (the #157 flake).
|
||||
// The leading empty act() flushes those pending effects, the length assertion turns a
|
||||
// still-missing registration into a clear failure instead of a silent no-op forEach,
|
||||
// and the act() around the handlers applies the fetch microtasks + state updates before
|
||||
// the caller asserts, so no assertion depends on wall-clock scheduling.
|
||||
async function runPollTick(intervalHandlers: Array<() => void>): Promise<void> {
|
||||
await act(async () => {});
|
||||
expect(intervalHandlers.length).toBeGreaterThan(0);
|
||||
await act(async () => {
|
||||
intervalHandlers.forEach((handler) => handler());
|
||||
});
|
||||
}
|
||||
|
||||
function fetchCount(path: string): number {
|
||||
return vi.mocked(window.fetch).mock.calls.filter(([input]) => input.toString() === path).length;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user