sdk-error-shape.test.ts 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
  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 "@kirincode-ai/sdk/v2"
  12. import { Server } from "../../src/server/server"
  13. import { disposeAllInstances, tmpdir } from "../fixture/fixture"
  14. import { resetDatabase } from "../fixture/db"
  15. afterEach(async () => {
  16. await disposeAllInstances()
  17. await resetDatabase()
  18. })
  19. function client(directory: string) {
  20. return createOpencodeClient({
  21. baseUrl: "http://test",
  22. directory,
  23. fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch,
  24. })
  25. }
  26. describe("v2 SDK error shape", () => {
  27. test("404 with NamedError body throws a real Error carrying the server message", async () => {
  28. await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
  29. const sdk = client(tmp.path)
  30. let caught: unknown
  31. try {
  32. await sdk.session.get({ sessionID: "ses_no_such" }, { throwOnError: true })
  33. } catch (e) {
  34. caught = e
  35. }
  36. expect(caught).toBeInstanceOf(Error)
  37. const err = caught as Error
  38. const cause = err.cause as { body?: any; status?: number }
  39. expect(err.message).toContain("Session not found")
  40. expect(cause.status).toBe(404)
  41. expect(cause.body).toMatchObject({
  42. name: "NotFoundError",
  43. data: { message: expect.stringContaining("Session not found") },
  44. })
  45. })
  46. test("400 schema rejection: SDK extracts the field-level reason from the NamedError body", async () => {
  47. // Canary for the #26631 wire shape. Asserts the contract end-to-end:
  48. // server emits {name:"BadRequest", data:{message, kind}}, SDK's
  49. // wrapClientError extracts .data.message into Error.message. If either
  50. // side regresses (#26457 reverted because both layers were missing),
  51. // this test fails before users see (empty response body).
  52. await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
  53. const sdk = client(tmp.path)
  54. let caught: unknown
  55. try {
  56. await sdk.sync.history.list({ body: { aggregate: -1 } as any }, { throwOnError: true })
  57. } catch (e) {
  58. caught = e
  59. }
  60. expect(caught).toBeInstanceOf(Error)
  61. const err = caught as Error
  62. const cause = err.cause as { body?: any; status?: number }
  63. expect(cause.status).toBe(400)
  64. expect(cause.body).toMatchObject({
  65. name: "BadRequest",
  66. data: { kind: expect.stringMatching(/^(Body|Payload)$/) },
  67. })
  68. expect(typeof cause.body.data.message).toBe("string")
  69. expect(cause.body.data.message.length).toBeGreaterThan(0)
  70. // Whatever the server put in data.message must be what the user sees.
  71. expect(err.message).toBe(cause.body.data.message)
  72. })
  73. })