141 lines
10 KiB
JavaScript
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(); }
|