diff --git a/package.json b/package.json index ef48956..c060878 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "lint": "pnpm -r --workspace-concurrency=1 lint", "format": "pnpm --filter @etherpad/desktop format", "test": "pnpm -r --workspace-concurrency=1 test", + "test:vectors": "pnpm --filter @etherpad/shell exec vitest run tests/wire/vectors.spec.ts", + "test:smoke": "pnpm --filter @etherpad/shell exec vitest run tests/wire/smoke.spec.ts", "test:watch": "pnpm --filter @etherpad/desktop test:watch", "test:e2e": "pnpm --filter @etherpad/desktop test:e2e", "mobile:dev": "pnpm --filter @etherpad/mobile dev", diff --git a/packages/shell/tests/fixtures/wire-vectors.json b/packages/shell/tests/fixtures/wire-vectors.json new file mode 100644 index 0000000..6b8ff01 --- /dev/null +++ b/packages/shell/tests/fixtures/wire-vectors.json @@ -0,0 +1,7 @@ +[ + {"name":"plain-insert","initialText":"abc\n","changeset":"Z:4>3=3+3$XYZ","pool":{"numToAttrib":{},"nextNum":0},"resultText":"abcXYZ\n"}, + {"name":"plain-delete","initialText":"abcdef\n","changeset":"Z:7<3=1-3$","pool":{"numToAttrib":{},"nextNum":0},"resultText":"aef\n"}, + {"name":"formatted-insert","initialText":"abc\n","changeset":"Z:4>4=3*0+4$bold","pool":{"numToAttrib":{"0":["bold","true"]},"nextNum":1},"resultText":"abcbold\n"}, + {"name":"multiline-insert","initialText":"abc\n","changeset":"Z:4>8=3|2+8$one\ntwo\n","pool":{"numToAttrib":{},"nextNum":0},"resultText":"abcone\ntwo\n\n"}, + {"name":"attrib-reuse","initialText":"abc\n","changeset":"Z:4>2*0+1=3*0+1$AB","pool":{"numToAttrib":{"0":["bold","true"]},"nextNum":1},"resultText":"AabcB\n"} +] diff --git a/packages/shell/tests/wire/smoke.spec.ts b/packages/shell/tests/wire/smoke.spec.ts new file mode 100644 index 0000000..bcd6ad6 --- /dev/null +++ b/packages/shell/tests/wire/smoke.spec.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeAll } from 'vitest'; + +/** + * Downstream wire-compatibility — live smoke test (headless-light). + * + * Phase 2 of ether/etherpad#7923. This proves the desktop/mobile shell could + * talk to a real Etherpad server *without* booting Electron. The full Electron + * e2e stays in this repo's own CI; this gate is deliberately headless-light — + * a plain HTTP roundtrip against the contract the shell depends on: + * 1. create a pad via the HTTP API, + * 2. fetch `/p/` (the exact URL the shell would load in its webview) + * and assert HTTP 200, + * 3. read the pad text back via the API to confirm content. + * + * Env contract: + * - ETHERPAD_SMOKE_URL server base URL (default http://localhost:9003) + * - ETHERPAD_SMOKE_APIKEY HTTP API key (required to actually run) + * + * Unless BOTH a reachable server and an API key are present, this SKIPS + * cleanly — it must never fail CI for lack of test infrastructure. + */ + +const BASE = (process.env.ETHERPAD_SMOKE_URL || 'http://localhost:9003').replace(/\/$/, ''); +const APIKEY = process.env.ETHERPAD_SMOKE_APIKEY || ''; +const API_VERSION = '1.2.13'; + +async function reachable(): Promise { + try { + const res = await fetch(`${BASE}/api`, { signal: AbortSignal.timeout(2000) }); + return res.ok; + } catch { + return false; + } +} + +interface ApiResponse { + code: number; + message: string; + data: T; +} + +async function api(fn: string, params: Record): Promise> { + const qs = new URLSearchParams({ apikey: APIKEY, ...params }); + const res = await fetch(`${BASE}/api/${API_VERSION}/${fn}?${qs.toString()}`, { + signal: AbortSignal.timeout(5000), + }); + expect(res.status).toBe(200); + return (await res.json()) as ApiResponse; +} + +let serverUp = false; + +beforeAll(async () => { + // No key means the smoke can't run, so don't waste a network call + timeout + // probing reachability — it can't change the (skip) outcome. + if (APIKEY) serverUp = await reachable(); +}); + +describe('live server smoke (shell HTTP contract)', () => { + it('completes a create -> fetch /p/ -> getText roundtrip', async () => { + if (!serverUp || !APIKEY) { + const why = !APIKEY ? `ETHERPAD_SMOKE_APIKEY not set` : `no Etherpad reachable at ${BASE}`; + console.warn( + `[smoke] ${why} — skipping live smoke test. ` + + `Set ETHERPAD_SMOKE_URL + ETHERPAD_SMOKE_APIKEY to run it.`, + ); + return; // skip cleanly: never fail the gate without a reachable server + key + } + + const padID = `phase2-smoke-${Date.now()}`; + const text = 'phase2 wire-compat smoke\n'; + + const created = await api('createPad', { padID, text }); + expect(created.code, created.message).toBe(0); + + try { + // The exact URL the shell loads in its webview. + const padRes = await fetch(`${BASE}/p/${encodeURIComponent(padID)}`, { + signal: AbortSignal.timeout(5000), + }); + expect(padRes.status).toBe(200); + + const got = await api<{ text: string }>('getText', { padID }); + expect(got.code, got.message).toBe(0); + // Etherpad guarantees a pad's text ends with exactly one trailing + // newline, so setting "X\n" reads back as "X\n\n". Normalize the + // trailing newline(s) on both sides before comparing. + const trimTrailing = (s: string) => s.replace(/\n*$/, '\n'); + expect(trimTrailing(got.data.text)).toBe(trimTrailing(text)); + } finally { + // Guaranteed cleanup even if an assertion above throws; swallow delete + // errors so cleanup never masks the real failure. + await api('deletePad', { padID }).catch(() => {}); + } + }); +}); diff --git a/packages/shell/tests/wire/vectors.spec.ts b/packages/shell/tests/wire/vectors.spec.ts new file mode 100644 index 0000000..df722ea --- /dev/null +++ b/packages/shell/tests/wire/vectors.spec.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve, dirname } from 'node:path'; + +/** + * Downstream wire-compatibility — fixture-integrity guard. + * + * Phase 2 of ether/etherpad#7923. Phase 1 added a canonical wire-format + * fixture (`wire-vectors.json`) that every Etherpad client must decode + * identically. The Rust/CLI clients re-implement changeset decoding and + * therefore run these vectors through their own decoder. + * + * The desktop/mobile apps are thin shells: they load an Etherpad server URL + * and embed CORE'S editor (Ace) inside a webview, so there is NO local + * changeset decoder to exercise. This test is therefore a *fixture-integrity + * guard* rather than a decode test — it asserts the shape/contract of the + * vendored fixture so that: + * - a malformed or empty fixture injected into this repo fails loudly, and + * - the contract the embedded editor relies on is documented in-repo. + * + * The fixture path is overridable via `ETHERPAD_WIRE_VECTORS`, defaulting to + * the vendored copy next to this test. + */ + +const here = dirname(fileURLToPath(import.meta.url)); +const DEFAULT_VECTORS_PATH = resolve(here, '../fixtures/wire-vectors.json'); +const VECTORS_PATH = process.env.ETHERPAD_WIRE_VECTORS || DEFAULT_VECTORS_PATH; + +interface WireVector { + name: string; + initialText: string; + changeset: string; + pool: { numToAttrib: Record; nextNum: number }; + resultText: string; +} + +function loadVectors(): WireVector[] { + const raw = readFileSync(VECTORS_PATH, 'utf8'); + return JSON.parse(raw) as WireVector[]; +} + +describe('wire-vectors fixture integrity', () => { + const vectors = loadVectors(); + + it('is a non-empty array', () => { + expect(Array.isArray(vectors)).toBe(true); + expect(vectors.length).toBeGreaterThan(0); + }); + + it('has unique vector names', () => { + const names = vectors.map((v) => v.name); + expect(new Set(names).size).toBe(names.length); + }); + + describe.each(vectors.map((v) => [v.name, v] as const))('vector %s', (_name, v) => { + it('has all five fields with the right types', () => { + expect(typeof v.name).toBe('string'); + expect(v.name.length).toBeGreaterThan(0); + expect(typeof v.initialText).toBe('string'); + expect(typeof v.changeset).toBe('string'); + expect(v.changeset.length).toBeGreaterThan(0); + expect(typeof v.resultText).toBe('string'); + expect(typeof v.pool).toBe('object'); + expect(v.pool).not.toBeNull(); + }); + + it('changeset uses the canonical Z: header', () => { + expect(v.changeset.startsWith('Z:')).toBe(true); + }); + + it('pool.numToAttrib is a plain object and pool.nextNum is a number', () => { + expect(typeof v.pool.numToAttrib).toBe('object'); + expect(v.pool.numToAttrib).not.toBeNull(); + expect(Array.isArray(v.pool.numToAttrib)).toBe(false); + expect(typeof v.pool.nextNum).toBe('number'); + expect(Number.isInteger(v.pool.nextNum)).toBe(true); + expect(v.pool.nextNum).toBeGreaterThanOrEqual(0); + }); + + it('initialText and resultText are non-empty and newline-terminated', () => { + expect(v.initialText.length).toBeGreaterThan(0); + expect(v.initialText.endsWith('\n')).toBe(true); + expect(v.resultText.length).toBeGreaterThan(0); + expect(v.resultText.endsWith('\n')).toBe(true); + }); + }); +});