diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts index 07761eecc..152f84f72 100644 --- a/e2e/login.spec.ts +++ b/e2e/login.spec.ts @@ -118,7 +118,8 @@ test.describe("login page", () => { await page.getByTestId("login-password-submit").click(); expect((await answer).status()).toBe(401); - await expect(page.getByText("Invalid credentials")).toBeVisible(); + // The page says it in its own words, not the server's. + await expect(page.getByText("Login failed")).toBeVisible(); await expect(page).toHaveURL(/\/auth\/login/); expect( (await context.cookies()).some((c) => c.name === SESSION_COOKIE), diff --git a/public/sw.js b/public/sw.js index 7b7df52cf..bf37050ea 100644 --- a/public/sw.js +++ b/public/sw.js @@ -68,7 +68,6 @@ self.addEventListener("activate", (event) => { self.registration.navigationPreload ? self.registration.navigationPreload.enable() : Promise.resolve(), - clearLeftoverAppBadge(), ]).then(() => self.clients.claim()), ); }); @@ -353,16 +352,6 @@ async function trimCache(cacheName, maxEntries) { } } -// Earlier releases set a dose count on the app icon; nothing sends one now, -// so clear what they left. Never fails the activation. -async function clearLeftoverAppBadge() { - try { - await self.navigator?.clearAppBadge?.(); - } catch { - // Optional. - } -} - self.addEventListener("push", (event) => { if (!event.data) return; diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index 6fc4a074e..76dff4bd5 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -482,13 +482,15 @@ describe("sw.js — activate", () => { expect(harness.navPreload.enabled).toBe(true); }); - it("clears the dose count an earlier release left on the app icon", async () => { + it("leaves the app icon badge to the page, never touches it while activating", async () => { + // A badge call from the worker can stall Chromium's worker thread, so the + // activation never claims its pages. The page clears a leftover count. const harness = bootServiceWorker(); const { badge } = swFakes(harness); await dispatchActivate(harness); - expect(badge.cleared).toBe(true); + expect(badge.cleared).toBe(false); }); }); diff --git a/src/components/__tests__/service-worker-registrar.test.ts b/src/components/__tests__/service-worker-registrar.test.ts index d4ece3ff7..9ffb644f6 100644 --- a/src/components/__tests__/service-worker-registrar.test.ts +++ b/src/components/__tests__/service-worker-registrar.test.ts @@ -8,7 +8,10 @@ */ import { describe, expect, it } from "vitest"; -import { shouldRegisterServiceWorker } from "../service-worker-registrar"; +import { + clearLeftoverAppBadge, + shouldRegisterServiceWorker, +} from "../service-worker-registrar"; describe("shouldRegisterServiceWorker", () => { it("registers in production with window + serviceWorker support", () => { @@ -29,3 +32,37 @@ describe("shouldRegisterServiceWorker", () => { expect(shouldRegisterServiceWorker("production", true, false)).toBe(false); }); }); + +describe("clearLeftoverAppBadge", () => { + function fakeWindow(standalone: boolean, hasApi = true) { + const calls = { cleared: 0 }; + const win = { + matchMedia: () => ({ matches: standalone }), + navigator: hasApi + ? { + clearAppBadge: async () => { + calls.cleared += 1; + }, + } + : {}, + } as unknown as Window; + return { win, calls }; + } + + it("clears the icon badge in the installed app", () => { + const { win, calls } = fakeWindow(true); + clearLeftoverAppBadge(win); + expect(calls.cleared).toBe(1); + }); + + it("leaves a browser tab alone", () => { + const { win, calls } = fakeWindow(false); + clearLeftoverAppBadge(win); + expect(calls.cleared).toBe(0); + }); + + it("does nothing where the badge API does not exist", () => { + const { win } = fakeWindow(true, false); + expect(() => clearLeftoverAppBadge(win)).not.toThrow(); + }); +}); diff --git a/src/components/service-worker-registrar.tsx b/src/components/service-worker-registrar.tsx index fb33f5fea..a9b60cc21 100644 --- a/src/components/service-worker-registrar.tsx +++ b/src/components/service-worker-registrar.tsx @@ -19,6 +19,22 @@ export function shouldRegisterServiceWorker( return true; } +/** + * Earlier releases set a dose count on the installed app's icon, and nothing + * sets one now, so a leftover count would stay for good. Cleared from the page + * of the installed app only, where the badge exists; never from the worker, + * where the call can stall Chromium's worker thread. + */ +export function clearLeftoverAppBadge(win: Window): void { + const standalone = + win.matchMedia?.("(display-mode: standalone)").matches ?? false; + const nav = win.navigator as Navigator & { + clearAppBadge?: () => Promise; + }; + if (!standalone || typeof nav.clearAppBadge !== "function") return; + nav.clearAppBadge().catch(() => undefined); +} + /** * App-wide service-worker registration. * @@ -64,6 +80,7 @@ export function ServiceWorkerRegistrar(): null { .catch(() => { /* best effort — registration failure must never break the app */ }); + clearLeftoverAppBadge(window); }); }; diff --git a/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx b/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx index 7d9c1bef0..a9f3cdb19 100644 --- a/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx +++ b/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx @@ -164,12 +164,12 @@ describe("useUrlFilterSync with FilterBar", () => { it("writes a facet picked from the open menu as a new history entry", async () => { const { pill, history, pick } = await mount(); - const entries = window.history.length; await pick("Type", "Pulse"); await expectSearch("?type=PULSE"); + // A push and no replace is the new entry. `history.length` is no proof: + // the browser caps it, and other files in this frame push entries too. expect(history.pushes()).toEqual(["?type=PULSE"]); expect(history.replaces()).toEqual([]); - expect(window.history.length).toBe(entries + 1); await expect.element(pill("Type: Pulse")).toBeInTheDocument(); });