diff --git a/e2e/coach-answer-layout.spec.ts b/e2e/coach-answer-layout.spec.ts new file mode 100644 index 000000000..b4aec3b52 --- /dev/null +++ b/e2e/coach-answer-layout.spec.ts @@ -0,0 +1,336 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import type { Locator, Page, Route, TestInfo } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; + +/** + * The geometry of a Coach answer, at 1440 x 900 (desktop project) and + * 390 x 844 (mobile project). + * + * Contracts under test: + * + * 1. Under an answer there is exactly one action row: the icon group and + * the time on the left, the tokens and model on the right, both halves + * on one line (their centres within 2 px, the row no taller than one + * 44 px target), the left group starting on the bubble's left edge and + * the meta ending inside the answer column. + * 2. The follow-up chips sit in the answer's column: their left edge is + * the bubble's, their text is the answer's size, and a chip is at most + * 36 px tall beside a pointer and at least 44 px on a phone. + * 3. Under a question, copy and remember share one row; nothing stacks + * below it. + * 4. Scrolled away from the end, a round button appears; it brings the + * thread back to the end and goes away again. + * 5. No thumbs, no evidence disclosure, no horizontal overflow. + * + * Screenshots are attached to the report (no pixel baselines) and, with + * `COACH_SHOT_DIR` set, written there too. + */ + +const CONVERSATION_ID = "coach-answer-layout-e2e"; +const TITLE = "Blood pressure and sleep"; + +const ANSWERS = [ + "Your blood pressure stayed in a steady band over the last month. The morning readings sat a little higher than the evening ones, which is common, and none of the weeks stands out from the others.", + "Sleep looked regular on most nights. The weekends ran about forty minutes later at both ends, so the total stayed much the same while the timing moved.", + "Your resting heart rate held steady across the same weeks. Nothing in it lines up with the later weekend nights, so the two seem to move independently for now.", + "The period before looked much the same for blood pressure. Two readings in the second week were higher than the rest, and both were taken shortly after a workout.", + "Taken together, the month reads calm: steady blood pressure, regular sleep and a resting heart rate without surprises. Measuring at the same time each morning keeps the trend easy to read.", +]; +const QUESTIONS = [ + "How was my blood pressure this month?", + "And my sleep?", + "What about my resting heart rate?", + "Compare blood pressure with the period before", + "Sum it up for me", +]; + +const STEPS = [ + { + id: "s1", + tool: "get_metric_series", + labelKey: "coach.step.readWindow", + label: "Checking: Blood pressure, last 30 days", + domain: "bp", + window: "last30days", + status: "done", + count: 28, + }, + { + id: "s2", + tool: "get_sleep", + labelKey: "coach.step.readWindow", + label: "Checking: Sleep, last 30 days", + domain: "sleep", + window: "last30days", + status: "done", + count: 30, + }, +]; + +const FOLLOW_UPS = [ + { + id: "f1", + kind: "previous_period", + labelKey: "coach.followUp.previousPeriod", + label: "Compare with the period before", + reuse: false, + origin: "server", + }, + { + id: "f2", + kind: "related_metric", + labelKey: "coach.followUp.relatedMetric", + label: "See also: Resting HR", + reuse: false, + origin: "server", + }, +]; + +function messages() { + // Today, a few minutes apart, so the time reads as a bare clock. + const base = Date.now() - 60 * 60 * 1000; + const at = (minute: number) => + new Date(base + minute * 60 * 1000).toISOString(); + return ANSWERS.flatMap((answer, index) => [ + { + id: `${CONVERSATION_ID}-u${index + 1}`, + role: "user", + content: QUESTIONS[index], + createdAt: at(index * 2), + metricSource: null, + providerType: null, + promptVersion: null, + tokensUsed: null, + model: null, + }, + { + id: `${CONVERSATION_ID}-a${index + 1}`, + role: "assistant", + content: answer, + createdAt: at(index * 2 + 1), + metricSource: { + windows: ["last30days"], + metrics: ["bp", "sleep"], + steps: STEPS, + ...(index === ANSWERS.length - 1 ? { followUps: FOLLOW_UPS } : {}), + }, + providerType: "mock", + promptVersion: "e2e", + tokensUsed: 1234, + model: "gpt-4o-mini-2024-07-18", + }, + ]); +} + +function fulfilJson(route: Route, data: unknown) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data, error: null }), + }); +} + +async function mockConversation(page: Page) { + const summary = { + id: CONVERSATION_ID, + title: TITLE, + createdAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + updatedAt: new Date().toISOString(), + messageCount: ANSWERS.length * 2, + fenced: false, + attachments: [], + documentTitle: null, + }; + await page.route( + /\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/, + async (route) => { + const url = new URL(route.request().url()); + if (url.pathname.startsWith(`/api/insights/chat/${CONVERSATION_ID}/`)) { + return fulfilJson(route, { results: [] }); + } + if (url.pathname === `/api/insights/chat/${CONVERSATION_ID}`) { + return fulfilJson(route, { + ...summary, + attachmentCount: 0, + summary: null, + messages: messages(), + }); + } + return fulfilJson(route, { conversations: [summary], nextCursor: null }); + }, + ); + await page.route("**/api/insights/coach/nudge-status*", (route) => + fulfilJson(route, { nudgedAt: null, unread: false }), + ); + await page.route("**/api/coach/about-me/questions*", (route) => + fulfilJson(route, { questions: [] }), + ); +} + +async function box(locator: Locator) { + const b = await locator.boundingBox(); + expect(b, "element has a box").not.toBeNull(); + return b!; +} + +async function shoot(page: Page, testInfo: TestInfo, name: string) { + const body = await page.screenshot({ fullPage: false }); + await testInfo.attach(name, { body, contentType: "image/png" }); + const dir = process.env.COACH_SHOT_DIR; + if (dir) { + mkdirSync(dir, { recursive: true }); + await page.screenshot({ path: join(dir, `${name}.png`) }); + } +} + +const VIEWPORTS = { + "chromium-desktop": { width: 1440, height: 900, label: "1440" }, + "chromium-mobile": { width: 390, height: 844, label: "390" }, +} as const; + +test.describe("Coach answer layout", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("one action row, chips in the answer column, jump to the latest", async ({ + page, + }, testInfo) => { + const viewport = VIEWPORTS[testInfo.project.name as keyof typeof VIEWPORTS]; + test.skip(!viewport, "measured in the desktop and the mobile project"); + const desktop = viewport.width >= 640; + await page.setViewportSize({ + width: viewport.width, + height: viewport.height, + }); + await page.emulateMedia({ reducedMotion: "reduce" }); + await serveAiBlock(page, aiBlockAvailable()); + await mockConversation(page); + + await page.goto(`/coach?c=${CONVERSATION_ID}`, { + waitUntil: "domcontentloaded", + }); + const thread = page.locator('[data-slot="coach-message-thread"]'); + const answers = page.locator('[data-slot="coach-bubble-assistant"]'); + await expect(answers).toHaveCount(ANSWERS.length, { timeout: 15_000 }); + const last = answers.last(); + await expect(last).toContainText(ANSWERS.at(-1)!.slice(0, 40)); + // The thread opens at its end. + await expect + .poll(() => + thread.evaluate( + (el) => el.scrollHeight - el.scrollTop - el.clientHeight, + ), + ) + .toBeLessThanOrEqual(2); + + if (desktop) await last.hover(); + await shoot(page, testInfo, `answer-${viewport.label}`); + + // 5. Nothing of the old furniture. + await expect( + page.locator('[data-slot^="coach-message-feedback"]'), + ).toHaveCount(0); + await expect(page.locator('[data-slot^="coach-evidence"]')).toHaveCount(0); + await expect(page.locator('[data-slot="coach-token-footer"]')).toHaveCount( + 0, + ); + for (const el of [page.locator("#main-content"), thread]) { + const overflow = await el.evaluate((n) => n.scrollWidth - n.clientWidth); + expect(overflow, "no horizontal overflow").toBeLessThanOrEqual(0); + } + + // 1. One action row under the answer. + const bubble = last.locator('[data-slot="coach-answer-bubble"]'); + const column = last.locator('[data-slot="coach-answer-column"]'); + const row = last.locator('[data-slot="coach-answer-actions"]'); + const left = row.locator('[data-slot="coach-answer-actions-left"]'); + const meta = row.locator('[data-slot="coach-answer-meta"]'); + await expect(row).toHaveCount(1); + await expect(meta).toContainText("1,234 tokens"); + await expect(row).toHaveCSS("opacity", "1"); + const [bubbleBox, columnBox, rowBox, leftBox, metaBox] = await Promise.all( + [bubble, column, row, left, meta].map(box), + ); + expect( + Math.abs( + leftBox.y + leftBox.height / 2 - (metaBox.y + metaBox.height / 2), + ), + "left group and meta share one line", + ).toBeLessThanOrEqual(2); + expect(rowBox.height, "the row is one line").toBeLessThanOrEqual(44); + expect(Math.abs(leftBox.x - bubbleBox.x)).toBeLessThanOrEqual(1); + expect(metaBox.x + metaBox.width).toBeLessThanOrEqual( + columnBox.x + columnBox.width + 1, + ); + await expect(left.locator('[data-slot="coach-message-time"]')).toHaveCount( + 1, + ); + + // 2. The chips in the answer's column, at the answer's size. + const chips = last.locator('[data-slot="coach-follow-up-chips"]'); + await expect(chips.locator("[data-follow-up-id]")).toHaveCount(2); + const chipsBox = await box(chips); + expect(Math.abs(chipsBox.x - bubbleBox.x)).toBeLessThanOrEqual(1); + expect(chipsBox.y).toBeGreaterThanOrEqual(rowBox.y + rowBox.height - 1); + const answerSize = await bubble.evaluate( + (el) => getComputedStyle(el).fontSize, + ); + for (const chip of await chips.locator("[data-follow-up-id]").all()) { + await expect(chip).toHaveCSS("font-size", answerSize); + const chipBox = await box(chip); + if (desktop) expect(chipBox.height).toBeLessThanOrEqual(36); + else expect(chipBox.height).toBeGreaterThanOrEqual(44); + } + + // 3. Copy and remember share the question's one row. + const question = page.locator('[data-slot="coach-bubble-user"]').last(); + if (desktop) await question.hover(); + const userRow = question.locator('[data-slot="coach-user-actions"]'); + const remember = userRow.locator('[data-slot="coach-remember-message"]'); + const copy = userRow.locator('[data-slot="coach-copy-message"]'); + await expect(remember).toHaveAttribute("aria-label", "Remember"); + const [rememberBox, copyBox] = await Promise.all([remember, copy].map(box)); + expect( + Math.abs( + rememberBox.y + + rememberBox.height / 2 - + (copyBox.y + copyBox.height / 2), + ), + ).toBeLessThanOrEqual(1); + // The question's column holds the bubble and its one row, nothing else. + expect( + await userRow.evaluate((el) => el.parentElement?.childElementCount), + ).toBe(2); + + // 4. Away from the end, the button appears and brings the thread back. + const jump = page.locator('[data-slot="coach-scroll-to-bottom"]'); + await expect(jump).toHaveAttribute("data-visible", "false"); + await expect(jump).toHaveCSS("opacity", "0"); + await thread.evaluate((el) => el.scrollTo({ top: 0, behavior: "auto" })); + await expect(jump).toHaveAttribute("data-visible", "true"); + await expect(jump).toHaveCSS("opacity", "1"); + await shoot(page, testInfo, `scrolled-up-${viewport.label}`); + const jumpBox = await box(jump); + const threadBox = await box(thread); + expect( + Math.abs( + jumpBox.x + jumpBox.width / 2 - (threadBox.x + threadBox.width / 2), + ), + "centred over the thread", + ).toBeLessThanOrEqual(1); + await jump.click(); + await expect + .poll(() => + thread.evaluate( + (el) => el.scrollHeight - el.scrollTop - el.clientHeight, + ), + ) + .toBeLessThanOrEqual(2); + await expect(jump).toHaveAttribute("data-visible", "false"); + await expect(jump).toHaveAttribute("tabindex", "-1"); + await expect(page.locator("#coach-composer-textarea")).toBeFocused(); + }); +});