diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59f7378..5a14bde 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,8 @@ jobs: cache: pnpm - name: Install run: pnpm install --frozen-lockfile + - name: Fetch + install bundled Etherpad source (embedded server) + run: pnpm --filter @etherpad/desktop fetch:etherpad - name: Build run: pnpm build - name: Package @@ -53,6 +55,8 @@ jobs: cache: pnpm - name: Install run: pnpm install --frozen-lockfile + - name: Fetch + install bundled Etherpad source (embedded server) + run: pnpm --filter @etherpad/desktop fetch:etherpad - name: Build run: pnpm build - name: Package @@ -89,6 +93,8 @@ jobs: cache: pnpm - name: Install run: pnpm install --frozen-lockfile + - name: Fetch + install bundled Etherpad source (embedded server) + run: pnpm --filter @etherpad/desktop fetch:etherpad - name: Build run: pnpm build - name: Package diff --git a/.github/workflows/snap-publish.yml b/.github/workflows/snap-publish.yml index c3320d6..2e1f8e7 100644 --- a/.github/workflows/snap-publish.yml +++ b/.github/workflows/snap-publish.yml @@ -52,6 +52,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Fetch + install bundled Etherpad source (embedded server) + run: pnpm --filter @etherpad/desktop fetch:etherpad + - name: Build app run: pnpm build diff --git a/docs/superpowers/specs/2026-05-11-mobile-offline-editing-scope.md b/docs/superpowers/specs/2026-05-11-mobile-offline-editing-scope.md new file mode 100644 index 0000000..c87696b --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-mobile-offline-editing-scope.md @@ -0,0 +1,195 @@ +# Mobile offline editing — scoping (v2) + +> **Status:** scoping document, not a design. The point of this doc is to +> surface the design forks so we can pick a direction before writing the +> v2 design proper. + +## Problem + +Today a mobile pad won't render at all without connectivity — the iframe +loads `https:///p/` and that's a dead end offline. The user's +Phase 7 device test made the gap obvious: black/blocked iframe with no +fallback beyond "open in browser." + +The asked-for v2 outcome: + +> *"Embedded local Etherpad server on mobile with offline edits and sync +> on reconnection — likely via `nodejs-mobile` or a CRDT-backed offline +> queue."* — `docs/superpowers/specs/2026-05-11-etherpad-mobile-android-design.md` §v2 + +User picked the **CRDT offline queue** direction over `nodejs-mobile`. +Reasons (implied): smaller native footprint, no Node-on-Android stack, +no Etherpad server processes to manage on a phone, lower battery cost. + +## Hard constraint: Etherpad's sync model is OT, not CRDT + +Etherpad uses **Operational Transformation** (OT) via custom "changesets" +— a 20-year-old format where every keystroke ships as a transformation +of the previous server state. Concurrent edits are resolved by the +server transforming incoming changesets against the canonical history. + +CRDT (Yjs, Automerge, etc.) is a fundamentally different model — each +peer can apply ops in any order and they commute. CRDT and OT do not +compose without a bridge. + +Three implications: + +1. **There's no off-the-shelf "Etherpad client that speaks CRDT".** + Pure-mobile CRDT means the mobile client diverges from the server + protocol; the bridge happens on sync. +2. **Concurrent edits between an online web client and an offline + mobile client can't merge correctly via Etherpad's REST API.** + `POST /setText` is destructive; the proper merge requires the + changeset stream over WebSocket, which assumes both sides speak OT. +3. **Conflict semantics differ.** OT favours preserving intent ("user + was typing here, even if the text shifted"); CRDT favours + convergence ("everyone agrees what the doc looks like"). Users may + see surprises when the two meet. + +## Four design options + +Listed cheapest first. + +### A. Read-only offline snapshot (≈ 1 week) + +When a pad opens online, cache its current text + last-revision number +via `GET /api/1.2.x/getText`. When offline, render the cached text +inside the shell (no editing). When online again, re-fetch. + +**Pros:** trivial; no CRDT; no server changes; useful for "look up that +note while on the bus" scenarios. Ships before everything else. + +**Cons:** read-only. Doesn't satisfy "offline edits." + +**Practical fit:** good v2.0. Probably worth shipping standalone before +attempting writes. + +### B. Local edit buffer + last-write-wins on sync (≈ 2-3 weeks) + +When offline, the user edits a local mirror of the pad text (plain +textarea, or a minimal CodeMirror with no real-time anything). On +reconnect, `POST /setText` with the buffer. If the server has changed +since the snapshot's revision, prompt the user: "merge changes" (a +naive 3-way text merge via diff3) or "discard mine" or "keep mine." + +**Pros:** simple; no new server protocol; existing Etherpad API; works +for solo authors who occasionally edit offline. + +**Cons:** lossy if concurrent web edits happened while offline — naive +merge can produce garbage. Not collaborative offline. Single-user use +case mostly. + +**Practical fit:** good v2.1 once v2.0 is in. + +### C. Operation queue + replay over WebSocket (≈ 4-6 weeks) + +When offline, record every keystroke as an Etherpad changeset against +the last-known server revision. When reconnected, open the pad's +WebSocket and replay queued changesets in order — Etherpad's OT +transforms them against any web edits made in the meantime. + +**Pros:** uses Etherpad's existing OT semantics, so concurrent edits +merge "correctly" the way Etherpad already merges them. No server +changes. Closest to "real" Etherpad offline. + +**Cons:** requires implementing changeset generation on mobile (the +diff-to-changeset logic lives in Etherpad's client today; we'd port it +or vendor it). Need to handle the case where queued changesets break +because the server document has diverged too much. Quite a bit of code +to write + test. + +**Practical fit:** the "good enough for power users" target. Doesn't +need a new sync model — just better client glue. + +### D. CRDT client + server plugin (`ep_yjs` or similar) (≈ 8-12 weeks) + +Replace the on-pad editor (currently Etherpad's monolithic client) with +a Yjs-backed editor on mobile. Server-side, an Etherpad plugin (existing +prototype: `ep_yjs`, but it's unmaintained) maps Yjs updates onto +Etherpad changesets and back. Online or offline edits are the same +code path — Yjs handles the queue + sync automatically. + +**Pros:** clean conflict resolution; same editor handles online + +offline transparently; no separate "sync logic" to maintain; matches +modern real-time apps' UX. Mobile-and-web could one day share the +CRDT editor. + +**Cons:** biggest lift. Server-side plugin needs maintenance. Existing +Etherpad clients still speak OT — the plugin has to act as a bridge. +Multi-month effort, real risk of getting stuck on edge cases (Yjs ↔ +OT round-tripping has subtle bugs that bite at scale). + +**Practical fit:** the right end state, but probably not the next 3 PRs. + +## Recommended phasing + +| Phase | Scope | When | +|---|---|---| +| **v2.0** | Option A — read-only offline snapshot. Sidebar shows "📱 cached" badge per pad with the cache age. Tap to view; banner says "Offline (last synced N min ago) — reconnect to edit." | Next sprint | +| **v2.1** | Option B — single-user offline edit with last-write-wins merge prompt. Edits queued in IndexedDB. | Sprint after | +| **v2.2** | Option C — replay over WebSocket, no manual merge prompt. Etherpad's OT handles concurrent edits. | Quarter after | +| **v3.0** | Option D — Yjs-backed editor + `ep_yjs` plugin. Replaces v2.x mobile-specific code with a cross-platform offline-first editor. | Future | + +This staircase keeps each step independently shippable and falsifiable — +if v2.0's adoption is low, v2.1+ aren't worth building. If v2.1's merge +prompts annoy users daily, that's the signal to skip ahead to v2.2. + +## Open questions before drafting the v2.0 design + +1. **What's the storage budget per pad on mobile?** `@capacitor/preferences` + tops out around 6MB per key on Android (SharedPreferences soft limit). + Pads can grow past that. We'd switch large pads to `@capacitor/filesystem`. + How big are the user's typical pads? Decision impacts whether v2.0 needs + the filesystem adapter from day one. + +2. **Cache invalidation policy.** Per-pad TTL? Refresh-on-open? Manual + "sync" button? What happens if the cached pad is from 2 weeks ago? + +3. **Should we cache *every* pad in pad-history or only the ones the + user has opened on mobile recently?** Opening 30 pads on desktop and + then expecting them all to be cached on the phone is a footgun. + +4. **What status should the UI show?** "synced" vs "stale" vs "offline" + vs "unreachable" — currently the shell only has `tabState` + loading/loaded/error/crashed. We'd add an offline indicator. + +5. **Does the user have any existing Etherpad fork or plugin we should + align with?** If they're already running `ep_socketio_logging` or + similar, that could expose useful metadata cheaply. + +6. **iOS pretty much rules out background sync.** Are we OK with + "sync runs only when the app is open"? That's the v2.0/2.1 model. + v2.2+ would want a service worker or background fetch. + +## What scoping doesn't cover yet + +These need their own focused decisions when their phase rolls around: + +- The mobile editor itself for write modes. Today the iframe IS the + editor. v2.1+ replaces it on the offline path; what does the online + path do then? Two editors? One that's offline-capable always? +- Authentication — Etherpad's session cookies vs OAuth vs API key. + Cached pads need to know whose pad they are. +- Multi-device merge (user edits the same pad offline on phone AND + laptop, both reconnect). v2.2 + Etherpad's OT handles this; v2.1's + merge prompt does not. +- Conflict UX in v2.1 — diff3 output is unreadable to non-engineers. + Probably a real "side by side" merge view is needed; that's its own + micro-spec. + +## Recommendation + +Start with **v2.0 (read-only offline snapshot)**. Two-PR rollout: + +1. Add `padCache` table to mobile storage (Preferences for ≤4MB pads, + Filesystem for the rest). On every iframe load, snapshot + `GET /api/getText` and write to cache. Smoke test: cache hit on + reload. +2. Detect offline (`navigator.onLine` + `@capacitor/network` ping). When + offline, hide the iframe behind a "📱 viewing offline cache" overlay + that renders the cached text plus a "reconnect to edit" banner. + +That ships standalone, takes the user from "broken when offline" to +"can read" without committing to any of the bigger sync decisions. +After that we have data on which pads people actually care about +offline-editing and the v2.1 design gets sharper. diff --git a/packages/desktop/.gitignore b/packages/desktop/.gitignore new file mode 100644 index 0000000..b49680b --- /dev/null +++ b/packages/desktop/.gitignore @@ -0,0 +1,10 @@ +node_modules +out +release +playwright-report +test-results + +# Etherpad bundled-source: fetched by scripts/fetch-etherpad.mjs, not +# committed because it's ~500MB of node_modules and version-pinned via +# the script's ETHERPAD_VERSION constant. +resources/etherpad/ diff --git a/packages/desktop/AGENTS.md b/packages/desktop/AGENTS.md index 4506974..26bec28 100644 --- a/packages/desktop/AGENTS.md +++ b/packages/desktop/AGENTS.md @@ -53,24 +53,40 @@ After main-process source changes, **restart `pnpm dev`** — Vite HMR only cove `etherpad-desktop` can spawn a local Etherpad on demand for users who don't have a remote server. The controller in `src/main/embedded/embedded-server.ts` -is a singleton that uses `npx etherpad-lite@latest` and stores pad data at -`userData/embedded-etherpad/`. The first invocation on a clean machine downloads -Etherpad (~100MB) and can take 60-180s; subsequent invocations are immediate. +is a singleton that spawns the **bundled Etherpad source** with `node +--require tsx/cjs node/server.ts --settings ` against a settings file +under `userData/embedded-etherpad/`. + +The source tree lives at `packages/desktop/resources/etherpad/` in dev (fetched +by `pnpm fetch:etherpad`, gitignored) and at `/etherpad/` in a +packaged app (electron-builder copies it via `extraResources`). +`findBundledEtherpadDir({ resourcesPath, appRoot })` is the single seam that +discovers it. + +The pinned Etherpad version is set in `scripts/fetch-etherpad.mjs` +(`ETHERPAD_VERSION`). The fetch script is idempotent — re-running is a no-op +unless the marker file mismatches or `--force` is passed. + +**Why bundled instead of `npx etherpad-lite@latest`:** the `etherpad-lite` +and `etherpad` npm packages were both unpublished, so the previous flow +404'd on every cold start. Bundling solves that + skips the multi-hundred-MB +npx download per machine. The `Workspace.kind` field distinguishes `'remote'` (default for back-compat) from `'embedded'`. The `AddWorkspaceDialog` exposes a "Use a local server" -checkbox; embedded workspaces skip the URL probe. +checkbox; embedded workspaces skip the URL probe and disable the URL field. The embedded server controller accepts injected `spawnFn` and `findFreePortFn` so unit tests can stub out child_process without mocking Node internals. -Future Spec 5 work: bundle Etherpad source so first-launch doesn't need -the network (avoids the npx download), version-pin the embedded Etherpad -separately from the desktop app's own version. +**Runtime prerequisite (dev only):** the host machine must have `node` on +`PATH` for the spawn to succeed. Production installers will bundle node via +electron-builder when we're ready to ship; for now the dev machine's `node` +is used. The E2E test for embedded workspaces (`tests/e2e/embedded-workspace.spec.ts`) -is skipped by default (requires `E2E_EMBEDDED=1` env var) because it needs a -warm npx cache to avoid CI timeouts. Unit-level coverage in +is skipped by default (requires `E2E_EMBEDDED=1` env var) because spinning +up Etherpad even from the bundled source adds ~30s. Unit-level coverage in `tests/main/embedded/embedded-server.spec.ts` provides the logic guarantees. ## Distribution diff --git a/packages/desktop/build/electron-builder.yml b/packages/desktop/build/electron-builder.yml index b0da146..2a732e7 100644 --- a/packages/desktop/build/electron-builder.yml +++ b/packages/desktop/build/electron-builder.yml @@ -11,6 +11,22 @@ files: - 'package.json' - '!**/*.{md,map,ts,tsx}' +# Etherpad source for the embedded server. Lives under +# `packages/desktop/resources/etherpad/` (fetched by +# `pnpm fetch:etherpad`). electron-builder copies the tree to the +# packaged app's `/etherpad/` — exactly where +# `findBundledEtherpadDir` looks at runtime. Tarred separately from +# asar so Node can spawn directly into the source files. +extraResources: + - from: resources/etherpad + to: etherpad + filter: + - '**/*' + # Don't ship Etherpad's own playwright/test trees in the installer. + - '!**/tests/**' + - '!**/.git/**' + - '!**/playwright.config.ts' + asar: true asarUnpack: - 'out/preload/index.cjs' diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 47bc02d..1b55ae2 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -22,6 +22,7 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "node scripts/run-e2e.mjs", + "fetch:etherpad": "node scripts/fetch-etherpad.mjs", "package": "electron-vite build && electron-builder --linux --config build/electron-builder.yml", "package:win": "electron-vite build && electron-builder --win --config build/electron-builder.yml", "package:linux": "electron-vite build && electron-builder --linux --config build/electron-builder.yml", diff --git a/packages/desktop/scripts/fetch-etherpad.mjs b/packages/desktop/scripts/fetch-etherpad.mjs new file mode 100644 index 0000000..829afdb --- /dev/null +++ b/packages/desktop/scripts/fetch-etherpad.mjs @@ -0,0 +1,249 @@ +#!/usr/bin/env node +/** + * One-time fetch + install of Etherpad source so the desktop's + * embedded-server module has something to spawn locally. + * + * - Downloads the GitHub source tarball for the pinned version below. + * - Extracts into `packages/desktop/resources/etherpad/` (gitignored). + * - Runs `pnpm install --prod` inside `src/` so Etherpad's runtime deps + * are ready. + * - Writes a `.installed-version` marker so re-runs are no-ops unless + * `--force` is passed or the pinned version changes. + * + * Idempotent. Safe to invoke from CI or as a postinstall step. Skips work + * when the marker matches. + * + * Uses system `tar` (available on Linux / macOS / Windows 10+) so no new + * npm dependency is needed. + * + * Future: a CI step before `electron-builder` will run this so the + * shipping installer contains Etherpad pre-installed. For now it's a + * dev-machine prerequisite for the embedded-server flow. + */ +import { spawn } from 'node:child_process'; +import { mkdir, readFile, writeFile, rm, stat, rename } from 'node:fs/promises'; +import { createWriteStream } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { pipeline } from 'node:stream/promises'; + +const ETHERPAD_VERSION = 'v2.7.3'; +const TARBALL_URL = `https://github.com/ether/etherpad-lite/archive/refs/tags/${ETHERPAD_VERSION}.tar.gz`; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const RESOURCES = resolve(__dirname, '..', 'resources'); +const TARGET = join(RESOURCES, 'etherpad'); +const TARBALL = join(RESOURCES, `etherpad-${ETHERPAD_VERSION}.tar.gz`); +const VERSION_MARKER = join(TARGET, '.installed-version'); + +const FORCE = process.argv.includes('--force'); + +async function exists(p) { + try { await stat(p); return true; } catch { return false; } +} + +async function run(cmd, args, opts = {}) { + return new Promise((resolveP, rejectP) => { + const child = spawn(cmd, args, { stdio: 'inherit', ...opts }); + child.on('exit', (code) => { + if (code === 0) resolveP(); + else rejectP(new Error(`${cmd} ${args.join(' ')} exited ${code}`)); + }); + child.on('error', rejectP); + }); +} + +async function main() { + if (!FORCE && await exists(VERSION_MARKER)) { + const installed = (await readFile(VERSION_MARKER, 'utf8')).trim(); + if (installed === ETHERPAD_VERSION) { + console.log(`[fetch-etherpad] Already on ${ETHERPAD_VERSION}, skipping (use --force to refetch).`); + return; + } + console.log(`[fetch-etherpad] Marker says ${installed}, target is ${ETHERPAD_VERSION} — refetching.`); + } + + await mkdir(RESOURCES, { recursive: true }); + console.log(`[fetch-etherpad] Downloading ${TARBALL_URL}`); + const res = await fetch(TARBALL_URL); + if (!res.ok) throw new Error(`download failed: ${res.status} ${res.statusText}`); + await pipeline(res.body, createWriteStream(TARBALL)); + + if (await exists(TARGET)) await rm(TARGET, { recursive: true, force: true }); + console.log('[fetch-etherpad] Extracting'); + await run('tar', ['-xzf', TARBALL, '-C', RESOURCES]); + + // The tarball extracts to `etherpad-` — rename to `etherpad`. + const semver = ETHERPAD_VERSION.startsWith('v') ? ETHERPAD_VERSION.slice(1) : ETHERPAD_VERSION; + const extractedDir = join(RESOURCES, `etherpad-${semver}`); + if (!(await exists(extractedDir))) { + throw new Error(`expected ${extractedDir} after extract`); + } + await rename(extractedDir, TARGET); + + // Slim Etherpad's deps before installing: we only ship the dirty-db + // path, so SQL/NoSQL drivers + Swagger UI + Azure auth + OpenTelemetry + // / Apache Arrow tooling are dead weight. Removing them at the + // package.json level (vs after install) means pnpm never even + // downloads them. Cuts the resulting install from ~300MB to ~50MB. + // + // Conservative list — only drivers + tools the embedded-server flow + // demonstrably doesn't load. `ueberdb2` stays (the abstraction layer + // `dirty` is dispatched through). `tsx` + `cross-env` stay (server + // entry point). `socket.io` + `express` + standard libs stay. + console.log('[fetch-etherpad] Slimming unused DB drivers + dev-only deps from Etherpad src/'); + await pruneUnusedDeps(join(TARGET, 'src')); + + // Drop documentation, packaging scripts, dev-only workspace packages + // before install so pnpm doesn't pull their dev deps either. + for (const sub of ['doc', 'docs', 'packaging', 'snap', 'docker-compose.yml', 'docker-compose.dev.yml', 'Dockerfile', 'admin', 'ui', 'tests', 'AGENTS.MD', 'CHANGELOG.md', 'CONTRIBUTING.md', 'SECURITY.md', 'best_practices.md', 'README.md']) { + await rm(join(TARGET, sub), { recursive: true, force: true }); + } + + console.log('[fetch-etherpad] Installing Etherpad runtime deps (pnpm install in src/)'); + await run('pnpm', ['install', '--prod', '--no-frozen-lockfile'], { cwd: join(TARGET, 'src') }); + + // Post-install: surgically delete .pnpm entries that ueberdb2 / Etherpad + // pull in transitively but that the dirty-db embedded path never loads. + // pnpm can't trim these via --filter / --no-optional because they're + // bundled as direct deps of intermediate packages. Removing the .pnpm + // store entry breaks `require('mongodb')` immediately, but the dirty + // driver doesn't take that path. + console.log('[fetch-etherpad] Post-install: pruning unused .pnpm store entries'); + // Etherpad is a pnpm workspace — node_modules/.pnpm lives at the + // workspace root, not under src/. + await prunePnpmStore(join(TARGET, 'node_modules', '.pnpm')); + + await writeFile(VERSION_MARKER, `${ETHERPAD_VERSION}\n`); + await rm(TARBALL, { force: true }); + console.log(`[fetch-etherpad] Done — Etherpad ${ETHERPAD_VERSION} installed at ${TARGET}`); +} + +const SLIM_REMOVE = [ + // SQL / NoSQL drivers not used when dbType: "dirty" + '@elastic/elasticsearch', + 'cassandra-driver', + 'mongodb', + 'mysql2', + 'pg', + 'rethinkdb', + 'redis', + 'surrealdb', +]; + +/** + * Substrings matching .pnpm store directory names that the dirty-db + * embedded path never loads. .pnpm dirs are formatted like + * `+@...`, so the colon in scoped names becomes + * `+` — match accordingly. + * + * Anything in here is verified safe to delete via the post-install + * smoke test (`node --require tsx/cjs node/server.ts` → /api/ probe). + * Add cautiously; verify before commit. + */ +const PNPM_PRUNE_PREFIXES = [ + // Cloud DB drivers (transitively via ueberdb2) + '@elastic+', + 'cassandra-driver@', + 'mongodb@', + 'mongodb-', + 'mysql2@', + 'mysql@', + 'pg@', + 'pg-', + 'redis@', + '@redis+', + 'rethinkdb@', + 'surrealdb@', + 'tedious@', + // Cloud-DB heavy transitives + 'apache-arrow@', + '@js-joda+', + '@azure+', + '@typespec+', + // Note: @opentelemetry was tempting but prom-client (Etherpad's + // metrics export) require()s @opentelemetry/api during boot; pruning + // it surfaces a noisy error in logs even though Etherpad continues. + // Note: swagger-ui-express + swagger-jsdoc were tempting prunes but + // Etherpad's RestAPI hook require()s swagger-ui-express at server + // boot. Removing them logs an error and disables /api-docs but the + // core /api/ still works — keeping them in for a clean boot. + // @types/* — TypeScript type defs, never require()d at runtime. + '@types+', + // Alternative ueberdb2 backends we don't use + 'rusty-store-kv-', + // MongoDB leftovers (mongodb itself was pruned earlier) + 'bson@', + // tsx (TypeScript loader) requires esbuild + typescript at runtime, so + // they must stay even though they look like dev tools. + // jsdom is used by Etherpad's ImportEtherpad — keep. +]; + +async function pruneUnusedDeps(pkgDir) { + const pkgPath = join(pkgDir, 'package.json'); + const pkg = JSON.parse(await readFile(pkgPath, 'utf8')); + let removed = 0; + for (const name of SLIM_REMOVE) { + if (pkg.dependencies?.[name]) { + delete pkg.dependencies[name]; + removed += 1; + } + if (pkg.optionalDependencies?.[name]) { + delete pkg.optionalDependencies[name]; + removed += 1; + } + } + await writeFile(pkgPath, JSON.stringify(pkg, null, 2)); + console.log(`[fetch-etherpad] pruned ${removed} entries from ${pkgDir}/package.json`); +} + +async function prunePnpmStore(pnpmDir) { + const { readdir } = await import('node:fs/promises'); + let entries; + try { + entries = await readdir(pnpmDir); + } catch { + console.warn(`[fetch-etherpad] ${pnpmDir} missing; skipping store prune`); + return; + } + let removedCount = 0; + let removedBytes = 0; + for (const entry of entries) { + if (!PNPM_PRUNE_PREFIXES.some((p) => entry.startsWith(p))) continue; + const full = join(pnpmDir, entry); + try { + const size = await dirSize(full); + await rm(full, { recursive: true, force: true }); + removedCount += 1; + removedBytes += size; + } catch (err) { + console.warn(`[fetch-etherpad] failed to prune ${entry}:`, err.message); + } + } + const mb = (removedBytes / 1024 / 1024).toFixed(1); + console.log(`[fetch-etherpad] pruned ${removedCount} .pnpm store entries (${mb} MB)`); +} + +async function dirSize(dir) { + const { readdir: rd, stat: st } = await import('node:fs/promises'); + let total = 0; + const stack = [dir]; + while (stack.length) { + const current = stack.pop(); + let items; + try { items = await rd(current); } catch { continue; } + for (const name of items) { + const p = join(current, name); + let info; + try { info = await st(p); } catch { continue; } + if (info.isDirectory()) stack.push(p); + else total += info.size; + } + } + return total; +} + +main().catch((err) => { + console.error('[fetch-etherpad] failed:', err.message); + process.exit(1); +}); diff --git a/packages/desktop/src/main/app/lifecycle.ts b/packages/desktop/src/main/app/lifecycle.ts index cdfed33..be1cdbf 100644 --- a/packages/desktop/src/main/app/lifecycle.ts +++ b/packages/desktop/src/main/app/lifecycle.ts @@ -16,7 +16,7 @@ import { registerIpc } from '../ipc/handlers.js'; import { serializeWindowsForQuit } from './quit-state.js'; import { createUpdater } from './updater.js'; import type { UpdaterController } from './updater.js'; -import { createEmbeddedServer } from '../embedded/embedded-server.js'; +import { createEmbeddedServer, findBundledEtherpadDir } from '../embedded/embedded-server.js'; import type { EmbeddedServerController } from '../embedded/embedded-server.js'; import { createPadContentIndex } from '../pads/pad-content-index.js'; import type { PadContentIndex } from '../pads/pad-content-index.js'; @@ -134,9 +134,26 @@ export async function boot(): Promise { }, }); + // Find the bundled Etherpad source — dev: `packages/desktop/resources/etherpad/`; + // packaged: `/etherpad/` (electron-builder extraResources). + // app.getAppPath() gives the asar/dev root; resourcesPath the packaged location. + const etherpadDir = findBundledEtherpadDir({ + resourcesPath: process.resourcesPath, + appRoot: app.getAppPath(), + }); + log.info('embedded etherpad source resolution', { + resourcesPath: process.resourcesPath, + appRoot: app.getAppPath(), + resolved: etherpadDir, + }); + if (!etherpadDir) { + log.warn('embedded Etherpad source not bundled; `Use a local server` will fail until ' + + '`pnpm --filter @etherpad/desktop fetch:etherpad` is run'); + } const embeddedServer = createEmbeddedServer({ log, userDataDir: userData, + ...(etherpadDir ? { etherpadDir } : {}), }); // If any persisted workspace is `kind: 'embedded'`, eagerly start the diff --git a/packages/desktop/src/main/embedded/embedded-server.ts b/packages/desktop/src/main/embedded/embedded-server.ts index f106e50..4b0195e 100644 --- a/packages/desktop/src/main/embedded/embedded-server.ts +++ b/packages/desktop/src/main/embedded/embedded-server.ts @@ -1,5 +1,5 @@ import { spawn, type ChildProcess } from 'node:child_process'; -import { mkdirSync, writeFileSync, createWriteStream } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync, createWriteStream } from 'node:fs'; import { join } from 'node:path'; import type { Logger } from '../logging/logger.js'; @@ -16,17 +16,72 @@ export type EmbeddedServerController = { state(): EmbeddedServerState; }; +/** + * The path to a Node-runnable executable. In production the Electron + * binary doubles as Node when `ELECTRON_RUN_AS_NODE=1` is set, which is + * the canonical way Electron apps ship a Node runtime without bundling + * a separate copy. Tests inject a plain `node` path. + */ +export interface NodeRuntime { + execPath: string; + /** Extra env vars needed (e.g. `ELECTRON_RUN_AS_NODE=1` for Electron). */ + env: Record; +} + +/** Reasonable default for production Electron apps. */ +export const electronAsNode: NodeRuntime = { + execPath: process.execPath, + env: { ELECTRON_RUN_AS_NODE: '1' }, +}; + +/** + * Resolve where the bundled Etherpad source lives. In dev (`pnpm dev`) the + * fetch script drops it at `packages/desktop/resources/etherpad/`. In a + * packaged Electron app electron-builder copies the same tree into + * `/etherpad/` via `extraResources`. + * + * Returns `null` when neither location has a `src/node/server.ts` — the + * caller surfaces a friendly error rather than asphyxiating on a spawn + * failure inside `npx`. + */ +export function findBundledEtherpadDir(opts: { resourcesPath?: string; appRoot?: string }): string | null { + const candidates: string[] = []; + // Packaged app: electron-builder extraResources copies to resourcesPath. + if (opts.resourcesPath) candidates.push(join(opts.resourcesPath, 'etherpad')); + // Dev/test layouts where main runs from various depths: + if (opts.appRoot) { + candidates.push(join(opts.appRoot, 'resources', 'etherpad')); + // electron-vite's out/main → appRoot may resolve to out/main; resources is at ../../resources + candidates.push(join(opts.appRoot, '..', '..', 'resources', 'etherpad')); + candidates.push(join(opts.appRoot, '..', 'resources', 'etherpad')); + } + for (const dir of candidates) { + if (existsSync(join(dir, 'src', 'node', 'server.ts'))) return dir; + } + return null; +} + /** * Singleton-only for v1. Creates an isolated dirty.db settings file under - * userData/embedded-etherpad/ and spawns `npx etherpad-lite@latest` against - * it on a random localhost port. + * userData/embedded-etherpad/ and spawns the bundled Etherpad source + * (`scripts/fetch-etherpad.mjs` is the dev prereq; CI will pre-bundle). * * Pure function shape — accepts a `spawnFn` and `findFreePortFn` injection - * so tests can substitute fakes without mocking node:child_process. + * so tests can substitute fakes without mocking node:child_process. The + * `etherpadDir` is also injectable for tests; in production it's resolved + * via `findBundledEtherpadDir` at call sites. */ export function createEmbeddedServer(opts: { log: Logger; userDataDir: string; + /** Path containing `src/node/server.ts`. When omitted, `start()` throws. */ + etherpadDir?: string; + /** + * Node runtime to spawn (defaults to Electron-as-Node so production + * doesn't need system node installed). Tests pass `{ execPath: 'node', + * env: {} }` to keep spawn args simple. + */ + nodeRuntime?: NodeRuntime; spawnFn?: typeof spawn; findFreePortFn?: () => Promise; }): EmbeddedServerController { @@ -94,28 +149,53 @@ export function createEmbeddedServer(opts: { // on EPD_LOG_EMBEDDED which nobody knew to set. const logPath = join(logsDir, 'embedded-etherpad.log'); const logStream = createWriteStream(logPath, { flags: 'a' }); - logStream.write(`\n--- embedded etherpad start @ ${new Date().toISOString()} (port ${port}) ---\n`); + // Also copy to a stable /tmp path so e2e failure diagnostics survive + // the test runner's cleanup of userDataDir. EPD_EMBEDDED_DEBUG=1 enables. + const debugPath = process.env.EPD_EMBEDDED_DEBUG ? '/tmp/epd-embedded-debug.log' : null; + const debugStream = debugPath ? createWriteStream(debugPath, { flags: 'w' }) : null; + const teeAll = (s: string | Buffer): void => { + logStream.write(s); + debugStream?.write(s); + }; + teeAll(`\n--- embedded etherpad start @ ${new Date().toISOString()} (port ${port}) ---\n`); opts.log.info('starting embedded etherpad', { port, logPath }); + if (!opts.etherpadDir) { + throw new Error( + 'Etherpad source not bundled. Run `pnpm fetch:etherpad` in packages/desktop ' + + 'to install it locally, or wait for the CI-bundled release.', + ); + } + const etherpadSrc = join(opts.etherpadDir, 'src'); + const node = opts.nodeRuntime ?? electronAsNode; + const spawnMeta = `--- spawn cwd=${etherpadSrc} exec=${node.execPath} env+=${JSON.stringify(node.env)} ---\n`; + teeAll(spawnMeta); + opts.log.info('spawning embedded etherpad', { execPath: node.execPath, cwd: etherpadSrc }); proc = spawnImpl( - 'npx', - ['--yes', 'etherpad-lite@latest', '--settings', settingsPath], + node.execPath, + ['--require', 'tsx/cjs', 'node/server.ts', '--settings', settingsPath], { - cwd: dataDir, + cwd: etherpadSrc, stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, NODE_ENV: 'production' }, + env: { ...process.env, ...node.env, NODE_ENV: 'production' }, }, ); - proc.stdout?.on('data', (b: Buffer) => { logStream.write(b); }); + proc.on('error', (err) => { + teeAll(`--- spawn error: ${err.message} ---\n`); + opts.log.error('embedded spawn error', { message: err.message }); + }); + proc.stdout?.on('data', (b: Buffer) => { teeAll(b); }); proc.stderr?.on('data', (b: Buffer) => { - logStream.write(b); + teeAll(b); const chunk = b.toString(); stderrTail = (stderrTail + chunk).slice(-stderrTailLimit); if (process.env.EPD_LOG_EMBEDDED) process.stderr.write(`[embedded] ${chunk}`); }); proc.on('exit', (code, signal) => { opts.log.warn('embedded etherpad exited', { code, signal }); - logStream.end(`--- exited code=${code} signal=${signal} ---\n`); + const exitMsg = `--- exited code=${code} signal=${signal} ---\n`; + logStream.end(exitMsg); + debugStream?.end(exitMsg); if (s.kind === 'starting') { // Exit before readiness — record so waitForReachable can fail fast // instead of polling for the full timeout. @@ -128,11 +208,12 @@ export function createEmbeddedServer(opts: { }); const url = `http://127.0.0.1:${port}`; - // 8 minutes: cold-start `npx etherpad-lite@latest` downloads a multi- - // hundred-MB tarball before the server even begins booting. 240s was - // not enough on slower connections — users saw the "Starting…" dialog - // hang and gave up. - await waitForReachable(url, 480_000, () => exitedEarly, () => stderrTail); + // 120 seconds: bundled-source spawn skips the npx download but + // Etherpad's own boot does tsx-compile of every TS file on first + // run (~30-60s cold, much faster warm). Slow disks / busy CPU + // can push that further. Previously 480s when we did the npx + // download; 90s was too tight under e2e jitter. + await waitForReachable(url, 120_000, () => exitedEarly, () => stderrTail); serverUrl = url; s = { kind: 'running', url }; return url; diff --git a/packages/desktop/tests/e2e/embedded-workspace.spec.ts b/packages/desktop/tests/e2e/embedded-workspace.spec.ts index e045109..2b5bcb9 100644 --- a/packages/desktop/tests/e2e/embedded-workspace.spec.ts +++ b/packages/desktop/tests/e2e/embedded-workspace.spec.ts @@ -1,13 +1,17 @@ // tests/e2e/embedded-workspace.spec.ts // // This test exercises creating a local (embedded) Etherpad workspace. -// It uses `npx etherpad-lite@latest` which downloads Etherpad on a clean -// machine (~100MB) and can take 60-180s on first run. On CI without npx -// cache warmth the test would time out, so it is marked test.skip here. -// The unit-level coverage in tests/main/embedded/ and +// It depends on `pnpm fetch:etherpad` having populated +// `packages/desktop/resources/etherpad/` so the embedded-server spawn +// has source to run. On a fresh clone with bundled source the test +// takes ~30-60s (Etherpad cold-start). +// +// Gated on E2E_EMBEDDED=1 by default — too slow to add to every PR's +// e2e batch. Unit-level coverage in tests/main/embedded/ + // tests/main/ipc/workspace-handlers.spec.ts covers the logic paths. // -// To run locally (after `npx etherpad-lite@latest` is cached): +// To run locally: +// pnpm --filter @etherpad/desktop fetch:etherpad // E2E_EMBEDDED=1 pnpm test:e2e --grep "embedded workspace" import { test, expect } from '@playwright/test'; @@ -16,29 +20,29 @@ import { launchApp } from './fixtures/launch'; const RUN_EMBEDDED = Boolean(process.env.E2E_EMBEDDED); test.describe.serial('embedded workspace', () => { - test.setTimeout(300_000); + test.setTimeout(180_000); test('user can create a local workspace and open a pad in it', async () => { - test.skip(!RUN_EMBEDDED, 'Skipped: set E2E_EMBEDDED=1 to run (requires npx cache warmth)'); + test.skip(!RUN_EMBEDDED, 'Skipped: set E2E_EMBEDDED=1 to run (requires fetch:etherpad)'); const h = await launchApp(); try { // The first-run AddWorkspaceDialog should be showing await h.shell.getByLabel(/name/i).fill('Local'); - // Toggle the embedded checkbox - await h.shell.getByRole('checkbox', { name: /use a local etherpad server/i }).click(); + // Toggle the embedded checkbox — actual label is + // "Use a local server (runs on this computer)". + await h.shell.getByRole('checkbox', { name: /use a local server/i }).click(); - // URL field should be gone - await expect(h.shell.getByLabel(/etherpad url/i)).not.toBeVisible(); + // URL field is disabled (not hidden) when embedded is checked. + await expect(h.shell.getByLabel(/etherpad url/i)).toBeDisabled(); - // Click Add — this starts the embedded server which may take several minutes on first run + // Click Add — spawns the bundled Etherpad. Cold-start ~30-60s. await h.shell.getByRole('button', { name: /^add$/i }).click(); - // The embedded server takes time to start on a cold cache; allow up to 4 minutes. await expect( h.shell.getByRole('button', { name: /open instance local/i }), - ).toBeVisible({ timeout: 240_000 }); + ).toBeVisible({ timeout: 120_000 }); // Open a pad await h.shell.getByRole('button', { name: /new pad/i }).click(); diff --git a/packages/desktop/tests/main/embedded/embedded-server.spec.ts b/packages/desktop/tests/main/embedded/embedded-server.spec.ts index 23544aa..cc61da6 100644 --- a/packages/desktop/tests/main/embedded/embedded-server.spec.ts +++ b/packages/desktop/tests/main/embedded/embedded-server.spec.ts @@ -2,7 +2,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { EventEmitter } from 'node:events'; import type { ChildProcess } from 'node:child_process'; -import { createEmbeddedServer } from '../../../src/main/embedded/embedded-server'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createEmbeddedServer, findBundledEtherpadDir } from '../../../src/main/embedded/embedded-server'; // Stub global fetch so waitForReachable resolves quickly vi.stubGlobal( @@ -60,10 +63,12 @@ describe('EmbeddedServerController', () => { vi.clearAllMocks(); }); - it('start() spawns npx etherpad-lite with the correct args', async () => { + it('start() spawns the bundled Etherpad source via node + tsx/cjs', async () => { const ctrl = createEmbeddedServer({ log: makeLog(), userDataDir: '/tmp/test-embedded', + etherpadDir: '/tmp/fake-etherpad', + nodeRuntime: { execPath: 'node', env: {} }, spawnFn: spawnFn as never, findFreePortFn, }); @@ -71,17 +76,37 @@ describe('EmbeddedServerController', () => { const url = await ctrl.start(); expect(spawnFn).toHaveBeenCalledOnce(); - const [cmd, args] = spawnFn.mock.calls[0] as [string, string[]]; - expect(cmd).toBe('npx'); - expect(args).toContain('etherpad-lite@latest'); - expect(args).toContain('--yes'); + const [cmd, args, spawnOpts] = spawnFn.mock.calls[0] as [string, string[], { cwd: string; env: Record }]; + expect(cmd).toBe('node'); + expect(args.slice(0, 3)).toEqual(['--require', 'tsx/cjs', 'node/server.ts']); + expect(args).toContain('--settings'); + expect(spawnOpts.cwd).toBe('/tmp/fake-etherpad/src'); + expect(spawnOpts.env.NODE_ENV).toBe('production'); expect(url).toBe('http://127.0.0.1:19999'); }); + it('defaults to Electron-as-Node when no nodeRuntime injected', async () => { + const ctrl = createEmbeddedServer({ + log: makeLog(), + userDataDir: '/tmp/test-embedded', + etherpadDir: '/tmp/fake-etherpad', + // No nodeRuntime — fall through to electronAsNode default. + spawnFn: spawnFn as never, + findFreePortFn, + }); + + await ctrl.start(); + + const [cmd, , spawnOpts] = spawnFn.mock.calls[0] as [string, string[], { env: Record }]; + expect(cmd).toBe(process.execPath); + expect(spawnOpts.env.ELECTRON_RUN_AS_NODE).toBe('1'); + }); + it('start() returns the same URL on a second call without spawning again', async () => { const ctrl = createEmbeddedServer({ log: makeLog(), userDataDir: '/tmp/test-embedded', + etherpadDir: '/tmp/fake-etherpad', spawnFn: spawnFn as never, findFreePortFn, }); @@ -97,6 +122,7 @@ describe('EmbeddedServerController', () => { const ctrl = createEmbeddedServer({ log: makeLog(), userDataDir: '/tmp/test-embedded', + etherpadDir: '/tmp/fake-etherpad', spawnFn: spawnFn as never, findFreePortFn, }); @@ -116,6 +142,7 @@ describe('EmbeddedServerController', () => { const ctrl = createEmbeddedServer({ log: makeLog(), userDataDir: '/tmp/test-embedded', + etherpadDir: '/tmp/fake-etherpad', spawnFn: spawnFn as never, findFreePortFn, }); @@ -129,6 +156,7 @@ describe('EmbeddedServerController', () => { const ctrl = createEmbeddedServer({ log: makeLog(), userDataDir: '/tmp/test-embedded', + etherpadDir: '/tmp/fake-etherpad', spawnFn: spawnFn as never, findFreePortFn, }); @@ -143,6 +171,7 @@ describe('EmbeddedServerController', () => { const ctrl = createEmbeddedServer({ log: makeLog(), userDataDir: '/tmp/test-embedded', + etherpadDir: '/tmp/fake-etherpad', spawnFn: spawnFn as never, findFreePortFn, }); @@ -157,6 +186,7 @@ describe('EmbeddedServerController', () => { const ctrl = createEmbeddedServer({ log: makeLog(), userDataDir: '/tmp/test-embedded', + etherpadDir: '/tmp/fake-etherpad', spawnFn: spawnFn as never, findFreePortFn, }); @@ -170,6 +200,7 @@ describe('EmbeddedServerController', () => { const ctrl = createEmbeddedServer({ log: makeLog(), userDataDir: '/tmp/test-embedded', + etherpadDir: '/tmp/fake-etherpad', spawnFn: spawnFn as never, findFreePortFn, }); @@ -181,3 +212,38 @@ describe('EmbeddedServerController', () => { expect(spawnFn).toHaveBeenCalledOnce(); }); }); + +describe('findBundledEtherpadDir', () => { + let tmp: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'epd-bundled-')); + }); + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it('returns null when neither resourcesPath nor appRoot has Etherpad', () => { + const r = findBundledEtherpadDir({ resourcesPath: tmp }); + expect(r).toBeNull(); + }); + + it('finds Etherpad via resourcesPath when src/node/server.ts exists', () => { + const etherpadSrc = join(tmp, 'etherpad', 'src', 'node'); + mkdirSync(etherpadSrc, { recursive: true }); + writeFileSync(join(etherpadSrc, 'server.ts'), ''); + const r = findBundledEtherpadDir({ resourcesPath: tmp }); + expect(r).toBe(join(tmp, 'etherpad')); + }); + + it('falls back to appRoot/resources/etherpad when resourcesPath lookup misses', () => { + const devLayout = join(tmp, 'app', 'resources', 'etherpad', 'src', 'node'); + mkdirSync(devLayout, { recursive: true }); + writeFileSync(join(devLayout, 'server.ts'), ''); + const r = findBundledEtherpadDir({ + resourcesPath: join(tmp, 'packaged'), + appRoot: join(tmp, 'app'), + }); + expect(r).toBe(join(tmp, 'app', 'resources', 'etherpad')); + }); +}); diff --git a/packages/mobile/tests/android-fixtures.ts b/packages/mobile/tests/android-fixtures.ts new file mode 100644 index 0000000..912383b --- /dev/null +++ b/packages/mobile/tests/android-fixtures.ts @@ -0,0 +1,94 @@ +import { spawnSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; + +/** + * Adb-based driver for an Android emulator (or USB-attached device). + * + * Why adb input rather than CDP / puppeteer? Android WebView's stripped-down + * CDP rejects `Browser.setDownloadBehavior` (Playwright) and + * `Target.getBrowserContexts` (puppeteer-core), so the higher-level libraries + * can't attach cleanly. adb input commands work on every WebView version and + * cover the three primitives we need: tap, type, screenshot. + * + * Coordinate-based taps are inherently brittle vs the DOM-selector approach + * — these tests are paired with `screencap` snapshots so visual diffs catch + * any layout drift. + */ + +const APP_ID = 'com.etherpad.mobile'; +const DEFAULT_DEVICE = process.env.ADB_DEVICE ?? 'emulator-5554'; +const ADB = process.env.ADB ?? `${process.env.HOME}/Android/Sdk/platform-tools/adb`; + +function adb(args: string[], device = DEFAULT_DEVICE): string { + const out = spawnSync(ADB, ['-s', device, ...args], { encoding: 'utf8' }); + if (out.status !== 0) { + throw new Error(`adb ${args.join(' ')} failed: ${out.stderr || out.stdout}`); + } + return out.stdout; +} + +export function adbClearAppData(device = DEFAULT_DEVICE): void { + adb(['shell', 'pm', 'clear', APP_ID], device); +} + +export function adbForceStop(device = DEFAULT_DEVICE): void { + adb(['shell', 'am', 'force-stop', APP_ID], device); +} + +export function adbLaunchApp(device = DEFAULT_DEVICE): void { + adb(['shell', 'am', 'start', '-n', `${APP_ID}/.MainActivity`], device); +} + +/** Adb tap at (x, y). Coordinates are device pixels. */ +export function adbTap(x: number, y: number, device = DEFAULT_DEVICE): void { + adb(['shell', 'input', 'tap', String(x), String(y)], device); +} + +/** Adb text input. Spaces become %s; special chars must be escaped by caller. */ +export function adbText(text: string, device = DEFAULT_DEVICE): void { + adb(['shell', 'input', 'text', text.replace(/ /g, '%s')], device); +} + +export function adbBack(device = DEFAULT_DEVICE): void { + adb(['shell', 'input', 'keyevent', 'KEYCODE_BACK'], device); +} + +export function adbHome(device = DEFAULT_DEVICE): void { + adb(['shell', 'input', 'keyevent', 'KEYCODE_HOME'], device); +} + +/** Capture a PNG screenshot. Returns the file path for convenience. */ +export function adbScreenshot(toPath: string, device = DEFAULT_DEVICE): string { + const buf = spawnSync(ADB, ['-s', device, 'exec-out', 'screencap', '-p']); + if (buf.status !== 0) throw new Error(`screencap failed: ${buf.stderr.toString()}`); + writeFileSync(toPath, buf.stdout); + return toPath; +} + +/** + * Dump the foreground app's UI hierarchy as XML. Useful for asserting on + * native UI state when a coordinate-tap is too coarse. Works for the + * WebView too — its accessibility nodes are exposed via the same XML. + */ +export function adbDumpUi(device = DEFAULT_DEVICE): string { + // uiautomator writes to /sdcard/window_dump.xml then we pull it. + adb(['shell', 'uiautomator', 'dump', '--compressed', '/sdcard/window_dump.xml'], device); + return adb(['shell', 'cat', '/sdcard/window_dump.xml'], device); +} + +/** Wait for the UI dump to contain `text` (regex matched). Polls every 500ms. */ +export async function waitForUiText(needle: RegExp, timeoutMs = 15_000, device = DEFAULT_DEVICE): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const dump = adbDumpUi(device); + if (needle.test(dump)) return; + } catch { + // ignore transient dump failures (uiautomator can race the WebView) + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`UI text matching ${needle} not found within ${timeoutMs}ms`); +} + +export const APP = APP_ID; diff --git a/packages/mobile/tests/android.spec.ts b/packages/mobile/tests/android.spec.ts new file mode 100644 index 0000000..85dc240 --- /dev/null +++ b/packages/mobile/tests/android.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from '@playwright/test'; +import { + adbClearAppData, + adbDumpUi, + adbLaunchApp, + adbScreenshot, + waitForUiText, +} from './android-fixtures.js'; + +/** + * Smoke tests for the Capacitor Android build via adb input commands. + * + * Skipped by default; set ANDROID_E2E=1 to run. Requires: + * - ADB device at ADB_DEVICE (default `emulator-5554`) + * - APK installed: `adb install -r app-debug.apk` + * + * Headless emulator setup is documented in + * `~/.claude/.../reference_android_toolchain.md`. + * + * Why adb input rather than Playwright/CDP — see comment in + * `android-fixtures.ts`. tl;dr: WebView's CDP rejects the high-level + * libraries' attach handshakes; adb input is the lowest-common-denominator. + */ +const RUN = Boolean(process.env.ANDROID_E2E); + +test.describe('Android (emulator/device) smoke', () => { + test.skip(!RUN, 'set ANDROID_E2E=1 to run; requires running emulator + installed APK'); + test.setTimeout(60_000); + + test.beforeEach(() => { + adbClearAppData(); + adbLaunchApp(); + }); + + test('first launch shows the AddWorkspaceDialog', async () => { + // uiautomator dump exposes the WebView text content; wait for the + // dialog heading to appear. + await waitForUiText(/add an etherpad instance/i); + adbScreenshot('test-results/android-first-launch.png'); + const dump = adbDumpUi(); + expect(dump).toMatch(/add an etherpad instance/i); + expect(dump).toMatch(/etherpad url/i); + }); + + test('uiautomator can see the name + url + colour fields', async () => { + await waitForUiText(/add an etherpad instance/i); + const dump = adbDumpUi(); + // The shell renders Name + Etherpad URL labels + Colour palette. + expect(dump).toMatch(/\bName\b/); + expect(dump).toMatch(/Etherpad URL/i); + expect(dump).toMatch(/Colour/i); + }); +}); diff --git a/packages/shell/src/dialogs/AddWorkspaceDialog.tsx b/packages/shell/src/dialogs/AddWorkspaceDialog.tsx index 408e572..671ef28 100644 --- a/packages/shell/src/dialogs/AddWorkspaceDialog.tsx +++ b/packages/shell/src/dialogs/AddWorkspaceDialog.tsx @@ -12,16 +12,21 @@ export function AddWorkspaceDialog({ dismissable }: { dismissable: boolean }): R const [name, setName] = useState(settingsUserName); const [serverUrl, setServerUrl] = useState(''); const [color, setColor] = useState(PALETTE[0]!); + const [embedded, setEmbedded] = useState(false); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); - const canSubmit = Boolean(name) && Boolean(serverUrl); + // Embedded workspaces don't need a serverUrl — the local Etherpad assigns + // its own. Disable the URL field and skip the Boolean(serverUrl) gate. + const canSubmit = Boolean(name) && (embedded || Boolean(serverUrl)); const submit = async () => { setBusy(true); setError(null); try { - const ws = await ipc.workspace.add({ name, serverUrl, color }); + const ws = embedded + ? await ipc.workspace.add({ name, color, kind: 'embedded' }) + : await ipc.workspace.add({ name, serverUrl, color }); useShellStore.getState().setActiveWorkspaceId(ws.id); await ipc.window.setActiveWorkspace(ws.id); dialogActions.closeDialog(); @@ -63,9 +68,23 @@ export function AddWorkspaceDialog({ dismissable }: { dismissable: boolean }): R value={serverUrl} onChange={(e) => setServerUrl(e.target.value)} placeholder="https://pads.example.com" - required + disabled={embedded} + required={!embedded} /> +
{t.addWorkspace.colorLabel}
diff --git a/packages/shell/src/i18n/en.ts b/packages/shell/src/i18n/en.ts index b3861e3..33e9b17 100644 --- a/packages/shell/src/i18n/en.ts +++ b/packages/shell/src/i18n/en.ts @@ -29,9 +29,12 @@ export const en = { submit: 'Add', cancel: 'Cancel', probing: 'Checking server…', + embedded: 'Use a local server (runs on this computer)', + embeddedHint: "Etherpad starts up the first time you open a pad. Server-side data lives in your app's user-data folder.", errorUrl: 'Enter a valid URL.', errorUnreachable: 'Could not reach that server.', errorNotEtherpad: 'That URL does not look like Etherpad.', + errorEmbeddedUnavailable: 'Local server is not bundled in this build.', }, openPad: { title: 'Open a pad',