mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(web): stabilize document and example downloads
This commit is contained in:
1 parent
fb9ae2ac61
commit
b0a94b3423
6 files changed
+61
-31
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user