Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions packages/shell/tests/fixtures/wire-vectors.json
Original file line number Diff line number Diff line change
@@ -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"}
]
96 changes: 96 additions & 0 deletions packages/shell/tests/wire/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -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/<pad>` (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<boolean> {
try {
const res = await fetch(`${BASE}/api`, { signal: AbortSignal.timeout(2000) });
return res.ok;
} catch {
return false;
}
}

interface ApiResponse<T> {
code: number;
message: string;
data: T;
}

async function api<T>(fn: string, params: Record<string, string>): Promise<ApiResponse<T>> {
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<T>;
}

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/<pad> -> 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<null>('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<null>('deletePad', { padID }).catch(() => {});
}
});
});
88 changes: 88 additions & 0 deletions packages/shell/tests/wire/vectors.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>; 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);
});
});
});
Loading