import assert from "node:assert/strict"; // Production bundle/worker, mobile Chromium; all API and push-provider operations are fixtures. // Use the same PLAYWRIGHT_MODULE, CHROME_EXECUTABLE and WEB_TEST_URL as agent-push.browser.mjs. const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || "playwright-core"); const target = new URL(process.env.WEB_TEST_URL || "http://127.0.0.1:5197/"); assert.ok(target.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(target.hostname)); const browser = await chromium.launch({ executablePath: process.env.CHROME_EXECUTABLE || undefined, headless: true, ...(process.env.BROWSER_NO_SANDBOX === "1" ? { args: ["--no-sandbox"] } : {}) }); const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); const sourceId = "4599d348-e053-4937-987d-1392746fb2ce"; const config = { version: 1, enabled: true, webPushAvailable: true, apnsAvailable: false, sourceId, quarantined: false, vapidPublicKey: "B".repeat(87), policy: { presenceHeartbeatMs: 15000, presenceLeaseMs: 60000 } }; let registerStatus = 200, configStatus = 200, puts = 0; const errors = []; await context.addInitScript(() => { localStorage.setItem("sybil_admin_token", "mobile-notification-fixture"); window.__permission = "default"; window.__decision = "default"; window.__subscribed = false; window.__subscribes = 0; window.__subscribeError = null; window.__workerError = false; window.__subscribeHang = false; window.__permissionGesture = false; Object.defineProperty(Notification, "permission", { configurable: true, get: () => window.__permission }); Notification.requestPermission = () => { window.__permissionGesture = navigator.userActivation.isActive; window.__permission = window.__decision; return Promise.resolve(window.__permission); }; const register = navigator.serviceWorker.register.bind(navigator.serviceWorker); navigator.serviceWorker.register = (...args) => window.__workerError ? Promise.reject(new Error("private-worker-detail")) : register(...args); const subscription = { toJSON: () => ({ endpoint: "https://fcm.googleapis.com/fcm/send/private-endpoint", keys: { p256dh: "B".repeat(87), auth: "A".repeat(22) } }), unsubscribe: async () => { window.__subscribed = false; return true; } }; PushManager.prototype.getSubscription = async () => window.__subscribed ? subscription : null; PushManager.prototype.subscribe = async () => { window.__subscribes++; if (window.__subscribeError) throw new DOMException("private-provider-key", window.__subscribeError); if (window.__subscribeHang) return await new Promise(() => {}); window.__subscribed = true; return subscription; }; }); await context.route("**/*", async route => { const request = route.request(), url = new URL(request.url()), path = url.pathname; if (url.origin !== target.origin) { await route.abort(); return; } if (!path.startsWith("/api/")) { await route.continue(); return; } let value = {}, status = 200; if (path.endsWith("/push/config")) { value = config; status = configStatus; } else if (path.includes("/push/installations/")) { if (request.method() === "DELETE") { await route.fulfill({ status: 204 }); return; } puts++; status = registerStatus; value = status === 200 ? { installationId: path.split("/").at(-1), transport: "web", enabled: true, version: 1 } : { error: "private-server-token" }; } else if (path.endsWith("/auth/session")) value = { mode: "token", authenticated: true }; else if (path.endsWith("/workspace-items")) value = { items: [] }; else if (path.endsWith("/models")) value = { providers: {} }; else if (path.endsWith("/chat-tools")) value = { tools: [] }; else if (path.endsWith("/active-runs")) value = { chats: [], searches: [] }; else if (path.endsWith("/read-state")) value = { sourceId, cursor: "0", updatedAt: null }; await route.fulfill({ status, json: value }); }); await context.routeWebSocket("**/api/v1/agent/ws", socket => socket.close()); const page = await context.newPage(); page.on("pageerror", error => errors.push(error.message)); const enable = page.getByRole("button", { name: "Enable notifications", exact: true }); const refresh = page.getByRole("button", { name: "Refresh", exact: true }); const alert = page.locator(".workspace-sidebar [role=alert]"); const waitEnable = () => page.waitForFunction(() => Array.from(document.querySelectorAll("button")).some(el => el.textContent === "Enable notifications" && !el.disabled)); async function attempt(pattern) { await refresh.click(); await waitEnable(); await enable.click(); await page.waitForFunction(pattern => { const el = document.querySelector(".workspace-sidebar [role=alert]"); return el && new RegExp(pattern).test(el.textContent); }, pattern.source); assert.match(await alert.textContent(), pattern); assert.equal(await page.evaluate(() => window.__permissionGesture), true); assert.equal(await page.getByRole("button", { name: "Turn off notifications", exact: true }).count(), 0); assert.doesNotMatch(await page.locator(".workspace-sidebar").textContent(), /private-worker-detail|private-provider-key|private-server-token|private-endpoint/); } try { await page.goto(target.href, { waitUntil: "networkidle" }); await page.getByRole("button", { name: "Open sidebar", exact: true }).click(); await page.getByText("Agent notifications", { exact: true }).click(); await attempt(/prompt was dismissed/); assert.equal(puts, 0); await page.evaluate(() => { window.__decision = "denied"; }); await attempt(/not allowed/); assert.equal(puts, 0); assert.ok(await enable.isDisabled()); await page.evaluate(() => { window.__permission = "default"; window.__decision = "granted"; window.__workerError = true; }); await attempt(/worker could not start/); assert.equal(puts, 0); await page.evaluate(() => { window.__workerError = false; }); for (const [name, pattern] of [["AbortError", /push service \(AbortError\)/], ["NotAllowedError", /site settings.*system settings/], ["NotSupportedError", /cannot create a push subscription/], ["InvalidStateError", /incompatible notification subscription/]]) { await page.evaluate(name => { window.__subscribeError = name; }, name); await attempt(pattern); assert.equal(puts, 0); // Successful foreground config refresh must not dismiss an opt-in diagnosis. await page.evaluate(() => window.dispatchEvent(new Event("online"))); await page.waitForTimeout(150); assert.match(await alert.textContent(), pattern); } await page.evaluate(() => { window.__subscribeError = null; window.__subscribeHang = true; }); await page.clock.install(); await refresh.click(); await waitEnable(); const before = await page.evaluate(() => window.__subscribes); await enable.click(); await page.waitForFunction(before => window.__subscribes > before, before); await page.clock.fastForward(10001); await alert.filter({ hasText: "TimeoutError" }).waitFor(); assert.equal(puts, 0); await page.clock.resume(); await page.evaluate(() => { window.__subscribeHang = false; }); for (const [status, pattern] of [[401, /sign-in.*authorized/], [403, /sign-in.*authorized/], [400, /rejected.*subscription/], [409, /expired/], [429, /busy/], [503, /unavailable on the Sybil server/]]) { registerStatus = status; await attempt(pattern); assert.equal(await page.evaluate(() => window.__subscribed), false, "failed registration rolls back the subscription"); } registerStatus = 200; configStatus = 503; await refresh.click(); await alert.filter({ hasText: "unavailable on the Sybil server" }).waitFor(); configStatus = 200; await refresh.click(); await waitEnable(); await enable.click(); await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor(); assert.equal(await alert.count(), 0); await page.getByText("Android / GrapheneOS help", { exact: true }).click(); await page.getByRole("link", { name: "GrapheneOS setup guide" }).waitFor(); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); if (process.env.PUSH_ERROR_SCREENSHOT) await page.screenshot({ path: process.env.PUSH_ERROR_SCREENSHOT }); for (const viewport of [{ width: 320, height: 568 }, { width: 390, height: 450 }]) { await page.setViewportSize(viewport); const signOut = page.getByRole("button", { name: "Sign out", exact: true }); await signOut.scrollIntoViewIfNeeded(); assert.ok(await signOut.evaluate(el => { const box = el.getBoundingClientRect(); return box.top >= 0 && box.bottom <= innerHeight; }), "notification controls remain reachable with long help and a short viewport"); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); } await page.setViewportSize({ width: 390, height: 844 }); await page.getByRole("button", { name: "Turn off notifications", exact: true }).click(); await waitEnable(); await page.evaluate(() => { delete window.PushManager; }); await refresh.click(); await page.getByText(/This browser does not support notifications here/).waitFor(); assert.ok(await enable.isDisabled()); await page.evaluate(() => { Object.defineProperty(window, "isSecureContext", { configurable: true, value: true }); Object.defineProperty(navigator, "userAgent", { configurable: true, value: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)" }); }); await refresh.click(); await page.getByText(/On iPhone or iPad, add Sybil to your Home Screen/).waitFor(); assert.ok(await enable.isDisabled()); await page.evaluate(() => { Object.defineProperty(window, "isSecureContext", { configurable: true, value: false }); }); await refresh.click(); await page.getByText(/Notifications require a secure connection/).waitFor(); assert.ok(await enable.isDisabled()); assert.deepEqual(errors, []); console.log("PASS mobile permission dismissal/denial, worker/subscription failures, timeout, HTTP statuses, rollback, retained diagnostics, recovery, unsupported/insecure browsers and GrapheneOS guidance"); } finally { await context.close(); await browser.close(); }