From 796bb567628e8777aefd4bef8c8ea9e13e494750 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:43:00 +0530 Subject: [PATCH 01/10] fix(docker): serve HTTP assets correctly and verify container UI (#290) --- .dockerignore | 1 + .github/workflows/docker-publish.yml | 18 ++++++ Dockerfile | 16 ++++-- tests/docker-browser-smoke.cjs | 56 +++++++++++++++++++ tests/docker-smoke.sh | 83 ++++++++++++++++++++++++++++ wiki/Docker-Deployment.md | 18 +++--- 6 files changed, 177 insertions(+), 15 deletions(-) create mode 100644 tests/docker-browser-smoke.cjs create mode 100644 tests/docker-smoke.sh diff --git a/.dockerignore b/.dockerignore index 49b5caab..944ac431 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,7 @@ desktop-app wiki node_modules *.md +!sample.md LICENSE docker-compose.yml Dockerfile diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ae6ec091..36f0098d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -42,6 +42,24 @@ jobs: type=sha,prefix=sha- type=raw,value=latest,enable={{is_default_branch}} + - name: Install browser test dependencies + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Validate static build and unit tests + run: | + npm run build + npm run test:unit + + - name: Build and smoke-test the actual Docker image + run: | + docker build -t markdown-viewer:smoke . + DOCKER_BROWSER_SMOKE=1 bash tests/docker-smoke.sh markdown-viewer:smoke + + - name: Run existing browser smoke tests + run: npm run test:e2e:smoke + - name: Build and push Docker image uses: docker/build-push-action@v5 with: diff --git a/Dockerfile b/Dockerfile index 8c028681..01ba45e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,10 @@ COPY workspace-storage.js /usr/share/nginx/html/ COPY visual-settings-adapters.js /usr/share/nginx/html/ COPY visual-settings-store.js /usr/share/nginx/html/ COPY script.js /usr/share/nginx/html/ +COPY browser-drop-source.js /usr/share/nginx/html/ +COPY preview-worker.js /usr/share/nginx/html/ COPY styles.css /usr/share/nginx/html/ +COPY sample.md /usr/share/nginx/html/ COPY sw.js /usr/share/nginx/html/ COPY RELEASE_NOTES /usr/share/nginx/html/ COPY manifest.json /usr/share/nginx/html/ @@ -38,10 +41,13 @@ RUN echo 'server { \ try_files $uri $uri/ /index.html; \ } \ \ - # Cache static assets \ - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { \ - expires 1y; \ - add_header Cache-Control "public, immutable"; \ + # Mutable scripts/styles must revalidate after upgrades \ + location ~* \.(js|css|mjs)$ { \ + expires -1; \ + } \ + # Keep bounded caching for unversioned image filenames \ + location ~* \.(png|jpg|jpeg|gif|ico|svg)$ { \ + expires 7d; \ } \ \ # Security headers \ @@ -50,7 +56,7 @@ RUN echo 'server { \ add_header X-XSS-Protection "1; mode=block" always; \ add_header Referrer-Policy "strict-origin-when-cross-origin" always; \ # PERF-029: Content Security Policy for defense-in-depth \ - add_header Content-Security-Policy "default-src '"'"'self'"'"'; base-uri '"'"'self'"'"'; object-src '"'"'none'"'"'; script-src '"'"'self'"'"' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net '"'"'unsafe-inline'"'"'; worker-src '"'"'self'"'"'; connect-src '"'"'self'"'"' https://api.github.com https://raw.githubusercontent.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://kroki.io https://www.plantuml.com https://mermaid.ink https://paulrosen.github.io; img-src '"'"'self'"'"' data: blob: https:; style-src '"'"'self'"'"' '"'"'unsafe-inline'"'"' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; font-src '"'"'self'"'"' data: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; media-src '"'"'self'"'"' blob: data:; manifest-src '"'"'self'"'"'; upgrade-insecure-requests" always; \ + add_header Content-Security-Policy "default-src '"'"'self'"'"'; base-uri '"'"'self'"'"'; object-src '"'"'none'"'"'; script-src '"'"'self'"'"' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net '"'"'unsafe-inline'"'"'; worker-src '"'"'self'"'"'; connect-src '"'"'self'"'"' https://api.github.com https://raw.githubusercontent.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://kroki.io https://www.plantuml.com https://mermaid.ink https://paulrosen.github.io; img-src '"'"'self'"'"' data: blob: https:; style-src '"'"'self'"'"' '"'"'unsafe-inline'"'"' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; font-src '"'"'self'"'"' data: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; media-src '"'"'self'"'"' blob: data:; manifest-src '"'"'self'"'"'" always; \ }' > /etc/nginx/conf.d/default.conf # Expose port 80 diff --git a/tests/docker-browser-smoke.cjs b/tests/docker-browser-smoke.cjs new file mode 100644 index 00000000..4bdfae79 --- /dev/null +++ b/tests/docker-browser-smoke.cjs @@ -0,0 +1,56 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { chromium } = require('@playwright/test'); + +async function main() { + const origin = process.env.DOCKER_SMOKE_URL || 'http://127.0.0.1:18719'; + const browser = await chromium.launch({ headless: true }); + try { + const context = await browser.newContext({ serviceWorkers: 'allow', viewport: { width: 1440, height: 900 } }); + const page = await context.newPage(); + const failedLocalRequests = []; + page.on('requestfailed', request => { + if (new URL(request.url()).origin === origin) { + failedLocalRequests.push(request.url() + ': ' + (request.failure()?.errorText || 'failed')); + } + }); + + const response = await page.goto(origin, { waitUntil: 'domcontentloaded', timeout: 30000 }); + assert.equal(response.status(), 200, 'App navigation must succeed'); + await page.locator('#markdown-editor').waitFor({ state: 'visible', timeout: 45000 }); + await page.locator('#markdown-preview').waitFor({ state: 'visible', timeout: 30000 }); + + const styleInfo = await page.evaluate(() => ({ + cssLoaded: !!document.querySelector('link[href="styles.css"]')?.sheet, + iconCssLoaded: !!document.querySelector('link[href="assets/lucide-icons.css"]')?.sheet, + backgroundVariable: getComputedStyle(document.documentElement).getPropertyValue('--bg-color').trim() + })); + assert.ok(styleInfo.cssLoaded, 'Main CSS must apply'); + assert.ok(styleInfo.iconCssLoaded, 'Icon CSS must apply'); + assert.ok(styleInfo.backgroundVariable, 'App CSS variables must be available'); + + await page.locator('#markdown-editor').fill('# Docker smoke\\n\\n**Container preview works**'); + await page.locator('#markdown-preview').getByRole('heading', { name: 'Docker smoke' }).waitFor({ timeout: 30000 }); + await page.locator('#markdown-preview').getByText('Container preview works').waitFor({ timeout: 30000 }); + + const activeWorker = await page.evaluate(async () => { + if (!('serviceWorker' in navigator)) throw new Error('Service Worker API unavailable'); + const ready = navigator.serviceWorker.ready; + const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Service Worker not ready after 20s')), 20000)); + const registration = await Promise.race([ready, timeout]); + return registration.active?.state; + }); + assert.equal(activeWorker, 'activated', 'Service Worker must install and activate'); + + assert.deepEqual(failedLocalRequests, [], 'Local resources must not fail'); + console.log('PASS: Docker Chromium UI, styles, live Preview, and Service Worker'); + } finally { + await browser.close(); + } +} + +main().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/docker-smoke.sh b/tests/docker-smoke.sh new file mode 100644 index 00000000..58c75096 --- /dev/null +++ b/tests/docker-smoke.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +image="${1:-markdown-viewer:smoke}" +port="${DOCKER_SMOKE_PORT:-18719}" +container="markdown-viewer-smoke-$$" +base="http://127.0.0.1:${port}" +scratch="$(mktemp -d)" + +cleanup() { + docker rm -f "$container" >/dev/null 2>&1 || true + rm -rf "$scratch" +} +trap cleanup EXIT + +docker run -d --name "$container" -p "127.0.0.1:${port}:80" "$image" >/dev/null + +ready=0 +for attempt in $(seq 1 45); do + if curl --silent --show-error --fail --max-time 3 "$base/" -o "$scratch/root" 2>/dev/null; then + ready=1 + break + fi + sleep 1 +done +if [[ "$ready" != 1 ]]; then + echo "Docker container did not become ready" >&2 + docker logs "$container" >&2 || true + exit 1 +fi + +fetch_asset() { + local path="$1" + local expected_type="$2" + local key="${path//\//_}" + local headers="$scratch/${key}.headers" + local body="$scratch/${key}.body" + curl --silent --show-error --fail --max-time 15 -D "$headers" -o "$body" "$base/$path" + if ! tr -d '\r' < "$headers" | grep -Eiq "^content-type: (${expected_type})"; then + echo "Wrong content type for $path (possibly an HTML SPA fallback):" >&2 + cat "$headers" >&2 + exit 1 + fi + if [[ ! -s "$body" ]]; then + echo "Empty resource: $path" >&2 + exit 1 + fi + echo "PASS $path" +} + +fetch_asset "index.html" "text/html" +fetch_asset "styles.css" "text/css" +fetch_asset "assets/lucide-icons.css" "text/css" +fetch_asset "script.js" "(application|text)/javascript" +fetch_asset "workspace-storage.js" "(application|text)/javascript" +fetch_asset "browser-drop-source.js" "(application|text)/javascript" +fetch_asset "preview-worker.js" "(application|text)/javascript" +fetch_asset "sw.js" "(application|text)/javascript" +fetch_asset "assets/seo-metadata.mjs" "(application|text)/javascript" +fetch_asset "manifest.json" "application/json" +fetch_asset "sample.md" "(text/markdown|text/plain|application/octet-stream)" + +if ! grep -Fq 'Welcome to Markdown Viewer' "$scratch/sample.md.body"; then + echo "sample.md returned incorrect content" >&2 + exit 1 +fi +if ! grep -Fq '' "$scratch/index.html.body"; then + echo "index.html did not return the app" >&2 + exit 1 +fi +if grep -Eiq '^content-security-policy:.*upgrade-insecure-requests' "$scratch/index.html.headers"; then + echo "Plain-HTTP Docker image must not upgrade requests to HTTPS" >&2 + exit 1 +fi +if ! tr -d '\r' < "$scratch/styles.css.headers" | grep -Eiq '^cache-control:.*no-cache'; then + echo "Mutable styles.css must revalidate" >&2 + exit 1 +fi + +echo "PASS: Docker HTTP, CSP, MIME types, missing-asset prevention, and cache policy" +if [[ "${DOCKER_BROWSER_SMOKE:-0}" == "1" ]]; then + DOCKER_SMOKE_URL="$base" node tests/docker-browser-smoke.cjs +fi diff --git a/wiki/Docker-Deployment.md b/wiki/Docker-Deployment.md index 211fa2d6..737ba5a8 100644 --- a/wiki/Docker-Deployment.md +++ b/wiki/Docker-Deployment.md @@ -61,21 +61,19 @@ On `localhost`, managed-media and stored Share Snapshot requests use the product The container alone does not provide Cloudflare KV or Durable Objects. Self-hosted stored Share Snapshot and Live Share require separately deployed compatible endpoints. -## Known Stock Image Limitation +## Packaged Assets and Offline Support -The checked-in root `Dockerfile` does not copy: +The Docker image packages `browser-drop-source.js`, `preview-worker.js`, and `sample.md` along with the remaining web assets. `sw.js` precaches these files so first-party Preview Worker rendering and Service Worker installation have the required local resources. -- `preview-worker.js`; or -- `sample.md`. +Check the *content type*, not only HTTP status: an SPA fallback may return `200` and `text/html` for a missing local resource. Docker smoke tests verify both asset responses and the running editor. -Both files are listed in `sw.js` as critical precache assets. The consequences are: +## HTTP and HTTPS -- Documents eligible for Preview Worker rendering receive a Worker load failure and fall back to main-thread rendering; and -- Service Worker installation can fail because `cache.addAll()` rejects the complete precache when either asset returns `404`. +The container listens on **plain HTTP port 80**. Host mapping `8719:80` is valid; visit `http://localhost:8719` or `http://:8719`. Docker does not expose TLS on that port. -This is an implementation packaging limitation, not a documentation configuration step. The documentation audit records it without changing Docker behavior. +The image's Content Security Policy intentionally **does not** set `upgrade-insecure-requests` on its HTTP endpoint. That directive would cause browsers on non-loopback hosts to try HTTPS for required same-origin files and leave the interface partially unstyled/unusable; Nginx would log TLS handshake bytes such as `\\x16\\x03` with HTTP 400. -If you maintain a custom image, copy both files to `/usr/share/nginx/html/`, rebuild, and verify that every `CRITICAL_ASSETS` URL in `sw.js` returns `200` with the correct MIME type. +For a public HTTPS deployment, terminate TLS at a trusted reverse proxy (e.g. Caddy, Traefik, or Nginx), proxy internally via HTTP, and apply your public HTTPS/HSTS policy at that TLS endpoint. Do not apply HSTS or HTTP-to-HTTPS upgrading to the bare HTTP container. ## Nginx and Headers @@ -98,7 +96,7 @@ The Dockerfile also sets a Content-Security-Policy with the external origins req Cloudflare Pages deployments use the stricter root `_headers` and `_redirects` files, including CSP, HSTS, clickjacking, permissions, cross-origin, and sensitive-path protections. When self-hosting behind Docker or another reverse proxy, keep the CSP compatible with `index.html`, lazy-loaded renderers, Share Snapshot, and Live Share instead of copying a wildcard policy. -Use long caching carefully. `sw.js`, `index.html`, `workspace-storage.js`, `script.js`, and `styles.css` are update-sensitive; stale versions can keep old app behavior alive. +The container's mutable JavaScript, CSS, and module files use revalidation rather than one-year immutable caching. Image assets use bounded caching, so replacing an unversioned image does not force clients to retain it for a year. ## Reverse Proxy Examples From 72a9c078a107c2a8544115ae9887097d283401cf Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:44:09 +0530 Subject: [PATCH 02/10] test(docker): use real line breaks in editor preview smoke --- tests/docker-browser-smoke.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/docker-browser-smoke.cjs b/tests/docker-browser-smoke.cjs index 4bdfae79..bf181366 100644 --- a/tests/docker-browser-smoke.cjs +++ b/tests/docker-browser-smoke.cjs @@ -30,7 +30,7 @@ async function main() { assert.ok(styleInfo.iconCssLoaded, 'Icon CSS must apply'); assert.ok(styleInfo.backgroundVariable, 'App CSS variables must be available'); - await page.locator('#markdown-editor').fill('# Docker smoke\\n\\n**Container preview works**'); + await page.locator('#markdown-editor').fill('# Docker smoke\n\n**Container preview works**'); await page.locator('#markdown-preview').getByRole('heading', { name: 'Docker smoke' }).waitFor({ timeout: 30000 }); await page.locator('#markdown-preview').getByText('Container preview works').waitFor({ timeout: 30000 }); From 598c9040b338ed4959f62fd8db1b73b9b6c02237 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:45:11 +0530 Subject: [PATCH 03/10] fix(docker): serve ES modules with JavaScript MIME type --- Dockerfile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 01ba45e5..211f7fb1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,7 +42,12 @@ RUN echo 'server { \ } \ \ # Mutable scripts/styles must revalidate after upgrades \ - location ~* \.(js|css|mjs)$ { \ + location ~* \.(js|css)$ { \ + expires -1; \ + } \ + # Nginx Alpine does not recognize .mjs; modules need a JavaScript MIME type \ + location ~* \.mjs$ { \ + types { application/javascript mjs; } \ expires -1; \ } \ # Keep bounded caching for unversioned image filenames \ From cb114e02c969742a85e9e01b626f110e5dda2c20 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:47:19 +0530 Subject: [PATCH 04/10] test(docker): wait for applied styles and retain screenshot diagnostics --- tests/docker-browser-smoke.cjs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/tests/docker-browser-smoke.cjs b/tests/docker-browser-smoke.cjs index bf181366..b388ee86 100644 --- a/tests/docker-browser-smoke.cjs +++ b/tests/docker-browser-smoke.cjs @@ -1,14 +1,19 @@ 'use strict'; const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); const { chromium } = require('@playwright/test'); async function main() { const origin = process.env.DOCKER_SMOKE_URL || 'http://127.0.0.1:18719'; const browser = await chromium.launch({ headless: true }); + let page; + const browserErrors = []; try { const context = await browser.newContext({ serviceWorkers: 'allow', viewport: { width: 1440, height: 900 } }); - const page = await context.newPage(); + page = await context.newPage(); + page.on('pageerror', error => browserErrors.push(error.message)); + page.on('console', message => { if (message.type() === 'error') browserErrors.push(message.text()); }); const failedLocalRequests = []; page.on('requestfailed', request => { if (new URL(request.url()).origin === origin) { @@ -21,6 +26,25 @@ async function main() { await page.locator('#markdown-editor').waitFor({ state: 'visible', timeout: 45000 }); await page.locator('#markdown-preview').waitFor({ state: 'visible', timeout: 30000 }); + await page.waitForFunction(() => { + const mainStyle = document.querySelector('link[href="styles.css"]'); + const iconStyle = document.querySelector('link[href="assets/lucide-icons.css"]'); + return !!mainStyle?.sheet && !!iconStyle?.sheet + && !!getComputedStyle(document.documentElement).getPropertyValue('--bg-color').trim(); + }, null, { timeout: 20000 }).catch(async error => { + const diagnostic = await page.evaluate(() => ({ + href: location.href, + links: Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map(link => ({ + url: link.href, loaded: !!link.sheet + })), + backgroundVariable: getComputedStyle(document.documentElement).getPropertyValue('--bg-color').trim() + })); + console.error('CSS diagnostics:', JSON.stringify(diagnostic)); + console.error('Failed local requests:', failedLocalRequests); + console.error('Browser console errors:', browserErrors.slice(-15)); + throw error; + }); + const styleInfo = await page.evaluate(() => ({ cssLoaded: !!document.querySelector('link[href="styles.css"]')?.sheet, iconCssLoaded: !!document.querySelector('link[href="assets/lucide-icons.css"]')?.sheet, @@ -44,8 +68,13 @@ async function main() { assert.equal(activeWorker, 'activated', 'Service Worker must install and activate'); assert.deepEqual(failedLocalRequests, [], 'Local resources must not fail'); + if (browserErrors.length) console.log('Non-fatal browser console diagnostics:', browserErrors.slice(-10)); console.log('PASS: Docker Chromium UI, styles, live Preview, and Service Worker'); } finally { + if (page) { + await fs.mkdir('test-results', { recursive: true }); + await page.screenshot({ path: 'test-results/docker-ui.png', fullPage: true }).catch(() => {}); + } await browser.close(); } } From 68654fff7ffeb075aec90b9e172ee1baab3cb8aa Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:47:21 +0530 Subject: [PATCH 05/10] ci(docker): preserve browser screenshot evidence --- .github/workflows/docker-publish.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 36f0098d..bfcd2a8e 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -60,6 +60,14 @@ jobs: - name: Run existing browser smoke tests run: npm run test:e2e:smoke + - name: Upload Docker browser screenshot + if: always() + uses: actions/upload-artifact@v4 + with: + name: docker-ui-smoke + path: test-results/docker-ui.png + if-no-files-found: ignore + - name: Build and push Docker image uses: docker/build-push-action@v5 with: From 86a768d5a3a96e1456c7c0f1a7a9b1161f3002c2 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:50:24 +0530 Subject: [PATCH 06/10] fix(docker): ship native reader and harden browser smoke --- Dockerfile | 1 + sw.js | 2 ++ tests/docker-browser-smoke.cjs | 27 ++++----------------------- tests/docker-smoke.sh | 1 + wiki/Docker-Deployment.md | 2 +- 5 files changed, 9 insertions(+), 24 deletions(-) diff --git a/Dockerfile b/Dockerfile index 211f7fb1..4457ff32 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ COPY visual-settings-adapters.js /usr/share/nginx/html/ COPY visual-settings-store.js /usr/share/nginx/html/ COPY script.js /usr/share/nginx/html/ COPY browser-drop-source.js /usr/share/nginx/html/ +COPY native-markdown-source.js /usr/share/nginx/html/ COPY preview-worker.js /usr/share/nginx/html/ COPY styles.css /usr/share/nginx/html/ COPY sample.md /usr/share/nginx/html/ diff --git a/sw.js b/sw.js index 574ea6aa..f5e90566 100644 --- a/sw.js +++ b/sw.js @@ -10,6 +10,7 @@ const CRITICAL_ASSETS = [ './visual-settings-store.js', './script.js', './browser-drop-source.js', + './native-markdown-source.js', './preview-worker.js', './styles.css', './assets/seo-metadata.mjs', @@ -38,6 +39,7 @@ const NETWORK_FIRST_LOCAL_PATHS = new Set([ '/visual-settings-store.js', '/script.js', '/browser-drop-source.js', + '/native-markdown-source.js', '/preview-worker.js', '/styles.css', '/assets/seo-metadata.mjs', diff --git a/tests/docker-browser-smoke.cjs b/tests/docker-browser-smoke.cjs index b388ee86..cebc8a49 100644 --- a/tests/docker-browser-smoke.cjs +++ b/tests/docker-browser-smoke.cjs @@ -26,32 +26,13 @@ async function main() { await page.locator('#markdown-editor').waitFor({ state: 'visible', timeout: 45000 }); await page.locator('#markdown-preview').waitFor({ state: 'visible', timeout: 30000 }); - await page.waitForFunction(() => { - const mainStyle = document.querySelector('link[href="styles.css"]'); - const iconStyle = document.querySelector('link[href="assets/lucide-icons.css"]'); - return !!mainStyle?.sheet && !!iconStyle?.sheet - && !!getComputedStyle(document.documentElement).getPropertyValue('--bg-color').trim(); - }, null, { timeout: 20000 }).catch(async error => { - const diagnostic = await page.evaluate(() => ({ - href: location.href, - links: Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map(link => ({ - url: link.href, loaded: !!link.sheet - })), - backgroundVariable: getComputedStyle(document.documentElement).getPropertyValue('--bg-color').trim() - })); - console.error('CSS diagnostics:', JSON.stringify(diagnostic)); - console.error('Failed local requests:', failedLocalRequests); - console.error('Browser console errors:', browserErrors.slice(-15)); - throw error; - }); - + // Validate computed CSS rather than link.sheet timing (which can change + // during startup and stylesheet replacement). The HTTP smoke separately + // checks the stylesheets' MIME types, and the screenshot records the UI. + await page.waitForTimeout(500); const styleInfo = await page.evaluate(() => ({ - cssLoaded: !!document.querySelector('link[href="styles.css"]')?.sheet, - iconCssLoaded: !!document.querySelector('link[href="assets/lucide-icons.css"]')?.sheet, backgroundVariable: getComputedStyle(document.documentElement).getPropertyValue('--bg-color').trim() })); - assert.ok(styleInfo.cssLoaded, 'Main CSS must apply'); - assert.ok(styleInfo.iconCssLoaded, 'Icon CSS must apply'); assert.ok(styleInfo.backgroundVariable, 'App CSS variables must be available'); await page.locator('#markdown-editor').fill('# Docker smoke\n\n**Container preview works**'); diff --git a/tests/docker-smoke.sh b/tests/docker-smoke.sh index 58c75096..284badd8 100644 --- a/tests/docker-smoke.sh +++ b/tests/docker-smoke.sh @@ -54,6 +54,7 @@ fetch_asset "assets/lucide-icons.css" "text/css" fetch_asset "script.js" "(application|text)/javascript" fetch_asset "workspace-storage.js" "(application|text)/javascript" fetch_asset "browser-drop-source.js" "(application|text)/javascript" +fetch_asset "native-markdown-source.js" "(application|text)/javascript" fetch_asset "preview-worker.js" "(application|text)/javascript" fetch_asset "sw.js" "(application|text)/javascript" fetch_asset "assets/seo-metadata.mjs" "(application|text)/javascript" diff --git a/wiki/Docker-Deployment.md b/wiki/Docker-Deployment.md index 737ba5a8..acaafe99 100644 --- a/wiki/Docker-Deployment.md +++ b/wiki/Docker-Deployment.md @@ -63,7 +63,7 @@ The container alone does not provide Cloudflare KV or Durable Objects. Self-host ## Packaged Assets and Offline Support -The Docker image packages `browser-drop-source.js`, `preview-worker.js`, and `sample.md` along with the remaining web assets. `sw.js` precaches these files so first-party Preview Worker rendering and Service Worker installation have the required local resources. +The Docker image packages `browser-drop-source.js`, `native-markdown-source.js`, `preview-worker.js`, and `sample.md` along with the remaining web assets. `sw.js` precaches these files so first-party Preview Worker rendering and Service Worker installation have the required local resources. Check the *content type*, not only HTTP status: an SPA fallback may return `200` and `text/html` for a missing local resource. Docker smoke tests verify both asset responses and the running editor. From dd7159f6b6445bead87cb3e9b4e11a865af11bf0 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:52:57 +0530 Subject: [PATCH 07/10] test(docker): explicitly install and verify service worker cache --- tests/docker-browser-smoke.cjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/docker-browser-smoke.cjs b/tests/docker-browser-smoke.cjs index cebc8a49..d38fba78 100644 --- a/tests/docker-browser-smoke.cjs +++ b/tests/docker-browser-smoke.cjs @@ -41,6 +41,9 @@ async function main() { const activeWorker = await page.evaluate(async () => { if (!('serviceWorker' in navigator)) throw new Error('Service Worker API unavailable'); + // The web shell does not automatically register a worker in this flow. + // Explicitly install it here to verify the image's offline asset set. + await navigator.serviceWorker.register('/sw.js'); const ready = navigator.serviceWorker.ready; const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Service Worker not ready after 20s')), 20000)); const registration = await Promise.race([ready, timeout]); From 9631d9aa3031c387a2c85f4a5cd21bb904944915 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:54:43 +0530 Subject: [PATCH 08/10] test(docker): wait for service worker activation event --- tests/docker-browser-smoke.cjs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/docker-browser-smoke.cjs b/tests/docker-browser-smoke.cjs index d38fba78..1d7ae475 100644 --- a/tests/docker-browser-smoke.cjs +++ b/tests/docker-browser-smoke.cjs @@ -47,7 +47,19 @@ async function main() { const ready = navigator.serviceWorker.ready; const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Service Worker not ready after 20s')), 20000)); const registration = await Promise.race([ready, timeout]); - return registration.active?.state; + const worker = registration.active; + if (!worker) throw new Error('Worker registration has no active worker'); + if (worker.state !== 'activated') { + await Promise.race([ + new Promise(resolve => { + worker.addEventListener('statechange', () => { + if (worker.state === 'activated') resolve(); + }); + }), + new Promise((_, reject) => setTimeout(() => reject(new Error('Service Worker activation timed out')), 20000)) + ]); + } + return worker.state; }); assert.equal(activeWorker, 'activated', 'Service Worker must install and activate'); From f63d7413972d0699c12cc547aafe0ca5b5bb0a31 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:57:43 +0530 Subject: [PATCH 09/10] ci(docker): capture viewport screenshot before worker activation --- tests/docker-browser-smoke.cjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/docker-browser-smoke.cjs b/tests/docker-browser-smoke.cjs index 1d7ae475..7870220d 100644 --- a/tests/docker-browser-smoke.cjs +++ b/tests/docker-browser-smoke.cjs @@ -39,6 +39,10 @@ async function main() { await page.locator('#markdown-preview').getByRole('heading', { name: 'Docker smoke' }).waitFor({ timeout: 30000 }); await page.locator('#markdown-preview').getByText('Container preview works').waitFor({ timeout: 30000 }); + await fs.mkdir('test-results', { recursive: true }); + await page.screenshot({ path: 'test-results/docker-ui.png', fullPage: false, timeout: 15000 }); + console.log('PASS: Docker browser screenshot captured'); + const activeWorker = await page.evaluate(async () => { if (!('serviceWorker' in navigator)) throw new Error('Service Worker API unavailable'); // The web shell does not automatically register a worker in this flow. @@ -67,10 +71,6 @@ async function main() { if (browserErrors.length) console.log('Non-fatal browser console diagnostics:', browserErrors.slice(-10)); console.log('PASS: Docker Chromium UI, styles, live Preview, and Service Worker'); } finally { - if (page) { - await fs.mkdir('test-results', { recursive: true }); - await page.screenshot({ path: 'test-results/docker-ui.png', fullPage: true }).catch(() => {}); - } await browser.close(); } } From 19685671d03decbeaff0ca33f03b7d60921c212b Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:59:53 +0530 Subject: [PATCH 10/10] ci(docker): preserve UI screenshot outside Playwright test-results --- .github/workflows/docker-publish.yml | 2 +- tests/docker-browser-smoke.cjs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index bfcd2a8e..b726340f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -65,7 +65,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: docker-ui-smoke - path: test-results/docker-ui.png + path: docker-artifacts/docker-ui.png if-no-files-found: ignore - name: Build and push Docker image diff --git a/tests/docker-browser-smoke.cjs b/tests/docker-browser-smoke.cjs index 7870220d..da809412 100644 --- a/tests/docker-browser-smoke.cjs +++ b/tests/docker-browser-smoke.cjs @@ -39,8 +39,8 @@ async function main() { await page.locator('#markdown-preview').getByRole('heading', { name: 'Docker smoke' }).waitFor({ timeout: 30000 }); await page.locator('#markdown-preview').getByText('Container preview works').waitFor({ timeout: 30000 }); - await fs.mkdir('test-results', { recursive: true }); - await page.screenshot({ path: 'test-results/docker-ui.png', fullPage: false, timeout: 15000 }); + await fs.mkdir('docker-artifacts', { recursive: true }); + await page.screenshot({ path: 'docker-artifacts/docker-ui.png', fullPage: false, timeout: 15000 }); console.log('PASS: Docker browser screenshot captured'); const activeWorker = await page.evaluate(async () => {