Skip to content

test: downstream wire-compat (vectors + smoke)#136

Merged
JohnMcLear merged 2 commits into
mainfrom
feat/downstream-wire-compat
Jun 9, 2026
Merged

test: downstream wire-compat (vectors + smoke)#136
JohnMcLear merged 2 commits into
mainfrom
feat/downstream-wire-compat

Conversation

@JohnMcLear

@JohnMcLear JohnMcLear commented Jun 9, 2026

Copy link
Copy Markdown
Member

PR Summary by Qodo

Add wire-compat vector tests and optional live smoke test (node:test via tsx)
🧪 Tests ⚙️ Configuration changes 🕐 20-40 Minutes

Grey Divider

Walkthroughs

User Description

Phase 2 of ether/etherpad#7923.

Core PR #7923 (Phase 1) added a canonical wire-format fixture that every Etherpad client must decode identically. This PR is the downstream half for etherpad-cli-client: it vendors that fixture and verifies this repo's own Changeset/AttributePool decoders produce identical results.

This repo previously had no test runner at all — this adds the first one, using the built-in node:test driver run through tsx (the repo is TS, ESM, .js-extension imports).

What's added

  • test/fixtures/wire-vectors.json — the canonical fixture, vendored verbatim. 5 vectors: plain insert/delete, formatted insert, multiline insert, attribute reuse. Each says: applying changeset to initialText (with pool) yields resultText. Path overridable via ETHERPAD_WIRE_VECTORS.
  • test/vectors.test.ts (pnpm run test:vectors) — server-free, must pass. Reconstructs each AttributePool via AttributePool.fromJsonable, applies the changeset with the repo's Changeset.applyToText (text equality) and Changeset.applyToAText (drives the pool decode through mustGetAttrib), asserting byte-for-byte against resultText.
  • test/smoke.test.ts (pnpm run test:smoke) — live, skips cleanly with no server. Reads ETHERPAD_SMOKE_URL (default http://localhost:9003) and ETHERPAD_SMOKE_APIKEY. If no API key, or /api/ isn't reachable (1.5s probe), it t.skip()s instead of failing. With a server it: creates a pad via HTTP API → connects with the repo's own connect() socket.io client → appends via the USER_CHANGES write path (pad.append) → polls getText (bounded 10s) to confirm round-trip → deletes the pad.

Scripts

"test:vectors": "tsx --test test/vectors.test.ts",
"test:smoke":   "tsx --test test/smoke.test.ts",
"test":         "pnpm run test:vectors && pnpm run test:smoke"

These exact names are what the core CI manifest invokes.

Env contract

var default effect
ETHERPAD_WIRE_VECTORS vendored test/fixtures/wire-vectors.json override fixture path
ETHERPAD_SMOKE_URL http://localhost:9003 smoke target
ETHERPAD_SMOKE_APIKEY (unset → skip) HTTP API key

Results

pnpm run test:vectors5 pass / 0 fail, no decoder changes needed (this repo's decoders are already wire-compatible). pnpm run test:smoke with no server → 1 skipped, 0 fail (green). Lint clean.

Only test files, the fixture, the tsx devDep, and the three scripts are added; no source refactors.

🤖 Generated with Claude Code

AI Description
• Vendor canonical Etherpad wire-format vectors and validate Changeset/AttributePool decoding.
• Add a server-free vectors suite plus a live smoke suite that skips cleanly without credentials.
• Introduce tsx-driven node:test runner and CI-compatible test:* scripts.
Diagram
graph TD
  TR(["pnpm test (tsx + node:test)"]) --> TV["vectors.test.ts"] --> CL["Changeset + AttributePool"]
  TV --> FX["wire-vectors.json"]
  TR --> TS["smoke.test.ts"] --> ES{{"Etherpad server"}}
  TS --> CL
  subgraph Legend
    direction LR
    _t([Test runner/test]) ~~~ _f["File/fixture"] ~~~ _ext{{External service}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Adopt Vitest/Jest as the test runner
  • ➕ Richer ecosystem (watch mode, snapshots, coverage integrations)
  • ➕ Common conventions for TS projects
  • ➖ Heavier dependency footprint than node:test
  • ➖ More configuration required for ESM + TS + .js-extension imports
2. Compile tests with `tsc` and run `node --test` on emitted JS
  • ➕ Avoids runtime TS transpilation dependency
  • ➕ Maximizes parity with published JS artifacts
  • ➖ Adds build step and output management for tests
  • ➖ Slower iteration; more CI plumbing for temporary build artifacts
3. Containerize Etherpad for smoke test in CI
  • ➕ Makes the live smoke test deterministic and always exercised
  • ➕ Avoids reliance on external environment variables/availability
  • ➖ Increases CI complexity and runtime
  • ➖ Requires maintaining a compatible Etherpad image/config

Recommendation: Keep the current tsx + node:test approach: it’s minimal, matches Node 18+ ESM constraints, and cleanly separates deterministic (vectors) from opportunistic (smoke) coverage via skip logic. Consider containerizing Etherpad later if always-on smoke coverage becomes a CI requirement.

Grey Divider

File Changes

Tests (3)
wire-vectors.json Vendor canonical wire-format vectors fixture +7/-0

Vendor canonical wire-format vectors fixture

• Adds a JSON fixture containing five canonical changeset vectors, including inserts, deletes, multiline changes, formatting, and attribute reuse cases.

test/fixtures/wire-vectors.json


smoke.test.ts Add live HTTP+socket.io round-trip smoke test with skip behavior +119/-0

Add live HTTP+socket.io round-trip smoke test with skip behavior

• Introduces an integration test that creates a pad via HTTP API, connects using the repo's socket.io client, appends via the USER_CHANGES path, and polls getText for round-trip verification. Skips cleanly when the API key is missing or the server is unreachable; performs best-effort cleanup by deleting the pad.

test/smoke.test.ts


vectors.test.ts Add offline wire-compat vector replay tests for decoders +56/-0

Add offline wire-compat vector replay tests for decoders

• Loads the wire vector fixture (path overridable via 'ETHERPAD_WIRE_VECTORS') and replays each changeset against the initial text. Verifies both 'Changeset.applyToText' and 'Changeset.applyToAText' outputs match the expected result text, exercising 'AttributePool.fromJsonable' decoding.

test/vectors.test.ts


Other (2)
package.json Add tsx and node:test scripts for vectors and smoke suites +5/-1

Add tsx and node:test scripts for vectors and smoke suites

• Adds 'tsx' as a devDependency and introduces 'test', 'test:vectors', and 'test:smoke' scripts. Updates lint script formatting to allow additional scripts.

package.json


pnpm-lock.yaml Lockfile updates for tsx (and transitive esbuild/fsevents) +290/-0

Lockfile updates for tsx (and transitive esbuild/fsevents)

• Records the newly added 'tsx@4.22.4' dependency and its transitive dependencies (notably 'esbuild' platform packages and optional 'fsevents').

pnpm-lock.yaml


Grey Divider

Qodo Logo

Phase 2 of ether/etherpad#7923. Adds this repo's first test runner
(node:test via tsx) plus two suites:

- test:vectors — server-free, replays the canonical wire-format fixture
  through the repo's own Changeset/AttributePool decoders and asserts
  byte-for-byte text equality. All 5 vectors pass with no decoder changes.
- test:smoke — live HTTP+socket.io round-trip; skips cleanly when no
  server/API key is available.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0)

Grey Divider


Action required

1. close() may be undefined ✓ Resolved 🐞 Bug ☼ Reliability
Description
smoke.test.ts calls client?.close(), but connect() returns an EventEmitter immediately and only
assigns close() later inside an async superagent.get(...).then(...) callback; if that callback never
runs, the finally block calls an undefined value and throws TypeError. This can mask the real
failure and make the smoke test fail during cleanup.
Code

test/smoke.test.ts[R110-112]

+  } finally {
+    client?.close();
+    // Best-effort cleanup.
Evidence
The smoke test always sets client = connect(...) before entering the finally, and then calls
client?.close(). In connect(), ee.close is not defined until later inside the .then(...)
callback, so close can be unset when cleanup runs.

test/smoke.test.ts[76-112]
src/index.ts[110-143]
src/index.ts[241-243]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The smoke test calls `client?.close()` during cleanup. `connect()` returns before `close` is assigned, so `client` can be non-null while `client.close` is still `undefined`, causing a TypeError in the `finally` block.
### Issue Context
`connect()` assigns `ee.close = () => socket.close()` only after an async HTTP GET resolves.
### Fix
Preferred:
1) In `src/index.ts`, initialize `ee.close` immediately to a no-op and add a `.catch(...)` on the initial `superagent.get(...)` to emit `connect_error` and/or set `close` to a safe cleanup implementation.
2) In `test/smoke.test.ts`, defensively call `client?.close?.()` so cleanup never throws.
### Fix Focus Areas
- test/smoke.test.ts[76-112]
- src/index.ts[110-143]
- src/index.ts[241-243]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Dangling timeout keeps test alive ✓ Resolved 🐞 Bug ➹ Performance
Description
withTimeout() creates a setTimeout that is never cleared/unref'ed, so even if the wrapped promise
resolves quickly the pending timer can keep the Node test process alive until it fires (10s in the
socket-connect wait). This makes the smoke test unnecessarily slow and can delay CI completion.
Code

