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

141 lines
10 KiB
JavaScript

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