Skip to content

Commit ab83da6

Browse files
JohnMcLearclaude
andauthored
test: downstream wire-compat (vectors + smoke) (#78)
* test: downstream wire-compat (vectors + smoke) Phase 2 of ether/etherpad#7923. Phase 1 added a canonical wire-format fixture that all Etherpad clients must decode identically. The desktop/mobile apps are thin shells: they embed core's Ace editor in a webview and load a server URL, so there is no local changeset decoder. The vectors test is therefore a fixture-integrity guard (shape/contract of the vendored wire-vectors.json), not a decode test. The smoke test is a headless-light HTTP roundtrip against the server contract the shell depends on; the full Electron e2e stays in this repo's own CI. - packages/shell/tests/fixtures/wire-vectors.json: vendored canonical fixture (overridable via ETHERPAD_WIRE_VECTORS). - packages/shell/tests/wire/vectors.spec.ts: asserts every record has the 5 fields with correct types; pool.numToAttrib is a plain object, nextNum a non-negative integer; initial/resultText non-empty and \n-terminated. - packages/shell/tests/wire/smoke.spec.ts: reads ETHERPAD_SMOKE_URL (default http://localhost:9003) + ETHERPAD_SMOKE_APIKEY; skips cleanly unless both a reachable server and a key are present. When live: create pad via HTTP API, fetch /p/<pad> (the URL the shell loads) -> 200, getText to confirm content. - root package.json: add test:vectors / test:smoke scripts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: address Qodo review on smoke test - Skip the reachability probe entirely when ETHERPAD_SMOKE_APIKEY is unset (no key => the test always skips, so the probe + timeout was wasted work). - Wrap the create -> fetch -> getText roundtrip in try/finally so the pad is always deleted even when an assertion throws; swallow delete errors so cleanup never masks the real failure. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: normalize trailing newline in smoke getText assertion Etherpad guarantees a pad ends with exactly one trailing newline, so setText("X\n") reads back as "X\n\n". Compare normalized text instead of exact equality. Verified live against a real core on :9013. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a75d3d8 commit ab83da6

4 files changed

Lines changed: 193 additions & 0 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
"lint": "pnpm -r --workspace-concurrency=1 lint",
2727
"format": "pnpm --filter @etherpad/desktop format",
2828
"test": "pnpm -r --workspace-concurrency=1 test",
29+
"test:vectors": "pnpm --filter @etherpad/shell exec vitest run tests/wire/vectors.spec.ts",
30+
"test:smoke": "pnpm --filter @etherpad/shell exec vitest run tests/wire/smoke.spec.ts",
2931
"test:watch": "pnpm --filter @etherpad/desktop test:watch",
3032
"test:e2e": "pnpm --filter @etherpad/desktop test:e2e",
3133
"mobile:dev": "pnpm --filter @etherpad/mobile dev",
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[
2+
{"name":"plain-insert","initialText":"abc\n","changeset":"Z:4>3=3+3$XYZ","pool":{"numToAttrib":{},"nextNum":0},"resultText":"abcXYZ\n"},
3+
{"name":"plain-delete","initialText":"abcdef\n","changeset":"Z:7<3=1-3$","pool":{"numToAttrib":{},"nextNum":0},"resultText":"aef\n"},
4+
{"name":"formatted-insert","initialText":"abc\n","changeset":"Z:4>4=3*0+4$bold","pool":{"numToAttrib":{"0":["bold","true"]},"nextNum":1},"resultText":"abcbold\n"},
5+
{"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"},
6+
{"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"}
7+
]
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { readFileSync } from 'node:fs';
3+
import { fileURLToPath } from 'node:url';
4+
import { resolve, dirname } from 'node:path';
5+
6+
/**
7+
* Downstream wire-compatibility — fixture-integrity guard.
8+
*
9+
* Phase 2 of ether/etherpad#7923. Phase 1 added a canonical wire-format
10+
* fixture (`wire-vectors.json`) that every Etherpad client must decode
11+
* identically. The Rust/CLI clients re-implement changeset decoding and
12+
* therefore run these vectors through their own decoder.
13+
*
14+
* The desktop/mobile apps are thin shells: they load an Etherpad server URL
15+
* and embed CORE'S editor (Ace) inside a webview, so there is NO local
16+
* changeset decoder to exercise. This test is therefore a *fixture-integrity
17+
* guard* rather than a decode test — it asserts the shape/contract of the
18+
* vendored fixture so that:
19+
* - a malformed or empty fixture injected into this repo fails loudly, and
20+
* - the contract the embedded editor relies on is documented in-repo.
21+
*
22+
* The fixture path is overridable via `ETHERPAD_WIRE_VECTORS`, defaulting to
23+
* the vendored copy next to this test.
24+
*/
25+
26+
const here = dirname(fileURLToPath(import.meta.url));
27+
const DEFAULT_VECTORS_PATH = resolve(here, '../fixtures/wire-vectors.json');
28+
const VECTORS_PATH = process.env.ETHERPAD_WIRE_VECTORS || DEFAULT_VECTORS_PATH;
29+
30+
interface WireVector {
31+
name: string;
32+
initialText: string;
33+
changeset: string;
34+
pool: { numToAttrib: Record<string, unknown>; nextNum: number };
35+
resultText: string;
36+
}
37+
38+
function loadVectors(): WireVector[] {
39+
const raw = readFileSync(VECTORS_PATH, 'utf8');
40+
return JSON.parse(raw) as WireVector[];
41+
}
42+
43+
describe('wire-vectors fixture integrity', () => {
44+
const vectors = loadVectors();
45+
46+
it('is a non-empty array', () => {
47+
expect(Array.isArray(vectors)).toBe(true);
48+
expect(vectors.length).toBeGreaterThan(0);
49+
});
50+
51+
it('has unique vector names', () => {
52+
const names = vectors.map((v) => v.name);
53+
expect(new Set(names).size).toBe(names.length);
54+
});
55+
56+
describe.each(vectors.map((v) => [v.name, v] as const))('vector %s', (_name, v) => {
57+
it('has all five fields with the right types', () => {
58+
expect(typeof v.name).toBe('string');
59+
expect(v.name.length).toBeGreaterThan(0);
60+
expect(typeof v.initialText).toBe('string');
61+
expect(typeof v.changeset).toBe('string');
62+
expect(v.changeset.length).toBeGreaterThan(0);
63+
expect(typeof v.resultText).toBe('string');
64+
expect(typeof v.pool).toBe('object');
65+
expect(v.pool).not.toBeNull();
66+
});
67+
68+
it('changeset uses the canonical Z: header', () => {
69+
expect(v.changeset.startsWith('Z:')).toBe(true);
70+
});
71+
72+
it('pool.numToAttrib is a plain object and pool.nextNum is a number', () => {
73+
expect(typeof v.pool.numToAttrib).toBe('object');
74+
expect(v.pool.numToAttrib).not.toBeNull();
75+
expect(Array.isArray(v.pool.numToAttrib)).toBe(false);
76+
expect(typeof v.pool.nextNum).toBe('number');
77+
expect(Number.isInteger(v.pool.nextNum)).toBe(true);
78+
expect(v.pool.nextNum).toBeGreaterThanOrEqual(0);
79+
});
80+
81+
it('initialText and resultText are non-empty and newline-terminated', () => {
82+
expect(v.initialText.length).toBeGreaterThan(0);
83+
expect(v.initialText.endsWith('\n')).toBe(true);
84+
expect(v.resultText.length).toBeGreaterThan(0);
85+
expect(v.resultText.endsWith('\n')).toBe(true);
86+
});
87+
});
88+
});

0 commit comments

Comments
 (0)