The uniform wash (background-glass) made the rail and the chat chrome nearly opaque - "why is the sidebar so white and non-translucent... its supposed to be translucent and blurry!". The rail, the chat header, the welcome card and the composer now use the same recipe as every other surface in the app (background-blur under the shared gloss + veil + --glass-bg, exactly what the deck cards do), and the dedicated wash asset is deleted. Answer bubbles keep a thin 0.3 veil over the same blurred wallpaper. Measured: the rail runs 181,229,241 over the sky to 133,174,75 over the grass (translucent, d166) and the chat/library screens still scan at 0 below-minimum text-contrast findings in all three themes. The chat suite now pins both the shared recipe and the translucency, so a future "make it uniform" pass cannot quietly reintroduce an opaque panel. Phone touch keeps the flat 32% glass. Version 0.6.3, SW v85.
842 lines
44 KiB
JavaScript
842 lines
44 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 floats: the header, the empty-state card and the
|
|
composer keep the rail's own 12px gutter on every side (the shared
|
|
--layout-gutter) and the panel radius on all four corners. Before, the
|
|
header and composer were full-bleed bands — flush to the viewport edge,
|
|
the composer's bottom corners cropped off-screen. The empty-state card
|
|
is Aero's frosted panel, so this block runs there. */
|
|
const aero = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 });
|
|
await seed(aero.page, { decks: [DECK_NOTES], theme: "aero" });
|
|
await aero.page.goto(`${APP}/chat`, { waitUntil: "networkidle" });
|
|
await aero.page.waitForTimeout(1200);
|
|
const chrome = await aero.page.evaluate(() => {
|
|
const card = document.querySelector(".sidebar-surface");
|
|
const header = document.querySelector(".chat-header");
|
|
const band = document.querySelector(".chat-composer-band");
|
|
const empty = document.querySelector(".chat-empty");
|
|
if (!card || !header || !band || !empty) return null;
|
|
const box = (el) => {
|
|
const rect = el.getBoundingClientRect();
|
|
const style = getComputedStyle(el);
|
|
return {
|
|
left: Math.round(rect.left),
|
|
top: Math.round(rect.top),
|
|
right: Math.round(rect.right),
|
|
bottom: Math.round(rect.bottom),
|
|
tl: style.borderTopLeftRadius,
|
|
tr: style.borderTopRightRadius,
|
|
bl: style.borderBottomLeftRadius,
|
|
br: style.borderBottomRightRadius,
|
|
border: style.borderTopWidth,
|
|
wash: style.backgroundImage.includes("background-blur"),
|
|
};
|
|
};
|
|
return {
|
|
card: box(card),
|
|
header: box(header),
|
|
band: box(band),
|
|
empty: box(empty),
|
|
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
};
|
|
});
|
|
check(
|
|
"desktop: chat panels float on the rail's gutter",
|
|
chrome !== null &&
|
|
chrome.card.left === 12 &&
|
|
chrome.header.left === chrome.card.right + 12 &&
|
|
chrome.empty.left === chrome.header.left &&
|
|
chrome.band.left === chrome.header.left &&
|
|
chrome.header.right === chrome.empty.right &&
|
|
chrome.header.right === chrome.band.right &&
|
|
chrome.viewport.width - chrome.header.right === 12 &&
|
|
chrome.header.top === 12 &&
|
|
chrome.viewport.height - chrome.band.bottom === 12,
|
|
JSON.stringify(chrome),
|
|
);
|
|
check(
|
|
"desktop: every chat panel is rounded on all four corners",
|
|
chrome !== null &&
|
|
[chrome.header, chrome.empty, chrome.band].every(
|
|
(panel) =>
|
|
panel.tl === "24px" && panel.tr === "24px" && panel.bl === "24px" && panel.br === "24px" && panel.border === "1px",
|
|
),
|
|
JSON.stringify(chrome),
|
|
);
|
|
/* Translucency, measured on the pixels: an opaque (or near-opaque) panel
|
|
reads nearly the same colour over the bright sky and over the dark
|
|
grass; glass follows the scene behind it. */
|
|
const railShot = await aero.page.screenshot({ type: "png", scale: "css" });
|
|
const railData = `data:image/png;base64,${railShot.toString("base64")}`;
|
|
const railTint = await aero.page.evaluate(async ({ dataUrl }) => {
|
|
const image = await new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => resolve(img);
|
|
img.onerror = reject;
|
|
img.src = dataUrl;
|
|
});
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = image.width;
|
|
canvas.height = image.height;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.drawImage(image, 0, 0);
|
|
const strip = (y) => {
|
|
const data = ctx.getImageData(30, y, 200, 12).data;
|
|
let r = 0, g = 0, b = 0, n = 0;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
r += data[i]; g += data[i + 1]; b += data[i + 2]; n += 1;
|
|
}
|
|
return [Math.round(r / n), Math.round(g / n), Math.round(b / n)];
|
|
};
|
|
return { upper: strip(440), lower: strip(680) };
|
|
}, { dataUrl: railData });
|
|
const tintDistance = Math.max(
|
|
...[0, 1, 2].map((index) => Math.abs(railTint.upper[index] - railTint.lower[index])),
|
|
);
|
|
check(
|
|
"desktop: the rail is still translucent (its tint follows the scene)",
|
|
tintDistance >= 30,
|
|
`${railTint.upper.join(",")} → ${railTint.lower.join(",")} (Δ${tintDistance})`,
|
|
);
|
|
check(
|
|
"desktop: the rail and the chat chrome share the app's glass recipe",
|
|
chrome !== null && [chrome.card, chrome.header, chrome.empty, chrome.band].every((panel) => panel.wash),
|
|
chrome ? [chrome.card.wash, chrome.header.wash, chrome.empty.wash, chrome.band.wash].join(",") : "missing",
|
|
);
|
|
check(
|
|
"desktop: the empty state is centered between header and composer",
|
|
chrome !== null && Math.abs(chrome.empty.top - chrome.header.bottom - (chrome.band.top - chrome.empty.bottom)) <= 1,
|
|
chrome ? `${chrome.empty.top - chrome.header.bottom} / ${chrome.band.top - chrome.empty.bottom}` : "missing",
|
|
);
|
|
await aero.context.close();
|
|
|
|
/* The composer's controls share one language: send is a filled primary
|
|
tile shaped like the attach tile (it used to be a pale outline that read
|
|
as disabled), and the chips/inputs carry the shared radii. */
|
|
const controls = await desktop.page.evaluate(() => {
|
|
const style = (el) => {
|
|
const cs = getComputedStyle(el);
|
|
return {
|
|
image: cs.backgroundImage,
|
|
radius: cs.borderTopLeftRadius,
|
|
height: Math.round(el.getBoundingClientRect().height),
|
|
};
|
|
};
|
|
return {
|
|
send: style(document.querySelector('button[title="Send"]')),
|
|
attach: style(document.querySelector('button[title="Attach photos or documents"]')),
|
|
chip: style(document.querySelector(".chat-composer-band .chip")),
|
|
input: style(document.querySelector(".chat-composer-band textarea")),
|
|
};
|
|
});
|
|
check(
|
|
"desktop: send is a filled tile shaped like attach",
|
|
controls.send.image.includes("gradient") &&
|
|
controls.send.radius === controls.attach.radius &&
|
|
controls.send.height === controls.attach.height &&
|
|
controls.attach.height >= 40,
|
|
JSON.stringify(controls),
|
|
);
|
|
check(
|
|
"desktop: chips and the input share the control radii",
|
|
controls.chip.height >= 40 && controls.chip.radius === "12px" && controls.input.radius === "14px",
|
|
JSON.stringify(controls),
|
|
);
|
|
|
|
/* 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();
|