From e945ac9935a509b8e32360abe0591829b0e4850c Mon Sep 17 00:00:00 2001 From: mp Date: Mon, 26 Oct 2026 17:36:16 +0900 Subject: [PATCH 01/12] feat(push): show device delivery health and actionable repair (#1039) --- DESIGN.md | 30 +- THIRD_PARTY_NOTICES.md | 3 +- changelog.d/device-push-health.md | 9 + docs/development.md | 1 + scripts/ci-browser.sh | 1 + scripts/push-health-demo-regression.ts | 394 +++++++++++++++++++++++++ scripts/push-settings-regression.ts | 29 +- server/index.ts | 5 +- server/push-health.contract.test.ts | 149 ++++++++++ server/push.test.ts | 239 +++++++++++++++ server/push.ts | 243 +++++++++++++-- shared/protocol.ts | 5 +- shared/push.ts | 21 ++ site/demo/fixtures.ts | 20 ++ site/demo/transport.ts | 67 ++++- src/App.tsx | 6 +- src/components/PushHealthNotice.css | 14 + src/components/PushHealthNotice.tsx | 32 ++ src/components/PushTestControls.tsx | 53 ++-- src/lib/api.ts | 16 + src/lib/i18n/devices.ja.ts | 4 + src/lib/i18n/devices.ko.ts | 8 +- src/lib/i18n/devices.zh.ts | 4 + src/lib/push.ts | 36 +++ src/lib/pushHealth.test.ts | 276 +++++++++++++++++ src/lib/pushHealth.ts | 197 +++++++++++++ src/lib/useNotifications.ts | 26 +- 27 files changed, 1829 insertions(+), 59 deletions(-) create mode 100644 changelog.d/device-push-health.md create mode 100644 scripts/push-health-demo-regression.ts create mode 100644 server/push-health.contract.test.ts create mode 100644 src/components/PushHealthNotice.css create mode 100644 src/components/PushHealthNotice.tsx create mode 100644 src/lib/pushHealth.test.ts create mode 100644 src/lib/pushHealth.ts diff --git a/DESIGN.md b/DESIGN.md index ca51978010..9f17c16645 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -776,8 +776,8 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`. not restore, `--term-bg` and the rule. A tab strip takes the same surface as the header over it from `769px` (`--strip-bg`: `--bg` under the chat lens, `--term-bg` otherwise, `--bg-panel` below `769px`) and keeps its hairline, under the chat the one line over the transcript (no - fade under it). The update notice and a PC's action banner are drawn in the pane column, over - the tab strip, never across the window: the sidebar and its top row stay one piece. A layout + fade under it). The update notice, the alerts health notice and a PC's action banner are drawn in + the pane column, over the tab strip, never across the window: the sidebar and its top row stay one piece. A layout call the palette made that a PC's bridge from before the route answered 404 to reads there too, for a moment, as a `.pane-notice` line of `--status-blocked` `--fs-xs` text under a hairline ("This PC's bridge does not offer this yet"); the tab's menu says the same at the strip's end. With the @@ -3027,6 +3027,32 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`. - Not for the pane already open, and not while the app is hidden (system notifications cover that). - Reduced motion: it fades in and out where it rests, without falling or spreading. +### Alerts health notice (`.update-notice.is-alerts`) +- This device asked for alerts and the last push to it failed for good: one line in the pane + column, on the update notice's row (`.update-notice`: `--space-2 --space-4` padding, `--fs-xs`, + a hairline under it), tinted `--status-blocked-tint` with `--text-strong` text and a `BellOff` + glyph in `--status-blocked` before the sentence, so the state is not carried by the tint alone. + One sentence, one `.btn` at the end of the line (under it on a phone): **Turn alerts on again** + where subscribing anew mends it (the push service retired the subscription, the server does not + know it, it was made for another server key), reading **Turning alerts on…** and disabled + while the repair runs; **Alerts**, opening Settings → Alerts, for a refusal, which + re-subscribing does not mend and Send test answers. Not dismissible: the line leaves on the + push the repair or a test proves, or when alerts are turned off here. +- What it says is decided once (`lib/pushHealth.ts`) and read by this line and by the Test + notification row of Settings → Alerts alike, so the two never disagree and no second banner + competes: Settings' hint carries the same sentence, its **Turn alerts on again** is the same + repair, and either place's button waits while the other's runs. A fault is a permanent failure + strictly newer than the last success; a failure an alert has since gone past, no answer, + throttling and a server error are history, at most one dim line in Settings. A denied + permission has no notice (the More menu's Alerts item already reads **On in the app**); + Settings says what to allow. The PC out of reach changes nothing, and nothing is said of a + subscription nobody has looked at: no probe push is ever sent to find out. +- Design: herdr's own single-notice flow (the update, exposure and PC-action rows) and the + existing Send test / Turn alerts on again pair, extended rather than a second surface. Neither + the Codex app nor the Claude app surfaces push-delivery health, so there is no precedent to + follow there; Agent of Empires' classifier (MIT, THIRD_PARTY_NOTICES.md) is adapted for the + rule, with its repair narrowed to the faults re-subscribing mends. + ### Token gate - A centered password card replaces the entire shell while authentication is required. It has a real label, autofocused field, primary Unlock button and linked alert text. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f6f9ca4414..4d4a32a979 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -116,7 +116,8 @@ The sidebar's label filter (`src/lib/sidebarFilter.ts`) adapts the idea of `web/src/components/sidebar/filterGroups.ts` at revision `b3141c8a310338dedf87ebbd13a0d67ecd69030d`, that a sidebar row survives a typed filter when it, a header over it or a row under it matches; its substring matcher, facets and sunk/trash semantics are -not taken over. Under the MIT License printed above, with this copyright notice: +not taken over. `src/lib/pushHealth.ts` adapts its push health classifier (`web/src/lib/pushHealth.ts`) +at the same revision. Under the MIT License printed above, with this copyright notice: ``` Copyright (c) 2026 Nathan Brake diff --git a/changelog.d/device-push-health.md b/changelog.d/device-push-health.md new file mode 100644 index 0000000000..d2a8469cc2 --- /dev/null +++ b/changelog.d/device-push-health.md @@ -0,0 +1,9 @@ +### Added +- A device whose push alerts stopped is told why: when the push service retired its subscription, + the server no longer knows it or it was made for another server key, one line over the pane and + the Test notification row of Settings → Alerts say so and offer Turn alerts on again, which + subscribes the device anew; a refusal is told and opens Alerts, where Send test answers it. The + server keeps the outcome of the last push to each subscription (`push-delivery.json`, owner-only, + bounded like the retired list) and answers `POST /api/push/status` to the subscription's owner + holding its keys alone. A failure older than the last success, no answer, throttling or a server + error never raises the line, and no probe push is sent to find out. diff --git a/docs/development.md b/docs/development.md index 115b9f2711..12f91e7f2f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -167,6 +167,7 @@ bun scripts/key-bar-hold-repeat-regression.ts # phone touch key-bar taps, holds bun scripts/settings-pages-demo-regression.ts # every Settings page on a 390px and a 320px phone, and Back stepping out of the dialog bun scripts/interface-font-demo-regression.ts # Interface font at 390 and 1440: System and a typed list lead --font-ui, a reload keeps it, Default restores it; the message box takes the Chat font bun scripts/theme-first-paint-demo-regression.ts # the saved theme in every painted frame before and after the app starts, root and demo builds under the app's CSP +bun scripts/push-health-demo-regression.ts # the alerts health notice with a stand-in PushManager: a retired subscription told once over the pane and in Settings, repaired once; a refusal, a key mismatch, denied, unsupported, offline, loading and a watching device's refused test, at 390 and 1440, light and dark, Korean at 390 bun scripts/chat-browser-qa.ts # chat lens end to end bun scripts/omo-plan-browser-regression.ts # OmO todo progress and plan panel on isolated native fixtures bun scripts/pane-input-focus-demo-regression.ts # the input focus shortcut against fictional panes diff --git a/scripts/ci-browser.sh b/scripts/ci-browser.sh index e7e8d6e588..7983441830 100644 --- a/scripts/ci-browser.sh +++ b/scripts/ci-browser.sh @@ -77,6 +77,7 @@ run_script scripts/settings-pages-demo-regression.ts run_script scripts/interface-font-demo-regression.ts run_script scripts/theme-first-paint-demo-regression.ts run_script scripts/security-summary-demo-regression.ts +run_script scripts/push-health-demo-regression.ts run_script scripts/cliproxy-settings-regression.ts run_script scripts/pane-input-focus-demo-regression.ts run_script scripts/terminal-keyboard-demo-regression.ts diff --git a/scripts/push-health-demo-regression.ts b/scripts/push-health-demo-regression.ts new file mode 100644 index 0000000000..ba037a7358 --- /dev/null +++ b/scripts/push-health-demo-regression.ts @@ -0,0 +1,394 @@ +/** + * The alerts health notice and Settings → Alerts on the unmodified app over the demo's fixture + * transport, with a stand-in PushManager lent to the page: a push service that retired this + * device's subscription is told once, over the pane and in Settings alike, repaired once from + * Settings, and the line leaves on the success the repair proves. Then the states around it: a + * refusal (no repair, Alerts opens Settings), a key mismatch, a denied permission, no push + * support, the PC out of reach, a look still under way, and a test refused to a watching + * device. No notification is sent to any real device; all files and HTTP traffic stay in this + * disposable, loopback-only app, and no herdr session is opened. + * + * UI_EVIDENCE_DIR: push-health-<390|1440>-[-ko][-].png and manifest.json. + */ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { chromium, type Browser, type Locator, type Page } from "playwright-core"; +import { DEMO_PUSH } from "../site/demo/fixtures.ts"; +import { KO } from "../src/lib/i18n.ko.ts"; +import { buildDemoApp } from "./demo-build.ts"; +import { chromePath } from "./chrome-path.ts"; +import { openSettingsPage } from "./settings-page.ts"; + +type Theme = "light" | "dark"; +type Language = "en" | "ko"; +interface Shot { file: string; width: number; height: number; theme: Theme; language: Language; state: string; sha256: string; capturedAt: string } + +const EVIDENCE = process.env.UI_EVIDENCE_DIR; +const manifest: Shot[] = []; +const results: Record = {}; +const PHONE = { width: 390, height: 844 }; +const DESKTOP = { width: 1440, height: 900 }; +const NOTICE = '[data-testid="push-health-notice"]'; +const HINT = '[data-testid="push-health-hint"]'; +const QA_KEY = "herdr-web-ui:push-health-qa"; + +/** + * Lent to the page after the demo transport: a PushManager whose subscriptions are the fixtures + * (site/demo/fixtures.ts) or fresh ones, and a gate on the transport's fetch so an answer can be + * held back, counted, refused or lost. `localStorage[QA_KEY]` picks the state the page loads in. + */ +const STAND_IN = ``; + +/** + * Run around the demo transport's own script: the transport ends the turn it opens with on a 4500 ms timer + * (site/demo/transport.ts, "the turn in progress"), which would land at whatever moment a capture is taken. + * That one timer is held, never run, so every frame shows the same turn in progress; the page's own timers + * are untouched once the transport has loaded. + */ +const HOLD_DEMO_TURN = ``; +const RELEASE_TIMERS = ``; + +declare global { + interface Window { + __heldDemoTurn?: () => void; + __push: { + counts: Record; + offline: boolean; + hold(key: string): void; + release(key: string): void; + refuse(key: string, status: number, code: string): void; + respond(key: string, body: object): void; + answerAgain(key: string): void; + holdSubscription(name: "retired" | "refused" | "mismatch"): void; + endpoint(): string | null; + }; + } +} + +const sha256 = (path: string): string => createHash("sha256").update(readFileSync(path)).digest("hex"); + +async function capture(page: Page, width: number, theme: Theme, language: Language, state: string): Promise { + if (!EVIDENCE) return; + const file = `push-health-${width}-${theme}${language === "ko" ? "-ko" : ""}${state === "rest" ? "" : `-${state}`}.png`; + const path = join(EVIDENCE, file); + // a sheet still rising or dropping is drawn at its end, so a capture never lands mid-slide + await page.screenshot({ path, animations: "disabled" }); + const viewport = page.viewportSize()!; + manifest.push({ file, width: viewport.width, height: viewport.height, theme, language, state, sha256: sha256(path), capturedAt: new Date().toISOString() }); +} + +const count = (page: Page, key: string): Promise => page.evaluate((k) => window.__push.counts[k] ?? 0, key); +const waitForCount = (page: Page, key: string, least: number): Promise => page.waitForFunction(([k, n]) => (window.__push.counts[k] ?? 0) >= n, [key, least] as const); +const cameToFront = (page: Page): Promise => page.evaluate(() => document.dispatchEvent(new Event("visibilitychange"))); +/** Nothing in the page scrolls sideways: no control reaches past the viewport. */ +const noSidewaysScroll = (page: Page): Promise => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth && [...document.querySelectorAll(".settings-dialog, .pane-column")].every((el) => el.scrollWidth <= el.clientWidth + 0.5)); +const touchSized = (locator: Locator): Promise => locator.evaluate((el) => { const box = el.getBoundingClientRect(); return box.width >= 44 && box.height >= 44; }); + +async function run(browser: Browser, origin: string, width: number, theme: Theme, language: Language): Promise { + const label = (text: string): string => language === "ko" ? KO[text] ?? text : text; + const phone = width === PHONE.width; + const viewport = phone ? PHONE : DESKTOP; + const tag = `${width} ${theme} ${language}`; + const open = async (mode: string) => { + const context = await browser.newContext({ viewport, ...(phone ? { deviceScaleFactor: 3, isMobile: true, hasTouch: true } : {}), locale: language === "ko" ? "ko-KR" : "en-US", permissions: ["notifications"] }); + await context.addInitScript(([settings, key, state]) => { localStorage.setItem("herdr-web-ui:settings", settings); localStorage.setItem(key, state); }, [JSON.stringify({ language, theme, alertsOn: true }), QA_KEY, mode] as const); + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(origin); + await page.locator(".conn-live").waitFor(); + assert.equal(await page.evaluate(() => typeof window.__heldDemoTurn), "function", `${tag}: the demo's turn is held, so no capture races its end`); + assert.equal(await page.evaluate(() => document.documentElement.dataset["theme"]), theme, `${tag}: the page is drawn in the ${theme} theme`); + return { context, page, errors }; + }; + const openAlerts = async (page: Page): Promise => { + const dialog = page.locator(".settings-dialog"); + if (await dialog.count() === 0) await page.keyboard.press("ControlOrMeta+Shift+Comma"); + await openSettingsPage(page, label("Alerts"), label("Back to settings")); + return dialog; + }; + const closeSettings = async (page: Page): Promise => { + await page.getByRole("button", { name: label("Close settings"), exact: true }).click(); + await page.locator(".settings-dialog").waitFor({ state: "detached" }); + // a phone's sheet drops out of sight after React removed it: its last frame hangs on an inert host at + // the end of (lib/sheets.ts) until it has slid out, and a capture waits for it to go + await page.waitForFunction(() => ![...document.body.children].some((node) => node instanceof HTMLDivElement && node.inert && node.getAttribute("aria-hidden") === "true" && node.childElementCount === 0)); + }; + const notice = (page: Page): Locator => page.locator(NOTICE); + const hint = (page: Page): Locator => page.locator(HINT); + const GONE = label("The push service retired this device's subscription. Turn alerts on again to make a new one."); + const SENT = label("Test notification sent. Check this device for the alert."); + const REPAIR = label("Turn alerts on again"); + const REPAIRING = label("Turning alerts on…"); + + // ---- the retired device: told once, repaired once from Settings ---------------------------- + { + const { context, page, errors } = await open("fresh"); + try { + await waitForCount(page, "POST /api/push/status", 1); + assert.equal(await notice(page).count(), 0, `${tag}: a registered subscription with a clean record shows no notice`); + // the push service retired this device's subscription since: the app comes to the front and reads it + await page.evaluate(() => window.__push.holdSubscription("retired")); + await cameToFront(page); + await notice(page).waitFor(); + assert.equal(await notice(page).getAttribute("data-problem"), "gone"); + assert.equal(await notice(page).locator("span").textContent(), GONE); + assert.equal(await notice(page).count(), 1, `${tag}: one notice`); + assert.ok(await noSidewaysScroll(page), `${tag}: nothing scrolls sideways with the notice`); + const button = notice(page).getByRole("button", { name: REPAIR, exact: true }); + if (phone) assert.ok(await touchSized(button), `${tag}: the notice's button is a finger-sized target`); + await capture(page, width, theme, language, "rest"); + if (!phone) { + await button.hover(); + await capture(page, width, theme, language, "hover"); + await page.mouse.move(0, 0); + } + await button.focus(); + if (!(await button.evaluate((el) => el.matches(":focus-visible")))) { await page.keyboard.press("Tab"); await page.keyboard.press("Shift+Tab"); } + assert.ok(await button.evaluate((el) => el.matches(":focus-visible") && document.activeElement === el), `${tag}: the button shows its focus ring from the keyboard`); + await capture(page, width, theme, language, "focus"); + const box = (await button.boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await capture(page, width, theme, language, "pressed"); + await page.mouse.move(0, 0); + await page.mouse.up(); + assert.equal(await count(page, "POST /api/push/subscribe"), 1, `${tag}: a press let go elsewhere starts no repair`); + + // a look still under way: nothing changes until the answer comes + await page.evaluate(() => window.__push.hold("POST /api/push/status")); + await cameToFront(page); + await waitForCount(page, "POST /api/push/status", 3); + await capture(page, width, theme, language, "loading"); + assert.equal(await notice(page).count(), 1); + await page.evaluate(() => window.__push.release("POST /api/push/status")); + // the PC out of reach: what was known stands + await page.evaluate(() => { window.__push.offline = true; }); + await cameToFront(page); + await waitForCount(page, "POST /api/push/status", 4); + await page.locator(".conn-live").waitFor(); + assert.equal(await notice(page).count(), 1, `${tag}: the notice stays while the PC is out of reach`); + await capture(page, width, theme, language, "offline"); + await page.evaluate(() => { window.__push.offline = false; }); + + // Settings says the same, once; the repair runs there + const dialog = await openAlerts(page); + await hint(page).filter({ hasText: GONE }).waitFor(); + assert.equal(await page.locator(".update-notice.is-alerts").count(), 1, `${tag}: Settings adds no second banner`); + const repair = dialog.getByRole("button", { name: REPAIR, exact: true }); + if (phone) assert.ok(await touchSized(repair), `${tag}: the repair button in Settings is finger-sized`); + assert.ok(await noSidewaysScroll(page), `${tag}: Settings fits`); + await capture(page, width, theme, language, "settings"); + await page.evaluate(() => { window.__push.hold("POST /api/push/subscribe"); window.__push.hold("POST /api/push/test"); }); + await repair.click(); + await dialog.getByRole("button", { name: REPAIRING, exact: true }).waitFor(); + assert.equal(await dialog.getByRole("button", { name: REPAIRING, exact: true }).isDisabled(), true); + assert.equal(await notice(page).getByRole("button", { name: REPAIRING, exact: true }).isDisabled(), true, `${tag}: the notice's button waits with Settings'`); + assert.equal(await dialog.getByRole("button", { name: label("Send test"), exact: true }).isDisabled(), true); + await capture(page, width, theme, language, "repairing"); + // the retired endpoint is refused and a new subscription made, then the confirmation push, then the look that clears the line + await page.evaluate(() => window.__push.release("POST /api/push/subscribe")); + await waitForCount(page, "POST /api/push/test", 1); + assert.equal(await notice(page).count(), 1, `${tag}: still one notice until the push is proven`); + await page.evaluate(() => window.__push.release("POST /api/push/test")); + await hint(page).filter({ hasText: SENT }).waitFor(); + await notice(page).waitFor({ state: "detached" }); + assert.equal(await count(page, "POST /api/push/subscribe"), 3, `${tag}: the retired endpoint was handed over, refused, and a new one registered`); + assert.equal(await count(page, "POST /api/push/test"), 1, `${tag}: one confirmation push`); + assert.match((await page.evaluate(() => window.__push.endpoint())) ?? "", /device-2$/); + await capture(page, width, theme, language, "result"); + if (phone) { + await page.locator(".settings-body:not([hidden])").evaluate((body) => { body.scrollTop = body.scrollHeight; }); + await capture(page, width, theme, language, "settings-end"); + } + results[`${tag} retired-device-repair`] = { notices: 1, subscribeCalls: 3, testCalls: 1, cleared: true }; + + // a watching device: it may receive alerts, but its test is refused (403 read_only); the line says it can only + // watch, Send test becomes unavailable, nothing is registered and no banner appears + await page.evaluate(() => window.__push.refuse("POST /api/push/test", 403, "read_only")); + const registered = await count(page, "POST /api/push/subscribe"); + await dialog.getByRole("button", { name: label("Send test"), exact: true }).click(); + await hint(page).filter({ hasText: label("This device can only watch.") }).waitFor(); + assert.equal(await count(page, "POST /api/push/subscribe"), registered, `${tag}: a refused test changes no registration`); + assert.equal(await page.locator(".update-notice.is-alerts").count(), 0); + assert.equal(await dialog.getByRole("button", { name: label("Send test"), exact: true }).isDisabled(), true, `${tag}: a watching device cannot send a test`); + await capture(page, width, theme, language, "observe"); + await page.evaluate(() => window.__push.answerAgain("POST /api/push/test")); + await closeSettings(page); + + // a subscription made for another server key: the longest line, repaired from the notice + await page.evaluate(() => window.__push.holdSubscription("mismatch")); + await cameToFront(page); + await notice(page).waitFor(); + assert.equal(await notice(page).getAttribute("data-problem"), "key-mismatch"); + assert.ok(await noSidewaysScroll(page), `${tag}: the longest line fits`); + await capture(page, width, theme, language, "key-mismatch"); + await openAlerts(page); + await hint(page).filter({ hasText: label("This device's push subscription was made for another server key. Turn alerts on again to make a new one.") }).waitFor(); + await capture(page, width, theme, language, "key-mismatch-settings"); + await closeSettings(page); + const before = await count(page, "POST /api/push/subscribe"); + await notice(page).getByRole("button", { name: REPAIR, exact: true }).click(); + await notice(page).waitFor({ state: "detached" }); + assert.equal(await count(page, "POST /api/push/subscribe"), before + 1, `${tag}: a mismatched key is replaced with one registration`); + await capture(page, width, theme, language, "repaired"); + + // the server no longer knows this subscription: told as missing, with the same repair + await page.evaluate(() => window.__push.refuse("POST /api/push/status", 404, "subscription_not_found")); + await cameToFront(page); + await notice(page).and(page.locator('[data-problem="missing"]')).waitFor(); + assert.equal(await notice(page).locator("span").textContent(), label("This device's push subscription is missing. Turn alerts on again.")); + assert.ok(await noSidewaysScroll(page), `${tag}: the missing line fits`); + await capture(page, width, theme, language, "missing"); + + // a transient failure newer than the last success: a line in Settings, never a notice, no repair + await page.evaluate(() => window.__push.respond("POST /api/push/status", { registered: true, last_success_at: "2026-10-20T08:12:00.000Z", last_failure_at: "2026-10-21T07:41:00.000Z", last_failure: "network" })); + await cameToFront(page); + await notice(page).waitFor({ state: "detached" }); + const settings = await openAlerts(page); + await hint(page).filter({ hasText: label("The last alert did not reach this device; the next one is tried as usual. Send a test to check.") }).waitFor(); + assert.equal(await settings.getByRole("button", { name: REPAIR, exact: true }).count(), 0, `${tag}: a transient failure offers no re-subscription`); + await capture(page, width, theme, language, "transient"); + await page.evaluate(() => window.__push.answerAgain("POST /api/push/status")); + await closeSettings(page); + assert.deepEqual(errors, [], `${tag}: no page errors`); + } finally { await context.close(); } + } + + // ---- a refusal: told, and Alerts opens Settings where Send test answers it ------------------- + { + const { context, page, errors } = await open("refused"); + try { + await notice(page).waitFor(); + assert.equal(await notice(page).getAttribute("data-problem"), "rejected"); + assert.equal(await notice(page).getByRole("button", { name: REPAIR, exact: true }).count(), 0, `${tag}: a refusal offers no re-subscription`); + await capture(page, width, theme, language, "refused"); + await notice(page).getByRole("button", { name: label("Alerts"), exact: true }).click(); + await page.locator(".settings-dialog").getByRole("tabpanel", { name: label("Alerts"), exact: true }).waitFor(); + await hint(page).filter({ hasText: label("The push service refused the last alert to this device. Send a test to check whether it still does.") }).waitFor(); + await capture(page, width, theme, language, "refused-settings"); + await page.locator(".settings-dialog").getByRole("button", { name: label("Send test"), exact: true }).click(); + await hint(page).filter({ hasText: SENT }).waitFor(); + await notice(page).waitFor({ state: "detached" }); + assert.deepEqual(errors, []); + } finally { await context.close(); } + } + + // ---- a denied permission: guidance in Settings, no notice, no repair ------------------------ + { + const { context, page, errors } = await open("denied"); + try { + await openAlerts(page); + await hint(page).filter({ hasText: label("Allow notifications in your browser settings, then try again.") }).waitFor(); + assert.equal(await notice(page).count(), 0, `${tag}: a denied permission is no notice`); + assert.equal(await page.locator(".settings-dialog").getByRole("button", { name: REPAIR, exact: true }).count(), 0); + await capture(page, width, theme, language, "permission"); + assert.deepEqual(errors, []); + } finally { await context.close(); } + } + + // ---- no push here: the line says what push needs --------------------------------------------- + { + const { context, page, errors } = await open("unsupported"); + try { + await openAlerts(page); + await hint(page).filter({ hasText: label("Push alerts need HTTPS or localhost, and on iPhone the home-screen app.") }).waitFor(); + assert.equal(await notice(page).count(), 0); + assert.equal(await count(page, "POST /api/push/status"), 0, `${tag}: no push, no question`); + await capture(page, width, theme, language, "unsupported"); + assert.deepEqual(errors, []); + } finally { await context.close(); } + } + console.log(`PASS push health ${tag}: one notice over the pane and in Settings, repaired once, cleared on the proven push; refusal, key mismatch, missing, transient, denied, unsupported, offline, loading and a watching device's refused test`); +} + +const app = mkdtempSync(join(tmpdir(), "herdr-push-health-demo-")); +try { + await buildDemoApp(app); + const index = join(app, "index.html"); + const html = readFileSync(index, "utf8"); + assert.match(html, /${RELEASE_TIMERS}${STAND_IN}\n