import assert from "node:assert/strict"; // Fresh isolated Chromium only. All REST, sockets and PushManager provider calls // are mocked. The real /sw.js and browser IndexedDB/notification APIs execute. // PLAYWRIGHT_MODULE=/path/playwright-core/index.mjs CHROME_EXECUTABLE=/path/chrome // WEB_TEST_URL=http://127.0.0.1:5197 node tests/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: 1200, height: 900 }, permissions: ["notifications"] }); const sourceId = "4599d348-e053-4937-987d-1392746fb2ce", requests = [], sockets = [], errors = [], blocked = []; const replacementSourceId = "22222222-2222-4222-8222-222222222222"; const config = { version: 1, enabled: true, webPushAvailable: true, apnsAvailable: false, sourceId: null, quarantined: false, vapidPublicKey: "B".repeat(87), policy: { presenceHeartbeatMs: 15000, presenceLeaseMs: 60000 } }; const idle = { state: "idle", isThinking: false, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [] }; let readCursor = "0", readSourceId = sourceId, failDelete = false; await context.addInitScript(() => { if (!localStorage.getItem("fixture-bootstrapped")) { localStorage.setItem("fixture-bootstrapped", "true"); localStorage.setItem("sybil_admin_token", "browser-fixture-token"); } window.__pushCalls = []; window.__fixturePermission = localStorage.getItem("fixture-subscription") ? "granted" : "default"; window.__fixtureDecision = "denied"; Object.defineProperty(Notification, "permission", { configurable: true, get: () => window.__fixturePermission }); Notification.requestPermission = () => { window.__pushCalls.push({ type: "permission", gesture: navigator.userActivation.isActive }); window.__fixturePermission = window.__fixtureDecision; return Promise.resolve(window.__fixturePermission); }; const subscription = () => ({ toJSON: () => ({ endpoint: "https://fcm.googleapis.com/fcm/send/browser-fixture", expirationTime: null, keys: { p256dh: "B".repeat(87), auth: "A".repeat(22) } }), unsubscribe: async () => { window.__pushCalls.push({ type: "unsubscribe" }); localStorage.removeItem("fixture-subscription"); return true; }, }); PushManager.prototype.getSubscription = async () => localStorage.getItem("fixture-subscription") ? subscription() : null; PushManager.prototype.subscribe = async options => { window.__pushCalls.push({ type: "subscribe", visible: options.userVisibleOnly, keyLength: options.applicationServerKey.byteLength }); localStorage.setItem("fixture-subscription", "true"); return subscription(); }; }); await context.route("**/*", async route => { const request = route.request(), url = new URL(request.url()); if (url.origin !== target.origin) { blocked.push(url.origin); await route.abort(); return; } if (!url.pathname.startsWith("/api/")) { await route.continue(); return; } const method = request.method(), path = url.pathname; const body = request.postData() ? request.postDataJSON() : undefined; requests.push({ path, method, body, authorization: request.headers().authorization }); assert.ok(["Bearer browser-fixture-token", "Bearer browser-fixture-rotated-token"].includes(request.headers().authorization)); let value; if (path.endsWith("/push/config")) value = config; else if (path.includes("/push/installations/")) { if (method === "DELETE") { await route.fulfill({ status: failDelete ? 503 : 204, ...(failDelete ? { json: { message: "offline" } } : {}) }); return; } value = { installationId: path.split("/").at(-1), transport: "web", enabled: true, version: 1 }; } else if (path.includes("/presence/")) { if (method === "DELETE") { await route.fulfill({ status: 204 }); return; } value = { expiresAt: new Date(Date.now() + 60000).toISOString() }; } else if (path.endsWith("/read-state")) { if (method === "PUT") readCursor = body.cursor; value = { cursor: readCursor, sourceId: readSourceId, updatedAt: null }; } else if (path.endsWith("/agent/messages")) value = { conversationId: "1", messages: [], cursor: "0", beforeCursor: null, hasMore: false }; else if (path.endsWith("/agent")) value = { id: "1", title: "Sybil Agent", activity: idle, protocol: "pi-room.v1", websocket: "/v1/agent/ws" }; else if (path.includes("session")) value = { mode: "token", authenticated: true }; else if (path.endsWith("/tools") || path.endsWith("/chat-tools")) value = { tools: [] }; else if (path.endsWith("/models")) value = { models: [], providers: {} }; else value = []; await route.fulfill({ json: value }); }); await context.routeWebSocket("**/api/v1/agent/ws", socket => { socket.onMessage(raw => { const frame = JSON.parse(String(raw)); if (frame.type === "auth") { sockets.push({ socket, frame }); socket.send(JSON.stringify({ type: "ready", conversationId: "1" })); } }); }); const page = await context.newPage(); page.on("pageerror", error => errors.push(error.message)); const expectEventually = async predicate => { for (let i = 0; i < 100; i++) { if (await predicate()) return; await new Promise(resolve => setTimeout(resolve, 50)); } assert.fail("Condition did not settle"); }; try { await page.goto(target.href, { waitUntil: "networkidle" }); await page.getByText("Agent notifications", { exact: true }).click(); await page.getByText("Notifications are not configured on this server yet.", { exact: true }).waitFor(); assert.equal(await page.getByRole("button", { name: "Enable notifications", exact: true }).isDisabled(), true, "unknown source cannot opt in"); assert.deepEqual(await page.evaluate(() => window.__pushCalls), []); config.sourceId = sourceId; await page.getByRole("button", { name: "Refresh", exact: true }).click(); await expectEventually(() => page.getByRole("button", { name: "Enable notifications", exact: true }).isEnabled()); assert.equal(requests.filter(request => request.path.includes("/push/installations/")).length, 0); await page.getByRole("button", { name: "Enable notifications", exact: true }).click(); await page.getByText(/Notifications were not allowed/).waitFor(); assert.equal(requests.filter(request => request.path.includes("/push/installations/")).length, 0); assert.deepEqual(await page.evaluate(() => window.__pushCalls), [{ type: "permission", gesture: true }]); await page.evaluate(() => { window.__fixturePermission = "default"; window.__fixtureDecision = "granted"; }); await page.getByRole("button", { name: "Refresh", exact: true }).click(); await expectEventually(() => page.getByRole("button", { name: "Enable notifications", exact: true }).isEnabled()); await page.getByRole("button", { name: "Enable notifications", exact: true }).click(); await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor(); const registrations = requests.filter(request => request.path.includes("/push/installations/") && request.method === "PUT"); assert.equal(registrations.length, 1); assert.equal(registrations[0].body.transport, "web"); assert.deepEqual(await page.evaluate(() => window.__pushCalls.filter(call => call.type === "subscribe")), [{ type: "subscribe", visible: true, keyLength: 65 }]); await expectEventually(() => sockets.length === 1); const firstSession = sockets[0].frame.presenceSessionId; assert.equal(sockets[0].frame.clientKind, "web"); assert.ok(requests.some(request => request.path.endsWith(firstSession) && request.body?.visibility === "visible")); // /search has no Agent pane or room socket but must still report attention. const second = await context.newPage(); second.on("pageerror", error => errors.push(error.message)); await second.goto(new URL("/search", target).href, { waitUntil: "networkidle" }); await second.bringToFront(); await expectEventually(() => new Set(requests.filter(request => request.path.includes("/presence/")).map(request => request.path)).size >= 2); const sessionPaths = [...new Set(requests.filter(request => request.path.includes("/presence/")).map(request => request.path))]; assert.equal(sessionPaths.length, 2); const secondPath = sessionPaths.find(path => !path.endsWith(firstSession)); await expectEventually(() => requests.some(request => request.path === secondPath && request.body?.focused === true)); await second.getByRole("textbox").first().click(); await second.getByRole("textbox").first().pressSequentially("interaction"); await expectEventually(() => requests.some(request => request.path === secondPath && request.body?.interacted === true)); for (const path of sessionPaths) { const sequences = requests.filter(request => request.path === path && request.method === "PUT").map(request => request.body.sequence); assert.deepEqual(sequences, [...new Set(sequences)].sort((a, b) => a - b)); } await page.bringToFront(); const worker = context.serviceWorkers().find(worker => new URL(worker.url()).pathname === "/sw.js"); assert.ok(worker, "actual service worker activated"); const push = async cursor => worker.evaluate(async ({ cursor, sourceId }) => { const value = { version: 1, kind: "message", sourceId, cursor, messageId: `outbound:${cursor}`, title: "Sybil Agent", body: "You have a new message.", url: "/?agent=root", tag: `sybil-agent:${sourceId}` }; const event = new Event("push"), waits = []; Object.defineProperties(event, { data: { value: { text: () => JSON.stringify(value) } }, waitUntil: { value: promise => waits.push(promise) } }); self.dispatchEvent(event); await Promise.all(waits); return (await self.registration.getNotifications()).map(notification => ({ title: notification.title, body: notification.body, tag: notification.tag, data: notification.data })); }, { cursor, sourceId }); const notification = await push("8"); assert.equal(notification.length, 1); assert.equal(notification[0].title, "Sybil Agent"); assert.equal(notification[0].body, "You have a new message."); assert.equal(notification[0].data.cursor, "8"); assert.equal(requests.filter(request => request.method === "PUT" && request.path.endsWith("/read-state")).length, 0, "push reception never marks read"); readCursor = "7"; sockets[0].socket.send(JSON.stringify({ type: "read_state", sourceId, cursor: readCursor, updatedAt: null })); await new Promise(resolve => setTimeout(resolve, 200)); assert.equal(await worker.evaluate(async () => (await self.registration.getNotifications()).length), 1); readCursor = "8"; sockets[0].socket.send(JSON.stringify({ type: "read_state", sourceId, cursor: readCursor, updatedAt: null })); await expectEventually(async () => await worker.evaluate(async () => (await self.registration.getNotifications()).length) === 0); const dbRows = await worker.evaluate(async () => { const db = await new Promise(resolve => { const request = indexedDB.open("sybil-agent-notifications-v1"); request.onsuccess = () => resolve(request.result); }); const rows = await new Promise(resolve => { const request = db.transaction("sources").objectStore("sources").getAll(); request.onsuccess = () => resolve(request.result); }); db.close(); return rows; }); assert.equal(dbRows[0].readCursor, "8"); assert.doesNotMatch(JSON.stringify(dbRows), /browser-fixture-token|fcm.googleapis/); // The rendered room belongs to source A. A B read frame must stop the room, // preserving A's cursor instead of relabeling old queued reads with B. await second.close(); await page.locator('[data-testid="agent-sidebar-item"]').click(); sockets[0].socket.send(JSON.stringify({ type: "read_state", sourceId: replacementSourceId, cursor: "0", updatedAt: null })); await page.getByText("Agent history changed. Reload the page before marking messages read.", { exact: true }).waitFor(); await page.evaluate(() => window.dispatchEvent(new Event("focus"))); await new Promise(resolve => setTimeout(resolve, 250)); assert.equal(requests.filter(request => request.method === "PUT" && request.path.endsWith("/read-state")).length, 0, "source mismatch cannot produce relabeled writes or a write loop"); // Replacing the source at the same backend/token invalidates persisted push // consent. The new source requires another gesture and another installation. const oldConsent = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1"))); const registrationCount = requests.filter(request => request.path.includes("/push/installations/") && request.method === "PUT").length; config.sourceId = replacementSourceId; readSourceId = replacementSourceId; readCursor = "0"; await page.getByRole("button", { name: "Refresh", exact: true }).click(); await expectEventually(() => page.getByRole("button", { name: "Enable notifications", exact: true }).isEnabled()); const retiredConsent = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1"))); assert.equal(retiredConsent.enabled, false); assert.equal(retiredConsent.sourceId, sourceId); assert.equal(requests.filter(request => request.path.includes("/push/installations/") && request.method === "PUT").length, registrationCount); assert.ok(requests.some(request => request.path.endsWith(oldConsent.installationId) && request.method === "DELETE")); await page.getByRole("button", { name: "Enable notifications", exact: true }).click(); await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor(); const replacementConsent = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1"))); assert.equal(replacementConsent.enabled, true); assert.equal(replacementConsent.sourceId, replacementSourceId); assert.equal(replacementConsent.scope, oldConsent.scope); assert.notEqual(replacementConsent.installationId, oldConsent.installationId); assert.ok((await page.evaluate(() => window.__pushCalls.filter(call => call.type === "permission"))).every(call => call.gesture === true)); const socketCount = sockets.length; await page.getByRole("button", { name: "Retry", exact: true }).click(); await expectEventually(() => sockets.length > socketCount); assert.equal(await page.getByText("Agent history changed. Reload the page before marking messages read.", { exact: true }).count(), 0, "explicit reload binds a fresh room to B"); await page.getByText("Agent notifications", { exact: true }).click(); await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor(); // Credential rotation is a new authority scope. The old UUID remains queued // locally even if this server would return204 for an unknown DELETE. const originalAuthority = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1"))); await page.evaluate(() => localStorage.setItem("sybil_admin_token", "browser-fixture-rotated-token")); await page.reload({ waitUntil: "networkidle" }); await page.getByText("Agent notifications", { exact: true }).click(); await expectEventually(() => page.getByRole("button", { name: "Enable notifications", exact: true }).isEnabled()); const rotatedOff = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1"))); assert.deepEqual(rotatedOff.pendingRevocations, [{ installationId: originalAuthority.installationId, scope: originalAuthority.scope }]); assert.ok(!requests.some(request => request.method === "DELETE" && request.path.endsWith(originalAuthority.installationId) && request.authorization === "Bearer browser-fixture-rotated-token"), "rotated credentials cannot falsely acknowledge old-authority cleanup"); await page.evaluate(() => { window.__fixtureDecision = "granted"; }); await page.getByRole("button", { name: "Enable notifications", exact: true }).click(); await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor(); const rotatedOn = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1"))); assert.equal(rotatedOn.enabled, true); assert.notEqual(rotatedOn.scope, originalAuthority.scope); assert.notEqual(rotatedOn.installationId, originalAuthority.installationId); assert.deepEqual(rotatedOn.pendingRevocations, rotatedOff.pendingRevocations); await page.evaluate(() => localStorage.setItem("sybil_admin_token", "browser-fixture-token")); await page.reload({ waitUntil: "networkidle" }); await page.getByText("Agent notifications", { exact: true }).click(); await expectEventually(() => page.getByRole("button", { name: "Enable notifications", exact: true }).isEnabled()); const returned = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1"))); assert.deepEqual(returned.pendingRevocations, [{ installationId: rotatedOn.installationId, scope: rotatedOn.scope }]); assert.ok(requests.some(request => request.method === "DELETE" && request.path.endsWith(originalAuthority.installationId) && request.authorization === "Bearer browser-fixture-token")); await page.evaluate(() => { window.__fixtureDecision = "granted"; }); await page.getByRole("button", { name: "Enable notifications", exact: true }).click(); await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor(); // Offline sign-out still removes the local subscription and leaves a tombstone. failDelete = true; await page.getByRole("button", { name: "Sign out", exact: true }).click(); await page.getByRole("button", { name: /Sign in/i }).waitFor(); const local = await page.evaluate(() => ({ token: localStorage.getItem("sybil_admin_token"), subscription: localStorage.getItem("fixture-subscription"), consent: JSON.parse(localStorage.getItem("sybil.agent.push.v1")) })); assert.equal(local.token, null); assert.equal(local.subscription, null); assert.equal(local.consent.enabled, false); assert.equal(local.consent.revocationPending, true); assert.ok(requests.some(request => request.method === "DELETE" && request.path.includes("/push/installations/") && request.authorization === "Bearer browser-fixture-token")); // Existing app fonts are blocked as well; nothing leaves the loopback fixture. assert.ok(blocked.every(origin => origin === "https://fonts.googleapis.com")); assert.deepEqual(errors, []); console.log("PASS real worker activation/showNotification/IndexedDB/read close; unknown-source/gesture denial/enable; two-tab + standalone search presence; auth-frame binding; source-replacement read quiescence and explicit re-opt-in/reload; authority-scoped cleanup/credential rotation/return retry; offline logout; no provider network"); } finally { await context.close(); await browser.close(); }