diff --git a/e2e/coach-activity.spec.ts b/e2e/coach-activity.spec.ts new file mode 100644 index 000000000..259a8b2f6 --- /dev/null +++ b/e2e/coach-activity.spec.ts @@ -0,0 +1,488 @@ +import AxeBuilder from "@axe-core/playwright"; +import type { Page } from "@playwright/test"; +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; + +import { expect, test } from "./setup/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; +import { + FIXED_NOW, + done, + end, + feed, + installLiveCoachStream, + posts, + serveConversation, + tokens, + applyTheme, + type StubConversation, +} from "./setup/coach-live-stream"; + +/** + * The live trail of a Coach turn: one quiet status line at the top of the + * answer. + * + * Contracts under test, at 1440 and 390 px, light and dark: + * + * 1. While the turn runs the line says what is happening right now and + * moves with the frames: Thinking… → Fetching blood pressure… → + * Summarising 214 readings… → Writing the answer…. + * 2. A table the turn has read shows as a one-line preview before the + * first token. + * 3. Nothing opens by itself: the trail stays closed (`aria-expanded` + * false) in every state, after the answer and after a reload. + * 4. After the answer the line is one summary ("Thought it through, 1 + * lookup, 7 s"); the preview gives way to the answer's chart. + * 5. A tap opens the trail; the reasoning text of a round sits behind its + * own closed disclosure. + * 6. No horizontal scroll, a 44 px tap target on phones, axe clean. + * + * The chat stream is fed frame by frame from the spec + * (`installLiveCoachStream`), so every live state is observed rather than + * flashing by. Set COACH_SCREENSHOTS_DIR to also write screenshots of each + * state. + */ + +const CONVERSATION_ID = "coach-activity-e2e"; +const QUESTION = "How was my blood pressure this month?"; +const ANSWER = + "Your blood pressure held a steady band this month, around 126/82 mmHg on most days. Nothing stands out against your usual range."; + +const BP_VALUES: Array<[number, number]> = [ + [128, 84], + [125, 82], + [131, 85], + [122, 80], + [124, 81], + [127, 83], + [121, 79], + [126, 82], + [123, 80], + [126, 81], +]; + +const BP_TABLE = { + ref: "r1", + source: { + tool: "get_metric_table", + domain: "bp", + window: "last30days", + period: "current", + granularity: "day", + }, + shape: "timeSeries", + titleKey: "coach.result.title.byDay", + title: "Blood pressure by day", + rowCount: BP_VALUES.length, + chartKind: "line", + displayed: false, + columns: [ + { + key: "day", + kind: "period", + labelKey: "coach.result.column.day", + label: "Day", + }, + { + key: "systolic", + kind: "number", + labelKey: "coach.result.column.systolic", + label: "Systolic", + unit: "mmHg", + decimals: 0, + }, + { + key: "diastolic", + kind: "number", + labelKey: "coach.result.column.diastolic", + label: "Diastolic", + unit: "mmHg", + decimals: 0, + }, + { + key: "readings", + kind: "count", + labelKey: "coach.result.column.readings", + label: "Readings", + }, + ], + rows: BP_VALUES.map(([sys, dia], i) => [ + new Date(Date.UTC(2026, 8, 7 + i)).toISOString().slice(0, 10), + sys, + dia, + i === 0 ? 205 : 1, + ]), + truncated: false, + chart: { kind: "line", x: "day", series: ["systolic", "diastolic"] }, +}; +const BP_SHOWN = { ...BP_TABLE, displayed: true }; +const BP_META = { + ref: "r1", + source: BP_TABLE.source, + shape: BP_TABLE.shape, + titleKey: BP_TABLE.titleKey, + title: BP_TABLE.title, + rowCount: BP_TABLE.rowCount, + chartKind: "line", + displayed: true, +}; + +const STEP = { + id: "s1", + tool: "get_metric_table", + labelKey: "coach.step.readWindow", + label: "Checking: Blood pressure, last 30 days", + domain: "bp", + window: "last30days", + granularity: "day", +}; + +function entry( + id: string, + phase: string, + status: string, + label: string, + extra: Record = {}, +) { + return { + type: "activity", + activity: { + id, + phase, + status, + round: phase === "answer" ? 2 : 1, + labelKey: `insights.coach.activity.${phase}`, + label, + ...extra, + }, + }; +} + +const THINK_TITLE = "Looking at the whole month"; +const THINK_TEXT = + "The month has daily readings; a daily table answers this best."; + +const ACTIVITY_META = [ + { + id: "a1", + phase: "thinking", + status: "done", + round: 1, + labelKey: "insights.coach.activity.thinking", + label: "Thinking…", + durationMs: 2_400, + }, + { + id: "a2", + phase: "fetch", + status: "done", + round: 1, + labelKey: "insights.coach.activity.fetching", + label: "Fetching blood pressure, last 30 days…", + stepRef: "s1", + count: 214, + durationMs: 900, + }, + { + id: "a3", + phase: "digest", + status: "done", + round: 1, + labelKey: "insights.coach.activity.digestDone", + label: "214 readings from 1 area", + count: 214, + durationMs: 400, + }, + { + id: "a4", + phase: "answer", + status: "done", + round: 2, + labelKey: "insights.coach.activity.answer", + label: "Writing the answer…", + durationMs: 3_000, + }, +]; + +const METRIC_SOURCE = { + windows: ["last30days"], + metrics: ["bp"], + counts: { bp: 214 }, + steps: [{ ...STEP, status: "done", count: 214, resultRef: "r1" }], + activity: ACTIVITY_META, + results: [BP_META], + method: { + entries: [ + { + domain: "bp", + window: "last30days", + granularity: "day", + count: 214, + aggregation: "mean", + }, + ], + text: "Blood pressure, last 30 days, 214 readings, daily averages.", + }, +}; + +function conversation(): StubConversation { + return { + id: CONVERSATION_ID, + title: "Blood pressure this month", + messages: [], + results: { "coach-activity-e2e-a1": [BP_SHOWN] }, + trails: { + "coach-activity-e2e-a1": { + entries: [{ id: "a1", title: THINK_TITLE, text: THINK_TEXT }], + }, + }, + }; +} + +const VIEWPORTS = [ + { name: "1440", width: 1440, height: 900 }, + { name: "390", width: 390, height: 844 }, +] as const; + +const SHOTS = process.env.COACH_SCREENSHOTS_DIR; + +async function shot(page: Page, name: string) { + if (!SHOTS) return; + mkdirSync(SHOTS, { recursive: true }); + await page.screenshot({ path: join(SHOTS, `${name}.png`) }); +} + +async function noHorizontalScroll(page: Page) { + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); +} + +test.describe("Coach live trail", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + for (const viewport of VIEWPORTS) { + for (const theme of ["light", "dark"] as const) { + test(`${viewport.name} px, ${theme}: one quiet line that never opens by itself`, async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "sets its own viewports; one project suffices", + ); + const label = `${viewport.name}-${theme}`; + await page.setViewportSize(viewport); + await page.clock.setFixedTime(FIXED_NOW); + await serveAiBlock(page, aiBlockAvailable()); + const stub = conversation(); + await serveConversation(page, stub); + await installLiveCoachStream(page); + await applyTheme(page, theme); + + await page.goto("/coach", { waitUntil: "domcontentloaded" }); + await page.locator('[data-slot="coach-input-textarea"]').fill(QUESTION); + await page.locator('[data-slot="coach-input-send"]').click(); + + const turn = page.locator( + '[role="log"] [data-slot="coach-bubble-assistant"]', + ); + const line = turn.locator('[data-slot="coach-turn-steps"]'); + const toggle = line.locator('[data-slot="coach-turn-steps-toggle"]'); + const current = line.locator('[data-slot="coach-turn-steps-active"]'); + + // 1. Before any frame: Thinking…, closed. + await expect(line).toHaveAttribute("data-state", "running"); + await expect(current).toHaveText("Thinking…"); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + + await feed(page, [entry("a1", "thinking", "running", "Thinking…")]); + await feed(page, [ + entry("a1", "thinking", "running", "Thinking…", { + title: THINK_TITLE, + }), + ]); + await expect(current).toHaveText(THINK_TITLE); + + await feed(page, [ + entry("a1", "thinking", "done", "Thinking…", { + title: THINK_TITLE, + text: THINK_TEXT, + durationMs: 2_400, + }), + { type: "step", step: { ...STEP, status: "running" } }, + entry( + "a2", + "fetch", + "running", + "Fetching blood pressure, last 30 days…", + { + stepRef: "s1", + }, + ), + ]); + await expect(current).toHaveText( + "Fetching blood pressure, last 30 days…", + ); + + // 2. The table it read previews before the first token. + await feed(page, [ + { type: "result", result: BP_TABLE, interim: true }, + { type: "step", step: { ...STEP, status: "done", count: 214 } }, + entry( + "a2", + "fetch", + "done", + "Fetching blood pressure, last 30 days…", + { + stepRef: "s1", + count: 214, + durationMs: 900, + }, + ), + entry("a3", "digest", "running", "Summarising 214 readings…", { + count: 214, + }), + ]); + await expect(current).toHaveText("Summarising 214 readings…"); + const preview = turn.locator( + '[data-slot="coach-interim-result"][data-ref="r1"]', + ); + await expect(preview).toBeVisible(); + await expect(preview).toContainText("Blood pressure by day"); + await expect( + turn.locator('[data-slot="coach-answer-bubble"]'), + ).toHaveCount(0); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await noHorizontalScroll(page); + await shot(page, `${label}-1-running`); + + await feed(page, [ + entry("a3", "digest", "done", "214 readings from 1 area", { + count: 214, + durationMs: 400, + }), + entry("a4", "answer", "running", "Writing the answer…"), + ...tokens(ANSWER), + ]); + await expect(current).toHaveText("Writing the answer…"); + await expect( + turn.locator('[data-slot="coach-answer-bubble"]'), + ).toContainText(ANSWER); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + + // The persisted copy lands with the refetch after `done`. + stub.messages.push( + { id: "coach-activity-e2e-u1", role: "user", content: QUESTION }, + { + id: "coach-activity-e2e-a1", + role: "assistant", + content: ANSWER, + metricSource: METRIC_SOURCE, + }, + ); + await feed(page, [ + entry("a4", "answer", "done", "Writing the answer…", { + durationMs: 3_000, + }), + { type: "provenance", metricSource: METRIC_SOURCE }, + { type: "result", result: BP_SHOWN }, + done(CONVERSATION_ID, "coach-activity-e2e-a1"), + ]); + await end(page); + + // 4. One calm summary, still closed; the preview became the chart. + const bubble = page + .locator('[data-slot="coach-bubble-assistant"]') + .filter({ hasText: ANSWER }); + const settled = bubble.locator('[data-slot="coach-turn-steps"]'); + await expect(settled).toHaveAttribute("data-state", "done"); + await expect( + settled.locator('[data-slot="coach-turn-steps-done"]'), + ).toHaveText("Thought it through, 1 lookup, 7 s"); + const settledToggle = settled.locator( + '[data-slot="coach-turn-steps-toggle"]', + ); + await expect(settledToggle).toHaveAttribute("aria-expanded", "false"); + await expect( + bubble.locator('[data-slot="coach-interim-result"]'), + ).toHaveCount(0); + await expect( + bubble.locator('[data-slot="coach-result-chart"][data-ref="r1"]'), + ).toBeVisible(); + await expect( + bubble.locator('[data-slot="coach-result-chart-plot"]'), + ).toBeVisible(); + expect((await posts(page))[0]).toMatchObject({ message: QUESTION }); + await noHorizontalScroll(page); + await shot(page, `${label}-2-settled`); + + // 6. A 44 px tap target on phones. + if (viewport.width < 640) { + const box = await settledToggle.boundingBox(); + expect(box?.height ?? 0).toBeGreaterThanOrEqual(44); + } + + // 5. A tap opens the trail; the round's reasoning stays behind its own + // closed disclosure. + await settledToggle.click(); + await expect(settledToggle).toHaveAttribute("aria-expanded", "true"); + const panel = settled.locator('[data-slot="coach-turn-steps-panel"]'); + await expect(panel).toBeVisible(); + const rows = panel.locator('[data-slot="coach-turn-step"]'); + await expect(rows).toHaveCount(4); + await expect(rows.nth(0)).toHaveAttribute("data-phase", "thinking"); + await expect(rows.nth(1)).toHaveAttribute("data-phase", "fetch"); + await expect( + panel.locator('[data-slot="coach-method-line"]'), + ).toContainText(METRIC_SOURCE.method.text); + const detail = rows + .nth(0) + .locator('[data-slot="coach-turn-step-detail-toggle"]'); + await expect(detail).toHaveAttribute("aria-expanded", "false"); + await expect(panel).not.toContainText(THINK_TEXT); + await detail.click(); + await expect(panel).toContainText(THINK_TEXT); + await noHorizontalScroll(page); + await shot(page, `${label}-3-open`); + + const axe = await new AxeBuilder({ page }) + .include('[data-slot="coach-bubble-assistant"]') + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect( + axe.violations.map((v) => ({ + id: v.id, + nodes: v.nodes.map((n) => n.target.join(" ")), + })), + ).toEqual([]); + + // 3. After a reload the trail is closed again, and opening it reads + // the stored titles and texts. + await page.goto(`/coach?c=${CONVERSATION_ID}`, { + waitUntil: "domcontentloaded", + }); + const reloaded = page + .locator('[data-slot="coach-bubble-assistant"]') + .filter({ hasText: ANSWER }) + .locator('[data-slot="coach-turn-steps"]'); + await expect(reloaded).toHaveAttribute("data-state", "done"); + await expect( + reloaded.locator('[data-slot="coach-turn-steps-done"]'), + ).toHaveText("Thought it through, 1 lookup, 7 s"); + const reloadedToggle = reloaded.locator( + '[data-slot="coach-turn-steps-toggle"]', + ); + await expect(reloadedToggle).toHaveAttribute("aria-expanded", "false"); + await expect( + reloaded.locator('[data-slot="coach-turn-steps-panel"]'), + ).toHaveCount(0); + await reloadedToggle.click(); + await expect( + reloaded.locator('[data-slot="coach-turn-step"]').nth(0), + ).toContainText(THINK_TITLE); + }); + } + } +}); diff --git a/e2e/coach-clarification.spec.ts b/e2e/coach-clarification.spec.ts new file mode 100644 index 000000000..a703bfc66 --- /dev/null +++ b/e2e/coach-clarification.spec.ts @@ -0,0 +1,261 @@ +import { expect, test } from "./setup/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; +import { + FIXED_NOW, + done, + end, + feed, + installLiveCoachStream, + posts, + serveConversation, + tokens, + applyTheme, + type StubConversation, +} from "./setup/coach-live-stream"; + +/** + * A clarifying question is a conversation, not a form. + * + * 1. The question is a normal Coach message; its choices are quiet reply + * pills under it, the assumed one first. There is no card. + * 2. A pill sends its label as the person's own message, with + * `clarification: { messageId, choiceId }`. + * 3. A typed answer works too, with `clarification: { messageId }`. + * 4. The trail above the question stays closed. + */ + +const CONVERSATION_ID = "coach-clarify-e2e"; +const QUESTION = "Is my heart rate okay?"; +const ASKS = + "Do you mean your resting heart rate or your heart rate while walking? Otherwise I'll look at your resting heart rate."; +const ANSWER = + "Your walking heart rate sat around 98 bpm over the last 30 days, close to your usual level."; + +const CLARIFICATION = { + kind: "metric", + freeText: true, + assumption: "c1", + choices: [ + { + id: "c1", + labelKey: "insights.coach.metric.resting_hr", + label: "Resting HR", + value: { metric: "resting_hr" }, + }, + { + id: "c2", + labelKey: "insights.coach.metric.walking_hr", + label: "Walking HR", + value: { metric: "walking_hr" }, + }, + ], +}; +const ASK_SOURCE = { + windows: ["last30days"], + metrics: ["resting_hr"], + activity: [ + { + id: "a1", + phase: "thinking", + status: "done", + round: 1, + labelKey: "insights.coach.activity.thinking", + label: "Thinking…", + durationMs: 1_200, + }, + { + id: "a2", + phase: "asking", + status: "done", + round: 1, + labelKey: "insights.coach.activity.asking", + label: "Asking you…", + durationMs: 300, + }, + ], + clarification: CLARIFICATION, +}; + +async function ask( + page: import("@playwright/test").Page, + stub: StubConversation, +) { + await page.locator('[data-slot="coach-input-textarea"]').fill(QUESTION); + await page.locator('[data-slot="coach-input-send"]').click(); + const live = page.locator( + '[role="log"] [data-slot="coach-turn-steps-active"]', + ); + await feed(page, [ + { + type: "activity", + activity: { + ...ASK_SOURCE.activity[0], + status: "running", + durationMs: undefined, + }, + }, + { + type: "activity", + activity: { + ...ASK_SOURCE.activity[1], + status: "running", + durationMs: undefined, + }, + }, + ]); + await expect(live).toHaveText("Asking you…"); + stub.messages.push( + { id: "coach-clarify-e2e-u1", role: "user", content: QUESTION }, + { + id: "coach-clarify-e2e-a1", + role: "assistant", + content: ASKS, + metricSource: ASK_SOURCE, + }, + ); + await feed(page, [ + ...tokens(ASKS), + { type: "activity", activity: ASK_SOURCE.activity[1] }, + { type: "provenance", metricSource: ASK_SOURCE }, + { type: "clarification", clarification: CLARIFICATION }, + done(CONVERSATION_ID, "coach-clarify-e2e-a1"), + ]); + await end(page); +} + +test.describe("Coach clarifying question", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("pills in the conversation, the assumed one first, and a tap answers", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "the flow is viewport-independent; the activity spec covers 390 px", + ); + await page.clock.setFixedTime(FIXED_NOW); + await serveAiBlock(page, aiBlockAvailable()); + const stub: StubConversation = { + id: CONVERSATION_ID, + title: "Heart rate", + messages: [], + results: {}, + trails: {}, + }; + await serveConversation(page, stub); + await installLiveCoachStream(page); + await applyTheme(page, "light"); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); + + await ask(page, stub); + + const asked = page + .locator('[data-slot="coach-bubble-assistant"]') + .filter({ hasText: ASKS }); + await expect(asked).toHaveCount(1); + // 1. No card anywhere; the pills sit in the answer's own column. + await expect( + page.locator('[data-slot="coach-clarification-card"]'), + ).toHaveCount(0); + const pills = asked.locator('[data-slot="coach-suggested-replies"]'); + await expect(pills).toBeVisible(); + await expect(pills).toHaveAttribute( + "data-message-id", + "coach-clarify-e2e-a1", + ); + const choices = pills.locator("[data-choice-id]"); + await expect(choices).toHaveCount(2); + await expect(choices.nth(0)).toHaveAttribute("data-choice-id", "c1"); + // 4. The trail stays closed. + await expect( + asked.locator('[data-slot="coach-turn-steps-toggle"]'), + ).toHaveAttribute("aria-expanded", "false"); + + // 2. A tap sends the label as the person's message. + const walking = choices.nth(1); + const label = (await walking.textContent())?.trim() ?? ""; + await walking.click(); + await expect( + page + .locator('[data-slot="coach-bubble-user"]') + .filter({ hasText: label }), + ).toBeVisible(); + await expect(pills).toHaveCount(0); + const sent = await posts(page); + expect(sent[1]).toMatchObject({ + conversationId: CONVERSATION_ID, + message: label, + clarification: { messageId: "coach-clarify-e2e-a1", choiceId: "c2" }, + }); + + stub.messages.push( + { id: "coach-clarify-e2e-u2", role: "user", content: label }, + { + id: "coach-clarify-e2e-a2", + role: "assistant", + content: ANSWER, + metricSource: { windows: ["last30days"], metrics: ["walking_hr"] }, + }, + ); + await feed(page, [ + ...tokens(ANSWER), + done(CONVERSATION_ID, "coach-clarify-e2e-a2"), + ]); + await end(page); + await expect( + page + .locator('[data-slot="coach-bubble-assistant"]') + .filter({ hasText: ANSWER }), + ).toBeVisible(); + await expect( + page.locator('[data-slot="coach-suggested-replies"]'), + ).toHaveCount(0); + }); + + test("a typed answer answers the question too", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "viewport-independent; desktop run suffices", + ); + await page.clock.setFixedTime(FIXED_NOW); + await serveAiBlock(page, aiBlockAvailable()); + const stub: StubConversation = { + id: CONVERSATION_ID, + title: "Heart rate", + messages: [], + results: {}, + trails: {}, + }; + await serveConversation(page, stub); + await installLiveCoachStream(page); + await applyTheme(page, "dark"); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); + + await ask(page, stub); + await expect( + page.locator('[data-slot="coach-suggested-replies"]'), + ).toBeVisible(); + + await page + .locator('[data-slot="coach-input-textarea"]') + .fill("the walking one"); + await page.locator('[data-slot="coach-input-send"]').click(); + await expect( + page + .locator('[data-slot="coach-bubble-user"]') + .filter({ hasText: "the walking one" }), + ).toBeVisible(); + const sent = await posts(page); + expect(sent[1]).toMatchObject({ + message: "the walking one", + clarification: { messageId: "coach-clarify-e2e-a1" }, + }); + expect( + (sent[1].clarification as Record).choiceId, + ).toBeUndefined(); + await end(page); + }); +}); diff --git a/e2e/coach-dialog.spec.ts b/e2e/coach-dialog.spec.ts index a5dfc1478..2f7581165 100644 --- a/e2e/coach-dialog.spec.ts +++ b/e2e/coach-dialog.spec.ts @@ -17,12 +17,13 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; * puts the table on the clipboard. * 3. A follow-up chip under the latest reply sends its label with * `followUp: { messageId, id }`. - * 4. A reply that ends in a clarifying question shows the choices card - * and no chips (as the server sends it); a choice sends its label with - * `clarification: { messageId, choiceId }`, and the card leaves once a + * 4. A reply that ends in a clarifying question offers its choices as + * reply pills under it and no chips (as the server sends it); there is + * no card. A choice sends its label with + * `clarification: { messageId, choiceId }`, and the pills leave once a * plain answer is the latest reply. - * 5. axe finds nothing on the answer, the chips and the clarification - * card, in the light and the dark theme. + * 5. axe finds nothing on the answer, the chips and the reply pills, in + * the light and the dark theme. * * The chat POST is a stubbed event stream in the documented frame order, * and the conversation detail it refetches after `done` returns the same @@ -441,7 +442,7 @@ async function expectNoAxeViolations(page: Page, label: string) { const result = await new AxeBuilder({ page }) .include('[data-slot="coach-bubble-assistant"]') .include('[data-slot="coach-follow-up-chips"]') - .include('[data-slot="coach-clarification-card"]') + .include('[data-slot="coach-suggested-replies"]') .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) .analyze(); expect( @@ -581,26 +582,31 @@ test.describe("Coach dialog", () => { }); expect(posts[1].clarification).toBeUndefined(); - // 4. The reply asks; the card offers the choices, and the chips of - // the earlier reply are gone. - const card = page.locator('[data-slot="coach-clarification-card"]'); - await expect(card).toBeVisible(); - await expect(card).toHaveAttribute( + // 4. The reply asks; pills under it offer the choices, and the chips + // of the earlier reply are gone. No card. + await expect( + page.locator('[data-slot="coach-clarification-card"]'), + ).toHaveCount(0); + const pills = secondBubble.locator( + '[data-slot="coach-suggested-replies"]', + ); + await expect(pills).toBeVisible(); + await expect(pills).toHaveAttribute( "data-message-id", TURNS[1].assistantId, ); - await expect(card.locator("[data-choice-id]")).toHaveCount(2); + await expect(pills.locator("[data-choice-id]")).toHaveCount(2); await expect(chips).toHaveCount(0); // The earlier result still renders under its own answer. await expect(figure).toBeVisible(); - // 5. axe over the answers, the result in both views and the card. + // 5. axe over the answers, the result in both views and the pills. await expectNoAxeViolations(page, `${theme} chart view with a question`); await figure.locator('[data-slot="coach-result-view-table"]').click(); await expect(table).toBeVisible(); await expectNoAxeViolations(page, `${theme} table view with a question`); - await card.locator('[data-choice-id="c2"]').click(); + await pills.locator('[data-choice-id="c2"]').click(); const thirdBubble = page .locator('[data-slot="coach-bubble-assistant"]') @@ -616,7 +622,7 @@ test.describe("Coach dialog", () => { expect(posts[2].followUp).toBeUndefined(); // A plain answer closes the question and offers no chips. - await expect(card).toHaveCount(0); + await expect(pills).toHaveCount(0); await expect(chips).toHaveCount(0); await expect( thirdBubble.locator('[data-slot="coach-turn-steps"]'), diff --git a/e2e/coach-memory.spec.ts b/e2e/coach-memory.spec.ts new file mode 100644 index 000000000..e9ed1973d --- /dev/null +++ b/e2e/coach-memory.spec.ts @@ -0,0 +1,436 @@ +import type { Page, Route } 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 { + FIXED_NOW, + done, + end, + feed, + installLiveCoachStream, + posts, + serveConversation, + tokens, + applyTheme, + type StubConversation, +} from "./setup/coach-live-stream"; + +/** + * What the Coach keeps, in the same quiet language as everything else in + * the thread, and the thinking depth in its quick settings. + * + * 1. A fact it saved: one meta line under the answer, "Remembered: …", + * with Undo, which forgets it. + * 2. A fact it proposes (health): no line; "Yes, remember it" / "No" as + * reply pills, a tap answers with `memoryDecision`. + * 3. A plan it proposes: "Take on this plan" / "Not now", with + * `planDecision`. No plan card in the thread. + * 4. The memory list in Settings → Coach: an entry is edited in place. + * 5. The thinking depth: levels above the operator's cap are disabled and + * say so; with reasoning switched off the field is locked with one + * sentence. + */ + +const CONVERSATION_ID = "coach-memory-e2e"; + +function fulfilJson(route: Route, data: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify({ data, error: null }), + }); +} + +interface Fact { + id: string; + category: string; + text: string; + confidence: number; + source: string; + createdAt: string; +} + +async function serveFacts(page: Page, facts: Fact[]) { + const calls: Array<{ method: string; path: string; body: unknown }> = []; + await page.route( + /\/api\/insights\/coach\/facts(?:\/[^?]+)?(?:\?.*)?$/, + async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const method = request.method(); + calls.push({ + method, + path: url.pathname, + body: method === "GET" ? null : request.postDataJSON(), + }); + const id = url.pathname.split("/").pop() ?? ""; + if (method === "DELETE") { + const at = facts.findIndex((f) => f.id === id); + if (at !== -1) facts.splice(at, 1); + return fulfilJson(route, { deleted: true }); + } + if (method === "PATCH") { + const fact = facts.find((f) => f.id === id); + const next = (request.postDataJSON() as { fact: string }).fact; + if (fact) fact.text = next; + return fulfilJson(route, { fact }); + } + return fulfilJson(route, { facts }); + }, + ); + return calls; +} + +function stub(): StubConversation { + return { + id: CONVERSATION_ID, + title: "Goals", + messages: [], + results: {}, + trails: {}, + }; +} + +async function send(page: Page, text: string) { + await page.locator('[data-slot="coach-input-textarea"]').fill(text); + await page.locator('[data-slot="coach-input-send"]').click(); +} + +async function answer( + page: Page, + conversation: StubConversation, + turn: { + userId: string; + userText: string; + id: string; + text: string; + metricSource: Record; + frames: unknown[]; + providerType?: string; + }, +) { + conversation.messages.push( + { id: turn.userId, role: "user", content: turn.userText }, + { + id: turn.id, + role: "assistant", + content: turn.text, + metricSource: turn.metricSource, + providerType: turn.providerType, + }, + ); + await feed(page, [ + ...tokens(turn.text), + { type: "provenance", metricSource: turn.metricSource }, + ...turn.frames, + done(CONVERSATION_ID, turn.id), + ]); + await end(page); + await expect( + page + .locator('[data-slot="coach-bubble-assistant"]') + .filter({ hasText: turn.text }), + ).toBeVisible(); +} + +test.describe("Coach memory and plans", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "viewport-independent; desktop run suffices", + ); + await page.clock.setFixedTime(FIXED_NOW); + await applyTheme(page, "light"); + }); + + test("a saved fact is one quiet line, and Undo forgets it", async ({ + page, + }) => { + await serveAiBlock(page, aiBlockAvailable()); + const facts: Fact[] = [ + { + id: "fact-1", + category: "goal", + text: "You want to reach 75 kg by December", + confidence: 1, + source: "coach", + createdAt: "2026-10-06T09:00:00.000Z", + }, + ]; + const calls = await serveFacts(page, facts); + const conversation = stub(); + await serveConversation(page, conversation); + await installLiveCoachStream(page); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); + + await send(page, "I want to reach 75 kg by December."); + await answer(page, conversation, { + userId: "coach-memory-e2e-u1", + userText: "I want to reach 75 kg by December.", + id: "coach-memory-e2e-a1", + text: "That is about 3 kg from where you are now, a steady pace over ten weeks.", + metricSource: { + windows: [], + metrics: ["weight"], + memoryNote: { proposal: false, factId: "fact-1", category: "goal" }, + }, + frames: [ + { + type: "memoryNote", + note: { + proposal: false, + factId: "fact-1", + category: "goal", + fact: "You want to reach 75 kg by December", + }, + }, + ], + }); + + const note = page.locator('[data-slot="coach-memory-note"]'); + await expect(note).toHaveText( + /Remembered: You want to reach 75 kg by December\.\s*Undo/, + ); + // No card, no accent: a meta line. + await expect(note).toHaveClass(/text-muted-foreground/); + await note.locator('[data-slot="coach-memory-note-undo"]').click(); + await expect(note).toHaveCount(0); + expect( + calls.some((c) => c.method === "DELETE" && c.path.endsWith("/fact-1")), + ).toBe(true); + }); + + test("a proposed fact and a proposed plan are answered with reply pills", async ({ + page, + }) => { + await serveAiBlock(page, aiBlockAvailable()); + await serveFacts(page, []); + const conversation = stub(); + await serveConversation(page, conversation); + await installLiveCoachStream(page); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); + + // A health fact is proposed, never saved by itself. + await send(page, "I started taking ramipril in May."); + await answer(page, conversation, { + userId: "coach-memory-e2e-u1", + userText: "I started taking ramipril in May.", + id: "coach-memory-e2e-a1", + text: "Your readings since May sit a little lower. Should I remember that you take ramipril since May?", + metricSource: { + windows: [], + metrics: ["bp"], + memoryNote: { + proposal: true, + proposalId: "p1", + category: "medication", + }, + }, + frames: [ + { + type: "memoryNote", + note: { + proposal: true, + proposalId: "p1", + category: "medication", + fact: "Takes ramipril since May", + }, + }, + ], + }); + await expect(page.locator('[data-slot="coach-memory-note"]')).toHaveCount( + 0, + ); + const pills = page.locator('[data-slot="coach-suggested-replies"]'); + await expect(pills.locator("[data-reply-id]")).toHaveText([ + "Yes, remember it", + "No", + ]); + await pills.locator('[data-reply-id="memory-accept"]').click(); + await expect( + page + .locator('[data-slot="coach-bubble-user"]') + .filter({ hasText: "Yes, remember it" }), + ).toBeVisible(); + expect((await posts(page))[1]).toMatchObject({ + message: "Yes, remember it", + memoryDecision: { + messageId: "coach-memory-e2e-a1", + proposalId: "p1", + accept: true, + }, + }); + await answer(page, conversation, { + userId: "coach-memory-e2e-u2", + userText: "Yes, remember it", + id: "coach-memory-e2e-a2", + text: "Got it, I'll remember.", + providerType: "decision", + metricSource: { windows: [], metrics: [] }, + frames: [], + }); + await expect(pills).toHaveCount(0); + + // A plan is one sentence in the answer, then two pills. No card. + await send(page, "How do I stop snacking late?"); + await answer(page, conversation, { + userId: "coach-memory-e2e-u3", + userText: "How do I stop snacking late?", + id: "coach-memory-e2e-a3", + text: "When you finish dinner, then brush your teeth right away. I'll check in with you in 14 days.", + metricSource: { + windows: [], + metrics: [], + planProposal: { planId: "plan-1", metric: "WEIGHT", reviewInDays: 14 }, + }, + frames: [ + { + type: "planProposal", + proposal: { + planId: "plan-1", + metric: "WEIGHT", + reviewInDays: 14, + ifCue: "When you finish dinner", + thenAction: "brush your teeth right away", + }, + }, + ], + }); + await expect( + page.locator('[data-slot="coach-plan-proposal-card"]'), + ).toHaveCount(0); + await expect(pills.locator("[data-reply-id]")).toHaveText([ + "Take on this plan", + "Not now", + ]); + await pills.locator('[data-reply-id="plan-decline"]').click(); + expect((await posts(page))[3]).toMatchObject({ + message: "Not now", + planDecision: { + messageId: "coach-memory-e2e-a3", + planId: "plan-1", + accept: false, + }, + }); + await end(page); + }); + + test("an entry in the memory list is edited in place", async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + const calls = await serveFacts(page, [ + { + id: "fact-1", + category: "goal", + text: "Wants to reach 80 kg by autumn", + confidence: 1, + source: "user", + createdAt: "2026-10-01T09:00:00.000Z", + }, + { + id: "fact-2", + category: "preference", + text: "Prefers morning workouts", + confidence: 0.8, + source: "coach", + createdAt: "2026-10-02T09:00:00.000Z", + }, + ]); + await page.goto("/settings/coach#coach-memory", { + waitUntil: "domcontentloaded", + }); + const card = page.locator("#coach-memory"); + const rows = card.locator('[data-testid="settings-coach-memory-fact"]'); + await expect(rows).toHaveCount(2); + await expect(rows.nth(0)).toContainText("from you"); + await expect(rows.nth(1)).toContainText("from the Coach"); + + await rows + .nth(0) + .locator('[data-slot="settings-coach-memory-edit"]') + .click(); + const input = rows + .nth(0) + .locator('[data-slot="settings-coach-memory-edit-input"]'); + await input.fill("Wants to reach 78 kg by December"); + await rows + .nth(0) + .locator('[data-slot="settings-coach-memory-edit-save"]') + .click(); + await expect(rows.nth(0)).toContainText("Wants to reach 78 kg by December"); + expect( + calls.find((c) => c.method === "PATCH" && c.path.endsWith("/fact-1")) + ?.body, + ).toEqual({ fact: "Wants to reach 78 kg by December" }); + }); + + for (const scenario of [ + { + name: "capped at Medium", + block: { + level: "medium", + preference: "medium", + maxLevel: "medium", + available: true, + offIsReal: true, + source: "admin_cap", + }, + }, + { + name: "switched off by the operator", + block: { + level: "off", + preference: "medium", + maxLevel: "off", + available: false, + offIsReal: true, + source: "admin_off", + }, + }, + ]) { + test(`thinking depth in the quick settings, ${scenario.name}`, async ({ + page, + }) => { + await page.route("**/api/auth/me", async (route) => { + const response = await route.fetch(); + const body = (await response.json()) as { + data: Record | null; + }; + if (body.data) { + body.data.ai = aiBlockAvailable(); + body.data.coachReasoning = scenario.block; + } + await route.fulfill({ response, json: body }); + }); + await serveFacts(page, []); + await serveConversation(page, stub()); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); + await page.locator('[data-slot="coach-settings"]').first().click(); + const select = page.locator('[data-slot="coach-reasoning-select"]'); + await expect(select).toBeVisible(); + await expect(select.locator("option")).toHaveText([ + "Off", + "Low", + "Medium", + scenario.block.available ? "High (limited by the admin)" : "High", + ]); + const field = page.locator('[data-slot="coach-reasoning-field"]'); + if (scenario.block.available) { + await expect(select).toBeEnabled(); + await expect(select.locator('option[value="high"]')).toBeDisabled(); + await expect(field).toContainText("a little more waiting"); + } else { + await expect(select).toBeDisabled(); + await expect(field).toContainText( + "The admin has turned deeper thinking off.", + ); + } + // The memory link names how much the Coach keeps. + await expect( + page.locator('[data-slot="coach-settings-memory-link"]'), + ).toHaveText("Memory (0)"); + }); + } +}); diff --git a/e2e/setup/coach-live-stream.ts b/e2e/setup/coach-live-stream.ts new file mode 100644 index 000000000..e82e97954 --- /dev/null +++ b/e2e/setup/coach-live-stream.ts @@ -0,0 +1,220 @@ +import type { Page, Request, Route } from "@playwright/test"; + +/** + * A Coach chat stream the spec drives frame by frame. + * + * `route.fulfill` hands the browser the whole event stream at once, so the + * live states of a turn (the status line moving from "Thinking…" to + * "Fetching…" to "Summarising…", an interim table before the first token) + * would flash by unobserved. This replaces `fetch` in the page for the chat + * POST only: the response body is a stream the spec feeds with + * `feed(page, frames)` and closes with `end(page)`. Every other request goes + * through untouched, so `page.route` mocks keep working. + * + * The POST bodies are recorded in the page (`posts(page)`). + */ +export async function installLiveCoachStream(page: Page): Promise { + await page.addInitScript(() => { + const w = window as unknown as { + __coachPosts: unknown[]; + __coachFeed: (frames: unknown[]) => void; + __coachEnd: () => void; + }; + const original = window.fetch.bind(window); + let controller: ReadableStreamDefaultController | null = null; + const encoder = new TextEncoder(); + w.__coachPosts = []; + w.__coachFeed = (frames) => { + for (const frame of frames) { + controller?.enqueue( + encoder.encode(`data: ${JSON.stringify(frame)}\n\n`), + ); + } + }; + w.__coachEnd = () => { + controller?.close(); + controller = null; + }; + window.fetch = async (input, init) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const method = ( + init?.method ?? (input instanceof Request ? input.method : "GET") + ).toUpperCase(); + if ( + method === "POST" && + new URL(url, window.location.href).pathname === "/api/insights/chat" + ) { + w.__coachPosts.push(JSON.parse(String(init?.body ?? "{}"))); + const body = new ReadableStream({ + start(c) { + controller = c; + }, + }); + init?.signal?.addEventListener("abort", () => { + try { + controller?.error(new DOMException("aborted", "AbortError")); + } catch { + // already closed + } + }); + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + return original(input, init); + }; + }); +} + +export async function feed(page: Page, frames: unknown[]): Promise { + await page.evaluate((f) => { + (window as unknown as { __coachFeed: (x: unknown[]) => void }).__coachFeed( + f, + ); + }, frames); +} + +export async function end(page: Page): Promise { + await page.evaluate(() => { + (window as unknown as { __coachEnd: () => void }).__coachEnd(); + }); +} + +export async function posts( + page: Page, +): Promise>> { + return page.evaluate( + () => + (window as unknown as { __coachPosts: Array> }) + .__coachPosts, + ); +} + +export function tokens(text: string) { + return text.split(/(?<= )/).map((token) => ({ type: "token", token })); +} + +function fulfilJson(route: Route, data: unknown) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data, error: null }), + }); +} + +export interface StoredMessage { + id: string; + role: "user" | "assistant"; + content: string; + metricSource?: Record | null; + providerType?: string | null; +} + +/** + * The conversation as the stubbed server holds it. The spec pushes the + * persisted turn before it sends `done`, so the refetch after `done` finds + * the copy that replaces the streamed reply. + */ +export interface StubConversation { + id: string; + title: string; + messages: StoredMessage[]; + /** `…/messages/{id}/results`, by message id. */ + results: Record; + /** `…/messages/{id}/trail`, by message id. */ + trails: Record; +} + +export async function serveConversation( + page: Page, + conversation: StubConversation, +): Promise { + const at = (index: number) => + new Date(Date.UTC(2026, 9, 6, 9, 0, index)).toISOString(); + const summary = () => ({ + id: conversation.id, + title: conversation.title, + createdAt: at(0), + updatedAt: at(conversation.messages.length), + messageCount: conversation.messages.length, + fenced: false, + attachments: [], + documentTitle: null, + }); + await page.route( + /\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/, + async (route: Route, request: Request) => { + const url = new URL(request.url()); + const base = `/api/insights/chat/${conversation.id}`; + const message = url.pathname.match( + /\/messages\/([^/]+)\/(results|trail)$/, + ); + if (message) { + const [, id, kind] = message; + return kind === "results" + ? fulfilJson(route, { results: conversation.results[id] ?? [] }) + : fulfilJson(route, { + trail: conversation.trails[id] ?? null, + ai: { available: true, reason: null }, + }); + } + if (url.pathname === base) { + return fulfilJson(route, { + ...summary(), + attachmentCount: 0, + summary: null, + messages: conversation.messages.map((m, index) => ({ + id: m.id, + role: m.role, + content: m.content, + createdAt: at(index + 1), + metricSource: m.metricSource ?? null, + providerType: + m.role === "assistant" ? (m.providerType ?? "mock") : null, + promptVersion: null, + tokensUsed: m.role === "assistant" ? 420 : null, + model: m.role === "assistant" ? "mock" : null, + })), + }); + } + if (url.pathname === "/api/insights/chat" && request.method() === "GET") { + return fulfilJson(route, { + conversations: conversation.messages.length > 0 ? [summary()] : [], + nextCursor: null, + }); + } + return route.fallback(); + }, + ); + 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: [] }), + ); +} + +export function done(conversationId: string, messageId: string) { + return { + type: "done", + conversationId, + messageId, + usage: { totalTokens: 420 }, + }; +} + +export async function applyTheme(page: Page, theme: "light" | "dark") { + await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" }); + await page.addInitScript((value: string) => { + window.localStorage.setItem("healthlog-theme", value); + }, theme); +} + +/** Midday, so no "today / yesterday" boundary moves a label. */ +export const FIXED_NOW = new Date("2026-10-06T12:00:00+02:00");