140 lines
9.4 KiB
JavaScript
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",
|
|
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();
|
|
}
|