test/smoke.test.ts[R52-57]

+const withTimeout = <T>(p: Promise<T>, ms: number, label: string): Promise<T> =>
+  Promise.race([
+    p,
+    new Promise<T>((_, reject) =>
+      setTimeout(() => reject(new Error(`timeout: ${label}`)), ms)),
+  ]);
Evidence
The smoke test's withTimeout helper creates a setTimeout inside Promise.race without any
clearTimeout/unref, and it is used with a 10s timeout for socket connect—leaving a live timer behind
even on fast success.

test/smoke.test.ts[52-57]
test/smoke.test.ts[81-89]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`withTimeout()` schedules a timer but never cancels it when the wrapped promise settles. In Node.js, pending timers keep the event loop alive, so the test process can remain running until the timeout elapses.
### Issue Context
This impacts `test:smoke`, especially the `withTimeout(..., 10000, 'socket connect')` call.
### Fix
Change `withTimeout()` to store the timeout handle and `clearTimeout()` it in a `finally` after `Promise.race()` settles, and consider calling `timeoutId.unref()` so the timer does not keep the process alive.
### Fix Focus Areas
- test/smoke.test.ts[52-57]
- test/smoke.test.ts[81-89]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread test/smoke.test.ts
- Guard close() call (client?.close?.()): connect() assigns close only
  after its async bootstrap resolves, so the method may be undefined when
  the finally-block cleanup runs.
