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

269 lines
17 KiB
TypeScript

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: [] });
});