mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
a1511b33b3
commit
15c77597fc
9 files changed
+117
-14
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user