Files
ThothII/frontend/e2e/ui-visual-review.spec.ts
T

731 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Page } from "@playwright/test";
import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../src/test/workspace-fixtures";
import { memoryFormattingExamples } from "../src/shell/memoryFormattingExamples";
// These tests never forward API requests to an installation or invoke a real model.
const workspace = "visual-fixture";
const title = "Clinical research workspace";
const model = { provider: "test", id: "review-model", name: "Review model", reasoning: true };
const revision = workspaceRevisionFixture(workspace);
const evidence = {
id: "evidence:cohort", title: "Cohort definition", kind: "domain", language: "en",
purposes: ["sql_generation"], applies_to: { concepts: ["cohort"], tables: [], columns: [] },
payload: { rule: "# Approved cohort\n\nInclude eligible patients. Review the inclusion criteria before generating SQL." },
provenance: { source_file: "research-guide.md", supporting_excerpts: ["Eligibility is evaluated at admission."] },
review_items: [], file: "curated/domain/cohort.md", status: "active", revision: "1",
};
const evidencePage = {
items: [evidence], total: 1, page: 1, page_size: 25, errors: [], initialized: true,
active_revision: "1", pending_revision: null, source_reviews: [],
location: { repository: "/installation/workspaces", workspace: "/installation/workspaces/clinical-research",
runtime_workspace: "/data/workspaces/clinical-research", host: "installation", command: "tht workspace consolidate",
git_commands: ["git status", "git diff"] },
};
async function fixtures(page: Page, mode: "full" | "embedded" = "full") {
const writes: string[] = [];
await page.route("**/config.js", route => route.fulfill({
contentType: "application/javascript",
body: `window.__THOTHII_CONFIG__ = ${JSON.stringify({ backendBaseUrl: "/api", shell: { mode, defaultLocale: "en" } })};
${mode === "embedded" ? `
document.documentElement.setAttribute("data-bs-theme", "light");
const language = document.createElement("select");
language.className = "omics-language-select";
language.dataset.lang = "en";
language.hidden = true;
document.body.prepend(language);
` : ""}`,
}));
await page.route("http://127.0.0.1:5183/api/**", async route => {
const path = new URL(route.request().url()).pathname.replace(/^\/api/, "");
if (route.request().method() !== "GET") {
writes.push(path);
return route.fulfill({ status: 503, json: { message: "Visual fixture: no writes" } });
}
let body: unknown;
if (path === "/auth/config") body = { mode: "mock", localLogin: false, oidcLogin: false };
else if (path === "/me") body = { issuer: "test", subject: "admin", displayName: "Review administrator", isAdmin: true,
roles: ["admin"], permissions: ["session.use", "session.read_all", "session.manage_all", "database.manage", "workspace.manage", "workspace.secrets.manage", "memory.manage", "evidence.manage", "pi.manage"], csrfToken: null, session: null };
else if (path === "/settings") body = { workspace, provider: model.provider, model: model.id, thinking: "high" };
else if (path === "/models") body = { models: [model] };
else if (path === "/workspaces") body = [workspaceSummaryFixture(workspace, { displayName: title })];
else if (path === `/workspaces/${workspace}`) body = { workspace: canonicalWorkspaceFixture(workspace), revision };
else if (path.endsWith("/runtime-configuration")) body = { workspaceId: workspace, revision, configurationState: "ready", requirements: [] };
else if (path.endsWith("/preprocessing")) body = { schemaVersion: 1, workspaceId: workspace, state: "ready", actionable: true, clearable: true,
detail: "Catalog revision 1 is indexed.", metadataRevision: 1, preprocessedMetadataRevision: 1 };
else if (path === "/workspace-registry/status") body = { branch: "main", ahead: 0, behind: 0, degraded: false };
else if (path.endsWith("/memory/pending")) body = [];
else if (path.endsWith("/memory")) body = { items: [], total: 0, page: 1, page_size: 25 };
else if (path.endsWith("/evidence")) body = evidencePage;
else if (path.includes("/evidence/")) body = { ...evidencePage, item: evidence };
else if (path === "/pi-management/status") body = { ready: true, credentials: "present", hostPlatform: "macos", version: "0.80.3",
config: { provider: model.provider, model: model.id, reasoning: "high" } };
else if (path === "/sessions") body = [{ id: "archived-fixture", status: "finalized", question: "How many eligible patients?", name: "Cohort review",
archived: true, active: false, workspace, created_at: "2026-09-01T12:00:00Z", updated_at: null, author: null, group: null, summary: null }];
else if (path.endsWith("/documents")) body = [{ key: "question", title: "Original question", phase: "F1", format: "markdown", content: "# Cohort review\n\nHow many eligible patients were admitted in 2025?" }];
else if (path === "/health/dwh") body = { ok: true, detail: "Available" };
else if (path === "/catalog/databases") body = [];
else if (path === "/catalog/metrics") body = { scope: "global", databaseId: null, tables: 0, columns: 0, sensitiveColumns: 0, relationships: 0,
descriptionTargets: 0, describedTargets: 0, descriptionCoverage: 0, updatedAt: null };
else if (path === "/catalog/metadata-generation/models") body = { models: [], default: null };
else if (path.endsWith("-runs")) body = [];
else return route.fulfill({ status: 404, json: { message: `Missing visual fixture: ${path}` } });
return route.fulfill({ json: body });
});
return writes;
}
async function navigation(page: Page) {
const trigger = page.getByRole("button", { name: "Navigation", exact: true });
if (await trigger.isVisible()) await trigger.click();
}
for (const mode of ["full", "embedded"] as const) {
for (const viewport of [{ width: 1280, height: 720 }, { width: 390, height: 844 }]) {
test(`catalog configuration scrolls to all fields and actions in ${mode} at ${viewport.width}px`, async ({ page }, testInfo) => {
await page.setViewportSize(viewport);
const writes = await fixtures(page, mode);
await page.route("**/api/catalog/databases", route => route.fulfill({ json: [{
workspaceId: workspace, workspaceName: title, workspaceAvailable: true,
workspaceRevision: revision, workspaceEvidence: { sourceType: null, state: "not_declared" },
runtimeBinding: null, configured: false, engine: "postgres", databaseName: "warehouse",
schema: "public", version: 0, createdAt: "", updatedAt: "",
binding: { transport: "postgres_direct", port: 5432 }, connectionStatus: "untested",
secrets: { password: false, apiKey: false, sshPrivateKey: false,
sshPrivateKeyPassphrase: false, sshKnownHosts: false, tlsCa: false },
}] }));
await page.goto("/?thoth_route=administration/database");
await page.getByRole("button", { name: `Configure catalog for ${title}`, exact: true }).click();
const form = page.getByRole("region", { name: "Configure catalog form", exact: true });
await expect(form).toBeVisible();
const scrollArea = page.locator(".thot-fleet-ledger__workspace");
const box = await scrollArea.boundingBox();
expect(box).not.toBeNull();
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height - 30);
await page.mouse.wheel(0, 3000);
await expect(form.getByRole("button", { name: "Save catalog configuration", exact: true })).toBeInViewport();
await expect(form.getByLabel("Password", { exact: true })).toBeInViewport();
expect(await scrollArea.evaluate(el => el.scrollTop)).toBeGreaterThan(0);
expect((await inspect(page)).overflow).toBe(0);
await page.screenshot({ path: testInfo.outputPath("catalog-configuration-bottom.png") });
await page.mouse.wheel(0, -3000);
await expect.poll(() => scrollArea.evaluate(el => el.scrollTop)).toBe(0);
await form.getByRole("button", { name: "Back to list", exact: true }).scrollIntoViewIfNeeded();
await form.getByRole("button", { name: "Back to list", exact: true }).click();
await expect(page.getByRole("button", { name: `Configure catalog for ${title}`, exact: true })).toBeVisible();
expect(writes).toEqual([]);
});
}
}
async function admin(page: Page, name: string) {
await navigation(page);
const toggle = page.getByRole("button", { name: "Administration", exact: true });
if (await toggle.getAttribute("aria-expanded") !== "true") await toggle.click();
await page.getByRole("button", { name, exact: true }).click();
}
async function inspect(page: Page) {
return page.evaluate(() => {
const visible = (e: Element) => {
const box = e.getBoundingClientRect();
return box.width > 0 && box.height > 0 && getComputedStyle(e).visibility !== "hidden" && !e.closest("[hidden]");
};
return {
overflow: document.documentElement.scrollWidth - innerWidth,
serif: [...document.querySelectorAll("h1,h2,h3,h4,button,label")].filter(visible)
.filter(e => /Fraunces|Georgia|Times|ui-serif/.test(getComputedStyle(e).fontFamily)).map(e => e.textContent?.slice(0,60)),
tiny: [...document.querySelectorAll("h1,h2,h3,h4,button,label,small")].filter(visible)
.filter(e => parseFloat(getComputedStyle(e).fontSize) < 12).map(e => e.textContent?.slice(0,60)),
};
});
}
for (const width of [390, 1280]) {
test(`session navigation has readiness dot and bounded accordion lists at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 1000 });
const writes = await fixtures(page);
const sessions = Array.from({ length: 80 }, (_, index) => ({
id: `nav-${index}`, name: `Session ${index}`, question: `Question ${index}`,
archived: index >= 40, active: false, status: index >= 40 ? "finalized" : "open",
workspace, group: null, author: null, summary: null,
created_at: "2026-09-01T12:00:00Z", updated_at: null,
}));
await page.route("**/api/sessions?*", route => route.fulfill({ json: sessions }));
await page.route("**/api/sessions", route => route.fulfill({ json: sessions }));
await page.goto("/");
await navigation(page);
await page.getByRole("button", { name: "Administration", exact: true }).click();
const workspaceButton = page.getByRole("button", { name: "Workspace", exact: true });
await expect(workspaceButton.getByRole("img")).toHaveAttribute("data-ready", "true");
await expect(workspaceButton).toHaveAccessibleDescription("Workspace readiness: ready");
await expect(page.getByText("Workspace readiness: ready", { exact: true })).toHaveCount(0);
const green = await workspaceButton.getByRole("img").evaluate(el => getComputedStyle(el).backgroundColor);
const active = page.getByRole("button", { name: "Active sessions", exact: true });
const archive = page.getByRole("button", { name: "Archive (40)", exact: true });
await expect(active).toHaveAttribute("aria-expanded", "false");
await expect(archive).toHaveAttribute("aria-expanded", "false");
await expect(page.getByRole("checkbox", { name: "Select all" })).toHaveCount(0);
await expect(page.getByText("Sessions", { exact: true })).toHaveCount(0);
const activeBox = await active.boundingBox();
const archiveBox = await archive.boundingBox();
expect(archiveBox!.y - activeBox!.y - activeBox!.height).toBeLessThan(8);
await page.screenshot({ path: testInfo.outputPath("session-accordions-closed.png"), animations: "disabled" });
for (const name of ["Active sessions", "Archive (40)"]) {
await page.getByRole("button", { name, exact: true }).click();
await expect(name === "Active sessions" ? archive : active).toHaveAttribute("aria-expanded", "false");
await expect(page.locator(".thot-session-accordion__panel:visible")).toHaveCount(1);
const panel = page.getByRole("region", { name, exact: true });
await expect(panel.getByRole("checkbox", { name: "Select all" })).toBeVisible();
await expect(panel).toHaveCSS("overflow-y", "auto");
const dimensions = await panel.evaluate(el => ({ height: el.getBoundingClientRect().height, scroll: el.scrollHeight, client: el.clientHeight }));
expect(dimensions.height).toBeLessThanOrEqual(288);
expect(dimensions.client).toBeGreaterThan(30);
expect(dimensions.scroll).toBeGreaterThan(dimensions.client);
const box = await panel.boundingBox();
expect(box!.y + box!.height).toBeLessThanOrEqual(1000);
await panel.evaluate(el => { el.scrollTop = el.scrollHeight; });
expect(await panel.evaluate(el => el.scrollTop)).toBeGreaterThan(0);
}
await page.screenshot({ path: testInfo.outputPath("session-accordions.png"), animations: "disabled" });
await expect(archive).toBeInViewport();
expect((await inspect(page)).overflow).toBe(0);
// A refetch failure must not keep a stale green signal.
await page.route("**/api/workspaces/*/preprocessing", route => route.fulfill({ status: 503, json: { error: "Fixture unavailable" } }));
await expect(workspaceButton.getByRole("img")).toHaveAttribute("data-ready", "false", { timeout: 15000 });
await expect(workspaceButton).toHaveAccessibleDescription("Workspace readiness: unavailable");
expect(await workspaceButton.getByRole("img").evaluate(el => getComputedStyle(el).backgroundColor)).not.toBe(green);
expect(writes).toEqual([]);
});
}
for (const width of [390, 1280, 2400]) {
test(`knowledge readers use available width and readable paragraphs at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 1100 });
const writes = await fixtures(page);
const sample = memoryFormattingExamples[0];
const longEvidence = { ...evidence, applies_to: { concepts: ["Esempio simulato"], tables: ["demo_visuale.identificatore_lungo_per_la_verifica_della_formattazione"], columns: [] },
payload: { rule: sample.detail + "\n\n" + [1, 2, 3, 4, 5, 6].map(level => "#".repeat(level) + " Sottotitolo " + level + "\n\nTesto della sottosezione.").join("\n\n") + "\n\n```sql\nSELECT 1;\n```" }, provenance: { source_file: "source/demo.md", supporting_excerpts: ["**Esempio simulato** con `codice_inline` e un collegamento alla regola.\n\nSecondo paragrafo di provenienza."] } };
await page.route(`**/api/workspaces/${workspace}/evidence/*`, route => route.fulfill({ json: { ...evidencePage, item: longEvidence } }));
await page.goto("/");
await admin(page, "Evidence");
await page.getByRole("button", { name: evidence.title, exact: true }).click();
const evidenceDetail = page.getByRole("region", { name: "Evidence detail" });
const article = evidenceDetail.locator("article");
await expect(article).toHaveCSS("max-width", "none");
const rule = article.locator(".thot-knowledge-prose").first();
await expect(rule.locator(":scope > p")).not.toHaveCount(1);
await expect(rule.locator("p").first()).toHaveCSS("max-width", "none");
await expect(article.locator(".thot-knowledge-title")).toHaveCSS("font-size", "24px");
for (const heading of await article.locator(".thot-knowledge-heading").all()) {
await expect(heading).toHaveCSS("font-size", "20px");
await expect(heading).toHaveCSS("font-weight", "600");
await expect(heading).toHaveCSS("margin-bottom", "8px");
}
const bodyFamily = await rule.evaluate(el => getComputedStyle(el).fontFamily);
expect(bodyFamily).toContain("Manrope");
for (const paragraph of await article.locator("section > p:not(.thot-knowledge-meta), .thot-knowledge-prose p").all()) {
await expect(paragraph).toHaveCSS("font-size", "16px");
await expect(paragraph).toHaveCSS("line-height", "26.4px");
await expect(paragraph).toHaveCSS("font-family", bodyFamily);
}
for (const heading of await rule.locator("h1,h2,h3,h4,h5,h6").all()) {
await expect(heading).toHaveCSS("font-size", "16px");
await expect(heading).toHaveCSS("font-weight", "600");
await expect(heading).toHaveCSS("font-family", bodyFamily);
}
await expect(rule.locator("pre code")).toHaveCSS("font-size", "14px");
await expect(rule.locator("p code").first()).toHaveCSS("font-size", "14px");
expect(await article.evaluate(el => {
const parent = el.parentElement!;
const css = getComputedStyle(parent);
return Math.abs(el.getBoundingClientRect().width - (parent.clientWidth - parseFloat(css.paddingLeft) - parseFloat(css.paddingRight)));
})).toBeLessThan(2);
const copy = article.getByRole("button", { name: "Copy Evidence file path", exact: true });
await expect(copy).toHaveText("");
await expect(copy.locator("svg")).toHaveCount(1);
await expect(article.locator("strong").filter({ hasText: "Esempio simulato" })).toBeVisible();
await article.locator(".thot-knowledge-title").scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath("evidence-readable.png"), animations: "disabled" });
expect((await inspect(page)).overflow).toBe(0);
await admin(page, "Memory");
await page.getByRole("button", { name: sample.subject, exact: true }).click();
const memoryDetail = page.getByRole("region", { name: "Memory detail" });
await expect(memoryDetail.getByRole("note")).toContainText("Not saved, indexed or sent to the model.");
const content = memoryDetail.locator(".thot-knowledge-prose").first();
await expect(content.locator(":scope > p")).not.toHaveCount(1);
await expect(content.locator("p").first()).toHaveCSS("max-width", "none");
await expect(content.locator("p").nth(1)).toHaveCSS("margin-top", "20px");
await expect(memoryDetail.locator(".thot-knowledge-title")).toHaveCSS("font-size", "24px");
for (const heading of await memoryDetail.locator(".thot-knowledge-heading").all()) {
await expect(heading).toHaveCSS("font-size", "20px");
await expect(heading).toHaveCSS("font-weight", "600");
await expect(heading).toHaveCSS("font-family", bodyFamily);
await expect(heading).toHaveCSS("margin-bottom", "8px");
}
await expect(content.locator("p").first()).toHaveCSS("font-size", "16px");
await expect(content.locator("p").first()).toHaveCSS("line-height", "26.4px");
await expect(content.locator("p").first()).toHaveCSS("font-family", bodyFamily);
expect(await content.evaluate(el => Math.abs(el.getBoundingClientRect().width - el.parentElement!.clientWidth))).toBeLessThan(2);
await memoryDetail.getByRole("heading", { name: sample.subject, exact: true }).scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath("memory-readable.png"), animations: "disabled" });
await expect(memoryDetail.getByRole("button", { name: "Edit card", exact: true })).toHaveCount(0);
expect((await inspect(page)).overflow).toBe(0);
await page.getByRole("button", { name: "Use dark theme", exact: true }).click();
await expect(page.locator(".thot-host")).toHaveAttribute("data-theme", "dark");
await memoryDetail.getByRole("heading", { name: sample.subject, exact: true }).scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath("memory-readable-dark.png"), animations: "disabled" });
await expect(content.locator("p").first()).toHaveCSS("line-height", "26.4px");
await page.getByRole("button", { name: memoryFormattingExamples[1].subject, exact: true }).click();
await expect(memoryDetail.getByRole("heading", { name: "Prima del conteggio" })).toHaveCSS("font-size", "16px");
await expect(memoryDetail.getByRole("heading", { name: "Prima del conteggio" })).toHaveCSS("font-family", bodyFamily);
await memoryDetail.locator(".thot-knowledge-title").scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath("memory-headings-dark.png"), animations: "disabled" });
await page.getByRole("button", { name: memoryFormattingExamples[2].subject, exact: true }).click();
await expect(memoryDetail.locator("pre")).toHaveCSS("font-size", "14px");
expect((await inspect(page)).overflow).toBe(0);
expect(writes).toEqual([]);
});
}
for (const width of [390, 1280]) {
test(`full shell keeps the visual revision across language and theme changes at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 1000 });
const writes = await fixtures(page);
await page.goto("/");
const header = page.locator(".thot-full-header");
await expect(header).toBeVisible();
await expect(header).toHaveCSS("background-color", "rgb(203, 51, 59)");
expect(await header.locator(".thot-full-header__brand span").evaluate(el =>
getComputedStyle(el).color === getComputedStyle(el.closest("header")!).color,
)).toBe(true);
await page.evaluate(() => document.fonts.ready);
expect(await page.evaluate(() => [...document.fonts].some(font =>
font.family.includes("Manrope Variable") && font.status === "loaded",
))).toBe(true);
const layout = await page.locator(".thot-context-layout").boundingBox();
expect(layout!.x).toBeGreaterThanOrEqual(20);
expect(Math.abs(layout!.x - (width - layout!.x - layout!.width))).toBeLessThan(1);
await admin(page, "Database");
await expect(page.getByRole("heading", { name: "Database management", exact: true })).toBeVisible();
await expect(page.locator(".thot-fleet-ledger__header-status")).toHaveCSS("padding-top", "16px");
await page.getByRole("button", { name: /Working context/ }).click();
const fields = page.locator(".thot-context-fields");
const done = fields.getByRole("button", { name: "Done", exact: true });
await expect(done).toBeVisible();
if (width >= 1280) {
const workspaceBox = await fields.getByRole("combobox", { name: "Workspace", exact: true }).boundingBox();
const modelBox = await fields.getByRole("combobox", { name: "Model", exact: true }).boundingBox();
const doneBox = await done.boundingBox();
expect(Math.abs(workspaceBox!.y - modelBox!.y)).toBeLessThan(1);
expect(Math.abs(modelBox!.y - doneBox!.y)).toBeLessThan(1);
expect(doneBox!.x).toBeGreaterThan(modelBox!.x + modelBox!.width);
for (const name of ["My sessions", "All sessions"]) {
await expect(page.getByRole("tab", { name, exact: true })).toHaveCSS("border-bottom-width", "1px");
}
}
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
await page.screenshot({ path: testInfo.outputPath("full-context-light.png"), animations: "disabled" });
await done.click();
await header.getByRole("combobox").selectOption("it");
await expect(page.getByRole("heading", { name: "Gestione database", exact: true })).toBeVisible();
await expect(page.locator(".thot-administration-eyebrow")).toHaveText("Amministrazione");
await expect(page.locator(".thot-context-summary small")).toHaveText("Contesto di lavoro");
await page.getByRole("button", { name: "Usa il tema scuro", exact: true }).click();
await expect(page.locator(".thot-host")).toHaveAttribute("data-theme", "dark");
await expect(header).toHaveCSS("background-color", "rgb(203, 51, 59)");
await page.screenshot({ path: testInfo.outputPath("full-header-dark.png"), animations: "disabled" });
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
await page.reload();
await expect(page.getByRole("heading", { name: "Gestione database", exact: true })).toBeVisible();
await expect(header.getByRole("combobox")).toHaveValue("it");
await expect(page.locator(".thot-host")).toHaveAttribute("data-theme", "dark");
await page.getByRole("button", { name: "Usa il tema chiaro", exact: true }).click();
await header.getByRole("combobox").selectOption("en");
await expect(page.getByRole("heading", { name: "Database management", exact: true })).toHaveCSS("font-size", "24px");
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
expect(writes).toEqual([]);
});
}
test("session tabs have padded labels and matching one-pixel borders on every side", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 1000 });
await fixtures(page);
await page.goto("/?thoth_route=administration%2Fdatabase");
const header = page.locator(".thot-full-header");
for (const locale of ["en", "it"]) {
await header.getByRole("combobox").selectOption(locale);
await expect(page.getByRole("button", { name: locale === "it" ? "Sessione" : "Session", exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: /^(New session|Return to session|Nuova sessione|Torna alla sessione)$/ })).toHaveCount(0);
const tabs = page.locator('[role="tablist"]').getByRole("tab");
for (const theme of ["light", "dark"]) {
if (theme === "dark") await header.getByRole("button", { name: locale === "it" ? "Usa il tema scuro" : "Use dark theme", exact: true }).click();
for (const selected of [0, 1]) {
await tabs.nth(selected).click();
await expect(tabs.nth(selected)).toHaveAttribute("aria-selected", "true");
for (const tab of await tabs.all()) {
await expect(tab).toHaveCSS("border-bottom-width", "1px");
const borders = await tab.evaluate(el => {
const css = getComputedStyle(el);
return { widths: [css.borderTopWidth, css.borderRightWidth, css.borderBottomWidth, css.borderLeftWidth],
colors: [css.borderTopColor, css.borderRightColor, css.borderBottomColor, css.borderLeftColor],
paddingX: css.paddingLeft, paddingY: css.paddingTop,
fits: el.scrollHeight <= el.clientHeight };
});
expect(borders.widths).toEqual(["1px", "1px", "1px", "1px"]);
expect(new Set(borders.colors).size).toBe(1);
expect(borders).toMatchObject({ paddingX: "11px", paddingY: "3px", fits: true });
}
}
await page.mouse.move(0, 0);
await page.screenshot({ path: testInfo.outputPath(`session-tabs-${locale}-${theme}.png`), animations: "disabled" });
if (theme === "dark") await header.getByRole("button", { name: locale === "it" ? "Usa il tema chiaro" : "Use light theme", exact: true }).click();
}
}
});
for (const locale of ["en", "it"]) {
test(`login keeps only the main title in ${locale}`, async ({ page }, testInfo) => {
await fixtures(page);
await page.route("**/config.js", route => route.fulfill({ contentType: "application/javascript",
body: `window.__THOTHII_CONFIG__ = ${JSON.stringify({ backendBaseUrl: "/api", shell: { mode: "full", defaultLocale: locale } })};` }));
await page.route("**/api/auth/config", route => route.fulfill({ json: { mode: "local", localLogin: true, oidcLogin: false } }));
await page.route("**/api/me", route => route.fulfill({ status: 401, json: { code: "unauthenticated" } }));
await page.goto("/");
await expect(page.getByRole("heading", { name: locale === "it" ? "Accedi a ThothII" : "Sign in to ThothII" })).toBeVisible();
await expect(page.getByText(locale === "it" ? "Accesso a ThothII" : "ThothII access", { exact: true })).toHaveCount(0);
await expect(page.getByText(locale === "it" ? "Usa l’account della tua installazione per continuare." : "Use your installation account to continue.", { exact: true })).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath(`login-${locale}.png`), animations: "disabled" });
});
}
test("language selector has no pointer ring and retains keyboard focus in both themes", async ({ page }, testInfo) => {
await fixtures(page);
await page.goto("/");
const language = page.getByRole("combobox", { name: "Interface language" });
for (const theme of ["light", "dark"]) {
if (theme === "dark") await page.getByRole("button", { name: "Use dark theme", exact: true }).click();
await language.click();
await page.keyboard.press("Escape");
await expect(language).toBeFocused();
await expect(language).toHaveCSS("outline-style", "none");
await expect(language).toHaveCSS("box-shadow", "none");
await page.screenshot({ path: testInfo.outputPath(`language-pointer-${theme}.png`), animations: "disabled" });
await page.keyboard.press("Tab");
await page.keyboard.press("Shift+Tab");
await expect(language).toBeFocused();
await expect(language).toHaveCSS("outline-style", "solid");
}
});
for (const route of ["/", "/?thoth_route=administration%2Fdatabase"]) {
test(`Core prompt remains readable after loading ${route}`, async ({ page }, testInfo) => {
const writes = await fixtures(page);
await page.goto(route);
if (route !== "/") {
await navigation(page);
await page.getByRole("button", { name: "Session", exact: true }).click();
}
const prompt = page.getByRole("textbox", { name: "New question", exact: true });
await expect(prompt).toBeVisible();
await prompt.focus();
const readable = async () => {
await expect.poll(() => prompt.evaluate(el => {
const style = getComputedStyle(el);
return el.clientHeight >= parseFloat(style.lineHeight) + parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
})).toBe(true);
};
await readable();
await expect(prompt).toHaveCSS("outline-style", "none");
await page.screenshot({ path: testInfo.outputPath("Prompt-focused.png") });
// Full-mode gutters can wrap the empty placeholder on a narrow viewport.
// Compare clearing with that same viewport's initial height, not a single-line constant.
await page.setViewportSize({ width: 390, height: 900 });
await readable();
const emptyHeight = await prompt.evaluate(el => el.clientHeight);
await prompt.fill("First line");
await prompt.press("Shift+Enter");
await prompt.press("a");
await expect(prompt).toHaveValue("First line\na");
await expect.poll(() => prompt.evaluate(el => el.clientHeight)).toBeGreaterThan(40);
const question = "Compare eligible patients across the available cohorts. ".repeat(30);
await prompt.fill(question);
await page.setViewportSize({ width: 390, height: 900 });
await readable();
await expect.poll(() => prompt.evaluate(el => el.clientHeight)).toBe(160);
expect(await prompt.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true);
await admin(page, "Database");
await navigation(page);
await page.getByRole("button", { name: "Session", exact: true }).click();
await expect(prompt).toHaveValue(question);
await readable();
await page.screenshot({ path: testInfo.outputPath("Prompt-multiline-mobile.png") });
await prompt.fill("");
await readable();
await expect.poll(() => prompt.evaluate(el => el.clientHeight)).toBe(emptyHeight);
expect(writes).toEqual(route === "/" ? ["/runtime/prewarm"] : ["/runtime/prewarm", "/runtime/prewarm"]);
});
}
for (const width of [390, 649, 768, 1280, 1600]) {
test(`all surfaces retain shared typography at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 1000 });
const writes = await fixtures(page);
await page.goto("/");
await expect(page.getByTestId("app-shell")).toBeVisible();
await expect(page.getByRole("button", { name: /Working context/ })).toContainText(title);
for (const name of ["Workspace", "Database", "Memory", "Evidence", "Pi configuration"]) {
await admin(page, name);
const heading = page.getByRole("heading", { name: `${name === "Pi configuration" ? "Pi" : name} management`, exact: true });
await expect(heading).toBeVisible();
await expect(heading).toHaveCSS("font-size", "24px");
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
if (name === "Memory") await expect(page.getByText("No memory cards yet.", { exact: false })).toBeVisible();
if (name === "Database") {
const statuses = page.locator(".thot-fleet-ledger__header-status .thot-fleet-ledger__status-item");
await expect(statuses).toHaveCount(3);
const catalog = await statuses.nth(0).boundingBox();
const operations = await statuses.nth(1).boundingBox();
const metadata = await statuses.nth(2).boundingBox();
const summary = await page.locator(".thot-fleet-ledger__kpi-section > h2").boundingBox();
const summaryBlock = await page.locator(".thot-fleet-ledger__kpi-section").boundingBox();
const navigationBlock = await page.locator(".thot-fleet-ledger__navigation").boundingBox();
const gridBlock = await page.locator(".thot-fleet-ledger__content").boundingBox();
const databasesLabel = await page.locator(".thot-fleet-ledger__navigation").getByText("Databases", { exact: true }).boundingBox();
const tablesLabel = await page.locator(".thot-fleet-ledger__kpi-label").getByText("Tables", { exact: true }).boundingBox();
expect(Math.abs(summaryBlock!.x - gridBlock!.x)).toBeLessThan(1);
expect(Math.abs(navigationBlock!.x - gridBlock!.x)).toBeLessThan(1);
expect(Math.abs(summary!.x - databasesLabel!.x)).toBeLessThan(1);
expect(Math.abs(summary!.x - tablesLabel!.x)).toBeLessThan(1);
const gridLabel = await page.locator(".thot-fleet-grid-toolbar").getByText("Workspace databases", { exact: true }).boundingBox();
expect(Math.abs(summary!.x - gridLabel!.x)).toBeLessThan(1);
expect(Math.abs(summaryBlock!.width - gridBlock!.width)).toBeLessThan(1);
expect(Math.abs(navigationBlock!.width - gridBlock!.width)).toBeLessThan(1);
const contextTrigger = page.getByRole("button", { name: /Working context/ });
await contextTrigger.click();
const contextBody = page.locator(".thot-context-body");
await expect(contextBody).toBeVisible();
await expect(contextBody).not.toContainText("Your last choices take precedence");
await expect(contextBody).not.toContainText("Installation default");
await expect(contextBody).not.toContainText("Last choice");
const contextBlock = await contextBody.boundingBox();
const workspaceSelect = await contextBody.getByRole("combobox", { name: "Workspace", exact: true }).boundingBox();
expect(Math.abs(contextBlock!.x - summaryBlock!.x)).toBeLessThan(1);
expect(Math.abs(workspaceSelect!.x - summary!.x)).toBeLessThan(1);
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
await page.screenshot({ path: testInfo.outputPath("Context-expanded.png") });
await contextBody.getByRole("button", { name: "Done", exact: true }).click();
expect(Math.abs(catalog!.x - summary!.x)).toBeLessThan(1);
const workArea = await page.locator(".thot-fleet-ledger__workspace").boundingBox();
if (workArea!.width >= 768) {
const coverage = await page.locator(".thot-fleet-ledger__kpi-label").filter({ hasText: "Description coverage" }).boundingBox();
expect(Math.abs(metadata!.x - coverage!.x)).toBeLessThan(1);
expect(Math.abs(operations!.x - (catalog!.x + metadata!.x) / 2)).toBeLessThan(1);
}
for (const status of await statuses.all()) {
const label = await status.locator(".thot-fleet-ledger__status-label > span:first-child").boundingBox();
const mark = await status.locator(".thot-fleet-ledger__status-mark").boundingBox();
const value = await status.locator(".thot-fleet-ledger__status-value").boundingBox();
expect(mark!.x - (label!.x + label!.width)).toBeGreaterThanOrEqual(7);
expect(Math.abs(label!.x - value!.x)).toBeLessThan(1);
}
}
if (name === "Evidence") {
const search = await page.getByRole("textbox", { name: "Search Evidence" }).boundingBox();
expect(search!.width).toBeGreaterThan(180);
}
await page.screenshot({ path: testInfo.outputPath(`${name.replaceAll(" ", "-")}.png`) });
}
await page.getByRole("button", { name: "Host maintenance", exact: true }).click();
await expect(page.getByRole("tab", { name: "macOS", exact: true })).toHaveAttribute("aria-selected", "true");
await expect(page.getByRole("region", { name: "Pi operator workflow" })).toHaveCSS("font-size", "16px");
await page.screenshot({ path: testInfo.outputPath("Pi-guide.png") });
await navigation(page);
await page.getByRole("tab", { name: "All sessions", exact: true }).click();
await expect(page.locator(".thot-wordmark--sidebar")).toHaveCSS("font-size", "32px");
await page.getByRole("button", { name: /Archive \(1\)/ }).click();
await page.getByRole("button", { name: "Cohort review", exact: true }).click();
const documents = page.getByRole("complementary", { name: "Session summary" });
await expect(documents).toBeVisible();
await expect(documents.getByRole("heading", { name: "Original question" })).toBeVisible();
const bounds = await documents.boundingBox();
expect(bounds!.width).toBeGreaterThanOrEqual(Math.min(width, 300));
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
await page.screenshot({ path: testInfo.outputPath("Sessions.png") });
await page.getByRole("button", { name: "Close panel", exact: true }).click();
await expect(page.getByRole("textbox", { name: "New question", exact: true })).toBeVisible();
await expect(page.locator(".thot-wordmark--core")).toHaveCSS("font-size", "48px");
await page.screenshot({ path: testInfo.outputPath("Core.png") });
expect(writes).toEqual([]);
});
}
test("theme follows the app, not an unrelated OS preference; keyboard tabs and dirty guard remain", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
const writes = await fixtures(page);
await page.emulateMedia({ colorScheme: "dark" });
await page.goto("/");
await expect(page.getByTestId("app-shell")).toBeVisible();
await expect(page.getByRole("banner", { name: "Application header" })).toBeVisible();
await admin(page, "Pi configuration");
const testButton = page.getByRole("button", { name: "Test catalog default", exact: true });
await expect(testButton).toHaveCSS("background-color", "oklch(0.9985 0.0006 17.2)");
await page.getByRole("button", { name: "Use dark theme", exact: true }).click();
await expect(page.locator(".thot-context-trigger")).toHaveCSS("color", "oklch(0.931 0 90)");
await expect(testButton).toHaveCSS("background-color", "oklch(0.62 0.008 23.2 / 0.3)");
await page.screenshot({ path: testInfo.outputPath("Pi-dark.png") });
const guideButton = page.getByRole("button", { name: "Host maintenance", exact: true });
await guideButton.click();
const mac = page.getByRole("tab", { name: "macOS", exact: true });
await mac.focus(); await page.keyboard.press("ArrowRight");
await expect(page.getByRole("tab", { name: "Windows", exact: true })).toBeFocused();
await expect(page.getByRole("tab", { name: "Windows", exact: true })).toHaveAttribute("aria-selected", "true");
await admin(page, "Memory");
await page.getByRole("button", { name: "New card", exact: true }).click();
await page.getByRole("textbox", { name: "Title", exact: true }).fill("Keep this draft");
await page.getByRole("button", { name: "Session", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Title", exact: true })).toHaveValue("Keep this draft");
await page.screenshot({ path: testInfo.outputPath("Memory-dark-unsaved.png") });
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await page.getByRole("button", { name: "Session", exact: true }).click();
await expect(page.getByRole("textbox", { name: "New question", exact: true })).toBeVisible();
expect(writes).toEqual(["/runtime/prewarm"]);
});
test("host header and left rail leave the application a bounded working area", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 1000 });
await fixtures(page, "embedded");
await page.goto("/");
await expect(page.getByTestId("app-shell")).toBeVisible();
await expect(page.locator(".thot-full-header")).toHaveCount(0);
// Geometry fixture, not a replacement for acceptance inside the real Omics deployment.
await page.addStyleTag({ content: `
body { margin: 0; }
#root { margin-left: 240px; margin-top: 72px; --thoth-app-height: calc(100dvh - 72px); }
.px-4 { padding-left: 2.25rem !important; padding-right: 2.25rem !important; }
` });
await admin(page, "Workspace");
const shell = await page.getByTestId("app-shell").boundingBox();
expect(shell!.x).toBe(240);
expect(shell!.x + shell!.width).toBeLessThanOrEqual(1280);
expect(shell!.y + shell!.height).toBeLessThanOrEqual(1000);
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
await page.screenshot({ path: testInfo.outputPath("Host-bounded-Workspace.png") });
await admin(page, "Evidence");
const search = await page.getByRole("textbox", { name: "Search Evidence" }).boundingBox();
expect(search!.width).toBeGreaterThan(180);
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
});
for (const width of [390, 1280]) {
test(`embedded active session keeps steering and stop inside the portal at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 822 });
await fixtures(page, "embedded");
await page.route("**/api/sessions", route => route.request().method() === "POST"
? route.fulfill({ json: { id: "layout-session" } }) : route.fallback());
await page.route("**/api/sessions/layout-session", route => route.fulfill({ json: { id: "layout-session", active: true, status: "open", workspace } }));
await page.goto("/");
// The Omics template bounds #root below its topbar and clips overflow.
await page.addStyleTag({ content: `
body { margin: 0; padding-top: 70px; }
#root { height: calc(100dvh - 110px); overflow: hidden; }
` });
const prompt = page.getByRole("textbox", { name: "New question", exact: true });
await prompt.fill("Review the cohort");
await page.getByRole("button", { name: "Send", exact: true }).click();
const steering = page.getByRole("textbox", { name: "Steering", exact: true });
await expect(steering).toBeVisible();
// Drive the same store projection as streaming, without a real model.
await page.evaluate(async () => {
const path = "/src/store/sessionStore.ts";
const { useSessionStore } = await import(/* @vite-ignore */ path);
useSessionStore.getState().applyEvent({ type: "text_delta", text: "Reviewing the cohort\n".repeat(80) });
});
await steering.fill("Please pause the analysis\n".repeat(15));
const stop = page.getByRole("button", { name: "Stop and save session", exact: true });
await page.locator("#root").evaluate(el => { el.scrollTop = 0; });
const root = await page.locator("#root").boundingBox();
for (const control of [steering, stop]) {
const box = await control.boundingBox();
expect(box!.y).toBeGreaterThanOrEqual(root!.y);
expect(box!.y + box!.height).toBeLessThanOrEqual(root!.y + root!.height);
await expect(control).toBeInViewport();
}
await page.screenshot({ path: testInfo.outputPath("embedded-session.png") });
await stop.click();
await expect(page.getByRole("dialog", { name: "Stop the session?" })).toBeVisible();
});
}
for (const [width, height, mode] of [
[390, 844, "embedded"], [1280, 900, "embedded"], [1440, 1000, "full"],
[844, 390, "embedded"], [320, 640, "embedded"],
] as const) {
test(`session confirmation layout stays within ${mode} view at ${width}x${height}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height });
await fixtures(page, mode);
await page.route("**/api/sessions", route => route.request().method() === "POST"
? route.fulfill({ json: { id: "layout-session" } }) : route.fallback());
await page.route("**/api/sessions/layout-session", route => route.fulfill({ json: { id: "layout-session", active: true, status: "open", workspace } }));
await page.goto("/");
if (mode === "embedded") await page.addStyleTag({ content: `
body { margin: 0; padding-top: 60px; }
#root { margin-left: ${width > 900 ? 160 : 0}px; height: calc(100dvh - 80px); overflow: hidden; }
` });
await expect(page.getByRole("textbox", { name: "New question", exact: true })).toBeVisible();
await page.getByRole("textbox", { name: "New question", exact: true }).fill("Review the cohort");
await page.getByRole("button", { name: "Send", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Steering", exact: true })).toBeVisible();
await page.evaluate(async () => {
const path = "/src/store/sessionStore.ts";
const { useSessionStore } = await import(/* @vite-ignore */ path);
useSessionStore.getState().applyEvent({ type: "ui_request", ui_request: {
id: "layout-gate", widget: "artifact-gate", title: "Verifica della domanda e dei criteri di inclusione",
artifact: { kind: "markdown", content: "## Criteri di inclusione\n\n" + "Verificare i pazienti inclusi nella coorte e il periodo di riferimento.\n\n".repeat(100) },
options: [{ id: "approve", label: "Conferma e procedi alla fase successiva" }], reserved: ["back", "other"],
} });
});
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeVisible();
const bounds = await dialog.boundingBox();
const area = await page.getByTestId("app-shell").boundingBox();
expect(area).not.toBeNull();
expect(bounds!.x).toBeGreaterThanOrEqual(area!.x);
expect(bounds!.y).toBeGreaterThanOrEqual(area!.y);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(area!.x + area!.width);
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(area!.y + area!.height);
expect(bounds!.width).toBeGreaterThan(Math.min(1000, area!.width - 60));
expect(bounds!.height).toBeGreaterThan(area!.height * 0.8);
const confirmations = dialog.getByRole("button", { name: "Conferma e procedi alla fase successiva" });
await expect(confirmations).toHaveCount(2);
for (const button of await confirmations.all()) {
await button.scrollIntoViewIfNeeded();
await expect(button).toBeInViewport();
}
expect(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
const body = dialog.locator(".thot-session-dialog__body");
expect(await body.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true);
await body.evaluate(el => { el.scrollTop = el.scrollHeight; });
await page.screenshot({ path: testInfo.outputPath("session-review.png") });
await page.evaluate(async () => {
const path = "/src/store/sessionStore.ts";
const { useSessionStore } = await import(/* @vite-ignore */ path);
useSessionStore.getState().clearPending();
});
await page.getByRole("button", { name: "Stop and save session", exact: true }).click();
const stop = page.getByRole("dialog", { name: "Stop the session?" });
await expect(stop.getByRole("button", { name: "Stop & save", exact: true })).toHaveCount(2);
await expect(stop.getByRole("button", { name: "Cancel", exact: true })).toBeFocused();
const stopBounds = await stop.boundingBox();
expect(stopBounds!.width).toBeGreaterThan(Math.min(600, area!.width - 60));
expect(stopBounds!.y).toBeGreaterThanOrEqual(area!.y);
expect(stopBounds!.y + stopBounds!.height).toBeLessThanOrEqual(area!.y + area!.height);
await page.screenshot({ path: testInfo.outputPath("session-stop.png") });
await stop.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(stop).toHaveCount(0);
});
}