test(e2e): drive the Coach stream frame by frame for the trail, pills and memory

The chat POST is answered from a stream the spec feeds, so each live
state is observed: the status line moving from thinking to fetching to
summarising, a table previewed before the first token, the trail closed
in every state and after a reload, at 1440 and 390 px in both themes.
Clarifying choices, memory and plan decisions answer through pills, an
entry in the memory list is edited in place, and the thinking depth
shows the operator's cap and lock. The dialog spec reads the pills
where it read the card.
This commit is contained in:
Marc-André Bombeck committed 2026-10-06 20:47:28 +02:00
1 parent c95dcb34db
commit 7b36b09f1f
5 files changed
+1426 -15

No files matched your search

+488
View File
@@ -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<string, unknown> = {},
) {
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);
});
}
}
});
+261
View File
@@ -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<string, unknown>).choiceId,
).toBeUndefined();
await end(page);
});
});
+21 -15
View File
@@ -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"]'),
+436
View File
@@ -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<string, unknown>;
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<string, unknown> | 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)");
});
}
});
+220
View File
@@ -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<void> {
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<Uint8Array> | 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<Uint8Array>({
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<void> {
await page.evaluate((f) => {
(window as unknown as { __coachFeed: (x: unknown[]) => void }).__coachFeed(
f,
);
}, frames);
}
export async function end(page: Page): Promise<void> {
await page.evaluate(() => {
(window as unknown as { __coachEnd: () => void }).__coachEnd();
});
}
export async function posts(
page: Page,
): Promise<Array<Record<string, unknown>>> {
return page.evaluate(
() =>
(window as unknown as { __coachPosts: Array<Record<string, unknown>> })
.__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<string, unknown> | 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<string, unknown[]>;
/** `…/messages/{id}/trail`, by message id. */
trails: Record<string, unknown>;
}
export async function serveConversation(
page: Page,
conversation: StubConversation,
): Promise<void> {
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");