mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
The standard template carries SDNN and RMSSD since the wearable leaves landed, and the cardio group has fifteen leaves. Assert the untouched mobility group at zero instead.
329 lines
13 KiB
TypeScript
329 lines
13 KiB
TypeScript
import type { Page } from "@playwright/test";
|
|
import AxeBuilder from "@axe-core/playwright";
|
|
|
|
import { expect, test } from "./setup/test";
|
|
|
|
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
|
|
|
/**
|
|
* Settings → Export & Import + Gesundheitsakte UI smoke.
|
|
*
|
|
* Validates that:
|
|
* 1. The Export & Import surfaces (`/settings/export`) render with their
|
|
* stable testids: the four CSV/JSON tiles and the import cards.
|
|
* 2. The import surfaces (v1.15.7, issue #281) render: the Apple Health
|
|
* and generic-JSON cards.
|
|
* 3. The full health-record export panel lives on its own top-level
|
|
* `/settings/gesundheitsakte` section (v1.18.0 S5) — including the
|
|
* "included data" disclosure, which a first run opens and which stays
|
|
* operable in both directions.
|
|
* 4. The scope picker mounts twice on that page (the export panel and the
|
|
* share-link create form) and each mount is separately addressable.
|
|
* 5. Clicking the Measurements CSV download button fires a real
|
|
* browser download — proving the `/api/export/measurements`
|
|
* endpoint is reachable from the browser end-to-end.
|
|
*
|
|
* The other CSV / JSON cards share the same code path; one happy-path
|
|
* download is enough to lock in the wiring without doubling the e2e
|
|
* runtime.
|
|
*/
|
|
|
|
/**
|
|
* Open Settings → Gesundheitsakte and wait until the page is actually live.
|
|
*
|
|
* Everything on that page is server-rendered, so the rows are visible long
|
|
* before the client boundary that owns them has hydrated — a click or a
|
|
* `boundingBox()` taken on `domcontentloaded` alone hits inert or replaced
|
|
* markup often enough to matter. `GesundheitsakteSection` is one client
|
|
* boundary carrying both the export panel and the sharing card, and the
|
|
* sharing card's query only runs once that boundary is live, so its request is
|
|
* the signal that the whole surface is wired.
|
|
*/
|
|
async function openGesundheitsakte(page: Page): Promise<void> {
|
|
const boundaryHydrated = page.waitForResponse(
|
|
(response) =>
|
|
response.request().method() === "GET" &&
|
|
new URL(response.url()).pathname === "/api/share-links",
|
|
);
|
|
await page.goto("/settings/gesundheitsakte", {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
await boundaryHydrated;
|
|
}
|
|
|
|
test.describe("Settings → Export & Import", () => {
|
|
test.use({ storageState: STORAGE_STATE_PATH });
|
|
|
|
test("renders all export surfaces with stable testids", async ({ page }) => {
|
|
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
|
|
// v1.18.0 — the full health-record export moved to its own
|
|
// `/settings/gesundheitsakte` section. The Export & Import page keeps
|
|
// the four CSV/backup tiles with the `export-card-*` shape.
|
|
for (const id of [
|
|
"export-card-measurements-csv",
|
|
"export-card-medications-csv",
|
|
"export-card-mood-csv",
|
|
"export-card-full-backup",
|
|
]) {
|
|
await expect(page.getByTestId(id)).toBeVisible({ timeout: 10_000 });
|
|
}
|
|
});
|
|
|
|
test("renders the health-record export panel on its own section", async ({
|
|
page,
|
|
}) => {
|
|
// v1.18.0 — the health-record export is the hero of the
|
|
// dedicated, module-gated Gesundheitsakte section.
|
|
await openGesundheitsakte(page);
|
|
await expect(page.getByTestId("health-record-export-panel")).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
});
|
|
|
|
test("gives each scope-picker mount its own test ids", async ({ page }) => {
|
|
// The Gesundheitsakte page carries two mounts of the same picker: the
|
|
// export panel at the top and the share-link create form under `#sharing`.
|
|
// Every id inside the picker ends with the surface that mounts it, so an
|
|
// assertion binds to one mount instead of resolving to both.
|
|
await openGesundheitsakte(page);
|
|
await expect(page.getByTestId("report-scope-picker-export")).toHaveCount(1);
|
|
await expect(page.getByTestId("report-scope-picker-share")).toHaveCount(1);
|
|
for (const id of [
|
|
"report-group-row-vitals",
|
|
"report-group-check-vitals",
|
|
"report-sensitive-tier",
|
|
"report-scope-summary",
|
|
]) {
|
|
await expect(page.getByTestId(`${id}-export`)).toHaveCount(1);
|
|
await expect(page.getByTestId(`${id}-share`)).toHaveCount(1);
|
|
}
|
|
});
|
|
|
|
test("opens the scope picker expanded on a first run, fenced tier and all", async ({
|
|
page,
|
|
}) => {
|
|
// The three panel states are the release's whole point, and on the first
|
|
// one nothing is selected at all. The picker still opens without a click,
|
|
// because with an empty scope the standard-report button and the named
|
|
// groups are exactly what the person needs in front of them. Asserted
|
|
// against the export mount; the share form runs its own picker.
|
|
await openGesundheitsakte(page);
|
|
await expect(page.getByTestId("report-scope-picker-export")).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
await expect(
|
|
page.getByTestId("report-group-row-vitals-export"),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByTestId("report-group-row-sensitive-export"),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
page.getByTestId("report-sensitive-tier-export"),
|
|
).toBeVisible();
|
|
// The fenced tier has no group control — there is no single click that
|
|
// turns on more than one sensitive leaf.
|
|
await expect(
|
|
page.getByTestId("report-group-check-sensitive-export"),
|
|
).toHaveCount(0);
|
|
await expect(page.getByTestId("report-scope-summary-export")).toBeVisible();
|
|
});
|
|
|
|
test("first run selects nothing and refuses to generate until it does", async ({
|
|
page,
|
|
}) => {
|
|
// The rule the whole surface exists for: nothing reaches the report that
|
|
// was not ticked. So a first run starts empty, Generate refuses the press
|
|
// and says why, and the one-click action is what makes that cost nothing —
|
|
// it fills the standard set and the control comes alive.
|
|
await openGesundheitsakte(page);
|
|
const generate = page.getByTestId("health-record-generate");
|
|
await expect(generate).toBeVisible({ timeout: 10_000 });
|
|
await expect(generate).toBeDisabled();
|
|
await expect(
|
|
page.getByTestId("report-group-row-vitals-export"),
|
|
).toContainText("0/7");
|
|
await expect(page.getByTestId("report-scope-summary-export")).toContainText(
|
|
"Nothing selected yet",
|
|
);
|
|
|
|
await page.getByTestId("report-apply-standard-export").click();
|
|
|
|
await expect(generate).toBeEnabled();
|
|
// Exactly the shipped set: the three classic vitals, weight and BMI, the
|
|
// two heart-rate-variability rows, and nothing at all from the groups the
|
|
// template leaves alone.
|
|
await expect(
|
|
page.getByTestId("report-group-row-vitals-export"),
|
|
).toContainText("3/7");
|
|
await expect(
|
|
page.getByTestId("report-group-row-body-export"),
|
|
).toContainText("2/12");
|
|
await expect(
|
|
page.getByTestId("report-group-row-cardio-export"),
|
|
).toContainText("2/15");
|
|
await expect(
|
|
page.getByTestId("report-group-row-mobility-export"),
|
|
).toContainText("0/11");
|
|
// The fenced tier is not part of any one-click set.
|
|
await expect(
|
|
page.getByTestId("report-scope-summary-export"),
|
|
).not.toContainText("incl.");
|
|
});
|
|
|
|
test("a share link cannot be minted on an empty scope either", async ({
|
|
page,
|
|
}) => {
|
|
await openGesundheitsakte(page);
|
|
const create = page.getByTestId("share-create-submit");
|
|
await expect(create).toBeVisible({ timeout: 10_000 });
|
|
await page.locator("#share-label").fill("Cardiology");
|
|
// A label alone is not a scope: the link would serve a page with nothing
|
|
// on it, so the control stays shut until something is chosen.
|
|
await expect(create).toBeDisabled();
|
|
await expect(page.getByTestId("report-scope-summary-share")).toContainText(
|
|
"Nothing selected yet",
|
|
);
|
|
await page.getByTestId("report-apply-standard-share").click();
|
|
await expect(create).toBeEnabled();
|
|
});
|
|
|
|
test("clears the tap floor on the group rows at 390 px", async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await openGesundheitsakte(page);
|
|
const row = page.getByTestId("report-group-row-vitals-export");
|
|
await expect(row).toBeVisible({ timeout: 10_000 });
|
|
const box = await row.boundingBox();
|
|
expect(box).not.toBeNull();
|
|
expect(box!.height).toBeGreaterThanOrEqual(44);
|
|
});
|
|
|
|
test("opens a group's leaf grid from its chevron", async ({ page }) => {
|
|
await openGesundheitsakte(page);
|
|
const row = page.getByTestId("report-group-row-vitals-export");
|
|
await expect(row).toBeVisible({ timeout: 10_000 });
|
|
// Collapsed: the leaves are behind the disclosure, which is what makes a
|
|
// repeat run two interactions.
|
|
await expect(page.getByTestId("report-leaf-PULSE-export")).toHaveCount(0);
|
|
await row.getByRole("button", { expanded: false }).click();
|
|
await expect(page.getByTestId("report-leaf-PULSE-export")).toBeVisible();
|
|
});
|
|
|
|
test("renders the import surfaces with stable testids", async ({ page }) => {
|
|
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
|
|
// v1.15.7 (issue #281) — the Apple Health and generic-JSON import
|
|
// cards live in the same section, below the export options.
|
|
for (const id of ["import-card-apple-health", "import-card-json"]) {
|
|
await expect(page.getByTestId(id)).toBeVisible({ timeout: 10_000 });
|
|
}
|
|
});
|
|
|
|
test("JSON import 'Download example' fires a real download", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
|
|
const exampleBtn = page.getByTestId("import-json-download-example");
|
|
await expect(exampleBtn).toBeVisible({ timeout: 10_000 });
|
|
|
|
const a11y = await new AxeBuilder({ page })
|
|
.include('[data-testid="import-card-json"]')
|
|
.analyze();
|
|
expect(
|
|
a11y.violations.filter(
|
|
({ impact }) => impact === "serious" || impact === "critical",
|
|
),
|
|
).toEqual([]);
|
|
|
|
const downloadPromise = page.waitForEvent("download", { timeout: 30_000 });
|
|
await exampleBtn.click();
|
|
const download = await downloadPromise;
|
|
|
|
const path = await download.path();
|
|
expect(path).toBeTruthy();
|
|
const fs = await import("node:fs/promises");
|
|
const buf = await fs.readFile(path!);
|
|
const parsed = JSON.parse(buf.toString("utf8"));
|
|
expect(Array.isArray(parsed.measurements)).toBe(true);
|
|
expect(Array.isArray(parsed.moodEntries)).toBe(true);
|
|
});
|
|
|
|
test("included-data disclosure opens on a first run and still collapses", async ({
|
|
page,
|
|
}) => {
|
|
// The disclosure that fronts the scope picker on
|
|
// `/settings/gesundheitsakte`. An account with no saved selection is a
|
|
// first run, and a first run opens it: that is the only place the standard
|
|
// template is applied, and a template hidden behind a closed disclosure
|
|
// would be opt-out wearing a hat.
|
|
//
|
|
// Opening by default must not turn the disclosure into decoration, so the
|
|
// operable part is asserted in both directions — it closes, taking the
|
|
// panel with it, and it reopens.
|
|
await page.route("**/api/share-links", async (route) => {
|
|
if (route.request().method() !== "GET") {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ data: [], error: null, meta: null }),
|
|
});
|
|
});
|
|
await openGesundheitsakte(page);
|
|
const toggle = page.getByTestId("health-record-included-data-toggle");
|
|
const panel = page.getByTestId("health-record-included-data-panel");
|
|
await expect(toggle).toBeVisible({ timeout: 10_000 });
|
|
// Open on first render, with the checklist panel behind it.
|
|
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
|
await expect(panel).toBeVisible();
|
|
// Collapses on demand, and the panel goes with it.
|
|
await toggle.click();
|
|
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
|
await expect(panel).toHaveCount(0);
|
|
// And back — the disclosure stays a disclosure.
|
|
await toggle.click();
|
|
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
|
await expect(panel).toBeVisible();
|
|
});
|
|
|
|
test("Measurements CSV download fires a real download event", async ({
|
|
page,
|
|
isMobile,
|
|
}) => {
|
|
// See the JSON-download test above: mobile emulation does not emit a
|
|
// `download` event for the anchor click. Covered on chromium-desktop.
|
|
test.skip(isMobile, "downloads aren't observable under mobile emulation");
|
|
// Stub the API so we don't need 90 days of seeded data — the
|
|
// browser-side wiring is what we're validating, not the route's
|
|
// DB query.
|
|
await page.route("**/api/export/measurements*", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/csv; charset=utf-8",
|
|
headers: {
|
|
"Content-Disposition":
|
|
'attachment; filename="healthlog-measurements-test.csv"',
|
|
},
|
|
body: "type,value,unit,measuredAt,source,notes,glucoseContext\nWEIGHT,80,kg,2026-05-01T08:00:00.000Z,MANUAL,,\n",
|
|
}),
|
|
);
|
|
|
|
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
|
|
const downloadBtn = page.getByTestId("export-action-measurements-csv");
|
|
await expect(downloadBtn).toBeVisible({ timeout: 10_000 });
|
|
|
|
const downloadPromise = page.waitForEvent("download", {
|
|
timeout: 30_000,
|
|
});
|
|
await downloadBtn.click();
|
|
const download = await downloadPromise;
|
|
|
|
const path = await download.path();
|
|
expect(path).toBeTruthy();
|
|
const fs = await import("node:fs/promises");
|
|
const buf = await fs.readFile(path!);
|
|
expect(buf.byteLength).toBeGreaterThan(0);
|
|
expect(buf.toString("utf8")).toContain("WEIGHT,80,kg");
|
|
});
|
|
});
|