Files
HealthLog/e2e/coach-answer-layout.spec.ts
T

337 lines
12 KiB
TypeScript

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();
});
});