freezmo/tests/chat-test.mjs
Martin Dvorak 303e73f555 Chat chrome ends at the rail with a rounded corner
Docking the two chat bands into the rail card removed the floating cut
but the edge stayed a straight vertical line - still a seam ("this
still looks bad"). Both bands now keep the rail's own 12px gutter and
end in the rail card's 1.5rem radius on their left side, so the ending
reads as deliberate; the header divider spans the flat part of the
bottom edge, from the corner curve inward. Phones keep the plain
full-width borders.

The chat suite now pins the geometry: 12px gutter at the seam, 24px
left corners on both bands, zeroed band borders, inset ::after
hairlines. Chat 104/104, mobile 126/126, regression 50/50.

Version 0.5.7, SW -> freezmo-v79.
2026-10-14 18:06:21 +00:00

744 lines
40 KiB
JavaScript

/**
* Chat mode (phase 1): the streaming API (validation, allowlist, context
* travel, history window, thinking separation, usage-limit mapping), the
* attachment extraction route, and the phone/desktop UI (entry points, scope,
* streaming, stop, model picker, context picker, edit/regenerate, markdown,
* math, sources, offline, reduced motion).
*
* Runs against a stub Ollama, so no real model is needed.
*/
import http from "node:http";
import { readFileSync } from "node:fs";
import { DECK, launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp } from "./lib.mjs";
const { check, finish } = makeChecker();
const browser = await launchBrowser();
const STUB_PORT = 11997;
const APP_PORT = 3212;
const APP = `http://127.0.0.1:${APP_PORT}`;
/* ------------------------------------------------------------------ */
/* Stub Ollama */
/* ------------------------------------------------------------------ */
let mode = "ok"; // ok | thinking | slow | limit
let lastChat = null;
const ANSWER = [
"**Rayleigh** scattering makes the sky blue.",
"",
"- short wavelengths scatter more",
"- violet is mostly absorbed",
"",
"| Layer | Effect |",
"| --- | --- |",
"| Troposphere | blue |",
"",
"```js",
'const sky = "blue";',
"```",
"",
"Mass-energy: $E = mc^2$ done.",
].join("\n");
const stub = http.createServer((request, response) => {
if (request.url?.startsWith("/api/tags")) {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ models: [{ name: "stub-text" }, { name: "stub-vision" }, { name: "stub-extra" }] }));
return;
}
let raw = "";
request.on("data", (chunk) => (raw += chunk));
request.on("end", () => {
let body = {};
try {
body = JSON.parse(raw || "{}");
} catch {
/* ignore */
}
if (request.url?.startsWith("/api/chat")) {
const system = String(body.messages?.[0]?.content ?? "");
// Vision/extraction step (JSON mode).
if (body.format === "json" || system.includes("transcriber")) {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(
JSON.stringify({
message: {
content: JSON.stringify({
title: "Photo notes",
subject: "Biology",
language: "English",
notes: "EXTRACTED-PHOTO-MARKER: photosynthesis needs light and water.",
keyConcepts: ["Photosynthesis"],
}),
},
}),
);
return;
}
lastChat = body;
if (mode === "limit") {
response.writeHead(429, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "You have reached your monthly usage limit. Upgrade your plan." }));
return;
}
response.writeHead(200, { "Content-Type": "application/x-ndjson" });
const chunks = mode === "slow" ? ["Slow ", "answer ", "still ", "coming"] : [ANSWER.slice(0, 24), ANSWER.slice(24, 80), ANSWER.slice(80)];
let index = 0;
const writeChunk = () => {
response.write(JSON.stringify({ message: { content: chunks[index] }, done: false }) + "\n");
index += 1;
};
// Slow mode: the first chunk lands at once so a stop test has content.
if (mode === "slow") writeChunk();
const timer = setInterval(
() => {
if (index < chunks.length) {
if (index === 0 && mode === "thinking") {
response.write(JSON.stringify({ message: { thinking: "REASONING-MARKER let me think…" }, done: false }) + "\n");
}
writeChunk();
} else {
response.write(JSON.stringify({ message: { content: "" }, done: true }) + "\n");
response.end();
clearInterval(timer);
}
},
mode === "slow" ? 700 : 30,
);
// `res` close fires when the client aborts or the response ends; `req`
// close would fire immediately after the request body.
response.on("close", () => clearInterval(timer));
return;
}
response.writeHead(404);
response.end();
});
});
await new Promise((resolve) => stub.listen(STUB_PORT, "127.0.0.1", resolve));
check("stub app port is free", !(await portInUse(APP_PORT)));
const app = spawnApp(APP_PORT, {
OLLAMA_BASE_URL: `http://127.0.0.1:${STUB_PORT}`,
OLLAMA_AUTH: "local",
OLLAMA_VERIFY_KEY: "false",
CHAT_MODELS: "stub-text,stub-vision",
CHAT_MODEL_DEFAULT: "stub-text",
});
const DECK_NOTES = {
...DECK,
sourceText: "FOTOSYNTEZA-MARKER: plants convert light, water and CO2 into sugar. Chlorophyll absorbs red and blue light.",
};
async function chatPost(payload) {
const response = await fetch(`${APP}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const events = response.headers.get("content-type")?.includes("ndjson")
? (await response.text())
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
: null;
const body = events ? null : await response.json().catch(() => null);
return { status: response.status, events, body };
}
try {
const up = await waitForHttp(`${APP}/api/health`);
check("stub app instance is up", up);
if (up) {
/* 1. API: validation + allowlist ---------------------------------- */
const badModel = await chatPost({ model: "evil-model", messages: [{ role: "user", content: "hi" }] });
check("api: model outside the allowlist → 400 bad_model", badModel.status === 400 && badModel.body?.kind === "bad_model", JSON.stringify(badModel.body));
const empty = await chatPost({ messages: [] });
check("api: empty messages → 400", empty.status === 400);
const wrongLast = await chatPost({ messages: [{ role: "user", content: "hi" }, { role: "assistant", content: "hello" }] });
check("api: last message must be from the user → 400", wrongLast.status === 400);
/* 2. API: streaming + context travel + thinking -------------------- */
const ok = await chatPost({
messages: [{ role: "user", content: "Why is the sky blue?" }],
context: [{ deckId: "seed_deck", deckTitle: "Sky deck", section: "Notes", text: "RAYLEIGH-CONTEXT-MARKER scattering notes." }],
});
const tokens = (ok.events ?? []).filter((event) => event.type === "token").map((event) => event.text).join("");
const done = (ok.events ?? []).find((event) => event.type === "done");
check("api: streams tokens and a done event", ok.status === 200 && tokens.includes("Rayleigh") && done?.model === "stub-text", done?.model);
check("api: thinking never mixes into the answer", !tokens.includes("REASONING-MARKER"));
check(
"api: system prompt carries the chosen context + the rules",
lastChat?.messages?.[0]?.content?.includes("RAYLEIGH-CONTEXT-MARKER") &&
lastChat?.messages?.[0]?.content?.includes("primary source"),
);
check("api: the conversation window follows the system prompt", lastChat?.messages?.[1]?.role === "user");
mode = "thinking";
const thinking = await chatPost({ messages: [{ role: "user", content: "Think first" }] });
const thinkingEvents = (thinking.events ?? []).filter((event) => event.type === "thinking").map((event) => event.text).join("");
const thinkingTokens = (thinking.events ?? []).filter((event) => event.type === "token").map((event) => event.text).join("");
check("api: reasoning streams separately", thinkingEvents.includes("REASONING-MARKER") && !thinkingTokens.includes("REASONING-MARKER"));
mode = "ok";
/* 3. API: history window + context cap ----------------------------- */
const longHistory = Array.from({ length: 60 }, (_, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `Message ${index}: ${"x".repeat(600)}`,
}));
longHistory[59] = { role: "user", content: `Last question ${"y".repeat(600)}` };
await chatPost({ messages: longHistory });
const sentCount = lastChat?.messages?.length ?? 0;
check("api: sliding window trims the history", sentCount > 1 && sentCount < 61, `${sentCount} messages reached the model`);
const manyChunks = Array.from({ length: 40 }, (_, index) => ({
deckId: `deck_${index}`,
deckTitle: `Deck ${index}`,
section: "Notes",
text: `CHUNK-${index} ${"z".repeat(11_500)}`,
}));
await chatPost({ messages: [{ role: "user", content: "question" }], context: manyChunks });
const systemLength = String(lastChat?.messages?.[0]?.content ?? "").length;
check("api: context is capped well below what was sent", systemLength > 1000 && systemLength < 60_000, `${systemLength} chars`);
check("api: the first (best) chunk survives the cap", String(lastChat?.messages?.[0]?.content ?? "").includes("CHUNK-0"));
/* 4. API: general chat + usage limit ------------------------------- */
const general = await chatPost({ messages: [{ role: "user", content: "hello" }], context: [] });
check("api: no context → general-chat system prompt", String(lastChat?.messages?.[0]?.content ?? "").includes("No notes are attached") && general.status === 200);
mode = "limit";
const limited = await chatPost({ messages: [{ role: "user", content: "hi" }] });
const limitEvent = (limited.events ?? []).find((event) => event.type === "error");
check("api: monthly usage 429 → usage_limit error event", limitEvent?.kind === "usage_limit", JSON.stringify(limitEvent).slice(0, 90));
mode = "ok";
/* 5. API: attachment extraction ------------------------------------ */
const extracted = await fetch(`${APP}/api/chat/extract`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ images: ["data:image/png;base64," + "A".repeat(64)] }),
});
const extractedBody = await extracted.json();
check("extract: photo → vision text", extracted.status === 200 && extractedBody.text?.includes("EXTRACTED-PHOTO-MARKER"));
const plain = await fetch(`${APP}/api/chat/extract`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "pasted text stays as is" }),
});
const plainBody = await plain.json();
check("extract: plain text needs no model call", plain.status === 200 && plainBody.text === "pasted text stays as is");
const emptyExtract = await fetch(`${APP}/api/chat/extract`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
check("extract: nothing attached → 400", emptyExtract.status === 400);
/* 6. UI: entry points ---------------------------------------------- */
const desktop = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 });
await seed(desktop.page, { decks: [DECK_NOTES] });
await desktop.page.goto(APP, { waitUntil: "networkidle" });
await desktop.page.waitForTimeout(900);
const askLink = desktop.page.getByRole("link", { name: "Ask", exact: true });
check("desktop: Library has an Ask link", (await askLink.count()) === 1 && (await askLink.getAttribute("href")) === "/chat");
check("desktop: sidebar has a Chat row", (await desktop.page.locator('aside a[href="/chat"]').count()) === 1);
await askLink.click();
await desktop.page.waitForTimeout(1200);
check("desktop: chat route opens with the sidebar", desktop.page.url().endsWith("/chat") && (await desktop.page.locator("aside").count()) >= 1);
check("desktop: no tab bar on chat", (await desktop.page.locator("nav.tabbar").count()) === 0);
/* The rail must not paint over the page: its glass panel stays inside the
rail and the chat content starts after it. Regression: a wide chat list
stretched the panel (flex item min-width: auto) past the rail and
covered the header title, composer and context chip. */
const railMetrics = () =>
desktop.page.evaluate(() => {
const aside = document.querySelector("aside[aria-label]");
const panel = aside?.firstElementChild;
const main = document.querySelector("main");
if (!aside || !panel || !main) return null;
const a = aside.getBoundingClientRect();
const p = panel.getBoundingClientRect();
const m = main.getBoundingClientRect();
return {
panelInside: p.right <= a.right - 1,
contentClear: m.left >= a.right - 1,
gap: Math.round(m.left - p.right),
overflowing: [...panel.querySelectorAll("*")].some((el) => el.getBoundingClientRect().right > p.right + 1),
};
});
const expandedRail = await railMetrics();
check("desktop: sidebar panel stays inside the rail", expandedRail !== null && expandedRail.panelInside && !expandedRail.overflowing);
check("desktop: chat content starts after the sidebar", expandedRail !== null && expandedRail.contentClear && expandedRail.gap > 0);
await desktop.page.getByRole("button", { name: "Collapse sidebar" }).click();
await desktop.page.waitForTimeout(400);
const collapsedRail = await railMetrics();
check("desktop: collapsed rail also stays inside and clear", collapsedRail !== null && collapsedRail.panelInside && !collapsedRail.overflowing && collapsedRail.contentClear);
await desktop.page.getByRole("button", { name: "Expand sidebar" }).click();
await desktop.page.waitForTimeout(400);
check("desktop: AI disclaimer under the composer", await desktop.page.getByText("Freezmo uses AI and can make mistakes").isVisible());
/* The chat chrome ends at the rail with the rail card's own radius: the
header and the composer are full-bleed bands inside the main pane, which
starts right at the sidebar — a straight vertical edge there read as a
hard cut (the 12px gutter showed the raw scene and the bands' fill/blur
made it a color step at the seam). Both now keep the rail's 12px gutter
and end in a 1.5rem rounded corner on their left side, with their
hairlines inset instead of touching the seam. */
const seam = await desktop.page.evaluate(() => {
const card = document.querySelector(".sidebar-surface");
const header = document.querySelector(".chat-header");
const band = document.querySelector(".chat-composer-band");
if (!card || !header || !band) return null;
const line = (el) => {
const cs = getComputedStyle(el, "::after");
return { content: cs.content, left: parseFloat(cs.left) };
};
const hcs = getComputedStyle(header);
return {
cardRight: Math.round(card.getBoundingClientRect().right),
headerLeft: Math.round(header.getBoundingClientRect().left),
bandLeft: Math.round(band.getBoundingClientRect().left),
headerRadius: hcs.borderStartStartRadius,
headerEndRadius: hcs.borderEndStartRadius,
headerBorder: hcs.borderBottomWidth,
bandBorder: getComputedStyle(band).borderTopWidth,
headerLine: line(header),
bandLine: line(band.parentElement),
};
});
check(
"desktop: chat chrome ends in a rounded corner at the seam",
seam !== null &&
seam.headerLeft === seam.cardRight + 12 &&
seam.bandLeft === seam.cardRight + 12 &&
seam.headerRadius === "24px" &&
seam.headerEndRadius === "24px" &&
seam.headerBorder === "0px" &&
seam.bandBorder === "0px" &&
seam.headerLine.content !== "none" &&
seam.headerLine.left > 0 &&
seam.bandLine.content !== "none" &&
seam.bandLine.left > 0,
JSON.stringify(seam),
);
/* The notes-off banner is gone: with "keep notes text" off, chat no longer
nags at the bottom — the scope chip already says what it reads. */
const quiet = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 });
await seed(quiet.page, {
decks: [DECK_NOTES],
extra: { "freezmo.settings.v1": JSON.stringify({ apiKey: "test-key", chatNotes: false }) },
});
await quiet.page.goto(`${APP}/chat`, { waitUntil: "networkidle" });
await quiet.page.waitForTimeout(900);
const quietText = await quiet.page.evaluate(() => document.body.innerText);
check("desktop: no notes-off banner with chat notes off", !/ignore notes text|ignoruje/i.test(quietText));
await quiet.context.close();
/* 6b. desktop pickers: the chip that opens also closes ---------------
(It used to only ever open, so the only way out was a choice.) */
const modelChip = () => desktop.page.getByRole("button", { name: /Stub (Text|Vision)|Chat model/ }).first();
const modelDialog = desktop.page.getByRole("dialog", { name: "Chat model" });
const scopeDialog = desktop.page.getByRole("dialog", { name: "What may chat read?" });
const scopeChip = () => desktop.page.getByRole("button", { name: "All notes" }).first();
await modelChip().click();
await desktop.page.waitForTimeout(400);
check("desktop: model chip opens the dropdown", (await modelDialog.count()) === 1);
check("desktop: model chip reads pressed while open", (await modelChip().getAttribute("aria-expanded")) === "true");
await modelChip().click();
await desktop.page.waitForTimeout(400);
check("desktop: the same chip closes it (no forced choice)", (await modelDialog.count()) === 0);
check("desktop: chip is not pressed once closed", (await modelChip().getAttribute("aria-expanded")) === "false");
await scopeChip().click();
await desktop.page.waitForTimeout(400);
check("desktop: note chip opens the dropdown", (await scopeDialog.count()) === 1);
await modelChip().click();
await desktop.page.waitForTimeout(400);
check(
"desktop: opening one picker closes the other",
(await scopeDialog.count()) === 0 && (await modelDialog.count()) === 1,
);
await desktop.page.mouse.click(700, 300);
await desktop.page.waitForTimeout(400);
check("desktop: a click outside closes the dropdown", (await modelDialog.count()) === 0);
await desktop.context.close();
const phone = await makePage(browser);
await seed(phone.page, { decks: [DECK_NOTES] });
await phone.page.goto(APP, { waitUntil: "networkidle" });
await phone.page.waitForTimeout(900);
const askButton = phone.page.getByRole("link", { name: "Ask", exact: true });
check("phone: Library has an Ask button", (await askButton.count()) === 1 && (await askButton.getAttribute("href")) === "/chat");
await phone.page.getByRole("button", { name: "Deck actions" }).click();
await phone.page.waitForTimeout(300);
const askDeck = phone.page.getByRole("link", { name: "Ask about this deck" });
check("phone: deck menu has Ask about this deck", (await askDeck.count()) === 1 && (await askDeck.getAttribute("href"))?.includes("/chat?deck=seed_deck"));
await askDeck.click();
await phone.page.waitForTimeout(1400);
check("phone: chat opens scoped to the deck", phone.page.url().includes("deck=seed_deck") && (await phone.page.getByText("1 deck").count()) >= 1);
check("phone: no tab bar on chat", (await phone.page.locator("nav.tabbar").count()) === 0);
check("phone: back button present", (await phone.page.getByRole("button", { name: "Back" }).count()) === 1);
/* 7. UI: streaming + markdown + math + sources ---------------------- */
check("chat: empty state with suggestions", await phone.page.getByText("Ask your notes anything").isVisible());
check("chat: AI disclaimer under the composer (empty state too)", await phone.page.getByText("Freezmo uses AI and can make mistakes").isVisible());
check("chat: deck suggestion present", (await phone.page.getByRole("button", { name: /Summarize my notes/ }).count()) === 1);
const context = phone.page.locator(".chat-md").first();
await phone.page.getByRole("button", { name: /Summarize my notes/ }).click();
await phone.page.waitForTimeout(2500);
check("chat: user bubble appears", (await phone.page.getByText("Summarize my notes").count()) >= 1);
check("chat: assistant answer streams in", await context.getByText("scattering makes the sky blue").isVisible());
check("chat: markdown bold + list render", (await context.locator("strong").count()) >= 1 && (await context.locator("li").count()) >= 2);
check("chat: table renders", (await context.locator("table").count()) === 1);
check(
"chat: code block + copy button",
(await context.locator("pre").count()) === 1 && (await context.getByRole("button", { name: "Copy" }).count()) >= 1,
);
check("chat: math renders through KaTeX", (await phone.page.locator(".katex").count()) >= 1);
check("chat: sources chip links to the deck", (await phone.page.locator('a[href="/deck/seed_deck"]').count()) >= 1);
check("chat: context reached the model", String(lastChat?.messages?.[0]?.content ?? "").includes("FOTOSYNTEZA-MARKER"));
/* 8. UI: model picker + context picker ------------------------------ */
await phone.page.getByRole("button", { name: "Stub Text" }).click();
await phone.page.waitForTimeout(400);
check("model: picker lists both models with availability", (await phone.page.getByText("stub-vision").count()) >= 1 && (await phone.page.getByText("available").count()) >= 1);
check("model: a model listed by the server's tags is selectable", (await phone.page.getByText("stub-extra").count()) >= 1);
await phone.page.getByRole("button", { name: /stub-vision/ }).click();
await phone.page.getByRole("dialog", { name: "Chat model" }).waitFor({ state: "detached", timeout: 4000 }).catch(() => {});
await phone.page.waitForTimeout(300);
check("model: choice updates the chip", (await phone.page.getByRole("button", { name: "Stub Vision" }).count()) === 1);
check(
"model: choice is remembered",
(await phone.page.evaluate(() => localStorage.getItem("freezmo.chat.model.v1"))) === "stub-vision",
);
await phone.page.reload({ waitUntil: "networkidle" });
await phone.page.waitForTimeout(1200);
check("model: remembered across reloads", (await phone.page.getByRole("button", { name: "Stub Vision" }).count()) === 1);
await phone.page.getByRole("button", { name: "1 deck" }).click();
await phone.page.waitForTimeout(400);
check("scope: picker opens with the modes", (await phone.page.getByText("All notes").count()) >= 1 && (await phone.page.getByText("No notes").count()) >= 1);
await phone.page.getByRole("button", { name: /No notes/ }).first().click();
await phone.page.getByRole("dialog", { name: "What may chat read?" }).waitFor({ state: "detached", timeout: 4000 }).catch(() => {});
await phone.page.waitForTimeout(300);
check("scope: chip follows the choice", (await phone.page.getByRole("button", { name: "No notes" }).count()) === 1);
/* 9. UI: thinking row ----------------------------------------------- */
mode = "thinking";
await phone.page.getByRole("textbox").fill("Think about it");
await phone.page.getByRole("button", { name: "Send", exact: true }).click();
await phone.page.waitForTimeout(2500);
const thinkingRow = phone.page.getByRole("button", { name: /Thinking/ }).last();
check("thinking: row appears", (await thinkingRow.count()) >= 1);
check("thinking: reasoning hidden by default", (await phone.page.getByText("REASONING-MARKER").count()) === 0);
await thinkingRow.click();
await phone.page.waitForTimeout(300);
check("thinking: expanding reveals it", (await phone.page.getByText(/REASONING-MARKER/).count()) >= 1);
mode = "ok";
/* 10. UI: stop mid-stream ------------------------------------------- */
mode = "slow";
await phone.page.getByRole("textbox").fill("Slow one");
await phone.page.getByRole("button", { name: "Send", exact: true }).click();
await phone.page.waitForTimeout(1700);
check("stop: stop button appears while streaming", (await phone.page.getByRole("button", { name: "Stop" }).count()) === 1);
await phone.page.getByRole("button", { name: "Stop" }).click();
await phone.page.waitForTimeout(700);
check("stop: send button returns", (await phone.page.getByRole("button", { name: "Send", exact: true }).count()) === 1);
const partial = await phone.page.locator(".chat-md").last().innerText().catch(() => "");
// Next's route announcer is also role=alert — only real error cards count.
const alerts = await phone.page.locator('[role="alert"]:not(#__next-route-announcer__)').count();
check("stop: partial answer kept, no error card", partial.includes("Slow") && alerts === 0, JSON.stringify(partial.slice(0, 30)));
mode = "ok";
/* 11. UI: regenerate + edit ----------------------------------------- */
const lastAssistantActions = phone.page.locator("div").filter({ has: phone.page.getByRole("button", { name: "Regenerate" }) });
check("actions: regenerate offered on the last answer", (await phone.page.getByRole("button", { name: "Regenerate" }).count()) >= 1);
const before = await phone.page.locator(".chat-md").count();
await phone.page.getByRole("button", { name: "Regenerate" }).last().click();
await phone.page.waitForTimeout(2200);
check("actions: regenerate streams a fresh answer", (await phone.page.locator(".chat-md").count()) === before);
void lastAssistantActions;
check("actions: edit offered on the last user message", (await phone.page.getByRole("button", { name: "Edit and resend" }).count()) === 1);
await phone.page.getByRole("button", { name: "Edit and resend" }).click();
await phone.page.waitForTimeout(400);
check("actions: edit restores the text into the composer", (await phone.page.getByRole("textbox").inputValue()) === "Slow one");
/* 12. UI: usage limit + offline ------------------------------------- */
mode = "limit";
await phone.page.getByRole("textbox").fill("Will hit the limit");
await phone.page.getByRole("button", { name: "Send", exact: true }).click();
await phone.page.waitForTimeout(2000);
check("limit: error card shown", (await phone.page.getByRole("alert").count()) >= 1 && (await phone.page.getByText(/free monthly Ollama usage/i).count()) >= 1);
check("limit: Settings + Ollama actions offered", (await phone.page.getByRole("button", { name: /Open settings/ }).count()) >= 1 && (await phone.page.getByRole("button", { name: /Ollama/ }).count()) >= 1);
check("limit: the draft is kept for a retry", (await phone.page.getByRole("textbox").inputValue()) === "Will hit the limit");
mode = "ok";
await phone.context.setOffline(true);
await phone.page.waitForTimeout(500);
check("offline: send disabled with a reason", (await phone.page.getByRole("button", { name: "Send", exact: true }).isDisabled()) && (await phone.page.getByText(/Offline — sending is paused/).count()) === 1);
await phone.context.setOffline(false);
await phone.page.waitForTimeout(400);
/* 13. UI: attachment ------------------------------------------------- */
await phone.page.getByRole("button", { name: "Attach photos or documents" }).click();
await phone.page.locator('input[type="file"]').setInputFiles({
name: "notes.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
),
});
await phone.page.waitForTimeout(1800);
check("attach: chip appears after extraction", (await phone.page.getByText("notes.png").count()) >= 1);
await phone.page.getByRole("textbox").fill("What does the photo say?");
await phone.page.getByRole("button", { name: "Send", exact: true }).click();
await phone.page.waitForTimeout(2200);
check("attach: extracted text travels as context", String(lastChat?.messages?.[0]?.content ?? "").includes("EXTRACTED-PHOTO-MARKER"));
/* 14. UI: new chat + old-decks note --------------------------------- */
await phone.page.getByRole("button", { name: "New chat" }).click();
await phone.page.waitForTimeout(900);
check("new chat: clears the conversation", (await phone.page.locator(".chat-md").count()) === 0 && (await phone.page.getByText("Ask your notes anything").count()) === 1);
const oldDeck = { ...DECK, id: "old_deck", title: "Imported deck", sourceText: "" };
const oldPage = await makePage(browser);
await seed(oldPage.page, { decks: [oldDeck] });
await oldPage.page.goto(`${APP}/chat?deck=old_deck`, { waitUntil: "networkidle" });
await oldPage.page.waitForTimeout(1200);
check("old decks: the fallback notice is shown", (await oldPage.page.getByText(/Older decks only have the generated study content/).count()) === 1);
check("old decks: scope chip scoped to it", (await oldPage.page.getByText("1 deck").count()) >= 1);
await oldPage.context.close();
/* 16. phase 2: history, actions, sync, export ----------------------- */
{
const hist = await makePage(browser);
await seed(hist.page, { decks: [DECK_NOTES] });
await hist.page.goto(`${APP}/chat?deck=seed_deck`, { waitUntil: "networkidle" });
await hist.page.waitForTimeout(1200);
await hist.page.getByRole("textbox").fill("What is photosynthesis?");
await hist.page.getByRole("button", { name: "Send", exact: true }).click();
await hist.page.waitForTimeout(2600);
const readChats = (target) =>
target.evaluate(
() =>
new Promise((resolve) => {
const request = indexedDB.open("freezmo-chat", 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains("chats")) db.createObjectStore("chats", { keyPath: "id" });
};
request.onsuccess = () => {
const db = request.result;
try {
const all = db.transaction("chats", "readonly").objectStore("chats").getAll();
all.onsuccess = () => {
db.close();
resolve(all.result);
};
all.onerror = () => {
db.close();
resolve([]);
};
} catch {
db.close();
resolve([]);
}
};
request.onerror = () => resolve([]);
}),
);
const stored = await readChats(hist.page);
check("history: conversation saved to IndexedDB", stored.length === 1, String(stored.length));
check("history: auto title from the first question", stored[0]?.title === "What is photosynthesis?", stored[0]?.title);
check("history: messages + sources stored", stored[0]?.messages?.length === 2 && (stored[0]?.messages?.[1]?.sources?.length ?? 0) >= 1);
check("history: URL carries the conversation id", hist.page.url().includes("/chat?c="), hist.page.url());
// Assistant actions: save to notes + turn into flashcards.
const beforeNotes = await hist.page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1"))[0].sourceText);
await hist.page.getByRole("button", { name: "Save to notes" }).click();
await hist.page.waitForTimeout(700);
const afterNotes = await hist.page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1"))[0].sourceText);
check("actions: save to notes appends to the scoped deck", afterNotes.length > beforeNotes.length && afterNotes.includes("Rayleigh"));
check("actions: saved note is labelled", afterNotes.includes("Saved from chat"));
await hist.page.getByRole("button", { name: "Turn into flashcards" }).click();
await hist.page.waitForTimeout(1600);
check(
"actions: flashcards opens the wizard on the options step",
hist.page.url().includes("/create") && (await hist.page.getByText("2 / 3").count()) === 1,
hist.page.url(),
);
await hist.page.getByRole("button", { name: "Back" }).click();
await hist.page.waitForTimeout(500);
const seededText = await hist.page.locator("textarea").first().inputValue().catch(() => "");
check("actions: answer pre-filled as notes", seededText.includes("Rayleigh"));
// Back to the conversation: it survives, sources included.
await hist.page.goto(`${APP}/chat?c=${stored[0].id}`, { waitUntil: "networkidle" });
await hist.page.waitForTimeout(1600);
check("history: conversation survives navigation + reload", (await hist.page.locator(".chat-md").count()) === 1);
check("history: sources chips survive too", (await hist.page.locator('a[href="/deck/seed_deck"]').count()) >= 1);
// Rename + search + delete/undo in the phone sheet.
await hist.page.getByRole("button", { name: "Chats" }).click();
await hist.page.waitForTimeout(600);
const sheet = hist.page.getByRole("dialog", { name: "Chats" });
check("history: phone sheet lists the chat", (await sheet.getByText("What is photosynthesis?").count()) >= 1);
await sheet.getByRole("button", { name: "Rename" }).click();
await sheet.getByRole("textbox", { name: "Rename" }).fill("Photosynthesis chat");
await sheet.getByRole("button", { name: "Save" }).click();
await hist.page.waitForTimeout(700);
check("history: rename updates the list", (await sheet.getByText("Photosynthesis chat").count()) >= 1);
check("history: rename persisted", (await readChats(hist.page))[0]?.title === "Photosynthesis chat");
await sheet.getByRole("searchbox", { name: "Search chats" }).fill("zzz-nothing");
await hist.page.waitForTimeout(400);
check("history: search filters and explains", (await sheet.getByText("No chats match that search.").count()) === 1);
await sheet.getByRole("searchbox", { name: "Search chats" }).fill("photosynthesis");
await hist.page.waitForTimeout(400);
check("history: search finds by title", (await sheet.getByText("Photosynthesis chat").count()) >= 1);
await sheet.getByRole("searchbox", { name: "Search chats" }).fill("");
await sheet.getByRole("button", { name: "Delete chat" }).click();
await hist.page.waitForTimeout(800);
check("history: delete removes the chat", (await sheet.getByText("Photosynthesis chat").count()) === 0 && (await readChats(hist.page)).length === 0);
await hist.page.getByRole("button", { name: "Undo" }).click();
await hist.page.waitForTimeout(800);
check("history: undo restores the chat", (await readChats(hist.page)).length === 1);
await sheet.getByRole("button", { name: "New chat" }).click();
await hist.page.waitForTimeout(700);
check(
"history: new chat clears the conversation",
(await hist.page.locator(".chat-md").count()) === 0 && (await hist.page.getByText("Ask your notes anything").count()) === 1,
);
// Open from the list again; scope is remembered across a reload.
await hist.page.getByRole("button", { name: "Chats" }).click();
await hist.page.waitForTimeout(600);
await sheet.getByText("Photosynthesis chat").click();
await hist.page.waitForTimeout(1400);
check("history: opening from the list loads it", (await hist.page.locator(".chat-md").count()) === 1);
await hist.page.getByRole("button", { name: /1 deck/ }).click();
await hist.page.waitForTimeout(400);
await hist.page.getByRole("button", { name: /No notes/ }).first().click();
await hist.page.waitForTimeout(500);
await hist.page.reload({ waitUntil: "networkidle" });
await hist.page.waitForTimeout(1600);
check("history: scope remembered after reload", (await hist.page.getByRole("button", { name: "No notes" }).count()) === 1);
// Offline: history readable, sending paused.
await hist.page.context().setOffline(true);
await hist.page.waitForTimeout(400);
check(
"history: readable offline with sending paused",
(await hist.page.locator(".chat-md").count()) === 1 && (await hist.page.getByText(/Offline — sending is paused/).count()) === 1,
);
await hist.page.context().setOffline(false);
// Export includes chats and never the key.
await hist.page.goto(APP, { waitUntil: "networkidle" });
await hist.page.waitForTimeout(900);
await hist.page.locator("nav.tabbar").getByRole("button").click();
await hist.page.getByRole("dialog").waitFor({ timeout: 6000 });
await hist.page.waitForTimeout(700);
const downloadPromise = hist.page.waitForEvent("download");
await hist.page.getByRole("button", { name: "Export all data" }).click();
const download = await downloadPromise;
const raw = readFileSync(await download.path(), "utf8");
const backup = JSON.parse(raw);
check("export: chats included, API key excluded", backup.data.chats?.length === 1 && !raw.includes("test-key"), JSON.stringify(backup.data.chats?.length));
// Encrypted sync carries chats to a second device.
await hist.page.getByRole("button", { name: "Turn on sync" }).click();
await hist.page.waitForTimeout(300);
await hist.page.getByPlaceholder("Sync passphrase").fill("correct horse battery");
await hist.page.getByPlaceholder("Repeat the passphrase").fill("correct horse battery");
await hist.page.getByRole("button", { name: "Start syncing" }).click();
await hist.page.waitForTimeout(3000);
const syncId = await hist.page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.sync.v1")).id);
check("sync: enabled with chats in the payload", /^[a-f0-9]{32}$/.test(syncId), syncId);
await hist.page.keyboard.press("Escape");
await hist.page.waitForTimeout(500);
const second = await makePage(browser);
await seed(second.page, { decks: [] });
await second.page.goto(APP, { waitUntil: "networkidle" });
await second.page.waitForTimeout(900);
await second.page.locator("nav.tabbar").getByRole("button").click();
await second.page.getByRole("dialog").waitFor({ timeout: 6000 });
await second.page.waitForTimeout(500);
await second.page.getByRole("button", { name: "Join another sync" }).click();
await second.page.waitForTimeout(300);
await second.page.getByPlaceholder("Sync ID").fill(syncId);
await second.page.getByPlaceholder("Sync passphrase").fill("correct horse battery");
await second.page.getByRole("button", { name: "Join" }).click();
await second.page.waitForTimeout(3500);
const secondChats = await readChats(second.page);
check("sync: joined device received the chat", secondChats.length === 1 && secondChats[0].title === "Photosynthesis chat", JSON.stringify(secondChats.length));
// Clean up the server copy so the shared sync dir stays tidy.
await second.page.getByRole("button", { name: "Delete synced data" }).click();
await second.page.waitForTimeout(300);
await second.page.getByRole("button", { name: "Delete", exact: true }).click();
await second.page.waitForTimeout(1500);
const gone = await second.page.evaluate(async (id) => {
const response = await fetch(`/api/sync?id=${id}`, { headers: { "x-ollama-key": "test-key" } });
return response.status;
}, syncId);
check("sync: server copy removed after the test", gone === 404, String(gone));
await second.context.close();
// Desktop: the history panel sits next to the conversation.
await hist.page.setViewportSize({ width: 1280, height: 900 });
await hist.page.goto(`${APP}/chat?c=${stored[0].id}`, { waitUntil: "networkidle" });
await hist.page.waitForTimeout(1600);
check(
"desktop: history panel lists the chat",
(await hist.page.locator("aside").getByText("Photosynthesis chat").count()) >= 1 && (await hist.page.locator(".chat-md").count()) === 1,
);
await hist.context.close();
}
/* 15. reduced motion still works ------------------------------------ */
const reduced = await makePage(browser, { reducedMotion: "reduce" });
await seed(reduced.page, { decks: [DECK_NOTES] });
await reduced.page.goto(`${APP}/chat`, { waitUntil: "networkidle" });
await reduced.page.waitForTimeout(1000);
await reduced.page.getByRole("textbox").fill("Reduced motion question");
await reduced.page.getByRole("button", { name: "Send", exact: true }).click();
await reduced.page.waitForTimeout(2500);
check("reduced motion: chat still answers", (await reduced.page.locator(".chat-md").count()) >= 1);
await reduced.context.close();
await phone.context.close();
}
} finally {
app.stop();
stub.close();
await browser.close();
}
finish();