import-export.test.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. import { expect, test } from "bun:test"
  2. import fs from "node:fs/promises"
  3. import os from "node:os"
  4. import path from "node:path"
  5. import { OPENCODE_VERSION } from "../src/version"
  6. import { writeExport } from "../src/commands/handlers/export"
  7. const info = {
  8. id: "ses_export_test",
  9. projectID: "global",
  10. cost: 0,
  11. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  12. time: { created: 1, updated: 2 },
  13. title: "Exported session",
  14. location: { directory: "/project" },
  15. }
  16. const transfer = {
  17. info,
  18. messages: [
  19. { id: "msg_first", type: "user", text: "First", time: { created: 1 } },
  20. { id: "msg_second", type: "user", text: "Second", time: { created: 2 } },
  21. ],
  22. }
  23. const sanitizedTransfer = {
  24. info: {
  25. ...info,
  26. title: "[redacted:session-title:ses_export_test]",
  27. location: { directory: "/[redacted:session-directory:ses_export_test]" },
  28. },
  29. messages: [
  30. {
  31. id: "msg_first",
  32. type: "user",
  33. text: "[redacted:text:msg_first]",
  34. time: { created: 1 },
  35. },
  36. {
  37. id: "msg_second",
  38. type: "user",
  39. text: "[redacted:text:msg_second]",
  40. time: { created: 2 },
  41. },
  42. ],
  43. }
  44. const health = () => Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
  45. function run(args: string[], stdin?: string) {
  46. const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
  47. cwd: path.join(import.meta.dir, ".."),
  48. stdin: stdin === undefined ? undefined : new Blob([stdin]),
  49. stdout: "pipe",
  50. stderr: "pipe",
  51. })
  52. return Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
  53. }
  54. test("export is raw by default and supports explicit sanitization", async () => {
  55. const sanitization: string[] = []
  56. const server = Bun.serve({
  57. port: 0,
  58. fetch(request) {
  59. const url = new URL(request.url)
  60. if (url.pathname === "/api/health") return health()
  61. if (url.pathname === `/api/session/${info.id}`) return Response.json({ data: info })
  62. if (url.pathname === `/api/session/${info.id}/export`) {
  63. sanitization.push(url.searchParams.get("sanitize") ?? "")
  64. return Response.json({ data: url.searchParams.get("sanitize") === "true" ? sanitizedTransfer : transfer })
  65. }
  66. return new Response("Not found", { status: 404 })
  67. },
  68. })
  69. try {
  70. const [stdout, , exitCode] = await run(["export", "-s", info.id, "--server", server.url.toString()])
  71. const exported = JSON.parse(stdout)
  72. expect(exitCode).toBe(0)
  73. expect(exported).toEqual(transfer)
  74. const [sanitized, , sanitizedExitCode] = await run([
  75. "export",
  76. "-s",
  77. info.id,
  78. "--sanitize",
  79. "--server",
  80. server.url.toString(),
  81. ])
  82. expect(sanitizedExitCode).toBe(0)
  83. expect(JSON.parse(sanitized)).toEqual(sanitizedTransfer)
  84. expect(sanitization).toEqual(["false", "true"])
  85. } finally {
  86. await server.stop(true)
  87. }
  88. }, 15_000)
  89. test("export reports an empty session list without a stack trace", async () => {
  90. const server = Bun.serve({
  91. port: 0,
  92. fetch(request) {
  93. const url = new URL(request.url)
  94. if (url.pathname === "/api/health") return health()
  95. if (url.pathname === "/api/location") {
  96. return Response.json({
  97. directory: "/project",
  98. project: { id: "global", directory: "/project", canonical: "/project" },
  99. })
  100. }
  101. if (url.pathname === "/api/session") return Response.json({ data: [], cursor: {} })
  102. return new Response("Not found", { status: 404 })
  103. },
  104. })
  105. try {
  106. const [stdout, stderr, exitCode] = await run(["export", "--server", server.url.toString()])
  107. expect(exitCode).toBe(0)
  108. expect(stdout).toBe("")
  109. expect(stderr).toBe(`No sessions found${os.EOL}`)
  110. } finally {
  111. await server.stop(true)
  112. }
  113. })
  114. test("interactive export writes a temporary JSON file", async () => {
  115. const output = await writeExport(transfer, info.id, false)
  116. const file = output.trim()
  117. try {
  118. expect(path.dirname(file)).toBe(os.tmpdir())
  119. expect(await Bun.file(file).json()).toEqual(transfer)
  120. } finally {
  121. await fs.rm(file, { force: true })
  122. }
  123. })
  124. test("import validates a file and sends it to the resolved location", async () => {
  125. const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-import-"))
  126. const file = path.join(root, "session.json")
  127. await fs.writeFile(file, JSON.stringify(transfer))
  128. let imported: unknown
  129. const server = Bun.serve({
  130. port: 0,
  131. async fetch(request) {
  132. const url = new URL(request.url)
  133. if (url.pathname === "/api/health") return health()
  134. if (url.pathname === "/api/location") {
  135. return Response.json({
  136. directory: root,
  137. project: { id: "global", directory: root, canonical: root },
  138. })
  139. }
  140. if (url.pathname === "/api/session/import") {
  141. imported = await request.json()
  142. return Response.json({ data: { ...info, location: { directory: root } } })
  143. }
  144. return new Response("Not found", { status: 404 })
  145. },
  146. })
  147. try {
  148. const [stdout, , exitCode] = await run([
  149. "import",
  150. file,
  151. "--directory",
  152. root,
  153. "--server",
  154. server.url.toString(),
  155. ])
  156. expect(exitCode).toBe(0)
  157. expect(stdout).toBe(`Imported session: ${info.id}${os.EOL}`)
  158. expect(imported).toEqual({ ...transfer, location: { directory: root } })
  159. } finally {
  160. await server.stop(true)
  161. await fs.rm(root, { recursive: true, force: true })
  162. }
  163. })
  164. test("import reports an existing session without a stack trace", async () => {
  165. const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-import-conflict-"))
  166. const file = path.join(root, "session.json")
  167. await fs.writeFile(file, JSON.stringify(transfer))
  168. const server = Bun.serve({
  169. port: 0,
  170. fetch(request) {
  171. const url = new URL(request.url)
  172. if (url.pathname === "/api/health") return health()
  173. if (url.pathname === "/api/location") {
  174. return Response.json({
  175. directory: root,
  176. project: { id: "global", directory: root, canonical: root },
  177. })
  178. }
  179. if (url.pathname === "/api/session/import") return new Response("Conflict", { status: 409 })
  180. return new Response("Not found", { status: 404 })
  181. },
  182. })
  183. try {
  184. const [stdout, stderr, exitCode] = await run(["import", file, "--server", server.url.toString()])
  185. expect(exitCode).toBe(0)
  186. expect(stdout).toBe("")
  187. expect(stderr).toBe(`Session already exists${os.EOL}`)
  188. } finally {
  189. await server.stop(true)
  190. await fs.rm(root, { recursive: true, force: true })
  191. }
  192. })