// OpenPWA device-test lab: service worker (scope /lab/device-tests/). // // This worker exists to make three things observable on a real device without // Web Inspector: // 1. Exposure: which event-handler properties exist on the global scope // (BCD #22959 tests `"onnotificationclick" in self`). Answered on a // {type: "PROBE"} message. // 2. Whether a notificationclick handler fires at all (WebKit bug 268797), // and through which registration path: addEventListener and the // `onnotificationclick` property are both installed and each writes its // own record, so the tester sees which one ran. // 3. What the tap did: whether an existing window was found and focused, or a // new one opened. // Records go to IndexedDB (a worker has no localStorage) and are posted to // every window client; the page also reads IndexedDB on load, so a record // written while no window existed is not lost. // // No fetch handler and no caching: the lab must always show the deployed // version, and nothing here should change how the network behaves. Nothing is // sent anywhere. const SW_VERSION = "lab-sw-v2"; const DB_NAME = "openpwa-lab"; const STORE = "events"; const LAB_URL = new URL("./", self.location.href).href; const openDb = () => new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, 1); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE, { keyPath: "id", autoIncrement: true }); if (!db.objectStoreNames.contains("kv")) db.createObjectStore("kv"); }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); const saveRecord = async (record) => { const db = await openDb(); await new Promise((resolve, reject) => { const tx = db.transaction(STORE, "readwrite"); tx.objectStore(STORE).add(record); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); db.close(); }; const broadcast = async (message) => { const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); for (const client of windows) client.postMessage(message); return windows; }; // Captured when the script first runs, BEFORE this file assigns // self.onnotificationclick below: after the assignment `in` is always true and // typeof is always "function", whether or not the platform exposes the handler. const INITIAL_EXPOSURE = Object.freeze({ "'onnotificationclick' in self": "onnotificationclick" in self, "typeof self.onnotificationclick": typeof self.onnotificationclick, "'onnotificationclose' in self": "onnotificationclose" in self, "'onpush' in self": "onpush" in self, "'onpushsubscriptionchange' in self": "onpushsubscriptionchange" in self, }); const exposure = () => ({ swVersion: SW_VERSION, capturedAt: "worker start, before any handler property is assigned", ...INITIAL_EXPOSURE, "'Notification' in self": "Notification" in self, "'showNotification' in registration": "showNotification" in self.registration, "'pushManager' in registration": "pushManager" in self.registration, "typeof clients.openWindow": typeof self.clients.openWindow, scope: self.registration.scope, }); const describeNotification = (notification) => ({ title: notification.title, body: notification.body, tag: notification.tag, data: notification.data ?? null, }); const focusOrOpen = async (targetUrl) => { const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); const existing = windows.find((client) => client.url.startsWith(LAB_URL)); if (existing && typeof existing.focus === "function") { try { await existing.focus(); return { windowsFound: windows.length, outcome: "focused existing window", url: existing.url }; } catch (error) { return { windowsFound: windows.length, outcome: `focus failed: ${error.name}`, url: existing.url }; } } if (typeof self.clients.openWindow !== "function") { return { windowsFound: windows.length, outcome: "clients.openWindow unavailable", url: null }; } try { const opened = await self.clients.openWindow(targetUrl); return { windowsFound: windows.length, outcome: opened ? "opened new window" : "openWindow returned null", url: opened ? opened.url : null }; } catch (error) { return { windowsFound: windows.length, outcome: `openWindow failed: ${error.name}`, url: null }; } }; // Both registration paths record; only the listener focuses or opens a window, // so a device that fires both does not try to open two windows. const recordEvent = async (type, via, event, navigate) => { const record = { type, via, at: new Date().toISOString(), action: event.action ?? "", notification: event.notification ? describeNotification(event.notification) : null, swVersion: SW_VERSION, }; if (navigate) { const target = (event.notification && event.notification.data && event.notification.data.url) || LAB_URL; event.notification.close(); record.navigation = await focusOrOpen(new URL(target, LAB_URL).href); } await saveRecord(record); await broadcast({ type: "LAB_EVENT", record }); }; self.addEventListener("install", () => self.skipWaiting()); self.addEventListener("activate", (event) => { event.waitUntil(self.clients.claim()); }); self.addEventListener("notificationclick", (event) => { event.waitUntil(recordEvent("notificationclick", "addEventListener", event, true)); }); self.onnotificationclick = (event) => { event.waitUntil(recordEvent("notificationclick", "onnotificationclick property", event, false)); }; self.addEventListener("notificationclose", (event) => { event.waitUntil(recordEvent("notificationclose", "addEventListener", event, false)); }); // Real push delivery needs a sending server, which the lab does not have. The // handler is here so that if a message ever arrives, it is recorded and shown. self.addEventListener("push", (event) => { const text = event.data ? event.data.text() : ""; event.waitUntil( saveRecord({ type: "push", via: "addEventListener", at: new Date().toISOString(), text, swVersion: SW_VERSION }) .then(() => self.registration.showNotification("OpenPWA lab push", { body: text || "(no payload)", tag: "lab-push" })), ); }); const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); self.addEventListener("message", (event) => { const data = event.data || {}; const reply = (message) => { if (event.source && typeof event.source.postMessage === "function") event.source.postMessage(message); }; if (data.type === "PROBE") { reply({ type: "LAB_PROBE", exposure: exposure() }); return; } if (data.type === "SHOW_DELAYED") { const delay = Math.min(Math.max(Number(data.delayMs) || 0, 0), 30000); event.waitUntil( wait(delay) .then(() => self.registration.showNotification(data.title, data.options)) .then( () => reply({ type: "LAB_SHOWN", ok: true, delayMs: delay }), (error) => reply({ type: "LAB_SHOWN", ok: false, delayMs: delay, error: `${error.name}: ${error.message}` }), ), ); } });