|
| 1 | +import { describe, it, expect, beforeAll } from 'vitest'; |
| 2 | + |
| 3 | +/** |
| 4 | + * Downstream wire-compatibility — live smoke test (headless-light). |
| 5 | + * |
| 6 | + * Phase 2 of ether/etherpad#7923. This proves the desktop/mobile shell could |
| 7 | + * talk to a real Etherpad server *without* booting Electron. The full Electron |
| 8 | + * e2e stays in this repo's own CI; this gate is deliberately headless-light — |
| 9 | + * a plain HTTP roundtrip against the contract the shell depends on: |
| 10 | + * 1. create a pad via the HTTP API, |
| 11 | + * 2. fetch `/p/<pad>` (the exact URL the shell would load in its webview) |
| 12 | + * and assert HTTP 200, |
| 13 | + * 3. read the pad text back via the API to confirm content. |
| 14 | + * |
| 15 | + * Env contract: |
| 16 | + * - ETHERPAD_SMOKE_URL server base URL (default http://localhost:9003) |
| 17 | + * - ETHERPAD_SMOKE_APIKEY HTTP API key (required to actually run) |
| 18 | + * |
| 19 | + * Unless BOTH a reachable server and an API key are present, this SKIPS |
| 20 | + * cleanly — it must never fail CI for lack of test infrastructure. |
| 21 | + */ |
| 22 | + |
| 23 | +const BASE = (process.env.ETHERPAD_SMOKE_URL || 'http://localhost:9003').replace(/\/$/, ''); |
| 24 | +const APIKEY = process.env.ETHERPAD_SMOKE_APIKEY || ''; |
| 25 | +const API_VERSION = '1.2.13'; |
| 26 | + |
| 27 | +async function reachable(): Promise<boolean> { |
| 28 | + try { |
| 29 | + const res = await fetch(`${BASE}/api`, { signal: AbortSignal.timeout(2000) }); |
| 30 | + return res.ok; |
| 31 | + } catch { |
| 32 | + return false; |
| 33 | + } |
| 34 | +} |
| 35 | + |
| 36 | +interface ApiResponse<T> { |
| 37 | + code: number; |
| 38 | + message: string; |
| 39 | + data: T; |
| 40 | +} |
| 41 | + |
| 42 | +async function api<T>(fn: string, params: Record<string, string>): Promise<ApiResponse<T>> { |
| 43 | + const qs = new URLSearchParams({ apikey: APIKEY, ...params }); |
| 44 | + const res = await fetch(`${BASE}/api/${API_VERSION}/${fn}?${qs.toString()}`, { |
| 45 | + signal: AbortSignal.timeout(5000), |
| 46 | + }); |
| 47 | + expect(res.status).toBe(200); |
| 48 | + return (await res.json()) as ApiResponse<T>; |
| 49 | +} |
| 50 | + |
| 51 | +let serverUp = false; |
| 52 | + |
| 53 | +beforeAll(async () => { |
| 54 | + // No key means the smoke can't run, so don't waste a network call + timeout |
| 55 | + // probing reachability — it can't change the (skip) outcome. |
| 56 | + if (APIKEY) serverUp = await reachable(); |
| 57 | +}); |
| 58 | + |
| 59 | +describe('live server smoke (shell HTTP contract)', () => { |
| 60 | + it('completes a create -> fetch /p/<pad> -> getText roundtrip', async () => { |
| 61 | + if (!serverUp || !APIKEY) { |
| 62 | + const why = !APIKEY ? `ETHERPAD_SMOKE_APIKEY not set` : `no Etherpad reachable at ${BASE}`; |
| 63 | + console.warn( |
| 64 | + `[smoke] ${why} — skipping live smoke test. ` + |
| 65 | + `Set ETHERPAD_SMOKE_URL + ETHERPAD_SMOKE_APIKEY to run it.`, |
| 66 | + ); |
| 67 | + return; // skip cleanly: never fail the gate without a reachable server + key |
| 68 | + } |
| 69 | + |
| 70 | + const padID = `phase2-smoke-${Date.now()}`; |
| 71 | + const text = 'phase2 wire-compat smoke\n'; |
| 72 | + |
| 73 | + const created = await api<null>('createPad', { padID, text }); |
| 74 | + expect(created.code, created.message).toBe(0); |
| 75 | + |
| 76 | + try { |
| 77 | + // The exact URL the shell loads in its webview. |
| 78 | + const padRes = await fetch(`${BASE}/p/${encodeURIComponent(padID)}`, { |
| 79 | + signal: AbortSignal.timeout(5000), |
| 80 | + }); |
| 81 | + expect(padRes.status).toBe(200); |
| 82 | + |
| 83 | + const got = await api<{ text: string }>('getText', { padID }); |
| 84 | + expect(got.code, got.message).toBe(0); |
| 85 | + // Etherpad guarantees a pad's text ends with exactly one trailing |
| 86 | + // newline, so setting "X\n" reads back as "X\n\n". Normalize the |
| 87 | + // trailing newline(s) on both sides before comparing. |
| 88 | + const trimTrailing = (s: string) => s.replace(/\n*$/, '\n'); |
| 89 | + expect(trimTrailing(got.data.text)).toBe(trimTrailing(text)); |
| 90 | + } finally { |
| 91 | + // Guaranteed cleanup even if an assertion above throws; swallow delete |
| 92 | + // errors so cleanup never masks the real failure. |
| 93 | + await api<null>('deletePad', { padID }).catch(() => {}); |
| 94 | + } |
| 95 | + }); |
| 96 | +}); |
0 commit comments