Add cross-browser e2e tests, field-limit cheat sheet, and docs

Verification:
- Add Playwright harness (e2e/) covering Chromium, Firefox, and WebKit plus
  Android/iOS mobile emulations. Smoke tests assert: default flyer renders
  with no JS/console errors, @media print hides app chrome and keeps the
  flyer, anonymous npub generation (WebCrypto), legacy /nostr/ redirect
  preserves query+hash, and event-lookup URLs show the loading state instead
  of the default flyer. All six projects pass.
- package.json/playwright.config.js wire up the projects and a managed static
  server; .gitignore excludes node_modules and Playwright output.

Docs:
- docs/flyer-field-limits.md: per-script character cheat sheet for each flyer
  field (single-line caps + multi-line guidance), measured by rendering each
  script at print size.
- README: add a Features section matching the shipping client and a
  cross-browser verification section; link the cheat sheet.
- AGENTS.md: document the e2e suite and the one-time WebKit Linux dep.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-09 15:29:49 -04:00
1 parent 562b7d1429
commit c5b2c20fc9
9 files changed
+406 -5

No files matched your search

+5
View File
@@ -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/
+9
View File
@@ -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.
+31 -5
View File
@@ -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
+70
View File
@@ -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.
+58
View File
@@ -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.
+89
View File
@@ -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);
});
});
+78
View File
@@ -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"
}
}
}
}
+14
View File
@@ -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"
}
}
+52
View File
@@ -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 }
}
}
]
});