fix(web): stabilize document and example downloads

This commit is contained in:
Marc-André Bombeck committed 2026-07-29 15:57:20 +02:00
1 parent fb9ae2ac61
commit b0a94b3423
6 files changed
+61 -31

No files matched your search

+10 -7
View File
@@ -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;
+7
View File
@@ -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,
@@ -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:|<script)/i,
);
const encoded = EXAMPLE_IMPORT_DOWNLOAD_HREF.split(",", 2)[1];
expect(encoded).toBeTruthy();
expect(JSON.parse(decodeURIComponent(encoded!))).toEqual(EXAMPLE_IMPORT);
const html = render(<ImportPanel />);
expect(html).toContain('download="healthlog-import-example.json"');
expect(html).toContain('href="data:application/json;charset=utf-8,');
});
});
describe("EXAMPLE_CSV", () => {
@@ -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 —
@@ -25,6 +25,7 @@ import { DoseHistoryImportCard } from "./dose-history-import-card";
export {
EXAMPLE_IMPORT,
EXAMPLE_IMPORT_DOWNLOAD_HREF,
EXAMPLE_CSV,
parseImportJson,
} from "./import-examples";
@@ -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")}
</Button>
<Button
type="button"
asChild
variant="ghost"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={downloadExample}
data-testid="import-json-download-example"
>
<Download className="h-3.5 w-3.5" />
{t("settings.sections.export.import.json.downloadExample")}
<a
href={EXAMPLE_IMPORT_DOWNLOAD_HREF}
download="healthlog-import-example.json"
data-testid="import-json-download-example"
>
<Download className="h-3.5 w-3.5" />
{t("settings.sections.export.import.json.downloadExample")}
</a>
</Button>
<Button
type="button"