fix(tab-progress): drop the redundant status poll and quieten the test suite

The hook is mounted globally in WebSocketProvider, so refetchInterval on its
    per-printer status queries added one request per printer every 30s on every
    page. The Printers page already runs that fallback on the same query key, and
    useWebSocket writes ['printerStatus', id] straight into the cache, so the poll
    bought nothing outside the Printers page and cost a request per printer per
    tab everywhere else.

    Also captures document.title at mount instead of restoring to a hardcoded
    'Bambuddy', so the default no longer has to be kept in sync with index.html.

    jsdom has no canvas backend, so getContext('2d') logged a "Not implemented"
    jsdomError with a full React stack on every run of the hook's tests, and the
    favicon branch bailed on the null context and went untested. Stubbing
    getContext/toDataURL removes the noise and lets the ring code run, so the
    favicon swap and the restore-on-toggle-off path are now asserted.
This commit is contained in:
maziggy
2026-08-02 09:38:08 +02:00
5 changed files with 168 additions and 110 deletions
@@ -75,13 +75,47 @@ function wrapper() {
);
}
// jsdom has no canvas backend — calling getContext('2d') logs a "Not implemented"
// jsdomError (with a full React stack) into the suite output on every run, and
// leaves the favicon path untested because it bails on the null context. Stub
// both canvas calls so the ring code actually executes and the swap is assertable.
const RING_URL = 'data:image/png;base64,ring';
const fakeCtx = {
beginPath: vi.fn(),
arc: vi.fn(),
stroke: vi.fn(),
lineWidth: 0,
strokeStyle: '',
lineCap: 'butt',
} as unknown as CanvasRenderingContext2D;
const realGetContext = HTMLCanvasElement.prototype.getContext;
const realToDataURL = HTMLCanvasElement.prototype.toDataURL;
function faviconHref(): string {
return document.querySelector<HTMLLinkElement>('link[rel~="icon"]')!.href;
}
describe('usePrintProgressTitle effect', () => {
beforeEach(() => {
h.getPrinters.mockReset();
h.getPrinterStatus.mockReset();
HTMLCanvasElement.prototype.getContext = (() =>
fakeCtx) as typeof HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.toDataURL = (() =>
RING_URL) as typeof HTMLCanvasElement.prototype.toDataURL;
// Replaces <title> too, so set the title after wiring the head up — the hook
// captures document.title at mount.
document.head.innerHTML = '<link rel="icon" href="/favicon.svg">';
document.title = 'Bambuddy';
});
afterEach(() => cleanup());
afterEach(() => {
cleanup();
HTMLCanvasElement.prototype.getContext = realGetContext;
HTMLCanvasElement.prototype.toDataURL = realToDataURL;
});
it('is inert while the pref is off — never touches the tab title', async () => {
h.theme.value = { progressInTitle: false, resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
@@ -91,10 +125,11 @@ describe('usePrintProgressTitle effect', () => {
await new Promise((r) => setTimeout(r, 20));
expect(document.title).toBe('Something Else');
expect(faviconHref()).toContain('/favicon.svg');
expect(h.getPrinters).not.toHaveBeenCalled();
});
it('shows the active print percentage in the title when enabled', async () => {
it('shows the active print percentage in the title and swaps the favicon', async () => {
h.theme.value = { progressInTitle: true, resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
h.getPrinters.mockResolvedValue([{ id: 1 }]);
h.getPrinterStatus.mockResolvedValue({ state: 'RUNNING', progress: 42, remaining_time: 600 });
@@ -102,5 +137,21 @@ describe('usePrintProgressTitle effect', () => {
renderHook(() => usePrintProgressTitle(), { wrapper: wrapper() });
await waitFor(() => expect(document.title).toBe('42% · Bambuddy'));
expect(faviconHref()).toBe(RING_URL);
});
it('restores the original title and favicon when the pref is switched off', async () => {
h.theme.value = { progressInTitle: true, resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
h.getPrinters.mockResolvedValue([{ id: 1 }]);
h.getPrinterStatus.mockResolvedValue({ state: 'RUNNING', progress: 42, remaining_time: 600 });
const { rerender } = renderHook(() => usePrintProgressTitle(), { wrapper: wrapper() });
await waitFor(() => expect(document.title).toBe('42% · Bambuddy'));
h.theme.value = { ...h.theme.value, progressInTitle: false };
rerender();
await waitFor(() => expect(document.title).toBe('Bambuddy'));
expect(faviconHref()).toContain('/favicon.svg');
});
});
+1 -1
View File
@@ -19,7 +19,7 @@ interface ThemeContextType {
lightStyle: ThemeStyle;
lightBackground: LightBackground;
lightAccent: ThemeAccent;
// Show live print progress (% + green ring favicon) in the browser tab
// Show live print progress (% + accent-coloured ring favicon) in the browser tab
progressInTitle: boolean;
setProgressInTitle: (v: boolean) => void;
// Actions
+12 -5
View File
@@ -3,7 +3,6 @@ import { useEffect, useRef } from 'react';
import { api } from '../api/client';
import { useTheme } from '../contexts/ThemeContext';
const DEFAULT_TITLE = 'Bambuddy';
const FALLBACK_ACCENT = '#00ae42'; // Bambuddy green, if --accent can't be read (e.g. jsdom)
// A remaining_time <= 0 means "ETA not known yet" (the backend defaults it to 0,
@@ -111,15 +110,22 @@ export function usePrintProgressTitle() {
enabled: progressInTitle,
});
// No refetchInterval here on purpose. This hook is mounted globally, so a
// poll would add one request per printer every interval on every page — the
// Printers page already runs its own 30s fallback on this exact key. The
// WebSocket writes ['printerStatus', id] directly (useWebSocket), which keeps
// the tab live; a cosmetic title going stale during a WS outage is fine.
const statusQueries = useQueries({
queries: (progressInTitle ? printers ?? [] : []).map((p) => ({
queryKey: ['printerStatus', p.id],
queryFn: () => api.getPrinterStatus(p.id),
refetchInterval: 30000, // fallback; WebSocket drives live updates
})),
});
const originalsRef = useRef<Map<HTMLLinkElement, string>>(new Map());
// The tab's own title, captured before we ever touch it, so restoring doesn't
// depend on a constant matching index.html.
const defaultTitleRef = useRef(document.title);
// Whether we currently own the tab title/favicon. Lets us stay inert while
// off (never touch the tab) yet still restore once if we ever took it over.
const ownsRef = useRef(false);
@@ -129,12 +135,12 @@ export function usePrintProgressTitle() {
useEffect(() => {
if (progressInTitle && pct != null) {
document.title = `${pct}% · ${DEFAULT_TITLE}`;
document.title = `${pct}% · ${defaultTitleRef.current}`;
setFavicon(drawProgressFavicon(pct), originalsRef.current);
ownsRef.current = true;
} else if (ownsRef.current) {
// Disabled or idle after having taken over — hand the tab back.
document.title = DEFAULT_TITLE;
document.title = defaultTitleRef.current;
setFavicon(null, originalsRef.current);
ownsRef.current = false;
}
@@ -145,9 +151,10 @@ export function usePrintProgressTitle() {
useEffect(() => {
const originals = originalsRef.current;
const owns = ownsRef;
const defaultTitle = defaultTitleRef.current;
return () => {
if (owns.current) {
document.title = DEFAULT_TITLE;
document.title = defaultTitle;
setFavicon(null, originals);
}
};
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-utEp4da9.js"></script>
<script type="module" crossorigin src="/assets/index-BSGkCTjg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D4bpNaiw.css">
</head>
<body>