Files
HealthLog/e2e/achievements.spec.ts
T
Marc-André Bombeck a23ee61184 test(e2e): route cleanup happens by construction, not per spec
A spec that arms `page.route(...)` in a `beforeEach` and never drops the
handler races its own teardown. The context closes, an async handler is
still awaiting, its next call rejects with "Test ended.", and Playwright
attributes that to the test that had just passed. With `retries: 2` plus
`failOnFlakyTests`, which spec bites is a matter of timing.

`e2e/setup/test.ts` re-exports Playwright's `test` extended with an auto
fixture that unroutes every page and the context after each test, and
clears the page event listeners a spec subscribes to; `expect` rides
along so a spec needs one import. Auto means a spec author cannot forget
it and does not have to know it exists. Every spec now takes `test` from
there, and `e2e-route-cleanup-guard.test.ts` keeps the import boundary
closed with an allowlist that carries its reason.

The per-file `afterEach` on the cycle spec goes away — leaving both would
read as not trusting the fixture.

Not covered: a page inside a context a spec builds itself. Only
`share-documents.spec.ts` does that, and it owns those contexts.
2026-07-25 23:02:03 +02:00

229 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.15 phase-B4 — /achievements navigation smoke.
*
* The page itself has unit-test coverage for the locked-vs-unlocked
* card render; this spec answers the higher-level "can the user reach
* the page from the global nav?" question. We mock
* `/api/gamification/achievements` so the assertion does not depend on
* the seed user actually having any achievement data — the routing +
* sidebar wiring is what matters here, not the data freshness.
*
* v1.4.18 — added a second describe-block exercising the four card
* states (unlocked / locked-earnable / hidden-locked / hidden-unlocked)
* and asserting that hidden cards never leak their real strings to the
* DOM.
*/
test.describe("achievements page is reachable from the sidebar", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
await page.route("**/api/gamification/achievements", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
summary: {
unlockedCount: 0,
totalCount: 38,
earnedPoints: 0,
totalPoints: 9999,
completionPercent: 0,
nextAchievement: null,
},
achievements: [
{
id: "intake-total-1",
metric: "totalTakenIntakes",
category: "medication",
titleKey: "achievements.badges.intakeTotal1.title",
descriptionKey: "achievements.badges.intakeTotal1.description",
icon: "Pill",
format: "count",
target: 1,
current: 0,
points: 8,
unlocked: false,
progressPercent: 0,
completedAt: null,
},
],
metrics: {},
},
error: null,
}),
}),
);
});
test("desktop sidebar exposes the achievements link", async ({
page,
viewport,
}) => {
// The sidebar nav is hidden on small viewports (`md:flex`). The
// chromium-mobile profile (Pixel 5, 393 px wide) cannot satisfy
// this contract by design — skip there so the spec only runs in
// the chromium-desktop project (1280×720). Without this guard
// every push fails 1 / 4 specs purely because the same suite is
// sharded across both projects.
test.skip(
(viewport?.width ?? 0) < 768,
"sidebar is hidden on mobile by design (md:flex breakpoint)",
);
await page.goto("/", { waitUntil: "domcontentloaded" });
// Match by aria-label so we don't accidentally pick up a tooltip.
const sidebar = page.locator('aside[aria-label="Sidebar"]');
await expect(sidebar).toBeVisible();
await sidebar.getByRole("link", { name: "Achievements" }).click();
await expect(page).toHaveURL(/\/achievements\b/);
// Page title from the loaded route. The unit test guards the
// grouped + locked-card render shape; here we only assert the route
// mounted and rendered the heading.
await expect(
page.getByRole("heading", { name: "Achievements" }),
).toBeVisible({ timeout: 5000 });
});
});
test.describe("achievements page renders all four card states", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
await page.route("**/api/gamification/achievements", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
summary: {
unlockedCount: 2,
totalCount: 4,
earnedPoints: 43,
totalPoints: 200,
completionPercent: 50,
nextAchievement: null,
},
achievements: [
// Unlocked public
{
id: "intake-total-1",
metric: "totalTakenIntakes",
category: "medication",
titleKey: "achievements.badges.intakeTotal1.title",
descriptionKey: "achievements.badges.intakeTotal1.description",
icon: "Pill",
format: "count",
target: 1,
current: 5,
points: 8,
unlocked: true,
progressPercent: 100,
completedAt: "2026-04-15T12:00:00.000Z",
isHidden: false,
},
// Locked-earnable public (mood streak — user has mood data)
{
id: "mood-streak-7",
metric: "moodDayStreak",
category: "mood",
titleKey: "achievements.badges.moodStreak7.title",
descriptionKey: "achievements.badges.moodStreak7.description",
icon: "Smile",
format: "days",
target: 7,
current: 3,
points: 50,
unlocked: false,
progressPercent: 42,
completedAt: null,
isHidden: false,
},
// Hidden + locked → opaque placeholder
{
id: "hidden-night-owl",
metric: "nightOwlCount",
category: "hidden",
titleKey: "achievements.badges.hiddenNightOwl.title",
descriptionKey:
"achievements.badges.hiddenNightOwl.description",
icon: "Moon",
format: "count",
target: 1,
current: 0,
points: 25,
unlocked: false,
progressPercent: 0,
completedAt: null,
isHidden: true,
},
// Hidden + unlocked → real strings revealed
{
id: "hidden-doctor-pdf",
metric: "doctorPdfCount",
category: "hidden",
titleKey: "achievements.badges.hiddenDoctorPdf.title",
descriptionKey:
"achievements.badges.hiddenDoctorPdf.description",
icon: "FileText",
format: "count",
target: 1,
current: 1,
points: 35,
unlocked: true,
progressPercent: 100,
completedAt: "2026-04-30T10:00:00.000Z",
isHidden: true,
},
],
metrics: {},
},
error: null,
}),
}),
);
});
test("renders unlocked, locked-earnable, hidden-locked and hidden-unlocked cards", async ({
page,
}) => {
await page.goto("/achievements", { waitUntil: "domcontentloaded" });
// Page heading present
await expect(
page.getByRole("main").getByRole("heading", {
name: "Achievements",
level: 1,
exact: true,
}),
).toBeVisible({ timeout: 5000 });
// Unlocked public — title visible
await expect(page.getByText("First intake")).toBeVisible();
// Locked-earnable public — title visible AND criterion hint
// visible (3 / 7 days for mood-streak-7).
await expect(page.getByText("Mood diarist 7d")).toBeVisible();
// Hidden-locked — opaque placeholder visible. The real title /
// description must NOT appear in the rendered HTML.
await expect(page.getByText("Hidden achievement").first()).toBeVisible();
const html = await page.content();
expect(html).not.toContain("Night owl");
expect(html).not.toContain("between 02:00");
expect(html).not.toContain("nightOwlCount");
// Hidden-unlocked — title revealed once earned
await expect(page.getByText("House call")).toBeVisible();
// The opaque hidden card carries the data-slot for E2E hooks
await expect(
page.locator('[data-slot="achievement-card-hidden"]'),
).toBeVisible();
});
});