fix(e2e,insights): the coach specs follow the drawer, and the status cards stop tearing hydration

The coach launcher deliberately opens the side drawer in place now —
the spec expected the old navigation and waited for a URL that never
comes; it asserts the drawer (bottom-sheet on the phone viewport) and
the nudge-dot lifecycle instead. The metric and slug status cards
rendered locale-dependent content in the hydration pass, which React
flagged as a mismatch (#418) on the dashboard and the mood page — they
ride the established mounted gate now, pinning the server branch for
the first paint. The midnight flake in the intake bucket tests is
pinned to a fixed clock: the fixtures minted UTC days while the route
buckets Berlin days, so runs between midnight and two failed.
This commit is contained in:
Marc-André Bombeck committed 2026-06-13 01:02:14 +02:00
1 parent a1511b33b3
commit 15c77597fc
9 files changed
+117 -14

No files matched your search

+8
View File
@@ -80,7 +80,15 @@ test.describe("Settings → AI provider dropdown UX (B2)", () => {
}),
);
// Hydration gate — the provider query is fired by the same client
// boundary that renders the select, so its response proves React
// has hydrated the card and attached the select's change handler.
// `selectOption` on the SSR-painted element any earlier flips the
// native value without React noticing, and the config form below
// never switches (CI failure at slow hydration).
const cardHydrated = page.waitForResponse("**/api/user/ai-provider");
await page.goto("/settings/ai", { waitUntil: "domcontentloaded" });
await cardHydrated;
// 1. Dropdown surfaces on the page.
const select = page.getByTestId("ai-active-provider-select");
+48 -8
View File
@@ -19,8 +19,9 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* bottom-sheet (`data-variant="bottom-sheet"`) on `<sm`, as a
* right-side sheet (`data-variant="side-sheet"`) on `>=sm`.
*
* 3. With an unread nudge the FAB carries the dot and clicking it
* navigates to the full-page chat at `/insights/coach`.
* 3. With an unread nudge the FAB carries the dot; clicking the FAB
* opens the Coach drawer in place (v1.16.11 — no navigation, the
* page underneath stays) and the dot clears.
*
* 4. The drawer's "Conversations" button no longer opens an in-panel
* tray; it hands off to the full-page route `/insights/coach`.
@@ -177,7 +178,15 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
"drawer bottom-sheet branch is mobile-only",
);
// Hydration gate — the status query is fired by the sub-page
// boundary itself, so its response proves React has hydrated the
// page and attached the launch icon's click handler. Clicking the
// SSR-painted icon any earlier is silently lost (CI flake).
const pageHydrated = page.waitForResponse(
/\/api\/insights\/blood-pressure-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
await pageHydrated;
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
@@ -196,7 +205,12 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
"drawer side-sheet branch is desktop-only",
);
// Hydration gate — see the bottom-sheet branch above.
const pageHydrated = page.waitForResponse(
/\/api\/insights\/blood-pressure-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
await pageHydrated;
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
@@ -207,9 +221,9 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
await expect(drawer).toHaveAttribute("data-variant", "side-sheet");
});
test("an unread Coach nudge paints the FAB dot and clicking the FAB opens the full-page chat", async ({
test("an unread Coach nudge paints the FAB dot and clicking the FAB opens the Coach drawer in place", async ({
page,
}) => {
}, testInfo) => {
// The FAB contract is viewport-independent — the test runs on
// both projects. Override the default nudge mock with an unread
// one (later registration wins).
@@ -224,7 +238,14 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
}),
);
// Hydration gate — the nudge-status query is fired by the FAB
// component itself, so its response proves the FAB's click handler
// is attached (the button paints from SSR HTML long before that).
const fabHydrated = page.waitForResponse(
/\/api\/insights\/coach\/nudge-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
await fabHydrated;
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
@@ -236,10 +257,24 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
).toBeVisible();
await fab.click();
await page.waitForURL("**/insights/coach", { timeout: 10_000 });
await expect(
page.locator('[data-slot="coach-page"]'),
).toBeVisible({ timeout: 10_000 });
// v1.16.11 — the FAB opens the side drawer in place instead of
// navigating to `/insights/coach`; the page underneath stays.
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
await expect(drawer).toHaveAttribute(
"data-variant",
testInfo.project.name === "chromium-mobile"
? "bottom-sheet"
: "side-sheet",
);
expect(new URL(page.url()).pathname).not.toContain("/insights/coach");
// Opening the Coach counts as reading the nudge on this device —
// the dot clears and the unread flag drops off the FAB.
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
0,
);
await expect(fab).not.toHaveAttribute("data-unread", "true");
});
test("the drawer's Conversations button hands off to the full-page chat on Desktop Chrome", async ({
@@ -253,7 +288,12 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
"exercises the desktop drawer layout",
);
// Hydration gate — see the bottom-sheet test above.
const pageHydrated = page.waitForResponse(
/\/api\/insights\/blood-pressure-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
await pageHydrated;
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
@@ -177,8 +177,11 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
const src = readFileSync(PAGE_PATH, "utf8");
// The placeholder count keys off the layout config minus the
// already-mounted (layout-gated) cells, and only while loading.
// `mounted &&` pins the hydration render to the SSR output — the
// auth query can settle before the page boundary hydrates, and an
// extra BMI silhouette then mismatched the server HTML (React #418).
expect(src).toMatch(
/const\s+chartRowPlaceholderCount\s*=\s*primaryLoading[\s\S]*?resolveChartRowPlaceholderCount\(layout,[\s\S]*?hasHeightCm:\s*Boolean\(user\?\.heightCm\)/,
/const\s+chartRowPlaceholderCount\s*=\s*primaryLoading[\s\S]*?resolveChartRowPlaceholderCount\(layout,[\s\S]*?hasHeightCm:\s*mounted\s*&&\s*Boolean\(user\?\.heightCm\)/,
);
expect(src).toMatch(/-\s*charts\.length/);
// The reserved cells render as aria-hidden ChartSkeletons in their
@@ -528,6 +528,25 @@ describe("v1.15.9 — schedule-anchored compliance buckets (BUG #1)", () => {
// engine's expected-dose count per day (not the count of logged intake
// rows). With partial adherence the per-day rate `taken / scheduled`
// genuinely reflects taken-of-expected — it is NOT pinned at ~100%.
//
// The clock is pinned to a fixed midday instant: the fixtures mint one
// event per UTC day, but the route buckets by the user's local
// (Europe/Berlin) day — with a live clock, a run between 00:00 and
// ~01:59 local (UTC date still "yesterday") left the local TODAY
// bucket with `scheduled = 1, taken = 0` and failed the full-adherence
// assertion. At 12:00 UTC the UTC day and the Berlin day coincide, so
// the per-UTC-day fixture math is exact. Only `Date` is faked — the
// route awaits real promises.
beforeEach(() => {
vi.useFakeTimers({
now: new Date("2026-06-10T12:00:00Z"),
toFake: ["Date"],
});
});
afterEach(() => {
vi.useRealTimers();
});
function activeDailyMed(createdAt: Date) {
return {
id: "m1",
+8 -1
View File
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { MoodForm } from "@/components/mood/mood-form";
import { MoodList } from "@/components/mood/mood-list";
@@ -17,6 +18,7 @@ import { useTranslations } from "@/lib/i18n/context";
export default function MoodPage() {
const { isAuthenticated, isLoading } = useAuth();
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
@@ -45,7 +47,12 @@ export default function MoodPage() {
}
}, [isLoading, isAuthenticated, router]);
if (isLoading) {
// `!mounted` keeps the hydration render identical to the SSR HTML: the
// auth query is fired by the early-hydrating shell and can settle before
// this page boundary hydrates, so `isLoading` alone flipped the branch
// and React logged hydration error #418 — same fix as the measurements
// page; see `useMounted`.
if (!mounted || isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
+7 -1
View File
@@ -1429,11 +1429,17 @@ export default function DashboardPage() {
// already mounted) so the page holds roughly its final
// footprint from first paint and the snapshot landing swaps
// content instead of growing the page.
// `mounted &&` pins the hydration render to the SSR output: the
// auth query is fired by the early-hydrating shell, so `user`
// can already carry a heightCm when this late-hydrating page
// boundary replays its first render — the extra BMI silhouette
// then mismatched the server HTML (React #418). The count may
// only personalise from the first client re-render.
const chartRowPlaceholderCount = primaryLoading
? Math.max(
0,
resolveChartRowPlaceholderCount(layout, {
hasHeightCm: Boolean(user?.heightCm),
hasHeightCm: mounted && Boolean(user?.heightCm),
}) - charts.length,
)
: 0;
@@ -104,7 +104,14 @@ describe("<HealthKitMetricPage> status mount", () => {
/>,
);
expect(html).toContain("Your HRV has trended upward");
// The server pass renders the card's loading skeleton even when the
// mocked query reports settled data: `<MetricStatusCard>` pins SSR +
// the hydration render to the loading branch (`!mounted`) so a query
// that settles before a late-hydrating boundary replays its first
// render cannot mismatch the server HTML (React #418). The settled
// text swaps in on the first client re-render.
expect(html).toContain('data-testid="insight-status-card-loading"');
expect(html).not.toContain("Your HRV has trended upward");
// The card fetch is enabled (statusMetric present + data present).
expect(metricStatusMock).toHaveBeenCalledWith("HEART_RATE_VARIABILITY", true);
});
@@ -3,6 +3,7 @@
import type { ReactNode } from "react";
import { useInsightMetricStatus } from "@/hooks/use-insight-status";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
@@ -35,6 +36,7 @@ export function MetricStatusCard({
enabled,
}: MetricStatusCardProps) {
const { t } = useTranslations();
const mounted = useMounted();
const { data: status, isLoading } = useInsightMetricStatus(metric, enabled);
return (
@@ -44,7 +46,11 @@ export function MetricStatusCard({
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isLoading}
// Same hydration gate as `<SlugInsightStatusCard>`: the hook is
// `enabled: isAuthenticated && enabled`, and both inputs can settle
// before a late-hydrating boundary replays its first render — the
// loading branch then disagrees with the server HTML (React #418).
loading={!mounted || isLoading}
preparing={status?.preparing ?? false}
/>
);
@@ -6,6 +6,7 @@ import {
useInsightStatus,
type InsightStatusMetric,
} from "@/hooks/use-insight-status";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
@@ -32,6 +33,7 @@ export function SlugInsightStatusCard({
icon,
}: SlugInsightStatusCardProps) {
const { t } = useTranslations();
const mounted = useMounted();
const { data: status, isLoading } = useInsightStatus(slug);
return (
@@ -41,7 +43,12 @@ export function SlugInsightStatusCard({
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isLoading}
// `!mounted ||` keeps the SSR pass and the hydration render on the
// same skeleton branch. The status query is `enabled: isAuthenticated`,
// and the auth query resolves from the early-hydrating shell — a
// late-hydrating page boundary then saw `isLoading` flip true while
// the server HTML carried the settled card (React #418).
loading={!mounted || isLoading}
preparing={status?.preparing ?? false}
/>
);