import { expect, test, type Page } from "@playwright/test"; import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../src/test/workspace-fixtures"; // 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) { const writes: string[] = []; 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(); } 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 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: "Return to 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") }); 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: "Return to 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)).toBeLessThanOrEqual(40); expect(writes).toEqual([]); }); } 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); 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 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.evaluate(() => document.documentElement.setAttribute("data-bs-theme", "dark")); 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: "Return to 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: "Return to session", exact: true }).click(); await expect(page.getByRole("textbox", { name: "New question", exact: true })).toBeVisible(); expect(writes).toEqual([]); }); 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); await page.goto("/"); await expect(page.getByTestId("app-shell")).toBeVisible(); // Geometry fixture, not a replacement for acceptance inside the real Omics deployment. await page.addStyleTag({ content: ` #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: [] }); });