239 lines
20 KiB
JavaScript
239 lines
20 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));
|
|
// Playwright forces every target focused/visible. Supply controlled DOM state
|
|
// to exercise the production reporter's blur/visibility wiring deterministically.
|
|
const beforeBlur = requests.length;
|
|
await page.evaluate(() => {
|
|
Object.defineProperty(document, "hasFocus", { configurable: true, value: () => false });
|
|
window.dispatchEvent(new Event("blur"));
|
|
});
|
|
await expectEventually(() => requests.slice(beforeBlur).some(request => request.path.endsWith(firstSession) && request.body?.visibility === "visible" && request.body?.focused === false));
|
|
await page.evaluate(() => {
|
|
Object.defineProperty(document, "hidden", { configurable: true, value: true });
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
});
|
|
await expectEventually(() => requests.slice(beforeBlur).some(request => request.path.endsWith(firstSession) && request.body?.visibility === "hidden" && request.body?.focused === false));
|
|
await page.evaluate(() => { delete document.hasFocus; delete document.hidden; window.dispatchEvent(new Event("focus")); });
|
|
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 beforeHide = requests.length;
|
|
await page.evaluate(() => window.dispatchEvent(new Event("pagehide")));
|
|
await expectEventually(() => requests.slice(beforeHide).some(request => request.path.endsWith(firstSession) && request.body?.visibility === "hidden" && request.body?.focused === false));
|
|
await page.evaluate(() => window.dispatchEvent(new Event("pageshow")));
|
|
await expectEventually(() => requests.slice(beforeHide).some(request => request.path.endsWith(firstSession) && request.body?.visibility === "visible" && request.body?.focused === true));
|
|
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(); }
|