From de9d8bcbd2bed0c6b69a3023a291ae7d185158f3 Mon Sep 17 00:00:00 2001 From: John McLear Date: Tue, 9 Jun 2026 11:42:43 +0100 Subject: [PATCH 1/2] test: downstream wire-compat (vectors + smoke) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- package.json | 6 +- pnpm-lock.yaml | 290 ++++++++++++++++++++++++++++++++ test/fixtures/wire-vectors.json | 7 + test/smoke.test.ts | 119 +++++++++++++ test/vectors.test.ts | 56 ++++++ 5 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/wire-vectors.json create mode 100644 test/smoke.test.ts create mode 100644 test/vectors.test.ts diff --git a/package.json b/package.json index c1cfa2d..8722753 100644 --- a/package.json +++ b/package.json @@ -39,13 +39,17 @@ "@types/superagent": "^8.1.7", "eslint": "^9.32.0", "eslint-config-etherpad": "^3.0.13", + "tsx": "^4.22.4", "typescript": "^5.4.2" }, "scripts": { "build": "tsc", "prepublishOnly": "npm run build", "lint": "eslint .", - "lint:fix": "eslint --fix ." + "lint:fix": "eslint --fix .", + "test": "pnpm run test:vectors && pnpm run test:smoke", + "test:vectors": "tsx --test test/vectors.test.ts", + "test:smoke": "tsx --test test/smoke.test.ts" }, "engines": { "node": ">=18.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61be387..0129aa2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: eslint-config-etherpad: specifier: ^3.0.13 version: 3.0.22(eslint@9.32.0)(typescript@5.9.2) + tsx: + specifier: ^4.22.4 + version: 4.22.4 typescript: specifier: ^5.4.2 version: 5.9.2 @@ -42,6 +45,162 @@ packages: '@emnapi/wasi-threads@1.0.4': resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==} + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.7.0': resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -549,6 +708,11 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} @@ -779,6 +943,11 @@ packages: resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} engines: {node: '>=14.0.0'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1324,6 +1493,11 @@ packages: peerDependencies: typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1432,6 +1606,84 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.28.0': + optional: true + + '@esbuild/android-arm64@0.28.0': + optional: true + + '@esbuild/android-arm@0.28.0': + optional: true + + '@esbuild/android-x64@0.28.0': + optional: true + + '@esbuild/darwin-arm64@0.28.0': + optional: true + + '@esbuild/darwin-x64@0.28.0': + optional: true + + '@esbuild/freebsd-arm64@0.28.0': + optional: true + + '@esbuild/freebsd-x64@0.28.0': + optional: true + + '@esbuild/linux-arm64@0.28.0': + optional: true + + '@esbuild/linux-arm@0.28.0': + optional: true + + '@esbuild/linux-ia32@0.28.0': + optional: true + + '@esbuild/linux-loong64@0.28.0': + optional: true + + '@esbuild/linux-mips64el@0.28.0': + optional: true + + '@esbuild/linux-ppc64@0.28.0': + optional: true + + '@esbuild/linux-riscv64@0.28.0': + optional: true + + '@esbuild/linux-s390x@0.28.0': + optional: true + + '@esbuild/linux-x64@0.28.0': + optional: true + + '@esbuild/netbsd-arm64@0.28.0': + optional: true + + '@esbuild/netbsd-x64@0.28.0': + optional: true + + '@esbuild/openbsd-arm64@0.28.0': + optional: true + + '@esbuild/openbsd-x64@0.28.0': + optional: true + + '@esbuild/openharmony-arm64@0.28.0': + optional: true + + '@esbuild/sunos-x64@0.28.0': + optional: true + + '@esbuild/win32-arm64@0.28.0': + optional: true + + '@esbuild/win32-ia32@0.28.0': + optional: true + + '@esbuild/win32-x64@0.28.0': + optional: true + '@eslint-community/eslint-utils@4.7.0(eslint@9.32.0)': dependencies: eslint: 9.32.0 @@ -1998,6 +2250,35 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + escape-string-regexp@1.0.5: {} escape-string-regexp@4.0.0: {} @@ -2286,6 +2567,9 @@ snapshots: dezalgo: 1.0.4 once: 1.4.0 + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -2884,6 +3168,12 @@ snapshots: tslib: 1.14.1 typescript: 5.9.2 + tsx@4.22.4: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/test/fixtures/wire-vectors.json b/test/fixtures/wire-vectors.json new file mode 100644 index 0000000..6b8ff01 --- /dev/null +++ b/test/fixtures/wire-vectors.json @@ -0,0 +1,7 @@ +[ + {"name":"plain-insert","initialText":"abc\n","changeset":"Z:4>3=3+3$XYZ","pool":{"numToAttrib":{},"nextNum":0},"resultText":"abcXYZ\n"}, + {"name":"plain-delete","initialText":"abcdef\n","changeset":"Z:7<3=1-3$","pool":{"numToAttrib":{},"nextNum":0},"resultText":"aef\n"}, + {"name":"formatted-insert","initialText":"abc\n","changeset":"Z:4>4=3*0+4$bold","pool":{"numToAttrib":{"0":["bold","true"]},"nextNum":1},"resultText":"abcbold\n"}, + {"name":"multiline-insert","initialText":"abc\n","changeset":"Z:4>8=3|2+8$one\ntwo\n","pool":{"numToAttrib":{},"nextNum":0},"resultText":"abcone\ntwo\n\n"}, + {"name":"attrib-reuse","initialText":"abc\n","changeset":"Z:4>2*0+1=3*0+1$AB","pool":{"numToAttrib":{"0":["bold","true"]},"nextNum":1},"resultText":"AabcB\n"} +] diff --git a/test/smoke.test.ts b/test/smoke.test.ts new file mode 100644 index 0000000..c080e5c --- /dev/null +++ b/test/smoke.test.ts @@ -0,0 +1,119 @@ +/** + * Live wire round-trip smoke test. + * + * Phase 2 of ether/etherpad#7923. Against a real Etherpad server this: + * 1. creates a pad via the HTTP API, + * 2. connects with the repo's own socket.io client (connect()), + * 3. appends text via the client's USER_CHANGES write path (pad.append), + * 4. reads the pad back via the HTTP API getText and asserts round-trip. + * + * Env contract: + * ETHERPAD_SMOKE_URL base server URL (default http://localhost:9003) + * ETHERPAD_SMOKE_APIKEY HTTP API key (required to actually run) + * + * If the server is unreachable (or no API key is provided) the test SKIPS + * cleanly rather than failing, so it is safe to run in server-free CI. + */ + +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import superagent from 'superagent'; + +import {connect, type PadState} from '../src/index.js'; + +const BASE_URL = (process.env.ETHERPAD_SMOKE_URL || 'http://localhost:9003').replace(/\/+$/, ''); +const API_KEY = process.env.ETHERPAD_SMOKE_APIKEY || ''; +const API = `${BASE_URL}/api/1`; + +// Quick reachability probe: hit /api/ with a short timeout. Any successful +// HTTP response means the server is up; a network error means skip. +const isReachable = async (): Promise => { + try { + await superagent.get(`${BASE_URL}/api/`).timeout({deadline: 1500, response: 1500}); + return true; + } catch (err) { + // A 4xx/5xx still proves the server is answering; only treat transport + // errors (no response) as "unreachable". + return Boolean((err as {response?: unknown}).response); + } +}; + +const apiCall = async ( + method: string, + params: Record, +): Promise<{code: number; message: string; data: unknown}> => { + const res = await superagent + .get(`${API}/${method}`) + .query({apikey: API_KEY, ...params}) + .timeout({deadline: 5000, response: 5000}); + return res.body as {code: number; message: string; data: unknown}; +}; + +const withTimeout = (p: Promise, ms: number, label: string): Promise => + Promise.race([ + p, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`timeout: ${label}`)), ms)), + ]); + +test('live wire round-trip via USER_CHANGES', async (t) => { + if (!API_KEY) { + t.skip('ETHERPAD_SMOKE_APIKEY not set — skipping live smoke test'); + return; + } + if (!(await isReachable())) { + t.skip(`Etherpad not reachable at ${BASE_URL} — skipping live smoke test`); + return; + } + + const padId = `phase2-smoke-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + const payload = `hello-wire-${Date.now()}`; + + // 1. Create the pad via the HTTP API. + const created = await apiCall('createPad', {padID: padId}); + assert.equal(created.code, 0, `createPad failed: ${created.message}`); + + let client: ReturnType | undefined; + try { + // 2. Connect with the repo's own socket.io client. + client = connect(`${BASE_URL}/p/${padId}`); + + await withTimeout( + new Promise((resolve, reject) => { + client!.on('connected', (_state: PadState) => resolve()); + client!.on('connect_error', (e: unknown) => reject(new Error(`connect_error: ${String(e)}`))); + client!.on('disconnect', (e: unknown) => reject(new Error(`disconnect: ${String(e)}`))); + }), + 10000, + 'socket connect', + ); + + // 3. Append text via the client's USER_CHANGES write path. + client.append(payload); + + // 4. Poll the HTTP API until the text round-trips (bounded). + const deadline = Date.now() + 10000; + let roundTripped = false; + let lastText = ''; + while (Date.now() < deadline) { + const got = await apiCall('getText', {padID: padId}); + assert.equal(got.code, 0, `getText failed: ${got.message}`); + lastText = ((got.data as {text?: string}).text) || ''; + if (lastText.includes(payload)) { + roundTripped = true; + break; + } + await new Promise((r) => setTimeout(r, 250)); + } + + assert.ok(roundTripped, `appended text did not round-trip; pad contained: ${JSON.stringify(lastText)}`); + } finally { + client?.close(); + // Best-effort cleanup. + try { + await apiCall('deletePad', {padID: padId}); + } catch { + // ignore cleanup failures + } + } +}); diff --git a/test/vectors.test.ts b/test/vectors.test.ts new file mode 100644 index 0000000..34fda26 --- /dev/null +++ b/test/vectors.test.ts @@ -0,0 +1,56 @@ +/** + * Downstream wire-compatibility vectors. + * + * Phase 2 of ether/etherpad#7923: the core repo ships a canonical wire-format + * fixture, and every client must decode it identically. This test loads that + * fixture and replays each vector through THIS repo's own Changeset / + * AttributePool decoders, asserting the resulting text matches byte-for-byte. + * + * The fixture path is overridable via the ETHERPAD_WIRE_VECTORS env var, + * defaulting to the vendored copy under test/fixtures/. + */ + +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {fileURLToPath} from 'node:url'; +import {dirname, join} from 'node:path'; + +import * as Changeset from '../src/Changeset.js'; +import AttributePool, {type JsonableAttributePool} from '../src/AttributePool.js'; + +interface WireVector { + name: string; + initialText: string; + changeset: string; + pool: JsonableAttributePool; + resultText: string; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const defaultPath = join(here, 'fixtures', 'wire-vectors.json'); +const fixturePath = process.env.ETHERPAD_WIRE_VECTORS || defaultPath; + +const vectors = JSON.parse(readFileSync(fixturePath, 'utf8')) as WireVector[]; + +assert.ok(Array.isArray(vectors) && vectors.length > 0, `no vectors found in ${fixturePath}`); + +for (const vector of vectors) { + test(`wire-vector: ${vector.name}`, () => { + // Reconstruct the attribute pool exactly as it arrived on the wire. + const pool = new AttributePool().fromJsonable(vector.pool); + + // Apply the changeset to the starting text using the repo's own decoder. + // applyToText is text-only (attributes do not change document text), which + // is exactly what the resultText assertion checks; the pool round-trip is + // exercised so a broken fromJsonable would surface via applyToAText below. + const text = Changeset.applyToText(vector.changeset, vector.initialText); + assert.equal(text, vector.resultText, `text mismatch for ${vector.name}`); + + // Also drive the full attributed-text path so the AttributePool decode is + // genuinely exercised (mustGetAttrib resolves the *N slots against pool). + const atext = {text: vector.initialText, attribs: ''}; + const applied = Changeset.applyToAText(vector.changeset, atext, pool); + assert.equal(applied.text, vector.resultText, `atext mismatch for ${vector.name}`); + }); +} From e4b4643c7185c2059375c430e07ca2e65d01999a Mon Sep 17 00:00:00 2001 From: John McLear Date: Tue, 9 Jun 2026 11:48:02 +0100 Subject: [PATCH 2/2] test: address Qodo review on smoke test - 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) --- test/smoke.test.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/test/smoke.test.ts b/test/smoke.test.ts index c080e5c..4b6ba96 100644 --- a/test/smoke.test.ts +++ b/test/smoke.test.ts @@ -49,12 +49,21 @@ const apiCall = async ( return res.body as {code: number; message: string; data: unknown}; }; -const withTimeout = (p: Promise, ms: number, label: string): Promise => - Promise.race([ - p, - new Promise((_, reject) => - setTimeout(() => reject(new Error(`timeout: ${label}`)), ms)), - ]); +const withTimeout = async (p: Promise, ms: number, label: string): Promise => { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + p, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`timeout: ${label}`)), ms); + }), + ]); + } finally { + // Clear the pending timer so a fast-resolving promise doesn't leave a + // dangling timeout keeping the test process alive until it fires. + if (timer) clearTimeout(timer); + } +}; test('live wire round-trip via USER_CHANGES', async (t) => { if (!API_KEY) { @@ -108,7 +117,10 @@ test('live wire round-trip via USER_CHANGES', async (t) => { assert.ok(roundTripped, `appended text did not round-trip; pad contained: ${JSON.stringify(lastText)}`); } finally { - client?.close(); + // connect() assigns close() only after its async bootstrap resolves, so + // the method may still be undefined here — guard the call itself, not just + // the (always-defined) client reference. + client?.close?.(); // Best-effort cleanup. try { await apiCall('deletePad', {padID: padId});