testing-platform-orchestrator.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. #!/usr/bin/env npx tsx
  2. /**
  3. * Test Orchestrator
  4. *
  5. * Automates server lifecycle for running spec files against the standalone server.
  6. *
  7. * Prerequisites:
  8. * Build standalone first: `npm run compile-standalone`
  9. *
  10. * Usage:
  11. * - Single file: `npm run test:tp-orchestrator path/to/spec.json`
  12. * - All specs dir: `npm run test:tp-orchestrator tests/specs`
  13. *
  14. * Flags:
  15. * --server-logs Show server logs (hidden by default)
  16. * --count=<number> Repeat execution N times (default: 1)
  17. * --fix Automatically update spec files with actual responses
  18. * --coverage Generate integration test coverage information
  19. *
  20. */
  21. import { ChildProcess, spawn } from "child_process"
  22. import fs from "fs"
  23. import minimist from "minimist"
  24. import net from "net"
  25. import path from "path"
  26. import kill from "tree-kill"
  27. let showServerLogs = false
  28. let fix = false
  29. let coverage = false
  30. const WAIT_SERVER_DEFAULT_TIMEOUT = 15000
  31. const usedPorts = new Set<number>()
  32. /**
  33. * Find an available TCP port within the given range [min, max].
  34. *
  35. * - Ports are allocated sequentially (starting at `min`) rather than randomly,
  36. * which avoids accidental reuse when running hundreds of tests in a row.
  37. * - Each successfully allocated port is tracked in `usedPorts` to guarantee
  38. * it is never handed out again within the lifetime of this orchestrator.
  39. * - Before returning, the function binds a temporary server to the port to
  40. * verify that the OS really considers it available, then immediately closes it.
  41. *
  42. * This approach makes the orchestrator much more robust on CI (e.g. GitHub Actions),
  43. * where a just-terminated server may leave its socket in TIME_WAIT and cause
  44. * flakiness if the same port is reallocated too soon.
  45. */
  46. async function getAvailablePort(min = 20000, max = 49151): Promise<number> {
  47. return new Promise((resolve, _) => {
  48. const tryPort = (candidate?: number) => {
  49. const port = candidate ?? Math.floor(Math.random() * (max - min + 1)) + min
  50. if (usedPorts.has(port)) {
  51. // already allocated in this run
  52. return tryPort()
  53. }
  54. const server = net.createServer()
  55. server.once("error", () => tryPort())
  56. server.once("listening", () => {
  57. server.close(() => {
  58. usedPorts.add(port) // mark reserved
  59. resolve(port)
  60. })
  61. })
  62. server.listen(port, "127.0.0.1")
  63. }
  64. tryPort()
  65. })
  66. }
  67. // Poll until a given TCP port on a host is accepting connections.
  68. async function waitForPort(port: number, host = "127.0.0.1", timeout = 10000): Promise<void> {
  69. const start = Date.now()
  70. const waitForPortSleepMs = 100
  71. while (Date.now() - start < timeout) {
  72. await new Promise((res) => setTimeout(res, waitForPortSleepMs))
  73. try {
  74. await new Promise<void>((resolve, reject) => {
  75. const socket = net.connect(port, host, () => {
  76. socket.destroy()
  77. resolve()
  78. })
  79. socket.on("error", reject)
  80. })
  81. return
  82. } catch {
  83. // try again
  84. }
  85. }
  86. throw new Error(`Timeout waiting for ${host}:${port}`)
  87. }
  88. async function startServer(): Promise<{ server: ChildProcess; grpcPort: string }> {
  89. const grpcPort = (await getAvailablePort()).toString()
  90. const hostbridgePort = (await getAvailablePort()).toString()
  91. const server = spawn("npx", ["tsx", "scripts/test-standalone-core-api-server.ts"], {
  92. stdio: showServerLogs ? "inherit" : "pipe",
  93. env: {
  94. ...process.env,
  95. PROTOBUS_PORT: grpcPort,
  96. HOSTBRIDGE_PORT: hostbridgePort,
  97. USE_C8: coverage ? "true" : "false",
  98. },
  99. })
  100. // Wait for either the server to become ready or fail on spawn error
  101. await Promise.race([
  102. waitForPort(Number(grpcPort), "127.0.0.1", WAIT_SERVER_DEFAULT_TIMEOUT),
  103. new Promise((_, reject) => server.once("error", reject)),
  104. ])
  105. return { server, grpcPort }
  106. }
  107. function stopServer(server: ChildProcess): Promise<void> {
  108. return new Promise((resolve) => {
  109. if (!server.pid) return resolve()
  110. kill(server.pid, "SIGINT", (err) => {
  111. if (err) console.warn("Failed to kill server process:", err)
  112. server.once("exit", () => resolve())
  113. })
  114. })
  115. }
  116. function runTestingPlatform(specFile: string, grpcPort: string): Promise<void> {
  117. return new Promise((resolve, reject) => {
  118. const testProcess = spawn("npx", ["ts-node", "index.ts", specFile, ...(fix ? ["--fix"] : [])], {
  119. cwd: path.join(process.cwd(), "testing-platform"),
  120. stdio: "inherit",
  121. env: {
  122. ...process.env,
  123. STANDALONE_GRPC_SERVER_PORT: grpcPort,
  124. },
  125. })
  126. testProcess.once("error", reject)
  127. testProcess.once("exit", (code) => {
  128. code === 0 ? resolve() : reject(new Error(`Exit code ${code}`))
  129. })
  130. })
  131. }
  132. async function runSpec(specFile: string): Promise<void> {
  133. const { server, grpcPort } = await startServer()
  134. try {
  135. await runTestingPlatform(specFile, grpcPort)
  136. console.log(`✅ ${path.basename(specFile)} passed`)
  137. } finally {
  138. await stopServer(server)
  139. }
  140. }
  141. function collectSpecFiles(inputPath: string): string[] {
  142. const fullPath = path.resolve(inputPath)
  143. if (!fs.existsSync(fullPath)) throw new Error(`Path does not exist: ${fullPath}`)
  144. const stat = fs.statSync(fullPath)
  145. if (stat.isDirectory()) {
  146. return fs
  147. .readdirSync(fullPath)
  148. .filter((f) => f.endsWith(".json"))
  149. .map((f) => path.join(fullPath, f))
  150. }
  151. if (fullPath.endsWith(".json")) return [fullPath]
  152. throw new Error("Spec path must be a JSON file or a folder containing JSON files")
  153. }
  154. async function runAll(inputPath: string, count: number) {
  155. const specFiles = collectSpecFiles(inputPath)
  156. if (specFiles.length === 0) {
  157. console.warn(`⚠️ No spec files found in ${inputPath}`)
  158. return
  159. }
  160. let success = 0
  161. let failure = 0
  162. const totalStart = Date.now()
  163. for (let i = 0; i < count; i++) {
  164. console.log(`\n🔁 Run #${i + 1} of ${count}`)
  165. for (const specFile of specFiles) {
  166. try {
  167. await runSpec(specFile)
  168. success++
  169. } catch (err) {
  170. console.error(`❌ run #${i + 1}: ${path.basename(specFile)} failed:`, (err as Error).message)
  171. failure++
  172. }
  173. }
  174. if (failure > 0) process.exitCode = 1
  175. }
  176. console.log(`✅ Passed: ${success}`)
  177. if (failure > 0) console.log(`❌ Failed: ${failure}`)
  178. console.log(`📋 Total specs: ${specFiles.length} Total runs: ${specFiles.length * count}`)
  179. console.log(`🏁 All runs completed in ${((Date.now() - totalStart) / 1000).toFixed(2)}s`)
  180. }
  181. async function main() {
  182. const args = minimist(process.argv.slice(2), { default: { count: 1 } })
  183. const inputPath = args._[0]
  184. const count = Number(args.count)
  185. showServerLogs = Boolean(args["server-logs"])
  186. fix = Boolean(args["fix"])
  187. coverage = Boolean(args["coverage"])
  188. if (!inputPath) {
  189. console.error(
  190. "Usage: npx tsx scripts/testing-platform-orchestrator.ts <spec-file-or-folder> [--count=N] [--server-logs] [--fix] [--coverage]",
  191. )
  192. process.exit(1)
  193. }
  194. await runAll(inputPath, count)
  195. }
  196. if (require.main === module) {
  197. main().catch((err) => {
  198. console.error("❌ Fatal error:", err)
  199. process.exit(1)
  200. })
  201. }