mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0 * test: stabilize water capture re-entry
This commit is contained in:
1 parent
b01822be48
commit
62eb431d66
372 files changed
+25178
-8561
No files matched your search
+846
-114
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user