Files
Sybil-2/web/tests/agent-push.browser.mjs
T

220 lines
19 KiB
JavaScript

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(); }