protocol-mirror.e2e.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  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 } 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 the wire field names the TS side declares. `global` is the
  54. // reserved-keyword key the Python side carries via functional TypedDict —
  55. // catching exactly the round-12 kind of drift (a renamed/dropped field, an
  56. // optional field the other side made required).
  57. const probe = [
  58. 'import json, sys',
  59. `sys.path.insert(0, ${JSON.stringify(pyDir)})`,
  60. 'import protocol as p',
  61. 'def keys(td): return {"required": sorted(td.__required_keys__), "optional": sorted(td.__optional_keys__)}',
  62. 'print(json.dumps({',
  63. ' "BootMessage": keys(p.BootMessage),',
  64. ' "Namespace": keys(p.Namespace),',
  65. ' "RunMessage": keys(p.RunMessage),',
  66. ' "BootAckMessage": keys(p.BootAckMessage),',
  67. ' "CallMessage": keys(p.CallMessage),',
  68. ' "LogMessage": keys(p.LogMessage),',
  69. ' "DoneErrorField": keys(p.DoneErrorField),',
  70. ' "DoneMessage": keys(p.DoneMessage),',
  71. ' "ErrorClass": keys(p.ErrorClass),',
  72. '}))',
  73. ].join('\n')
  74. const { stdout } = await execFileAsync('python3', ['-I', '-c', probe])
  75. const seen = JSON.parse(stdout) as Record<string, { required: string[]; optional: string[] }>
  76. // The wire field sets each frame carries, mirroring src/protocol.ts. `global`
  77. // is the JSON key `CallMessage`/`Namespace` send (a Python keyword, declared
  78. // functionally on the Python side).
  79. expect(seen).toEqual({
  80. BootMessage: { required: ['addressSpaceBytes', 'cpuSeconds', 'maxLogBytes', 'maxValueBytes', 'namespaces', 'type'], optional: [] },
  81. Namespace: { required: ['global', 'names'], optional: ['errorClass'] },
  82. RunMessage: { required: ['program', 'type'], optional: [] },
  83. BootAckMessage: { required: ['type'], optional: [] },
  84. CallMessage: { required: ['args', 'global', 'id', 'name', 'type'], optional: [] },
  85. LogMessage: { required: ['text', 'type'], optional: ['truncated'] },
  86. DoneErrorField: { required: ['kind', 'message'], optional: [] },
  87. DoneMessage: { required: ['type'], optional: ['error', 'value'] },
  88. ErrorClass: { required: ['memberNameProperty', 'name'], optional: [] },
  89. })
  90. })
  91. })
  92. it('names the py/ directory that ships with the package', () => {
  93. // Resolves py/ relative to this test file; the same directory ships in the
  94. // package.json `files` whitelist (`py/**/*.py`). The tests/ directory itself
  95. // is not published — this asserts the source-tree layout the mirror test
  96. // depends on, so it holds even when python3 is absent from the runner.
  97. expect(existsSync(pyDir)).toBe(true)
  98. })