207 lines
13 KiB
JavaScript
207 lines
13 KiB
JavaScript
import assert from "node:assert/strict";
|
|
|
|
// All requests are fixtures. Run against a local production preview.
|
|
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 date = "2026-11-11T12:00:00Z";
|
|
const message = (id, content) => ({ id, role: "assistant", content, createdAt: date, name: null, metadata: null });
|
|
const makeChat = (id, title) => ({ id, title, parentChatId: null, titleGenerationPending: false, createdAt: date, updatedAt: date,
|
|
starred: false, starredAt: null, initiatedProvider: "openai", initiatedModel: "reader", lastUsedProvider: "openai", lastUsedModel: "reader",
|
|
additionalSystemPrompt: null, enabledTools: [], messages: [message(`${id}-initial`, `${title} initial answer`)] });
|
|
const waitGate = () => { let release; const promise = new Promise(resolve => { release = resolve; }); return { promise, release }; };
|
|
async function waitUntil(check) {
|
|
const deadline = Date.now() + 10000;
|
|
while (!check()) {
|
|
assert.ok(Date.now() < deadline, "fixture request arrived before timeout");
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
}
|
|
}
|
|
|
|
async function fixture(mode) {
|
|
const context = await browser.newContext({ viewport: { width: 1100, height: 850 } });
|
|
const a = makeChat("a", "Conversation A"), b = makeChat("b", "Conversation B");
|
|
const state = { active: mode.startsWith("attach"), attachCalls: 0, delayedReads: 0, submissions: 0, searchSubmissions: 0,
|
|
collectionReads: 0, holdB: false, gate: waitGate(), errors: [] };
|
|
const search = { id: "draft-search", title: "Draft search", query: "draft query", createdAt: date, updatedAt: date,
|
|
starred: false, starredAt: null, requestId: null, latencyMs: null, error: null, answerText: "Draft search completed",
|
|
answerRequestId: null, answerCitations: null, answerError: null, results: [] };
|
|
await context.addInitScript(() => localStorage.setItem("sybil_admin_token", "reader-token"));
|
|
await context.route("**/*", async route => {
|
|
const url = new URL(route.request().url());
|
|
if (url.origin !== target.origin) return route.abort();
|
|
if (!url.pathname.startsWith("/api/")) return route.continue();
|
|
const path = url.pathname.slice(4);
|
|
let body;
|
|
if (path === "/v1/auth/session") body = { authenticated: true, mode: "token" };
|
|
else if (path === "/v1/workspace-items") {
|
|
state.collectionReads += 1;
|
|
if (mode === "draft-navigation" && state.collectionReads === 1) await state.gate.promise;
|
|
body = { items: [a, b].map(chat => ({ ...chat, type: "chat" })) };
|
|
}
|
|
else if (path === "/v1/models") body = { providers: { openai: { models: ["reader"], loadedAt: date, error: null } } };
|
|
else if (path === "/v1/active-runs") body = { chats: state.active ? ["a"] : [], searches: [] };
|
|
else if (path === "/v1/chats/a") body = { chat: a };
|
|
else if (path === "/v1/chats/b") {
|
|
if (state.holdB) { state.delayedReads += 1; await state.gate.promise; }
|
|
body = { chat: b };
|
|
}
|
|
else if (path === "/v1/chat-completions/stream") {
|
|
state.submissions += 1;
|
|
await state.gate.promise;
|
|
b.messages.push(message("late-send", "Late reply for conversation B"));
|
|
return route.fulfill({ contentType: "text/event-stream", body: 'event: done\ndata: {"text":"Late reply for conversation B"}\n\n' }).catch(() => undefined);
|
|
}
|
|
else if (path === "/v1/searches" || path === "/v1/searches/draft-search") body = { search };
|
|
else if (path === "/v1/searches/draft-search/run/stream") {
|
|
state.searchSubmissions += 1;
|
|
return route.fulfill({ contentType: "text/event-stream", body: `event: done\ndata: ${JSON.stringify({ search })}\n\n` });
|
|
}
|
|
else if (path === "/v1/chats/a/stream/attach") {
|
|
state.attachCalls += 1;
|
|
if (mode === "attach-race") {
|
|
state.active = false; a.messages.push(message("finished", "Completed before attachment"));
|
|
return route.fulfill({ status: 404, json: { message: "active chat stream not found" } });
|
|
}
|
|
if (mode === "attach-retry" && state.attachCalls === 1) {
|
|
return route.fulfill({ status: 503, json: { message: "Connection is temporarily unavailable" } });
|
|
}
|
|
await state.gate.promise;
|
|
state.active = false; a.messages.push(message("finished", "Recovered completed answer"));
|
|
return route.fulfill({ contentType: "text/event-stream", body: 'event: done\ndata: {"text":"Recovered completed answer"}\n\n' });
|
|
}
|
|
else if (/\/agent\//.test(path)) body = {};
|
|
else return route.fulfill({ status: 404, json: { message: `Unexpected fixture route ${path}` } });
|
|
return route.fulfill({ json: body }).catch(() => undefined);
|
|
});
|
|
const page = await context.newPage();
|
|
page.setDefaultTimeout(10000);
|
|
page.on("pageerror", error => state.errors.push(error.message));
|
|
await page.goto(new URL(mode === "draft-navigation" ? "/lite" : "/lite?chat=a", target).href);
|
|
await page.getByRole("heading", { name: mode === "draft-navigation" ? "New chat" : "Conversation A", exact: true }).waitFor();
|
|
return { context, page, state, a, b };
|
|
}
|
|
|
|
try {
|
|
{
|
|
const { context, page, state } = await fixture("attach-success");
|
|
await page.waitForFunction(() => document.querySelector("#lite-draft")?.disabled === true);
|
|
await waitUntil(() => state.attachCalls === 1);
|
|
assert.equal(state.attachCalls, 1);
|
|
assert.equal(await page.getByRole("button", { name: "Send message", exact: true }).isDisabled(), true);
|
|
assert.equal(await page.getByText("Recovered completed answer", { exact: true }).count(), 0);
|
|
state.gate.release();
|
|
await page.getByText("Recovered completed answer", { exact: true }).waitFor();
|
|
await page.waitForFunction(() => !document.querySelector("#lite-draft").disabled);
|
|
assert.deepEqual(state.errors, []);
|
|
await context.close();
|
|
}
|
|
{
|
|
const { context, page, state } = await fixture("attach-race");
|
|
await page.getByText("Completed before attachment", { exact: true }).waitFor();
|
|
await page.waitForFunction(() => !document.querySelector("#lite-draft").disabled);
|
|
assert.equal(await page.getByRole("alert").count(), 0, "finished attachment race recovers quietly");
|
|
assert.deepEqual(state.errors, []);
|
|
await context.close();
|
|
}
|
|
{
|
|
const { context, page, state } = await fixture("attach-retry");
|
|
await page.getByRole("alert").filter({ hasText: "Connection is temporarily unavailable" }).waitFor();
|
|
assert.equal(await page.getByRole("button", { name: "Refresh", exact: true }).isEnabled(), true, "remote work cannot disable recovery");
|
|
assert.equal(await page.getByRole("button", { name: "New chat", exact: true }).isEnabled(), true, "failed attachment cannot trap navigation");
|
|
state.gate.release();
|
|
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
|
await page.getByText("Recovered completed answer", { exact: true }).waitFor();
|
|
assert.equal(state.attachCalls, 2);
|
|
assert.deepEqual(state.errors, []);
|
|
await context.close();
|
|
}
|
|
{
|
|
const { context, page, state } = await fixture("navigation");
|
|
await page.getByText("Conversation A initial answer", { exact: true }).waitFor();
|
|
state.holdB = true;
|
|
await page.locator(".lite-history-list button").filter({ hasText: "Conversation B" }).click();
|
|
await page.waitForURL(/chat=b/);
|
|
await page.waitForFunction(() => document.querySelector(".lite-status")?.textContent === "Loading…");
|
|
await page.locator(".lite-history-list button").filter({ hasText: "Conversation A" }).click();
|
|
await page.getByText("Conversation A initial answer", { exact: true }).waitFor();
|
|
state.gate.release();
|
|
await page.waitForTimeout(150);
|
|
assert.equal(await page.getByText("Conversation B initial answer", { exact: true }).count(), 0, "late selection cannot overwrite the current transcript");
|
|
assert.equal(new URL(page.url()).searchParams.get("chat"), "a");
|
|
await page.goBack();
|
|
await page.getByText("Conversation B initial answer", { exact: true }).waitFor();
|
|
assert.equal(new URL(page.url()).searchParams.get("chat"), "b");
|
|
assert.deepEqual(state.errors, []);
|
|
await context.close();
|
|
}
|
|
{
|
|
const { context, page, state } = await fixture("logout");
|
|
state.holdB = true;
|
|
await page.locator(".lite-history-list button").filter({ hasText: "Conversation B" }).click();
|
|
await page.waitForURL(/chat=b/);
|
|
await page.getByRole("button", { name: "Sign out", exact: true }).click();
|
|
await page.getByRole("heading", { name: "Sign in to Sybil", exact: true }).waitFor();
|
|
state.gate.release();
|
|
await page.waitForTimeout(150);
|
|
assert.equal(await page.getByText("Conversation B initial answer", { exact: true }).count(), 0, "late authenticated reads cannot repaint after sign out");
|
|
assert.deepEqual(state.errors, []);
|
|
await context.close();
|
|
}
|
|
{
|
|
const { context, page, state } = await fixture("back-during-send");
|
|
await page.locator(".lite-history-list button").filter({ hasText: "Conversation B" }).click();
|
|
await page.getByText("Conversation B initial answer", { exact: true }).waitFor();
|
|
await page.getByLabel("Message", { exact: true }).fill("A question only for conversation B");
|
|
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
|
await page.waitForFunction(() => document.querySelector("#lite-draft")?.disabled === true);
|
|
await waitUntil(() => state.submissions === 1);
|
|
await page.goBack();
|
|
await page.getByText("Conversation A initial answer", { exact: true }).waitFor();
|
|
state.gate.release();
|
|
await page.waitForTimeout(150);
|
|
assert.equal(await page.getByLabel("Message", { exact: true }).inputValue(), "", "aborted send must not transplant a draft into another conversation");
|
|
assert.equal(await page.getByText("Late reply for conversation B", { exact: true }).count(), 0);
|
|
assert.equal(await page.getByText("A question only for conversation B", { exact: true }).count(), 0);
|
|
assert.deepEqual(state.errors, []);
|
|
await context.close();
|
|
}
|
|
{
|
|
const { context, page, state, a } = await fixture("model-refresh");
|
|
await page.getByText("Conversation A initial answer", { exact: true }).waitFor();
|
|
await page.locator("summary").filter({ hasText: "Settings" }).click();
|
|
await page.getByLabel("Model", { exact: true }).fill("my-next-model");
|
|
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
|
await page.waitForFunction(() => !document.querySelector("#lite-draft").disabled);
|
|
assert.equal(await page.getByLabel("Model", { exact: true }).inputValue(), "my-next-model", "refresh preserves an unsent model choice");
|
|
a.lastUsedModel = "updated-on-another-device";
|
|
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
|
await page.waitForFunction(() => document.querySelector('input[list="lite-model-options"]').value === "updated-on-another-device");
|
|
await page.locator(".lite-history-list button").filter({ hasText: "Conversation B" }).click();
|
|
await page.getByText("Conversation B initial answer", { exact: true }).waitFor();
|
|
assert.equal(await page.getByLabel("Model", { exact: true }).inputValue(), "reader", "switching conversations restores its saved model");
|
|
assert.deepEqual(state.errors, []);
|
|
await context.close();
|
|
}
|
|
{
|
|
const { context, page, state } = await fixture("draft-navigation");
|
|
await waitUntil(() => state.collectionReads === 1);
|
|
assert.equal(await page.getByLabel("Message", { exact: true }).isDisabled(), true);
|
|
await page.getByRole("button", { name: "New search", exact: true }).click();
|
|
await page.getByRole("heading", { name: "New search", exact: true }).waitFor();
|
|
await waitUntil(() => state.collectionReads >= 2);
|
|
await page.getByRole("button", { name: "New search", exact: true }).click();
|
|
state.gate.release();
|
|
await page.waitForFunction(() => !document.querySelector("#lite-draft").disabled);
|
|
await page.getByLabel("Search the web", { exact: true }).fill("draft query");
|
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
await page.getByText("Draft search completed", { exact: true }).waitFor();
|
|
assert.equal(state.searchSubmissions, 1);
|
|
assert.deepEqual(state.errors, []);
|
|
await context.close();
|
|
}
|
|
console.log("Lite recovery browser checks passed: active-stream attachment, completion race, retry, stale navigation, back, sign-out isolation, model selection and repeated draft navigation.");
|
|
} finally { await browser.close(); }
|