diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 979bbf9..bd4d2b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,8 +23,26 @@ env: IMAGE_NAME: ${{ github.repository }} jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Run browser tests + run: npm test + env: + CI: "true" + semantic-version: runs-on: ubuntu-latest + needs: [test] if: github.event_name == 'push' outputs: new_tag: ${{ steps.tag.outputs.new_tag }} @@ -204,6 +222,7 @@ jobs: validate-docker: runs-on: ubuntu-latest + needs: [test] if: github.event_name == 'pull_request' steps: - name: Checkout code diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 1fa34ec..e2edb7f 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -22,7 +22,7 @@ jobs: - name: Stage site files run: | mkdir _site - cp index.html 404.html qr.html manifest.json CNAME _site/ + cp index.html 404.html qr.html manifest.json sw.js CNAME _site/ cp favicon.ico favicon.png icon.png icon-512.png icon.svg _site/ - uses: actions/configure-pages@v6 - uses: actions/upload-pages-artifact@v5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbd64df --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +test-results/ +playwright-report/ diff --git a/.trivyignore b/.trivyignore index da5033e..bca4b98 100644 --- a/.trivyignore +++ b/.trivyignore @@ -6,3 +6,13 @@ # Not exploitable in our deployment: hashpad serves static files only; # the JOSE code path is not reached at runtime. CVE-2026-34986 + +# Go stdlib 1.26.8 / golang.org/x/net 0.59.0 inside the caddy binary of caddy:2-alpine. +# The newest published caddy:2-alpine (2026-10-06) still ships them, so a base-image +# bump cannot fix this. These are denial-of-service issues (HTTP/2 SETTINGS flood, +# crafted Range headers, TLS ECH) that Caddy does expose; mitigated only by running +# behind the cluster ingress/load balancer. Remove once Caddy rebuilds with Go >= 1.26.9 +# and x/net >= 0.60.0 (review by 2026-11-16). +CVE-2026-78667 +CVE-2026-78669 +CVE-2026-97031 diff --git a/Caddyfile b/Caddyfile index 19642b0..0237159 100644 --- a/Caddyfile +++ b/Caddyfile @@ -69,6 +69,12 @@ } header @html Cache-Control "public, max-age=3600, must-revalidate" + # Service worker must always revalidate + @sw { + path /sw.js + } + header @sw Cache-Control "no-cache" + # On-the-fly compression (zstd preferred, gzip fallback) encode zstd gzip diff --git a/Dockerfile b/Dockerfile index e355da4..c5c83e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,7 @@ COPY index.html . COPY qr.html . COPY 404.html . COPY manifest.json . +COPY sw.js . COPY favicon.ico . COPY favicon.png . COPY icon.png . diff --git a/README.md b/README.md index 7d58f0e..bdfd5be 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,38 @@ Your document **is** the URL. Share it anywhere, and the recipient gets your exa - **Zero Backend** - No server storage, no database, no accounts - **URL-Based Storage** - Content is compressed and encoded in the URL hash - **Password Protection** - Optional AES-256-GCM encryption with PBKDF2 key derivation -- **Markdown Highlighting** - Real-time syntax highlighting for headings, code, bold, italic, links, and more +- **Markdown Highlighting** - Real-time syntax highlighting while you type (see [Supported Markdown](#supported-markdown)) - **QR Code Sharing** - Generate QR codes for easy mobile sharing - **Dark/Light Mode** - Automatically follows system preference -- **PWA Support** - Install as a native app on any device +- **PWA & Offline** - Install as a native app on any device; a service worker caches the app so it keeps working offline (the QR page also needs a prior online visit to cache its CDN library) - **Export Options** - Save as HTML or plain text - **Undo/Redo** - Full editing history (Ctrl+Z / Ctrl+Shift+Z) +## Supported Markdown + +| Syntax | Example | +|--------|---------| +| Headings | `# H1` to `###### H6` | +| Bold | `**bold**`, `__bold__` | +| Italic | `*italic*`, `_italic_` | +| Strikethrough | `~~strike~~` | +| Highlight | `==mark==` | +| Inline code | `` `code` `` | +| Fenced code blocks | `` ``` `` or `~~~` | +| Links | `[text](https://url "title")`, `[text][ref]`, ``, bare `https://url` | +| Images | `![alt](url)` | +| Lists | `-`, `*`, `+`, `1.` | +| Task lists | `- [ ]`, `- [x]` | +| Blockquotes with nesting | `> quote`, `> > nested` | +| Horizontal rules | `---`, `***`, `___` | +| Tables | `\| a \| b \|` | +| Footnote references | `[^1]` | +| Backslash escapes | `\*` | + +- Syntax is highlighted in place; the text stays plain Markdown and is never rendered to HTML in the editor. +- Images are highlighted only, not loaded, so no third-party requests are made when opening a shared link. +- Only `http(s)` and `mailto:` links are clickable. + ## URL Format | Format | Description | @@ -60,10 +85,22 @@ docker run -p 8080:8080 hashpad ### Kubernetes (Helm) ```bash -# Adapt values.yaml for your environment, then: -helm install hashpad ./helm +# Adapt deploy/helm/hashpad/values.yaml for your environment, then: +helm install hashpad ./deploy/helm/hashpad ``` +## Testing + +Browser tests use Playwright (Chromium) and run against `tests/sample.md`, which exercises every supported Markdown construct. + +```bash +npm install +npx playwright install chromium +npm test +``` + +When adding new Markdown syntax, add it to `tests/sample.md` and add a matching assertion in `tests/markdown.spec.js`. + ## Security ### Encryption Details @@ -82,11 +119,14 @@ hashpad/ ├── qr.html # QR code generator ├── 404.html # Error page ├── manifest.json # PWA manifest +├── sw.js # Service worker (offline cache) ├── favicon.png # App icon ├── icon.svg # Vector icon ├── Dockerfile # Container build (Caddy Alpine) ├── Caddyfile # Web server configuration ├── docker-compose.yml # Container orchestration +├── tests/ # Playwright browser tests and Markdown sample +├── package.json # Test tooling └── deploy/helm/hashpad # Kubernetes Helm chart ├── Chart.yaml ├── values.yaml diff --git a/index.html b/index.html index 6cda133..6be2a5b 100644 --- a/index.html +++ b/index.html @@ -27,6 +27,10 @@ /* Layout */ --max-width: 800px; --padding: 18px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --edge: 16px; /* Light theme colors */ --bg-color: #ffffff; @@ -137,7 +141,7 @@ background: var(--code-bg); color: var(--code-color); padding: 0.1em 0.3em; - border-radius: 3px; + border-radius: var(--radius-sm); } .md-codeblock { @@ -145,7 +149,7 @@ background: var(--code-bg); display: block; padding: 0.5em; - border-radius: 4px; + border-radius: var(--radius-md); } .md-bold { @@ -160,17 +164,45 @@ font-style: italic; } - .md-url { + .md-url, .md-link { cursor: pointer; } + span.md-link { + color: var(--brand-color); + text-decoration: underline; + } + + .md-image { + color: var(--brand-color); + font-style: italic; + } + + .md-footnote { + color: var(--brand-color); + font-size: 0.85em; + vertical-align: super; + } + + .md-escape { + opacity: 0.5; + } + + .md-mark { + background: rgba(255, 221, 0, 0.4); + border-radius: var(--radius-sm); + } + /* Blockquote */ .md-blockquote { - display: block; border-left: 3px solid var(--brand-color); - padding-left: 1em; - margin-left: 0; + padding-left: calc(0.5em + var(--depth, 1) * 0.6em); opacity: 0.85; + box-decoration-break: clone; + } + + .md-bq-marker { + opacity: 0.4; } /* Table rows */ @@ -188,10 +220,25 @@ /* Horizontal rule */ .md-hr { display: block; - color: transparent; + color: rgba(128, 128, 128, 0.5); border-bottom: 2px solid rgba(128, 128, 128, 0.3); - margin: 0.25em 0; - line-height: 0.5; + } + + .md-link-def { + opacity: 0.6; + } + + .md-list-marker { + color: var(--brand-color); + font-weight: bold; + } + + .md-task { + font-family: var(--font-family-mono); + } + + .md-task-done { + opacity: 0.5; } /* FAB Button */ @@ -200,7 +247,7 @@ background-color: var(--button-bg); border-radius: 50%; border: none; - bottom: 15px; + bottom: var(--edge); box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, .12); color: #fff; cursor: pointer; @@ -213,7 +260,7 @@ padding: 0; place-items: center; position: fixed; - right: 15px; + right: var(--edge); text-decoration: none; touch-action: manipulation; transition: background-color .3s ease-out; @@ -259,12 +306,12 @@ flex-direction: column; width: 220px; position: fixed; - right: 16px; - bottom: 77px; + right: var(--edge); + bottom: calc(var(--edge) + 46px + 16px); box-shadow: 0 19px 38px rgba(0, 0, 0, 0.10), 0 15px 12px rgba(0, 0, 0, 0.05); background: var(--elevated-bg); backdrop-filter: blur(10px); - border-radius: 10px; + border-radius: var(--radius-lg); z-index: 2; } @@ -309,13 +356,13 @@ } #menu .item:first-child { - border-top-left-radius: 10px; - border-top-right-radius: 10px; + border-top-left-radius: var(--radius-lg); + border-top-right-radius: var(--radius-lg); } #menu .item:last-child { - border-bottom-left-radius: 10px; - border-bottom-right-radius: 10px; + border-bottom-left-radius: var(--radius-lg); + border-bottom-right-radius: var(--radius-lg); } /* Notification */ @@ -327,11 +374,11 @@ display: flex; flex-direction: column; position: fixed; - right: 17px; - top: 17px; + right: var(--edge); + top: var(--edge); background: var(--notification-bg); color: #fff; - border-radius: 30px; + border-radius: 999px; font: 18px / 1 var(--font-family); font-weight: 600; z-index: 2; @@ -361,7 +408,7 @@ #password-modal .modal-content { background: var(--elevated-bg); - border-radius: 12px; + border-radius: var(--radius-lg); padding: 24px; width: 90%; max-width: 360px; @@ -379,7 +426,7 @@ padding: 12px 16px; font-size: 16px; border: 2px solid rgba(0, 0, 0, 0.1); - border-radius: 8px; + border-radius: var(--radius-md); margin-bottom: 16px; font-family: var(--font-family); background: var(--bg-color); @@ -405,7 +452,7 @@ #password-modal button { padding: 10px 20px; - border-radius: 8px; + border-radius: var(--radius-md); font-size: 14px; font-weight: 600; cursor: pointer; @@ -437,14 +484,14 @@ /* Lock indicator */ #lock-indicator { position: fixed; - top: 17px; - left: 17px; + top: var(--edge); + left: var(--edge); display: none; align-items: center; gap: 8px; padding: 8px 14px; background: var(--elevated-bg); - border-radius: 20px; + border-radius: 999px; font-size: 14px; color: var(--text-color); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); @@ -507,13 +554,13 @@ Save as HTML Save as MD @@ -706,6 +753,9 @@ addEventListener('DOMContentLoaded', load); addEventListener('hashchange', load); + if ('serviceWorker' in navigator) { + addEventListener('load', () => navigator.serviceWorker.register('./sw.js').catch(() => {})); + } addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.code === 'KeyS') { e.preventDefault(); @@ -1048,26 +1098,39 @@ {name: 'md-h4', re: /^####[ \t]+.*/}, {name: 'md-h5', re: /^#####[ \t]+.*/}, {name: 'md-h6', re: /^######[ \t]+.*/}, - {name: 'md-blockquote', re: /^>[ \t]?.*/}, - {name: 'md-hr', re: /^---+$/}, + {name: 'md-hr', re: /^[ \t]*([-*_])([ \t]*\1){2,}[ \t]*$/}, {name: 'md-table-separator', re: /^\|[-:\s|]+\|$/}, {name: 'md-table-row', re: /^\|.+\|$/}, + {name: 'md-link-def', re: /^[ \t]{0,3}\[[^\]\n]+\]:[ \t]+\S+.*/}, ]; - // Inline patterns + // Markdown link target; only http(s) and mailto become anchors, so no javascript: URLs + function markdownLinkHref(raw) { + const url = /\]\(([^)\s]+)/.exec(raw)[1]; + return /^(https?:\/\/|mailto:)/i.test(url) ? url : null; + } + + // Inline patterns; href maps a match to its link target (null renders a plain span) const inlineMatchers = [ + {name: 'md-escape', re: /\\[\\`*_{}\[\]()#+\-.!~<>|=]/y}, {name: 'md-codeblock', re: /```[^\n]*\n[\s\S]*?\n```/y}, {name: 'md-codeblock', re: /~~~[^\n]*\n[\s\S]*?\n~~~/y}, {name: 'md-code', re: /`[^`\n]*`/y}, - {name: 'md-bold', re: /\*\*[^*\n]+?\*\*/y}, - {name: 'md-bold', re: /__[^_\n]+?__/y}, - {name: 'md-strike', re: /~~[^~\n]+?~~/y}, - {name: 'md-italic', re: /\*[^*\n]+?\*/y}, - {name: 'md-italic', re: /_[^_\n]+?_/y}, - {name: 'md-url', re: /https?:\/\/[^\s<>()\[\]{}"'`]+/y}, + {name: 'md-image', re: /!\[[^\]\n]*\]\([^)\s\n]+(?:[ \t]+"[^"\n]*")?\)/y}, + {name: 'md-footnote', re: /\[\^[^\]\s]+\]/y}, + {name: 'md-link', re: /\[[^\]\n]+\]\[[^\]\n]*\]/y}, + {name: 'md-link', re: /\[[^\]\n]*\]\([^)\s\n]+(?:[ \t]+"[^"\n]*")?\)/y, href: markdownLinkHref}, + {name: 'md-bold', re: /\*\*(?:[^*\n]|\*(?!\*))+?\*\*/y, nest: true, delim: 2}, + {name: 'md-bold', re: /__(?:[^_\n]|_(?!_))+?__/y, nest: true, delim: 2}, + {name: 'md-strike', re: /~~[^~\n]+?~~/y, nest: true, delim: 2}, + {name: 'md-italic', re: /\*(?:[^*\n]|\*\*[^*\n]+?\*\*)+?\*/y, nest: true, delim: 1}, + {name: 'md-italic', re: /_(?:[^_\n]|__[^_\n]+?__)+?_/y, nest: true, delim: 1}, + {name: 'md-mark', re: /==[^=\n]+?==/y, nest: true, delim: 2}, + {name: 'md-url', re: /\s]+>/y, href: raw => raw.slice(1, -1)}, + {name: 'md-url', re: /https?:\/\/[^\s<>()\[\]{}"'`]+/y, href: raw => raw}, ]; - const specials = ['`', '~', '*', '_', 'h']; + const specials = ['`', '~', '*', '_', 'h', '[', '!', '<', '\\', '=']; // Process text with inline patterns only function processInline(text, container) { @@ -1079,20 +1142,22 @@ const res = m.re.exec(text); if (res && res.index === i) { const raw = res[0]; - if (m.name === 'md-url') { - const a = document.createElement('a'); - a.className = 'md-url'; - a.href = raw; - a.textContent = raw; - a.target = '_blank'; - a.rel = 'noopener noreferrer'; - container.appendChild(a); + const href = m.href?.(raw); + const node = document.createElement(href ? 'a' : 'span'); + node.className = m.name; + if (m.nest && !href) { + node.appendChild(document.createTextNode(raw.slice(0, m.delim))); + processInline(raw.slice(m.delim, -m.delim), node); + node.appendChild(document.createTextNode(raw.slice(-m.delim))); } else { - const span = document.createElement('span'); - span.className = m.name; - span.textContent = raw; - container.appendChild(span); + node.textContent = raw; + } + if (href) { + node.href = href; + node.target = '_blank'; + node.rel = 'noopener noreferrer'; } + container.appendChild(node); i += raw.length; matched = true; break; @@ -1166,11 +1231,41 @@ } } - if (blockMatch) { + const quoteMatch = /^((?:>[ \t]?)+)/.exec(line); + const listMatch = /^([ \t]*)([-*+]|\d{1,9}[.)])([ \t]+)(\[[ xX]\][ \t]+)?/.exec(line); + + if (quoteMatch) { + const prefix = quoteMatch[1]; + const depth = prefix.match(/>/g).length; + const span = document.createElement('span'); + span.className = 'md-blockquote'; + span.style.setProperty('--depth', depth); + const markerSpan = document.createElement('span'); + markerSpan.className = 'md-bq-marker'; + markerSpan.textContent = prefix; + span.appendChild(markerSpan); + processInline(line.slice(prefix.length), span); + frag.appendChild(span); + } else if (blockMatch) { const span = document.createElement('span'); span.className = blockMatch.name; span.textContent = line; frag.appendChild(span); + } else if (listMatch) { + // Style only the marker and task box; the rest of the item is inline-formatted + const [whole, indent, marker, spacing, task] = listMatch; + if (indent) frag.appendChild(document.createTextNode(indent)); + const markerSpan = document.createElement('span'); + markerSpan.className = 'md-list-marker'; + markerSpan.textContent = marker + spacing; + frag.appendChild(markerSpan); + if (task) { + const taskSpan = document.createElement('span'); + taskSpan.className = /\[[xX]\]/.test(task) ? 'md-task md-task-done' : 'md-task'; + taskSpan.textContent = task; + frag.appendChild(taskSpan); + } + processInline(line.slice(whole.length), frag); } else if (line.length > 0) { // Process inline formatting for non-empty lines processInline(line, frag); @@ -1259,7 +1354,7 @@ const s = getSelection(); const pos = {start: 0, end: 0, dir: undefined}; let {anchorNode, anchorOffset, focusNode, focusOffset} = s; - if (!anchorNode || !focusNode) throw 'error1'; + if (!anchorNode || !focusNode) return pos; if (anchorNode === element && focusNode === element) { pos.start = (anchorOffset > 0 && element.textContent) ? element.textContent.length : 0; pos.end = (focusOffset > 0 && element.textContent) ? element.textContent.length : 0; @@ -1518,7 +1613,13 @@ - + + + + + + diff --git a/manifest.json b/manifest.json index 02f9cd8..489a719 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,6 @@ { - "name": "Hashpad" + "name": "Hashpad", "short_name": "Hashpad", - "description": "Minimal text editor with URL-based storage", "description": "A minimal, serverless text editor that stores content in the URL", "start_url": "/", "display": "standalone", diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..22df7e9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "hashpad", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hashpad", + "devDependencies": { + "@playwright/test": "^1.64.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4b4dce6 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "hashpad", + "private": true, + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.64.1" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..a89e24b --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,19 @@ +const { defineConfig } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: 'tests', + use: { + baseURL: 'http://127.0.0.1:4173', + }, + webServer: { + command: 'python3 -m http.server 4173 --bind 127.0.0.1', + url: 'http://127.0.0.1:4173/index.html', + reuseExistingServer: !process.env.CI, + }, + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + }, + ], +}); diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..96e195d --- /dev/null +++ b/sw.js @@ -0,0 +1,59 @@ +// Hashpad service worker: network-first with cache fallback. +const CACHE = 'hashpad-v1'; +const SHELL = [ + './', + 'index.html', + 'qr.html', + 'manifest.json', + 'icon.png', + 'icon-512.png', + 'favicon.png', + 'https://cdn.jsdelivr.net/npm/qrcode-generator@2.0.2', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches + .open(CACHE) + .then((cache) => cache.addAll(SHELL)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key))) + ) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + const { request } = event; + if (request.method !== 'GET') return; + const sameOrigin = new URL(request.url).origin === self.location.origin; + if (!sameOrigin && !SHELL.includes(request.url)) return; + event.respondWith(networkFirst(request)); +}); + +async function networkFirst(request) { + try { + const response = await fetch(request); + if (response.ok) { + const copy = response.clone(); + caches.open(CACHE).then((cache) => cache.put(request, copy)); + } + return response; + } catch (error) { + const cached = await caches.match(request, { ignoreSearch: true }); + if (cached) return cached; + if (request.mode === 'navigate') { + const shell = await caches.match('index.html'); + if (shell) return shell; + } + throw error; + } +} diff --git a/tests/markdown.spec.js b/tests/markdown.spec.js new file mode 100644 index 0000000..6d5d352 --- /dev/null +++ b/tests/markdown.spec.js @@ -0,0 +1,190 @@ +const { test, expect } = require('@playwright/test'); +const fs = require('fs'); +const path = require('path'); + +const sample = fs.readFileSync(path.join(__dirname, 'sample.md'), 'utf8'); + +async function load(page, text) { + await page.goto('/index.html'); + const article = page.locator('article'); + await article.click(); + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (i > 0) await page.keyboard.press('Enter'); + if (lines[i]) await page.keyboard.insertText(lines[i]); + } + await page.keyboard.press('End'); + await page.keyboard.type(' '); + await page.keyboard.press('Backspace'); + await page.waitForTimeout(200); + return article; +} + +test('round-trips text', async ({ page }) => { + const article = await load(page, sample); + expect((await article.textContent()).trim()).toBe(sample.trim()); +}); + +test('headings', async ({ page }) => { + const article = await load(page, sample); + for (let n = 1; n <= 6; n++) { + await expect(article.locator('.md-h' + n)).toHaveCount(n === 2 ? 9 : 1); + } +}); + +test('inline styles', async ({ page }) => { + const article = await load(page, sample); + const count = sel => article.locator(sel).count(); + expect(await count('.md-bold')).toBeGreaterThanOrEqual(4); + expect(await count('.md-italic')).toBeGreaterThanOrEqual(2); + expect(await count('.md-strike')).toBeGreaterThanOrEqual(1); + expect(await count('.md-mark')).toBeGreaterThanOrEqual(1); + expect(await count('.md-code')).toBeGreaterThanOrEqual(1); + expect(await count('.md-escape')).toBe(2); +}); + +test('nested emphasis', async ({ page }) => { + const article = await load(page, sample); + const count = sel => article.locator(sel).count(); + expect(await count('.md-bold .md-italic')).toBeGreaterThanOrEqual(1); + expect(await count('.md-bold a.md-link')).toBe(1); + expect(await count('.md-italic .md-code')).toBe(1); + expect(await count('.md-strike .md-bold')).toBe(1); + expect(await count('.md-mark .md-italic')).toBe(1); + expect(await count('.md-bold .md-italic')).toBeGreaterThanOrEqual(2); +}); + +test('links', async ({ page }) => { + const article = await load(page, sample); + const count = sel => article.locator(sel).count(); + expect(await count('a.md-link[href="https://example.com"]')).toBeGreaterThanOrEqual(1); + expect(await count('a.md-link[href="mailto:test@example.com"]')).toBe(1); + expect(await count('a[href^="javascript"]')).toBe(0); + expect(await article.locator('span.md-link', { hasText: 'Unsafe link' }).count()).toBe(1); + expect(await count('a.md-url')).toBeGreaterThanOrEqual(2); +}); + +test('images, footnotes, references', async ({ page }) => { + const article = await load(page, sample); + const count = sel => article.locator(sel).count(); + expect(await count('.md-image')).toBe(1); + expect(await count('.md-footnote')).toBeGreaterThanOrEqual(1); + expect(await article.locator('span.md-link', { hasText: 'Reference link' }).count()).toBe(1); + expect(await count('.md-link-def')).toBeGreaterThanOrEqual(2); +}); + +test('lists', async ({ page }) => { + const article = await load(page, sample); + const count = sel => article.locator(sel).count(); + expect(await count('.md-list-marker')).toBe(10); + expect(await count('.md-task')).toBe(3); + expect(await count('.md-task-done')).toBe(2); +}); + +test('nested blockquotes', async ({ page }) => { + const article = await load(page, sample); + const count = sel => article.locator(sel).count(); + expect(await count('.md-blockquote')).toBe(4); + const depths = await article.locator('.md-blockquote').evaluateAll(els => els.map(e => e.style.getPropertyValue('--depth'))); + expect(depths).toEqual(['1', '2', '3', '1']); + expect(await count('.md-blockquote a.md-link')).toBe(1); +}); + +test('horizontal rules are visible and editable', async ({ page }) => { + const article = await load(page, sample); + const count = sel => article.locator(sel).count(); + expect(await count('.md-hr')).toBe(4); + const color = await article.locator('.md-hr').first().evaluate(el => getComputedStyle(el).color); + expect(color).not.toBe('rgba(0, 0, 0, 0)'); + expect(color).not.toBe('transparent'); + expect(await article.locator('.md-hr').first().textContent()).toBe('---'); +}); + +test('tables', async ({ page }) => { + const article = await load(page, sample); + const count = sel => article.locator(sel).count(); + expect(await count('.md-table-row')).toBe(3); + expect(await count('.md-table-separator')).toBe(1); +}); + +test('code blocks are inert', async ({ page }) => { + const article = await load(page, sample); + const count = sel => article.locator(sel).count(); + expect(await count('.md-codeblock')).toBe(2); + expect(await count('.md-codeblock .md-bold')).toBe(0); + expect(await count('.md-codeblock *')).toBe(0); +}); + +test('typing below a quote stays out of the quote', async ({ page }) => { + const article = await load(page, '> quote'); + await page.keyboard.press('End'); + await page.keyboard.press('Enter'); + await page.keyboard.type('below'); + await page.waitForTimeout(200); + expect((await article.textContent()).replace(/\n$/, '')).toBe('> quote\nbelow'); + expect(await article.locator('.md-blockquote').count()).toBe(1); + expect(await article.locator('.md-blockquote').textContent()).not.toContain('below'); + const pos = await page.evaluate(() => { + const s = getSelection(); + const r = s.getRangeAt(0); + const pre = document.createRange(); + pre.selectNodeContents(document.querySelector('article')); + pre.setEnd(r.endContainer, r.endOffset); + return pre.toString().length; + }); + expect(pos).toBe('> quote\nbelow'.length); +}); + +test('typing in the middle of nested bold keeps the caret', async ({ page }) => { + const article = await load(page, '**bold _it_ end**'); + await page.keyboard.press('Home'); + for (let i = 0; i < 3; i++) { + await page.keyboard.press('ArrowRight'); + } + await page.keyboard.type('X'); + await page.waitForTimeout(200); + await page.keyboard.type('Y'); + expect(await article.textContent()).toBe('**bXYold _it_ end**'); +}); + +test('service worker registers', async ({ page }) => { + await page.goto('/index.html'); + const ok = await page.evaluate(async () => { + const reg = await navigator.serviceWorker.ready; + return !!reg.active; + }); + expect(ok).toBe(true); +}); + +test('manifest is valid JSON', async ({ page }) => { + const res = await page.request.get('/manifest.json'); + const json = await res.json(); + expect(json.name).toBe('Hashpad'); +}); + +test('loads and highlights without page errors', async ({ page }) => { + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + await page.goto('/index.html'); + await page.waitForTimeout(300); + await load(page, sample); + await page.evaluate(() => getSelection().removeAllRanges()); + await page.locator('article').dispatchEvent('keyup', { key: 'a' }); + await page.waitForTimeout(300); + expect(errors).toEqual([]); +}); + +test('app shell loads offline', async ({ page, context }) => { + await page.goto('/index.html'); + await page.evaluate(() => navigator.serviceWorker.ready); + await page.reload(); + await page.waitForFunction(() => navigator.serviceWorker.controller); + await context.setOffline(true); + await page.reload(); + await expect(page.locator('article')).toBeVisible(); + const article = page.locator('article'); + await article.click(); + await page.keyboard.insertText('# offline'); + await page.keyboard.type(' '); + await expect(article.locator('.md-h1')).toHaveCount(1); +}); diff --git a/tests/sample.md b/tests/sample.md new file mode 100644 index 0000000..b1745d7 --- /dev/null +++ b/tests/sample.md @@ -0,0 +1,79 @@ +# Heading 1 +## Heading 2 +### Heading 3 +#### Heading 4 +##### Heading 5 +###### Heading 6 + +## Inline styles + +**bold**, __bold too__, *italic*, _italic too_, ~~strike~~, ==marked== +`inline code` and a \*escaped\* asterisk + +## Nested emphasis + +**bold with _italic_ and [a link](https://example.com)** +*italic with `code`* +~~strike with **bold**~~ +==mark with _italic_== +**a *b* c** + +## Links and images + +[Inline link](https://example.com "with title") +[Mail link](mailto:test@example.com) +[Unsafe link](javascript:alert(1)) +Bare URL: https://example.com/path?x=1 +Autolink: +![alt text](https://example.com/img.png "image title") +[Reference link][ref] and a footnote[^1] + +[ref]: https://example.com +[^1]: Footnote text + +## Lists + +- dash item +* star item ++ plus item + - nested dash with **bold** +1. first +2. second +3) paren style + +- [ ] open task +- [x] done task +- [X] done task, capital X + +## Blockquotes + +> single level +> > nested level two +> > > nested level three +> quote with **bold**, `code` and [a link](https://example.com) + +## Horizontal rules + +--- +*** +___ +- - - + +## Table + +| Name | Value | +|------|:-----:| +| a | 1 | +| b | 2 | + +## Code blocks + +```js +const x = 1; // **not bold** inside +``` + +~~~ +tilde fence +~~~ + +Plain text at the end.