Files
Sybil-2/web/tests/lite-agent.browser.mjs

140 lines
9.4 KiB
JavaScript

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