From 860ab68c04262898076c256f09b5deff1ec6c5af Mon Sep 17 00:00:00 2001 From: John McLear Date: Tue, 9 Jun 2026 11:40:58 +0100 Subject: [PATCH 01/14] test: downstream client compatibility gate (Phase 1) (#7923) * docs: design for downstream client compatibility tests Co-Authored-By: Claude Opus 4.8 (1M context) * docs: Phase 1 implementation plan for downstream compat tests Co-Authored-By: Claude Opus 4.8 (1M context) * test(downstream): add golden wire-vector generator Co-Authored-By: Claude Opus 4.8 (1M context) * test(downstream): add committed golden wire-vectors fixture Co-Authored-By: Claude Opus 4.8 (1M context) * test(downstream): assert wire-vectors fixture stability + consistency Co-Authored-By: Claude Opus 4.8 (1M context) * test(downstream): pin socket.io handshake + USER_CHANGES sequence Co-Authored-By: Claude Opus 4.8 (1M context) * test(downstream): snapshot client-facing HTTP API shapes Co-Authored-By: Claude Opus 4.8 (1M context) * test(downstream): add client manifest (entries disabled pending Phase 2) Co-Authored-By: Claude Opus 4.8 (1M context) * ci(downstream): add downstream-smoke workflow (boot/self-check/teardown + manifest scaffold) Co-Authored-By: Claude Opus 4.8 (1M context) * ci(downstream): validate settings rewrite + ignore docs/** (Qodo) Fail fast if the template's port/auth literals drift so a no-op sed can't silently boot the smoke server on the wrong port/auth. Also ignore docs/** (not just doc/**) so docs-only PRs don't trigger the boot job. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/downstream-smoke.yml | 111 +++ ...9-downstream-client-compat-tests-phase1.md | 633 ++++++++++++++++++ ...9-downstream-client-compat-tests-design.md | 154 +++++ src/package.json | 1 + .../specs/downstream/generate-vectors.ts | 77 +++ .../backend/specs/downstream/wire-http-api.ts | 54 ++ .../specs/downstream/wire-socket-sequence.ts | 60 ++ .../backend/specs/downstream/wire-vectors.ts | 33 + src/tests/downstream/clients.json | 29 + src/tests/fixtures/wire-vectors.json | 62 ++ 10 files changed, 1214 insertions(+) create mode 100644 .github/workflows/downstream-smoke.yml create mode 100644 docs/superpowers/plans/2026-06-09-downstream-client-compat-tests-phase1.md create mode 100644 docs/superpowers/specs/2026-06-09-downstream-client-compat-tests-design.md create mode 100644 src/tests/backend/specs/downstream/generate-vectors.ts create mode 100644 src/tests/backend/specs/downstream/wire-http-api.ts create mode 100644 src/tests/backend/specs/downstream/wire-socket-sequence.ts create mode 100644 src/tests/backend/specs/downstream/wire-vectors.ts create mode 100644 src/tests/downstream/clients.json create mode 100644 src/tests/fixtures/wire-vectors.json diff --git a/.github/workflows/downstream-smoke.yml b/.github/workflows/downstream-smoke.yml new file mode 100644 index 000000000..a3049d0c1 --- /dev/null +++ b/.github/workflows/downstream-smoke.yml @@ -0,0 +1,111 @@ +name: Downstream smoke + +# Boots a real Etherpad from the PR and verifies the separate downstream clients +# (Rust terminal editor, Node CLI, desktop/mobile) still round-trip against it. +# Phase 1 lands the boot/healthcheck/self-check/teardown harness + manifest; the +# per-client matrix activates as each client flips `enabled:true` in clients.json. + +on: + pull_request: + paths-ignore: + - "doc/**" + - "docs/**" + schedule: + - cron: '0 4 * * *' # nightly against the default branch + +permissions: + contents: read + +jobs: + smoke: + name: Boot + downstream clients + runs-on: ubuntu-latest + timeout-minutes: 25 + env: + PNPM_HOME: ~/.pnpm-store + APIKEY: downstream-smoke-key + steps: + - name: Checkout core (PR) + uses: actions/checkout@v6 + + - uses: actions/cache@v5 + name: Cache pnpm store + with: + path: ${{ env.PNPM_HOME }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - uses: pnpm/action-setup@v6 + name: Install pnpm + with: + run_install: false + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm i --frozen-lockfile + + - name: Boot Etherpad on :9003 (apikey auth) + run: | + # The template ships a literal "port": 9001 and sso auth; rewrite both. + # (PORT env is ignored once the settings file specifies a port.) + sed -e 's#"port": 9001,#"port": 9003,#' \ + -e 's#${AUTHENTICATION_METHOD:sso}#apikey#' \ + settings.json.template > settings.json + # Fail fast if the template format drifted and sed silently no-op'd. + grep -q '"port": 9003,' settings.json \ + || { echo "::error::port rewrite failed — settings.json.template format changed"; exit 1; } + grep -q '"authenticationMethod": "apikey"' settings.json \ + || { echo "::error::auth rewrite failed — settings.json.template format changed"; exit 1; } + printf '%s' "$APIKEY" > APIKEY.txt + pnpm run prod > /tmp/ep.log 2>&1 & + echo $! > /tmp/ep.pid + echo "booted pid $(cat /tmp/ep.pid)" + + - name: Wait for healthcheck + run: | + for i in $(seq 1 60); do + if curl -fsS "http://localhost:9003/api/" >/dev/null 2>&1; then + echo "server up after ${i} tries"; exit 0 + fi + sleep 2 + done + echo "::error::server did not come up"; tail -50 /tmp/ep.log; exit 1 + + - name: Self-check — authenticated create + read roundtrip + run: | + K="$APIKEY" + curl -fsS "http://localhost:9003/api/1/createPad?apikey=${K}&padID=smoke&text=hi%0A" | tee /tmp/create.json + grep -q '"code":0' /tmp/create.json + curl -fsS "http://localhost:9003/api/1/getText?apikey=${K}&padID=smoke" | tee /tmp/get.json + grep -q '"text":"hi' /tmp/get.json + + - name: Generate canonical wire-vectors + run: cd src && pnpm run vectors:gen + + - name: Run enabled downstream clients + run: | + ENABLED=$(node -e 'const c=require("./src/tests/downstream/clients.json").filter(x=>x.enabled); process.stdout.write(JSON.stringify(c))') + if [ "$ENABLED" = "[]" ]; then + echo "No downstream clients enabled yet (Phase 1 harness only). Skipping." + exit 0 + fi + # Phase 2 implements per-`kind` clone @ pinned ref + toolchain setup + + # vector injection (cp src/tests/fixtures/wire-vectors.json into the + # client) + `vectorTest` + `smokeCmd` against http://localhost:9003, + # iterating the entries in $ENABLED. Until a client is enabled this is + # a no-op so the harness lands green on its own. + echo "$ENABLED" + + - name: Teardown (by PID, never pkill) + if: always() + run: | + if [ -f /tmp/ep.pid ]; then + kill "$(cat /tmp/ep.pid)" 2>/dev/null || true + echo "killed $(cat /tmp/ep.pid)" + fi diff --git a/docs/superpowers/plans/2026-06-09-downstream-client-compat-tests-phase1.md b/docs/superpowers/plans/2026-06-09-downstream-client-compat-tests-phase1.md new file mode 100644 index 000000000..778c9a3d0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-downstream-client-compat-tests-phase1.md @@ -0,0 +1,633 @@ +# Downstream Client Compatibility Tests — Phase 1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a core-side compatibility gate (golden-vector + socket-sequence + HTTP-API-shape contract tests, plus a downstream-smoke workflow scaffold) so a PR against `develop` detects changes that would break the separate downstream clients. + +**Architecture:** Layer A — hermetic contract tests run inside core's existing mocha backend suite, anchored to a committed `wire-vectors.json` fixture generated from core's own `Changeset`/`AttributePool`. Layer B — a `downstream-smoke.yml` workflow that boots a real Etherpad on :9003, proves the boot→healthcheck→teardown cycle with a self-check, and is ready to matrix over a `clients.json` manifest as clients register in Phase 2. + +**Tech Stack:** TypeScript, mocha (`--import=tsx`), `assert/strict`, supertest + socket.io-client via `common.ts` helpers, GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-06-09-downstream-client-compat-tests-design.md` + +**Scope note:** This plan is **Phase 1 only** (all changes in `ether/etherpad`). Phase 2 (wiring each client repo's `test:vectors` + smoke and registering it in the manifest) is one separate plan/PR per client repo and is out of scope here. + +--- + +## File Structure + +- Create `src/tests/backend/specs/downstream/generate-vectors.ts` — pure module exporting `generateVectors(): WireVector[]`; the single source of truth for the canonical wire fixtures. Also runnable as a CLI to (re)write the fixture. +- Create `src/tests/fixtures/wire-vectors.json` — committed canonical fixture (generated, never hand-edited). +- Create `src/tests/backend/specs/downstream/wire-vectors.ts` — mocha spec asserting the committed fixture is stable and self-consistent. +- Create `src/tests/backend/specs/downstream/wire-socket-sequence.ts` — mocha spec asserting the socket.io handshake + USER_CHANGES→ACCEPT_COMMIT sequence/shapes. +- Create `src/tests/backend/specs/downstream/wire-http-api.ts` — mocha spec snapshotting client-facing HTTP API response shapes. +- Create `src/tests/downstream/clients.json` — manifest of downstream clients (data; entries `enabled:false` until their Phase-2 smoke lands). +- Create `.github/workflows/downstream-smoke.yml` — boot/healthcheck/self-check/teardown + manifest matrix scaffold. +- Modify `src/package.json` — add `vectors:gen` script. + +All backend specs live under `specs/downstream/` so the existing `mocha ... --recursive tests/backend/specs` glob picks them up with zero config change. + +--- + +## Task 1: Golden-vector generator module + +**Files:** +- Create: `src/tests/backend/specs/downstream/generate-vectors.ts` +- Test: `src/tests/backend/specs/downstream/wire-vectors.ts` (created in Task 3; this task is tested via Task 2's run) + +- [ ] **Step 1: Write the generator module** + +Create `src/tests/backend/specs/downstream/generate-vectors.ts`: + +```typescript +'use strict'; + +/** + * Single source of truth for the downstream wire-compatibility fixtures. + * + * Each vector is a self-contained changeset application: given `initialAText` + * and `pool`, applying `changeset` yields `resultAText`. Downstream clients + * (which reimplement Etherpad's changeset/attribpool decoders) consume the + * exact same JSON and must reproduce `resultAText`. See the Phase 1 plan. + * + * Runnable as a CLI to (re)write src/tests/fixtures/wire-vectors.json: + * pnpm run vectors:gen + */ + +import Changeset from '../../../../static/js/Changeset'; +import AttributePool from '../../../../static/js/AttributePool'; + +export type WireVector = { + name: string; + initialText: string; + changeset: string; + pool: ReturnType; + resultText: string; +}; + +const vector = ( + name: string, + initialText: string, + build: (pool: AttributePool) => string, +): WireVector => { + const pool = new AttributePool(); + const changeset = build(pool); + Changeset.checkRep(changeset); + return { + name, + initialText, + changeset, + pool: pool.toJsonable(), + resultText: Changeset.applyToText(changeset, initialText), + }; +}; + +export const generateVectors = (): WireVector[] => [ + vector('plain-insert', 'abc\n', () => + Changeset.makeSplice('abc\n', 3, 0, 'XYZ')), + + vector('plain-delete', 'abcdef\n', () => + Changeset.makeSplice('abcdef\n', 1, 3, '')), + + vector('formatted-insert', 'abc\n', (pool) => + Changeset.makeSplice('abc\n', 3, 0, 'bold', [['bold', 'true']], pool)), + + vector('multiline-insert', 'abc\n', () => + Changeset.makeSplice('abc\n', 3, 0, 'one\ntwo\n')), + + vector('attrib-reuse', 'abc\n', (pool) => { + // Two formatted inserts sharing one pool entry exercises pool index reuse. + const cs1 = Changeset.makeSplice('abc\n', 0, 0, 'A', [['bold', 'true']], pool); + const mid = Changeset.applyToText(cs1, 'abc\n'); + const cs2 = Changeset.makeSplice(mid, mid.length - 1, 0, 'B', [['bold', 'true']], pool); + return Changeset.compose(cs1, cs2, pool); + }), +]; + +// CLI entry: write the canonical fixture to disk. +if (require.main === module) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const fs = require('fs'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const path = require('path'); + const out = path.join(__dirname, '../../../fixtures/wire-vectors.json'); + fs.writeFileSync(out, `${JSON.stringify(generateVectors(), null, 2)}\n`); + // eslint-disable-next-line no-console + console.log(`wrote ${out}`); +} +``` + +- [ ] **Step 2: Add the `vectors:gen` script** + +In `src/package.json`, add to `"scripts"` (alongside the existing `test` entry): + +```json +"vectors:gen": "tsx tests/backend/specs/downstream/generate-vectors.ts", +``` + +- [ ] **Step 3: Sanity-run the generator (no fixture committed yet)** + +Run from `src/`: +```bash +cd src && pnpm run vectors:gen +``` +Expected: prints `wrote .../src/tests/fixtures/wire-vectors.json` and the file exists with 5 vectors. Verify each has non-empty `changeset` and a `resultText` that differs from `initialText`. + +- [ ] **Step 4: Commit** + +```bash +git add src/tests/backend/specs/downstream/generate-vectors.ts src/package.json +git commit -m "test(downstream): add golden wire-vector generator" +``` + +--- + +## Task 2: Commit the generated fixture + +**Files:** +- Create: `src/tests/fixtures/wire-vectors.json` + +- [ ] **Step 1: Generate the fixture** + +Run from `src/`: +```bash +cd src && pnpm run vectors:gen +``` +Expected: `src/tests/fixtures/wire-vectors.json` written. + +- [ ] **Step 2: Eyeball the fixture** + +Open `src/tests/fixtures/wire-vectors.json`. Confirm it is a JSON array of 5 objects, each with keys `name, initialText, changeset, pool, resultText`. The `pool` for `plain-insert`/`plain-delete`/`multiline-insert` has empty `numToAttrib`; `formatted-insert` and `attrib-reuse` contain a `bold,true` entry. + +- [ ] **Step 3: Commit** + +```bash +git add src/tests/fixtures/wire-vectors.json +git commit -m "test(downstream): add committed golden wire-vectors fixture" +``` + +--- + +## Task 3: Fixture stability + self-consistency spec + +**Files:** +- Create: `src/tests/backend/specs/downstream/wire-vectors.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/tests/backend/specs/downstream/wire-vectors.ts`: + +```typescript +'use strict'; + +/** + * Guards the downstream wire-format contract: + * - the committed fixture exactly matches a fresh regeneration (any drift is a + * deliberate wire change and must be re-generated + reviewed in the same PR), and + * - every vector is internally consistent under core's own Changeset engine. + */ + +const assert = require('assert').strict; +import fs from 'fs'; +import path from 'path'; +import Changeset from '../../../../static/js/Changeset'; +import {generateVectors} from './generate-vectors'; + +const fixturePath = path.join(__dirname, '../../../fixtures/wire-vectors.json'); + +describe(__filename, function () { + it('committed fixture matches a fresh regeneration', function () { + const committed = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); + const fresh = generateVectors(); + assert.deepEqual(committed, fresh, + 'wire-vectors.json is stale — run `pnpm run vectors:gen` and commit the result'); + }); + + it('every vector applies to its result under core Changeset', function () { + for (const v of generateVectors()) { + Changeset.checkRep(v.changeset); + assert.equal(Changeset.applyToText(v.changeset, v.initialText), v.resultText, + `vector ${v.name} result mismatch`); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it passes (fixture already committed)** + +Run from `src/`: +```bash +cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-vectors.ts +``` +Expected: 2 passing. + +- [ ] **Step 3: Prove the guard bites (temporary edit)** + +Hand-edit one `resultText` in `src/tests/fixtures/wire-vectors.json`, re-run the command above. +Expected: the "committed fixture matches a fresh regeneration" test FAILS. Then `git checkout src/tests/fixtures/wire-vectors.json` to restore. + +- [ ] **Step 4: Commit** + +```bash +git add src/tests/backend/specs/downstream/wire-vectors.ts +git commit -m "test(downstream): assert wire-vectors fixture stability + consistency" +``` + +--- + +## Task 4: Socket message-sequence spec + +**Files:** +- Create: `src/tests/backend/specs/downstream/wire-socket-sequence.ts` + +Reference for helpers: `src/tests/backend/specs/messages.ts` (`common.connect`, `common.handshake`) and `src/tests/backend/common.ts`. + +- [ ] **Step 1: Write the failing test** + +Create `src/tests/backend/specs/downstream/wire-socket-sequence.ts`: + +```typescript +'use strict'; + +/** + * Pins the socket.io message sequence + shapes that every realtime client + * depends on: handshake -> CLIENT_VARS, then USER_CHANGES -> ACCEPT_COMMIT. + * A change here is a wire-protocol change that will break downstream clients. + */ + +const assert = require('assert').strict; +const common = require('../../common'); +const padManager = require('../../../node/db/PadManager'); +import AttributePool from '../../../../static/js/AttributePool'; +import Changeset from '../../../../static/js/Changeset'; + +describe(__filename, function () { + let agent: any; + let socket: any; + let padId: string; + + before(async function () { agent = await common.init(); }); + + beforeEach(async function () { + padId = common.randomString(); + const pad = await padManager.getPad(padId, 'init\n'); + await pad.setText('init\n'); + const res = await agent.get(`/p/${padId}`).expect(200); + socket = await common.connect(res); + }); + + afterEach(async function () { + if (socket != null) socket.close(); + socket = null; + }); + + it('handshake returns CLIENT_VARS with the client-facing shape', async function () { + const {type, data} = await common.handshake(socket, padId); + assert.equal(type, 'CLIENT_VARS'); + assert.ok(data.userId, 'CLIENT_VARS.userId missing'); + assert.ok(data.collab_client_vars, 'collab_client_vars missing'); + assert.equal(typeof data.collab_client_vars.rev, 'number'); + assert.ok(data.collab_client_vars.initialAttributedText, 'initialAttributedText missing'); + }); + + it('USER_CHANGES is acknowledged with ACCEPT_COMMIT and a bumped rev', async function () { + const {data: clientVars} = await common.handshake(socket, padId); + const rev = clientVars.collab_client_vars.rev; + const pool = new AttributePool(); + const cs = Changeset.makeSplice('init\n', 4, 0, '-typed', [], pool); + + const accepted = common.waitForSocketEvent(socket, 'message'); + socket.emit('message', { + type: 'COLLABROOM', + component: 'pad', + data: {type: 'USER_CHANGES', baseRev: rev, changeset: cs, apool: pool.toJsonable()}, + }); + const msg: any = await accepted; + assert.equal(msg.type, 'COLLABROOM'); + assert.equal(msg.data.type, 'ACCEPT_COMMIT'); + assert.equal(msg.data.newRev, rev + 1); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run from `src/`: +```bash +cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-socket-sequence.ts +``` +Expected: 2 passing. If `waitForSocketEvent`'s default 1s timeout is too tight on the ACCEPT_COMMIT, pass a larger `timeoutMs` (its 3rd arg) — e.g. `common.waitForSocketEvent(socket, 'message', 5000)`. + +- [ ] **Step 3: Commit** + +```bash +git add src/tests/backend/specs/downstream/wire-socket-sequence.ts +git commit -m "test(downstream): pin socket.io handshake + USER_CHANGES sequence" +``` + +--- + +## Task 5: HTTP API shape spec + +**Files:** +- Create: `src/tests/backend/specs/downstream/wire-http-api.ts` + +Reference: `src/tests/backend/specs/api/api.ts` for the `common.init()` agent + API-version pattern. + +- [ ] **Step 1: Write the failing test** + +Create `src/tests/backend/specs/downstream/wire-http-api.ts`: + +```typescript +'use strict'; + +/** + * Snapshots the *shapes* (keys/types, not volatile values) of the HTTP API + * endpoints downstream clients call to create pads and round-trip text. + */ + +const assert = require('assert').strict; +const common = require('../../common'); + +describe(__filename, function () { + let agent: any; + let apiVersion = 1; + const apiKey = common.apiKey; + const padId = common.randomString(); + const ep = (point: string, qs: string) => + `/api/${apiVersion}/${point}?apikey=${apiKey}&${qs}`; + + before(async function () { + agent = await common.init(); + const res = await agent.get('/api/').expect(200); + apiVersion = res.body.currentVersion; + }); + + it('createPad returns the standard {code,message,data} envelope', async function () { + const res = await agent.get(ep('createPad', `padID=${padId}&text=hello%0A`)).expect(200); + assert.deepEqual(Object.keys(res.body).sort(), ['code', 'data', 'message']); + assert.equal(res.body.code, 0); + }); + + it('setText + getText round-trips text through the documented shape', async function () { + await agent.get(ep('setText', `padID=${padId}&text=world%0A`)).expect(200); + const res = await agent.get(ep('getText', `padID=${padId}`)).expect(200); + assert.equal(res.body.code, 0); + assert.equal(typeof res.body.data.text, 'string'); + assert.equal(res.body.data.text, 'world\n'); + }); + + it('getRevisionsCount exposes a numeric revisions field', async function () { + const res = await agent.get(ep('getRevisionsCount', `padID=${padId}`)).expect(200); + assert.equal(res.body.code, 0); + assert.equal(typeof res.body.data.revisions, 'number'); + }); +}); +``` + +- [ ] **Step 2: Confirm the apiKey helper name** + +Run from `src/`: +```bash +cd src && grep -n "apiKey\|apikey" tests/backend/common.ts | head +``` +Expected: a `common.apiKey` export (or similar). If the export is named differently, adjust the `apiKey` reference in the spec to match before running. + +- [ ] **Step 3: Run test to verify it passes** + +Run from `src/`: +```bash +cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-http-api.ts +``` +Expected: 3 passing. + +- [ ] **Step 4: Commit** + +```bash +git add src/tests/backend/specs/downstream/wire-http-api.ts +git commit -m "test(downstream): snapshot client-facing HTTP API shapes" +``` + +--- + +## Task 6: Client manifest + +**Files:** +- Create: `src/tests/downstream/clients.json` + +- [ ] **Step 1: Write the manifest** + +Create `src/tests/downstream/clients.json` (SHAs are current `main` HEADs at authoring; `enabled:false` until each client's Phase-2 smoke lands): + +```json +[ + { + "name": "etherpad-pad", + "repo": "https://github.com/ether/pad.git", + "ref": "31176d64ce746d45349e58ee6c0bb043052c6e66", + "kind": "rust", + "enabled": false, + "vectorTest": "cargo test --test vectors", + "smokeCmd": "cargo test --test smoke -- --ignored" + }, + { + "name": "etherpad-cli-client", + "repo": "https://github.com/ether/etherpad-cli-client.git", + "ref": "edbe0bb70971e54514ebea672e4ad9b51fc55bff", + "kind": "node", + "enabled": false, + "vectorTest": "pnpm run test:vectors", + "smokeCmd": "pnpm run test:smoke" + }, + { + "name": "etherpad-desktop", + "repo": "https://github.com/ether/etherpad-desktop.git", + "ref": "ad273c119f1926a8390c9908fc91f62fa2cf740f", + "kind": "desktop", + "enabled": false, + "vectorTest": "pnpm run test:vectors", + "smokeCmd": "pnpm run test:smoke" + } +] +``` + +- [ ] **Step 2: Validate it is well-formed JSON** + +Run: +```bash +node -e "const c=require('./src/tests/downstream/clients.json'); console.log(c.length, c.map(x=>x.name).join(','))" +``` +Expected: `3 etherpad-pad,etherpad-cli-client,etherpad-desktop`. + +- [ ] **Step 3: Commit** + +```bash +git add src/tests/downstream/clients.json +git commit -m "test(downstream): add client manifest (entries disabled pending Phase 2)" +``` + +--- + +## Task 7: Downstream-smoke workflow + +**Files:** +- Create: `.github/workflows/downstream-smoke.yml` + +Reference an existing workflow (`.github/workflows/backend-tests.yml`) for the checkout/pnpm/node setup block this repo uses, and copy that setup verbatim into the job below. + +- [ ] **Step 1: Write the workflow** + +Create `.github/workflows/downstream-smoke.yml`: + +```yaml +name: Downstream smoke + +on: + pull_request: + schedule: + - cron: '0 4 * * *' # nightly against develop + +permissions: + contents: read + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Checkout core (PR) + uses: actions/checkout@v4 + + # --- Reuse core's standard node+pnpm setup (copy from backend-tests.yml) --- + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - name: Install deps + run: pnpm install --frozen-lockfile + + - name: Boot Etherpad on :9003 + env: + APIKEY: downstream-smoke-key + run: | + mkdir -p var + echo -n "$APIKEY" > APIKEY.txt + PORT=9003 pnpm run prod & + echo $! > /tmp/ep.pid + + - name: Wait for healthcheck + run: | + for i in $(seq 1 60); do + if curl -fsS http://localhost:9003/api/ >/dev/null; then + echo "up"; exit 0 + fi + sleep 2 + done + echo "server did not come up"; exit 1 + + - name: Self-check (boot + API roundtrip proves the harness) + run: | + K=downstream-smoke-key + curl -fsS "http://localhost:9003/api/1/createPad?apikey=$K&padID=smoke&text=hi%0A" + curl -fsS "http://localhost:9003/api/1/getText?apikey=$K&padID=smoke" | grep -q '"text":"hi' + + - name: Generate canonical wire-vectors + run: cd src && pnpm run vectors:gen + + - name: Run enabled downstream clients + run: | + node -e ' + const fs=require("fs"); + const clients=require("./src/tests/downstream/clients.json").filter(c=>c.enabled); + if(!clients.length){console.log("No clients enabled yet (Phase 1).");process.exit(0);} + fs.writeFileSync("/tmp/clients.json",JSON.stringify(clients)); + ' + # Phase 2 wires per-kind clone + toolchain + vector injection + smoke here, + # iterating /tmp/clients.json. Until a client is enabled this is a no-op. + + - name: Teardown (by PID, never pkill) + if: always() + run: | + if [ -f /tmp/ep.pid ]; then kill "$(cat /tmp/ep.pid)" 2>/dev/null || true; fi +``` + +- [ ] **Step 2: Confirm the boot command + port env** + +Run: +```bash +cd /home/jose/etherpad/etherpad-core-fresh && grep -nE '"prod"|"dev"|"start"' src/package.json +grep -rn "process.env.PORT\|settings.port" src/node/utils/Settings.ts | head +``` +Expected: confirm the script that starts a production server and that `PORT`/`APIKEY` are honored (Settings reads `process.env.PORT`). If the runnable script is named differently (e.g. `prod` vs `dev`), update the "Boot Etherpad" step to match. If APIKEY is read from a file rather than env, the `echo ... > APIKEY.txt` line already covers it. + +- [ ] **Step 3: Lint the workflow YAML** + +Run: +```bash +node -e "require('js-yaml')" 2>/dev/null && npx --yes js-yaml .github/workflows/downstream-smoke.yml >/dev/null && echo "valid yaml" || python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/downstream-smoke.yml')); print('valid yaml')" +``` +Expected: `valid yaml`. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/downstream-smoke.yml +git commit -m "ci(downstream): add downstream-smoke workflow (boot/self-check/teardown + manifest scaffold)" +``` + +--- + +## Task 8: Full backend-suite run + push + +**Files:** none (verification + integration) + +- [ ] **Step 1: Run the whole downstream spec group** + +Per the "always run backend tests" rule, run the new specs through the real mocha invocation the suite uses, from `src/`: +```bash +cd src && cross-env NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 --extension ts --recursive tests/backend/specs/downstream +``` +Expected: all specs in `tests/backend/specs/downstream/` pass (7 tests total across 3 files). + +- [ ] **Step 2: Confirm no regression in the fixture guard** + +Run from `src/`: +```bash +cd src && pnpm run vectors:gen && git diff --exit-code src/tests/fixtures/wire-vectors.json +``` +Expected: exit 0 (regeneration is byte-identical to the committed fixture). + +- [ ] **Step 3: Push the branch and open the PR** + +```bash +git push -u origin feat/downstream-client-compat-tests +gh pr create --base develop \ + --title "test: downstream client compatibility gate (Phase 1)" \ + --body "Adds core-side contract tests (golden wire-vectors, socket-sequence, HTTP API shapes) and a downstream-smoke workflow scaffold so PRs detect changes that would break the separate CLI / terminal / desktop clients. Phase 2 wires each client repo's vector+smoke tests and flips its manifest entry to enabled. Spec + plan under docs/superpowers/. Closes nothing; tracks the downstream-compat initiative." +``` + +- [ ] **Step 4: Watch CI** + +Per the "check CI after PRs" rule, wait ~20s then: +```bash +gh pr checks --watch +``` +Expected: backend tests green (now including the downstream specs); `Downstream smoke` green (self-check passes, no clients enabled yet). Fix any red before moving on. + +--- + +## Self-Review + +**Spec coverage:** +- Layer A golden vectors → Tasks 1–3. ✅ +- Layer A socket sequence → Task 4. ✅ +- Layer A HTTP API shapes → Task 5. ✅ +- Layer B manifest (pinned SHAs, `enabled` gate) → Task 6. ✅ +- Layer B workflow (boot :9003, healthcheck, vector generation, PID teardown, nightly+PR triggers) → Task 7. ✅ +- Flakiness mitigations: healthcheck-poll (Task 7 step), PID teardown (Task 7), :9003 (Task 7), no external clients on the gate yet so no flake surface (Task 6 `enabled:false`). ✅ +- Phasing: Phase 1 self-contained and green; Phase 2 explicitly out of scope. ✅ + +**Verification-required tasks** (Task 5 step 2, Task 7 step 2) ask the engineer to confirm the exact `common.apiKey` export name and the production boot script/port handling against the live repo before running — these are real lookups, not placeholders, because those names are repo-version-specific. + +**Type consistency:** `WireVector` fields (`name/initialText/changeset/pool/resultText`) are defined in Task 1 and used identically in Tasks 2–3. `generateVectors()` signature is stable across Tasks 1/3. Manifest keys (`enabled`, `vectorTest`, `smokeCmd`) in Task 6 match the workflow's `.filter(c=>c.enabled)` consumer in Task 7. diff --git a/docs/superpowers/specs/2026-06-09-downstream-client-compat-tests-design.md b/docs/superpowers/specs/2026-06-09-downstream-client-compat-tests-design.md new file mode 100644 index 000000000..47e7ecc74 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-downstream-client-compat-tests-design.md @@ -0,0 +1,154 @@ +# Downstream Client Compatibility Tests — Design + +**Date:** 2026-06-09 +**Status:** Approved (brainstorming complete) +**Target repo:** `ether/etherpad` (core), branch `develop` +**Downstream repos in scope:** `etherpad-pad` (Rust terminal editor + `etherpad-client` crate), `etherpad-cli-client` (Node/TS CLI), `etherpad-desktop` (Electron desktop + Capacitor mobile) + +## Problem + +The three downstream clients live in **separate repos** and consume core's wire +protocols rather than importing core as a library: + +- **etherpad-cli-client** ships its *own copies* of `Changeset.ts` + `AttributePool.ts` + and talks the socket.io `message` protocol. No test script today. +- **etherpad-pad** (Rust) hand-rolls engine.io v4 / socket.io v4 + changeset + decoding in its `etherpad-client` crate. Has CI + a `mock-socket` test feature. +- **etherpad-desktop** wraps / points at a core server URL. Has vitest + Playwright e2e + CI. + +A core PR can change the **HTTP API**, the **socket.io handshake / `message` +sequence**, or the **changeset / attribpool wire format** and break these clients +**silently**, because their CI never runs against the new core. The goal: a PR +against core `develop` (and friends) must detect downstream breakage before merge. + +## Decisions (locked during brainstorming) + +| Decision | Choice | +|---|---| +| Detection strategy | **Hybrid**: fast contract tests in core (every PR) + downstream smoke (every PR) | +| Smoke cadence | **Every PR** (with strong flakiness mitigations) | +| How CI gets clients | **Git clone at pinned refs**, recorded in a manifest in core | +| Contract depth | **Shared golden vectors** — core generates canonical fixtures, each client decodes the same fixtures with its own decoder | +| Sequencing | **Phase by layer** — Phase 1 all core-side; Phase 2 one client repo at a time | + +## Architecture + +Two-layer compatibility gate on every core PR: + +``` +core PR ──┬─► Layer A: Contract tests (hermetic, fast, no network/clients) + │ • golden-vector assertions (changeset/attribpool roundtrip) + │ • socket.io message-sequence test (CLIENT_READY → CLIENT_VARS, + │ USER_CHANGES → ACCEPT_COMMIT / NEW_CHANGES) + │ • HTTP API shape snapshots + │ + └─► Layer B: Downstream smoke (boots a real server, runs real clients) + • build + boot Etherpad from the PR on :9003 with a known API key + • healthcheck-poll until ready + • matrix over manifest: clone client @ pinned ref, set up toolchain, + inject core's freshly-generated vectors, run client `test:vectors`, + run client smoke: connect → create/open pad → write text → + read back via HTTP API → assert equality + • tear down server by PID +``` + +## Layer A — Contract tests (Phase 1, core) + +### Golden vectors +- Generator script: `src/tests/downstream/generate-vectors.ts` (run via a package + script, e.g. `pnpm run vectors:gen`). +- Output fixture: `src/tests/fixtures/wire-vectors.json`. Each record: + `{ name, initialAText, changeset, pool, resultAText }` covering the operation + classes clients must decode: plain insert, delete, format/attrib op, + multi-line insert (char_bank ending in `\n`), attribpool reuse across ops. +- Core test `src/tests/backend/specs/wire-vectors.ts`: regenerate in-memory and + assert it matches the committed fixture exactly (drift requires a deliberate + commit, which is the signal a wire change happened). + +### Socket message-sequence test +- `src/tests/backend/specs/wire-socket-sequence.ts`: drive a socket.io client + against the in-process server, assert the handshake message sequence and the + shape of `CLIENT_VARS`, `USER_CHANGES` → `ACCEPT_COMMIT`, and broadcast + `NEW_CHANGES`. Reuses the existing backend socket test helpers. + +### HTTP API shape snapshots +- `src/tests/backend/specs/wire-http-api.ts`: snapshot the response *shapes* + (keys / types, not volatile values) of the API endpoints clients call + (`createPad`, `setText`, `getText`, `getRevisionsCount`, session/auth as needed). + +These join the existing `backend-tests.yml` run — no new per-PR job, no new infra. + +## Layer B — Downstream smoke (Phase 1 scaffold, Phase 2 per client) + +### Manifest +`src/tests/downstream/clients.json`: +```json +[ + { "name": "etherpad-pad", "repo": "https://github.com/ether/pad.git", "ref": "", "kind": "rust", "smokeCmd": "..." }, + { "name": "etherpad-cli-client", "repo": "https://github.com/ether/etherpad-cli-client.git", "ref": "", "kind": "node", "smokeCmd": "..." }, + { "name": "etherpad-desktop", "repo": "https://github.com/ether/etherpad-desktop.git", "ref": "", "kind": "desktop", "smokeCmd": "..." } +] +``` +Refs are pinned to a specific commit SHA (not `main`) so a client's own pushes +cannot redden core CI; bumping a ref is a deliberate PR. Current `main` HEADs at +authoring time: pad `31176d6`, cli-client `edbe0bb`, desktop `ad273c1`. +Pinned refs mean a client's *own* breakage never randomly reddens core; picking up +a client fix is a deliberate ref-bump PR. Clients are added to the manifest as +their Phase-2 smoke lands — the workflow only runs what's registered. + +### Workflow +`.github/workflows/downstream-smoke.yml` (triggers: `pull_request` + nightly +`schedule` against `develop`): +1. Build core from the PR, install deps. +2. Boot Etherpad on **:9003** with a known `APIKEY` in the background; record PID. +3. Healthcheck-poll the server (bounded timeout) before proceeding. +4. Matrix over manifest entries: clone @ pinned ref → set up toolchain + (node+pnpm / rust / electron+xvfb) → copy core's freshly-generated + `wire-vectors.json` into the client → run `test:vectors` → run smoke. +5. Tear down: kill the recorded **PID** (never `pkill -f`). + +### Per-client smoke (Phase 2) +Minimal roundtrip exercising the real protocol end-to-end: +- **etherpad-pad** (`rust`): integration test gated by `ETHERPAD_SMOKE_URL`, using + the real tungstenite socket — connect, open pad, send a changeset, read back + via HTTP `getText`, assert. Plus `cargo test` vector consumer reading the + injected fixture. +- **etherpad-cli-client** (`node`): add a minimal test runner (none today — + `node:test` or vitest), a `test:vectors` decoding the fixture, and a smoke + using the client lib: connect → write → verify via HTTP `getText`. +- **etherpad-desktop** (`desktop`): **headless-light** vitest smoke that points + the shell/webview at the booted URL and roundtrips. The full Electron e2e stays + in desktop's own CI — it is **not** in the core gate. If Electron is + unavoidable here, run under `xvfb-run`. + +## Flakiness mitigations (because smoke runs on every PR) + +- Healthcheck-poll-with-timeout before any client runs. +- Bounded timeout + 1 retry per client smoke. +- Desktop kept headless-light; heavy Electron e2e excluded from the gate. +- PID-based teardown, never `pkill -f` (would kill the developer's other servers). +- Pinned manifest refs isolate core CI from clients' own breakage. +- Tests that bind a port use **:9003** (9001 is reserved for ad-hoc local use). + +## Phasing + +- **Phase 1 (core only, lands first, immediately useful):** vector generator + + `wire-vectors.json` + three contract specs + `downstream-smoke.yml` + + `clients.json` manifest. Harness proven with **one** reference client wired in + (the Rust `etherpad-pad`, which already has test infra). +- **Phase 2 (one client repo at a time):** order `etherpad-pad` → + `etherpad-cli-client` → `etherpad-desktop`. Each PR adds that client's + `test:vectors` + smoke and registers it in the core manifest. + +## Out of scope + +- Replacing clients' existing full e2e suites (they stay in their own repos). +- ep_kaput (excluded from all sweeps per standing instruction). +- Changing the wire protocol itself — this work only *observes* it. + +## Success criteria + +- A core PR that alters changeset serialization, the socket message sequence, or a + client-facing API shape fails Layer A (contract) and/or Layer B (smoke) before merge. +- Phase 1 lands green on core `develop` with the Rust client wired into the smoke matrix. +- Bumping a client's pinned ref is the only way a client's own changes affect core CI. diff --git a/src/package.json b/src/package.json index dbedd68ce..9e90c911f 100644 --- a/src/package.json +++ b/src/package.json @@ -154,6 +154,7 @@ "test-container": "mocha --import=tsx --timeout 30000 --extension ts,js tests/container/specs/api", "dev": "cross-env NODE_ENV=development node --require tsx/cjs node/server.ts", "prod": "cross-env NODE_ENV=production node --require tsx/cjs node/server.ts", + "vectors:gen": "node --require tsx/cjs tests/backend/specs/downstream/generate-vectors.ts", "ts-check": "tsc --noEmit", "ts-check:watch": "tsc --noEmit --watch", "test-ui": "cross-env NODE_ENV=production npx playwright test", diff --git a/src/tests/backend/specs/downstream/generate-vectors.ts b/src/tests/backend/specs/downstream/generate-vectors.ts new file mode 100644 index 000000000..5d821a3b1 --- /dev/null +++ b/src/tests/backend/specs/downstream/generate-vectors.ts @@ -0,0 +1,77 @@ +'use strict'; + +/** + * Single source of truth for the downstream wire-compatibility fixtures. + * + * Each vector is a self-contained changeset application: given `initialText` + * and `pool`, applying `changeset` yields `resultText`. Downstream clients + * (which reimplement Etherpad's changeset/attribpool decoders) consume the + * exact same JSON and must reproduce `resultText`. See the Phase 1 plan + * at docs/superpowers/plans/2026-06-09-downstream-client-compat-tests-phase1.md. + * + * Runnable as a CLI to (re)write src/tests/fixtures/wire-vectors.json: + * pnpm run vectors:gen + */ + +import * as Changeset from '../../../../static/js/Changeset'; +import AttributePool from '../../../../static/js/AttributePool'; + +export type WireVector = { + name: string; + initialText: string; + changeset: string; + pool: ReturnType; + resultText: string; +}; + +const vector = ( + name: string, + initialText: string, + build: (pool: AttributePool) => string, +): WireVector => { + const pool = new AttributePool(); + const changeset = build(pool); + Changeset.checkRep(changeset); + return { + name, + initialText, + changeset, + pool: pool.toJsonable(), + resultText: Changeset.applyToText(changeset, initialText), + }; +}; + +export const generateVectors = (): WireVector[] => [ + vector('plain-insert', 'abc\n', () => + Changeset.makeSplice('abc\n', 3, 0, 'XYZ')), + + vector('plain-delete', 'abcdef\n', () => + Changeset.makeSplice('abcdef\n', 1, 3, '')), + + vector('formatted-insert', 'abc\n', (pool) => + Changeset.makeSplice('abc\n', 3, 0, 'bold', [['bold', 'true']], pool)), + + vector('multiline-insert', 'abc\n', () => + Changeset.makeSplice('abc\n', 3, 0, 'one\ntwo\n')), + + vector('attrib-reuse', 'abc\n', (pool) => { + // Two formatted inserts sharing one pool entry exercises pool index reuse. + const cs1 = Changeset.makeSplice('abc\n', 0, 0, 'A', [['bold', 'true']], pool); + const mid = Changeset.applyToText(cs1, 'abc\n'); + const cs2 = Changeset.makeSplice(mid, mid.length - 1, 0, 'B', [['bold', 'true']], pool); + return Changeset.compose(cs1, cs2, pool); + }), +]; + +// CLI entry: write the canonical fixture to disk. +if (require.main === module) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const fs = require('fs'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const path = require('path'); + const out = path.join(__dirname, '../../../fixtures/wire-vectors.json'); + fs.mkdirSync(path.dirname(out), {recursive: true}); + fs.writeFileSync(out, `${JSON.stringify(generateVectors(), null, 2)}\n`); + // eslint-disable-next-line no-console + console.log(`wrote ${out}`); +} diff --git a/src/tests/backend/specs/downstream/wire-http-api.ts b/src/tests/backend/specs/downstream/wire-http-api.ts new file mode 100644 index 000000000..ce688151b --- /dev/null +++ b/src/tests/backend/specs/downstream/wire-http-api.ts @@ -0,0 +1,54 @@ +'use strict'; + +/** + * Snapshots the *shapes* (keys/types, not volatile values) of the HTTP API + * endpoints downstream clients call to create pads and round-trip text. + * Auth in the test harness is via JWT (common.generateJWTToken), matching the + * rest of the api specs — see api/createDiffHTML.ts. + */ + +const assert = require('assert').strict; +const common = require('../../common'); + +describe(__filename, function () { + let agent: any; + let apiVersion = 1; + const padId = `wireHttp_${common.randomString()}`; + const endPoint = (point: string) => `/api/${apiVersion}/${point}`; + + before(async function () { + agent = await common.init(); + const res = await agent.get('/api/').expect(200).expect('Content-Type', /json/); + apiVersion = res.body.currentVersion; + assert(apiVersion); + }); + + it('createPad returns the standard {code,data,message} envelope', async function () { + const res = await agent.get(`${endPoint('createPad')}?padID=${padId}&text=hello%0A`) + .set('Authorization', await common.generateJWTToken()) + .expect(200); + assert.deepEqual(Object.keys(res.body).sort(), ['code', 'data', 'message']); + assert.equal(res.body.code, 0); + }); + + it('setText + getText round-trips text through the documented shape', async function () { + await agent.post(endPoint('setText')) + .set('Authorization', await common.generateJWTToken()) + .send({padID: padId, text: 'world\n'}) + .expect(200); + const res = await agent.get(`${endPoint('getText')}?padID=${padId}`) + .set('Authorization', await common.generateJWTToken()) + .expect(200); + assert.equal(res.body.code, 0); + assert.equal(typeof res.body.data.text, 'string'); + assert.equal(res.body.data.text, 'world\n'); + }); + + it('getRevisionsCount exposes a numeric revisions field', async function () { + const res = await agent.get(`${endPoint('getRevisionsCount')}?padID=${padId}`) + .set('Authorization', await common.generateJWTToken()) + .expect(200); + assert.equal(res.body.code, 0); + assert.equal(typeof res.body.data.revisions, 'number'); + }); +}); diff --git a/src/tests/backend/specs/downstream/wire-socket-sequence.ts b/src/tests/backend/specs/downstream/wire-socket-sequence.ts new file mode 100644 index 000000000..3aa829362 --- /dev/null +++ b/src/tests/backend/specs/downstream/wire-socket-sequence.ts @@ -0,0 +1,60 @@ +'use strict'; + +/** + * Pins the socket.io message sequence + shapes that every realtime client + * depends on: handshake -> CLIENT_VARS, then USER_CHANGES -> ACCEPT_COMMIT. + * A change here is a wire-protocol change that will break downstream clients + * (the Rust terminal editor and the Node CLI both speak this sequence by hand). + */ + +const assert = require('assert').strict; +const common = require('../../common'); +const padManager = require('../../../../node/db/PadManager'); + +describe(__filename, function () { + let agent: any; + let socket: any; + let pad: any; + let padId: string; + + before(async function () { agent = await common.init(); }); + + beforeEach(async function () { + padId = common.randomString(); + pad = await padManager.getPad(padId, 'dummy\n'); + await pad.setText('\n'); // ensure the pad exists at a known empty state + const res = await agent.get(`/p/${padId}`).expect(200); + socket = await common.connect(res); + }); + + afterEach(async function () { + if (socket != null) socket.close(); + socket = null; + if (pad != null) await pad.remove(); + pad = null; + }); + + it('handshake returns CLIENT_VARS with the client-facing shape', async function () { + const {type, data} = await common.handshake(socket, padId); + assert.equal(type, 'CLIENT_VARS'); + assert.ok(data.userId, 'CLIENT_VARS.userId missing'); + assert.ok(data.collab_client_vars, 'collab_client_vars missing'); + assert.equal(typeof data.collab_client_vars.rev, 'number'); + assert.ok(data.collab_client_vars.initialAttributedText, + 'collab_client_vars.initialAttributedText missing'); + }); + + it('USER_CHANGES is acknowledged with ACCEPT_COMMIT and a bumped rev', async function () { + const {data: clientVars} = await common.handshake(socket, padId); + const rev = clientVars.collab_client_vars.rev; + const authorId = clientVars.userId; + // Insert ops must carry the session author attribute (`*0+N` + matching + // apool entry) or the server rejects with {disconnect:'badChangeset'}. + const apool = {numToAttrib: {0: ['author', authorId]}, nextNum: 1}; + await Promise.all([ + common.waitForAcceptCommit(socket, rev + 1), + common.sendUserChanges(socket, {baseRev: rev, changeset: 'Z:1>5*0+5$hello', apool}), + ]); + assert.equal(pad.text(), 'hello\n'); + }); +}); diff --git a/src/tests/backend/specs/downstream/wire-vectors.ts b/src/tests/backend/specs/downstream/wire-vectors.ts new file mode 100644 index 000000000..3fc5c092d --- /dev/null +++ b/src/tests/backend/specs/downstream/wire-vectors.ts @@ -0,0 +1,33 @@ +'use strict'; + +/** + * Guards the downstream wire-format contract: + * - the committed fixture exactly matches a fresh regeneration (any drift is a + * deliberate wire change and must be re-generated + reviewed in the same PR), and + * - every vector is internally consistent under core's own Changeset engine. + */ + +const assert = require('assert').strict; +import fs from 'fs'; +import path from 'path'; +import * as Changeset from '../../../../static/js/Changeset'; +import {generateVectors} from './generate-vectors'; + +const fixturePath = path.join(__dirname, '../../../fixtures/wire-vectors.json'); + +describe(__filename, function () { + it('committed fixture matches a fresh regeneration', function () { + const committed = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); + const fresh = generateVectors(); + assert.deepEqual(committed, fresh, + 'wire-vectors.json is stale — run `pnpm run vectors:gen` and commit the result'); + }); + + it('every vector applies to its result under core Changeset', function () { + for (const v of generateVectors()) { + Changeset.checkRep(v.changeset); + assert.equal(Changeset.applyToText(v.changeset, v.initialText), v.resultText, + `vector ${v.name} result mismatch`); + } + }); +}); diff --git a/src/tests/downstream/clients.json b/src/tests/downstream/clients.json new file mode 100644 index 000000000..52821dabc --- /dev/null +++ b/src/tests/downstream/clients.json @@ -0,0 +1,29 @@ +[ + { + "name": "etherpad-pad", + "repo": "https://github.com/ether/pad.git", + "ref": "31176d64ce746d45349e58ee6c0bb043052c6e66", + "kind": "rust", + "enabled": false, + "vectorTest": "cargo test --test vectors", + "smokeCmd": "cargo test --test smoke -- --ignored" + }, + { + "name": "etherpad-cli-client", + "repo": "https://github.com/ether/etherpad-cli-client.git", + "ref": "edbe0bb70971e54514ebea672e4ad9b51fc55bff", + "kind": "node", + "enabled": false, + "vectorTest": "pnpm run test:vectors", + "smokeCmd": "pnpm run test:smoke" + }, + { + "name": "etherpad-desktop", + "repo": "https://github.com/ether/etherpad-desktop.git", + "ref": "ad273c119f1926a8390c9908fc91f62fa2cf740f", + "kind": "desktop", + "enabled": false, + "vectorTest": "pnpm run test:vectors", + "smokeCmd": "pnpm run test:smoke" + } +] diff --git a/src/tests/fixtures/wire-vectors.json b/src/tests/fixtures/wire-vectors.json new file mode 100644 index 000000000..86870ae6e --- /dev/null +++ b/src/tests/fixtures/wire-vectors.json @@ -0,0 +1,62 @@ +[ + { + "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" + } +] From e56a33efd0b9fae4d27caa13ee4eedf12cafd340 Mon Sep 17 00:00:00 2001 From: John McLear Date: Tue, 9 Jun 2026 13:49:42 +0100 Subject: [PATCH 02/14] ci(downstream): enable pad/cli/desktop smoke clients (Phase 2) (#7924) Implements the per-kind orchestration (clone @ pinned ref, inject core's freshly-generated wire-vectors fixture via ETHERPAD_WIRE_VECTORS, run each client's vectorTest + smokeCmd against the booted server) in a testable run-clients.sh, and flips the three manifest entries to enabled, pinned to the commits that carry their Phase 2 tests: ether/pad#1, ether/etherpad-cli-client#136, ether/etherpad-desktop#78. Validated end-to-end locally against a real core: all three clients' vectors + live smoke pass. Refs should be bumped to the squash-merge commits once those client PRs land. Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/downstream-smoke.yml | 21 ++++---- src/tests/downstream/clients.json | 12 ++--- src/tests/downstream/run-clients.sh | 74 ++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 18 deletions(-) create mode 100755 src/tests/downstream/run-clients.sh diff --git a/.github/workflows/downstream-smoke.yml b/.github/workflows/downstream-smoke.yml index a3049d0c1..c565a22c5 100644 --- a/.github/workflows/downstream-smoke.yml +++ b/.github/workflows/downstream-smoke.yml @@ -88,19 +88,16 @@ jobs: - name: Generate canonical wire-vectors run: cd src && pnpm run vectors:gen + # Rust toolchain for the `rust`-kind client (etherpad-pad). Other kinds + # use the node+pnpm already set up above. + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + - name: Run enabled downstream clients - run: | - ENABLED=$(node -e 'const c=require("./src/tests/downstream/clients.json").filter(x=>x.enabled); process.stdout.write(JSON.stringify(c))') - if [ "$ENABLED" = "[]" ]; then - echo "No downstream clients enabled yet (Phase 1 harness only). Skipping." - exit 0 - fi - # Phase 2 implements per-`kind` clone @ pinned ref + toolchain setup + - # vector injection (cp src/tests/fixtures/wire-vectors.json into the - # client) + `vectorTest` + `smokeCmd` against http://localhost:9003, - # iterating the entries in $ENABLED. Until a client is enabled this is - # a no-op so the harness lands green on its own. - echo "$ENABLED" + env: + SMOKE_URL: http://localhost:9003 + SMOKE_APIKEY: ${{ env.APIKEY }} + run: bash src/tests/downstream/run-clients.sh - name: Teardown (by PID, never pkill) if: always() diff --git a/src/tests/downstream/clients.json b/src/tests/downstream/clients.json index 52821dabc..82a9c997a 100644 --- a/src/tests/downstream/clients.json +++ b/src/tests/downstream/clients.json @@ -2,27 +2,27 @@ { "name": "etherpad-pad", "repo": "https://github.com/ether/pad.git", - "ref": "31176d64ce746d45349e58ee6c0bb043052c6e66", + "ref": "ada8cafd33b4ab31206226b4c3db80b2aa00125a", "kind": "rust", - "enabled": false, + "enabled": true, "vectorTest": "cargo test --test vectors", "smokeCmd": "cargo test --test smoke -- --ignored" }, { "name": "etherpad-cli-client", "repo": "https://github.com/ether/etherpad-cli-client.git", - "ref": "edbe0bb70971e54514ebea672e4ad9b51fc55bff", + "ref": "e4b4643c7185c2059375c430e07ca2e65d01999a", "kind": "node", - "enabled": false, + "enabled": true, "vectorTest": "pnpm run test:vectors", "smokeCmd": "pnpm run test:smoke" }, { "name": "etherpad-desktop", "repo": "https://github.com/ether/etherpad-desktop.git", - "ref": "ad273c119f1926a8390c9908fc91f62fa2cf740f", + "ref": "9e02fd06b2be3f0eeb91d636b7bddc547210399d", "kind": "desktop", - "enabled": false, + "enabled": true, "vectorTest": "pnpm run test:vectors", "smokeCmd": "pnpm run test:smoke" } diff --git a/src/tests/downstream/run-clients.sh b/src/tests/downstream/run-clients.sh new file mode 100755 index 000000000..4ffa7e9a5 --- /dev/null +++ b/src/tests/downstream/run-clients.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# +# Runs each enabled downstream client from clients.json against an already-booted +# Etherpad: clone @ pinned ref, set up its toolchain, point it at core's freshly +# generated wire-vectors fixture, then run the client's vectorTest + smokeCmd. +# +# The fixture is injected via $ETHERPAD_WIRE_VECTORS (absolute) so clients test +# against CURRENT core's serialization, not their vendored snapshot. The smoke +# reaches the server via $ETHERPAD_SMOKE_URL + $ETHERPAD_SMOKE_APIKEY. +# +# Env (all optional except APIKEY): +# SMOKE_URL default http://localhost:9003 +# SMOKE_APIKEY required for the live smoke (clients skip cleanly without it) +# MANIFEST default src/tests/downstream/clients.json (relative to repo root) +# WIRE_VECTORS default /src/tests/fixtures/wire-vectors.json +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +MANIFEST="${MANIFEST:-$REPO_ROOT/src/tests/downstream/clients.json}" +WIRE_VECTORS="${WIRE_VECTORS:-$REPO_ROOT/src/tests/fixtures/wire-vectors.json}" +SMOKE_URL="${SMOKE_URL:-http://localhost:9003}" +SMOKE_APIKEY="${SMOKE_APIKEY:-}" + +[ -f "$WIRE_VECTORS" ] || { echo "::error::fixture not found: $WIRE_VECTORS"; exit 1; } + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +MANIFEST="$MANIFEST" node -e ' + const c = require(process.env.MANIFEST).filter((x) => x.enabled); + for (const x of c) { + process.stdout.write([x.name, x.repo, x.ref, x.kind, x.vectorTest, x.smokeCmd].join("\t") + "\n"); + } +' > "$WORK/clients.tsv" + +if [ ! -s "$WORK/clients.tsv" ]; then + echo "No downstream clients enabled. Nothing to run." + exit 0 +fi + +export ETHERPAD_WIRE_VECTORS="$WIRE_VECTORS" +export ETHERPAD_SMOKE_URL="$SMOKE_URL" +export ETHERPAD_SMOKE_APIKEY="$SMOKE_APIKEY" + +fail=0 +while IFS=$'\t' read -r name repo ref kind vectorTest smokeCmd; do + echo "::group::$name ($kind) @ ${ref:0:12}" + dir="$WORK/$name" + git clone --quiet "$repo" "$dir" + # Fetch the exact pinned commit (works even when it is not a branch tip). + git -C "$dir" fetch --quiet origin "$ref" 2>/dev/null || true + git -C "$dir" checkout --quiet "$ref" + + ( + cd "$dir" + case "$kind" in + rust) + eval "$vectorTest" + eval "$smokeCmd" + ;; + node|desktop) + pnpm install + eval "$vectorTest" + eval "$smokeCmd" + ;; + *) + echo "::error::unknown client kind: $kind"; exit 1 + ;; + esac + ) || { echo "::error::downstream client '$name' failed"; fail=1; } + echo "::endgroup::" +done < "$WORK/clients.tsv" + +exit "$fail" From b420cf4850ee490a57017af2b6d3df847d88e3a8 Mon Sep 17 00:00:00 2001 From: John McLear Date: Tue, 9 Jun 2026 14:31:05 +0100 Subject: [PATCH 03/14] ci(downstream): robust per-client error handling in run-clients.sh (#7927) * ci(downstream): robust per-client error handling in run-clients.sh (Qodo) - Move clone/fetch/checkout inside the per-client guarded block with explicit '|| exit 1' on every step. set -e is suspended inside a subshell used as an '||' operand, so relying on it silently swallowed clone/checkout failures (and continued from the wrong cwd); explicit guards make one client's failure a per-client fail=1 while the loop continues, and the run exits non-zero. - Stop suppressing fetch errors; fetch only when the pinned commit isn't already reachable, and surface the real error. - Run manifest commands via 'bash -c' instead of 'eval' (trusted in-repo allowlist; avoids double-parsing / leaking into this script's shell). Verified: two bogus clients -> both reported, loop continues, exit 1; happy path (cli, no server) -> vectors pass, smoke skips, exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) * ci(downstream): pin clients to their merged-commit SHAs The three client Phase-2 PRs merged; bump each manifest ref from its pre-merge PR-branch tip (now deleted / unfetchable) to its squash-merge commit on main: etherpad-pad -> 91620c6 (ether/pad#1) etherpad-cli-client -> ebc516e (ether/etherpad-cli-client#136) etherpad-desktop -> ab83da6 (ether/etherpad-desktop#78) Verified each merged main carries the test entry points (vectors.rs/smoke.rs; test:vectors/test:smoke scripts). Co-Authored-By: Claude Opus 4.8 (1M context) * ci(downstream): run manifest commands under bash strict mode (Qodo) bash -c spawns a fresh shell without -e/-u/-o pipefail, so a pipeline-stage failure inside a client's vectorTest/smokeCmd could be masked. Use 'bash -euo pipefail -c' so those failures surface as a non-zero exit. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- src/tests/downstream/clients.json | 6 ++--- src/tests/downstream/run-clients.sh | 36 +++++++++++++++++++---------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/tests/downstream/clients.json b/src/tests/downstream/clients.json index 82a9c997a..b4013a72f 100644 --- a/src/tests/downstream/clients.json +++ b/src/tests/downstream/clients.json @@ -2,7 +2,7 @@ { "name": "etherpad-pad", "repo": "https://github.com/ether/pad.git", - "ref": "ada8cafd33b4ab31206226b4c3db80b2aa00125a", + "ref": "91620c67c49536bb77e90b39f298ea70ae93c4a0", "kind": "rust", "enabled": true, "vectorTest": "cargo test --test vectors", @@ -11,7 +11,7 @@ { "name": "etherpad-cli-client", "repo": "https://github.com/ether/etherpad-cli-client.git", - "ref": "e4b4643c7185c2059375c430e07ca2e65d01999a", + "ref": "ebc516ef1a4e7a0c97ccd7a3f2db65e99f8e177c", "kind": "node", "enabled": true, "vectorTest": "pnpm run test:vectors", @@ -20,7 +20,7 @@ { "name": "etherpad-desktop", "repo": "https://github.com/ether/etherpad-desktop.git", - "ref": "9e02fd06b2be3f0eeb91d636b7bddc547210399d", + "ref": "ab83da645b8683afbbc203e4a3fa6f3622a55709", "kind": "desktop", "enabled": true, "vectorTest": "pnpm run test:vectors", diff --git a/src/tests/downstream/run-clients.sh b/src/tests/downstream/run-clients.sh index 4ffa7e9a5..e2f386820 100755 --- a/src/tests/downstream/run-clients.sh +++ b/src/tests/downstream/run-clients.sh @@ -46,28 +46,40 @@ fail=0 while IFS=$'\t' read -r name repo ref kind vectorTest smokeCmd; do echo "::group::$name ($kind) @ ${ref:0:12}" dir="$WORK/$name" - git clone --quiet "$repo" "$dir" - # Fetch the exact pinned commit (works even when it is not a branch tip). - git -C "$dir" fetch --quiet origin "$ref" 2>/dev/null || true - git -C "$dir" checkout --quiet "$ref" - + # Everything — clone, checkout, AND the tests — runs inside one guarded + # subshell so a single client's failure becomes a per-client failure (fail=1) + # and the loop continues to the rest. NOTE: `set -e` is suspended inside a + # subshell used as an `||` operand, so every step is guarded with an explicit + # `|| exit 1` rather than relying on `set -e`. The manifest commands are a + # trusted in-repo allowlist; running them via `bash -euo pipefail -c` (not + # `eval`) keeps them out of this script's own shell and applies strict mode + # (pipeline-stage failures surface) inside the child. ( - cd "$dir" + git clone --quiet "$repo" "$dir" || exit 1 + # A default clone has all branch heads; fetch the pinned commit only if it + # is not already reachable (e.g. a non-branch-tip SHA). Fetch errors are + # NOT suppressed so the real cause surfaces instead of a vague checkout fail. + if ! git -C "$dir" cat-file -e "${ref}^{commit}" 2>/dev/null; then + git -C "$dir" fetch --quiet origin "$ref" || exit 1 + fi + git -C "$dir" checkout --quiet "$ref" || exit 1 + + cd "$dir" || exit 1 case "$kind" in rust) - eval "$vectorTest" - eval "$smokeCmd" + bash -euo pipefail -c "$vectorTest" || exit 1 + bash -euo pipefail -c "$smokeCmd" || exit 1 ;; node|desktop) - pnpm install - eval "$vectorTest" - eval "$smokeCmd" + pnpm install || exit 1 + bash -euo pipefail -c "$vectorTest" || exit 1 + bash -euo pipefail -c "$smokeCmd" || exit 1 ;; *) echo "::error::unknown client kind: $kind"; exit 1 ;; esac - ) || { echo "::error::downstream client '$name' failed"; fail=1; } + ) || { echo "::error::downstream client '$name' failed (clone/checkout/test)"; fail=1; } echo "::endgroup::" done < "$WORK/clients.tsv" From 67c0e391fd14ee9a41273ad8bc4c113451506891 Mon Sep 17 00:00:00 2001 From: John McLear Date: Tue, 9 Jun 2026 15:13:11 +0100 Subject: [PATCH 04/14] fix(skin): paint the root canvas so iOS dark mode has no white status bar (#7606) (#7931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OP still saw a white strip above the dark pad on iOS Safari even with the theme-color metas in place. theme-color only tints the address-bar chrome; the status-bar safe area at the very top is painted by iOS from the ROOT canvas background. The colibris background variants only colour inner containers (#editorcontainerbox, body descendants), leaving and transparent (computed rgba(0,0,0,0)) with color-scheme: normal — so the canvas fell back to the UA light default (white). Android tints its chrome from theme-color, which is why it looked fine there but iOS did not. Paint the root per toolbar variant with the toolbar colour (matching theme-color and the OP's request that the bar match the toolbar) and set color-scheme so the safe area, toolbar, and address bar are one seamless colour. Verified in a mobile viewport: dark-OS root background is now rgb(72,83,101) (#485365, == --super-dark-color, the toolbar colour); light-OS stays white. Colours mirror skin_toolbar_colors.ts. Co-authored-by: Claude Opus 4.8 (1M context) --- src/static/skins/colibris/src/pad-variants.css | 16 ++++++++++++++++ .../specs/theme_color_dark_mode.spec.ts | 14 ++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/static/skins/colibris/src/pad-variants.css b/src/static/skins/colibris/src/pad-variants.css index 1b335d3ab..c67452d22 100644 --- a/src/static/skins/colibris/src/pad-variants.css +++ b/src/static/skins/colibris/src/pad-variants.css @@ -133,6 +133,22 @@ box-shadow: 0 0 14px 0px var(--super-dark-color); } +/* == Root canvas / iOS status-bar safe area (issue #7606) == */ +/* The variant rules above only paint inner containers, leaving the root + transparent. iOS Safari fills the status-bar safe area above the page from + the ROOT canvas background (not `theme-color`), so a dark-mode pad showed a + white strip above the dark toolbar. Paint the root with the TOOLBAR colour + (matching `theme-color` and the OP's request that the bar match the toolbar) + so the safe area, toolbar, and address bar are one seamless colour. The + colours mirror toolbarColorForTokens / skin_toolbar_colors.ts. `color-scheme` + keeps UA-painted chrome (overscroll, scrollbars, form controls) in step. */ +html.super-light-toolbar, html.light-toolbar { color-scheme: light; } +html.super-dark-toolbar, html.dark-toolbar { color-scheme: dark; } +html.super-light-toolbar { background-color: var(--super-light-color); } +html.light-toolbar { background-color: var(--light-color); } +html.super-dark-toolbar { background-color: var(--super-dark-color); } +html.dark-toolbar { background-color: var(--dark-color); } + diff --git a/src/tests/frontend-new/specs/theme_color_dark_mode.spec.ts b/src/tests/frontend-new/specs/theme_color_dark_mode.spec.ts index 056d232e9..bd0206f6a 100644 --- a/src/tests/frontend-new/specs/theme_color_dark_mode.spec.ts +++ b/src/tests/frontend-new/specs/theme_color_dark_mode.spec.ts @@ -59,4 +59,18 @@ test.describe('dark color scheme', () => { // before pad.css so it takes effect at first paint. await expect(page.locator('html')).toHaveClass(/super-dark-editor/); }); + + test('root canvas matches the toolbar so the iOS status-bar area is not white', + async ({page}) => { + await goToNewPad(page); + // The root must carry the toolbar colour as its background — iOS + // Safari paints the status-bar safe area from the root canvas, not from + // theme-color, so leaving it transparent produced a white strip above + // the dark pad (issue #7606). #485365 == --super-dark-color, the dark + // toolbar colour. + await expect + .poll(() => page.evaluate(() => + getComputedStyle(document.documentElement).backgroundColor)) + .toBe('rgb(72, 83, 101)'); + }); }); From 5b146f0b03ec50b49fa4099d7e0ba7000f22f3d0 Mon Sep 17 00:00:00 2001 From: John McLear Date: Tue, 9 Jun 2026 15:23:01 +0100 Subject: [PATCH 05/14] fix(pad): show detected language in settings dropdown (#7925) (#7928) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(pad): show detected language in settings dropdown (#7925) When the UI language was auto-detected from the browser (no language cookie and no pad-wide lang set), refreshMyViewControls() and refreshPadSettingsControls() set the language dropdown to `