From 0c9832eb8d2f4b9e817cdd4df7ea5bc4e199c195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 21:23:26 +0200 Subject: [PATCH 1/7] fix(import): accept a blood-glucose reading that carries no context A CSV import of a continuous-sensor export wrote nothing and rendered a green success state. Three faults compounded, and the reporter took all three apart before filing. The parser refused every BLOOD_GLUCOSE row with a blank glucoseContext. Behind it, the CHECK constraint from migration 0021 read both ways at once: a glucose row MUST carry a context, every other type MUST NOT. The second half is right and stays. The first was written when the only glucose writer was the entry form, where a person picks fasting or post-meal at the moment of the reading. Every machine writer that arrived since has no such answer per sample, so the Nightscout pull, the MCP log tool, the Telegram capture and both bulk importers were all building rows the database then refused. A missing context now stores as NULL, never as a fifth enum member. Every per-context reader selects by equality against the four members, so the reading stays out of the doctor report's fasting rows, the matching FHIR observations and the per-context targets, while the chart, the all-time summary and the clinical panel keep it in full. Hand entry still asks for a context, because at that moment you know it. The import cards now read the counts rather than the status code. Nothing written is a failure, a mixed file is a warning, and the refused rows are grouped by reason with a count instead of fifty identical lines, with the per-line detail one disclosure away. Both cards render through one outcome table, so a tick cannot reach a result that wrote nothing. Refs #640 --- docs/api/openapi.yaml | 15 +- docs/integrations/data-import.md | 75 ++++++- messages/de.json | 30 ++- messages/en.json | 30 ++- messages/es.json | 30 ++- messages/fr.json | 30 ++- messages/it.json | 30 ++- messages/pl.json | 30 ++- .../migration.sql | 31 +++ prisma/schema.prisma | 8 +- .../settings/import-panel/csv-import-card.tsx | 57 +---- .../import-panel/import-result-state.ts | 72 ++++++ .../import-panel/import-result-view.tsx | 205 ++++++++++++++++++ .../import-panel/json-import-card.tsx | 64 ++++-- src/lib/import/csv-measurements.ts | 22 +- src/lib/openapi/routes/import.ts | 4 +- src/lib/openapi/routes/measurements.ts | 4 +- src/lib/openapi/routes/shared.ts | 2 +- src/lib/validations/measurement.ts | 14 +- 19 files changed, 639 insertions(+), 114 deletions(-) create mode 100644 prisma/migrations/0274_glucose_context_optional/migration.sql create mode 100644 src/components/settings/import-panel/import-result-state.ts create mode 100644 src/components/settings/import-panel/import-result-view.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 227c94387..997fffdca 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8004,9 +8004,11 @@ paths: 10 000 valid rows). Header (order-independent): `type,value,unit,measuredAt[,glucoseContext,notes,externalId]`. `measuredAt` must carry an explicit ISO-8601 offset and is bounded (no future beyond a 5-min skew, no instant before 1900). Glucose accepts `mmol/L` (converted to canonical `mg/dL`); weight accepts `lb` (converted to - `kg`). An `externalId` column makes re-upload idempotent (upsert on `(userId, type, source=IMPORT, - externalId)`); without it a re-upload duplicates. `?dryRun=1` validates + previews without writing. Shares the - 5/hour `import:` rate bucket." + `kg`). `glucoseContext` is optional on a `BLOOD_GLUCOSE` row — a blank cell stores no context, which is the + normal shape of a continuous-sensor export; a non-empty value is still checked against the enum, and the column + is refused on any other type. An `externalId` column makes re-upload idempotent (upsert on `(userId, type, + source=IMPORT, externalId)`); without it a re-upload duplicates. `?dryRun=1` validates + previews without + writing. Shares the 5/hour `import:` rate bucket." parameters: - name: dryRun in: query @@ -8027,6 +8029,7 @@ paths: type,value,unit,measuredAt,glucoseContext,notes,externalId WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning, BLOOD_GLUCOSE,5.3,mmol/L,2026-05-01T08:05:00+02:00,FASTING,,meter-001 + BLOOD_GLUCOSE,5.9,mmol/L,2026-05-01T08:20:00+02:00,,,sensor-001 responses: "200": description: Per-row import outcome. @@ -17199,7 +17202,8 @@ components: - type: string - type: "null" glucoseContext: - description: Set only on BLOOD_GLUCOSE rows. + description: Set only on BLOOD_GLUCOSE rows, and null there too when the writer recorded no classification (a sensor + export, a device sync). Never invent one. anyOf: - type: string - type: "null" @@ -18015,7 +18019,8 @@ components: - type: string - type: "null" glucoseContext: - description: Set only on BLOOD_GLUCOSE rows. + description: Set only on BLOOD_GLUCOSE rows, and null there too when the writer recorded no classification (a sensor + export, a device sync). Never invent one. anyOf: - type: string - type: "null" diff --git a/docs/integrations/data-import.md b/docs/integrations/data-import.md index e01d4247d..bd4d13097 100644 --- a/docs/integrations/data-import.md +++ b/docs/integrations/data-import.md @@ -1,6 +1,6 @@ # Data import -HealthLog has two ways to bring data in, both reachable from +HealthLog has three ways to bring data in, all reachable from **Settings → Export & Import**: 1. An **Apple Health `export.zip`** upload — the full archive from the @@ -10,10 +10,18 @@ HealthLog has two ways to bring data in, both reachable from 2. A **generic JSON import** — a small, explicit payload of measurements and mood entries, useful for restoring a partial export or migrating from another tracker. This page documents that format. +3. A **CSV import** — one measurement per row, for a spreadsheet or a + meter / sensor export. Documented in section 3 below. -Both paths skip rows that already exist, so re-running an import is +All three paths skip rows that already exist, so re-running an import is safe — it merges rather than duplicating. +Every import reports what it actually wrote. A file where nothing was +written is shown as a failure, a file where some rows landed and some +were refused is shown as a partial result, and the refused rows are +grouped by reason so one bad column reads as one line rather than a +scroll. + ## 1. Apple Health `export.zip` On the iPhone: **Health app → profile picture → Export All Health @@ -56,7 +64,28 @@ skipped and counted under `skipped` in the response. | `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. | | `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. | | `notes` | no | string | Optional free text. | -| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE` (e.g. fasting / post-meal). | +| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE`. See [Glucose context](#glucose-context). | + +### Glucose context + +`glucoseContext` is one of `FASTING`, `POSTPRANDIAL`, `RANDOM`, +`BEDTIME`, and it is genuinely optional on a `BLOOD_GLUCOSE` row. Leave +it out (JSON) or leave the cell blank (CSV) and the reading is stored +with no context. + +That is the normal case for a continuous sensor or meter export, which +records a value and a timestamp per reading and classifies nothing. A +reading with no context still counts everywhere a reading counts: the +chart, the all-time summary, and the trailing-30-day clinical panel +(time in range, GMI, CV%, estimated A1c) are all context-agnostic. It is +left out only of the per-context breakdowns — the doctor report's +fasting / post-meal rows, the matching FHIR observations, the per-context +targets — because no classification was recorded and none is invented. + +Setting the column on any type other than `BLOOD_GLUCOSE` is rejected. +Setting it to a value outside the four above is rejected. Entering a +reading by hand in the app still asks for a context, because at that +moment you know it. ### Mood entry @@ -195,8 +224,48 @@ The response is a count envelope: } ``` +## 3. CSV import + +`POST /api/import/csv` takes the raw file as `text/csv` — one measurement +per row, no JSON wrapper. The **CSV file** control in +**Settings → Export & Import** posts to it, and **Download example** +mints a file with the right header. + +A header row is required; column order does not matter and header +matching is case-insensitive. + +| Column | Required | Notes | +| ---------------- | -------- | --------------------------------------------------------------------------------------------------------- | +| `type` | yes | A `MeasurementType` from the table above. | +| `value` | yes | A number, in the row's `unit`. | +| `unit` | yes | The canonical unit, or a recognised alias: `mmol/L` → `mg/dL`, `lb` → `kg`, `in` → `cm`, `°F` → `°C`. | +| `measuredAt` | yes | ISO-8601 **with an explicit offset** (`Z` or `±HH:MM`, and `±HHMM` is accepted). No offset, no import. | +| `glucoseContext` | no | Only for `BLOOD_GLUCOSE`; blank means no context. See [Glucose context](#glucose-context). | +| `notes` | no | Free text, up to 200 characters. | +| `externalId` | no | A source-stable id. With it, a re-upload updates the same row; without it, a re-upload creates a new one. | + +A sensor export therefore needs no glucose column at all: + +```csv +type,value,unit,measuredAt,glucoseContext,notes,externalId +BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,,sensor-1 +BLOOD_GLUCOSE,5.9,mmol/L,2024-04-03T13:30:00+1100,,,sensor-2 +``` + +Limits: 16 MB per upload, 10 000 valid rows per import, 5 imports per +hour (shared with the JSON import). `?dryRun=1` validates and previews +without writing anything — the **Preview** button uses it. + +The response carries per-row outcomes alongside the counts, and the card +renders the counts, not the status code: rows written and none skipped is +a success, a mixed result is a warning, and nothing written is a failure +with the reasons grouped by count. + ## Converting a CSV into the import JSON +The CSV import above covers measurements. Mood entries have no CSV +route, so a mood history still goes through the JSON payload. + A spreadsheet export is the most common starting point. The shape is small enough to convert by hand for a few rows, or with a one-off script for many. The steps are the same either way: diff --git a/messages/de.json b/messages/de.json index bfaf09acd..05226a816 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4495,7 +4495,10 @@ "rateLimited": "Zu viele Importe in dieser Stunde (Grenze 5). Bitte später erneut versuchen.", "failed": "Der Import ist fehlgeschlagen. Bitte erneut versuchen.", "readFailed": "Diese Datei konnte nicht gelesen werden.", - "resultSummary": "{measurements} Messwerte und {moods} Stimmungseinträge importiert, {skipped} übersprungen." + "resultSummary": "{measurements} Messwerte und {moods} Stimmungseinträge importiert, {skipped} übersprungen.", + "resultSuccess": "{measurements} Messwerte und {moods} Stimmungseinträge importiert.", + "resultNothing": "Nichts importiert. Alle {skipped} Einträge wurden übersprungen.", + "resultEmpty": "Diese Datei enthielt weder Messwerte noch Stimmungseinträge." }, "csv": { "title": "CSV-Datei", @@ -4516,7 +4519,30 @@ "readFailed": "Diese Datei konnte nicht gelesen werden.", "previewSummary": "Vorschau: {inserted} würden importiert, {skipped} übersprungen.", "resultSummary": "{inserted} neu importiert und {updated} aktualisiert, {skipped} übersprungen.", - "rowError": "Zeile {line}: {reason}" + "rowError": "Zeile {line}: {reason}", + "resultSuccess": "{inserted} neu importiert und {updated} aktualisiert.", + "resultNothing": "Nichts importiert. Alle {skipped} Zeilen wurden übersprungen.", + "resultEmpty": "Diese Datei hat eine Kopfzeile, aber keine Datenzeilen.", + "previewSuccess": "Vorschau: {inserted} würden importiert.", + "previewNothing": "Vorschau: nichts würde importiert. Alle {skipped} Zeilen würden übersprungen.", + "skipGroupOne": "{count} Zeile übersprungen: {reason}", + "skipGroupFew": "{count} Zeilen übersprungen: {reason}", + "skipGroupOther": "{count} Zeilen übersprungen: {reason}", + "skipDetails": "Alle übersprungenen Zeilen anzeigen", + "reasonUnknownType": "unbekannter Messwerttyp", + "reasonInvalidValue": "Wert ist keine Zahl", + "reasonValueOutOfRange": "Wert außerhalb des plausiblen Bereichs", + "reasonUnknownUnit": "Einheit für diesen Typ nicht erkannt", + "reasonMissingTimezoneOffset": "Zeitstempel ohne Zeitzonen-Offset", + "reasonInvalidTimestamp": "Zeitstempel nicht lesbar", + "reasonImplausibleTimestamp": "Zeitstempel liegt in der Zukunft oder vor 1900", + "reasonUnexpectedGlucoseContext": "Glukose-Kontext in einer Zeile, die kein Blutzucker ist", + "reasonInvalidGlucoseContext": "Glukose-Kontext ist kein zulässiger Wert", + "reasonNotesTooLong": "Notiz ist zu lang", + "reasonExternalIdTooLong": "Externe ID ist zu lang", + "reasonUnstableExternalId": "Externe ID ist über Exporte hinweg nicht stabil", + "reasonDuplicate": "bereits importiert", + "reasonUnknown": "übersprungen" } }, "subtitle": "Exportiere deine Daten oder importiere aus einer Sicherung." diff --git a/messages/en.json b/messages/en.json index 6a16a1816..da58b3e62 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4485,7 +4485,10 @@ "rateLimited": "Too many imports this hour (limit 5). Please try again later.", "failed": "The import failed. Please try again.", "readFailed": "Couldn't read that file.", - "resultSummary": "Imported {measurements} measurements and {moods} mood entries, {skipped} skipped." + "resultSummary": "Imported {measurements} measurements and {moods} mood entries, {skipped} skipped.", + "resultSuccess": "Imported {measurements} measurements and {moods} mood entries.", + "resultNothing": "Nothing was imported. All {skipped} entries were skipped.", + "resultEmpty": "That file carried no measurements and no mood entries." }, "csv": { "title": "CSV file", @@ -4506,7 +4509,30 @@ "readFailed": "Couldn't read that file.", "previewSummary": "Preview: {inserted} would import, {skipped} skipped.", "resultSummary": "Imported {inserted} new and updated {updated}, {skipped} skipped.", - "rowError": "Line {line}: {reason}" + "rowError": "Line {line}: {reason}", + "resultSuccess": "Imported {inserted} new and updated {updated}.", + "resultNothing": "Nothing was imported. All {skipped} rows were skipped.", + "resultEmpty": "That file has a header but no data rows.", + "previewSuccess": "Preview: {inserted} would import.", + "previewNothing": "Preview: nothing would import. All {skipped} rows would be skipped.", + "skipGroupOne": "{count} row skipped: {reason}", + "skipGroupFew": "{count} rows skipped: {reason}", + "skipGroupOther": "{count} rows skipped: {reason}", + "skipDetails": "Show every skipped row", + "reasonUnknownType": "unknown measurement type", + "reasonInvalidValue": "value is not a number", + "reasonValueOutOfRange": "value outside the plausible range", + "reasonUnknownUnit": "unit not recognised for this type", + "reasonMissingTimezoneOffset": "timestamp has no timezone offset", + "reasonInvalidTimestamp": "timestamp could not be read", + "reasonImplausibleTimestamp": "timestamp is in the future or before 1900", + "reasonUnexpectedGlucoseContext": "glucose context set on a row that is not blood glucose", + "reasonInvalidGlucoseContext": "glucose context is not one of the allowed values", + "reasonNotesTooLong": "note is too long", + "reasonExternalIdTooLong": "external ID is too long", + "reasonUnstableExternalId": "external ID is not stable across exports", + "reasonDuplicate": "already imported", + "reasonUnknown": "skipped" } }, "subtitle": "Export your data or import from a backup." diff --git a/messages/es.json b/messages/es.json index 61b9bbad9..0e94c76ed 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4497,7 +4497,10 @@ "rateLimited": "Demasiadas importaciones esta hora (límite 5). Inténtalo más tarde.", "failed": "La importación falló. Inténtalo de nuevo.", "readFailed": "No se pudo leer ese archivo.", - "resultSummary": "{measurements} mediciones y {moods} entradas de ánimo importadas, {skipped} omitidas." + "resultSummary": "{measurements} mediciones y {moods} entradas de ánimo importadas, {skipped} omitidas.", + "resultSuccess": "{measurements} mediciones y {moods} entradas de ánimo importadas.", + "resultNothing": "No se importó nada. Se omitieron las {skipped} entradas.", + "resultEmpty": "Ese archivo no contenía mediciones ni entradas de ánimo." }, "csv": { "title": "Archivo CSV", @@ -4518,7 +4521,30 @@ "readFailed": "No se pudo leer ese archivo.", "previewSummary": "Vista previa: {inserted} se importarían, {skipped} omitidas.", "resultSummary": "{inserted} nuevas importadas y {updated} actualizadas, {skipped} omitidas.", - "rowError": "Línea {line}: {reason}" + "rowError": "Línea {line}: {reason}", + "resultSuccess": "{inserted} nuevas importadas y {updated} actualizadas.", + "resultNothing": "No se importó nada. Se omitieron las {skipped} filas.", + "resultEmpty": "Ese archivo tiene encabezado pero ninguna fila de datos.", + "previewSuccess": "Vista previa: {inserted} se importarían.", + "previewNothing": "Vista previa: no se importaría nada. Se omitirían las {skipped} filas.", + "skipGroupOne": "{count} fila omitida: {reason}", + "skipGroupFew": "{count} filas omitidas: {reason}", + "skipGroupOther": "{count} filas omitidas: {reason}", + "skipDetails": "Mostrar todas las filas omitidas", + "reasonUnknownType": "tipo de medición desconocido", + "reasonInvalidValue": "el valor no es un número", + "reasonValueOutOfRange": "valor fuera del rango plausible", + "reasonUnknownUnit": "unidad no reconocida para este tipo", + "reasonMissingTimezoneOffset": "marca de tiempo sin desfase de zona horaria", + "reasonInvalidTimestamp": "no se pudo leer la marca de tiempo", + "reasonImplausibleTimestamp": "la marca de tiempo está en el futuro o es anterior a 1900", + "reasonUnexpectedGlucoseContext": "contexto de glucosa en una fila que no es de glucosa en sangre", + "reasonInvalidGlucoseContext": "el contexto de glucosa no es un valor permitido", + "reasonNotesTooLong": "la nota es demasiado larga", + "reasonExternalIdTooLong": "el ID externo es demasiado largo", + "reasonUnstableExternalId": "el ID externo no es estable entre exportaciones", + "reasonDuplicate": "ya importada", + "reasonUnknown": "omitida" } }, "subtitle": "Exporta tus datos o importa desde una copia de seguridad." diff --git a/messages/fr.json b/messages/fr.json index 73294c491..c9809da25 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4499,7 +4499,10 @@ "rateLimited": "Trop d'imports cette heure-ci (limite 5). Réessayez plus tard.", "failed": "L'import a échoué. Veuillez réessayer.", "readFailed": "Impossible de lire ce fichier.", - "resultSummary": "{measurements} mesures et {moods} entrées d'humeur importées, {skipped} ignorées." + "resultSummary": "{measurements} mesures et {moods} entrées d'humeur importées, {skipped} ignorées.", + "resultSuccess": "{measurements} mesures et {moods} entrées d'humeur importées.", + "resultNothing": "Rien n'a été importé. Les {skipped} entrées ont toutes été ignorées.", + "resultEmpty": "Ce fichier ne contenait ni mesures ni entrées d'humeur." }, "csv": { "title": "Fichier CSV", @@ -4520,7 +4523,30 @@ "readFailed": "Impossible de lire ce fichier.", "previewSummary": "Aperçu : {inserted} seraient importées, {skipped} ignorées.", "resultSummary": "{inserted} nouvelles importées et {updated} mises à jour, {skipped} ignorées.", - "rowError": "Ligne {line} : {reason}" + "rowError": "Ligne {line} : {reason}", + "resultSuccess": "{inserted} nouvelles importées et {updated} mises à jour.", + "resultNothing": "Rien n'a été importé. Les {skipped} lignes ont toutes été ignorées.", + "resultEmpty": "Ce fichier a un en-tête mais aucune ligne de données.", + "previewSuccess": "Aperçu : {inserted} seraient importées.", + "previewNothing": "Aperçu : rien ne serait importé. Les {skipped} lignes seraient toutes ignorées.", + "skipGroupOne": "{count} ligne ignorée : {reason}", + "skipGroupFew": "{count} lignes ignorées : {reason}", + "skipGroupOther": "{count} lignes ignorées : {reason}", + "skipDetails": "Afficher toutes les lignes ignorées", + "reasonUnknownType": "type de mesure inconnu", + "reasonInvalidValue": "la valeur n'est pas un nombre", + "reasonValueOutOfRange": "valeur hors de la plage plausible", + "reasonUnknownUnit": "unité non reconnue pour ce type", + "reasonMissingTimezoneOffset": "horodatage sans décalage horaire", + "reasonInvalidTimestamp": "horodatage illisible", + "reasonImplausibleTimestamp": "horodatage dans le futur ou antérieur à 1900", + "reasonUnexpectedGlucoseContext": "contexte glycémique sur une ligne qui n'est pas une glycémie", + "reasonInvalidGlucoseContext": "le contexte glycémique n'est pas une valeur autorisée", + "reasonNotesTooLong": "la note est trop longue", + "reasonExternalIdTooLong": "l'identifiant externe est trop long", + "reasonUnstableExternalId": "l'identifiant externe n'est pas stable d'un export à l'autre", + "reasonDuplicate": "déjà importée", + "reasonUnknown": "ignorée" } }, "subtitle": "Exportez vos données ou importez depuis une sauvegarde." diff --git a/messages/it.json b/messages/it.json index ec0ae461c..c5af13184 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4489,7 +4489,10 @@ "rateLimited": "Troppe importazioni in questa ora (limite 5). Riprova più tardi.", "failed": "L'importazione non è riuscita. Riprova.", "readFailed": "Impossibile leggere il file.", - "resultSummary": "{measurements} misurazioni e {moods} voci dell'umore importate, {skipped} saltate." + "resultSummary": "{measurements} misurazioni e {moods} voci dell'umore importate, {skipped} saltate.", + "resultSuccess": "{measurements} misurazioni e {moods} voci dell'umore importate.", + "resultNothing": "Non è stato importato nulla. Tutte le {skipped} voci sono state saltate.", + "resultEmpty": "Questo file non conteneva né misurazioni né voci dell'umore." }, "csv": { "title": "File CSV", @@ -4510,7 +4513,30 @@ "readFailed": "Impossibile leggere questo file.", "previewSummary": "Anteprima: {inserted} verrebbero importate, {skipped} saltate.", "resultSummary": "{inserted} nuove importate e {updated} aggiornate, {skipped} saltate.", - "rowError": "Riga {line}: {reason}" + "rowError": "Riga {line}: {reason}", + "resultSuccess": "{inserted} nuove importate e {updated} aggiornate.", + "resultNothing": "Non è stato importato nulla. Tutte le {skipped} righe sono state saltate.", + "resultEmpty": "Questo file ha un'intestazione ma nessuna riga di dati.", + "previewSuccess": "Anteprima: {inserted} verrebbero importate.", + "previewNothing": "Anteprima: non verrebbe importato nulla. Tutte le {skipped} righe verrebbero saltate.", + "skipGroupOne": "{count} riga saltata: {reason}", + "skipGroupFew": "{count} righe saltate: {reason}", + "skipGroupOther": "{count} righe saltate: {reason}", + "skipDetails": "Mostra tutte le righe saltate", + "reasonUnknownType": "tipo di misurazione sconosciuto", + "reasonInvalidValue": "il valore non è un numero", + "reasonValueOutOfRange": "valore fuori dall'intervallo plausibile", + "reasonUnknownUnit": "unità non riconosciuta per questo tipo", + "reasonMissingTimezoneOffset": "marca temporale senza offset del fuso orario", + "reasonInvalidTimestamp": "marca temporale illeggibile", + "reasonImplausibleTimestamp": "marca temporale nel futuro o precedente al 1900", + "reasonUnexpectedGlucoseContext": "contesto glicemico su una riga che non è glicemia", + "reasonInvalidGlucoseContext": "il contesto glicemico non è un valore consentito", + "reasonNotesTooLong": "la nota è troppo lunga", + "reasonExternalIdTooLong": "l'ID esterno è troppo lungo", + "reasonUnstableExternalId": "l'ID esterno non è stabile tra un export e l'altro", + "reasonDuplicate": "già importata", + "reasonUnknown": "saltata" } }, "subtitle": "Esporta i tuoi dati o importa da un backup." diff --git a/messages/pl.json b/messages/pl.json index 7b852c5ef..4d25b9249 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4483,7 +4483,10 @@ "rateLimited": "Zbyt wiele importów w tej godzinie (limit 5). Spróbuj później.", "failed": "Import nie powiódł się. Spróbuj ponownie.", "readFailed": "Nie można odczytać tego pliku.", - "resultSummary": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju, pominięto {skipped}." + "resultSummary": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju, pominięto {skipped}.", + "resultSuccess": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju.", + "resultNothing": "Nic nie zaimportowano. Pominięto wszystkie {skipped} wpisów.", + "resultEmpty": "Ten plik nie zawierał pomiarów ani wpisów nastroju." }, "csv": { "title": "Plik CSV", @@ -4504,7 +4507,30 @@ "readFailed": "Nie udało się odczytać tego pliku.", "previewSummary": "Podgląd: {inserted} zostanie zaimportowanych, pominięto {skipped}.", "resultSummary": "Zaimportowano {inserted} nowych i zaktualizowano {updated}, pominięto {skipped}.", - "rowError": "Wiersz {line}: {reason}" + "rowError": "Wiersz {line}: {reason}", + "resultSuccess": "Zaimportowano {inserted} nowych i zaktualizowano {updated}.", + "resultNothing": "Nic nie zaimportowano. Pominięto wszystkie {skipped} wierszy.", + "resultEmpty": "Ten plik ma nagłówek, ale nie zawiera wierszy danych.", + "previewSuccess": "Podgląd: {inserted} zostanie zaimportowanych.", + "previewNothing": "Podgląd: nic nie zostanie zaimportowane. Pominięte zostaną wszystkie {skipped} wierszy.", + "skipGroupOne": "Pominięto {count} wiersz: {reason}", + "skipGroupFew": "Pominięto {count} wiersze: {reason}", + "skipGroupOther": "Pominięto {count} wierszy: {reason}", + "skipDetails": "Pokaż wszystkie pominięte wiersze", + "reasonUnknownType": "nieznany typ pomiaru", + "reasonInvalidValue": "wartość nie jest liczbą", + "reasonValueOutOfRange": "wartość poza wiarygodnym zakresem", + "reasonUnknownUnit": "jednostka nierozpoznana dla tego typu", + "reasonMissingTimezoneOffset": "znacznik czasu bez przesunięcia strefy czasowej", + "reasonInvalidTimestamp": "nie udało się odczytać znacznika czasu", + "reasonImplausibleTimestamp": "znacznik czasu w przyszłości lub sprzed 1900 roku", + "reasonUnexpectedGlucoseContext": "kontekst glukozy w wierszu, który nie jest pomiarem glukozy", + "reasonInvalidGlucoseContext": "kontekst glukozy nie jest dozwoloną wartością", + "reasonNotesTooLong": "notatka jest zbyt długa", + "reasonExternalIdTooLong": "identyfikator zewnętrzny jest zbyt długi", + "reasonUnstableExternalId": "identyfikator zewnętrzny nie jest stabilny między eksportami", + "reasonDuplicate": "już zaimportowano", + "reasonUnknown": "pominięto" } }, "subtitle": "Wyeksportuj dane lub zaimportuj z kopii zapasowej." diff --git a/prisma/migrations/0274_glucose_context_optional/migration.sql b/prisma/migrations/0274_glucose_context_optional/migration.sql new file mode 100644 index 000000000..088d8db6a --- /dev/null +++ b/prisma/migrations/0274_glucose_context_optional/migration.sql @@ -0,0 +1,31 @@ +-- v1.32.x — a blood-glucose reading may carry no context. +-- +-- Migration 0021 introduced `measurements.glucose_context` with a CHECK that +-- read both ways at once: a BLOOD_GLUCOSE row MUST carry a context, and every +-- other type MUST NOT. The second half is right and stays. The first half was +-- written when the only glucose writer was the manual entry form, where a +-- person picks fasting / post-meal / random / bedtime at the moment of the +-- reading. +-- +-- Every machine writer that arrived later has no such classification per +-- sample: a continuous sensor export, the Nightscout SGV pull, the MCP +-- `log_measurement` tool and the Telegram numeric capture all build their row +-- without a context, so the insert failed against this constraint — a CSV +-- import of a sensor history wrote nothing, a Nightscout tick counted every +-- reading as a failed row. +-- +-- A missing context is missing. It stores as NULL, never as a fifth enum +-- member: every per-context reader (the doctor report, the FHIR per-context +-- Observations, the analytics + dashboard breakdowns, the target builder) +-- selects by equality against the four members, so NULL falls out of each +-- rather than being presented as a classification that nobody recorded. The +-- context-agnostic readers (the clinical panel, the charts, the all-time +-- summary) keep the row in full. +ALTER TABLE "measurements" + DROP CONSTRAINT IF EXISTS "measurements_glucose_context_requires_type"; + +ALTER TABLE "measurements" + ADD CONSTRAINT "measurements_glucose_context_requires_type" + CHECK ( + type = 'BLOOD_GLUCOSE' OR glucose_context IS NULL + ); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fd31c7cf6..3b31a9a02 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1637,7 +1637,13 @@ model Measurement { // explicitly LEGACY_UNKNOWN until a native sync or archive reprocess repairs // them. aggregationProvenance MeasurementAggregationProvenance? @map("aggregation_provenance") - // Only set for BLOOD_GLUCOSE measurements. NULL for all others. + // Only set for BLOOD_GLUCOSE measurements. NULL for all others, and NULL + // for a BLOOD_GLUCOSE row whose writer has no classification to record — + // a sensor export, the Nightscout pull, the MCP tool, the Telegram capture. + // A CHECK constraint (migrations 0021 + 0274) enforces the type pairing in + // one direction only: a non-glucose row can never carry a context. Every + // per-context reader selects by equality against the four enum members, so + // a NULL context stays absent rather than reading as a classification. glucoseContext GlucoseContext? @map("glucose_context") // v1.4.23 — per-stage sleep label. NULL for non-sleep rows; non-NULL // only for `type = SLEEP_DURATION`. Mirrors the glucoseContext diff --git a/src/components/settings/import-panel/csv-import-card.tsx b/src/components/settings/import-panel/csv-import-card.tsx index 1b4676291..609b43d7a 100644 --- a/src/components/settings/import-panel/csv-import-card.tsx +++ b/src/components/settings/import-panel/csv-import-card.tsx @@ -4,7 +4,6 @@ import { useId, useRef, useState } from "react"; import Link from "next/link"; import { AlertCircle, - CheckCircle2, Download, FileSpreadsheet, Loader2, @@ -19,21 +18,10 @@ import { apiFetchRaw } from "@/lib/api/api-fetch"; import { ImportCardShell } from "./import-card-shell"; import { MAX_PASTE_CHARS } from "./constants"; import { EXAMPLE_CSV } from "./import-examples"; - -interface CsvRowResult { - line: number; - status: "inserted" | "updated" | "skipped"; - reason?: string; -} - -interface CsvImportResult { - inserted: number; - updated: number; - skipped: number; - total: number; - dryRun: boolean; - rows: CsvRowResult[]; -} +import { + CsvImportResultView, + type CsvImportResult, +} from "./import-result-view"; export function CsvImportCard() { const { t } = useTranslations(); @@ -117,8 +105,6 @@ export function CsvImportCard() { } } - const errorRows = result?.rows.filter((r) => r.status === "skipped") ?? []; - return (
- {result && ( -
-

-

- {errorRows.length > 0 && ( -
    - {errorRows.slice(0, 50).map((r) => ( -
  • - {t("settings.sections.export.import.csv.rowError", { - line: r.line, - reason: r.reason ?? "skipped", - })} -
  • - ))} -
- )} -
- )} + {result && } {error && (

0 ? "failed" : "empty"; + return skipped > 0 ? "partial" : "success"; +} + +export interface SkipReasonGroup { + /** The machine-readable reason as the route reported it. */ + reason: string; + count: number; +} + +/** A row of the per-row envelope, narrowed to what the grouping needs. */ +export interface ImportRowResult { + line: number; + status: string; + reason?: string; +} + +const UNKNOWN_REASON = "unknown"; + +/** + * Collapse the per-row envelope into one entry per skip reason. + * + * A sensor export that trips one validation trips it on every row, so the + * raw list is the same sentence a few thousand times. Grouping turns "scroll + * fifty identical lines" into one fact with a count. Ordered by count + * descending, then by reason, so the same result always renders the same way. + */ +export function groupSkipReasons( + rows: readonly ImportRowResult[], +): SkipReasonGroup[] { + const counts = new Map(); + for (const row of rows) { + if (row.status !== "skipped") continue; + const reason = row.reason ?? UNKNOWN_REASON; + counts.set(reason, (counts.get(reason) ?? 0) + 1); + } + return [...counts.entries()] + .map(([reason, count]) => ({ reason, count })) + .sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)); +} diff --git a/src/components/settings/import-panel/import-result-view.tsx b/src/components/settings/import-panel/import-result-view.tsx new file mode 100644 index 000000000..78ac1fe44 --- /dev/null +++ b/src/components/settings/import-panel/import-result-view.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { AlertCircle, CheckCircle2, Info, TriangleAlert } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { + classifyImportOutcome, + groupSkipReasons, + type ImportOutcome, + type ImportRowResult, +} from "./import-result-state"; + +/** + * The one mapping from an import outcome to how it looks. + * + * `CheckCircle2` and `text-success` appear exactly once in the import panel, + * on the `success` key, so no future edit can attach a tick to a result that + * wrote nothing. `import-result-guard.test.ts` holds that line. + */ +const OUTCOME_PRESENTATION: Record< + ImportOutcome, + { Icon: LucideIcon; className: string } +> = { + success: { Icon: CheckCircle2, className: "text-success" }, + partial: { Icon: TriangleAlert, className: "text-warning" }, + failed: { Icon: AlertCircle, className: "text-destructive" }, + empty: { Icon: Info, className: "text-muted-foreground" }, +}; + +/** + * The headline line of an import result. Sits inside each card's existing + * `aria-live="polite"` region, so the outcome is announced as it lands. + */ +export function ImportOutcomeLine({ + outcome, + message, + testId, +}: { + outcome: ImportOutcome; + message: string; + testId: string; +}) { + const { Icon, className } = OUTCOME_PRESENTATION[outcome]; + return ( +

+

+ ); +} + +export interface CsvImportResult { + inserted: number; + updated: number; + skipped: number; + total: number; + dryRun: boolean; + rows: ImportRowResult[]; +} + +/** + * Plain-language label per machine-readable skip reason. + * + * Written as literal `t(...)` calls rather than a key lookup so the i18n + * call-site coverage guard sees every one of them. + */ +function useSkipReasonLabel(): (reason: string) => string { + const { t } = useTranslations(); + const labels: Record = { + unknown_type: t("settings.sections.export.import.csv.reasonUnknownType"), + invalid_value: t("settings.sections.export.import.csv.reasonInvalidValue"), + value_out_of_range: t( + "settings.sections.export.import.csv.reasonValueOutOfRange", + ), + unknown_unit: t("settings.sections.export.import.csv.reasonUnknownUnit"), + missing_timezone_offset: t( + "settings.sections.export.import.csv.reasonMissingTimezoneOffset", + ), + invalid_timestamp: t( + "settings.sections.export.import.csv.reasonInvalidTimestamp", + ), + implausible_timestamp: t( + "settings.sections.export.import.csv.reasonImplausibleTimestamp", + ), + unexpected_glucose_context: t( + "settings.sections.export.import.csv.reasonUnexpectedGlucoseContext", + ), + invalid_glucose_context: t( + "settings.sections.export.import.csv.reasonInvalidGlucoseContext", + ), + notes_too_long: t("settings.sections.export.import.csv.reasonNotesTooLong"), + external_id_too_long: t( + "settings.sections.export.import.csv.reasonExternalIdTooLong", + ), + unstable_external_id: t( + "settings.sections.export.import.csv.reasonUnstableExternalId", + ), + duplicate: t("settings.sections.export.import.csv.reasonDuplicate"), + }; + const fallback = t("settings.sections.export.import.csv.reasonUnknown"); + return (reason: string) => labels[reason] ?? fallback; +} + +/** + * The CSV import result: an honest headline, then the skipped rows grouped by + * reason with a count, with the per-line detail kept one disclosure away. + */ +export function CsvImportResultView({ result }: { result: CsvImportResult }) { + const { t, tCount } = useTranslations(); + const labelFor = useSkipReasonLabel(); + + const written = result.inserted + result.updated; + const outcome = classifyImportOutcome({ written, skipped: result.skipped }); + const groups = groupSkipReasons(result.rows); + const skippedRows = result.rows.filter((row) => row.status === "skipped"); + + const message = (() => { + if (outcome === "empty") { + return t("settings.sections.export.import.csv.resultEmpty"); + } + if (result.dryRun) { + if (outcome === "failed") { + return t("settings.sections.export.import.csv.previewNothing", { + skipped: result.skipped, + }); + } + if (outcome === "partial") { + return t("settings.sections.export.import.csv.previewSummary", { + inserted: result.inserted, + skipped: result.skipped, + }); + } + return t("settings.sections.export.import.csv.previewSuccess", { + inserted: result.inserted, + }); + } + if (outcome === "failed") { + return t("settings.sections.export.import.csv.resultNothing", { + skipped: result.skipped, + }); + } + if (outcome === "partial") { + return t("settings.sections.export.import.csv.resultSummary", { + inserted: result.inserted, + updated: result.updated, + skipped: result.skipped, + }); + } + return t("settings.sections.export.import.csv.resultSuccess", { + inserted: result.inserted, + updated: result.updated, + }); + })(); + + return ( +
+ + {groups.length > 0 && ( +
    + {groups.map((group) => ( +
  • + {tCount( + "settings.sections.export.import.csv.skipGroup", + group.count, + { count: group.count, reason: labelFor(group.reason) }, + )} +
  • + ))} +
+ )} + {skippedRows.length > 0 && ( +
+ + {t("settings.sections.export.import.csv.skipDetails")} + +
    + {skippedRows.map((row) => ( +
  • + {t("settings.sections.export.import.csv.rowError", { + line: row.line, + reason: labelFor(row.reason ?? "unknown"), + })} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/settings/import-panel/json-import-card.tsx b/src/components/settings/import-panel/json-import-card.tsx index 0c48645f0..128b2a7c4 100644 --- a/src/components/settings/import-panel/json-import-card.tsx +++ b/src/components/settings/import-panel/json-import-card.tsx @@ -2,14 +2,7 @@ import { useId, useRef, useState } from "react"; import Link from "next/link"; -import { - AlertCircle, - CheckCircle2, - Download, - FileJson, - Loader2, - Upload, -} from "lucide-react"; +import { AlertCircle, Download, FileJson, Loader2, Upload } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -19,6 +12,8 @@ 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 { classifyImportOutcome } from "./import-result-state"; +import { ImportOutcomeLine } from "./import-result-view"; interface JsonImportResult { measurements: number; @@ -28,6 +23,38 @@ interface JsonImportResult { export function JsonImportCard() { const { t } = useTranslations(); + + // Same reasoning as the CSV card: the JSON route answers 200 with a per- + // concept count envelope, so a payload where every entry was refused used + // to render the success tick. The counts decide the tone. + const jsonOutcome = (r: JsonImportResult) => + classifyImportOutcome({ + written: r.measurements + r.moodEntries, + skipped: r.skipped, + }); + const jsonMessage = (r: JsonImportResult) => { + const outcome = jsonOutcome(r); + if (outcome === "empty") { + return t("settings.sections.export.import.json.resultEmpty"); + } + if (outcome === "failed") { + return t("settings.sections.export.import.json.resultNothing", { + skipped: r.skipped, + }); + } + if (outcome === "partial") { + return t("settings.sections.export.import.json.resultSummary", { + measurements: r.measurements, + moods: r.moodEntries, + skipped: r.skipped, + }); + } + return t("settings.sections.export.import.json.resultSuccess", { + measurements: r.measurements, + moods: r.moodEntries, + }); + }; + const fileInputRef = useRef(null); const [text, setText] = useState(""); const [busy, setBusy] = useState(false); @@ -164,22 +191,11 @@ export function JsonImportCard() {
{result && ( -

-

+ )} {error && (

= { tags: ["Import"], summary: "Import measurements from CSV", description: - "Bulk-import measurements from a CSV file (web cold-start escape hatch). Body is raw `text/csv` (≤ 16 MB, ≤ 10 000 valid rows). Header (order-independent): `type,value,unit,measuredAt[,glucoseContext,notes,externalId]`. `measuredAt` must carry an explicit ISO-8601 offset and is bounded (no future beyond a 5-min skew, no instant before 1900). Glucose accepts `mmol/L` (converted to canonical `mg/dL`); weight accepts `lb` (converted to `kg`). An `externalId` column makes re-upload idempotent (upsert on `(userId, type, source=IMPORT, externalId)`); without it a re-upload duplicates. `?dryRun=1` validates + previews without writing. Shares the 5/hour `import:` rate bucket.", + "Bulk-import measurements from a CSV file (web cold-start escape hatch). Body is raw `text/csv` (≤ 16 MB, ≤ 10 000 valid rows). Header (order-independent): `type,value,unit,measuredAt[,glucoseContext,notes,externalId]`. `measuredAt` must carry an explicit ISO-8601 offset and is bounded (no future beyond a 5-min skew, no instant before 1900). Glucose accepts `mmol/L` (converted to canonical `mg/dL`); weight accepts `lb` (converted to `kg`). `glucoseContext` is optional on a `BLOOD_GLUCOSE` row — a blank cell stores no context, which is the normal shape of a continuous-sensor export; a non-empty value is still checked against the enum, and the column is refused on any other type. An `externalId` column makes re-upload idempotent (upsert on `(userId, type, source=IMPORT, externalId)`); without it a re-upload duplicates. `?dryRun=1` validates + previews without writing. Shares the 5/hour `import:` rate bucket.", parameters: [ { name: "dryRun", @@ -71,7 +71,7 @@ export const importPaths: NonNullable = { schema: { type: "string", example: - "type,value,unit,measuredAt,glucoseContext,notes,externalId\nWEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning,\nBLOOD_GLUCOSE,5.3,mmol/L,2026-05-01T08:05:00+02:00,FASTING,,meter-001", + "type,value,unit,measuredAt,glucoseContext,notes,externalId\nWEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning,\nBLOOD_GLUCOSE,5.3,mmol/L,2026-05-01T08:05:00+02:00,FASTING,,meter-001\nBLOOD_GLUCOSE,5.9,mmol/L,2026-05-01T08:20:00+02:00,,,sensor-001", }, }, }, diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 2621db8c3..4c4660d36 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -256,7 +256,9 @@ export const measurementResource = z .string() .nullable() .optional() - .describe("Set only on BLOOD_GLUCOSE rows."), + .describe( + "Set only on BLOOD_GLUCOSE rows, and null there too when the writer recorded no classification (a sensor export, a device sync). Never invent one.", + ), sleepStage: z .string() .nullable() diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 090d510c4..a3eeaca0a 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -70,7 +70,7 @@ loginPasswordSchema.meta({ createMeasurementSchema.meta({ id: "CreateMeasurementRequest", description: - "Single-measurement ingest body. Plausibility-range guard runs server-side; out-of-range values fail 422.", + "Single-measurement ingest body. Plausibility-range guard runs server-side; out-of-range values fail 422. `glucoseContext` stays REQUIRED on a `BLOOD_GLUCOSE` row here: this is the hand-entry surface, where the person taking the reading knows whether it was fasting or after a meal. The bulk ingest paths (CSV import, JSON import, device sync) accept a contextless reading, because a sensor export classifies nothing per sample.", }); listMeasurementsSchema.meta({ diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 7c9e3fa22..32dbc045f 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -648,8 +648,10 @@ export const createMeasurementSchema = z // `APPLE_HEALTH`; `WITHINGS` / `IMPORT` / `COMPUTED` are server-owned and // forging them would pollute the per-source canonical picker. source: writableMeasurementSourceEnum.optional().default("MANUAL"), - // Only applies when type === BLOOD_GLUCOSE. Mirrored by a CHECK - // constraint in Postgres (see migration 0021). + // Only applies when type === BLOOD_GLUCOSE. The "not on any other type" + // half is mirrored by a CHECK constraint in Postgres (migrations 0021 + + // 0274). The "required on a glucose row" half below is a rule of THIS + // schema only — see the refine. glucoseContext: glucoseContextEnum.optional(), // v1.4.25 W10 reconcile (code-review M4): the single-entry POST // dropped `deviceType` silently because the column existed on @@ -669,6 +671,14 @@ export const createMeasurementSchema = z .refine((data) => validateMeasurementRange(data.type, data.value) === null, { message: "Value out of plausible range", }) + // Deliberately asymmetric with the bulk ingest paths. This schema backs the + // hand-entry surface, where a person is present at the reading and knows + // whether it was fasting, after a meal, random or at bedtime — the form + // always offers the choice, so requiring it costs nothing and keeps the + // per-context breakdowns meaningful. A sensor or meter export has no such + // answer per sample, so the CSV import, the JSON import and the device sync + // paths accept a contextless reading and store NULL rather than guessing. + // Absence stays absence; it is never widened into a classification. .refine( (data) => (data.type === "BLOOD_GLUCOSE" && data.glucoseContext !== undefined) || From 4e95bd28f80086e02f183f5541fcac5f04af5d75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 21:45:10 +0200 Subject: [PATCH 2/7] fix(import): stop the Apple Health card ticking an archive that landed nothing Same shape as the CSV and JSON cards: reaching the done state rendered the success tick whether or not a single row was upserted. The archive's count picks the tone now, and an archive that wrote nothing says so. The clinical-record count stays out of that decision. Skipping electrocardiograms and lab documents is a documented, deliberate exclusion, not a refused row, so it must not read as a warning. Refs #640 --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../api/import/csv/__tests__/route.test.ts | 84 +++++++ .../__tests__/import-result-guard.test.ts | 75 ++++++ .../__tests__/import-result-state.test.ts | 94 ++++++++ .../__tests__/import-result-view.test.tsx | 180 +++++++++++++++ .../import-panel/apple-health-import-card.tsx | 42 ++-- .../import/__tests__/csv-measurements.test.ts | 45 +++- .../csv-import-contextless-glucose.test.ts | 215 ++++++++++++++++++ 13 files changed, 719 insertions(+), 22 deletions(-) create mode 100644 src/components/settings/import-panel/__tests__/import-result-guard.test.ts create mode 100644 src/components/settings/import-panel/__tests__/import-result-state.test.ts create mode 100644 src/components/settings/import-panel/__tests__/import-result-view.test.tsx create mode 100644 tests/integration/csv-import-contextless-glucose.test.ts diff --git a/messages/de.json b/messages/de.json index 05226a816..0457825b4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4472,6 +4472,7 @@ "failed": "Der Import ist fehlgeschlagen.", "rowsImported": "Bisher {count} Einträge importiert", "doneSummary": "Fertig — {imported} von {read} Datensätzen importiert, {skipped} klinische Datensätze übersprungen.", + "doneNothing": "Aus diesem Archiv wurde nichts importiert: 0 von {read} Datensätzen sind angekommen.", "estimateWarning": "Kumulative Summen für {count} Tag(e) wurden anhand der größten Exportquelle geschätzt. Native Apple-Health-Statistiken bleiben maßgeblich.", "phase": { "queued": "In Warteschlange…", diff --git a/messages/en.json b/messages/en.json index da58b3e62..37e2e2186 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4462,6 +4462,7 @@ "failed": "The import failed.", "rowsImported": "{count} rows imported so far", "doneSummary": "Done — {imported} of {read} records imported, {skipped} clinical records skipped.", + "doneNothing": "Nothing was imported from that archive: 0 of {read} records landed.", "estimateWarning": "Cumulative totals for {count} day(s) were estimated from the largest export source. Native Apple Health statistics remain authoritative.", "phase": { "queued": "Queued…", diff --git a/messages/es.json b/messages/es.json index 0e94c76ed..5430a002a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4474,6 +4474,7 @@ "failed": "La importación falló.", "rowsImported": "{count} filas importadas hasta ahora", "doneSummary": "Listo: {imported} de {read} registros importados, {skipped} registros clínicos omitidos.", + "doneNothing": "No se importó nada de ese archivo: llegaron 0 de {read} registros.", "estimateWarning": "Los totales acumulados de {count} día(s) se estimaron a partir de la mayor fuente de la exportación. Las estadísticas nativas de Apple Health siguen siendo la referencia.", "phase": { "queued": "En cola…", diff --git a/messages/fr.json b/messages/fr.json index c9809da25..0bc0a7a47 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4476,6 +4476,7 @@ "failed": "L'import a échoué.", "rowsImported": "{count} lignes importées jusqu'ici", "doneSummary": "Terminé — {imported} enregistrements sur {read} importés, {skipped} enregistrements cliniques ignorés.", + "doneNothing": "Rien n'a été importé de cette archive : 0 enregistrement sur {read} est arrivé.", "estimateWarning": "Les totaux cumulés de {count} jour(s) ont été estimés à partir de la source d’export la plus élevée. Les statistiques Apple Santé natives restent la référence.", "phase": { "queued": "En file d'attente…", diff --git a/messages/it.json b/messages/it.json index c5af13184..8064f1679 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4466,6 +4466,7 @@ "failed": "L'importazione non è riuscita.", "rowsImported": "{count} righe importate finora", "doneSummary": "Fatto: {imported} di {read} record importati, {skipped} record clinici saltati.", + "doneNothing": "Da questo archivio non è stato importato nulla: sono arrivati 0 record su {read}.", "estimateWarning": "I totali cumulativi di {count} giorno/i sono stati stimati dalla fonte di esportazione più alta. Le statistiche native di Apple Salute restano autorevoli.", "phase": { "queued": "In coda…", diff --git a/messages/pl.json b/messages/pl.json index 4d25b9249..9d18cea87 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4460,6 +4460,7 @@ "failed": "Import nie powiódł się.", "rowsImported": "Zaimportowano dotąd {count} wierszy", "doneSummary": "Gotowe — zaimportowano {imported} z {read} rekordów, pominięto {skipped} rekordów klinicznych.", + "doneNothing": "Z tego archiwum nic nie zaimportowano: dotarło 0 z {read} rekordów.", "estimateWarning": "Łączne wartości dla {count} dni oszacowano na podstawie największego źródła w eksporcie. Natywne statystyki Apple Health pozostają nadrzędne.", "phase": { "queued": "W kolejce…", diff --git a/src/app/api/import/csv/__tests__/route.test.ts b/src/app/api/import/csv/__tests__/route.test.ts index 5fae65083..70e47ed3e 100644 --- a/src/app/api/import/csv/__tests__/route.test.ts +++ b/src/app/api/import/csv/__tests__/route.test.ts @@ -339,3 +339,87 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => { ); }); }); + +describe("POST /api/import/csv — contextless blood glucose", () => { + // #640 — the reported file. A sensor export leaves `glucoseContext` blank + // on every row; the route must build a row with the column NULL rather + // than refusing the reading. + it("writes a contextless sensor reading with the canonical shape", async () => { + const res = await POST( + csvRequest( + [ + HEADER, + "BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1", + ].join("\n"), + ), + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(1); + expect(body.data?.skipped).toBe(0); + expect(body.data?.rows).toEqual([{ line: 2, status: "inserted" }]); + + const row = mMeasurement().createManyAndReturn.mock.calls[0][0].data[0]; + expect(row).toMatchObject({ + userId: "u-1", + type: "BLOOD_GLUCOSE", + unit: "mg/dL", + source: "IMPORT", + externalId: "sensor-1", + glucoseContext: null, + }); + expect(row.value).toBeCloseTo(95.4848, 3); + expect((row.measuredAt as Date).toISOString()).toBe( + "2024-04-03T02:15:00.000Z", + ); + }); + + it("keeps a re-upload of the same external id idempotent", async () => { + // The key already exists under (userId, type, source=IMPORT, externalId). + mMeasurement().findMany.mockResolvedValue([ + { type: "BLOOD_GLUCOSE", externalId: "sensor-1" }, + ]); + + const res = await POST( + csvRequest( + [ + HEADER, + "BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1", + ].join("\n"), + ), + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(0); + expect(body.data?.updated).toBe(1); + expect(body.data?.rows).toEqual([{ line: 2, status: "updated" }]); + // No second row minted — the existing one is updated in place. + expect(mMeasurement().createManyAndReturn).not.toHaveBeenCalled(); + const update = mMeasurement().updateMany.mock.calls[0][0]; + expect(update.where).toMatchObject({ + userId: "u-1", + source: "IMPORT", + type: "BLOOD_GLUCOSE", + externalId: "sensor-1", + }); + expect(update.data).toMatchObject({ glucoseContext: null, unit: "mg/dL" }); + }); + + it("still refuses a context that is not one of the four", async () => { + const res = await POST( + csvRequest( + [HEADER, "BLOOD_GLUCOSE,95,mg/dL,2026-05-01T08:00:00Z,LUNCH,,"].join( + "\n", + ), + ), + ); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(0); + expect(body.data?.rows[0]).toMatchObject({ + status: "skipped", + reason: "invalid_glucose_context", + }); + }); +}); diff --git a/src/components/settings/import-panel/__tests__/import-result-guard.test.ts b/src/components/settings/import-panel/__tests__/import-result-guard.test.ts new file mode 100644 index 000000000..ee5f100df --- /dev/null +++ b/src/components/settings/import-panel/__tests__/import-result-guard.test.ts @@ -0,0 +1,75 @@ +/** + * #640 structural guard — "wrote nothing, looked successful" cannot come back. + * + * The classifier property in `import-result-state.test.ts` proves the rule. + * This proves the rule is the only route to the affordance: the success icon + * and the success colour exist in exactly one file in the import panel, on + * exactly one key of the outcome table, and no card reaches for them + * directly. A future card that hand-rolls a green tick fails here rather than + * shipping and lying to a self-hoster about their sensor history. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +const PANEL_DIR = join(__dirname, ".."); +const PRESENTATION_FILE = "import-result-view.tsx"; + +/** The success affordance: the tick component and the success colour token. */ +const SUCCESS_MARKERS = ["CheckCircle2", "text-success"] as const; + +function panelSourceFiles(): string[] { + return readdirSync(PANEL_DIR).filter( + (name) => name.endsWith(".ts") || name.endsWith(".tsx"), + ); +} + +/** Code only — a comment naming the marker is prose, not an affordance. */ +function readCode(name: string): string { + return readFileSync(join(PANEL_DIR, name), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); +} + +describe("import panel — success affordance is owned by the outcome table", () => { + it("mentions the success markers in the presentation module only", () => { + const offenders = panelSourceFiles() + .filter((name) => name !== PRESENTATION_FILE) + .filter((name) => { + const source = readCode(name); + return SUCCESS_MARKERS.some((marker) => source.includes(marker)); + }); + expect(offenders).toEqual([]); + }); + + it("binds each success marker to the success outcome inside that module", () => { + const source = readCode(PRESENTATION_FILE); + for (const marker of SUCCESS_MARKERS) { + // Once as the lucide import (the tick only), once on the table row. + const uses = source.split(marker).length - 1; + expect(uses).toBe(marker === "CheckCircle2" ? 2 : 1); + } + // The table row itself: `success:` carries both markers, and no other + // outcome key does. + const successRow = source.match(/success:\s*\{[^}]*\}/)?.[0] ?? ""; + expect(successRow).toContain("CheckCircle2"); + expect(successRow).toContain("text-success"); + for (const other of ["partial", "failed", "empty"]) { + const row = source.match(new RegExp(`${other}:\\s*\\{[^}]*\\}`))?.[0]; + expect(row).toBeTruthy(); + for (const marker of SUCCESS_MARKERS) { + expect(row).not.toContain(marker); + } + } + }); + + it("routes every card's result line through the shared outcome line", () => { + for (const name of [ + "csv-import-card.tsx", + "json-import-card.tsx", + "apple-health-import-card.tsx", + ]) { + expect(readCode(name)).toMatch(/CsvImportResultView|ImportOutcomeLine/); + } + }); +}); diff --git a/src/components/settings/import-panel/__tests__/import-result-state.test.ts b/src/components/settings/import-panel/__tests__/import-result-state.test.ts new file mode 100644 index 000000000..7870b680e --- /dev/null +++ b/src/components/settings/import-panel/__tests__/import-result-state.test.ts @@ -0,0 +1,94 @@ +/** + * #640 — an import that wrote nothing must never read as a success. + * + * The reported failure was not the parser refusing rows; it was the card + * answering that refusal with a green tick, which is what turned a + * recoverable problem into a silent one. These pin the classifier that both + * import cards render from, and the structural guard that keeps the success + * affordance attached to the success outcome alone lives alongside in + * `import-result-guard.test.ts`. + */ +import { describe, it, expect } from "vitest"; + +import { + classifyImportOutcome, + groupSkipReasons, +} from "../import-result-state"; + +describe("classifyImportOutcome", () => { + it("calls the reported production result a failure", () => { + // The audit envelope from the report: 1 597 rows in, nothing written. + expect(classifyImportOutcome({ written: 0, skipped: 1597 })).toBe("failed"); + }); + + it("calls a file with no data rows empty, not a success", () => { + expect(classifyImportOutcome({ written: 0, skipped: 0 })).toBe("empty"); + }); + + it("calls a mixed result partial", () => { + expect(classifyImportOutcome({ written: 3, skipped: 2 })).toBe("partial"); + }); + + it("calls a clean result a success", () => { + expect(classifyImportOutcome({ written: 5, skipped: 0 })).toBe("success"); + }); + + it("never reaches success while nothing was written", () => { + for (let written = 0; written <= 4; written++) { + for (let skipped = 0; skipped <= 4; skipped++) { + const outcome = classifyImportOutcome({ written, skipped }); + if (written === 0) { + expect(outcome).not.toBe("success"); + expect(outcome).not.toBe("partial"); + } + if (outcome === "success") { + expect(written).toBeGreaterThan(0); + expect(skipped).toBe(0); + } + } + } + }); +}); + +describe("groupSkipReasons", () => { + it("collapses a repeated reason to one entry with a count", () => { + const rows = Array.from({ length: 1597 }, (_, i) => ({ + line: i + 2, + status: "skipped", + reason: "invalid_glucose_context", + })); + expect(groupSkipReasons(rows)).toEqual([ + { reason: "invalid_glucose_context", count: 1597 }, + ]); + }); + + it("ignores rows that were written", () => { + expect( + groupSkipReasons([ + { line: 2, status: "inserted" }, + { line: 3, status: "updated" }, + { line: 4, status: "skipped", reason: "unknown_unit" }, + ]), + ).toEqual([{ reason: "unknown_unit", count: 1 }]); + }); + + it("orders by count descending, then by reason", () => { + const rows = [ + { line: 2, status: "skipped", reason: "unknown_unit" }, + { line: 3, status: "skipped", reason: "duplicate" }, + { line: 4, status: "skipped", reason: "duplicate" }, + { line: 5, status: "skipped", reason: "notes_too_long" }, + ]; + expect(groupSkipReasons(rows)).toEqual([ + { reason: "duplicate", count: 2 }, + { reason: "notes_too_long", count: 1 }, + { reason: "unknown_unit", count: 1 }, + ]); + }); + + it("buckets a skipped row that carries no reason", () => { + expect(groupSkipReasons([{ line: 2, status: "skipped" }])).toEqual([ + { reason: "unknown", count: 1 }, + ]); + }); +}); diff --git a/src/components/settings/import-panel/__tests__/import-result-view.test.tsx b/src/components/settings/import-panel/__tests__/import-result-view.test.tsx new file mode 100644 index 000000000..de6506f2f --- /dev/null +++ b/src/components/settings/import-panel/__tests__/import-result-view.test.tsx @@ -0,0 +1,180 @@ +/** + * #640 — the CSV import result renders what actually happened. + * + * Project convention is SSR-only component tests (vitest runs `node`; + * `@testing-library/react` is not installed), so the view takes the result as + * a prop and each state is rendered directly. + * + * The states pinned here are the ones the report separated: a file where + * everything was refused must not carry the success icon or success wording, + * a mixed file reads as a warning, a clean file reads as a success, and a + * file with no data rows gets its own message rather than either. Skip + * reasons render grouped with a count, in plain language, inside the card's + * `aria-live` region so a screen reader hears the outcome too. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/export", +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { + CsvImportResultView, + type CsvImportResult, +} from "../import-result-view"; +import { CsvImportCard } from "../csv-import-card"; + +function render(node: React.ReactElement) { + return renderToStaticMarkup( + {node}, + ); +} + +function result(over: Partial = {}): CsvImportResult { + return { + inserted: 0, + updated: 0, + skipped: 0, + total: 0, + dryRun: false, + rows: [], + ...over, + }; +} + +/** The production envelope from the report: nothing written, everything refused. */ +const ALL_SKIPPED = result({ + skipped: 1597, + total: 1597, + rows: Array.from({ length: 1597 }, (_, i) => ({ + line: i + 2, + status: "skipped", + reason: "invalid_glucose_context", + })), +}); + +describe(" — result states", () => { + it("renders an all-skipped import as a failure, not a success", () => { + const html = render(); + expect(html).toContain('data-outcome="failed"'); + expect(html).not.toContain('data-outcome="success"'); + expect(html).not.toContain("text-success"); + expect(html).toContain("Nothing was imported"); + expect(html).toContain("All 1597 rows were skipped"); + }); + + it("renders a mixed import as a warning", () => { + const html = render( + , + ); + expect(html).toContain('data-outcome="partial"'); + expect(html).toContain("text-warning"); + expect(html).not.toContain("text-success"); + }); + + it("renders a clean import as a success", () => { + const html = render( + , + ); + expect(html).toContain('data-outcome="success"'); + expect(html).toContain("text-success"); + expect(html).toContain("Imported 4 new and updated 1."); + expect(html).not.toContain("skipped"); + }); + + it("gives a file with no data rows its own message", () => { + const html = render(); + expect(html).toContain('data-outcome="empty"'); + expect(html).not.toContain("text-success"); + expect(html).toContain("no data rows"); + }); + + it("reports a preview that would import nothing as a failure", () => { + const html = render( + , + ); + expect(html).toContain('data-outcome="failed"'); + expect(html).toContain("nothing would import"); + }); +}); + +describe(" — skip reasons", () => { + it("groups a repeated reason into one counted line, in plain language", () => { + const html = render(); + expect(html).toContain( + "1597 rows skipped: glucose context is not one of the allowed values", + ); + // One grouped line, not 1 597 (and not the old first-fifty list). + const groups = html.split('data-testid="import-csv-skip-groups"')[1] ?? ""; + expect(groups.split("").length - 1).toBeGreaterThan(0); + expect(html.match(/1597 rows skipped/g)).toHaveLength(1); + }); + + it("keeps the per-line detail behind a disclosure", () => { + const html = render( + , + ); + expect(html).toContain(" { + const html = render( + , + ); + expect(html).not.toContain("brand_new_reason"); + expect(html).toContain("1 row skipped: skipped"); + }); +}); + +describe(" — announcement", () => { + it("keeps the result slot inside the polite live region", () => { + const html = render(); + expect(html).toContain('aria-live="polite"'); + // No result yet: the region is present and empty, so the first outcome to + // land is announced rather than being read as initial content. + expect(html).not.toContain('data-testid="import-csv-result"'); + }); +}); diff --git a/src/components/settings/import-panel/apple-health-import-card.tsx b/src/components/settings/import-panel/apple-health-import-card.tsx index ce22accec..cfeb60c3f 100644 --- a/src/components/settings/import-panel/apple-health-import-card.tsx +++ b/src/components/settings/import-panel/apple-health-import-card.tsx @@ -2,7 +2,7 @@ import { useCallback, useId, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { AlertCircle, CheckCircle2, Loader2, Upload } from "lucide-react"; +import { AlertCircle, Loader2, Upload } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; @@ -11,6 +11,8 @@ import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import { ImportCardShell } from "./import-card-shell"; +import { classifyImportOutcome } from "./import-result-state"; +import { ImportOutcomeLine } from "./import-result-view"; /** Terminal states the status poll stops on. */ const TERMINAL_STATES: readonly string[] = ["done", "failed"]; @@ -237,22 +239,28 @@ export function AppleHealthImportCard() {

)} {isDone && ( -
-
+ 0 + ? t("settings.sections.export.import.appleHealth.doneSummary", { + imported: status?.result?.totals?.rowsUpserted ?? 0, + read: status?.result?.totals?.recordsRead ?? 0, + skipped: status?.result?.clinical?.skipped ?? 0, + }) + : t("settings.sections.export.import.appleHealth.doneNothing", { + read: status?.result?.totals?.recordsRead ?? 0, + }) + } + testId="import-apple-health-result" + /> )} {isDone && (status?.result?.cumulativeEstimates?.days ?? 0) > 0 && ( { }); describe("parseCsvMeasurements — glucose context", () => { - it("requires a context for BLOOD_GLUCOSE", () => { + // #640 — a continuous-sensor export carries a value and a timestamp per + // reading and classifies nothing. Refusing those rows rejected the normal + // case; the reading is accepted and the context stays absent. + it("accepts a BLOOD_GLUCOSE row with no context", () => { const out = parse(["BLOOD_GLUCOSE,95,mg/dL,2026-05-01T08:00:00Z,,,"]); - expect(out.rows[0]).toMatchObject({ - status: "skipped", - reason: "missing_glucose_context", - }); + expect(out.rows[0]).toMatchObject({ status: "ok" }); + expect(out.rows[0].row).toBeDefined(); + expect(out.rows[0].row).not.toHaveProperty("glucoseContext"); + }); + + it("accepts a contextless mmol/L reading at a +HHMM offset", () => { + const out = parse([ + "BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1", + "BLOOD_GLUCOSE,5.3,mmol/L,2024-04-17T08:06:00+1000,,Sensor,sensor-2", + ]); + expect(out.rows.map((r) => r.status)).toEqual(["ok", "ok"]); + for (const result of out.rows) { + expect(result.row).not.toHaveProperty("glucoseContext"); + expect(result.row?.unit).toBe("mg/dL"); + expect(result.row?.value).toBeCloseTo(95.4848, 3); + } + expect(out.rows[0].row?.measuredAt.toISOString()).toBe( + "2024-04-03T02:15:00.000Z", + ); + expect(out.rows[1].row?.measuredAt.toISOString()).toBe( + "2024-04-16T22:06:00.000Z", + ); + }); + + it("still accepts every enum context", () => { + const out = parse( + ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"].map( + (ctx) => `BLOOD_GLUCOSE,95,mg/dL,2026-05-01T08:00:00Z,${ctx},,`, + ), + ); + expect(out.rows.map((r) => r.row?.glucoseContext)).toEqual([ + "FASTING", + "POSTPRANDIAL", + "RANDOM", + "BEDTIME", + ]); }); it("rejects a context on a non-glucose row", () => { diff --git a/tests/integration/csv-import-contextless-glucose.test.ts b/tests/integration/csv-import-contextless-glucose.test.ts new file mode 100644 index 000000000..15dde8f74 --- /dev/null +++ b/tests/integration/csv-import-contextless-glucose.test.ts @@ -0,0 +1,215 @@ +/** + * #640 — a contextless blood-glucose reading reaches Postgres. + * + * The reported import wrote nothing. Two layers had to agree that a reading + * without a fasting / post-meal classification is a reading: the CSV parser, + * and the CHECK constraint from migration 0021 that demanded a context on + * every `BLOOD_GLUCOSE` row. Only a real database proves the second half, so + * this runs the route end to end and reads the row back. + * + * Also pinned here: the constraint's surviving half (a context on any other + * type is still refused), the re-upload idempotency the importer promises, + * and the writers that were failing silently against the old constraint + * (the CGM sync shape and the single-value tool shape) now landing. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER = "user-csv-glucose"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const HEADER = "type,value,unit,measuredAt,glucoseContext,notes,externalId"; + +async function signIn() { + await getPrismaClient().user.create({ + data: { + id: USER, + username: "csv-glucose", + email: "csv-glucose@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: USER, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +function csvRequest(rows: string[]): NextRequest { + return new NextRequest("http://localhost/api/import/csv", { + method: "POST", + headers: { "content-type": "text/csv" }, + body: [HEADER, ...rows].join("\n"), + }); +} + +interface CsvEnvelope { + data: { + inserted: number; + updated: number; + skipped: number; + total: number; + rows: Array<{ line: number; status: string; reason?: string }>; + } | null; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); +}); + +describe("POST /api/import/csv — contextless glucose (real Postgres)", () => { + it("persists a sensor reading with no context, in the canonical shape", async () => { + await signIn(); + const { POST } = await import("@/app/api/import/csv/route"); + + const res = await POST( + csvRequest([ + "BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1", + ]), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(1); + expect(body.data?.skipped).toBe(0); + + const rows = await getPrismaClient().measurement.findMany({ + where: { userId: USER }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].type).toBe("BLOOD_GLUCOSE"); + expect(rows[0].glucoseContext).toBeNull(); + expect(rows[0].source).toBe("IMPORT"); + expect(rows[0].unit).toBe("mg/dL"); + expect(rows[0].value).toBeCloseTo(95.4848, 3); + expect(rows[0].externalId).toBe("sensor-1"); + expect(rows[0].measuredAt.toISOString()).toBe("2024-04-03T02:15:00.000Z"); + }); + + it("stays idempotent when the same external id is re-uploaded", async () => { + await signIn(); + const { POST } = await import("@/app/api/import/csv/route"); + + const first = await POST( + csvRequest([ + "BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1", + ]), + ); + expect(((await first.json()) as CsvEnvelope).data?.inserted).toBe(1); + + const second = await POST( + csvRequest([ + "BLOOD_GLUCOSE,5.9,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1", + ]), + ); + const secondBody = (await second.json()) as CsvEnvelope; + expect(secondBody.data?.inserted).toBe(0); + expect(secondBody.data?.updated).toBe(1); + + const rows = await getPrismaClient().measurement.findMany({ + where: { userId: USER }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].glucoseContext).toBeNull(); + expect(rows[0].value).toBeCloseTo(106.2944, 3); + }); + + it("keeps a named context, and still refuses one that is not in the enum", async () => { + await signIn(); + const { POST } = await import("@/app/api/import/csv/route"); + + const res = await POST( + csvRequest([ + "BLOOD_GLUCOSE,95,mg/dL,2026-05-01T08:00:00Z,FASTING,,meter-1", + "BLOOD_GLUCOSE,95,mg/dL,2026-05-01T09:00:00Z,LUNCH,,meter-2", + ]), + ); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(1); + expect(body.data?.rows[1]).toMatchObject({ + status: "skipped", + reason: "invalid_glucose_context", + }); + + const rows = await getPrismaClient().measurement.findMany({ + where: { userId: USER }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].glucoseContext).toBe("FASTING"); + }); +}); + +describe("measurements.glucose_context — surviving CHECK half", () => { + it("still refuses a context on a row that is not blood glucose", async () => { + await signIn(); + await expect( + getPrismaClient().measurement.create({ + data: { + userId: USER, + type: "WEIGHT", + value: 80, + unit: "kg", + source: "IMPORT", + measuredAt: new Date("2026-05-01T08:00:00.000Z"), + glucoseContext: "FASTING", + }, + }), + ).rejects.toThrow(); + }); + + it("accepts the contextless shape the device writers build", async () => { + await signIn(); + // The Nightscout SGV upsert and the MCP / Telegram single-value capture + // both build a BLOOD_GLUCOSE row with no context. Under the old + // constraint every one of those inserts was refused by the database. + const sources = ["NIGHTSCOUT", "MCP", "TELEGRAM"] as const; + for (const [index, source] of sources.entries()) { + await getPrismaClient().measurement.create({ + data: { + userId: USER, + type: "BLOOD_GLUCOSE", + value: 95, + unit: "mg/dL", + source, + measuredAt: new Date(`2026-05-01T0${index}:00:00.000Z`), + externalId: `${source}-1`, + }, + }); + } + const rows = await getPrismaClient().measurement.findMany({ + where: { userId: USER, type: "BLOOD_GLUCOSE" }, + }); + expect(rows).toHaveLength(3); + expect(rows.every((row) => row.glucoseContext === null)).toBe(true); + }); +}); From e43e758c8d73ebf3360bbe56f453647bd4e0fd4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 21:56:10 +0200 Subject: [PATCH 3/7] docs(import): put a contextless sensor reading in the downloadable example The example CSV is the file people copy. It showed a glucose row with a FASTING context and nothing else, which is the shape a meter produces, not the shape a continuous sensor produces. It now carries both. Refs #640 --- .../settings/__tests__/import-panel.test.tsx | 12 ++++++++++++ .../settings/import-panel/import-examples.ts | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/src/components/settings/__tests__/import-panel.test.tsx b/src/components/settings/__tests__/import-panel.test.tsx index 8b55cb2d3..31df66cae 100644 --- a/src/components/settings/__tests__/import-panel.test.tsx +++ b/src/components/settings/__tests__/import-panel.test.tsx @@ -152,6 +152,18 @@ describe("EXAMPLE_CSV", () => { expect(out.rows.length).toBeGreaterThan(0); expect(out.rows.every((r) => r.status === "ok")).toBe(true); }); + + it("carries a contextless glucose reading, the shape a sensor export has", () => { + const out = parseCsvMeasurements(EXAMPLE_CSV, { + now: new Date("2026-06-01T00:00:00Z").getTime(), + }); + const glucose = out.rows + .map((r) => r.row) + .filter((row) => row?.type === "BLOOD_GLUCOSE"); + expect(glucose.length).toBeGreaterThanOrEqual(2); + expect(glucose.some((row) => row?.glucoseContext === undefined)).toBe(true); + expect(glucose.some((row) => row?.glucoseContext === "FASTING")).toBe(true); + }); }); describe("parseImportJson guard", () => { diff --git a/src/components/settings/import-panel/import-examples.ts b/src/components/settings/import-panel/import-examples.ts index 926fa3d1f..aaf154765 100644 --- a/src/components/settings/import-panel/import-examples.ts +++ b/src/components/settings/import-panel/import-examples.ts @@ -64,5 +64,9 @@ export const EXAMPLE_CSV = [ CSV_EXAMPLE_COLUMNS.join(","), "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning,", "BLOOD_GLUCOSE,5.3,mmol/L,2026-05-01T08:05:00+02:00,FASTING,,meter-001", + // A sensor export has no fasting / post-meal answer per reading, so the + // context cell is blank. The example carries that shape on purpose: it is + // the one people copy, and it is the case that used to be refused. + "BLOOD_GLUCOSE,5.9,mmol/L,2026-05-01T08:20:00+02:00,,,sensor-001", "BLOOD_PRESSURE_SYS,120,mmHg,2026-05-01T08:05:00+02:00,,,", ].join("\n"); From 208a2248cded2ce13da909c675e35ebd598c7c01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 21:57:06 +0200 Subject: [PATCH 4/7] test(import): cover the HealthKit glucose shape against the relaxed constraint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A HealthKit blood-glucose sample carries no context either — the mapping has no field for one — so the batch ingest was building the same row the old CHECK refused. Fold that source into the case that pins the contextless shape. Refs #640 --- .../csv-import-contextless-glucose.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/integration/csv-import-contextless-glucose.test.ts b/tests/integration/csv-import-contextless-glucose.test.ts index 15dde8f74..dad86ab24 100644 --- a/tests/integration/csv-import-contextless-glucose.test.ts +++ b/tests/integration/csv-import-contextless-glucose.test.ts @@ -189,10 +189,11 @@ describe("measurements.glucose_context — surviving CHECK half", () => { it("accepts the contextless shape the device writers build", async () => { await signIn(); - // The Nightscout SGV upsert and the MCP / Telegram single-value capture - // both build a BLOOD_GLUCOSE row with no context. Under the old - // constraint every one of those inserts was refused by the database. - const sources = ["NIGHTSCOUT", "MCP", "TELEGRAM"] as const; + // The Nightscout SGV upsert, the HealthKit batch mapping, and the MCP / + // Telegram single-value captures all build a BLOOD_GLUCOSE row with no + // context. Under the old constraint every one of those inserts was + // refused by the database. + const sources = ["NIGHTSCOUT", "APPLE_HEALTH", "MCP", "TELEGRAM"] as const; for (const [index, source] of sources.entries()) { await getPrismaClient().measurement.create({ data: { @@ -209,7 +210,7 @@ describe("measurements.glucose_context — surviving CHECK half", () => { const rows = await getPrismaClient().measurement.findMany({ where: { userId: USER, type: "BLOOD_GLUCOSE" }, }); - expect(rows).toHaveLength(3); + expect(rows).toHaveLength(sources.length); expect(rows.every((row) => row.glucoseContext === null)).toBe(true); }); }); From ec49157e857f9b0126e18d245a8ee00fcfe3171e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 22:18:03 +0200 Subject: [PATCH 5/7] =?UTF-8?q?chore(release):=20v1.32.38=20=E2=80=94=20ac?= =?UTF-8?q?cept=20a=20blood-glucose=20reading=20that=20carries=20no=20cont?= =?UTF-8?q?ext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 41 +++++++++++++++++++++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e658029f..7d5dd6562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,47 @@ ## [Unreleased] +## [1.32.38] — 2026-07-25 + +Blood-glucose readings from anything other than the manual entry form were +never saved. + +A rule added years ago required every blood-glucose reading to carry a +context: fasting, after a meal, random, or bedtime. That is a fair thing to +ask of someone typing a reading in by hand. It is impossible for a sensor, +which measures every few minutes and cannot know what you were doing. + +So every automated path failed. A CSV import reported the rows as skipped and +still showed a green tick. Nightscout counted them as failed rows and carried +on. Apple Health has no context field at all, so not one of its readings could +ever have been stored. The same for readings written through the automation +interface, through Telegram, and through the JSON importer. None of it was +visible: no error reached anyone, and the affected charts and panels simply +stayed empty as though nothing had been measured. + +A reading with no context is now stored with no context, which is what it is. +Where a context is given it is still checked. Manual entry still asks for one, +because the form offers the choices and nothing is lost by asking. + +Absence is now shown as absence: a reading without a context is left out of the +per-context breakdowns rather than being counted as one of them. + +**Imports no longer claim to have worked when they did not.** A run that wrote +nothing is shown as a failure. A run that wrote some rows and skipped others is +shown as a partial result. Only a run that actually landed something shows as +done. Skipped rows are grouped by reason with a count instead of listing the +first fifty identical lines, and the per-row detail is still there behind a +disclosure. This covers the CSV, JSON and Apple Health cards alike. + +Reported by @lutzkind, who traced it to the parser, ran it in isolation to rule +out the timestamp and unit handling, and checked the database afterwards to +prove nothing had been written. Files that were rejected can simply be imported +again. + +**For operators.** Migration `0274` relaxes the constraint. It adds no column +and rewrites no row. Readings that were refused in the past were never stored, +so they need a fresh import from the source. + ## [1.32.37] — 2026-07-25 Two ways a record could quietly lose data, both closed. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 997fffdca..d9448a580 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.32.37 + version: 1.32.38 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 374ad5de4..1f50c9823 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.37", + "version": "1.32.38", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 90b4ee924..0a394fe63 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.32.37"; + /* @sw-version-fallback */ "v1.32.38"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` From c506b72002af4da8a32d703ebb836174a39291d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 22:34:03 +0200 Subject: [PATCH 6/7] test(e2e): drop the cycle route handlers before teardown closes the page --- e2e/cycle-insights-mobile.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/e2e/cycle-insights-mobile.spec.ts b/e2e/cycle-insights-mobile.spec.ts index cb278f5d0..a247bbddd 100644 --- a/e2e/cycle-insights-mobile.spec.ts +++ b/e2e/cycle-insights-mobile.spec.ts @@ -175,6 +175,14 @@ test.describe("cycle + insights mobile smoke", () => { ); }); + // These handlers call route.fetch(), which needs a live page. Teardown can + // close the context while one is still in flight, and the rejected fetch + // then fails a test that had already passed. Drop the handlers first so + // nothing is left racing the close. + test.afterEach(async ({ page }) => { + await page.unrouteAll({ behavior: "ignoreErrors" }); + }); + test("/insights has no horizontal scroll on Pixel-5", async ({ page }) => { await page.goto("/insights", { waitUntil: "domcontentloaded" }); await page.waitForLoadState("networkidle"); From a23ee61184fc801c1e233c88f75cb69afdee0835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 23:02:03 +0200 Subject: [PATCH 7/7] test(e2e): route cleanup happens by construction, not per spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A spec that arms `page.route(...)` in a `beforeEach` and never drops the handler races its own teardown. The context closes, an async handler is still awaiting, its next call rejects with "Test ended.", and Playwright attributes that to the test that had just passed. With `retries: 2` plus `failOnFlakyTests`, which spec bites is a matter of timing. `e2e/setup/test.ts` re-exports Playwright's `test` extended with an auto fixture that unroutes every page and the context after each test, and clears the page event listeners a spec subscribes to; `expect` rides along so a spec needs one import. Auto means a spec author cannot forget it and does not have to know it exists. Every spec now takes `test` from there, and `e2e-route-cleanup-guard.test.ts` keeps the import boundary closed with an allowlist that carries its reason. The per-file `afterEach` on the cycle spec goes away — leaving both would read as not trusting the fixture. Not covered: a page inside a context a spec builds itself. Only `share-documents.spec.ts` does that, and it owns those contexts. --- e2e/README.md | 6 +- e2e/a11y.spec.ts | 9 +- e2e/achievements.spec.ts | 2 +- e2e/admin-api-tokens-mobile.spec.ts | 2 +- e2e/ai-provider-dropdown.spec.ts | 2 +- e2e/auth-redirect.spec.ts | 2 +- e2e/central-codex.spec.ts | 2 +- e2e/chart-overlay-controls.spec.ts | 2 +- e2e/charts-mobile.spec.ts | 2 +- e2e/coach-conversation-rename.spec.ts | 2 +- e2e/coach-guided-questions.spec.ts | 2 +- e2e/codex-flow.spec.ts | 2 +- e2e/cycle-insights-mobile.spec.ts | 10 +- e2e/dashboard.spec.ts | 2 +- e2e/documents-ai.spec.ts | 3 +- e2e/documents-auto-read.spec.ts | 3 +- e2e/documents-chat.spec.ts | 3 +- e2e/documents.spec.ts | 3 +- e2e/insights-generate.spec.ts | 2 +- e2e/insights-scroll-restoration.spec.ts | 2 +- e2e/ipad-viewport.spec.ts | 2 +- e2e/locale-switch.spec.ts | 2 +- e2e/login.spec.ts | 2 +- e2e/measurement-flow.spec.ts | 2 +- e2e/medications-wizard-biweekly.spec.ts | 2 +- e2e/medications-wizard-compose.spec.ts | 3 +- e2e/medications-wizard-daily.spec.ts | 2 +- e2e/medications-wizard-monthly.spec.ts | 2 +- e2e/medications-wizard-oneshot.spec.ts | 2 +- e2e/medications-wizard-rolling.spec.ts | 2 +- e2e/medications-wizard-weekdays.spec.ts | 2 +- e2e/mobile-density.spec.ts | 2 +- e2e/mobile-viewport.spec.ts | 2 +- e2e/mood-card-mobile.spec.ts | 2 +- e2e/mood-timestamp.spec.ts | 2 +- e2e/onboarding-flicker.spec.ts | 2 +- e2e/onboarding-tour-passthrough.spec.ts | 2 +- e2e/refetch-nav.spec.ts | 2 +- e2e/settings-export.spec.ts | 2 +- e2e/settings-integrations-mobile.spec.ts | 2 +- e2e/settings-mobile-consistency.spec.ts | 2 +- e2e/settings-overscroll.spec.ts | 2 +- e2e/setup/test.ts | 90 ++++++++++ e2e/share-documents.spec.ts | 3 +- e2e/sidebar-admin-no-expand.spec.ts | 2 +- e2e/timezone-profile.spec.ts | 2 +- e2e/today-just-in.spec.ts | 2 +- e2e/v1427-admin-login-overview.spec.ts | 2 +- e2e/v1427-coach-launch.spec.ts | 2 +- e2e/v1427-insights-empty-state.spec.ts | 2 +- e2e/v1427-measurements-add-param.spec.ts | 2 +- e2e/v1427-public-pages.spec.ts | 2 +- e2e/v1427-responsive-sheet.spec.ts | 2 +- e2e/version.spec.ts | 2 +- e2e/water-capture.spec.ts | 2 +- e2e/workouts-infinite-history.spec.ts | 2 +- src/__tests__/e2e-route-cleanup-guard.test.ts | 156 ++++++++++++++++++ 57 files changed, 311 insertions(+), 70 deletions(-) create mode 100644 e2e/setup/test.ts create mode 100644 src/__tests__/e2e-route-cleanup-guard.test.ts diff --git a/e2e/README.md b/e2e/README.md index fa2e35d96..4cf2a7ef8 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -12,10 +12,12 @@ The Playwright config (`../playwright.config.ts`) builds the app (`next build`) ## Layout -- **`setup/`** — `global-setup.ts` (auth + DB seeding), `test-helpers.ts` (shared login + navigation helpers). +- **`setup/`** — `test.ts` (the suite's `test` + `expect` entry point), `global-setup.ts` (auth + DB seeding), `test-helpers.ts` (shared login + navigation helpers). - **`utils/`** — fixtures such as `mock-dashboard-snapshot.ts`. - **`*.spec.ts`** — one spec per flow: auth/login/redirect, dashboard, charts, the medication-wizard cadences (`medications-wizard-*`), insights generation + scroll restoration, doctor report, settings, mobile-viewport consistency, locale switch, accessibility (`a11y.spec.ts`), and the `v1427-*` regression set. -## Convention +## Conventions + +Import `test` and `expect` from `./setup/test`, never from `@playwright/test` — types (`Page`, `Route`, …) still come from the package. The entry point extends Playwright's `test` with an automatic fixture that drops route handlers and page listeners after every test, so a handler left in flight cannot fail the test it belonged to once the context closes. `src/__tests__/e2e-route-cleanup-guard.test.ts` enforces the import boundary. Assert against stable `data-*` attributes (`data-slot`, `data-display-step`, root display attrs), not viewport-dependent visible text. Responsive `sm:hidden` UI changes break `getByText` on desktop while the underlying element is still present — anchoring on data attributes keeps the specs stable across breakpoints. See [`../CLAUDE.md`](../CLAUDE.md). diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index 1fc1382d3..bcce6030e 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -1,11 +1,6 @@ import AxeBuilder from "@axe-core/playwright"; -import { - expect, - test, - type Locator, - type Page, - type Route, -} from "@playwright/test"; +import type { Locator, Page, Route } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { POPULATED_SUMMARIES } from "./utils/mock-dashboard-snapshot"; diff --git a/e2e/achievements.spec.ts b/e2e/achievements.spec.ts index 9786df03c..9dacf0042 100644 --- a/e2e/achievements.spec.ts +++ b/e2e/achievements.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/admin-api-tokens-mobile.spec.ts b/e2e/admin-api-tokens-mobile.spec.ts index 08bef6dd2..3f82cdb2d 100644 --- a/e2e/admin-api-tokens-mobile.spec.ts +++ b/e2e/admin-api-tokens-mobile.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/ai-provider-dropdown.spec.ts b/e2e/ai-provider-dropdown.spec.ts index f12dd9b05..ea2c00bb2 100644 --- a/e2e/ai-provider-dropdown.spec.ts +++ b/e2e/ai-provider-dropdown.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/auth-redirect.spec.ts b/e2e/auth-redirect.spec.ts index 931cb32d9..196373969 100644 --- a/e2e/auth-redirect.spec.ts +++ b/e2e/auth-redirect.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; /** * The proxy at src/proxy.ts is the single gate enforcing auth on every diff --git a/e2e/central-codex.spec.ts b/e2e/central-codex.spec.ts index dec89459b..8d2e8a4ee 100644 --- a/e2e/central-codex.spec.ts +++ b/e2e/central-codex.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/chart-overlay-controls.spec.ts b/e2e/chart-overlay-controls.spec.ts index 2c4d5f8ef..84d7f2cc8 100644 --- a/e2e/chart-overlay-controls.spec.ts +++ b/e2e/chart-overlay-controls.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { diff --git a/e2e/charts-mobile.spec.ts b/e2e/charts-mobile.spec.ts index a5e86cf17..3e933b4d4 100644 --- a/e2e/charts-mobile.spec.ts +++ b/e2e/charts-mobile.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot"; diff --git a/e2e/coach-conversation-rename.spec.ts b/e2e/coach-conversation-rename.spec.ts index 97551287e..1a8677e9f 100644 --- a/e2e/coach-conversation-rename.spec.ts +++ b/e2e/coach-conversation-rename.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { setTimeout as delay } from "node:timers/promises"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/coach-guided-questions.spec.ts b/e2e/coach-guided-questions.spec.ts index 2b3599965..638d9c74e 100644 --- a/e2e/coach-guided-questions.spec.ts +++ b/e2e/coach-guided-questions.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/codex-flow.spec.ts b/e2e/codex-flow.spec.ts index 5e929066e..ce5883886 100644 --- a/e2e/codex-flow.spec.ts +++ b/e2e/codex-flow.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/cycle-insights-mobile.spec.ts b/e2e/cycle-insights-mobile.spec.ts index a247bbddd..d45dfbbf2 100644 --- a/e2e/cycle-insights-mobile.spec.ts +++ b/e2e/cycle-insights-mobile.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; @@ -175,14 +175,6 @@ test.describe("cycle + insights mobile smoke", () => { ); }); - // These handlers call route.fetch(), which needs a live page. Teardown can - // close the context while one is still in flight, and the rejected fetch - // then fails a test that had already passed. Drop the handlers first so - // nothing is left racing the close. - test.afterEach(async ({ page }) => { - await page.unrouteAll({ behavior: "ignoreErrors" }); - }); - test("/insights has no horizontal scroll on Pixel-5", async ({ page }) => { await page.goto("/insights", { waitUntil: "domcontentloaded" }); await page.waitForLoadState("networkidle"); diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index 017051510..ff07c9902 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot"; diff --git a/e2e/documents-ai.spec.ts b/e2e/documents-ai.spec.ts index f0f8abbbc..2e75854b0 100644 --- a/e2e/documents-ai.spec.ts +++ b/e2e/documents-ai.spec.ts @@ -16,7 +16,8 @@ * seeds a real `document_content_index` row (`ensureVaultAiFixture`) and drives * the real list union. */ -import { expect, test, type Page } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { diff --git a/e2e/documents-auto-read.spec.ts b/e2e/documents-auto-read.spec.ts index 5583b7320..d341fbed6 100644 --- a/e2e/documents-auto-read.spec.ts +++ b/e2e/documents-auto-read.spec.ts @@ -15,7 +15,8 @@ * AI e2e, so it is pinned here too. Assertions target stable data-slots, never * viewport-dependent text. */ -import { expect, test, type Page } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { diff --git a/e2e/documents-chat.spec.ts b/e2e/documents-chat.spec.ts index e57fc0b78..85afe9b79 100644 --- a/e2e/documents-chat.spec.ts +++ b/e2e/documents-chat.spec.ts @@ -22,7 +22,8 @@ * the specs time out. The seeded demo user has no AI provider, so the * no-provider state is the default. */ -import { expect, test, type Page } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index 470a67405..0232fd2fc 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -17,7 +17,8 @@ * they create, never on the shared doctor-flow corpus. */ import AxeBuilder from "@axe-core/playwright"; -import { expect, test, type Page } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { diff --git a/e2e/insights-generate.spec.ts b/e2e/insights-generate.spec.ts index 19deb147c..1bce560a7 100644 --- a/e2e/insights-generate.spec.ts +++ b/e2e/insights-generate.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/insights-scroll-restoration.spec.ts b/e2e/insights-scroll-restoration.spec.ts index b5be7995a..8dd6e160f 100644 --- a/e2e/insights-scroll-restoration.spec.ts +++ b/e2e/insights-scroll-restoration.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/ipad-viewport.spec.ts b/e2e/ipad-viewport.spec.ts index dde8b9369..8667d8c27 100644 --- a/e2e/ipad-viewport.spec.ts +++ b/e2e/ipad-viewport.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/locale-switch.spec.ts b/e2e/locale-switch.spec.ts index fc231d80d..e66191744 100644 --- a/e2e/locale-switch.spec.ts +++ b/e2e/locale-switch.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; /** * Locale-key drift smoke check — switch the cookie between EN and DE diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts index 23a29bf63..4a0b4ea49 100644 --- a/e2e/login.spec.ts +++ b/e2e/login.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; /** * Smoke checks on the login surface — must render, must wire the diff --git a/e2e/measurement-flow.spec.ts b/e2e/measurement-flow.spec.ts index 904095a1f..8457cf0e1 100644 --- a/e2e/measurement-flow.spec.ts +++ b/e2e/measurement-flow.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/medications-wizard-biweekly.spec.ts b/e2e/medications-wizard-biweekly.spec.ts index 68cc6b024..d687051fa 100644 --- a/e2e/medications-wizard-biweekly.spec.ts +++ b/e2e/medications-wizard-biweekly.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STUB_MEDICATION_ID, diff --git a/e2e/medications-wizard-compose.spec.ts b/e2e/medications-wizard-compose.spec.ts index ddba23f37..d31b51f14 100644 --- a/e2e/medications-wizard-compose.spec.ts +++ b/e2e/medications-wizard-compose.spec.ts @@ -1,4 +1,5 @@ -import { expect, test, type Route } from "@playwright/test"; +import type { Route } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STUB_MEDICATION_ID, diff --git a/e2e/medications-wizard-daily.spec.ts b/e2e/medications-wizard-daily.spec.ts index e6d773dd4..fb6b1dd7d 100644 --- a/e2e/medications-wizard-daily.spec.ts +++ b/e2e/medications-wizard-daily.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STUB_MEDICATION_ID, diff --git a/e2e/medications-wizard-monthly.spec.ts b/e2e/medications-wizard-monthly.spec.ts index 7e7a21373..885500859 100644 --- a/e2e/medications-wizard-monthly.spec.ts +++ b/e2e/medications-wizard-monthly.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STUB_MEDICATION_ID, diff --git a/e2e/medications-wizard-oneshot.spec.ts b/e2e/medications-wizard-oneshot.spec.ts index d851f9a4a..abe36b10a 100644 --- a/e2e/medications-wizard-oneshot.spec.ts +++ b/e2e/medications-wizard-oneshot.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STUB_MEDICATION_ID, diff --git a/e2e/medications-wizard-rolling.spec.ts b/e2e/medications-wizard-rolling.spec.ts index 02ecafc8e..c2ecef158 100644 --- a/e2e/medications-wizard-rolling.spec.ts +++ b/e2e/medications-wizard-rolling.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STUB_MEDICATION_ID, diff --git a/e2e/medications-wizard-weekdays.spec.ts b/e2e/medications-wizard-weekdays.spec.ts index 5386c6666..5ea977aaa 100644 --- a/e2e/medications-wizard-weekdays.spec.ts +++ b/e2e/medications-wizard-weekdays.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STUB_MEDICATION_ID, diff --git a/e2e/mobile-density.spec.ts b/e2e/mobile-density.spec.ts index af0251581..ebe54672a 100644 --- a/e2e/mobile-density.spec.ts +++ b/e2e/mobile-density.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { diff --git a/e2e/mobile-viewport.spec.ts b/e2e/mobile-viewport.spec.ts index af1e46888..200bf4c28 100644 --- a/e2e/mobile-viewport.spec.ts +++ b/e2e/mobile-viewport.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { diff --git a/e2e/mood-card-mobile.spec.ts b/e2e/mood-card-mobile.spec.ts index 9b74d77d1..e3ffa57fd 100644 --- a/e2e/mood-card-mobile.spec.ts +++ b/e2e/mood-card-mobile.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/mood-timestamp.spec.ts b/e2e/mood-timestamp.spec.ts index 3aeca210c..28daa3df8 100644 --- a/e2e/mood-timestamp.spec.ts +++ b/e2e/mood-timestamp.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/onboarding-flicker.spec.ts b/e2e/onboarding-flicker.spec.ts index d69f78d61..bd0ee08ac 100644 --- a/e2e/onboarding-flicker.spec.ts +++ b/e2e/onboarding-flicker.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { diff --git a/e2e/onboarding-tour-passthrough.spec.ts b/e2e/onboarding-tour-passthrough.spec.ts index 558967f53..003bd5e01 100644 --- a/e2e/onboarding-tour-passthrough.spec.ts +++ b/e2e/onboarding-tour-passthrough.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/refetch-nav.spec.ts b/e2e/refetch-nav.spec.ts index ce2f52e84..fb0f16934 100644 --- a/e2e/refetch-nav.spec.ts +++ b/e2e/refetch-nav.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/settings-export.spec.ts b/e2e/settings-export.spec.ts index b116e6a16..e2de1e22b 100644 --- a/e2e/settings-export.spec.ts +++ b/e2e/settings-export.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/settings-integrations-mobile.spec.ts b/e2e/settings-integrations-mobile.spec.ts index 55ab67f28..92397f634 100644 --- a/e2e/settings-integrations-mobile.spec.ts +++ b/e2e/settings-integrations-mobile.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/settings-mobile-consistency.spec.ts b/e2e/settings-mobile-consistency.spec.ts index a75a456d0..4ba303cf1 100644 --- a/e2e/settings-mobile-consistency.spec.ts +++ b/e2e/settings-mobile-consistency.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/settings-overscroll.spec.ts b/e2e/settings-overscroll.spec.ts index 544ecfb7b..b55eb33cc 100644 --- a/e2e/settings-overscroll.spec.ts +++ b/e2e/settings-overscroll.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/setup/test.ts b/e2e/setup/test.ts new file mode 100644 index 000000000..8893f5aa5 --- /dev/null +++ b/e2e/setup/test.ts @@ -0,0 +1,90 @@ +/** + * The E2E suite's `test` / `expect` entry point. + * + * Every spec imports from here instead of from `@playwright/test`, and the + * structural guard `src/__tests__/e2e-route-cleanup-guard.test.ts` keeps it + * that way. + * + * ## Why + * + * A spec that registers `page.route(...)` in a `beforeEach` leaves the handler + * armed until the context closes. If the handler is async — `route.fetch()`, + * an `await` before `route.fulfill()` — teardown can close the context while + * one invocation is still in flight. The callback then rejects against a dead + * target, Playwright attributes the rejection to the test that just finished, + * and a test that had already passed is reported as failed. With `retries: 2` + * plus `failOnFlakyTests` on CI, that is a release blocker whose trigger is + * timing, not code. + * + * The remedy Playwright names is `unrouteAll({ behavior: "ignoreErrors" })` + * before the test ends. Written per spec that is 36 copies of the same three + * lines and one forgotten copy away from the same failure. Written as an auto + * fixture it happens for every test in the suite, including specs nobody has + * written yet, and a spec author never has to know it exists. + * + * ## What it covers + * + * - `page.route(...)` on every page in the test's context. + * - `context.route(...)` on the context itself. + * - Page event listeners for the interactive event types a spec subscribes to. + * A listener that awaits something (a `dialog` handler, a `response` handler + * that reads a body) can reject after teardown for exactly the same reason a + * route handler can. + * + * ## What it does not cover + * + * A page that lives in a context the spec created itself — `browser.newContext()` + * inside a helper — is invisible here, because this fixture can only see the + * context Playwright built for the test. `share-documents.spec.ts` is the one + * spec that does this; it owns those contexts and has to clean them up itself. + */ +import { test as base, expect } from "@playwright/test"; + +/** + * Page event types cleared at teardown. Deliberately not the lifecycle events + * (`close`, `crash`, `frameattached`, …): those describe the teardown we are + * standing in, and nothing in the suite hangs work off them. These are the + * types a spec attaches a handler to, and the ones whose handler can still be + * running when the context goes away. + */ +const OBSERVED_PAGE_EVENTS = [ + "console", + "dialog", + "download", + "filechooser", + "pageerror", + "popup", + "request", + "requestfailed", + "requestfinished", + "response", + "websocket", + "worker", +] as const; + +export const test = base.extend<{ routeCleanup: void }>({ + routeCleanup: [ + async ({ context }, use) => { + await use(); + + // Best-effort: a spec is free to close its own context (or the browser + // can die under it), and a teardown helper must never be the thing that + // turns a green run red. Everything below is a cleanup, so a failure to + // clean up has nothing left to protect. + try { + for (const page of context.pages()) { + await page.unrouteAll({ behavior: "ignoreErrors" }); + for (const event of OBSERVED_PAGE_EVENTS) { + await page.removeAllListeners(event, { behavior: "ignoreErrors" }); + } + } + await context.unrouteAll({ behavior: "ignoreErrors" }); + } catch { + // Target already gone — nothing left to unroute. + } + }, + { auto: true }, + ], +}); + +export { expect }; diff --git a/e2e/share-documents.spec.ts b/e2e/share-documents.spec.ts index d33cfe1de..28d00a7d6 100644 --- a/e2e/share-documents.spec.ts +++ b/e2e/share-documents.spec.ts @@ -24,7 +24,8 @@ * broader corpus (`ensureVaultFixture`) supplies the ≥50 documents the picker * cap test and the not-attached foreign-id probe need. */ -import { expect, test, type Browser, type Page } from "@playwright/test"; +import type { Browser, Page } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { diff --git a/e2e/sidebar-admin-no-expand.spec.ts b/e2e/sidebar-admin-no-expand.spec.ts index 97051932c..2c914934e 100644 --- a/e2e/sidebar-admin-no-expand.spec.ts +++ b/e2e/sidebar-admin-no-expand.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/timezone-profile.spec.ts b/e2e/timezone-profile.spec.ts index 50dd311d4..f4865aa1d 100644 --- a/e2e/timezone-profile.spec.ts +++ b/e2e/timezone-profile.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/today-just-in.spec.ts b/e2e/today-just-in.spec.ts index 843783ce5..c4f0c61ca 100644 --- a/e2e/today-just-in.spec.ts +++ b/e2e/today-just-in.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import pg from "pg"; import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/v1427-admin-login-overview.spec.ts b/e2e/v1427-admin-login-overview.spec.ts index 8dcaad9bf..0446eacd0 100644 --- a/e2e/v1427-admin-login-overview.spec.ts +++ b/e2e/v1427-admin-login-overview.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts index 90e0a3765..9c976824c 100644 --- a/e2e/v1427-coach-launch.spec.ts +++ b/e2e/v1427-coach-launch.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/v1427-insights-empty-state.spec.ts b/e2e/v1427-insights-empty-state.spec.ts index 64c306a3e..62bbcff16 100644 --- a/e2e/v1427-insights-empty-state.spec.ts +++ b/e2e/v1427-insights-empty-state.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/v1427-measurements-add-param.spec.ts b/e2e/v1427-measurements-add-param.spec.ts index daf5096b3..e922945a6 100644 --- a/e2e/v1427-measurements-add-param.spec.ts +++ b/e2e/v1427-measurements-add-param.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/v1427-public-pages.spec.ts b/e2e/v1427-public-pages.spec.ts index 356f635f5..74eeac6db 100644 --- a/e2e/v1427-public-pages.spec.ts +++ b/e2e/v1427-public-pages.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; /** * v1.4.27 MB6 — public surfaces. diff --git a/e2e/v1427-responsive-sheet.spec.ts b/e2e/v1427-responsive-sheet.spec.ts index 1e7a35b86..762df1d6f 100644 --- a/e2e/v1427-responsive-sheet.spec.ts +++ b/e2e/v1427-responsive-sheet.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/version.spec.ts b/e2e/version.spec.ts index 9dc11eddc..98e5d5963 100644 --- a/e2e/version.spec.ts +++ b/e2e/version.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; /** * /api/version is the public, unauthenticated endpoint that drives diff --git a/e2e/water-capture.spec.ts b/e2e/water-capture.spec.ts index d45761495..7c9e99bf4 100644 --- a/e2e/water-capture.spec.ts +++ b/e2e/water-capture.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { diff --git a/e2e/workouts-infinite-history.spec.ts b/e2e/workouts-infinite-history.spec.ts index c4417320c..2fa882484 100644 --- a/e2e/workouts-infinite-history.spec.ts +++ b/e2e/workouts-infinite-history.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/src/__tests__/e2e-route-cleanup-guard.test.ts b/src/__tests__/e2e-route-cleanup-guard.test.ts new file mode 100644 index 000000000..0e7613914 --- /dev/null +++ b/src/__tests__/e2e-route-cleanup-guard.test.ts @@ -0,0 +1,156 @@ +/** + * Structural guard on the E2E suite's route-cleanup boundary. + * + * A spec that arms `page.route(...)` in a `beforeEach` and never drops the + * handler races its own teardown: the context closes, an in-flight handler + * rejects, and Playwright reports a test that had already passed as failed. + * `e2e/setup/test.ts` removes the possibility with an auto fixture, but only + * for specs that take their `test` from there — a spec that imports straight + * from `@playwright/test` silently opts out and looks identical in review. + * + * So the entry point is the invariant, and this file is what keeps it true. + * It is a tripwire, not a proof: it shows nobody has changed the import + * boundary without editing this file. That the fixture actually cleans up is + * a runtime property, proven by the suite itself. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, globSync } from "node:fs"; +import { join, sep } from "node:path"; + +const E2E = join(process.cwd(), "e2e"); + +/** The shared entry point, relative to `e2e/`. */ +const ENTRY = "setup/test.ts"; + +/** + * Files allowed to import `test` from `@playwright/test` directly, each with + * the reason it has to. + */ +const DIRECT_IMPORT_ALLOWLIST: Record = { + // The entry point itself — it extends Playwright's base `test`, which it + // cannot do without importing it. + [ENTRY]: "defines the extended `test` every other file imports", +}; + +function e2eFiles(): string[] { + return globSync("**/*.ts", { cwd: E2E }) + .map((p) => p.split(sep).join("/")) + .sort(); +} + +function read(rel: string): string { + return readFileSync(join(E2E, rel), "utf8"); +} + +/** + * The file with its comments removed. The entry point documents the very API + * calls it makes, so a naive substring match would be satisfied by the prose + * alone — a fixture gutted to a no-op would still read as compliant. + */ +function stripComments(src: string): string { + return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +} + +/** + * Every `from ""` import statement in a file, as + * `{ module, specifiers }` — `specifiers` is the raw brace body, or `"*"` for + * a namespace import. + */ +function imports(src: string): Array<{ module: string; specifiers: string }> { + const found: Array<{ module: string; specifiers: string }> = []; + for (const m of src.matchAll( + /import\s+(type\s+)?\{([\s\S]*?)\}\s*from\s*"([^"]+)"/g, + )) { + // `import type { … }` imports no runtime binding at all. + found.push({ module: m[3], specifiers: m[1] ? "" : m[2] }); + } + for (const m of src.matchAll(/import\s+\*\s+as\s+\w+\s+from\s*"([^"]+)"/g)) { + found.push({ module: m[1], specifiers: "*" }); + } + return found; +} + +/** Does this file import the runtime `test` binding from `module`? */ +function importsTestFrom(src: string, module: string): boolean { + return imports(src) + .filter((i) => i.module === module) + .some( + (i) => + i.specifiers === "*" || + i.specifiers + .split(",") + .map((s) => s.trim()) + .filter((s) => s && !s.startsWith("type ")) + .some((s) => /^test(\s+as\s+\w+)?$/.test(s)), + ); +} + +describe("the E2E suite takes `test` from the shared entry point", () => { + it("no file outside the allowlist imports `test` from @playwright/test", () => { + const offenders = e2eFiles().filter( + (rel) => + !(rel in DIRECT_IMPORT_ALLOWLIST) && + importsTestFrom(read(rel), "@playwright/test"), + ); + + expect(offenders).toEqual([]); + }); + + it("the allowlist names only files that exist and still need the exemption", () => { + for (const rel of Object.keys(DIRECT_IMPORT_ALLOWLIST)) { + expect( + importsTestFrom(read(rel), "@playwright/test"), + `${rel} is allowlisted but no longer imports \`test\` directly — drop the entry`, + ).toBe(true); + } + }); + + it("every spec imports `test` from the entry point", () => { + const specs = e2eFiles().filter((rel) => rel.endsWith(".spec.ts")); + // Sanity: the sweep found the suite, not an empty directory. + expect(specs.length).toBeGreaterThan(40); + + const missing = specs.filter((rel) => { + const src = read(rel); + // Specs sit at `e2e/.spec.ts`, so the entry resolves as + // `./setup/test`. A nested spec would need a different prefix; none + // exists, and this assertion is what would surface one. + return !importsTestFrom(src, "./setup/test"); + }); + + expect(missing).toEqual([]); + }); +}); + +describe("the entry point still cleans up", () => { + const src = stripComments(read(ENTRY)); + + it("registers the cleanup as an automatic fixture", () => { + // `auto: true` is the whole point: a spec author cannot forget it and does + // not have to declare it. Without the flag the fixture is dead code. + expect(src).toMatch(/\{\s*auto:\s*true\s*\}/); + }); + + it("drops route handlers on both the page and the context", () => { + expect(src).toMatch(/page\.unrouteAll\(\{\s*behavior:\s*"ignoreErrors"/); + expect(src).toMatch(/context\.unrouteAll\(\{\s*behavior:\s*"ignoreErrors"/); + }); + + it("drops page event listeners", () => { + expect(src).toMatch(/removeAllListeners\(/); + }); + + it("cleans up after the test body, not before it", () => { + // `await use()` first, cleanup after — a fixture that cleaned up before + // the test would unroute the handlers a `beforeEach` had just armed. + const useAt = src.indexOf("await use()"); + const unrouteAt = src.indexOf("unrouteAll("); + expect(useAt).toBeGreaterThan(-1); + expect(unrouteAt).toBeGreaterThan(useAt); + }); + + it("exports both `test` and `expect`, so a spec needs one import", () => { + expect(src).toMatch(/export const test\b/); + expect(src).toMatch(/export \{ expect \}/); + }); +});