Files
HealthLog/e2e/coach-guided-questions.spec.ts
Marc-André Bombeck b7830c3a72 test(e2e): cover Insights and the Coach memory without AI
Adds the AI-optional journeys: the overview with AI switched off by the
operator and with no provider (no AI requests, no refusals, one setup
hint, remembered dismissal), and on an account of its own, withdrawing AI
consent deletes the stored briefing, and a hidden Coach's conversation is
read and deleted from Settings, AI.

Specs that drive AI surfaces against mocked routes now serve an available
capability map through /api/auth/me, since the web no longer assumes AI
is on until told otherwise.
2026-09-24 11:36:30 +02:00

263 lines
9.6 KiB
TypeScript

import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
/**
* Guided clarifying-questions flow on the full-page Coach route
* (v1.16.5 — V2 of the v1.16.0 composer chips).
*
* Contracts under test:
*
* 1. With pending self-context questions, the entry card
* (`data-slot="coach-guided-offer"`) renders above the composer
* and offers start / later / don't-ask-again.
* 2. Starting the flow mounts the first deterministic question
* bubble in the thread (`data-slot="coach-guided-question"`,
* `data-state="current"`) with the progress line and the
* skip / later / dismiss actions.
* 3. Skip advances to the next question without any server write.
* 4. "Later" mid-sequence ends the flow without a summary when
* nothing was answered; the entry card stays hidden for the
* session.
* 5. The entry-card "later" hides the offer without touching the
* thread.
*
* 6. v1.16.6 — answering a question streams a contextual Coach
* reaction (mocked SSE) BEFORE the next question appears; the
* sequence advances only once the adopt offer settles.
*
* The questions API is mocked; the answer test also mocks the chat SSE
* POST (the reaction content itself is the model's business, only the
* ordering contract is pinned here).
*/
test.describe("Coach guided clarifying questions", () => {
// AI surfaces render from the `ai` block of `/api/auth/me`, and the suite
// has no reachable provider; every capability reads available here so the
// mocked AI routes below are what the surfaces talk to.
test.beforeEach(async ({ page }) => {
await serveAiBlock(page, aiBlockAvailable());
});
test.use({ storageState: STORAGE_STATE_PATH });
const QUESTIONS = [
"Guided e2e question one?",
"Guided e2e question two?",
"Guided e2e question three?",
];
const REACTION = "Thanks for sharing — that helps me read your numbers.";
test.beforeEach(async ({ page }) => {
// Empty conversation list so the page surface paints the empty
// thread + history rail without hitting the database.
// Wide pattern on purpose: the list GET may fire with or without a
// cursor query. Neither the detail GET nor the SSE POST fires in
// this spec (no conversation is opened, no message is sent).
await page.route("**/api/insights/chat*", (route) => {
// v1.16.6 — the answer test POSTs the guided answer through the
// normal chat pipeline; serve a minimal SSE reaction. GETs keep
// the empty conversation list.
if (route.request().method() === "POST") {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body:
`data: ${JSON.stringify({ type: "token", token: REACTION })}\n\n` +
`data: ${JSON.stringify({
type: "done",
conversationId: "conv-guided-e2e",
messageId: "msg-guided-e2e",
})}\n\n`,
});
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { conversations: [], nextCursor: null },
error: null,
}),
});
});
// Detail refetch after the SSE `done` frame.
await page.route("**/api/insights/chat/conv-guided-e2e", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
id: "conv-guided-e2e",
title: "Guided e2e",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
messageCount: 2,
summary: null,
messages: [
{
id: "msg-user-e2e",
role: "user",
content: "Yes, hypertension since 2019.",
createdAt: new Date().toISOString(),
metricSource: null,
providerType: null,
promptVersion: null,
},
{
id: "msg-guided-e2e",
role: "assistant",
content: REACTION,
createdAt: new Date().toISOString(),
metricSource: null,
providerType: "mock",
promptVersion: null,
},
],
},
error: null,
}),
}),
);
await page.route("**/api/insights/coach/nudge-status*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { nudgedAt: null, unread: false },
error: null,
}),
}),
);
// Pending questions: GET serves the snapshot, DELETE (dismiss one
// or all) empties it.
await page.route("**/api/coach/about-me/questions*", (route) => {
const isDelete = route.request().method() === "DELETE";
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { questions: isDelete ? [] : QUESTIONS },
error: null,
}),
});
});
});
test("offer card starts the guided sequence; skip and later walk it", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"flow logic is viewport-independent; desktop run suffices",
);
await page.goto("/coach", { waitUntil: "domcontentloaded" });
// 1. Entry card with the three choices.
const offer = page.locator('[data-slot="coach-guided-offer"]');
await expect(offer).toBeVisible({ timeout: 10_000 });
await expect(
offer.locator('[data-slot="coach-guided-offer-start"]'),
).toBeVisible();
await expect(
offer.locator('[data-slot="coach-guided-offer-later"]'),
).toBeVisible();
await expect(
offer.locator('[data-slot="coach-guided-offer-dismiss"]'),
).toBeVisible();
// 2. Start → first question bubble (typing reveal lasts <1s).
await offer.locator('[data-slot="coach-guided-offer-start"]').click();
await expect(offer).toHaveCount(0);
const question = page.locator(
'[data-slot="coach-guided-question"][data-state="current"]',
);
await expect(question).toBeVisible({ timeout: 10_000 });
await expect(question).toContainText(QUESTIONS[0], { timeout: 10_000 });
await expect(
question.locator('[data-slot="coach-guided-progress"]'),
).toContainText("1");
// 3. Skip → second question, original numbering.
await question.locator('[data-slot="coach-guided-skip"]').click();
await expect(question).toContainText(QUESTIONS[1], { timeout: 10_000 });
await expect(
question.locator('[data-slot="coach-guided-progress"]'),
).toContainText("2");
// 4. Later mid-sequence → flow ends, nothing answered → no summary,
// no question bubble, and the offer stays hidden for the session.
await question.locator('[data-slot="coach-guided-later"]').click();
await expect(
page.locator('[data-slot="coach-guided-question"]'),
).toHaveCount(0);
await expect(
page.locator('[data-slot="coach-guided-summary"]'),
).toHaveCount(0);
await expect(offer).toHaveCount(0);
});
test("entry-card later hides the offer without starting the flow", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"flow logic is viewport-independent; desktop run suffices",
);
await page.goto("/coach", { waitUntil: "domcontentloaded" });
const offer = page.locator('[data-slot="coach-guided-offer"]');
await expect(offer).toBeVisible({ timeout: 10_000 });
await offer.locator('[data-slot="coach-guided-offer-later"]').click();
await expect(offer).toHaveCount(0);
await expect(
page.locator('[data-slot="coach-guided-question"]'),
).toHaveCount(0);
});
test("an answer streams a Coach reaction before the next question", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"flow logic is viewport-independent; desktop run suffices",
);
await page.goto("/coach", { waitUntil: "domcontentloaded" });
const offer = page.locator('[data-slot="coach-guided-offer"]');
await expect(offer).toBeVisible({ timeout: 10_000 });
await offer.locator('[data-slot="coach-guided-offer-start"]').click();
const current = page.locator(
'[data-slot="coach-guided-question"][data-state="current"]',
);
await expect(current).toContainText(QUESTIONS[0], { timeout: 10_000 });
// Answer through the composer — the message rides the normal chat
// pipeline (mocked SSE above).
const textarea = page.locator('[data-slot="coach-input-textarea"]');
await textarea.fill("Yes, hypertension since 2019.");
await page.locator('[data-slot="coach-input-send"]').click();
// Order contract: the streamed Coach reaction appears, the adopt
// offer follows, and NO next question is current yet.
await expect(page.getByText(REACTION).first()).toBeVisible({
timeout: 10_000,
});
const adopt = page.locator('[data-slot="coach-self-context-adopt"]');
await expect(adopt).toBeVisible({ timeout: 10_000 });
await expect(current).toHaveCount(0);
// Settling the offer (decline via ✕) releases the next question.
await adopt
.locator('[data-slot="coach-self-context-adopt-dismiss"]')
.click();
await expect(current).toContainText(QUESTIONS[1], { timeout: 10_000 });
await expect(
current.locator('[data-slot="coach-guided-progress"]'),
).toContainText("2");
});
});