import assert from "node:assert/strict"; import { createHash } from "node:crypto"; // Local production preview only; every API call and socket is mocked. // PLAYWRIGHT_MODULE=/path/playwright-core/index.mjs CHROME_EXECUTABLE=/path/chrome // WEB_TEST_URL=http://127.0.0.1:5197 node tests/lite-agent.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:" && ["localhost", "127.0.0.1", "[::1]"].includes(target.hostname)); const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_EXECUTABLE || undefined, ...(process.env.BROWSER_NO_SANDBOX === "1" ? { args: ["--no-sandbox"] } : {}) }); const context = await browser.newContext({ viewport: { width: 1000, height: 850 }, serviceWorkers: "block" }); await context.addInitScript(() => { localStorage.setItem("sybil_admin_token", "lite-agent-fixture-token"); // Exercise missing APIs on Chromium readers without emulating their rendering engine. Object.defineProperty(Crypto.prototype, "randomUUID", { configurable: true, value: undefined }); delete Array.prototype.at; delete AbortSignal.prototype.throwIfAborted; }); const errors = [], requests = [], submissions = [], downloads = [], persistedBeforeSend = []; const sourceId = "00000000-0000-4000-8000-000000000001"; const date = "2026-11-10T12:00:00.000Z"; const idle = { state: "idle", isThinking: false, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [] }; const image = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a2ioAAAAASUVORK5CYII=", "base64"); const attachment = { id: "att_12345678-1234-4234-8234-123456789abc", kind: "image", filename: "fixture.png", mimeType: "image/png", sizeBytes: image.length, sha256: createHash("sha256").update(image).digest("hex") }; const history = [{ id: "outbound:1", role: "assistant", content: "A static **Agent reply**.\n\n![remote image](https://never-fetch.invalid/image.png)", createdAt: date, name: null, metadata: { kind: "message", status: "complete", entryId: 1, agentAttachments: [attachment] } }]; let cursor = 1, readCursor = "0", failedSend = false, live; const page = await context.newPage(); page.on("pageerror", error => errors.push(error.message)); await context.route("**/*", async route => { const request = route.request(), url = new URL(request.url()), path = url.pathname; requests.push(url.href); if (url.origin !== target.origin) return route.abort(); if (!path.startsWith("/api/")) return route.continue(); assert.equal(request.headers().authorization, "Bearer lite-agent-fixture-token"); const method = request.method(); let body; if (path.endsWith("/auth/session")) body = { authenticated: true, mode: "token" }; else if (path.endsWith("/workspace-items")) body = { items: [] }; else if (path.endsWith("/active-runs")) body = { chats: [], searches: [] }; else if (path.endsWith("/models")) body = { providers: { openai: { models: ["fixture-model"], loadedAt: date, error: null } } }; else if (path.endsWith("/chat-tools")) body = { tools: [] }; else if (path.endsWith("/agent/messages") && method === "POST") { const submission = request.postDataJSON(); submissions.push(submission); persistedBeforeSend.push(await page.evaluate(() => JSON.parse(sessionStorage.getItem("sybil.agent.optimistic-outbox.v1") || "[]"))); if (!failedSend) { failedSend = true; return route.abort("internetdisconnected"); } const row = { id: `inbound:sybil:${submission.requestId}`, role: "user", content: submission.content, createdAt: date, name: null, metadata: { kind: "message", status: "complete", entryId: ++cursor } }; history.push(row); live.send(JSON.stringify({ type: "messages", conversationId: "fixture-room", messages: [row], cursor: String(cursor), beforeCursor: String(cursor), hasMore: false })); body = { id: cursor, status: "placed", messageId: row.id }; } else if (path.endsWith("/agent/messages")) { const after = url.searchParams.get("after"); const messages = after === null ? history : history.filter(message => message.metadata.entryId > Number(after)); body = { conversationId: "fixture-room", messages, cursor: String(cursor), beforeCursor: messages.length ? String(messages[0].metadata.entryId) : null, hasMore: false }; } else if (path.endsWith("/read-state")) { if (method === "PUT") readCursor = request.postDataJSON().cursor; body = { sourceId, cursor: readCursor, updatedAt: null }; } else if (path.endsWith("/agent")) body = { id: "fixture-room", title: "Sybil Agent", activity: idle, protocol: "pi-room.v1", websocket: "/v1/agent/ws" }; else if (path.endsWith("/content")) { downloads.push(url.href); return route.fulfill({ contentType: "image/png", body: image }); } else { assert.fail(`Unexpected API request ${method} ${path}`); } await route.fulfill({ json: body }); }); await context.routeWebSocket("**/api/v1/agent/ws", socket => { live = socket; socket.onMessage(raw => { const event = JSON.parse(String(raw)); if (event.type === "auth") { assert.equal(event.token, "lite-agent-fixture-token"); socket.send(JSON.stringify({ type: "ready", conversationId: "fixture-room" })); } }); }); const open = () => page.goto(new URL("/lite?agent=root", target).href, { waitUntil: "networkidle" }); try { await open(); const composer = page.getByRole("textbox", { name: "Message Sybil Agent", exact: true }); const send = page.getByRole("button", { name: "Send message", exact: true }); await page.getByRole("status").filter({ hasText: /^Connected$/ }).waitFor(); assert.equal(await page.getByRole("heading", { level: 1 }).count(), 1); assert.equal(await page.locator(".lite-agent img").count(), 0); assert.deepEqual(downloads, [], "attachments do not download automatically"); assert.equal(requests.some(url => /character-|\/assets\/App-/.test(url)), false); assert.equal(requests.some(url => url.includes("never-fetch.invalid")), false); assert.equal(requests.some(url => url.includes("/presence/") || url.includes("/push/")), false, "lite does not start background push sessions"); await composer.fill("A draft preserved on reload"); await page.reload({ waitUntil: "networkidle" }); await composer.waitFor(); assert.equal(await composer.inputValue(), "A draft preserved on reload"); await composer.fill("Durable message for offline retry"); await send.click(); await page.getByRole("button", { name: "Retry message", exact: true }).waitFor(); assert.equal(await composer.inputValue(), "", "durable outbox owns a failed delivery after local save"); assert.equal(submissions.length, 1); assert.match(submissions[0].requestId, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); assert.equal(persistedBeforeSend[0][0].requestId, submissions[0].requestId, "retry identity saved before POST"); await page.reload({ waitUntil: "networkidle" }); await page.getByRole("button", { name: "Retry message", exact: true }).click(); await page.waitForFunction(() => document.querySelectorAll(".lite-agent .lite-error").length === 0); assert.equal(submissions.length, 2); assert.equal(submissions[1].requestId, submissions[0].requestId, "reload and retry keep one idempotent delivery identity"); assert.equal(await page.locator(`[data-agent-message-id="inbound:sybil:${submissions[0].requestId}"]`).count(), 1); const download = page.waitForEvent("download"); await page.getByRole("button", { name: "Download fixture.png", exact: true }).click(); assert.equal((await download).suggestedFilename(), "fixture.png"); assert.equal(downloads.length, 1); assert.ok(downloads.every(url => !url.includes("token"))); assert.equal(await page.locator(".lite-agent img").count(), 0, "explicit download does not add an animated image preview"); await page.setViewportSize({ width: 390, height: 844 }); await composer.scrollIntoViewIfNeeded(); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth), false); assert.equal(await page.getByText("Start a conversation with Sybil.", { exact: true }).count(), 0); const ownBubble = page.locator(`[data-agent-message-id="inbound:sybil:${submissions[0].requestId}"] .message-bubble--user`); assert.equal(await ownBubble.count(), 1, "Agent reuses the original user bubble"); assert.equal(await ownBubble.evaluate(element => getComputedStyle(element).borderBottomRightRadius), "5px"); const viewport = page.viewportSize(); const composerBox = await composer.boundingBox(); assert.ok(composerBox.y >= 0 && composerBox.y + composerBox.height <= viewport.height, "Agent composer stays in the viewport"); const motion = await page.locator(".lite-agent").evaluate(element => [...element.querySelectorAll("*")].some(node => { const style = getComputedStyle(node); return style.animationName !== "none" || (style.transitionDuration !== "0s" && style.transitionDuration !== "0s, 0s"); })); assert.equal(motion, false, "Agent presentation has no animations or transitions"); if (process.env.LITE_AGENT_SCREENSHOT) await page.screenshot({ path: process.env.LITE_AGENT_SCREENSHOT, fullPage: true }); assert.deepEqual(errors, []); console.log("PASS Lite Agent: draft reload, durable failed send/retry, UUID fallback, explicit verified download, no media/push/animations, mobile layout"); } finally { await context.close(); await browser.close(); }