- Clear the withTimeout setTimeout in a finally so a fast-resolving promise
  no longer leaves a dangling timer keeping the test process alive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@JohnMcLear

Copy link
Copy Markdown
Member Author

Thanks Qodo — both bugs were valid and are fixed in e4b4643 (test-only, no source changes):

  1. close() may be undefinedconnect() assigns ee.close only inside its async bootstrap .then(), so the method can be undefined when the finally cleanup runs. Changed client?.close()client?.close?.() to guard the call itself, not just the (always-defined) reference. Kept the fix in the test rather than hardening src/index.ts, to honour this PR's no-source-refactor scope.
  2. Dangling timeoutwithTimeout now clears its setTimeout in a finally, so a fast-resolving promise no longer leaves a pending timer keeping the test process alive.

@JohnMcLear JohnMcLear merged commit ebc516e into main Jun 9, 2026
2 checks passed
@JohnMcLear JohnMcLear deleted the feat/downstream-wire-compat branch June 9, 2026 12:42
JohnMcLear added a commit to ether/etherpad that referenced this pull request Jun 9, 2026
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) <noreply@anthropic.com>
JohnMcLear added a commit to ether/etherpad that referenced this pull request Jun 9, 2026
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) <noreply@anthropic.com>
JohnMcLear added a commit to ether/etherpad that referenced this pull request Jun 9, 2026
…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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant