diff --git a/e2e/settings-export.spec.ts b/e2e/settings-export.spec.ts index a6ef1db5c..f2ca7ee19 100644 --- a/e2e/settings-export.spec.ts +++ b/e2e/settings-export.spec.ts @@ -1,4 +1,5 @@ import type { Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "./setup/test"; @@ -214,18 +215,20 @@ test.describe("Settings → Export & Import", () => { test("JSON import 'Download example' fires a real download", async ({ page, - isMobile, }) => { - // Playwright's mobile emulation (Pixel 5 / isMobile) does not reliably - // emit a `download` event for a Blob + anchor click — the click is treated - // as a navigation, so `waitForEvent("download")` times out. The download - // wiring is engine behaviour, not viewport-dependent, and is covered on the - // chromium-desktop project; skip it on mobile rather than chase a flake. - test.skip(isMobile, "downloads aren't observable under mobile emulation"); 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; diff --git a/e2e/setup/vault-fixture.ts b/e2e/setup/vault-fixture.ts index cfb06e425..dc9314e84 100644 --- a/e2e/setup/vault-fixture.ts +++ b/e2e/setup/vault-fixture.ts @@ -299,8 +299,15 @@ async function upsertDocs( $8, 'binary2', $9, 'STORED', $10::timestamp, NOW(), NOW(), NULL) ON CONFLICT (id) DO UPDATE SET + user_id = EXCLUDED.user_id, kind = EXCLUDED.kind, title = EXCLUDED.title, + filename = EXCLUDED.filename, + mime_type = EXCLUDED.mime_type, + byte_size = EXCLUDED.byte_size, + content_encrypted = EXCLUDED.content_encrypted, + content_codec = EXCLUDED.content_codec, + content_sha256 = EXCLUDED.content_sha256, document_date = EXCLUDED.document_date, status = 'STORED', deleted_at = NULL, diff --git a/src/components/settings/__tests__/import-panel.test.tsx b/src/components/settings/__tests__/import-panel.test.tsx index 31df66cae..e4a1b50a6 100644 --- a/src/components/settings/__tests__/import-panel.test.tsx +++ b/src/components/settings/__tests__/import-panel.test.tsx @@ -32,6 +32,7 @@ import { I18nProvider } from "@/lib/i18n/context"; import { ImportPanel, EXAMPLE_IMPORT, + EXAMPLE_IMPORT_DOWNLOAD_HREF, EXAMPLE_CSV, parseImportJson, } from "../import-panel"; @@ -139,6 +140,23 @@ describe("EXAMPLE_IMPORT payload", () => { mood.mood, ); }); + + it("exposes a self-contained, inert JSON download target", () => { + expect(EXAMPLE_IMPORT_DOWNLOAD_HREF).toMatch( + /^data:application\/json;charset=utf-8,/, + ); + expect(EXAMPLE_IMPORT_DOWNLOAD_HREF).not.toMatch( + /(?:javascript:|blob:|); + expect(html).toContain('download="healthlog-import-example.json"'); + expect(html).toContain('href="data:application/json;charset=utf-8,'); + }); }); describe("EXAMPLE_CSV", () => { diff --git a/src/components/settings/import-panel/import-examples.ts b/src/components/settings/import-panel/import-examples.ts index aaf154765..9f4c6362b 100644 --- a/src/components/settings/import-panel/import-examples.ts +++ b/src/components/settings/import-panel/import-examples.ts @@ -39,6 +39,18 @@ export const EXAMPLE_IMPORT = { ], }; +/** + * Stable, browser-native download target for the JSON example. + * + * A data URL is intentionally used instead of a short-lived Blob URL. It lets + * the browser own the download directly from the anchor activation and avoids + * a create/revoke race when the page is busy or Chromium delays consumption of + * a synthetic Blob URL. The payload is small and contains example data only. + */ +export const EXAMPLE_IMPORT_DOWNLOAD_HREF = + "data:application/json;charset=utf-8," + + encodeURIComponent(JSON.stringify(EXAMPLE_IMPORT, null, 2)); + /** * Client-side parse guard for the JSON-import textarea. Returns the * parsed value when the text is valid JSON, otherwise a failure marker — diff --git a/src/components/settings/import-panel/index.tsx b/src/components/settings/import-panel/index.tsx index 8ea563915..38f895df9 100644 --- a/src/components/settings/import-panel/index.tsx +++ b/src/components/settings/import-panel/index.tsx @@ -25,6 +25,7 @@ import { DoseHistoryImportCard } from "./dose-history-import-card"; export { EXAMPLE_IMPORT, + EXAMPLE_IMPORT_DOWNLOAD_HREF, EXAMPLE_CSV, parseImportJson, } from "./import-examples"; diff --git a/src/components/settings/import-panel/json-import-card.tsx b/src/components/settings/import-panel/json-import-card.tsx index 30c01672e..4a81adf4d 100644 --- a/src/components/settings/import-panel/json-import-card.tsx +++ b/src/components/settings/import-panel/json-import-card.tsx @@ -11,7 +11,10 @@ import { useTranslations } from "@/lib/i18n/context"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { ImportCardShell } from "./import-card-shell"; import { MAX_PASTE_CHARS } from "./constants"; -import { EXAMPLE_IMPORT, parseImportJson } from "./import-examples"; +import { + EXAMPLE_IMPORT_DOWNLOAD_HREF, + parseImportJson, +} from "./import-examples"; import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; import { classifyWrittenOutcome } from "@/lib/outcome/written-outcome"; @@ -76,24 +79,6 @@ export function JsonImportCard() { } } - function downloadExample() { - const blob = new Blob([JSON.stringify(EXAMPLE_IMPORT, null, 2)], { - type: "application/json", - }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "healthlog-import-example.json"; - document.body.appendChild(a); - a.click(); - // Defer cleanup a tick: revoking the blob URL in the same synchronous - // frame as click() races the browser's download start and can cancel it. - setTimeout(() => { - a.remove(); - URL.revokeObjectURL(url); - }, 0); - } - async function handleImport() { setError(null); setResult(null); @@ -224,15 +209,19 @@ export function JsonImportCard() { {t("settings.sections.export.import.json.uploadFile")}