mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
One-shot prettier pass so the new format:check gate starts from a clean baseline. Formatting only — no behaviour change. Generator-owned and vendored artifacts stay untouched via .prettierignore.
255 lines
9.3 KiB
TypeScript
255 lines
9.3 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
|
|
|
/**
|
|
* 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", () => {
|
|
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");
|
|
});
|
|
});
|