256 lines
16 KiB
TypeScript
256 lines
16 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();
|
|
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: [] });
|
|
});
|