mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-17 16:47:05 +00:00
test: downstream client compatibility gate (Phase 1) (#7923)
* docs: design for downstream client compatibility tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: Phase 1 implementation plan for downstream compat tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(downstream): add golden wire-vector generator Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(downstream): add committed golden wire-vectors fixture Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(downstream): assert wire-vectors fixture stability + consistency Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(downstream): pin socket.io handshake + USER_CHANGES sequence Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(downstream): snapshot client-facing HTTP API shapes Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(downstream): add client manifest (entries disabled pending Phase 2) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(downstream): add downstream-smoke workflow (boot/self-check/teardown + manifest scaffold) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9af3a4de77
commit
860ab68c04
10 changed files with 1214 additions and 0 deletions
111
.github/workflows/downstream-smoke.yml
vendored
Normal file
111
.github/workflows/downstream-smoke.yml
vendored
Normal file
|
|
@ -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
|
||||
|
|
@ -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<AttributePool['toJsonable']>;
|
||||
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.
|
||||
|
|
@ -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": "<sha on main>", "kind": "rust", "smokeCmd": "..." },
|
||||
{ "name": "etherpad-cli-client", "repo": "https://github.com/ether/etherpad-cli-client.git", "ref": "<sha on main>", "kind": "node", "smokeCmd": "..." },
|
||||
{ "name": "etherpad-desktop", "repo": "https://github.com/ether/etherpad-desktop.git", "ref": "<sha on main>", "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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
77
src/tests/backend/specs/downstream/generate-vectors.ts
Normal file
77
src/tests/backend/specs/downstream/generate-vectors.ts
Normal file
|
|
@ -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<AttributePool['toJsonable']>;
|
||||
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}`);
|
||||
}
|
||||
54
src/tests/backend/specs/downstream/wire-http-api.ts
Normal file
54
src/tests/backend/specs/downstream/wire-http-api.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
60
src/tests/backend/specs/downstream/wire-socket-sequence.ts
Normal file
60
src/tests/backend/specs/downstream/wire-socket-sequence.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
33
src/tests/backend/specs/downstream/wire-vectors.ts
Normal file
33
src/tests/backend/specs/downstream/wire-vectors.ts
Normal file
|
|
@ -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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
29
src/tests/downstream/clients.json
Normal file
29
src/tests/downstream/clients.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
62
src/tests/fixtures/wire-vectors.json
vendored
Normal file
62
src/tests/fixtures/wire-vectors.json
vendored
Normal file
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue