Merge remote-tracking branch 'origin/main' into feat/141-media-browse
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m16s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

This commit is contained in:
2026-07-07 16:07:18 +02:00
+37 -33
View File
@@ -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;
}