From 6649566b23c3d2316e6782b1e9239801a31b2c82 Mon Sep 17 00:00:00 2001 From: ichwars Date: Tue, 8 Sep 2026 14:24:16 +0200 Subject: [PATCH] fix(frontend): preserve Safari 16 markdown loading --- .github/workflows/ci.yml | 16 ++ CHANGELOG.md | 1 + docs/README.md | 1 + docs/browser-compatibility.md | 86 ++++++++++ frontend/e2e/document-layout.spec.ts | 23 ++- frontend/package-lock.json | 148 ++---------------- frontend/package.json | 12 +- frontend/scripts/browserBaseline.ts | 72 +++++++++ frontend/scripts/check-browser-runtime.mjs | 97 ++++++++++++ .../components/FolderReadmePanel.test.tsx | 69 +++++++- .../document-layout/PdfPreviewPane.test.tsx | 2 +- .../__tests__/utils/browserBaseline.test.ts | 53 +++++++ frontend/src/components/FolderReadmePanel.tsx | 8 +- .../document-layout/PdfPreviewPane.tsx | 3 +- frontend/src/utils/remarkGfmNoAutolink.ts | 30 ++++ frontend/vite.config.ts | 12 +- 16 files changed, 485 insertions(+), 148 deletions(-) create mode 100644 docs/browser-compatibility.md create mode 100644 frontend/scripts/browserBaseline.ts create mode 100644 frontend/scripts/check-browser-runtime.mjs create mode 100644 frontend/src/__tests__/utils/browserBaseline.test.ts create mode 100644 frontend/src/utils/remarkGfmNoAutolink.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7c97ae23a..6b3a5b2eed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -333,6 +333,22 @@ jobs: working-directory: frontend run: npm run build + - name: Install browser for production README smoke test + working-directory: frontend + run: npx playwright install --with-deps chromium + + - name: Verify production README in Chromium + working-directory: frontend + run: | + npm run preview -- --host 127.0.0.1 --port 4184 --strictPort & + preview_pid=$! + trap 'kill "$preview_pid"' EXIT + for attempt in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:4184/ > /dev/null; then break; fi + sleep 1 + done + npm run check:browser-runtime + # ============================================================================ # Docker Tests (matches test_docker.sh) # ============================================================================ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ba2b307e5..9f41e37bfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to PrintOps will be documented in this file. ## Unreleased ### Fixed +- **Safari 16.0–16.3 File Manager loading (#144)** — Removed the GFM autolink-literal import that could prevent the lazy File Manager chunk from parsing. Tables, task lists, strikethrough, footnotes and explicit links remain available; bare URLs/emails now stay text. Production builds target Safari 16 and check emitted JavaScript, including the transformed PDF worker, for known incompatible syntax. PDF preview runtime support still requires a current browser; see [browser compatibility](docs/browser-compatibility.md). - **AMS material names, colours and RFID tare weights stay consistent (#137)** — AMS writes now keep product and subtype labels for display while sending printer-supported material types, preserve preset filament types, normalize tray colours at the MQTT boundary, and retain assignments when normalized telemetry returns. Newly detected Bambu RFID spools use the named low-temperature core with its catalog link; a conservative one-time repair corrects only untouched auto-detected legacy rows and preserves manual edits. - **FTPS retries and downloads remain bounded without wasting healthy work (#136)** — Explicit print and firmware uploads now make their own bounded connection attempts instead of spending every retry on a background handshake cooldown, while background cover and archive scans retain the cooldown guard. Slow downloads receive a capped size-based total budget in addition to the socket inactivity timeout, heavy 3MF transfers take turns per printer, timed-out path searches cooperatively stop their worker, and cover retries re-check the shared 3MF cache. Upload failures now distinguish TLS, authentication, network, path and SD-card/storage causes instead of blaming storage for every failure. - **Open print batches keep their final reusable reprint template (#135)** — Deleting one queue item or clearing history now retains the last per-plate source an active batch needs to recreate outstanding runs, while fulfilled and cancelled batches remain cleanable. Already stranded batches identify affected plates, only offer dispatchable quantities, keep cancellation permission-aware, and no longer let one stranded plate block healthy plates in the same batch. Retained responses name the batch and reason; printer, AMS, plate and variant settings continue to be cloned from the preserved run. diff --git a/docs/README.md b/docs/README.md index b672bfb8d4..9e416d14be 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,7 @@ Screenshots gehören nicht in das Repository. | Upstream | [H2C- und Home-Assistant-Funktionen 2026-08-27](upstream-h2c-home-assistant-evaluation-2026-08-27.md) | Gerätebestand, Sensorzustände, Interlock-Fallbacks und Folgeissues | | Betrieb | [H2C-Nozzle-Mapping-Audit](h2c-nozzle-mapping-dispatch-audit.md) | Software-Sicherheitsgrenze und Hardware-Freigabenachweis für physische H2C-Düsen-IDs | | Entwicklung | [README Editorial Guide](readme-editorial-guide.md) | Zielgruppen, Positionierung und Regeln für die zentrale Projekt-README | +| Browser | [Browser-Kompatibilität](browser-compatibility.md) | Safari-16-Syntaxbaseline, README-Markdown und Grenzen der PDF-Vorschau | Die allgemeine Projektübersicht, Installation und erste Schritte stehen in der [README im Projektstamm](../README.md). diff --git a/docs/browser-compatibility.md b/docs/browser-compatibility.md new file mode 100644 index 0000000000..6073393abc --- /dev/null +++ b/docs/browser-compatibility.md @@ -0,0 +1,86 @@ +# Browser-Kompatibilität + +Aktuelle Browser bleiben für den vollständigen Funktionsumfang empfohlen. +Der JavaScript-Produktionsbuild hat eine explizite **Safari-/iOS-16.0-Syntaxbaseline** +(neben Chrome/Edge 111 und Firefox 114). Insbesondere darf der Dateimanager +unter Safari/iOS 16.0–16.3 nicht wegen des README-Markdown-Imports leer bleiben. +Das ist keine Zusage, dass sämtliche Browser-APIs, CSS-Effekte, Offline-Funktionen +oder gerätespezifischen Abläufe auf diesen alten Versionen unterstützt werden. + +## Markdown im Dateimanager + +Tabellen, Aufgabenlisten, Durchstreichung, Fußnoten und normales Markdown bleiben +erhalten. Unmarkierte URLs und E-Mail-Adressen erscheinen als Text. Für klickbare +Links `[Beschreibung](https://example.com)`, `` oder +`` verwenden. Raw-HTML wird nicht ausgeführt; die URL-Filterung +von ReactMarkdown und `noopener noreferrer` für Links bleiben erhalten. + +Der Parser kombiniert die vier benötigten GFM-Erweiterungen direkt. Der +Autolink-Literal-Import aus `remark-gfm` entfällt: Er enthielt einen bereits beim +Laden ungültigen Lookbehind-Ausdruck. In PrintOps war davon der lazy geladene +Dateimanager betroffen, nicht der bereits funktionierende Login-Einstieg. + +## PDF-Vorschau ist eine separate Laufzeitgrenze + +Auch der PDF-Worker wird durch Vite übersetzt und auf Syntax geprüft, statt als +unveränderte `.mjs`-Datei kopiert zu werden. Das ergänzt **keine** fehlenden +Browser-APIs für PDF.js. Die Dokumentlayout-PDF-Vorschau benötigt weiterhin einen +aktuellen, von PDF.js unterstützten Browser; Safari 16 wird dafür nicht zugesagt. +Der bestehende Fehlerbereich der Vorschau schützt den übrigen Layouteditor. +Keine Änderungen an Belegberechnung, Berechtigungen oder PDF-Erzeugung im Backend. + +## Reproduzierbare Prüfungen + +Im Verzeichnis `frontend`: + +```sh +npm ci +npm run build -- --outDir ./dist/browser-baseline +npm run preview -- --outDir ./dist/browser-baseline --host 127.0.0.1 --port 4184 --strictPort +``` + +Jeder Vite-Produktionsbuild prüft automatisch alle tatsächlich geschriebenen +`.js`- und `.mjs`-Dateien im gewählten Ausgabeverzeichnis, einschließlich Worker +und öffentlicher Assets. Die AST-Prüfung erkennt Lookbehind-Literale, statische +Klassenblöcke und RegExp-Unicode-Sets; Text/Kommentare werden nicht als Code +fehlinterpretiert. Fehlendes/leeres Ausgabe-JavaScript führt zum Fehler. +Dies ist ein gezielter Regressionswächter, kein vollständiger Safari-Parser oder +eine Prüfung dynamisch erzeugter regulärer Ausdrücke. + +In einem zweiten Terminal, während die Vorschau läuft: + +```sh +npx playwright install chromium +npm run check:browser-runtime +``` + +Der Check lädt den **gebauten** Dateimanager mit deterministischen lokalen +API-Testdaten bei 1440×1000 und 390×844, prüft Markdown und Link-/HTML-Schutz und +klappt die README zu und wieder auf. Relevante Konsolen-/HTTP-Fehler schlagen +fehl. Service Worker sind für die API-Fixtures ausgeschaltet; die erwarteten +WebSocket-Verbindungsfehler ohne Backend sind explizit ausgenommen. Der Check +läuft auch im CI-Job „Frontend Build“. + +Für einen historischen Engine-Gegencheck kann eine **separat installierte** +Playwright-Version verwendet werden, ohne App-Abhängigkeiten zu downgraden: + +```sh +BASELINE_ENGINE=webkit BASELINE_PLAYWRIGHT=/absolute/path/to/playwright-core npm run check:browser-runtime +``` + +Unter PowerShell die beiden Variablen mit `$env:BASELINE_ENGINE='webkit'` und +`$env:BASELINE_PLAYWRIGHT='C:/.../playwright-core'` setzen und danach denselben +npm-Befehl ausführen. `BASELINE_URL` überschreibt die lokale Vorschauadresse; +`BASELINE_SCREENSHOTS` speichert Bilder in einem vorhandenen Verzeichnis. + +Validiert am 08.09.2026: WebKit 16.0 (Playwright 1.27.1, Build 1724, Windows) und +Chromium 149.0.7827.55. Derselbe alte Produktions-Dateimanager schlug in WebKit +mit `Invalid regular expression: invalid group specifier name` fehl; der +korrigierte Build besteht den README-Ablauf in beiden Engines und Viewports. +Das ersetzt keinen Test auf einem physischen iOS-Gerät. Bestehende mobile +Toolbar-Überbreite und Schrift-Rasterung der historischen Windows-Engine sind +nicht Teil dieser Parsefehler-Korrektur. + +Referenzen: [Safari 16.4 / neue JavaScript-Syntax](https://webkit.org/blog/13966/webkit-features-in-safari-16-4/), +[Vite Build-Ziel](https://vite.dev/config/build-options.html#build-target), +[PDF.js Browser-Anforderungen](https://github.com/mozilla/pdf.js/wiki/Frequently-Asked-Questions). diff --git a/frontend/e2e/document-layout.spec.ts b/frontend/e2e/document-layout.spec.ts index e0840fd194..d6815c73af 100644 --- a/frontend/e2e/document-layout.spec.ts +++ b/frontend/e2e/document-layout.spec.ts @@ -50,6 +50,13 @@ async function installDocumentLayoutApi(page: Page, evidenceKind: EvidenceKind | if (pathname === '/api/v1/auth/status') { return respond(route, { auth_enabled: false, requires_setup: false }); } + if (pathname === '/api/v1/auth/encryption-status') { + return respond(route, { + key_configured: true, key_source: 'generated', decryption_broken: false, migration_error_count: 0, + legacy_plaintext_rows: { oidc_providers: 0, user_totp: 0 }, + encrypted_rows: { oidc_providers: 0, user_totp: 0 }, + }); + } if (pathname === '/api/v1/business-profiles/options') { return respond(route, [{ id: 2, @@ -238,7 +245,7 @@ async function installDocumentLayoutApi(page: Page, evidenceKind: EvidenceKind | async function openWorkspace(page: Page) { await page.goto('/settings?tab=orders-calculation&sub=format-preview'); await expect(page.locator('#document-layout-workspace')).toBeVisible(); - await expect(page.locator('#document-layout-workspace').getByRole('heading', { name: /Format & Preview/i })).toBeVisible(); + await expect(page.getByRole('heading', { name: /Format & Preview/i })).toBeVisible(); await expect(page.getByRole('img', { name: 'PDF page 1' })).toBeVisible(); } @@ -293,6 +300,20 @@ test.describe('document layout desktop workflow', () => { }); }); +test('PDF preview survives native worker rejection using its module fallback', async ({ page }) => { + await page.addInitScript(() => { + window.Worker = class extends Worker { + constructor(...args: ConstructorParameters) { + super(...args); + this.terminate(); + throw new Error('Native workers blocked by baseline test'); + } + }; + }); + await installDocumentLayoutApi(page); + await openWorkspace(page); +}); + test.describe('document layout mobile workflow', () => { test.use({ viewport: { width: 390, height: 844 } }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e8a6983368..ff0bd965e0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,6 +28,14 @@ "i18next-browser-languagedetector": "^8.2.0", "jszip": "^3.10.1", "lucide-react": "^0.555.0", + "mdast-util-gfm-footnote": "^2.1.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.1.0", + "micromark-extension-gfm-strikethrough": "^2.1.0", + "micromark-extension-gfm-table": "^2.1.1", + "micromark-extension-gfm-task-list-item": "^2.1.0", "pdfjs-dist": "6.2.108", "qrcode.react": "^4.2.0", "react": "19.2.8", @@ -37,10 +45,10 @@ "react-router": "8.3.0", "react-simple-keyboard": "^3.8.164", "recharts": "^3.5.1", - "remark-gfm": "^4.0.1", "three": "^0.181.2" }, "devDependencies": { + "@eslint-community/regexpp": "^4.12.2", "@eslint/js": "^9.39.1", "@playwright/test": "1.61.1", "@tailwindcss/postcss": "^4.1.17", @@ -52,6 +60,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.2.0", "@vitest/coverage-v8": "^4.1.8", + "acorn": "^8.15.0", "autoprefixer": "^10.4.22", "baseline-browser-mapping": "^2.9.19", "eslint": "^9.39.1", @@ -5721,32 +5730,6 @@ "node": ">= 0.4" } }, - "node_modules/mdast-util-find-and-replace": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", - "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", - "dependencies": { - "@types/mdast": "^4.0.0", - "escape-string-regexp": "^5.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/mdast-util-from-markdown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", @@ -5770,40 +5753,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/mdast-util-gfm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", - "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", - "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-gfm-autolink-literal": "^2.0.0", - "mdast-util-gfm-footnote": "^2.0.0", - "mdast-util-gfm-strikethrough": "^2.0.0", - "mdast-util-gfm-table": "^2.0.0", - "mdast-util-gfm-task-list-item": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-autolink-literal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", - "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", - "dependencies": { - "@types/mdast": "^4.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "micromark-util-character": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/mdast-util-gfm-footnote": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", @@ -6060,40 +6009,6 @@ "micromark-util-types": "^2.0.0" } }, - "node_modules/micromark-extension-gfm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", - "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", - "dependencies": { - "micromark-extension-gfm-autolink-literal": "^2.0.0", - "micromark-extension-gfm-footnote": "^2.0.0", - "micromark-extension-gfm-strikethrough": "^2.0.0", - "micromark-extension-gfm-table": "^2.0.0", - "micromark-extension-gfm-tagfilter": "^2.0.0", - "micromark-extension-gfm-task-list-item": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-autolink-literal": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", - "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/micromark-extension-gfm-footnote": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", @@ -6146,18 +6061,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/micromark-extension-gfm-tagfilter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", - "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", - "dependencies": { - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/micromark-extension-gfm-task-list-item": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", @@ -7426,23 +7329,6 @@ "redux": "^5.0.0" } }, - "node_modules/remark-gfm": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", - "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-gfm": "^3.0.0", - "micromark-extension-gfm": "^3.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -7474,20 +7360,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/remark-stringify": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", - "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-to-markdown": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index f92afbf162..ce0f3fa9ac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "test:coverage": "vitest run --coverage", "test:ui": "vitest --ui", "check:i18n": "node scripts/check-i18n-parity.mjs", + "check:browser-runtime": "node scripts/check-browser-runtime.mjs", "check:order-master-data-generated": "node scripts/verify-order-master-data-unicode.mjs --generated-only", "verify:order-master-data-unicode": "node scripts/verify-order-master-data-unicode.mjs" }, @@ -37,6 +38,14 @@ "i18next-browser-languagedetector": "^8.2.0", "jszip": "^3.10.1", "lucide-react": "^0.555.0", + "mdast-util-gfm-footnote": "^2.1.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.1.0", + "micromark-extension-gfm-strikethrough": "^2.1.0", + "micromark-extension-gfm-table": "^2.1.1", + "micromark-extension-gfm-task-list-item": "^2.1.0", "pdfjs-dist": "6.2.108", "qrcode.react": "^4.2.0", "react": "19.2.8", @@ -46,7 +55,6 @@ "react-router": "8.3.0", "react-simple-keyboard": "^3.8.164", "recharts": "^3.5.1", - "remark-gfm": "^4.0.1", "three": "^0.181.2" }, "overrides": { @@ -62,6 +70,7 @@ } }, "devDependencies": { + "@eslint-community/regexpp": "^4.12.2", "@eslint/js": "^9.39.1", "@playwright/test": "1.61.1", "@tailwindcss/postcss": "^4.1.17", @@ -73,6 +82,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.2.0", "@vitest/coverage-v8": "^4.1.8", + "acorn": "^8.15.0", "autoprefixer": "^10.4.22", "baseline-browser-mapping": "^2.9.19", "eslint": "^9.39.1", diff --git a/frontend/scripts/browserBaseline.ts b/frontend/scripts/browserBaseline.ts new file mode 100644 index 0000000000..c423d4d27b --- /dev/null +++ b/frontend/scripts/browserBaseline.ts @@ -0,0 +1,72 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { parse, type Node } from 'acorn'; +import { RegExpParser, visitRegExpAST } from '@eslint-community/regexpp'; +import type { Plugin } from 'vite'; + +/** Known Safari 16.0–16.3 parse hazards, not a runtime API/polyfill audit. */ +export function browserSyntaxErrors(source: string): string[] { + const errors = new Set(); + const parser = new RegExpParser(); + const nodes: unknown[] = [parse(source, { ecmaVersion: 'latest', sourceType: 'module' })]; + while (nodes.length) { + const value = nodes.pop(); + if (!value || typeof value !== 'object') continue; + if (Array.isArray(value)) { nodes.push(...value); continue; } + const node = value as Node & { regex?: { pattern: string; flags: string } }; + if (node.type === 'StaticBlock') errors.add('class static initialization block'); + if (node.regex) { + const { pattern, flags } = node.regex; + const ast = parser.parsePattern(pattern, 0, pattern.length, { + unicode: flags.includes('u'), unicodeSets: flags.includes('v'), + }); + visitRegExpAST(ast, { + onAssertionEnter(assertion) { + if (assertion.kind === 'lookbehind') errors.add('regexp lookbehind literal'); + }, + }); + if (flags.includes('v')) errors.add('regexp unicode sets flag'); + } + // Acorn's RegExp value is not executable code; strings/comments are ignored. + nodes.push(...Object.values(value)); + } + return [...errors]; +} + +export function checkBrowserBaseline(directory: string): number { + let count = 0; + const errors: string[] = []; + function scan(current: string) { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const filename = path.join(current, entry.name); + if (entry.isDirectory()) { scan(filename); continue; } + if (!/\.(?:js|mjs)$/.test(entry.name)) continue; + count++; + try { + for (const error of browserSyntaxErrors(fs.readFileSync(filename, 'utf8'))) { + errors.push(`${path.relative(directory, filename)}: ${error}`); + } + } catch (error) { + errors.push(`${path.relative(directory, filename)}: ${String(error)}`); + } + } + } + scan(directory); + if (!count) throw new Error('Browser baseline: no JavaScript output found'); + if (errors.length) throw new Error(`Safari 16 syntax baseline failed:\n${errors.join('\n')}`); + return count; +} + +/** Check final disk output, including copied .mjs assets and worker bundles. */ +export function browserBaseline(): Plugin { + let directory = ''; + return { + name: 'browser-syntax-baseline', + apply: 'build', + configResolved(config) { directory = path.resolve(config.root, config.build.outDir); }, + closeBundle() { + const count = checkBrowserBaseline(directory); + console.log(`Browser syntax baseline: ${count} JavaScript files passed (Safari 16).`); + }, + }; +} diff --git a/frontend/scripts/check-browser-runtime.mjs b/frontend/scripts/check-browser-runtime.mjs new file mode 100644 index 0000000000..04ed46d0b0 --- /dev/null +++ b/frontend/scripts/check-browser-runtime.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +// Use a production preview, never the dev server. An isolated historical +// playwright-core package can be selected without changing application deps. +const require = createRequire(import.meta.url); +const playwright = require(process.env.BASELINE_PLAYWRIGHT || 'playwright'); +const engine = process.env.BASELINE_ENGINE || 'chromium'; +const url = process.env.BASELINE_URL || 'http://127.0.0.1:4184'; +const browser = await playwright[engine].launch({ headless: true }); +const markdown = [ + '# Safari baseline README', '', '| Part | Qty |', '| --- | ---: |', '| Gear | 2 |', '', + '- [x] Printed', '- [ ] Packed', '', '~~Old revision~~', '', + 'Material note[^1]', '', '[^1]: Use PETG.', '', + '[Docs](https://example.com/docs)', '', '', '', + 'https://example.com/bare print@example.com', '', + '[Blocked](javascript:alert%281%29)', '', '', +].join('\n'); + +try { + for (const viewport of [{ width: 1440, height: 1000 }, { width: 390, height: 844 }]) { + const page = await browser.newPage({ viewport, serviceWorkers: 'block' }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('response', (response) => { + if (response.status() >= 400) errors.push(`HTTP ${response.status()} ${response.url()}`); + }); + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + await page.addInitScript(() => { + localStorage.setItem('i18nextLng', 'en'); + localStorage.setItem('printops-language', 'en'); + }); + // Offline caching is outside this HTTP-fixture test. Blocking registration + // avoids the old Playwright shim returning undefined instead of a Promise. + await page.route('**/sw-register.js', (route) => route.fulfill({ contentType: 'application/javascript', body: '' })); + await page.route('**/api/v1/**', (route) => { + const pathname = new URL(route.request().url()).pathname; + let body = []; + if (pathname === '/api/v1/auth/status') body = { auth_enabled: false, requires_setup: false }; + else if (pathname === '/api/v1/library/folders') body = [{ + id: 42, name: 'Baseline models', parent_id: null, children: [], file_count: 0, + total_size: 0, created_at: '2026-09-08T00:00:00Z', + }]; + else if (pathname === '/api/v1/library/folders/42/readme') body = { filename: 'README.md', content: markdown, truncated: false }; + else if (pathname === '/api/v1/library/trash') body = { items: [], total: 0 }; + else if (pathname === '/api/v1/library/stats') body = { total_files: 0, total_folders: 1, total_size: 0, files_by_type: {} }; + else if (pathname === '/api/v1/settings') body = { language: 'en' }; + else if (pathname === '/api/v1/auth/encryption-status') body = { + key_configured: true, key_source: 'generated', decryption_broken: false, migration_error_count: 0, + legacy_plaintext_rows: { oidc_providers: 0, user_totp: 0 }, + encrypted_rows: { oidc_providers: 0, user_totp: 0 }, + }; + else if (pathname === '/api/v1/version') body = { version: 'baseline-test' }; + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + }); + await page.goto(`${url}/files?folder=42`); + const heading = page.getByRole('heading', { name: 'Safari baseline README' }); + try { + await heading.waitFor(); + } catch (error) { + console.error(JSON.stringify({ url: page.url(), errors, text: await page.locator('body').innerText() })); + throw error; + } + assert.match(await page.title(), /PrintOps/i); + assert.match(page.url(), /\/files\?folder=42$/); + assert.equal(await page.locator('vite-error-overlay').count(), 0); + const panel = page.getByRole('button', { name: 'README.md', exact: true }).locator('..'); + assert.match(await panel.getByRole('table').innerText(), /Gear/); + assert.equal(await panel.getByRole('checkbox').first().isChecked(), true); + assert.equal(await panel.getByRole('checkbox').first().isDisabled(), true); + assert.equal(await panel.locator('del').innerText(), 'Old revision'); + assert.match(await panel.locator('[data-footnotes]').innerText(), /Use PETG/); + assert.equal(await panel.getByRole('link', { name: 'Docs', exact: true }).getAttribute('rel'), 'noopener noreferrer'); + assert.equal(await panel.getByRole('link', { name: 'Blocked', exact: true }).getAttribute('href'), ''); + assert.equal(await panel.locator('a[href="https://example.com/bare"]').count(), 0); + assert.equal(await page.evaluate(() => Boolean(window.readmeExecuted)), false); + const toggle = page.getByRole('button', { name: 'README.md', exact: true }); + await toggle.click(); + assert.equal(await heading.count(), 0); + await toggle.click(); + await heading.waitFor(); + // WebSocket has no backend in this deterministic API fixture; its transport + // failure is unrelated to parsing/rendering and must not hide other errors. + const relevantErrors = errors.filter((error) => !/^(?:WebSocket connection.*failed|\[WebSocket\] Error)/i.test(error)); + assert.deepEqual(relevantErrors, []); + if (process.env.BASELINE_SCREENSHOTS) { + await page.screenshot({ path: path.join(process.env.BASELINE_SCREENSHOTS, `${engine}-${viewport.width}.png`) }); + } + console.log(JSON.stringify({ engine, version: browser.version(), viewport, flow: 'files -> README -> collapse -> expand', errors: relevantErrors, passed: true })); + await page.close(); + } +} finally { + await browser.close(); +} diff --git a/frontend/src/__tests__/components/FolderReadmePanel.test.tsx b/frontend/src/__tests__/components/FolderReadmePanel.test.tsx index 3e6c9642de..43c3f6ac70 100644 --- a/frontend/src/__tests__/components/FolderReadmePanel.test.tsx +++ b/frontend/src/__tests__/components/FolderReadmePanel.test.tsx @@ -3,13 +3,80 @@ */ import { describe, it, expect } from 'vitest'; -import { screen, waitFor } from '@testing-library/react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; import { render } from '../utils'; import { FolderReadmePanel } from '../../components/FolderReadmePanel'; import { server } from '../mocks/server'; +async function renderReadme(content: string) { + server.use(http.get('/api/v1/library/folders/:id/readme', () => + HttpResponse.json({ filename: 'README.md', content, truncated: false }), + )); + const result = render(); + await screen.findByText('README.md'); + return result; +} + describe('FolderReadmePanel', () => { + it('preserves GFM tables, task lists, strikethrough and footnotes', async () => { + const { container } = await renderReadme([ + '| Part | Qty |', '| --- | ---: |', '| Gear | 2 |', '', + '- [x] Printed', '- [ ] Packed', '', '~~Old revision~~', '', + 'Material note[^1]', '', '[^1]: Use PETG.', + ].join('\n')); + expect(screen.getByRole('table')).toHaveTextContent('Gear'); + expect(screen.getByRole('columnheader', { name: 'Qty' })).toBeInTheDocument(); + const tasks = screen.getAllByRole('checkbox'); + expect(tasks[0]).toBeChecked(); + expect(tasks[1]).not.toBeChecked(); + for (const task of tasks) expect(task).toBeDisabled(); + expect(container.querySelector('del')).toHaveTextContent('Old revision'); + expect(container.querySelector('[data-footnotes]')).toHaveTextContent('Use PETG.'); + }); + + it('preserves explicit and angle links with URL filtering and opener protection', async () => { + await renderReadme('[Docs](https://example.com/docs)\n\n\n\n'); + const links = screen.getAllByRole('link'); + expect(links.map((link) => link.getAttribute('href'))).toEqual([ + 'https://example.com/docs', 'https://example.com', 'mailto:print@example.com', + ]); + for (const link of links) { + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + } + }); + + it('keeps bare URLs and email addresses as readable text', async () => { + await renderReadme('https://example.com print@example.com'); + expect(screen.getByText('https://example.com print@example.com')).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('does not interpret raw HTML or allow executable URL schemes', async () => { + const { container } = await renderReadme([ + '', '', '', '', + '[Bad](javascript:alert%281%29)', '', '[Data](data:text/html,bad)', '', + '[Encoded](javascript:alert%281%29)', '', '![Bad image](javascript:alert%281%29)', + ].join('\n')); + expect(container.querySelector('script, [onerror]')).toBeNull(); + expect(screen.getByText('Bad')).toHaveAttribute('href', ''); + expect(screen.getByText('Data')).toHaveAttribute('href', ''); + expect(screen.getByText('Encoded')).toHaveAttribute('href', ''); + expect(screen.getByAltText('Bad image')).not.toHaveAttribute('src'); + }); + + it('preserves ordinary Markdown and can collapse and reopen the panel', async () => { + const { container } = await renderReadme('# Assembly\n\n> Fit **carefully**.\n\n1. First\n2. Second\n\n```js\nconst qty = 2;\n```'); + expect(container.querySelector('blockquote strong')).toHaveTextContent('carefully'); + expect(container.querySelector('ol')).toHaveTextContent('Second'); + expect(container.querySelector('pre code')).toHaveTextContent('const qty = 2;'); + fireEvent.click(screen.getByRole('button', { name: 'README.md' })); + expect(screen.queryByRole('heading', { name: 'Assembly' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'README.md' })); + expect(screen.getByRole('heading', { name: 'Assembly' })).toBeInTheDocument(); + }); + it('renders nothing when the folder has no markdown (404)', async () => { server.use( http.get('/api/v1/library/folders/:id/readme', () => diff --git a/frontend/src/__tests__/components/settings/document-layout/PdfPreviewPane.test.tsx b/frontend/src/__tests__/components/settings/document-layout/PdfPreviewPane.test.tsx index 71f24d9233..594dc53a2a 100644 --- a/frontend/src/__tests__/components/settings/document-layout/PdfPreviewPane.test.tsx +++ b/frontend/src/__tests__/components/settings/document-layout/PdfPreviewPane.test.tsx @@ -27,7 +27,7 @@ vi.mock('pdfjs-dist', () => ({ getDocument: mocks.getDocument, })); -vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', () => ({ +vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?worker&url', () => ({ default: '/assets/pdf.worker.test.mjs', })); diff --git a/frontend/src/__tests__/utils/browserBaseline.test.ts b/frontend/src/__tests__/utils/browserBaseline.test.ts new file mode 100644 index 0000000000..b525b9ac8b --- /dev/null +++ b/frontend/src/__tests__/utils/browserBaseline.test.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { browserSyntaxErrors, checkBrowserBaseline } from '../../../scripts/browserBaseline'; + +describe('production browser syntax baseline', () => { + it.each(['/(?<=prefix)value/u', '/(? { + expect(browserSyntaxErrors(source)).toContain('regexp lookbehind literal'); + }); + + it('rejects class static initialization blocks and unicode sets', () => { + expect(browserSyntaxErrors('class Example { static { this.ready = true; } }')) + .toContain('class static initialization block'); + expect(browserSyntaxErrors('/[a&&b]/v')).toContain('regexp unicode sets flag'); + }); + + it('does not confuse strings, comments, escaped patterns, named groups or lookahead with hazards', () => { + expect(browserSyntaxErrors([ + 'const note = "(?<= static {";', '// (?value)/u;', '/(?=value)(?!other)/;', + 'class Example { static value = 1; static method() {} }', + ].join('\n'))).toEqual([]); + }); + + const directories: string[] = []; + afterEach(() => { + for (const directory of directories.splice(0)) fs.rmSync(directory, { recursive: true, force: true }); + }); + function output() { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'printops-baseline-test-')); + directories.push(directory); + fs.mkdirSync(path.join(directory, 'assets')); + return directory; + } + + it('checks nested chunks AND raw .mjs assets in the selected output directory', () => { + const directory = output(); + fs.writeFileSync(path.join(directory, 'assets', 'page.js'), 'export const value = 1;'); + fs.writeFileSync(path.join(directory, 'assets', 'worker.mjs'), 'class Worker { static {} }'); + expect(() => checkBrowserBaseline(directory)).toThrow(/worker.mjs.*static initialization/); + fs.writeFileSync(path.join(directory, 'assets', 'worker.mjs'), 'export const value = 2;'); + expect(checkBrowserBaseline(directory)).toBe(2); + }); + + it('fails closed on missing/empty output and malformed JavaScript', () => { + const directory = output(); + expect(() => checkBrowserBaseline(path.join(directory, 'missing'))).toThrow(); + expect(() => checkBrowserBaseline(directory)).toThrow('no JavaScript output'); + fs.writeFileSync(path.join(directory, 'broken.js'), 'export const ='); + expect(() => checkBrowserBaseline(directory)).toThrow(/broken.js.*SyntaxError/); + }); +}); diff --git a/frontend/src/components/FolderReadmePanel.tsx b/frontend/src/components/FolderReadmePanel.tsx index 4712cb87c7..aed6e02ed8 100644 --- a/frontend/src/components/FolderReadmePanel.tsx +++ b/frontend/src/components/FolderReadmePanel.tsx @@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { ChevronDown, ChevronUp, FileText } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; +import remarkGfmNoAutolink from '../utils/remarkGfmNoAutolink'; import { api } from '../api/client'; @@ -13,8 +13,8 @@ interface FolderReadmePanelProps { /** * Side panel that renders a `.md` file from the selected folder (#1268). - * Hidden when the folder has no markdown file. Disables raw HTML and links - * stay text-only — same posture as the print-archive note panel. + * Hidden when the folder has no markdown file. Raw HTML is not interpreted; + * Markdown links use ReactMarkdown's URL filtering and opener protection. */ export function FolderReadmePanel({ folderId }: FolderReadmePanelProps) { const { t } = useTranslation(); @@ -56,7 +56,7 @@ export function FolderReadmePanel({ folderId }: FolderReadmePanelProps) { {!collapsed && (

