| 1 | // Protocol conformance: the shared corpus that the Rust side also parses. |
| 2 | import { test } from 'node:test' |
| 3 | import assert from 'node:assert/strict' |
| 4 | import { readdirSync, readFileSync } from 'node:fs' |
| 5 | import { join } from 'node:path' |
| 6 | import { FIXTURES } from './harness.mjs' |
| 7 | import { FrameDecoder, encodeFrame, validateMessage, MAGIC, MAX_FRAME } from '../dist/protocol.mjs' |
| 8 | |
| 9 | const corpusDir = join(FIXTURES, 'protocol') |
| 10 | const corpus = readdirSync(corpusDir) |
| 11 | .filter((name) => name.endsWith('.json')) |
| 12 | .sort() |
| 13 | .map((name) => ({ name, ...JSON.parse(readFileSync(join(corpusDir, name), 'utf8')) })) |
| 14 | |
| 15 | test('the corpus is non-trivial in both directions', () => { |
| 16 | for (const direction of ['host_to_core', 'core_to_host']) { |
| 17 | assert.ok(corpus.some((c) => c.direction === direction && c.valid), `${direction} valid cases`) |
| 18 | assert.ok(corpus.some((c) => c.direction === direction && !c.valid), `${direction} invalid cases`) |
| 19 | } |
| 20 | }) |
| 21 | |
| 22 | // These shared corpus rows apply to both tiers; the generated method table |
| 23 | // governs builtin-only production requests. |
| 24 | for (const entry of corpus) { |
| 25 | for (const tier of ['plugin', 'builtin']) { |
| 26 | test(`corpus ${entry.name} (${tier} tier): ${entry.valid ? 'parses and round-trips' : 'is rejected'}`, () => { |
| 27 | if (!entry.valid) { |
| 28 | assert.throws(() => validateMessage(entry.frame, entry.direction, tier)) |
| 29 | return |
| 30 | } |
| 31 | validateMessage(entry.frame, entry.direction, tier) |
| 32 | const [decoded] = new FrameDecoder().push(encodeFrame(entry.frame)) |
| 33 | assert.deepEqual(decoded, entry.frame) |
| 34 | validateMessage(decoded, entry.direction, tier) |
| 35 | }) |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | // The tier rule, against a table with a method reserved for the built-in tier |
| 40 | // independently of the generated production table. |
| 41 | const RESERVED = [ |
| 42 | { name: 'test/reserved', direction: 'host_to_core', request: true, params: 'EmptyParams', tiers: ['builtin'] }, |
| 43 | { name: 'test/reserved-in', direction: 'core_to_host', request: false, params: 'EmptyParams', tiers: ['builtin'] }, |
| 44 | { name: 'test/shared', direction: 'host_to_core', request: true, params: 'EmptyParams', tiers: ['plugin', 'builtin'] }, |
| 45 | ] |
| 46 | |
| 47 | test('a method reserved for the builtin tier is refused to a plugin-tier host in both directions', () => { |
| 48 | const asHost = { jsonrpc: '2.0', id: 1, method: 'test/reserved', params: {} } |
| 49 | const toHost = { jsonrpc: '2.0', method: 'test/reserved-in', params: {} } |
| 50 | assert.equal(validateMessage(asHost, 'host_to_core', 'builtin', RESERVED).method, 'test/reserved') |
| 51 | assert.equal(validateMessage(toHost, 'core_to_host', 'builtin', RESERVED).method, 'test/reserved-in') |
| 52 | assert.throws(() => validateMessage(asHost, 'host_to_core', 'plugin', RESERVED), /not allowed on the plugin tier/) |
| 53 | assert.throws(() => validateMessage(toHost, 'core_to_host', 'plugin', RESERVED), /not allowed on the plugin tier/) |
| 54 | // A method open to both tiers is open to both, and a method the table lacks is unknown, not "reserved". |
| 55 | for (const tier of ['plugin', 'builtin']) { |
| 56 | validateMessage({ jsonrpc: '2.0', id: 2, method: 'test/shared', params: {} }, 'host_to_core', tier, RESERVED) |
| 57 | assert.throws(() => validateMessage({ jsonrpc: '2.0', id: 3, method: 'test/none', params: {} }, 'host_to_core', tier, RESERVED), /unknown/) |
| 58 | } |
| 59 | // The reservation names a direction: the same name the other way is not the reserved row. |
| 60 | assert.throws(() => validateMessage({ jsonrpc: '2.0', id: 4, method: 'test/reserved', params: {} }, 'core_to_host', 'builtin', RESERVED), /unknown/) |
| 61 | }) |
| 62 | |
| 63 | |
| 64 | test('frames split across chunks decode once, in order', () => { |
| 65 | const bytes = Buffer.concat([encodeFrame({ a: 1 }), encodeFrame({ b: 'ü' })]) |
| 66 | const decoder = new FrameDecoder() |
| 67 | const out = [] |
| 68 | for (let i = 0; i < bytes.length; i += 3) out.push(...decoder.push(bytes.subarray(i, i + 3))) |
| 69 | assert.deepEqual(out, [{ a: 1 }, { b: 'ü' }]) |
| 70 | }) |
| 71 | |
| 72 | test('bad magic, oversized length, and non-JSON payloads are framing errors', () => { |
| 73 | assert.throws(() => new FrameDecoder().push(Buffer.from('NOPE\x00\x00\x00\x00')), /magic/) |
| 74 | const huge = Buffer.alloc(8) |
| 75 | MAGIC.copy(huge) |
| 76 | huge.writeUInt32LE(MAX_FRAME + 1, 4) |
| 77 | assert.throws(() => new FrameDecoder().push(huge), /MAX_FRAME/) |
| 78 | const bad = Buffer.alloc(9) |
| 79 | MAGIC.copy(bad) |
| 80 | bad.writeUInt32LE(1, 4) |
| 81 | bad.write('{', 8) |
| 82 | assert.throws(() => new FrameDecoder().push(bad), /not JSON/) |
| 83 | }) |
| 84 | |
| 85 | test('wire JSON guard refuses values that would serialize differently', async () => { |
| 86 | const { isJson } = await import('../src/json.ts') |
| 87 | assert.equal(isJson({ a: [1, 'two', null, { b: true }] }), true) |
| 88 | // A hole serializes as null, a non-index array property is dropped, a symbol |
| 89 | // key is ignored, and a getter can answer differently when serialized. |
| 90 | assert.equal(isJson(new Array(2)), false) |
| 91 | const extra = [1] |
| 92 | extra.note = 'dropped' |
| 93 | assert.equal(isJson(extra), false) |
| 94 | assert.equal(isJson({ [Symbol('s')]: 1 }), false) |
| 95 | let reads = 0 |
| 96 | assert.equal(isJson({ get value() { reads += 1; return reads } }), false) |
| 97 | assert.equal(isJson(Object.defineProperty({}, 'hidden', { value: 1, enumerable: false })), false) |
| 98 | assert.equal(isJson(new Date(0)), false) |
| 99 | }) |
| 100 |