1
0

protocol-mirror.e2e.ts 4.3 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
  1. import { execFile } from 'node:child_process'
  2. import { existsSync } from 'node:fs'
  3. import { fileURLToPath } from 'node:url'
  4. import { promisify } from 'node:util'
  5. import { describe, expect, it } from 'vitest'
  6. import { logTruncationMarker, WIRE_FRAME_FIELDS } from '../src/protocol.ts'
  7. /**
  8. * Cross-language mirror check between `src/protocol.ts` and `py/protocol.py`,
  9. * spawning a real `python3` to read the Python side. Two things are asserted:
  10. * the runtime surfaces both sides EXECUTE against — `PROTOCOL_FD` and the log
  11. * truncation marker text, where a drift silently corrupts a live run — and the
  12. * per-frame wire field sets (required/optional keys of each `TypedDict`), which
  13. * turns the otherwise review-only shape mirror into an executable check that
  14. * catches the round-12 kind of drift (a renamed/dropped field, or one side
  15. * making a field optional the other requires). Self-skips when no `python3` is
  16. * on PATH — CI provides one; the pure-TS `protocol.spec.ts` covers the host
  17. * codec unconditionally.
  18. */
  19. const execFileAsync = promisify(execFile)
  20. const pyDir = fileURLToPath(new URL('../py', import.meta.url))
  21. async function hasPython3(): Promise<boolean> {
  22. try {
  23. await execFileAsync('python3', ['--version'])
  24. return true
  25. } catch {
  26. return false
  27. }
  28. }
  29. const python3Available = await hasPython3()
  30. describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', () => {
  31. it('agrees on PROTOCOL_FD and the log truncation marker across byte budgets', async () => {
  32. const budgets = [1, 65536, 1048576]
  33. const probe = [
  34. 'import json, sys',
  35. `sys.path.insert(0, ${JSON.stringify(pyDir)})`,
  36. 'from protocol import PROTOCOL_FD, log_truncation_marker',
  37. `budgets = ${JSON.stringify(budgets)}`,
  38. 'print(json.dumps({',
  39. ' "fd": PROTOCOL_FD,',
  40. ' "markers": [log_truncation_marker(b) for b in budgets],',
  41. '}))',
  42. ].join('\n')
  43. const { stdout } = await execFileAsync('python3', ['-I', '-c', probe])
  44. const seen = JSON.parse(stdout) as { fd: number; markers: string[] }
  45. // fd 3 is the wire contract, not a tunable: the host pins it positionally
  46. // when it spawns the child.
  47. expect(seen.fd).toBe(3)
  48. expect(seen.markers).toEqual(budgets.map(budget => logTruncationMarker(budget)))
  49. })
  50. it('agrees on every frame type\'s wire field set between the TS and Python declarations', async () => {
  51. // Turn the TypedDict mirror from a review-only obligation into an executable
  52. // check: read each Python TypedDict's required/optional key sets and assert
  53. // them against WIRE_FRAME_FIELDS — the TS-side source of truth bound to the
  54. // frame interfaces by `satisfies` in protocol.ts, so a rename or a removed
  55. // field on the TS side breaks typecheck and an added field breaks this
  56. // comparison (the Python side would carry it). Covers the reply frames too.
  57. // `global` is the reserved-keyword wire key the Python side carries via a
  58. // functional TypedDict. This catches the round-12 kind of drift on EITHER
  59. // side of the wire.
  60. const pyNames = Object.keys(WIRE_FRAME_FIELDS)
  61. const probe = [
  62. 'import json, sys',
  63. `sys.path.insert(0, ${JSON.stringify(pyDir)})`,
  64. 'import protocol as p',
  65. 'def keys(td): return {"required": sorted(td.__required_keys__), "optional": sorted(td.__optional_keys__)}',
  66. `names = ${JSON.stringify(pyNames)}`,
  67. 'print(json.dumps({n: keys(getattr(p, n)) for n in names}))',
  68. ].join('\n')
  69. const { stdout } = await execFileAsync('python3', ['-I', '-c', probe])
  70. const seen = JSON.parse(stdout) as Record<string, { required: string[]; optional: string[] }>
  71. // Normalize the TS source of truth to the same sorted shape Python reports.
  72. const expected = Object.fromEntries(
  73. Object.entries(WIRE_FRAME_FIELDS).map(([name, sets]) => [
  74. name,
  75. { required: [...sets.required].sort(), optional: [...sets.optional].sort() },
  76. ]),
  77. )
  78. expect(seen).toEqual(expected)
  79. })
  80. })
  81. it('names the py/ directory that ships with the package', () => {
  82. // Resolves py/ relative to this test file; the same directory ships in the
  83. // package.json `files` whitelist (`py/**/*.py`). The tests/ directory itself
  84. // is not published — this asserts the source-tree layout the mirror test
  85. // depends on, so it holds even when python3 is absent from the runner.
  86. expect(existsSync(pyDir)).toBe(true)
  87. })