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

195 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
// Run against npm run build && npm run preview; all API responses are local fixtures.
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: 1100, height: 900 } });
const date = "2026-11-11T12:00:00.000Z";
const markdown = "A response with **formatted text**.\n\n- First item\n- Second item\n\n| Name | Value |\n| --- | --- |\n| Example | Table content |\n\n```js\nconst longLine = '" + "wide-content-".repeat(35) + "';\n```\n\n![No automatic image](https://example.test/animation.gif)";
const message = (id, role, content, metadata = null) => ({ id, role, content, createdAt: date, name: null, metadata });
const chat = { id: "chat-one", title: "Reader test conversation", parentChatId: null, titleGenerationPending: false,
createdAt: date, updatedAt: date, starred: true, starredAt: date, initiatedProvider: "openai", initiatedModel: "reader-model",
lastUsedProvider: "openai", lastUsedModel: "reader-model", additionalSystemPrompt: "Keep replies concise", enabledTools: [],
messages: [message("user-one", "user", "Show a list, a table, and a code sample."), message("assistant-one", "assistant", markdown, { generation: { provider: "openai", model: "reader-model" } })] };
const chats = new Map([[chat.id, chat]]);
const searches = new Map();
const calls = [], errors = [], requests = [], streamRequests = [];
let failFirstStream = true, failNextStream = false;
await context.addInitScript(() => {
localStorage.setItem("sybil_admin_token", "lite-fixture-token");
// Chromium readers can predate this built-in even though they support modules.
delete Array.prototype.at;
});
await context.route("**/*", async route => {
const url = new URL(route.request().url());
requests.push(url.href);
if (url.origin !== target.origin) return route.abort();
if (!url.pathname.startsWith("/api/")) return route.continue();
const path = url.pathname.slice(4), method = route.request().method();
calls.push({ path, method });
let body;
if (path === "/v1/auth/session") body = { mode: "token", authenticated: true };
else if (path === "/v1/workspace-items") {
const items = [...chats.values()].map(chat => ({ ...chat, type: "chat" })).concat([...searches.values()].map(search => ({ ...search, type: "search" })));
body = { items: url.searchParams.has("q") ? items.filter(item => item.title.includes(url.searchParams.get("q"))) : items, hasMore: false };
}
else if (path === "/v1/models") body = { providers: { openai: { models: ["reader-model", "another-model"], loadedAt: date, error: null } } };
else if (path === "/v1/active-runs") body = { chats: [], searches: [] };
else if (path === "/v1/chats" && method === "POST") {
const created = { ...chat, id: `chat-${chats.size + 1}`, title: "New conversation", messages: [], starred: false, additionalSystemPrompt: null };
chats.set(created.id, created); body = { chat: created };
}
else if (/^\/v1\/chats\/[^/]+$/.test(path)) body = { chat: chats.get(path.split("/").pop()) };
else if (path.includes("/title")) body = { chat };
else if (path === "/v1/chat-completions/stream") {
const request = route.request().postDataJSON(); streamRequests.push(request);
if (failFirstStream) { failFirstStream = false; return route.abort("failed"); }
if (failNextStream) { failNextStream = false; return route.fulfill({ status: 503, json: { message: "Model is temporarily unavailable" } }); }
assert.ok(request.clientRequestId);
const selected = chats.get(request.chatId);
const content = request.messages[request.messages.length - 1].content;
await new Promise(resolve => setTimeout(resolve, 500));
selected.messages.push(message(`user-${streamRequests.length}`, "user", content), message(`assistant-${streamRequests.length}`, "assistant", "The completed answer.", { generation: { provider: request.provider, model: request.model } }));
return route.fulfill({ contentType: "text/event-stream", body: 'event: delta\ndata: {"text":"Do not paint partial tokens"}\n\nevent: done\ndata: {"text":"The completed answer."}\n\n' });
}
else if (path === "/v1/searches" && method === "POST") {
const request = route.request().postDataJSON();
const search = { id: "search-one", title: request.title, query: request.query, createdAt: date, updatedAt: date, starred: false, starredAt: null,
requestId: null, latencyMs: null, error: null, answerText: null, answerRequestId: null, answerCitations: null, answerError: null, results: [] };
searches.set(search.id, search); body = { search };
}
else if (path === "/v1/searches/search-one/run/stream") {
const search = searches.get("search-one"); search.answerText = "A useful search answer.";
search.results = [{ id: "result", title: "An original source", url: "https://example.test/source", text: "Source description", highlights: [], rank: 1 }];
return route.fulfill({ contentType: "text/event-stream", body: `event: done\ndata: ${JSON.stringify({ search })}\n\n` });
}
else if (path === "/v1/searches/search-one") body = { search: searches.get("search-one") };
else return route.fulfill({ status: 404, json: { message: `Unexpected fixture request ${method} ${path}` } });
return route.fulfill({ json: body });
});
const page = await context.newPage();
page.on("pageerror", error => errors.push(error.message));
page.setDefaultTimeout(10000);
async function openSidebar() {
const trigger = page.getByRole("button", { name: "Open sidebar", exact: true });
if (await trigger.isVisible()) await trigger.click();
}
async function selectNavigation(name) {
await openSidebar();
await page.getByRole("button", { name, exact: true }).click();
}
async function checkComposerLayout(width) {
const scroll = page.locator(".lite-scroll");
const before = await page.locator(".lite-composer-shell").boundingBox();
assert.ok(before.y >= 0 && before.y + before.height <= page.viewportSize().height + 1, `composer visible at ${width}px`);
await scroll.evaluate(element => { element.scrollTop = element.scrollHeight; });
const after = await page.locator(".lite-composer-shell").boundingBox();
assert.equal(after.y, before.y, "scrolling the transcript does not move the composer");
await scroll.evaluate(element => { element.scrollTop = 0; });
const pageHeight = await page.evaluate(() => ({ height: document.documentElement.scrollHeight, viewport: window.innerHeight }));
assert.ok(pageHeight.height <= pageHeight.viewport + 1, `transcript content stays inside its scroller at ${width}px: ${JSON.stringify(pageHeight)}`);
}
try {
await page.goto(new URL("/lite?chat=chat-one", target).href);
await page.getByRole("heading", { name: chat.title, exact: true }).waitFor();
await page.getByText("First item", { exact: true }).waitFor();
assert.equal(await page.getByLabel("Model", { exact: true }).inputValue(), "reader-model");
assert.equal(await page.locator(".lite-main img").count(), 0);
assert.equal(await page.locator(".md-content table").count(), 1);
const visuals = await page.evaluate(() => ({
animated: [...document.querySelectorAll("*")].filter(el => getComputedStyle(el).animationName !== "none").length,
overflow: document.documentElement.scrollWidth > window.innerWidth,
background: getComputedStyle(document.documentElement).backgroundImage,
}));
assert.equal(visuals.animated, 0); assert.equal(visuals.overflow, false); assert.equal(visuals.background, "none");
for (const text of ["What’s on your mind?", "Made for slower screens.", "A little space to think", "Your persistent assistant", "Find something useful", "What would you like to find?", "Clear text.", "Fewer distractions.", "Simple pages.", "Replies appear when complete."]) {
assert.equal(await page.getByText(text, { exact: false }).count(), 0, `no added flavor text: ${text}`);
}
const bubble = await page.locator(".lite-message-user .message-bubble--user").first().evaluate(element => {
const style = getComputedStyle(element), box = element.getBoundingClientRect(), row = element.closest(".lite-message").getBoundingClientRect();
return { right: box.right, rowRight: row.right, width: box.width, rowWidth: row.width, bottomRightRadius: style.borderBottomRightRadius, background: style.backgroundImage };
});
assert.ok(Math.abs(bubble.right - bubble.rowRight) <= 1 && bubble.width < bubble.rowWidth, "user bubbles retain original right alignment and width limit");
assert.equal(bubble.bottomRightRadius, "5px");
assert.equal(bubble.background, "none");
assert.equal(await page.locator('[data-assistant-footer="assistant-one"]').getByRole("button", { name: "Copy message", exact: true }).count(), 1);
await checkComposerLayout(1100);
if (process.env.LITE_ARTIFACT_DIR) {
await mkdir(process.env.LITE_ARTIFACT_DIR, { recursive: true });
await page.screenshot({ path: `${process.env.LITE_ARTIFACT_DIR}/lite-desktop.png`, fullPage: true });
}
// Selecting the current row must retain its loaded transcript.
await page.locator(".lite-history-list button").filter({ hasText: chat.title }).click();
await page.getByText("First item", { exact: true }).waitFor();
await page.getByLabel("Message", { exact: true }).fill("A follow-up question");
await page.getByRole("button", { name: "Send message", exact: true }).click();
await page.getByText("The completed answer.", { exact: true }).waitFor();
assert.equal(await page.getByLabel("Message", { exact: true }).inputValue(), "");
assert.equal(await page.getByText("Do not paint partial tokens", { exact: true }).count(), 0);
assert.equal(streamRequests.length, 2);
assert.equal(streamRequests[0].clientRequestId, streamRequests[1].clientRequestId, "transport retry reuses submission ID");
assert.deepEqual(streamRequests[0].messages, streamRequests[1].messages);
failNextStream = true;
await page.getByLabel("Message", { exact: true }).fill("Preserve this draft");
await page.getByRole("button", { name: "Send message", exact: true }).click();
await page.getByRole("alert").filter({ hasText: "Model is temporarily unavailable" }).waitFor();
assert.equal(await page.getByLabel("Message", { exact: true }).inputValue(), "Preserve this draft");
const failedRequest = streamRequests[streamRequests.length - 1];
await page.getByRole("button", { name: "Send message", exact: true }).click();
await page.waitForFunction(() => document.querySelector("#lite-draft").value === "");
assert.equal(streamRequests[streamRequests.length - 1].clientRequestId, failedRequest.clientRequestId);
for (const width of [320, 390, 600]) {
await page.setViewportSize({ width, height: 844 });
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth), false, `no overflow at ${width}px`);
await checkComposerLayout(width);
await openSidebar();
await page.getByLabel("Search history", { exact: true }).fill("Reader");
const row = page.locator(".lite-history-list button").filter({ hasText: chat.title });
await row.waitFor();
await row.click();
assert.equal(await page.getByRole("button", { name: "Open sidebar", exact: true }).getAttribute("aria-expanded"), "false");
assert.equal(await page.locator(".lite-sidebar").evaluate(element => getComputedStyle(element).transitionDuration), "0s");
if (process.env.LITE_ARTIFACT_DIR) await page.screenshot({ path: `${process.env.LITE_ARTIFACT_DIR}/lite-${width}.png`, fullPage: true });
}
await selectNavigation("New chat");
await page.getByRole("heading", { name: "New chat", exact: true }).waitFor();
await page.waitForFunction(() => !document.querySelector("#lite-draft").disabled);
assert.equal(await page.locator(".lite-scroll").textContent(), "", "new chats have an empty transcript without welcome copy");
await page.getByLabel("Message", { exact: true }).fill("An unsent draft");
await selectNavigation("New chat");
assert.equal(await page.getByLabel("Message", { exact: true }).inputValue(), "");
await page.getByLabel("Message", { exact: true }).fill("Start a new conversation");
await page.getByRole("button", { name: "Send message", exact: true }).click();
await page.getByText("The completed answer.", { exact: true }).waitFor();
assert.ok(new URL(page.url()).searchParams.get("chat"));
await selectNavigation("New search");
await page.getByLabel("Search the web", { exact: true }).fill("e-ink reading");
await page.getByRole("button", { name: "Search", exact: true }).click();
await page.getByText("A useful search answer.", { exact: true }).waitFor();
assert.equal(await page.getByRole("link", { name: "An original source" }).getAttribute("href"), "https://example.test/source");
assert.equal(new URL(page.url()).searchParams.get("search"), "search-one");
assert.deepEqual(errors, []);
assert.equal(requests.some(url => /character-|\/assets\/App-/.test(url)), false);
assert.equal(await page.evaluate(() => Array.from(document.styleSheets).filter(sheet => !sheet.href || new URL(sheet.href).origin === location.origin)
.some(sheet => Array.from(sheet.cssRules).some(rule => rule.cssText.includes(".tool-call-stack-shell")))), false, "full animation styles are not loaded");
assert.equal(calls.some(call => /\/agent\//.test(call.path)), false, "chat/search does not open Agent or presence polling");
console.log("Lite workspace browser checks passed: original bubbles/footer, no flavor text, fixed composer, chat/retry, history/search, mobile sidebar and isolated assets.");
} catch (error) {
console.error("Workspace fixture state", { ...(await page.evaluate(() => ({
url: location.href, draft: document.querySelector("#lite-draft")?.value,
disabled: document.querySelector("#lite-draft")?.disabled,
title: document.querySelector("main h1")?.textContent,
statuses: [...document.querySelectorAll('[role="status"]')].map(element => element.textContent),
alerts: [...document.querySelectorAll('[role="alert"]')].map(element => element.textContent),
}))), pageErrors: errors, streams: streamRequests.length, recentCalls: calls.slice(-12) });
throw error;
} finally { await browser.close(); }