import-boundaries.test.ts 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. import { describe, expect, test } from "bun:test"
  2. import { mkdtemp, rm } from "node:fs/promises"
  3. import path from "node:path"
  4. const root = path.resolve(import.meta.dir, "../../..")
  5. describe("CLI frontend import boundaries", () => {
  6. test("does not import Core directly", async () => {
  7. const glob = new Bun.Glob("{src,test}/**/*.{ts,tsx}")
  8. const imports: string[] = []
  9. for await (const file of glob.scan({ cwd: path.join(root, "packages/cli") })) {
  10. const source = await Bun.file(path.join(root, "packages/cli", file)).text()
  11. if (/["']@opencode-ai\/core(?:\/[^"']*)?["']/.test(source)) imports.push(file)
  12. }
  13. expect(imports).toEqual([])
  14. })
  15. test("exposes only the intentional package entrypoints", async () => {
  16. const run = await import("@opencode-ai/cli/run")
  17. const mini = await import("@opencode-ai/tui/mini")
  18. const tool = await import("@opencode-ai/tui/mini/tool")
  19. const cli = await Bun.file(path.join(root, "packages/cli/package.json")).json()
  20. expect(Object.keys(run).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
  21. expect(Object.keys(mini).sort()).toEqual(["runMiniFrontend"])
  22. expect(Object.keys(tool).sort()).toEqual([
  23. "nonEmptyToolContent",
  24. "readDisplayText",
  25. "toolInlineInfo",
  26. "toolOutputText",
  27. ])
  28. expect(Object.keys(cli.exports).filter((key) => key === "./mini" || key.startsWith("./mini/"))).toEqual([])
  29. })
  30. test("keeps run and Mini on separate evaluation graphs", async () => {
  31. const run = await bundleInputs("packages/cli/src/commands/handlers/run.ts")
  32. expect(run).toContain("packages/cli/src/run/run.ts")
  33. expect(run).toContain("packages/tui/src/mini/tool.ts")
  34. expect(run).not.toContain("packages/tui/src/mini/runtime.ts")
  35. expect(run).not.toContain("packages/tui/src/mini/runtime.lifecycle.ts")
  36. expect(run).not.toContain("packages/tui/src/mini/footer.ts")
  37. expect(run).not.toContain("packages/tui/src/mini/scrollback.surface.ts")
  38. expect(run).not.toContain("packages/tui/src/runtime.tsx")
  39. const mini = await bundleInputs("packages/cli/src/commands/handlers/mini.ts")
  40. expect(mini).toContain("packages/cli/src/mini.ts")
  41. expect(mini).toContain("packages/tui/src/mini/index.ts")
  42. expect(mini).toContain("packages/tui/src/mini/runtime.ts")
  43. expect(mini).not.toContain("packages/cli/src/run/run.ts")
  44. expect(mini).not.toContain("packages/cli/src/run/noninteractive.ts")
  45. expect(mini).not.toContain("packages/cli/src/run/ui.ts")
  46. expect(mini).not.toContain("packages/tui/src/runtime.tsx")
  47. })
  48. test("keeps TUI Mini independent from Core, Server, and CLI", async () => {
  49. const glob = new Bun.Glob("**/*.{ts,tsx}")
  50. const imports: string[] = []
  51. for await (const file of glob.scan({ cwd: path.join(root, "packages/tui/src/mini") })) {
  52. const source = await Bun.file(path.join(root, "packages/tui/src/mini", file)).text()
  53. if (/["']@opencode-ai\/(?:core|server|cli)(?:\/[^"']*)?["']/.test(source)) imports.push(file)
  54. }
  55. expect(imports).toEqual([])
  56. const graph = await bundleInputs("packages/tui/src/mini/index.ts")
  57. expect(graph.filter((file) => file.startsWith("packages/core/"))).toEqual([])
  58. expect(graph.filter((file) => file.startsWith("packages/cli/") || file.startsWith("packages/server/"))).toEqual([])
  59. })
  60. })
  61. async function bundleInputs(entrypoint: string) {
  62. const temporary = await mkdtemp(path.join(import.meta.dir, ".import-boundary-"))
  63. const metafile = path.join(temporary, "meta.json")
  64. try {
  65. const child = Bun.spawn(
  66. [
  67. process.execPath,
  68. "build",
  69. entrypoint,
  70. "--target=bun",
  71. "--format=esm",
  72. "--packages=bundle",
  73. "--external=@opentui/core-*",
  74. `--metafile=${metafile}`,
  75. `--outdir=${path.join(temporary, "out")}`,
  76. ],
  77. { cwd: root, stdout: "pipe", stderr: "pipe" },
  78. )
  79. const [exitCode, stdout, stderr] = await Promise.all([
  80. child.exited,
  81. new Response(child.stdout).text(),
  82. new Response(child.stderr).text(),
  83. ])
  84. if (exitCode !== 0) throw new Error(stdout + stderr)
  85. const metadata = await Bun.file(metafile).json()
  86. return Object.keys(metadata.inputs).map((input) =>
  87. path.relative(root, path.resolve(root, input)).replaceAll(path.sep, "/"),
  88. )
  89. } finally {
  90. await rm(temporary, { recursive: true, force: true })
  91. }
  92. }