Files
HealthLog/e2e/coach-layout.spec.ts
T
Marc-André Bombeck 08f532b428 feat(coach): New conversation in the chat, a foldable list, and the day beside it
New conversation leaves the conversations panel's header and floats in
the conversation itself: a round 56 px button with a plus, the floating
Coach button's face, named "New conversation". It sits in the composer's
row, so it always belongs to the chat column, never to the list or a
docked day beside it. Where the column is wide it takes the bottom right
corner beside the composer; where the composer fills the width (a phone,
the drawer, a narrow column) it rises above the composer's edge. The
drawer shows it once there is a conversation to leave, in place of the
plus in its header. An empty conversation shows nothing either way.

The docked conversations panel folds like the day: its header opens with
a fold control, and folded it keeps a 48 px edge whose button opens it
again (aria-expanded, focus moved between the two, Escape inside folds
it). The top bar's toggle remains for the sheet below 1280 px. On /coach
the day docks right of the panel. Below 1600 px only one of the two is
open: a day opening folds the list without touching the remembered
choice, and opening the list folds the day to its edge. From 1600 px both
stay open. Switching or starting a conversation keeps an open day in the
URL.

The empty conversation drops everything under the composer: the daily
seeded opener and the scope pill are gone, and with them the only reader
of GET /api/insights/coach/seeded-question and of the
coach.ambientSuggestions preference. The route, its OpenAPI entry, the
preference and its Settings switch go too.
2026-10-09 13:44:58 +02:00

919 lines
35 KiB
TypeScript

