Remove misleading home 'Update' button (reloaded the page without changing any data — looked like a broken update); settings route + notification prefs lint/type-clean; wire sync progress into guidance dialog; sw.test mocks getRegistrations for clean-update register path
This commit is contained in:
parent
a0db9f7862
commit
cd111da42f
10 changed files with 418 additions and 23 deletions
|
|
@ -11,6 +11,7 @@ export default tseslint.config(
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
"coverage/**",
|
"coverage/**",
|
||||||
"experiments/**",
|
"experiments/**",
|
||||||
|
"instance/**",
|
||||||
"scripts/serve-demo.mjs",
|
"scripts/serve-demo.mjs",
|
||||||
"public/sw.js",
|
"public/sw.js",
|
||||||
"share/**",
|
"share/**",
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,24 @@ export function createLayout(): {
|
||||||
}
|
}
|
||||||
header.append(themeBtn);
|
header.append(themeBtn);
|
||||||
|
|
||||||
|
const settingsLink = document.createElement("a");
|
||||||
|
settingsLink.href = "/settings";
|
||||||
|
settingsLink.setAttribute("data-route", "/settings");
|
||||||
|
settingsLink.className = "app-header__settings";
|
||||||
|
settingsLink.textContent = "⚙";
|
||||||
|
settingsLink.setAttribute("aria-label", "Settings");
|
||||||
|
settingsLink.style.minWidth = "36px";
|
||||||
|
settingsLink.style.minHeight = "36px";
|
||||||
|
settingsLink.style.padding = "0.25rem 0.5rem";
|
||||||
|
settingsLink.style.fontSize = "1.125rem";
|
||||||
|
settingsLink.style.textDecoration = "none";
|
||||||
|
settingsLink.style.display = "inline-flex";
|
||||||
|
settingsLink.style.alignItems = "center";
|
||||||
|
settingsLink.style.justifyContent = "center";
|
||||||
|
settingsLink.style.borderRadius = "999px";
|
||||||
|
settingsLink.style.cursor = "pointer";
|
||||||
|
header.append(settingsLink);
|
||||||
|
|
||||||
const statusChip = document.createElement("div");
|
const statusChip = document.createElement("div");
|
||||||
statusChip.className = "app-header__status";
|
statusChip.className = "app-header__status";
|
||||||
statusChip.setAttribute("role", "status");
|
statusChip.setAttribute("role", "status");
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import { loadMapData, type MapHandle } from "./map.js";
|
||||||
import type { MapViewData } from "../domain/map/types.js";
|
import type { MapViewData } from "../domain/map/types.js";
|
||||||
import { createFestivalView } from "../ui/views/festival/festival.js";
|
import { createFestivalView } from "../ui/views/festival/festival.js";
|
||||||
import { createNotFoundView } from "../ui/views/not-found.js";
|
import { createNotFoundView } from "../ui/views/not-found.js";
|
||||||
|
import { renderSettings } from "../ui/views/settings/settings.js";
|
||||||
|
|
||||||
export interface RouteData {
|
export interface RouteData {
|
||||||
readonly emergency?: EmergencyViewModel | undefined;
|
readonly emergency?: EmergencyViewModel | undefined;
|
||||||
|
|
@ -75,6 +76,8 @@ function renderView(path: string, data?: RouteData): HTMLElement {
|
||||||
return createMapView(data?.map ?? null);
|
return createMapView(data?.map ?? null);
|
||||||
case "festival":
|
case "festival":
|
||||||
return createFestivalView();
|
return createFestivalView();
|
||||||
|
case "settings":
|
||||||
|
return renderSettings();
|
||||||
case "not-found":
|
case "not-found":
|
||||||
return createNotFoundView(path);
|
return createNotFoundView(path);
|
||||||
}
|
}
|
||||||
|
|
@ -219,24 +222,39 @@ function mount(): { router: Router; cleanup: () => void } {
|
||||||
: "Activating…";
|
: "Activating…";
|
||||||
paint();
|
paint();
|
||||||
},
|
},
|
||||||
}).then((outcome) => {
|
onProgress: (done, _total, label) => {
|
||||||
syncBusy = false;
|
syncMessage = `Downloading and verifying… (${String(done)} files · ${label})`;
|
||||||
syncMessage =
|
paint();
|
||||||
outcome.status === "ok"
|
},
|
||||||
? `Festival data v${String(outcome.version)} is live. You can go offline now.`
|
})
|
||||||
: outcome.status === "no-update"
|
.then((outcome) => {
|
||||||
? "You already have the latest festival data."
|
syncBusy = false;
|
||||||
: outcome.status === "offline"
|
syncMessage =
|
||||||
? "No sync source reachable right now."
|
outcome.status === "ok"
|
||||||
: outcome.status === "rejected"
|
? `Festival data v${String(outcome.version)} is live. You can go offline now.`
|
||||||
? `Update rejected — keeping current data. (${outcome.detail})`
|
: outcome.status === "no-update"
|
||||||
: outcome.status === "not-configured"
|
? "You already have the latest festival data."
|
||||||
? `No sync source configured. (${outcome.detail})`
|
: outcome.status === "offline"
|
||||||
: `Sync failed. (${outcome.detail})`;
|
? "No sync source reachable right now."
|
||||||
paint();
|
: outcome.status === "rejected"
|
||||||
// Refresh the readiness chip behind the dialog either way.
|
? `Update rejected — keeping current data. (${outcome.detail})`
|
||||||
void refreshReadiness(false);
|
: outcome.status === "not-configured"
|
||||||
});
|
? `No sync source configured. (${outcome.detail})`
|
||||||
|
: `Sync failed. (${outcome.detail})`;
|
||||||
|
paint();
|
||||||
|
// Refresh the readiness chip behind the dialog either way.
|
||||||
|
void refreshReadiness(false);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
// Belt-and-braces: runSync is contracted to return outcomes, never
|
||||||
|
// throw — but a throw must never leave the button stuck on
|
||||||
|
// "Downloading…" forever with no message.
|
||||||
|
syncBusy = false;
|
||||||
|
syncMessage =
|
||||||
|
error instanceof Error ? `Sync failed. (${error.message})` : "Sync failed.";
|
||||||
|
paint();
|
||||||
|
void refreshReadiness(false);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
106
src/app/notifications.ts
Normal file
106
src/app/notifications.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
export type NotifyMode = "banner" | "browser" | "sound";
|
||||||
|
|
||||||
|
interface WindowWithLegacyAudio extends Window {
|
||||||
|
webkitAudioContext?: typeof AudioContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrefs(): NotifyMode[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("lumen-notify-modes");
|
||||||
|
return raw ? (JSON.parse(raw) as NotifyMode[]) : ["banner", "browser"];
|
||||||
|
} catch {
|
||||||
|
return ["banner", "browser"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPrefs(modes: NotifyMode[]): void {
|
||||||
|
localStorage.setItem("lumen-notify-modes", JSON.stringify(modes));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEnabledModes(): NotifyMode[] {
|
||||||
|
return getPrefs();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleMode(mode: NotifyMode): NotifyMode[] {
|
||||||
|
const current = getPrefs();
|
||||||
|
const next = current.includes(mode) ? current.filter((m) => m !== mode) : [...current, mode];
|
||||||
|
setPrefs(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg: string): void {
|
||||||
|
const existing = document.getElementById("lumen-notification-banner");
|
||||||
|
if (existing) {
|
||||||
|
existing.textContent = msg;
|
||||||
|
existing.classList.add("notify-banner--visible");
|
||||||
|
window.setTimeout(() => {
|
||||||
|
existing.classList.remove("notify-banner--visible");
|
||||||
|
}, 4000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toast = document.createElement("div");
|
||||||
|
toast.id = "lumen-notification-banner";
|
||||||
|
toast.textContent = msg;
|
||||||
|
toast.className = "notify-banner notify-banner--visible";
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notify(msg: string, modes?: NotifyMode[]): void {
|
||||||
|
const active = modes ?? getPrefs();
|
||||||
|
if (active.includes("banner")) {
|
||||||
|
showToast(msg);
|
||||||
|
}
|
||||||
|
if (active.includes("browser") && typeof Notification !== "undefined") {
|
||||||
|
if (Notification.permission === "granted") {
|
||||||
|
new Notification(msg);
|
||||||
|
} else if (Notification.permission !== "denied") {
|
||||||
|
void Notification.requestPermission().then((p) => {
|
||||||
|
if (p === "granted") new Notification(msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (active.includes("sound")) {
|
||||||
|
try {
|
||||||
|
const Ctor = window.AudioContext ?? (window as WindowWithLegacyAudio).webkitAudioContext;
|
||||||
|
if (Ctor) {
|
||||||
|
const ctx = new Ctor();
|
||||||
|
if (ctx.state === "suspended") {
|
||||||
|
void ctx.resume();
|
||||||
|
}
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.frequency.value = 1200;
|
||||||
|
gain.gain.value = 0.5;
|
||||||
|
osc.start();
|
||||||
|
osc.stop(ctx.currentTime + 0.3);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// audio is best-effort — a blocked AudioContext must not eat the notification
|
||||||
|
}
|
||||||
|
// Visual pulse fallback for mobile browsers that block Web Audio / vibration
|
||||||
|
const pulse = document.createElement("div");
|
||||||
|
pulse.className = "notify-pulse";
|
||||||
|
document.body.appendChild(pulse);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
pulse.remove();
|
||||||
|
}, 600);
|
||||||
|
if (typeof navigator.vibrate === "function") {
|
||||||
|
try {
|
||||||
|
navigator.vibrate([300, 100, 300, 100, 300]);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("vibrate blocked", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestBrowserPermission(): void {
|
||||||
|
if (typeof Notification !== "undefined" && Notification.permission !== "granted") {
|
||||||
|
void Notification.requestPermission();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -962,3 +962,149 @@ html.dark .app-main__icon {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Notification banner that appears at top of app */
|
||||||
|
.notify-banner {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--header-h, 56px) + 1rem);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(-20px);
|
||||||
|
background: var(--accent, #c8473a);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition:
|
||||||
|
opacity 0.25s ease,
|
||||||
|
transform 0.25s ease,
|
||||||
|
visibility 0.25s;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-banner--visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-view {
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 42rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section p {
|
||||||
|
color: var(--fg-muted, #888);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid var(--border, #ddd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row__label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row__label strong {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row__label small {
|
||||||
|
color: var(--fg-muted, #888);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
min-width: 60px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid var(--border, #ccc);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface, #f0f0f0);
|
||||||
|
color: var(--fg, #000);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn--on {
|
||||||
|
background: var(--accent, #c8473a);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent, #c8473a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-test-btn {
|
||||||
|
display: block;
|
||||||
|
margin: 1.5rem auto 0;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent, #c8473a);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-test-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-pulse {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(200, 71, 58, 0.25);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9999;
|
||||||
|
animation: pulseFlash 0.6s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulseFlash {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.emergency-share-btn {
|
||||||
|
display: block;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.emergency-share-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
* Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207
|
* Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type RouteId = "home" | "emergency" | "schedule" | "map" | "festival" | "not-found";
|
export type RouteId =
|
||||||
|
"home" | "emergency" | "schedule" | "map" | "festival" | "settings" | "not-found";
|
||||||
|
|
||||||
export interface Route {
|
export interface Route {
|
||||||
id: RouteId;
|
id: RouteId;
|
||||||
|
|
@ -21,6 +22,7 @@ export const ROUTES: readonly Route[] = [
|
||||||
{ id: "emergency", path: "/emergency", label: "Emergency", title: "Emergency" },
|
{ id: "emergency", path: "/emergency", label: "Emergency", title: "Emergency" },
|
||||||
{ id: "schedule", path: "/schedule", label: "Schedule", title: "Schedule" },
|
{ id: "schedule", path: "/schedule", label: "Schedule", title: "Schedule" },
|
||||||
{ id: "map", path: "/map", label: "Map", title: "Map" },
|
{ id: "map", path: "/map", label: "Map", title: "Map" },
|
||||||
|
{ id: "settings", path: "/settings", label: "Settings", title: "Settings" },
|
||||||
{ id: "festival", path: "/festival", label: "Information", title: "Information" },
|
{ id: "festival", path: "/festival", label: "Information", title: "Information" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export function createHomeView(): HTMLElement {
|
||||||
grid.className = "home-grid";
|
grid.className = "home-grid";
|
||||||
|
|
||||||
for (const r of ROUTES) {
|
for (const r of ROUTES) {
|
||||||
|
if (r.id === "settings") continue;
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.className = `home-tile${r.id === "emergency" ? " home-tile--emergency" : ""}`;
|
a.className = `home-tile${r.id === "emergency" ? " home-tile--emergency" : ""}`;
|
||||||
a.href = r.path;
|
a.href = r.path;
|
||||||
|
|
|
||||||
89
src/ui/views/settings/settings.ts
Normal file
89
src/ui/views/settings/settings.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import {
|
||||||
|
getEnabledModes,
|
||||||
|
toggleMode,
|
||||||
|
requestBrowserPermission,
|
||||||
|
notify,
|
||||||
|
type NotifyMode,
|
||||||
|
} from "../../../app/notifications.js";
|
||||||
|
|
||||||
|
export function renderSettings(): HTMLElement {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "settings-view";
|
||||||
|
el.setAttribute("aria-labelledby", "settings-heading");
|
||||||
|
|
||||||
|
const h1 = document.createElement("h1");
|
||||||
|
h1.id = "settings-heading";
|
||||||
|
h1.textContent = "Settings";
|
||||||
|
el.appendChild(h1);
|
||||||
|
|
||||||
|
// Request browser notification permission on open
|
||||||
|
requestBrowserPermission();
|
||||||
|
|
||||||
|
// Notification section
|
||||||
|
const section = document.createElement("section");
|
||||||
|
section.className = "settings-section";
|
||||||
|
const sectionTitle = document.createElement("h2");
|
||||||
|
sectionTitle.textContent = "Notifications";
|
||||||
|
section.appendChild(sectionTitle);
|
||||||
|
|
||||||
|
const desc = document.createElement("p");
|
||||||
|
desc.textContent = "Choose how you want to be notified when things change.";
|
||||||
|
section.appendChild(desc);
|
||||||
|
|
||||||
|
const modes: { id: NotifyMode; label: string; hint: string }[] = [
|
||||||
|
{ id: "banner", label: "On-screen banner", hint: "Shows a message inside the app" },
|
||||||
|
{
|
||||||
|
id: "browser",
|
||||||
|
label: "Browser notification",
|
||||||
|
hint: "Pops up even if the tab is in the background",
|
||||||
|
},
|
||||||
|
{ id: "sound", label: "Sound", hint: "Plays a short beep" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const enabled = getEnabledModes();
|
||||||
|
|
||||||
|
for (const m of modes) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "settings-row";
|
||||||
|
|
||||||
|
const label = document.createElement("div");
|
||||||
|
label.className = "settings-row__label";
|
||||||
|
const strong = document.createElement("strong");
|
||||||
|
strong.textContent = m.label;
|
||||||
|
const hint = document.createElement("small");
|
||||||
|
hint.textContent = m.hint;
|
||||||
|
label.append(strong, hint);
|
||||||
|
row.appendChild(label);
|
||||||
|
|
||||||
|
const toggle = document.createElement("button");
|
||||||
|
toggle.className = "toggle-btn" + (enabled.includes(m.id) ? " toggle-btn--on" : "");
|
||||||
|
toggle.setAttribute("role", "switch");
|
||||||
|
toggle.setAttribute("aria-checked", String(enabled.includes(m.id)));
|
||||||
|
toggle.textContent = enabled.includes(m.id) ? "ON" : "OFF";
|
||||||
|
|
||||||
|
toggle.addEventListener("click", () => {
|
||||||
|
const next = toggleMode(m.id);
|
||||||
|
const isOn = next.includes(m.id);
|
||||||
|
toggle.classList.toggle("toggle-btn--on", isOn);
|
||||||
|
toggle.setAttribute("aria-checked", String(isOn));
|
||||||
|
toggle.textContent = isOn ? "ON" : "OFF";
|
||||||
|
notify("Toggled " + m.label + " " + (isOn ? "ON" : "OFF"), [m.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(toggle);
|
||||||
|
section.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
el.appendChild(section);
|
||||||
|
|
||||||
|
// Test notification button
|
||||||
|
const testBtn = document.createElement("button");
|
||||||
|
testBtn.className = "settings-test-btn";
|
||||||
|
testBtn.textContent = "Send a test notification";
|
||||||
|
testBtn.addEventListener("click", () => {
|
||||||
|
notify("Test notification — all enabled methods fired");
|
||||||
|
});
|
||||||
|
el.appendChild(testBtn);
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
@ -119,12 +119,20 @@ describe("SW registration — page bridge", () => {
|
||||||
it("registers /sw.js with scope / when supported", async () => {
|
it("registers /sw.js with scope / when supported", async () => {
|
||||||
const mockReg = { scope: "/", waiting: null } as unknown as ServiceWorkerRegistration;
|
const mockReg = { scope: "/", waiting: null } as unknown as ServiceWorkerRegistration;
|
||||||
const mockRegister = vi.fn().mockResolvedValue(mockReg);
|
const mockRegister = vi.fn().mockResolvedValue(mockReg);
|
||||||
|
const mockUnregister = vi.fn().mockResolvedValue(true);
|
||||||
Object.defineProperty(globalThis, "navigator", {
|
Object.defineProperty(globalThis, "navigator", {
|
||||||
value: { serviceWorker: { register: mockRegister } },
|
value: {
|
||||||
|
serviceWorker: {
|
||||||
|
register: mockRegister,
|
||||||
|
getRegistrations: vi.fn().mockResolvedValue([{ unregister: mockUnregister }]),
|
||||||
|
},
|
||||||
|
},
|
||||||
writable: true,
|
writable: true,
|
||||||
configurable: true,
|
configurable: true,
|
||||||
});
|
});
|
||||||
const res = await registerSW();
|
const res = await registerSW();
|
||||||
|
// clean-update: any pre-existing worker is unregistered before re-registering
|
||||||
|
expect(mockUnregister).toHaveBeenCalled();
|
||||||
expect(mockRegister).toHaveBeenCalledWith("/sw.js", { scope: "/" });
|
expect(mockRegister).toHaveBeenCalledWith("/sw.js", { scope: "/" });
|
||||||
expect(res.status).toBe("registered");
|
expect(res.status).toBe("registered");
|
||||||
});
|
});
|
||||||
|
|
@ -132,7 +140,12 @@ describe("SW registration — page bridge", () => {
|
||||||
it("returns failed when register throws (offline or HTTPS issue)", async () => {
|
it("returns failed when register throws (offline or HTTPS issue)", async () => {
|
||||||
const mockRegister = vi.fn().mockRejectedValue(new Error("HTTPS required"));
|
const mockRegister = vi.fn().mockRejectedValue(new Error("HTTPS required"));
|
||||||
Object.defineProperty(globalThis, "navigator", {
|
Object.defineProperty(globalThis, "navigator", {
|
||||||
value: { serviceWorker: { register: mockRegister } },
|
value: {
|
||||||
|
serviceWorker: {
|
||||||
|
register: mockRegister,
|
||||||
|
getRegistrations: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
},
|
||||||
writable: true,
|
writable: true,
|
||||||
configurable: true,
|
configurable: true,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,11 @@ function lumenShellPlugin(): Plugin {
|
||||||
const version = `${pkg.version}-${gitHash}`;
|
const version = `${pkg.version}-${gitHash}`;
|
||||||
const cacheName = `lumen-shell-${version}`;
|
const cacheName = `lumen-shell-${version}`;
|
||||||
|
|
||||||
// Collect hashed assets produced by this build
|
// Collect hashed assets produced by this build — sourcemaps excluded:
|
||||||
|
// they bloat the install (~580KB) and the SW would cache-first them.
|
||||||
const assetsDir = join(dist, "assets");
|
const assetsDir = join(dist, "assets");
|
||||||
const assetFiles = existsSync(assetsDir) ? readdirSync(assetsDir) : [];
|
const assetFiles = existsSync(assetsDir) ? readdirSync(assetsDir) : [];
|
||||||
const hashedAssets = assetFiles.map((f) => `/assets/${f}`);
|
const hashedAssets = assetFiles.filter((f) => !f.endsWith(".map")).map((f) => `/assets/${f}`);
|
||||||
|
|
||||||
// Core shell URLs that must be precached (deterministic list per build)
|
// Core shell URLs that must be precached (deterministic list per build)
|
||||||
const core = [
|
const core = [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue