release: v1.32.0 — hardening and scalability (#580)

* release: prepare v1.32.0

* test: stabilize water capture re-entry
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-22 04:14:15 +02:00
1 parent b01822be48
commit 62eb431d66
372 files changed
+25178 -8561

No files matched your search

+846 -114
View File
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -195,7 +195,11 @@ test.describe("achievements page renders all four card states", () => {
// Page heading present
await expect(
page.getByRole("heading", { name: "Achievements" }),
page.getByRole("main").getByRole("heading", {
name: "Achievements",
level: 1,
exact: true,
}),
).toBeVisible({ timeout: 5000 });
// Unlocked public — title visible
+218
View File
@@ -121,4 +121,222 @@ test.describe("Settings → AI provider dropdown UX (B2)", () => {
await expect(page.locator(`[data-chain-row="${slug}"]`)).toBeVisible();
}
});
test("provider editors submit once by Enter or click and isolate connection tests", async ({
page,
}) => {
const patchBodies: Array<Record<string, unknown>> = [];
let releasePendingPatch: () => void = () => undefined;
let holdNextPatch = false;
let testConnectionRequests = 0;
let providerState: Record<string, unknown> = {
provider: null,
model: null,
baseUrl: null,
responseTimeoutSeconds: null,
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: false,
hasOpenaiKey: false,
openaiKeyPreview: null,
};
await page.route("**/api/insights/settings", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
codexStatus: "disconnected",
codexConnectedAt: null,
hasAdminKey: false,
codexOauthConfigured: true,
privacyMode: "aggregated",
lastInsightAt: null,
},
error: null,
}),
}),
);
await page.route("**/api/user/ai-provider", async (route) => {
if (route.request().method() === "PATCH") {
const body = route.request().postDataJSON() as Record<string, unknown>;
patchBodies.push(body);
providerState = { ...providerState, ...body };
if (holdNextPatch) {
holdNextPatch = false;
await new Promise<void>((resolve) => {
releasePendingPatch = resolve;
});
}
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: providerState, error: null }),
});
});
await page.route("**/api/insights/provider-chain", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
activeProvider: "codex",
cachedActiveProvider: null,
configuredChain: [
{ providerType: "codex", available: true },
{ providerType: "openai", available: true },
{ providerType: "anthropic", available: true },
{ providerType: "local", available: true },
],
},
error: null,
}),
}),
);
await page.route("**/api/ai/test", (route) => {
testConnectionRequests += 1;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { ok: true, providerType: "local", model: "test-model" },
error: null,
}),
});
});
const providerHydrated = page.waitForResponse("**/api/user/ai-provider");
await page.goto("/settings/ai?provider=openai", {
waitUntil: "domcontentloaded",
});
await providerHydrated;
const select = page.getByTestId("ai-active-provider-select");
async function assertEnterAndClickSubmitOnce({
provider,
formTestId,
inputId,
enterValue,
clickValue,
expectedField,
}: {
provider: "openai" | "anthropic" | "local";
formTestId: string;
inputId: string;
enterValue: string;
clickValue: string;
expectedField: string;
}) {
await select.selectOption(provider);
const form = page.getByTestId(formTestId);
await expect(form).toHaveJSProperty("tagName", "FORM");
const input = page.locator(`#${inputId}`);
const save = form.locator('button[type="submit"]');
await expect(save).toHaveCount(1);
await input.fill(enterValue);
const enterBaseline = patchBodies.length;
await input.press("Enter");
await expect.poll(() => patchBodies.length).toBe(enterBaseline + 1);
await page.waitForTimeout(50);
expect(patchBodies).toHaveLength(enterBaseline + 1);
expect(patchBodies.at(-1)?.[expectedField]).toBe(enterValue);
await input.fill(clickValue);
const clickBaseline = patchBodies.length;
await save.click();
await expect.poll(() => patchBodies.length).toBe(clickBaseline + 1);
await page.waitForTimeout(50);
expect(patchBodies).toHaveLength(clickBaseline + 1);
expect(patchBodies.at(-1)?.[expectedField]).toBe(clickValue);
}
await assertEnterAndClickSubmitOnce({
provider: "openai",
formTestId: "ai-provider-config-openai",
inputId: "ai-openai-key",
enterValue: "sk-enter-openai",
clickValue: "sk-click-openai",
expectedField: "openaiKey",
});
await assertEnterAndClickSubmitOnce({
provider: "anthropic",
formTestId: "ai-provider-config-anthropic",
inputId: "ai-anthropic-key",
enterValue: "sk-ant-enter",
clickValue: "sk-ant-click",
expectedField: "anthropicKey",
});
await assertEnterAndClickSubmitOnce({
provider: "local",
formTestId: "ai-provider-config-local",
inputId: "ai-local-base-url",
enterValue: "http://localhost:11434/v1",
clickValue: "http://localhost:11435/v1",
expectedField: "baseUrl",
});
const timeoutForm = page.locator("form").filter({
has: page.locator("#ai-response-timeout"),
});
const timeoutInput = page.locator("#ai-response-timeout");
const timeoutSave = timeoutForm.locator('button[type="submit"]');
await expect(timeoutForm).toHaveCount(1);
await expect(timeoutSave).toHaveCount(1);
await timeoutInput.fill("9");
const invalidTimeoutBaseline = patchBodies.length;
await timeoutInput.press("Enter");
await expect(timeoutForm.locator("p.text-destructive")).toBeVisible();
expect(patchBodies).toHaveLength(invalidTimeoutBaseline);
await timeoutInput.fill("180");
const timeoutEnterBaseline = patchBodies.length;
await timeoutInput.press("Enter");
await expect.poll(() => patchBodies.length).toBe(timeoutEnterBaseline + 1);
await page.waitForTimeout(50);
expect(patchBodies).toHaveLength(timeoutEnterBaseline + 1);
expect(patchBodies.at(-1)?.responseTimeoutSeconds).toBe(180);
await timeoutInput.fill("240");
const timeoutClickBaseline = patchBodies.length;
await timeoutSave.click();
await expect.poll(() => patchBodies.length).toBe(timeoutClickBaseline + 1);
await page.waitForTimeout(50);
expect(patchBodies).toHaveLength(timeoutClickBaseline + 1);
expect(patchBodies.at(-1)?.responseTimeoutSeconds).toBe(240);
holdNextPatch = true;
await page.locator("#ai-local-base-url").fill("http://localhost:11436/v1");
const pendingBaseline = patchBodies.length;
await page
.getByTestId("ai-provider-config-local")
.evaluate((form: HTMLFormElement) => {
form.requestSubmit();
form.requestSubmit();
});
await expect.poll(() => patchBodies.length).toBe(pendingBaseline + 1);
await expect(
page
.getByTestId("ai-provider-config-local")
.locator('button[type="submit"]'),
).toBeDisabled();
await page.waitForTimeout(50);
expect(patchBodies).toHaveLength(pendingBaseline + 1);
const testConnection = page.getByTestId("ai-test-active-provider");
await expect(testConnection).toHaveAttribute("type", "button");
await testConnection.click();
await expect.poll(() => testConnectionRequests).toBe(1);
expect(patchBodies).toHaveLength(pendingBaseline + 1);
releasePendingPatch();
await expect(
page
.getByTestId("ai-provider-config-local")
.locator('button[type="submit"]'),
).toBeEnabled();
});
});
+115
View File
@@ -0,0 +1,115 @@
import { expect, test } from "@playwright/test";
import { setTimeout as delay } from "node:timers/promises";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
const CONVERSATION_ID = "conversation-rename-e2e";
const INITIAL_TITLE = "Morning check-in";
const RENAMED_TITLE = "Weekly health review";
test.describe("Coach conversation rename", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("renames by keyboard once and survives reload on desktop and mobile", async ({
page,
}) => {
let persistedTitle = INITIAL_TITLE;
let patchCount = 0;
await page.route(
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?|$)/,
async (route) => {
const request = route.request();
const url = new URL(request.url());
if (
request.method() === "PATCH" &&
url.pathname === `/api/insights/chat/${CONVERSATION_ID}`
) {
patchCount += 1;
const payload = JSON.parse(request.postData() ?? "{}") as {
title: string;
};
persistedTitle = payload.title;
await delay(150);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { id: CONVERSATION_ID, title: persistedTitle },
error: null,
}),
});
return;
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
conversations: [
{
id: CONVERSATION_ID,
title: persistedTitle,
createdAt: "2026-07-20T10:00:00.000Z",
updatedAt: "2026-07-20T10:00:00.000Z",
messageCount: 2,
fenced: false,
attachments: [],
documentTitle: null,
},
],
nextCursor: null,
},
error: null,
}),
});
},
);
await page.goto("/coach/conversations", {
waitUntil: "domcontentloaded",
});
const item = page.locator('[data-slot="coach-conversations-item"]');
await expect(item).toContainText(INITIAL_TITLE, { timeout: 10_000 });
const renameButton = item.locator(
'[data-slot="coach-conversation-rename"]',
);
await renameButton.click();
const input = item.locator('[data-slot="coach-conversation-rename-input"]');
await expect(input).toBeFocused();
await input.fill("Discard this draft");
await input.press("Escape");
await expect(input).toHaveCount(0);
await expect(item).toContainText(INITIAL_TITLE);
expect(patchCount).toBe(0);
await renameButton.click();
const activeInput = item.locator(
'[data-slot="coach-conversation-rename-input"]',
);
await activeInput.fill(` ${RENAMED_TITLE} `);
await activeInput.evaluate((element) => {
element.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
element.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
});
await expect(activeInput).toBeDisabled();
await expect(item).toContainText(RENAMED_TITLE);
await expect(activeInput).toHaveCount(0, { timeout: 10_000 });
expect(patchCount).toBe(1);
await page.reload({ waitUntil: "domcontentloaded" });
await expect(
page.locator('[data-slot="coach-conversations-item"]'),
).toContainText(RENAMED_TITLE, { timeout: 10_000 });
expect(patchCount).toBe(1);
});
});
+7
View File
@@ -93,7 +93,14 @@ test.describe("settings AI codex device flow (mocked)", () => {
}),
);
// Hydration gate — the settings query is fired by the same client
// boundary that renders the Codex form, so its response proves React
// has attached the CTA's click handler. On mobile the SSR-painted button
// can otherwise be clicked before hydration and the event is lost.
const codexFormHydrated = page.waitForResponse("**/api/insights/settings");
await page.goto("/settings/ai", { waitUntil: "domcontentloaded" });
await codexFormHydrated;
// Step 1 — CTA visible
const cta = page.getByRole("button", {
+12 -5
View File
@@ -283,11 +283,18 @@ test.describe("document vault — AI assist + content search", () => {
await expect(hint).toBeVisible();
const indexAll = page.locator('[data-slot="content-index-all"]');
await expect(indexAll).toBeVisible();
await indexAll.click();
await expect(
page.getByText("Indexing 5 document(s) in the background."),
).toBeVisible();
const reindexRequest = page.waitForRequest(
(request) =>
request.method() === "POST" &&
new URL(request.url()).pathname === "/api/documents/inbound/reindex",
);
await Promise.all([
reindexRequest,
expect(
page.getByText("Indexing 5 document(s) in the background."),
).toBeVisible(),
indexAll.click(),
]);
expect(reindexCalls).toBe(1);
});
+158 -1
View File
@@ -99,6 +99,42 @@ async function runAxe(page: Page) {
return blocking;
}
async function mockDocumentCoach(page: Page): Promise<void> {
const fulfil = (
route: Parameters<Parameters<Page["route"]>[1]>[0],
data: unknown,
) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data, error: null }),
});
await page.route("**/api/documents/inbound/usage", (route) =>
fulfil(route, {
usedBytes: 4096,
quotaBytes: 1_073_741_824,
maxFileBytes: DEFAULT_MAX_FILE_BYTES,
acceptedExtensions: [".pdf", ".png", ".jpg", ".jpeg", ".webp"],
linkedEpisodes: [],
assistAvailable: true,
contentIndex: { enabled: true, indexedCount: 1, totalCount: 1 },
}),
);
await page.route("**/api/documents/inbound/capability", (route) =>
fulfil(route, {
available: true,
mode: "vision",
reason: null,
pdfSupported: true,
egress: "external",
}),
);
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
fulfil(route, { documentsAutoAiRead: false }),
);
}
test.describe("document vault", () => {
test.use({ storageState: STORAGE_STATE_PATH });
@@ -191,6 +227,121 @@ test.describe("document vault", () => {
await expect(page).not.toHaveURL(/doc=/);
});
test("ordinary card selection owns doc history and preserves unrelated query state", async ({
page,
}) => {
await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`);
await expect(openButton(page, "MRT Knie")).toBeVisible();
await openButton(page, "MRT Knie").click();
const detail = page.getByRole("dialog", { name: "MRT Knie" });
await expect
.poll(() => new URL(page.url()).searchParams.get("doc"))
.toBe(MRT_DOC_ID);
expect(new URL(page.url()).searchParams.get("episode")).toBe(
KNIE_EPISODE_ID,
);
expect(new URL(page.url()).searchParams.get("view")).toBe("compact");
expect(new URL(page.url()).searchParams.get("q")).toBe("MRT");
await page.goBack();
await expect(detail).not.toBeVisible();
expect(new URL(page.url()).searchParams.get("doc")).toBeNull();
expect(new URL(page.url()).searchParams.get("view")).toBe("compact");
await page.goForward();
await expect(detail).toBeVisible();
await page.keyboard.press("Escape");
await expect(detail).not.toBeVisible();
await expect
.poll(() => new URL(page.url()).searchParams.get("doc"))
.toBeNull();
expect(new URL(page.url()).searchParams.get("episode")).toBe(
KNIE_EPISODE_ID,
);
expect(new URL(page.url()).searchParams.get("view")).toBe("compact");
expect(new URL(page.url()).searchParams.get("q")).toBe("MRT");
});
test("Coach close and overlay dismissal consume the document handoff", async ({
page,
}) => {
await mockDocumentCoach(page);
await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`);
await expect(openButton(page, "MRT Knie")).toBeVisible();
await openButton(page, "MRT Knie").click();
await expect
.poll(() => new URL(page.url()).searchParams.get("doc"))
.toBe(MRT_DOC_ID);
const detail = page.getByRole("dialog", { name: "MRT Knie" });
await detail.locator('[data-slot="document-chat-open"]').click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible();
await expect(detail).not.toBeVisible();
await drawer.locator('[data-slot="coach-drawer-close"]').click();
await expect(drawer).not.toBeVisible();
await expect
.poll(() => new URL(page.url()).searchParams.get("doc"))
.toBeNull();
expect(new URL(page.url()).searchParams.get("episode")).toBe(
KNIE_EPISODE_ID,
);
expect(new URL(page.url()).searchParams.get("view")).toBe("compact");
expect(new URL(page.url()).searchParams.get("q")).toBe("MRT");
await expect(detail).not.toBeVisible();
await page.reload();
await expect(detail).not.toBeVisible();
expect(new URL(page.url()).searchParams.get("doc")).toBeNull();
await expect(openButton(page, "MRT Knie")).toBeVisible();
await openButton(page, "MRT Knie").click();
await expect(detail).toBeVisible();
await detail.locator('[data-slot="document-chat-open"]').click();
await expect(drawer).toBeVisible();
await page
.locator('[data-slot="sheet-overlay"]')
.last()
.click({ position: { x: 2, y: 2 } });
await expect(drawer).not.toBeVisible();
await expect
.poll(() => new URL(page.url()).searchParams.get("doc"))
.toBeNull();
await expect(detail).not.toBeVisible();
});
test("Ask Coach maximize then browser back restores the selected document", async ({
page,
}) => {
await mockDocumentCoach(page);
await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`);
await expect(openButton(page, "MRT Knie")).toBeVisible();
await openButton(page, "MRT Knie").click();
await expect
.poll(() => new URL(page.url()).searchParams.get("doc"))
.toBe(MRT_DOC_ID);
const detail = page.getByRole("dialog", { name: "MRT Knie" });
await detail.locator('[data-slot="document-chat-open"]').click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible();
await drawer.locator('[data-slot="coach-drawer-maximize"]').click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/coach");
expect(new URL(page.url()).searchParams.get("doc")).toBe(MRT_DOC_ID);
await page.goBack();
await expect.poll(() => new URL(page.url()).pathname).toBe("/documents");
await expect(detail).toBeVisible();
const restoredUrl = new URL(page.url());
expect(restoredUrl.searchParams.get("doc")).toBe(MRT_DOC_ID);
expect(restoredUrl.searchParams.get("episode")).toBe(KNIE_EPISODE_ID);
expect(restoredUrl.searchParams.get("view")).toBe("compact");
expect(restoredUrl.searchParams.get("q")).toBe("MRT");
});
test("the condition page links into the vault and back", async ({ page }) => {
await page.goto(`/illness/${KNIE_EPISODE_ID}`);
const card = page.locator('[data-slot="episode-documents-card"]');
@@ -210,10 +361,16 @@ test.describe("document vault", () => {
await page.goto("/documents");
await uploadViaPicker(page, `${title}.pdf`, uniquePdf(title));
await page.getByRole("searchbox", { name: "Search documents" }).fill(title);
// The uploaded card is optimistic and can paint before the 200 ms search
// draft is committed to the URL. Opening it earlier races that pending
// replace against the detail's `?doc=` history entry and closes the sheet.
await expect
.poll(() => new URL(page.url()).searchParams.get("q"))
.toBe(title);
await expect(openButton(page, `${title}.pdf`)).toBeVisible();
await openButton(page, `${title}.pdf`).click();
const sheet = page.getByRole("dialog");
const sheet = page.getByRole("dialog", { name: `${title}.pdf` });
await expect(sheet).toBeVisible();
await sheet.getByRole("button", { name: "Delete" }).click();
+26 -9
View File
@@ -29,6 +29,12 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
}) => {
await page.goto("/insights", { waitUntil: "domcontentloaded" });
// A visible server-rendered link is not yet an interactive React link.
// Wait for this client component's hydration boundary before scrolling or
// clicking so the pointer gesture cannot race React replacing the anchor.
const tabStrip = page.locator("[data-slot='insights-tab-strip']");
await expect(tabStrip).toHaveAttribute("data-hydrated", "true");
// v1.25.1 — the app-wide scroll-to-top resets the `#main-content`
// scroll container on every route change (auth-shell), with a
// `window.scrollTo(0,0)` fallback for the body-scrolled shells. The
@@ -53,18 +59,24 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
window.scrollTo({ top: 600, behavior: "auto" });
}
});
await expect.poll(readScrollTop).toBeGreaterThan(100);
// Click the first available sub-page pill.
const subPagePill = page
.locator("[data-slot='insights-tab-strip-pill']")
.nth(1);
// Recovery is always present and remains a direct link across responsive
// pill grouping. Target its route contract instead of a mutable list index.
const subPagePill = tabStrip.locator(
"[data-slot='insights-tab-strip-pill'][href='/insights/recovery']",
);
await expect(subPagePill).toBeVisible();
await subPagePill.click();
await page.waitForURL(/\/insights\//);
await expect(page).toHaveURL(/\/insights\/recovery\/?$/);
// Back to the mother page via the overview pill (pill index 0).
await page.locator("[data-slot='insights-tab-strip-pill']").first().click();
await page.waitForURL(/\/insights\/?$/);
// Back to the mother page through the overview pill.
const overviewPill = tabStrip.locator(
"[data-slot='insights-tab-strip-pill'][href='/insights']",
);
await expect(overviewPill).toBeVisible();
await overviewPill.click();
await expect(page).toHaveURL(/\/insights\/?$/);
// The shell's route-change effect resets the scroll on the next tick;
// `useScrollResetOnRoute` defers to `requestAnimationFrame`. Wait for it
@@ -91,6 +103,12 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
}) => {
await page.goto("/insights", { waitUntil: "domcontentloaded" });
const nav = page.locator("[data-slot='insights-tab-strip']");
const innerScroller = nav.locator(
"[data-slot='insights-tab-strip-scroller']",
);
await expect(nav).toHaveAttribute("data-hydrated", "true");
await expect(innerScroller).toBeVisible();
const navOverflowX = await nav.evaluate(
(el) => getComputedStyle(el).overflowX,
);
@@ -100,7 +118,6 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
expect(navOverflowX).not.toBe("auto");
expect(navOverflowX).not.toBe("scroll");
const innerScroller = nav.locator("> div > div").first();
const innerOverflowX = await innerScroller.evaluate(
(el) => getComputedStyle(el).overflowX,
);
+1 -3
View File
@@ -137,9 +137,7 @@ export async function openCreateWizard(page: Page): Promise<void> {
{
name: "healthlog-locale",
value: "de",
url: page.url().startsWith("http")
? new URL(page.url()).origin
: "http://localhost:3000",
url: new URL(process.env.E2E_BASE_URL ?? "http://localhost:3000").origin,
},
]);
await page.goto("/medications?new=1", { waitUntil: "load" });
+1 -1
View File
@@ -206,7 +206,7 @@ test.describe("mobile-viewport smoke", () => {
// 1) The center capture action opens the capture picker.
await page.getByTestId("bottom-nav-capture").click();
await expect(page.getByTestId("capture-picker-options")).toBeVisible();
for (const kind of ["measurement", "medication", "mood"]) {
for (const kind of ["measurement", "medication", "mood", "water"]) {
await expect(page.getByTestId(`capture-picker-${kind}`)).toBeVisible();
}
// Dismiss the picker before opening the hub.
+62
View File
@@ -0,0 +1,62 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
test.describe("mood timestamp capture", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("keeps Time visible while details are collapsed and submits the selected instant", async ({
page,
}) => {
let submitted: Record<string, unknown> | null = null;
await page.route("**/api/mood-entries*", async (route) => {
if (route.request().method() === "POST") {
submitted = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ data: { id: "created-mood" }, error: null }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], meta: { total: 0, limit: 50, offset: 0 } },
error: null,
}),
});
});
await page.goto("/mood", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "Add", exact: true }).click();
await page.getByRole("radio", { name: "Amazing" }).click();
const details = page.getByRole("button", { name: "Note & details" });
await expect(details).toHaveAttribute("aria-expanded", "false");
const timestamp = page.locator('[data-slot="date-time-field"]');
await expect(timestamp).toBeVisible();
const dateInput = timestamp.locator(
'[data-slot="date-field"] input[type="text"]',
);
const timeInput = timestamp.locator(
'[data-slot="time-field"] input[type="text"]',
);
await dateInput.fill("2026-01-15");
await dateInput.blur();
await timeInput.fill("09:45");
await timeInput.blur();
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect.poll(() => submitted).not.toBeNull();
expect(submitted).toMatchObject({
mood: "SUPER_GUT",
moodLoggedAt: "2026-01-15T08:45:00.000Z",
});
});
});
+41
View File
@@ -198,3 +198,44 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
}
});
});
test.describe("Apple Health guidance", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("opens on Integrations and reaches the one-shot import fallback", async ({
page,
}) => {
await page.route("**/api/integrations/healthkit", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], lastSyncedAt: null },
error: null,
}),
}),
);
await page.goto("/settings/integrations");
const card = page.getByTestId("apple-health-card");
await expect(card).toBeVisible();
await expect(card).toContainText("HealthLog iOS app");
await expect(card).toContainText("not a web or OAuth connection");
await expect(page.getByTestId("apple-health-status")).toHaveAttribute(
"data-state",
"setup",
);
const importLink = page.getByTestId("apple-health-import-link");
await expect(importLink).toHaveAttribute(
"href",
"/settings/export#settings-section-import-title",
);
await page.goto("/settings/export#settings-section-import-title");
await expect(page).toHaveURL(
/\/settings\/export#settings-section-import-title$/,
);
await expect(page.getByTestId("import-card-apple-health")).toBeVisible();
});
});
+21 -3
View File
@@ -97,6 +97,8 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// that tries to click a header / sidebar / quick-add button times
// out (50+ failed CI runs since v1.4.13). Specs that need the
// tour can opt back in by mocking `/api/auth/me`.
// Nutrients is intentionally opt-in in production. The shared fixture
// enables it because the water-capture specs exercise that real gate.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role,
@@ -105,14 +107,16 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
codex_connection_status,
insights_privacy_mode,
onboarding_completed_at,
onboarding_tour_completed)
onboarding_tour_completed,
module_preferences_json)
VALUES ($1, $2, $3, $4, $5,
$6, $6,
180, $7, 'MALE',
'disconnected',
'aggregated',
$6,
true)
true,
'{"nutrients":true}'::jsonb)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
@@ -129,7 +133,8 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
insights_cached_at = NULL,
insights_cached_text = NULL,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = EXCLUDED.module_preferences_json`,
[
cuid(),
E2E_USER.username,
@@ -141,6 +146,19 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
],
);
// Repeated local E2E runs reuse the same seeded account and database.
// Its owner-scoped share-link bucket lasts an hour, so otherwise the third
// run can start above the 20-operation ceiling and fail before exercising
// the share flow. Reset only this fixture user's bucket; never touch
// unrelated rate-limit state in a developer database.
await pool.query(
`DELETE FROM rate_limits
WHERE key = 'share-link:' || (
SELECT id::text FROM users WHERE username = $1
)`,
[E2E_USER.username],
);
// Opt the seed account into cycle tracking. The user is seeded as
// `MALE`, and v1.18.0 resolves the `cycle` module through
// `isCycleEnabled(gender, CycleProfile)` — NULL `cycleTrackingEnabled`
+169 -14
View File
@@ -5,21 +5,14 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.27 MB6 — `/measurements?add=<TYPE>` consumer.
*
* The insights empty-state CTAs link here with a `?add=<TYPE>` query
* param. The page reads the param during render, opens the
* Add-Measurement primitive, pre-selects the matching type, then
* `router.replace("/measurements")` strips the query so the back
* button drops the user on the bare list instead of re-opening the
* dialog.
* Insight metric actions link here with `?add=<TYPE>` and an optional,
* allowlisted `returnTo`. The page opens the responsive capture primitive,
* pre-selects the matching type, and strips the query without losing the
* one-shot return context.
*
* Three contracts under test:
* 1. The primitive opens on first paint.
* 2. The form's type combobox shows the requested type.
* 3. The URL settles on `/measurements` (no `?add=`).
*
* Pixel 5 is the load-bearing project — every CTA hop originates from
* the mobile-first empty-state surfaces. Desktop Chrome covers the
* regression path through the dashboard quick-entry.
* The browser contract covers the populated-page action, legacy preselection,
* failure retention, success return, and rejection of an external destination.
* Playwright runs every case in both the desktop Chromium and Pixel 5 projects.
*/
test.describe("v1.4.27 — /measurements?add= consumer", () => {
test.use({ storageState: STORAGE_STATE_PATH });
@@ -49,6 +42,57 @@ test.describe("v1.4.27 — /measurements?add= consumer", () => {
);
});
test("a populated metric action opens the matching preselected capture", async ({
page,
}) => {
await page.route(/\/api\/analytics(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
summaries: {
WEIGHT: {
latest: 78.4,
count: 4,
min: 77.9,
max: 78.8,
mean: 78.3,
median: 78.25,
},
},
bpInTargetPct: null,
glucoseByContext: {},
},
error: null,
}),
}),
);
await page.goto("/insights/weight", { waitUntil: "domcontentloaded" });
// The action is server-rendered before React can handle client navigation.
// Pin the click to the Insights surface's existing hydration contract so
// it cannot race hydration and get replayed as a no-op.
await expect(
page.locator('[data-slot="insights-tab-strip"]'),
).toHaveAttribute("data-hydrated", "true");
const capture = page.locator('[data-slot="metric-add-reading"]');
await expect(capture).toBeVisible();
await expect(capture).toHaveAttribute(
"href",
"/measurements?add=WEIGHT&returnTo=%2Finsights%2Fweight",
);
await capture.click();
await expect(page).toHaveURL(/\/measurements(?:\?|$)/);
const content = page.locator('[data-slot="responsive-sheet-content"]');
await expect(content).toBeVisible();
await expect(content.getByRole("combobox").first()).toContainText(
/weight|gewicht/i,
);
});
for (const { param, label } of [
{ param: "WEIGHT", label: /weight|gewicht/i },
{ param: "BLOOD_PRESSURE", label: /blood pressure|blutdruck/i },
@@ -76,6 +120,117 @@ test.describe("v1.4.27 — /measurements?add= consumer", () => {
});
}
test("keeps the one-shot sheet and entered values when the write fails", async ({
page,
}) => {
await page.route("**/api/measurements*", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ data: null, error: "Write failed" }),
});
return;
}
await route.fallback();
});
await page.goto("/measurements?add=WEIGHT&returnTo=%2Finsights%2Fweight", {
waitUntil: "domcontentloaded",
});
const content = page.locator('[data-slot="responsive-sheet-content"]');
await expect(content).toBeVisible();
await expect(content.getByRole("combobox").first()).toContainText(
/weight|gewicht/i,
);
await content.locator("#value").fill("78.4");
await content.locator("#notes").fill("Keep this note");
await page
.getByRole("button", { name: /save|speichern/i })
.last()
.click();
await expect(content.getByRole("alert")).toBeVisible();
await expect(content.locator("#value")).toHaveValue("78.4");
await expect(content.locator("#notes")).toHaveValue("Keep this note");
await expect(content).toBeVisible();
expect(new URL(page.url()).pathname).toBe("/measurements");
});
test("returns to the validated metric route only after a successful write", async ({
page,
}) => {
await page.route("**/api/measurements*", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { id: "contextual-e2e", success: true },
error: null,
}),
});
return;
}
await route.fallback();
});
await page.goto("/measurements?add=WEIGHT&returnTo=%2Finsights%2Fweight", {
waitUntil: "domcontentloaded",
});
const content = page.locator('[data-slot="responsive-sheet-content"]');
await expect(content.getByRole("combobox").first()).toContainText(
/weight|gewicht/i,
);
await content.locator("#value").fill("78.4");
await Promise.all([
page.waitForURL((url) => url.pathname === "/insights/weight"),
page
.getByRole("button", { name: /save|speichern/i })
.last()
.click(),
]);
});
test("rejects an external return target after a successful write", async ({
page,
}) => {
await page.route("**/api/measurements*", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { id: "contextual-e2e", success: true },
error: null,
}),
});
return;
}
await route.fallback();
});
await page.goto(
"/measurements?add=WEIGHT&returnTo=https%3A%2F%2Fevil.example%2Fsteal",
{ waitUntil: "domcontentloaded" },
);
const appOrigin = new URL(page.url()).origin;
const content = page.locator('[data-slot="responsive-sheet-content"]');
await content.locator("#value").fill("78.4");
await page
.getByRole("button", { name: /save|speichern/i })
.last()
.click();
await expect(content).toHaveCount(0);
const settled = new URL(page.url());
expect(settled.origin).toBe(appOrigin);
expect(settled.pathname).toBe("/measurements");
});
test("unknown ?add value is dropped silently and the page renders empty", async ({
page,
}) => {
+144
View File
@@ -0,0 +1,144 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
mockDashboardSnapshot,
WEIGHT_ONLY_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
test.describe("water capture", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("opens globally, waits for success, and retains failed input", async ({
page,
}, testInfo) => {
await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES });
await page.route(/\/api\/analytics(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} },
error: null,
}),
}),
);
await page.route("**/api/mood/analytics", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], summary: { count: 0 } },
error: null,
}),
}),
);
let writeCount = 0;
let confirmSuccess: (() => void) | undefined;
const successConfirmed = new Promise<void>((resolve) => {
confirmSuccess = resolve;
});
await page.route("**/api/nutrients/water", async (route) => {
writeCount += 1;
if (writeCount === 1) {
await successConfirmed;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: { amountMl: 250 }, error: null }),
});
return;
}
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ data: null, error: "write failed" }),
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
const openWater = async (toastMayOverlap = false) => {
if (testInfo.project.name === "chromium-mobile") {
const capture = page.getByTestId("bottom-nav-capture");
if (toastMayOverlap) {
await capture.dispatchEvent("click");
} else {
await capture.click();
}
const option = page.getByTestId("capture-picker-water");
await expect(option).toBeVisible();
const box = await option.boundingBox();
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
if (toastMayOverlap) {
await option.dispatchEvent("click");
} else {
await option.click();
}
} else {
await page.locator('[data-tour-id="dashboard-quick-add"]').click();
await page.getByRole("menuitem", { name: "Log water" }).click();
}
await expect(
page.getByRole("heading", { name: "Add water" }),
).toBeVisible();
if (testInfo.project.name === "chromium-mobile") {
const actions = page
.getByRole("dialog")
.locator(
'[data-slot="water-quick-add-chips"] button, form button[type="submit"]',
);
await expect(actions).toHaveCount(4);
for (const action of await actions.all()) {
await expect(action).toHaveAccessibleName(/.+/);
const actionBox = await action.boundingBox();
expect(actionBox?.height ?? 0).toBeGreaterThanOrEqual(44);
}
}
};
await openWater();
const amount = page.getByRole("spinbutton", {
name: "Custom amount (mL)",
});
await amount.fill("250");
const submit = page
.getByRole("dialog")
.getByRole("button", { name: "Add", exact: true });
await amount.press("Enter");
await expect(submit).toBeDisabled();
await expect(amount).toHaveValue("250");
await expect(page.getByText("250 mL water added.")).toHaveCount(0);
confirmSuccess?.();
await expect(page.getByText("250 mL water added.")).toBeVisible();
await expect(page.getByRole("heading", { name: "Add water" })).toHaveCount(
0,
);
expect(writeCount).toBe(1);
await openWater(true);
const failedAmount = page.getByRole("spinbutton", {
name: "Custom amount (mL)",
});
await expect(failedAmount).toHaveValue("");
await failedAmount.fill("375");
await page
.getByRole("dialog")
.getByRole("button", { name: "Add", exact: true })
.click();
await expect(
page.getByText("Couldn't save that — try again."),
).toBeVisible();
await expect(failedAmount).toHaveValue("375");
await expect(
page.getByRole("heading", { name: "Add water" }),
).toBeVisible();
await expect(page.getByText("375 mL water added.")).toHaveCount(0);
expect(writeCount).toBe(2);
});
});
+140
View File
@@ -0,0 +1,140 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
interface MockWorkout {
id: string;
sportType: string;
startedAt: string;
endedAt: string;
durationSec: number;
distanceM: null;
activeEnergyKcal: null;
avgHr: null;
maxHr: null;
source: string;
externalId: string;
}
function workout(id: number): MockWorkout {
return {
id: `workout-${id}`,
sportType: "running",
startedAt: "2026-07-20T08:00:00.000Z",
endedAt: "2026-07-20T08:30:00.000Z",
durationSec: 1800,
distanceM: null,
activeEnergyKcal: null,
avgHr: null,
maxHr: null,
source: "APPLE_HEALTH",
externalId: `external-${id}`,
};
}
test.describe("workout history pagination", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("loads canonical rows beyond 100 once without duplicate ids", async ({
page,
}) => {
const firstPage = Array.from({ length: 100 }, (_, index) =>
workout(index + 1),
);
const secondPage = [
workout(100),
...Array.from({ length: 99 }, (_, index) => workout(index + 101)),
];
const thirdPage = [workout(200), workout(201)];
const observedOffsets: number[] = [];
let releaseFirstPage!: () => void;
const firstPageGate = new Promise<void>((resolve) => {
releaseFirstPage = resolve;
});
let releaseSecondPage!: () => void;
const secondPageGate = new Promise<void>((resolve) => {
releaseSecondPage = resolve;
});
await page.route(/\/api\/workouts(?:\?|$)/, async (route) => {
const url = new URL(route.request().url());
const limit = Number(url.searchParams.get("limit") ?? "50");
const offset = Number(url.searchParams.get("offset") ?? "0");
if (limit !== 100) {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
data: {
workouts: firstPage.slice(0, limit),
meta: {
total: 201,
limit,
offset,
droppedDuplicates: 0,
},
},
error: null,
}),
});
return;
}
observedOffsets.push(offset);
if (offset === 0) await firstPageGate;
if (offset === 100) await secondPageGate;
const workouts =
offset === 0 ? firstPage : offset === 100 ? secondPage : thirdPage;
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
data: {
workouts,
meta: {
total: 201,
limit,
offset,
droppedDuplicates: 0,
},
},
error: null,
}),
});
});
await page.goto("/insights/workouts");
await expect(page.locator('[data-slot="workouts-loading"]')).toBeVisible();
await expect(page.locator('[data-slot="workout-list-row"]')).toHaveCount(0);
releaseFirstPage();
const rows = page.locator('[data-slot="workout-list-row"]');
await expect(rows).toHaveCount(100);
await expect(page.locator('[data-slot="workouts-loading"]')).toHaveCount(0);
const loadMore = page.getByRole("button", { name: "Load more workouts" });
await loadMore.click();
await expect(
page.getByRole("button", { name: "Loading more workouts" }),
).toBeDisabled();
releaseSecondPage();
await expect(
page.locator('a[href="/insights/workouts/workout-101"]'),
).toHaveCount(1);
await expect(
page.locator('a[href="/insights/workouts/workout-100"]'),
).toHaveCount(1);
await expect(rows).toHaveCount(199);
await page.getByRole("button", { name: "Load more workouts" }).click();
await expect(
page.locator('a[href="/insights/workouts/workout-201"]'),
).toHaveCount(1);
await expect(rows).toHaveCount(201);
await expect(
page.getByRole("button", { name: "Load more workouts" }),
).toHaveCount(0);
expect(observedOffsets).toEqual([0, 100, 200]);
});
});