{children}

, h2: ({ children }) =>

{children}

, diff --git a/frontend/src/components/settings/document-layout/PdfPreviewPane.tsx b/frontend/src/components/settings/document-layout/PdfPreviewPane.tsx index 5d31cc1886..c6123fb039 100644 --- a/frontend/src/components/settings/document-layout/PdfPreviewPane.tsx +++ b/frontend/src/components/settings/document-layout/PdfPreviewPane.tsx @@ -7,7 +7,8 @@ import { type PDFDocumentProxy, type RenderTask, } from 'pdfjs-dist'; -import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; +// Process the worker through the same syntax target as the app, not as a raw asset. +import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?worker&url'; import { useEffect, useMemo, diff --git a/frontend/src/utils/remarkGfmNoAutolink.ts b/frontend/src/utils/remarkGfmNoAutolink.ts new file mode 100644 index 0000000000..26022118d2 --- /dev/null +++ b/frontend/src/utils/remarkGfmNoAutolink.ts @@ -0,0 +1,30 @@ +import { gfmFootnoteFromMarkdown } from 'mdast-util-gfm-footnote'; +import { gfmStrikethroughFromMarkdown } from 'mdast-util-gfm-strikethrough'; +import { gfmTableFromMarkdown } from 'mdast-util-gfm-table'; +import { gfmTaskListItemFromMarkdown } from 'mdast-util-gfm-task-list-item'; +import { gfmFootnote } from 'micromark-extension-gfm-footnote'; +import { gfmStrikethrough } from 'micromark-extension-gfm-strikethrough'; +import { gfmTable } from 'micromark-extension-gfm-table'; +import { gfmTaskListItem } from 'micromark-extension-gfm-task-list-item'; +import type { Processor } from 'unified'; +import type {} from 'remark-parse'; + +/** + * Parse-only GFM, excluding autolink literals whose import-time lookbehind + * breaks Safari 16.0–16.3 (#144). Bare URLs/emails stay text; explicit Markdown + * and angle-bracket links still work. Keep these imports separate: importing + * remark-gfm or mdast-util-gfm would reintroduce the incompatible module. + * There is no Markdown serialization consumer here. + */ +export default function remarkGfmNoAutolink(this: Processor): undefined { + const data = this.data(); + const syntax = data.micromarkExtensions || (data.micromarkExtensions = []); + const trees = data.fromMarkdownExtensions || (data.fromMarkdownExtensions = []); + syntax.push(gfmFootnote(), gfmStrikethrough(), gfmTable(), gfmTaskListItem()); + trees.push( + gfmFootnoteFromMarkdown(), + gfmStrikethroughFromMarkdown(), + gfmTableFromMarkdown(), + gfmTaskListItemFromMarkdown(), + ); +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b63923abe6..f4211905a1 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react' import path from 'path' import fs from 'fs' import type { Connect, Plugin } from 'vite' +import { browserBaseline } from './scripts/browserBaseline' // Backend port for dev server proxy (default: 8000) const backendPort = process.env.BACKEND_PORT || '8000' @@ -102,12 +103,21 @@ export default defineConfig({ // fix for subpath reverse proxies (#1195, wontfix) is reverted — that // audience uses NPM + Cloudflare Tunnel at a real domain per the // documented workaround, which doesn't depend on this setting. - plugins: [react(), serveGcodeViewer(), emitLocaleAssetManifest()], + plugins: [react(), serveGcodeViewer(), emitLocaleAssetManifest(), browserBaseline()], build: { + target: ['chrome111', 'edge111', 'firefox114', 'safari16', 'ios16'], outDir: '../static', emptyOutDir: true, chunkSizeWarningLimit: 3000, }, + worker: { + format: 'es', + // PDF.js also dynamically imports this module when native workers fail. + plugins: () => [{ + name: 'preserve-worker-module-exports', + options: (options) => ({ ...options, preserveEntrySignatures: 'strict' }), + }], + }, server: { host: '0.0.0.0', proxy: {