diff --git a/.gitignore b/.gitignore index b547b0e..ed7db95 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,11 @@ venv/ # Node/browser tooling node_modules/ +# Playwright e2e output +test-results/ +playwright-report/ +playwright/.cache/ + # Build and local output build/ dist/ diff --git a/AGENTS.md b/AGENTS.md index 53aa6f5..f5db780 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,3 +37,12 @@ pytest -q ``` For visual or print changes, also run a local static server and inspect the site root (`/`) in a browser. + +Cross-browser end-to-end tests live in `e2e/` (Playwright; Chromium, Firefox, WebKit, plus mobile emulations). Run them after changes that touch rendering, print, key generation, URL/redirect handling, or the loading state: + +```bash +npm install && npx playwright install +npm run test:e2e +``` + +WebKit/Safari on Linux needs `sudo npx playwright install-deps` once. See `e2e/README.md`. Test tooling is a dev dependency only — it must not pull runtime frontend dependencies from CDNs or otherwise weaken the static, host-agnostic client. diff --git a/README.md b/README.md index cde6e57..64a3bbc 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,24 @@ # VoxVera -VoxVera is now a static Nostr-powered flyer client. +VoxVera is a static, Nostr-powered flyer client. -The active app lives at the site root ([`site/`](site/)). It lets a user author flyer content, publish it as a public Nostr event, and render the flyer later from an event ID or Nostr address. The frontend is intentionally self-contained: relays store the content, and any static host can serve the client. +The active app lives at the site root ([`site/`](site/)). It lets anyone author flyer content, publish it as a public Nostr event, and re-render or reprint the flyer later from a Nostr address or event ID — no application server. The frontend is intentionally self-contained: Nostr relays store the content, and any static host can serve the client. The previous Tor/OnionShare CLI version is preserved on the `legacy-tor` branch. +## Features + +- **Author and publish.** Fill in the flyer fields and publish them as a Nostr `30078` parameterized replaceable event. Republishing with the same flyer name replaces the previous version. +- **Anonymous by default.** The client generates an anonymous browser-side key to sign events. A NIP-07 browser extension is detected and used if present, but is never required. +- **Open from a link or ID.** Load a flyer from an `naddr`, `nevent1`, `note1`, raw 64-char event ID, or `nostr:` URL. The lookup field tolerates copy/paste whitespace and newlines, so an event ID copied off a printed tear-off (where it wraps across two lines) still resolves. +- **Short, scannable poster URLs.** Each poster URL is a standard NIP-19 `naddr` carrying a single relay hint, placed in the URL `#fragment` (no `?addr=` query). Shorter URLs make the tear-off QR code easier to scan. Older relay-list `naddr`s and `?addr=` URLs still resolve. +- **Tear-off tabs.** The printable flyer has ten tear-off tabs. Each tab is a single hyperlinked call to action (the URL is the link, not printed as text) and prints the full event ID so anyone can re-find the flyer by typing it. +- **Loading state on shared links.** Opening a poster/event link shows a localized "Loading content…" placeholder (and a "Could not load this flyer." state on failure) instead of the default flyer; the default sample content appears only on the bare site URL. +- **14 languages with RTL support.** Flyer content and UI are localized; right-to-left scripts render correctly. Letter-spacing is applied per script so cursive (Arabic/Persian) and combining (Devanagari) scripts are not broken — see [the spec](docs/nostr-static-client-spec.md#letter-spacing-by-script). +- **Fits-the-page editing.** Fields that would overflow the printable sheet are rejected as you type, with an inline error under the affected field. See the [field length cheat sheet](docs/flyer-field-limits.md) for how much text fits each field per language. +- **QR codes.** The tear-off QR points at the poster URL (re-open and reprint); the main flyer QR points at the creator's content URL. +- **Self-contained.** No build step and no CDNs — Nostr and QR libraries are vendored under `site/vendor/`. + ## What Is In `main` - `site/index.html`, `site/nostr-client.js`, `site/nostr-client.css`, `site/locales.js`: static editor/viewer client @@ -14,6 +27,7 @@ The previous Tor/OnionShare CLI version is preserved on the `legacy-tor` branch. - `site/CNAME`: custom-domain target - `voxvera/nostr/`: small Python schema/validation helpers - `docs/nostr-static-client-spec.md`: design notes and development plan +- `docs/flyer-field-limits.md`: per-language field length cheat sheet ## Local Development @@ -45,11 +59,23 @@ The client is served from the domain root (`site/index.html`). `site/nostr/index ## Verification ```bash -node --check site/nostr-client.js -pytest -q +node --check site/nostr-client.js # client syntax +pytest -q # Nostr source schema ``` -The Python tests only cover the Nostr source schema. The frontend is static and should be checked in-browser for layout and print behavior after UI changes. +### Cross-browser end-to-end tests + +[Playwright](https://playwright.dev) smoke tests run the client on Chromium, Firefox, and WebKit (Safari's engine), plus Android/iOS mobile emulations — covering rendering, the `@media print` path, WebCrypto key generation, the `/nostr/` redirect, and the loading state. + +```bash +npm install +npx playwright install # download the browser engines +npm run test:e2e +``` + +See [`e2e/README.md`](e2e/README.md) for per-engine coverage and the one-time Linux step for WebKit (`sudo npx playwright install-deps`). + +The Python tests only cover the Nostr source schema. The frontend is static; the Playwright suite covers engine behavior, but real Safari/iOS print and clipboard behavior is still worth a manual pass before a release. ## License diff --git a/docs/flyer-field-limits.md b/docs/flyer-field-limits.md new file mode 100644 index 0000000..927ffe8 --- /dev/null +++ b/docs/flyer-field-limits.md @@ -0,0 +1,70 @@ +# Flyer Field Length Cheat Sheet + +How much text fits in each flyer field, by script. + +VoxVera does **not** impose a fixed character count. The editor measures the +rendered flyer live and rejects a keystroke the moment a field would overflow +the printable sheet (you'll see an inline "too long" error under that field). +So the real limit is enforced for you as you type — the numbers below are +practical guidance for drafting copy, sizing translations, or prompting an LLM +to write flyer text that will fit on the first try. + +Capacity varies by **script**, not by individual language, for three reasons: + +1. The flyer body font is monospace (`Courier New`), so within a script every + character takes the same width regardless of upper/lowercase. +2. Per-script letter-spacing policy (`ls-full` / `ls-cjk` / `ls-none`, see + [`nostr-static-client-spec.md`](nostr-static-client-spec.md#letter-spacing-by-script)). +3. Glyph width differs by script — CJK glyphs are full-width (≈2× a Latin + character), Devanagari fallback glyphs are narrower, etc. + +## Single-line fields (hard caps) + +**Title**, **Subtitle**, and **Headline** are single-line: they never wrap, so +these are the limits people actually run into. Counts are characters +(codepoints). + +| Languages | Title | Headline | Subtitle | +|---|---|---|---| +| Latin & Cyrillic & Hebrew — en, es, de, fr, pt, sw, tr, ru, he | ~28 | ~28 | ~32 | +| Arabic & Persian — ar, fa | ~28 | ~28 | ~38 | +| Devanagari — hi | ~36 | ~36 | ~51 | +| CJK — ja, zh | ~17 | ~17 | ~23 | + +## Multi-line fields (generous) + +**Body** (content), **Link message** (`url_message`), and **Footer** +(`footer_message`) wrap across multiple lines, so they hold far more and are +bounded only by the remaining height of the sheet. + +| Languages | Body (content) | +|---|---| +| Latin & Cyrillic & Hebrew | ~1,900 (a few hundred words) | +| Arabic & Persian | ~1,850 | +| Devanagari — hi | ~2,450 | +| CJK — ja, zh | ~1,170 | + +`url_message` and `footer_message` share the lower portion of the sheet and can +hold roughly 1,200–4,800 characters depending on script, but for a readable +flyer keep the link message to a sentence and the footer to a short line. + +The **URL** field is a link, not body copy — keep it short both because it's +shown verbatim on the flyer and because it becomes the main QR code (shorter +URLs scan more reliably). + +## Notes and caveats + +- **Approximate.** These come from rendering each script in a headless browser + at true print size (8.5in × 11in, content column 4.75in). The + Latin/Cyrillic numbers (`Courier New`) are the most reliable; numbers for + Hebrew, Arabic, Persian, Devanagari, and CJK depend on the system fallback + font, which can differ slightly between devices and from print output. +- **The app is authoritative.** If the editor accepts your text it fits; if it + rejects it, shorten it. Treat this sheet as a target, not a contract. +- **Redaction markup counts.** Wrapping text in `~~double tildes~~` renders it + struck-through; the four `~` characters count toward the field length even + though they don't appear on the flyer. +- **Re-measuring.** If the flyer layout, font, or letter-spacing policy + changes, these numbers should be re-derived by rendering each script at print + size and binary-searching each field for the largest run of characters that + does not overflow the field target or the sheet. diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..8f3a429 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,58 @@ +# Cross-browser end-to-end tests + +[Playwright](https://playwright.dev) smoke tests that exercise the static client +on every major browser engine and on mobile emulations, so a change that works +in Chrome can be confirmed to work in Firefox and Safari (WebKit) too. + +## Coverage + +Projects are defined in [`../playwright.config.js`](../playwright.config.js): + +| Project | Engine | Notes | +|---|---|---| +| `chromium-desktop` | Chromium | Chrome / Brave / Edge | +| `firefox-desktop` | Gecko | Firefox | +| `webkit-desktop` | WebKit | Safari on macOS | +| `chrome-android` | Chromium | Pixel 5 emulation (touch, mobile UA) | +| `safari-ios` | WebKit | iPhone 13 emulation — proxy for iOS Safari | +| `firefox-mobileviewport` | Gecko | Mobile **viewport only** (Playwright Firefox has no touch/isMobile emulation) | + +WebKit is the engine behind Safari and **every** iOS browser, so the WebKit +projects are our stand-in for the Apple ecosystem without a Mac in the loop. +They are not a perfect substitute for real Safari/iOS (notably print dialog +behavior and clipboard gesture rules), so a real-device pass is still worth +doing before a release. + +The specs in [`flyer.spec.js`](flyer.spec.js) check the genuinely +engine-sensitive surfaces: + +- default flyer renders with **no uncaught JS / console errors** +- `@media print` hides the app chrome and keeps the flyer (a print-media + screenshot of the sheet is attached per engine for visual diffing) +- anonymous key generation works (the **WebCrypto** path) +- the legacy `/nostr/` redirect preserves the query string and `#hash` +- an event-lookup URL shows the localized loading state, never the default flyer + +## Running + +```bash +npm install +npx playwright install # download Chromium, Firefox, WebKit +npm run test:e2e # all projects +npx playwright test --project=firefox-desktop # one engine +npm run report # open the HTML report (with print screenshots) +``` + +Playwright starts and stops the static server itself (`python3 -m http.server`), +so you do not need a server running first. + +### Linux: WebKit system dependency + +On a fresh Linux box WebKit needs extra system libraries. If the WebKit +projects fail to launch, install them once: + +```bash +sudo npx playwright install-deps # or: sudo apt-get install -y libavif16 +``` + +The Chromium and Firefox projects run without this step. diff --git a/e2e/flyer.spec.js b/e2e/flyer.spec.js new file mode 100644 index 0000000..e543abe --- /dev/null +++ b/e2e/flyer.spec.js @@ -0,0 +1,89 @@ +// @ts-check +const { test, expect } = require("@playwright/test"); + +// These run once per project (engine/device) defined in playwright.config.js, +// so every assertion below is validated on Chromium, Firefox, WebKit, and the +// Android/iOS mobile emulations. + +/** Collect uncaught errors + console.error so we can fail on JS that only one + * engine throws (the classic "works in Chrome" trap). */ +function trackErrors(page) { + const errors = []; + page.on("pageerror", (e) => errors.push(`pageerror: ${e.message}`)); + page.on("console", (m) => { + if (m.type() === "error") errors.push(`console.error: ${m.text()}`); + }); + return errors; +} + +test.describe("VoxVera static client", () => { + test("loads the default flyer with no JS errors", async ({ page }) => { + const errors = trackErrors(page); + await page.goto("/"); + const sheet = page.locator("#flyer-preview .container").first(); + await expect(sheet).toBeVisible(); + // Default sample content should render on the bare URL. + await expect(page.locator(".content h1").first()).toContainText("TOP SECRET"); + // A real Letter sheet is taller than it is wide. + const box = await sheet.boundingBox(); + expect(box && box.height).toBeGreaterThan(box ? box.width : 0); + expect(errors, errors.join("\n")).toHaveLength(0); + }); + + test("print media hides app chrome and keeps the flyer", async ({ page }, testInfo) => { + await page.goto("/"); + await page.emulateMedia({ media: "print" }); + // App header/toolbars must not print. + await expect(page.locator("body > header")).toBeHidden(); + const sheet = page.locator(".container").first(); + await expect(sheet).toBeVisible(); + // Attach the printed-media render of the sheet for visual cross-engine diff. + const shot = await sheet.screenshot(); + await testInfo.attach(`print-${testInfo.project.name}`, { body: shot, contentType: "image/png" }); + }); + + test("generates an anonymous npub (WebCrypto path)", async ({ page }) => { + const errors = trackErrors(page); + // The identity controls live in the editor panel (hidden in viewer mode). + await page.goto("/#editor"); + await page.locator("#generate-anon-identity").click(); + await expect(page.locator("#author-npub-output")).toContainText(/^npub1[0-9a-z]+/); + expect(errors, errors.join("\n")).toHaveLength(0); + }); + + test("legacy /nostr/ redirect preserves query + hash", async ({ page }) => { + await page.goto("/nostr/?ref=qr#hello"); + await page.waitForURL((url) => url.pathname === "/" && url.hash === "#hello"); + expect(new URL(page.url()).pathname).toBe("/"); + expect(new URL(page.url()).search).toBe("?ref=qr"); + expect(new URL(page.url()).hash).toBe("#hello"); + }); + + test("event-lookup URL shows the loading state, not default content", async ({ page }) => { + await page.goto("/"); + // Build a well-formed naddr in-page so we don't depend on the network. + const naddr = await page.evaluate(() => { + const t = window.NostrTools; + return t.nip19.naddrEncode({ + identifier: "voxvera:crossbrowser-smoke", + pubkey: "0".repeat(64), + kind: 30078, + relays: [] + }); + }); + expect(naddr).toMatch(/^naddr1/); + // Navigating to the fragment should immediately swap in the localized + // "Loading content..." placeholder rather than the default TOP SECRET flyer. + // Go via about:blank so this is a full document load (a bare #hash change + // on the same URL would not re-run the URL-event handler). + await page.goto("about:blank"); + await page.goto(`/#${naddr}`); + // The default flyer (with its h1 headings) must never appear; only the + // status placeholder. Accept either loading or the eventual load-failed + // message, since the offline fetch may resolve before we assert. + await expect(page.locator(".flyer-status-message")).toContainText( + /Loading content|Could not load this flyer/i + ); + await expect(page.locator(".content h1")).toHaveCount(0); + }); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0e78d07 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "voxvera-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "voxvera-e2e", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.49.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3d2a634 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "voxvera-e2e", + "version": "1.0.0", + "private": true, + "description": "Cross-browser end-to-end tests for the VoxVera static Nostr flyer client.", + "scripts": { + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.49.0" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..0c1764f --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,52 @@ +// @ts-check +const { defineConfig, devices } = require("@playwright/test"); + +// Cross-browser + cross-device coverage for the static client. The same site +// is exercised on each desktop engine (Chromium/Firefox/WebKit) and on mobile +// emulations (Android Chrome, iOS Safari, and a mobile-viewport Firefox). +// +// WebKit is the engine behind Safari (macOS) and every iOS browser, so the +// `webkit` and `Mobile Safari` projects are our proxy for the Apple ecosystem +// without a Mac in the loop. Playwright manages the static server itself. + +const PORT = 8799; + +module.exports = defineConfig({ + testDir: "./e2e", + timeout: 30000, + expect: { timeout: 7000 }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + reporter: [["list"], ["html", { open: "never" }]], + use: { + baseURL: `http://127.0.0.1:${PORT}`, + trace: "on-first-retry", + screenshot: "only-on-failure" + }, + webServer: { + command: `python3 -m http.server ${PORT} --directory site`, + url: `http://127.0.0.1:${PORT}/`, + reuseExistingServer: !process.env.CI, + timeout: 30000 + }, + projects: [ + // ---- Desktop engines ---- + { name: "chromium-desktop", use: { ...devices["Desktop Chrome"] } }, + { name: "firefox-desktop", use: { ...devices["Desktop Firefox"] } }, + { name: "webkit-desktop", use: { ...devices["Desktop Safari"] } }, + + // ---- Mobile emulations ---- + { name: "chrome-android", use: { ...devices["Pixel 5"] } }, + { name: "safari-ios", use: { ...devices["iPhone 13"] } }, + + // Playwright's Firefox engine does not support touch/isMobile device + // descriptors, so this is a mobile-viewport layout check only (no touch). + { + name: "firefox-mobileviewport", + use: { + ...devices["Desktop Firefox"], + viewport: { width: 412, height: 915 } + } + } + ] +});