sdk-error-shape.test.ts 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
  1. /**
  2. * Regression tests for the SDK error shape — the v2 SDK's `throwOnError: true`
  3. * path used to throw raw values (empty strings or POJOs from JSON-decoded
  4. * error bodies). The TUI catches those and `e.message`/`e.stack` are
  5. * undefined, so users see `[object Object]` or a blank crash.
  6. *
  7. * Both cases must throw a real `Error` instance with a non-empty `.message`
  8. * extracted from the response body, plus `.status` and `.body` attached.
  9. */
  10. import { afterEach, describe, expect, test } from "bun:test"
  11. import { createOpencodeClient } from "@opencode-ai/sdk/v2"
  12. import { Server } from "../../src/server/server"
  13. import * as Log from "@opencode-ai/core/util/log"
  14. import { disposeAllInstances, tmpdir } from "../fixture/fixture"
  15. import { resetDatabase } from "../fixture/db"
  16. void Log.init({ print: false })
  17. afterEach(async () => {
  18. await disposeAllInstances()
  19. await resetDatabase()
  20. })
  21. function client(directory: string) {
  22. return createOpencodeClient({
  23. baseUrl: "http://test",
  24. directory,
  25. fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch,
  26. })
  27. }
  28. describe("v2 SDK error shape", () => {
  29. test("404 with NamedError body throws a real Error carrying the server message", async () => {
  30. await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
  31. const sdk = client(tmp.path)
  32. let caught: unknown
  33. try {
  34. await sdk.session.get({ sessionID: "ses_no_such" }, { throwOnError: true })
  35. } catch (e) {
  36. caught = e
  37. }
  38. expect(caught).toBeInstanceOf(Error)
  39. const err = caught as Error
  40. const cause = err.cause as { body?: any; status?: number }
  41. expect(err.message).toContain("Session not found")
  42. expect(cause.status).toBe(404)
  43. expect(cause.body).toMatchObject({
  44. name: "NotFoundError",
  45. data: { message: expect.stringContaining("Session not found") },
  46. })
  47. })
  48. test("400 schema rejection: SDK extracts the field-level reason from the NamedError body", async () => {
  49. // Canary for the #26631 wire shape. Asserts the contract end-to-end:
  50. // server emits {name:"BadRequest", data:{message, kind}}, SDK's
  51. // wrapClientError extracts .data.message into Error.message. If either
  52. // side regresses (#26457 reverted because both layers were missing),
  53. // this test fails before users see (empty response body).
  54. await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
  55. const sdk = client(tmp.path)
  56. let caught: unknown
  57. try {
  58. await sdk.sync.history.list({ body: { aggregate: -1 } as any }, { throwOnError: true })
  59. } catch (e) {
  60. caught = e
  61. }
  62. expect(caught).toBeInstanceOf(Error)
  63. const err = caught as Error
  64. const cause = err.cause as { body?: any; status?: number }
  65. expect(cause.status).toBe(400)
  66. expect(cause.body).toMatchObject({
  67. name: "BadRequest",
  68. data: { kind: expect.stringMatching(/^(Body|Payload)$/) },
  69. })
  70. expect(typeof cause.body.data.message).toBe("string")
  71. expect(cause.body.data.message.length).toBeGreaterThan(0)
  72. // Whatever the server put in data.message must be what the user sees.
  73. expect(err.message).toBe(cause.body.data.message)
  74. })
  75. })