import AxeBuilder from "@axe-core/playwright";
import type { Locator, Page, Request, 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";
import { mockDay } from "./setup/day-mock";
/**
* The Coach page frame: the conversation, the conversations panel right of
* it, and the day right of that; New conversation floating in the
* conversation itself.
*
* Contracts under test, at 1920, 1440, 1280 and 390 px:
*
* 1. From 1280 px the panel docks beside the thread, open on a first visit,
* and the reading column (thread and composer) is exactly as wide with
* the panel open as folded. Folded, the panel keeps a 48 px edge whose
* button (in the place of the open panel's fold control) opens it
* again; the choice survives a reload. The panel is flush with the
* viewport's right edge and runs from the top of the window to the
* bottom; the top bar ends at its left edge and the panel's header
* border is the top bar's. Focus moves between the fold control and
* the edge's button, and Escape inside the open panel folds it.
* 2. New conversation is a round 56 px button in the conversation column,
* never over the panel, never over the composer's field, and absent
* from the panel's header.
* 3. A row's menu renames by keyboard (Escape cancels without closing the
* panel, Enter saves once) and deletes with an Undo that keeps the
* DELETE from ever leaving; a delete left alone commits once.
* 4. The gear opens the quick settings as a popover; Escape closes it and
* focus returns to the gear. `/coach?settings=data` opens it on "What I
* can see" and drops the param from the URL.
* 5. axe finds nothing on the open panel or the open settings, in the
* light and the dark theme.
* 6. The day docks right of the panel. At 1440 only one of the two is
* open: a day folds the panel, opening the panel folds the day. At 1920
* both stay open and the conversation keeps at least 560 px.
* 7. At 390 px the panel is a dialog sheet opened from the top bar's
* toggle; Escape closes it and focus returns to the toggle. The gear
* opens a bottom sheet that closes on Escape back to the gear. New
* conversation sits above the composer. Nothing scrolls sideways.
*
* Every Coach route is stubbed; the provider chain and provider config are
* stubbed too, so the model section renders the same on every machine.
* Screenshots are attached to the report (no pixel baselines).
*/
// A fixed midday, pinned in the browser too (see beforeEach): the panel groups
// conversations by the browser's local day, so a run just after midnight in
// the runner's or the profile's zone would move "an hour ago" into yesterday.
const NOW = Date.parse("2026-06-04T12:00:00Z");
const HOUR = 60 * 60 * 1000;
interface Conversation {
id: string;
title: string;
updatedAt: string;
}
function conversations(): Conversation[] {
return [
{
id: "frame-bp",
title: "Blood pressure this week",
updatedAt: new Date(NOW - HOUR).toISOString(),
},
{
id: "frame-sleep",
title: "Sleep since May",
updatedAt: new Date(NOW - 26 * HOUR).toISOString(),
},
{
id: "frame-weight",
title: "Weight over the summer",
updatedAt: new Date(NOW - 10 * 24 * HOUR).toISOString(),
},
];
}
const ANSWER =
"Your blood pressure held a steady band this week. The mornings ran a little higher than the evenings, which is common and nothing that stands out on its own.";
function fulfilJson(route: Route, data: unknown, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify({ data, error: null }),
});
}
interface CoachMock {
patches: Array<{ id: string; title: string }>;
deletes: string[];
}
async function mockCoach(page: Page): Promise<CoachMock> {
const state = conversations();
const mock: CoachMock = { patches: [], deletes: [] };
const dto = (c: Conversation) => ({
id: c.id,
title: c.title,
createdAt: c.updatedAt,
updatedAt: c.updatedAt,
messageCount: 2,
fenced: false,
attachments: [],
documentTitle: null,
});
await page.route(
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/,
async (route: Route, request: Request) => {
const url = new URL(request.url());
const match = url.pathname.match(/^\/api\/insights\/chat\/([^/]+)$/);
if (match) {
const id = decodeURIComponent(match[1]);
const found = state.find((c) => c.id === id);
if (request.method() === "PATCH") {
const { title } = request.postDataJSON() as { title: string };
mock.patches.push({ id, title });
if (found) found.title = title;
return fulfilJson(route, { id, title });
}
if (request.method() === "DELETE") {
mock.deletes.push(id);
const idx = state.findIndex((c) => c.id === id);
if (idx >= 0) state.splice(idx, 1);
return fulfilJson(route, { deleted: true });
}
if (!found) return fulfilJson(route, null, 404);
return fulfilJson(route, {
...dto(found),
attachmentCount: 0,
summary: null,
messages: [
{
id: `${id}-u`,
role: "user",
content: `Tell me about ${found.title.toLowerCase()}.`,
createdAt: found.updatedAt,
metricSource: null,
providerType: null,
promptVersion: null,
tokensUsed: null,
model: null,
},
{
id: `${id}-a`,
role: "assistant",
content: ANSWER,
createdAt: found.updatedAt,
metricSource: null,
providerType: "mock",
promptVersion: "e2e",
tokensUsed: 640,
model: "mock",
},
],
});
}
if (url.pathname.startsWith("/api/insights/chat/")) {
return fulfilJson(route, { results: [] });
}
const q = (url.searchParams.get("q") ?? "").toLowerCase();
return fulfilJson(route, {
conversations: state
.filter((c) => c.title.toLowerCase().includes(q))
.map(dto),
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: [] }),
);
await page.route("**/api/insights/provider-chain", (route) =>
route.request().method() === "GET"
? fulfilJson(route, {
activeProvider: "local",
cachedActiveProvider: "local",
configuredChain: [
{ providerType: "local", enabled: true, available: true },
{ providerType: "admin-openai", enabled: true, available: true },
],
})
: fulfilJson(route, { saved: true }),
);
await page.route("**/api/user/ai-provider", (route) =>
route.request().method() === "GET"
? fulfilJson(route, {
provider: "LOCAL",
model: "qwen2.5",
baseUrl: "http://localhost:11434/v1",
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: false,
hasOpenaiKey: false,
openaiKeyPreview: null,
compatBaseUrl: null,
compatModel: null,
hasCompatKey: false,
responseTimeoutSeconds: null,
localReasoningEffort: null,
compatReasoningEffort: null,
})
: fulfilJson(route, { updated: true }),
);
return mock;
}
const toggle = (page: Page) => page.locator('[data-slot="coach-panel-toggle"]');
const collapse = (page: Page) =>
page.locator('[data-slot="coach-panel-collapse"]');
const expand = (page: Page) => page.locator('[data-slot="coach-panel-expand"]');
const fab = (page: Page) => page.locator('[data-slot="coach-new-chat-fab"]');
const dayPanel = (page: Page) => page.locator('[data-slot="day-panel"]');
const panel = (page: Page) =>
page.locator('[data-slot="coach-conversations-panel"]');
/** Width of the reading column: the thread's capped inner column. */
async function columnWidths(page: Page) {
const thread = page.locator('[data-slot="coach-message-thread"] > *').first();
const composer = page.locator('[data-slot="coach-page-composer"] > div');
const t = await thread.boundingBox();
const c = await composer.boundingBox();
expect(t, "thread column").not.toBeNull();
expect(c, "composer column").not.toBeNull();
return { thread: t!.width, composer: c!.width, threadX: t!.x };
}
async function expectNoSidewaysScroll(page: Page) {
const overflow = await page.evaluate(() => {
const doc = document.scrollingElement!;
const main = document.getElementById("main-content")!;
return {
doc: doc.scrollWidth - doc.clientWidth,
main: main.scrollWidth - main.clientWidth,
};
});
expect(overflow.doc).toBeLessThanOrEqual(0);
expect(overflow.main).toBeLessThanOrEqual(0);
}
/**
* The docked panel is flush with the window: its right edge is the
* viewport's (no scrollbar gutter beside it), it runs from the top of the
* window to the bottom, the top bar ends at its left edge, its header row's
* bottom border is the top bar's, and its own border is the left one only.
*/
async function expectPanelFlush(page: Page, width: number, height: number) {
const geometry = await page.evaluate(() => {
const aside = document.querySelector(
'[data-slot="coach-conversations-panel"]',
)!;
const inner = aside.firstElementChild as HTMLElement;
// Open, the header row; folded, the edge's band.
const header =
aside.querySelector('[data-slot="coach-conversations-panel-header"]') ??
aside.querySelector('[data-slot="coach-panel-expand"]')!.parentElement!;
const bar = document.querySelector('[data-slot="top-bar"]')!;
const main = document.getElementById("main-content")!;
const style = getComputedStyle(inner);
const box = aside.getBoundingClientRect();
const barBox = bar.getBoundingClientRect();
return {
right: box.right,
top: box.top,
bottom: box.bottom,
left: box.left,
headerBottom: header.getBoundingClientRect().bottom,
barBottom: barBox.bottom,
barRight: barBox.right,
gutter: main.offsetWidth - main.clientWidth,
borders: [
style.borderTopWidth,
style.borderRightWidth,
style.borderBottomWidth,
style.borderLeftWidth,
],
};
});
expect(geometry.right).toBe(width);
expect(geometry.top).toBe(0);
expect(geometry.bottom).toBe(height);
expect(geometry.barRight).toBe(geometry.left);
expect(geometry.headerBottom).toBe(geometry.barBottom);
expect(geometry.gutter).toBe(0);
expect(geometry.borders).toEqual(["0px", "0px", "0px", "1px"]);
}
/**
* New conversation floats in the conversation column: a round 56 px button
* with a plus and its name, inside the column (never over a docked panel
* right of it) and clear of the composer's field. The panel's header has no
* such control.
*/
async function expectNewChatFab(page: Page) {
await expect(fab(page)).toHaveCount(1);
await expect(fab(page)).toHaveAttribute("aria-label", "New conversation");
await expect(fab(page).locator("svg.lucide-plus")).toHaveCount(1);
await expect(
panel(page).locator('[data-slot="coach-panel-new-chat"]'),
).toHaveCount(0);
const box = (await fab(page).boundingBox())!;
expect(Math.round(box.width)).toBe(56);
expect(Math.round(box.height)).toBe(56);
const main = (await page
.locator('[data-slot="coach-page-main"]')
.boundingBox())!;
expect(box.x).toBeGreaterThanOrEqual(main.x);
expect(box.x + box.width).toBeLessThanOrEqual(main.x + main.width);
const field = (await page
.locator(
'[data-slot="coach-page-composer"] [data-slot="coach-input-textarea"]',
)
.boundingBox())!;
const overlaps =
box.x < field.x + field.width &&
box.x + box.width > field.x &&
box.y < field.y + field.height &&
box.y + box.height > field.y;
expect(overlaps, "the button leaves the composer's field free").toBe(false);
}
/** Rendered heights of the docked panel's header buttons and first row. */
async function panelControlHeights(page: Page) {
const height = async (slot: string) =>
Math.round(
(await panel(page)
.locator(`[data-slot="${slot}"]`)
.first()
.boundingBox())!.height,
);
return {
collapse: await height("coach-panel-collapse"),
plans: await height("coach-panel-plans"),
gear: await height("coach-settings"),
row: await height("coach-history-select"),
};
}
async function shot(page: Page, testInfo: TestInfo, name: string) {
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path });
await testInfo.attach(name, { path, contentType: "image/png" });
const dir = process.env.COACH_SHOTS_DIR;
if (dir) await page.screenshot({ path: `${dir}/${name}.png` });
}
/**
* Record every mount of the conversations sheet from the first byte on.
* Reads the added nodes rather than the live DOM, so a sheet that mounts and
* unmounts again before the observer's callback runs is still counted.
*/
async function watchSheetMounts(page: Page) {
await page.addInitScript(() => {
const w = window as unknown as { __coachSheetSeen?: boolean };
w.__coachSheetSeen = false;
const sheet = '[role="dialog"][data-slot="coach-conversations-panel"]';
new MutationObserver((records) => {
for (const record of records) {
for (const node of record.addedNodes) {
if (
node instanceof Element &&
(node.matches(sheet) || node.querySelector(sheet))
) {
w.__coachSheetSeen = true;
}
}
}
}).observe(document, { childList: true, subtree: true });
});
}
async function sheetMounted(page: Page): Promise<boolean> {
return page.evaluate(
() =>
(window as unknown as { __coachSheetSeen?: boolean }).__coachSheetSeen ===
true,
);
}
async function openCoach(page: Page, path = "/coach?c=frame-bp") {
await page.goto(path, { waitUntil: "domcontentloaded" });
await expect(
page.locator('[data-slot="coach-message-thread"]').getByText(ANSWER),
).toBeVisible({ timeout: 15_000 });
}
async function waitForWidth(locator: Locator, width: number) {
await expect
.poll(async () => Math.round((await locator.boundingBox())?.width ?? -1))
.toBe(width);
}
test.describe("Coach page frame", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"each case sets its own viewport; the desktop project runs them all",
);
await page.clock.setFixedTime(new Date(NOW));
await serveAiBlock(page, aiBlockAvailable());
await page.emulateMedia({ reducedMotion: "reduce" });
});
for (const width of [1440, 1280]) {
test(`${width}: the panel docks, toggles, persists, and the reading column never changes width`, async ({
page,
}, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await mockCoach(page);
await openCoach(page);
// First visit: open.
await expect(panel(page)).toHaveAttribute("data-state", "open");
await waitForWidth(panel(page), 288);
// Docked, the fold control lives in the panel; the top bar has none.
await expect(toggle(page)).toHaveCount(0);
await expect(collapse(page)).toHaveAttribute("aria-expanded", "true");
await expect(collapse(page)).toHaveAttribute(
"aria-controls",
"coach-conversations-panel",
);
const open = await columnWidths(page);
expect(open.thread).toBeCloseTo(672, 0);
expect(Math.abs(open.composer - open.thread)).toBeLessThanOrEqual(1);
// The panel sits right of the column, never over it.
const aside = (await panel(page).boundingBox())!;
expect(aside.x).toBeGreaterThanOrEqual(open.threadX + open.thread);
await expectPanelFlush(page, width, 900);
await expectNewChatFab(page);
// The bar says where the reader is: the page, then the conversation.
await expect(
page.locator('[data-slot="coach-top-bar-trail"]'),
).toHaveText(/Coach.*Blood pressure this week/);
await expectNoSidewaysScroll(page);
await shot(page, testInfo, `coach-frame-${width}-open`);
// Folded: a 48 px edge stays, its button takes the focus, and the
// top bar ends at the edge.
await collapse(page).click();
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await waitForWidth(panel(page), 48);
await expect(expand(page)).toBeFocused();
await expect(expand(page)).toHaveAttribute("aria-expanded", "false");
await expect(expand(page)).toHaveAttribute(
"aria-label",
"Show conversations",
);
await expect(
panel(page).locator('[data-slot="coach-history-select"]'),
).toHaveCount(0);
await expectPanelFlush(page, width, 900);
const closed = await columnWidths(page);
expect(Math.abs(closed.thread - open.thread)).toBeLessThanOrEqual(1);
expect(Math.abs(closed.composer - open.composer)).toBeLessThanOrEqual(1);
await expectNewChatFab(page);
await expectNoSidewaysScroll(page);
await shot(page, testInfo, `coach-frame-${width}-closed`);
// Remembered across a reload.
await openCoach(page);
await expect(panel(page)).toHaveAttribute("data-state", "closed");
// Opened from the keyboard, focus lands on the fold control; Escape
// from inside folds it again and focus goes back to the edge.
await expand(page).focus();
await page.keyboard.press("Enter");
await expect(panel(page)).toHaveAttribute("data-state", "open");
await expect(collapse(page)).toBeFocused();
await panel(page)
.locator('[data-slot="coach-history-select"]')
.first()
.focus();
await page.keyboard.press("Escape");
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await expect(expand(page)).toBeFocused();
});
}
test("1440: the day and the conversations take turns, focus included", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockCoach(page);
await mockDay(page);
const day = "2026-06-01";
await page.goto(`/coach?c=frame-bp&day=${day}`, {
waitUntil: "domcontentloaded",
});
await expect(
page.locator('[data-slot="coach-message-thread"]').getByText(ANSWER),
).toBeVisible({ timeout: 15_000 });
// The day opens docked, right of the conversations, which fold to
// their edge for it.
await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked");
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await waitForWidth(panel(page), 48);
const rail = (await panel(page).boundingBox())!;
const dayBox = (await dayPanel(page).boundingBox())!;
expect(rail.x + rail.width).toBeLessThanOrEqual(dayBox.x + 1);
expect(Math.round(dayBox.x + dayBox.width)).toBe(1440);
await expectNewChatFab(page);
expect(
(await page.locator('[data-slot="coach-page-main"]').boundingBox())!
.width,
).toBeGreaterThanOrEqual(540);
await expectNoSidewaysScroll(page);
await shot(page, testInfo, "coach-frame-1440-day");
// Opening the conversations folds the day to its edge; focus goes to
// the conversations' fold control, the day is out of the URL, and the
// conversation stays the same.
await expand(page).click();
await expect(panel(page)).toHaveAttribute("data-state", "open");
await expect(dayPanel(page)).toHaveCount(0);
await expect(page.locator('[data-slot="day-rail"]')).toHaveAttribute(
"data-day",
day,
);
await expect(collapse(page)).toBeFocused();
await expect(page).toHaveURL(/\/coach\?c=frame-bp$/);
await expectNewChatFab(page);
await shot(page, testInfo, "coach-frame-1440-list");
// And back: the day's edge opens the day, the conversations fold.
await page.locator('[data-slot="day-expand"]').click();
await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked");
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await expect(page).toHaveURL(new RegExp(`c=frame-bp&day=${day}$`));
// Switching conversations keeps the day open.
await expand(page).click();
await page.locator('[data-slot="day-expand"]').click();
await expect(dayPanel(page)).toBeVisible();
await fab(page).click();
await expect(page).toHaveURL(new RegExp(`/coach\\?day=${day}$`));
await expect(dayPanel(page)).toBeVisible();
});
test("1920: the conversations and the day stay open side by side", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1920, height: 1000 });
await mockCoach(page);
await mockDay(page);
await page.goto("/coach?c=frame-bp&day=2026-06-01", {
waitUntil: "domcontentloaded",
});
await expect(
page.locator('[data-slot="coach-message-thread"]').getByText(ANSWER),
).toBeVisible({ timeout: 15_000 });
await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked");
await expect(panel(page)).toHaveAttribute("data-state", "open");
await waitForWidth(panel(page), 288);
const list = (await panel(page).boundingBox())!;
const dayBox = (await dayPanel(page).boundingBox())!;
expect(list.x + list.width).toBeLessThanOrEqual(dayBox.x + 1);
expect(
(await page.locator('[data-slot="coach-page-main"]').boundingBox())!
.width,
).toBeGreaterThanOrEqual(560);
await expectNewChatFab(page);
await expectNoSidewaysScroll(page);
await shot(page, testInfo, "coach-frame-1920-both");
// Folding one leaves the other alone.
await collapse(page).click();
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await expect(dayPanel(page)).toBeVisible();
await expand(page).click();
await expect(panel(page)).toHaveAttribute("data-state", "open");
await expect(dayPanel(page)).toBeVisible();
});
test("1280 fine pointer: compact header buttons and rows", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 900 });
await mockCoach(page);
await openCoach(page);
expect(await panelControlHeights(page)).toEqual({
collapse: 28,
plans: 28,
gear: 28,
row: 36,
});
});
test.describe("touch", () => {
// A touch tablet in landscape docks the panel too; the controls keep the
// 44 px floor because the size follows the input, not the width.
test.use({ hasTouch: true });
test("1280 touch: header buttons and rows keep the 44 px floor", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 900 });
await mockCoach(page);
await openCoach(page);
await expect(panel(page)).toHaveAttribute("data-state", "open");
expect(await panelControlHeights(page)).toEqual({
collapse: 44,
plans: 44,
gear: 44,
row: 44,
});
});
});
test("1440: picking a row opens it and New chat clears it, both in the URL", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockCoach(page);
await openCoach(page);
const rows = panel(page).locator('[data-slot="coach-history-select"]');
await expect(rows).toHaveCount(3);
await expect(panel(page).locator('[data-group="today"]')).toContainText(
"Blood pressure this week",
);
await expect(panel(page).locator('[data-group="yesterday"]')).toContainText(
"Sleep since May",
);
await expect(rows.nth(0)).toHaveAttribute("aria-current", "true");
await rows.nth(1).click();
await expect(page).toHaveURL(/\/coach\?c=frame-sleep$/);
await expect(rows.nth(1)).toHaveAttribute("aria-current", "true");
await fab(page).click();
await expect(page).toHaveURL(/\/coach$/);
await expect(
page.locator('[data-slot="coach-input-textarea"]'),
).toBeVisible();
await expect(panel(page).locator('[aria-current="true"]')).toHaveCount(0);
});
test("1440: rename by keyboard and delete with undo from the row menu", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 900 });
const mock = await mockCoach(page);
await openCoach(page);
const row = panel(page)
.locator('[data-slot="coach-history-item"]')
.filter({ hasText: "Sleep since May" });
const menu = row.locator('[data-slot="coach-history-row-menu"]');
await expect(menu).toHaveAttribute(
"aria-label",
"Actions for Sleep since May",
);
// Keyboard: focus the menu button, open it, pick Rename.
await menu.focus();
await page.keyboard.press("Enter");
await page.getByRole("menuitem", { name: "Rename" }).click();
const input = row.locator('[data-slot="coach-conversation-rename-input"]');
await expect(input).toBeFocused();
await input.fill("Discard this");
await page.keyboard.press("Escape");
await expect(input).toHaveCount(0);
await expect(panel(page)).toHaveAttribute("data-state", "open");
await expect(row).toContainText("Sleep since May");
await expect(menu).toBeFocused();
expect(mock.patches).toHaveLength(0);
await page.keyboard.press("Enter");
await page.getByRole("menuitem", { name: "Rename" }).click();
await expect(input).toBeFocused();
await input.fill("Sleep since spring");
await page.keyboard.press("Enter");
const renamed = panel(page)
.locator('[data-slot="coach-history-item"]')
.filter({ hasText: "Sleep since spring" });
await expect(renamed).toHaveCount(1);
await expect(input).toHaveCount(0);
expect(mock.patches).toEqual([
{ id: "frame-sleep", title: "Sleep since spring" },
]);
// Delete, then Undo: the row returns and no DELETE leaves.
await renamed.hover();
await renamed.locator('[data-slot="coach-history-row-menu"]').click();
await page.getByRole("menuitem", { name: "Delete" }).click();
await expect(renamed).toHaveCount(0);
await shot(page, testInfo, "coach-frame-1440-delete-toast");
await page.getByRole("button", { name: "Undo" }).click();
await expect(renamed).toHaveCount(1);
await page.waitForTimeout(6_500);
expect(mock.deletes).toEqual([]);
// Delete left alone commits once, after the undo window.
await renamed.hover();
await renamed.locator('[data-slot="coach-history-row-menu"]').click();
await page.getByRole("menuitem", { name: "Delete" }).click();
await expect(renamed).toHaveCount(0);
await expect
.poll(() => mock.deletes, { timeout: 10_000 })
.toEqual(["frame-sleep"]);
});
test("1440: the gear opens the quick settings as a popover; Escape returns to the gear", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockCoach(page);
await openCoach(page);
const gear = panel(page).locator('[data-slot="coach-settings"]');
await gear.click();
const popover = page.locator('[data-slot="coach-settings-popover"]');
await expect(popover).toBeVisible();
await expect(popover).toHaveAttribute("role", "dialog");
await expect(
popover.locator('[data-slot="coach-quick-provider"]'),
).toHaveValue("local");
await expect(
popover.locator('[data-slot="coach-quick-model"]'),
).toHaveValue("qwen2.5");
await expect(
popover.locator('[data-slot="coach-sources-window"]'),
).toBeVisible();
// The popover stays inside the viewport.
const box = (await popover.boundingBox())!;
expect(box.x + box.width).toBeLessThanOrEqual(1440);
expect(box.y + box.height).toBeLessThanOrEqual(900);
await shot(page, testInfo, "coach-frame-1440-settings");
await page.keyboard.press("Escape");
await expect(popover).toHaveCount(0);
await expect(gear).toBeFocused();
// Escape on the popover does not also close the panel.
await expect(panel(page)).toHaveAttribute("data-state", "open");
});
test("1440: /coach?settings=data opens the settings on what the Coach sees", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockCoach(page);
// A closed panel is revealed for the deep link.
await page.addInitScript(() => {
window.localStorage.setItem("healthlog.coach.panelOpen", "false");
});
// The hydration render does not know the viewport yet; the phone sheet
// must not mount on a desktop even for a moment.
await watchSheetMounts(page);
await page.goto("/coach?settings=data", { waitUntil: "domcontentloaded" });
const popover = page.locator('[data-slot="coach-settings-popover"]');
await expect(popover).toBeVisible({ timeout: 15_000 });
await expect(panel(page)).toHaveAttribute("data-state", "open");
expect(
await sheetMounted(page),
"the phone sheet flashed on a desktop deep link",
).toBe(false);
await expect(
popover.locator('[data-slot="coach-settings-data"]'),
).toBeFocused();
await expect(page).toHaveURL(/\/coach$/);
await page.keyboard.press("Escape");
await expect(popover).toHaveCount(0);
});
for (const theme of ["light", "dark"] as const) {
test(`1440 ${theme}: axe finds nothing on the panel or the quick settings`, async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" });
await page.addInitScript((value: string) => {
window.localStorage.setItem("healthlog-theme", value);
}, theme);
await mockCoach(page);
await openCoach(page);
await expect(page.locator("html")).toHaveClass(
new RegExp(`(^|\\s)${theme}(\\s|$)`),
);
await expect(panel(page)).toHaveAttribute("data-state", "open");
await shot(page, testInfo, `coach-frame-1440-${theme}`);
const scan = async (selector: string, label: string) => {
const result = await new AxeBuilder({ page })
.include(selector)
.withTags([
"wcag2a",
"wcag2aa",
"wcag21a",
"wcag21aa",
"best-practice",
])
.analyze();
expect(
result.violations.map((v) => ({
id: v.id,
nodes: v.nodes.map((n) => n.target.join(" ")),
})),
`${label} accessibility violations`,
).toEqual([]);
};
await scan('[data-slot="coach-conversations-panel"]', "panel");
await shot(page, testInfo, `coach-frame-1440-${theme}-panel`);
await panel(page).locator('[data-slot="coach-settings"]').click();
const popover = page.locator('[data-slot="coach-settings-popover"]');
await expect(
popover.locator('[data-slot="coach-quick-provider"]'),
).toBeVisible();
await scan('[data-slot="coach-settings-popover"]', "settings");
await shot(page, testInfo, `coach-frame-1440-${theme}-settings`);
});
}
test("390: /coach?settings=data opens the sheet and the settings on what the Coach sees", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockCoach(page);
await watchSheetMounts(page);
await page.goto("/coach?settings=data", { waitUntil: "domcontentloaded" });
// The settings bottom sheet opens over the conversations sheet and
// hides it from the accessibility tree, so the sheet is found by slot.
await expect(page.locator('[data-slot="coach-settings-data"]')).toBeVisible(
{ timeout: 15_000 },
);
await expect(
page.locator('[role="dialog"][data-slot="coach-conversations-panel"]'),
).toBeVisible();
// The positive control for the desktop case: the same watcher sees the
// sheet mount where it belongs.
expect(await sheetMounted(page)).toBe(true);
await expect(page).toHaveURL(/\/coach$/);
});
test("390: the panel is a sheet, the gear a bottom sheet, and nothing scrolls sideways", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockCoach(page);
await openCoach(page);
// Below 1280 px the panel waits to be asked.
await expect(panel(page)).toHaveCount(0);
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
// New conversation sits above the composer, clear of its field.
await expectNewChatFab(page);
const fabBox = (await fab(page).boundingBox())!;
const composerBox = (await page
.locator('[data-slot="coach-page-composer"]')
.boundingBox())!;
expect(fabBox.y + fabBox.height).toBeLessThanOrEqual(composerBox.y);
await expectNoSidewaysScroll(page);
await shot(page, testInfo, "coach-frame-390-thread");
await toggle(page).click();
const sheet = page.getByRole("dialog", { name: "Conversations" });
await expect(sheet).toBeVisible();
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
await expect(
sheet.locator('[data-slot="coach-panel-new-chat"]'),
).toHaveCount(0);
// The title keeps its whole word beside four header controls.
const title = sheet.locator(
'[data-slot="coach-conversations-panel-header"] h2',
);
expect(
await title.evaluate((el) => el.scrollWidth <= el.clientWidth),
"the sheet title is not truncated",
).toBe(true);
await shot(page, testInfo, "coach-frame-390-panel");
await page.keyboard.press("Escape");
await expect(sheet).toHaveCount(0);
await expect(toggle(page)).toBeFocused();
// The gear in the sheet opens a bottom sheet over it.
await toggle(page).click();
await expect(sheet).toBeVisible();
const gear = sheet.locator('[data-slot="coach-settings"]');
await gear.click();
const settings = page.locator('[data-slot="responsive-sheet-content"]');
await expect(settings).toHaveAttribute("data-variant", "sheet");
await expect(
settings.locator('[data-slot="coach-quick-provider"]'),
).toBeVisible();
const box = (await settings.boundingBox())!;
expect(Math.round(box.y + box.height)).toBe(844);
await shot(page, testInfo, "coach-frame-390-settings");
await page.keyboard.press("Escape");
await expect(settings).toHaveCount(0);
await expect(gear).toBeFocused();
await expect(sheet).toBeVisible();
// Picking a conversation closes the sheet and opens the thread.
await sheet
.locator('[data-slot="coach-history-select"]')
.filter({ hasText: "Weight over the summer" })
.click();
await expect(sheet).toHaveCount(0);
await expect(page).toHaveURL(/\/coach\?c=frame-weight$/);
await expectNoSidewaysScroll(page);
});
});