record-replay.test.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879
  1. import { NodeFileSystem } from "@effect/platform-node"
  2. import { describe, expect, test } from "bun:test"
  3. import { Cause, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect"
  4. import { Headers, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http"
  5. import { Socket } from "effect/unstable/socket"
  6. import * as fs from "node:fs"
  7. import * as os from "node:os"
  8. import * as path from "node:path"
  9. import { HttpRecorder } from "../src"
  10. import { HttpRecorderInternal } from "../src/internal"
  11. import { redactedErrorRequest } from "../src/internal-effect"
  12. import type { Interaction } from "../src/schema"
  13. const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray<Interaction>) =>
  14. Effect.runPromise(
  15. Effect.gen(function* () {
  16. const cassette = yield* HttpRecorderInternal.Cassette.Service
  17. yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction))
  18. }).pipe(
  19. Effect.provide(HttpRecorderInternal.Cassette.fileSystem({ directory })),
  20. Effect.provide(NodeFileSystem.layer),
  21. ),
  22. )
  23. const post = (url: string, body: object) =>
  24. Effect.gen(function* () {
  25. const http = yield* HttpClient.HttpClient
  26. const request = HttpClientRequest.post(url, {
  27. headers: { "content-type": "application/json" },
  28. body: HttpBody.text(JSON.stringify(body), "application/json"),
  29. })
  30. const response = yield* http.execute(request)
  31. return yield* response.text
  32. })
  33. const run = <A, E>(effect: Effect.Effect<A, E, HttpClient.HttpClient>) =>
  34. Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.http("record-replay/multi-step"))))
  35. const runWith = <A, E>(
  36. name: string,
  37. options: HttpRecorder.RecorderOptions,
  38. effect: Effect.Effect<A, E, HttpClient.HttpClient>,
  39. ) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.http(name, options))))
  40. const runRecorder = <A, E>(effect: Effect.Effect<A, E, HttpRecorderInternal.Cassette.Service | Scope.Scope>) =>
  41. Effect.runPromise(
  42. Effect.scoped(
  43. effect.pipe(
  44. Effect.provide(
  45. HttpRecorderInternal.Cassette.fileSystem({
  46. directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")),
  47. }),
  48. ),
  49. Effect.provide(NodeFileSystem.layer),
  50. ),
  51. ),
  52. )
  53. const failureText = (exit: Exit.Exit<unknown, unknown>) => {
  54. if (Exit.isSuccess(exit)) return ""
  55. return Cause.prettyErrors(exit.cause).join("\n")
  56. }
  57. describe("http-recorder", () => {
  58. test("redacts sensitive URL query parameters", () => {
  59. expect(
  60. HttpRecorderInternal.redactUrl(
  61. "https://example.test/path?key=secret-google-key&api_key=secret-openai-key&safe=value&X-Amz-Signature=secret-signature",
  62. ),
  63. ).toBe(
  64. "https://example.test/path?key=%5BREDACTED%5D&api_key=%5BREDACTED%5D&safe=value&X-Amz-Signature=%5BREDACTED%5D",
  65. )
  66. })
  67. test("redacts URL credentials", () => {
  68. expect(HttpRecorderInternal.redactUrl("https://user:password@example.test/path?safe=value")).toBe(
  69. "https://%5BREDACTED%5D:%5BREDACTED%5D@example.test/path?safe=value",
  70. )
  71. })
  72. test("applies custom URL redaction after built-in redaction", () => {
  73. expect(
  74. HttpRecorderInternal.redactUrl(
  75. "https://example.test/accounts/real-account/path?key=secret-key",
  76. undefined,
  77. (url) => url.replace("/accounts/real-account/", "/accounts/{account}/"),
  78. ),
  79. ).toBe("https://example.test/accounts/{account}/path?key=%5BREDACTED%5D")
  80. })
  81. test("redacts sensitive headers when allow-listed", () => {
  82. expect(
  83. HttpRecorderInternal.redactHeaders(
  84. {
  85. authorization: "Bearer secret-token",
  86. "content-type": "application/json",
  87. "x-custom-token": "custom-secret",
  88. "x-api-key": "secret-key",
  89. "x-goog-api-key": "secret-google-key",
  90. },
  91. ["authorization", "content-type", "x-api-key", "x-goog-api-key", "x-custom-token"],
  92. ["x-custom-token"],
  93. ),
  94. ).toEqual({
  95. authorization: "[REDACTED]",
  96. "content-type": "application/json",
  97. "x-api-key": "[REDACTED]",
  98. "x-custom-token": "[REDACTED]",
  99. "x-goog-api-key": "[REDACTED]",
  100. })
  101. })
  102. test("redacts error requests without retaining headers, params, or body", () => {
  103. const request = HttpClientRequest.post("https://example.test/path", {
  104. headers: { authorization: "Bearer super-secret" },
  105. body: HttpBody.text("super-secret-body", "text/plain"),
  106. }).pipe(HttpClientRequest.setUrlParam("api_key", "super-secret-key"))
  107. expect(redactedErrorRequest(request).toJSON()).toMatchObject({
  108. url: "https://example.test/path",
  109. urlParams: { params: [] },
  110. headers: {},
  111. body: { _tag: "Empty" },
  112. })
  113. })
  114. test("detects secret-looking values without returning the secret", () => {
  115. expect(
  116. HttpRecorderInternal.secretFindings({
  117. version: 1,
  118. interactions: [
  119. {
  120. transport: "http",
  121. request: {
  122. method: "POST",
  123. url: "https://example.test/path?key=sk-123456789012345678901234",
  124. headers: {},
  125. body: JSON.stringify({ nested: "AIzaSyDHibiBRvJZLsFnPYPoiTwxY4ztQ55yqCE" }),
  126. },
  127. response: {
  128. status: 200,
  129. headers: {},
  130. body: "Bearer abcdefghijklmnopqrstuvwxyz",
  131. },
  132. },
  133. ],
  134. }),
  135. ).toEqual([
  136. { path: "interactions[0].request.url", reason: "API key" },
  137. { path: "interactions[0].request.body", reason: "Google API key" },
  138. { path: "interactions[0].response.body", reason: "bearer token" },
  139. ])
  140. })
  141. test("detects secret-looking values inside metadata", () => {
  142. expect(
  143. HttpRecorderInternal.secretFindings({
  144. version: 1,
  145. metadata: { token: "sk-123456789012345678901234" },
  146. interactions: [],
  147. }),
  148. ).toEqual([{ path: "metadata.token", reason: "API key" }])
  149. })
  150. test("redacts configured and common sensitive JSON fields", () => {
  151. const redactor = HttpRecorderInternal.Redactor.make({ jsonFields: ["account_id"] })
  152. const request = redactor.request({
  153. method: "POST",
  154. url: "https://example.test/path",
  155. headers: { "content-type": "application/json" },
  156. body: JSON.stringify({
  157. password: "secret-password",
  158. accessToken: "access-token",
  159. nested: { account_id: "account-123", safe: "visible" },
  160. }),
  161. })
  162. expect(JSON.parse(request.body)).toEqual({
  163. password: "[REDACTED]",
  164. accessToken: "[REDACTED]",
  165. nested: { account_id: "[REDACTED]", safe: "visible" },
  166. })
  167. })
  168. test("extends default header redaction and allow lists", () => {
  169. const redactor = HttpRecorderInternal.Redactor.make({
  170. headers: ["x-custom-token"],
  171. allowRequestHeaders: ["anthropic-version", "x-custom-token"],
  172. })
  173. expect(
  174. redactor.request({
  175. method: "GET",
  176. url: "https://example.test/path",
  177. headers: {
  178. authorization: "Bearer secret",
  179. "content-type": "application/json",
  180. "anthropic-version": "2023-06-01",
  181. "x-custom-token": "secret",
  182. },
  183. body: "",
  184. }).headers,
  185. ).toEqual({
  186. "anthropic-version": "2023-06-01",
  187. "content-type": "application/json",
  188. "x-custom-token": "[REDACTED]",
  189. })
  190. })
  191. test("records WebSocket frames in observed client/server order", async () => {
  192. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-"))
  193. const response = JSON.stringify({ type: "response.completed", token: "server-secret" })
  194. let receive: ((message: string | Uint8Array) => Effect.Effect<unknown, unknown, unknown> | void) | undefined
  195. const upstream = Socket.make({
  196. runRaw: (handler, options) =>
  197. Effect.gen(function* () {
  198. receive = handler
  199. if (options?.onOpen) yield* options.onOpen
  200. receive = undefined
  201. }),
  202. writer: Effect.succeed(() =>
  203. Effect.suspend(() => {
  204. const result = receive?.(response)
  205. return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void
  206. }),
  207. ),
  208. })
  209. await Effect.runPromise(
  210. Effect.gen(function* () {
  211. const socket = yield* Socket.Socket
  212. const write = yield* socket.writer
  213. yield* socket.runRaw(() => {}, {
  214. onOpen: write(JSON.stringify({ type: "response.create", token: "client-secret" })),
  215. })
  216. }).pipe(
  217. Effect.scoped,
  218. Effect.provide(
  219. HttpRecorderInternal.socketLayer(
  220. "websocket/record",
  221. { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
  222. { directory, metadata: { provider: "test" }, mode: "record" },
  223. ).pipe(Layer.provide(Layer.succeed(Socket.Socket, upstream))),
  224. ),
  225. ),
  226. )
  227. expect(JSON.parse(fs.readFileSync(path.join(directory, "websocket/record.json"), "utf8"))).toMatchObject({
  228. interactions: [
  229. {
  230. transport: "websocket",
  231. open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
  232. events: [
  233. { direction: "client", kind: "text", body: '{"type":"response.create","token":"[REDACTED]"}' },
  234. { direction: "server", kind: "text", body: '{"type":"response.completed","token":"[REDACTED]"}' },
  235. ],
  236. },
  237. ],
  238. })
  239. })
  240. test("WebSocket replay preserves causal frame ordering", async () => {
  241. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-"))
  242. await seedCassetteDirectory(directory, "websocket/replay", [
  243. {
  244. transport: "websocket",
  245. open: { url: "wss://example.test/realtime", headers: {} },
  246. events: [
  247. { direction: "server", kind: "text", body: '{"type":"session.created"}' },
  248. { direction: "client", kind: "text", body: '{"type":"response.create","prompt":"hello"}' },
  249. { direction: "server", kind: "text", body: '{"type":"response.completed"}' },
  250. ],
  251. },
  252. ])
  253. const received: string[] = []
  254. await Effect.runPromise(
  255. Effect.gen(function* () {
  256. const socket = yield* Socket.Socket
  257. const write = yield* socket.writer
  258. yield* socket.runRaw((message) => {
  259. if (typeof message !== "string") return
  260. received.push(message)
  261. if (JSON.parse(message).type === "session.created")
  262. return write('{"prompt":"hello","type":"response.create"}')
  263. })
  264. }).pipe(
  265. Effect.scoped,
  266. Effect.provide(
  267. HttpRecorderInternal.socketLayer(
  268. "websocket/replay",
  269. { url: "wss://example.test/realtime" },
  270. { directory, compareClientMessagesAsJson: true, mode: "replay" },
  271. ).pipe(
  272. Layer.provide(
  273. Layer.succeed(
  274. Socket.Socket,
  275. Socket.make({
  276. runRaw: () => Effect.die(new Error("unexpected live WebSocket run")),
  277. writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))),
  278. }),
  279. ),
  280. ),
  281. ),
  282. ),
  283. ),
  284. )
  285. expect(received).toEqual(['{"type":"session.created"}', '{"type":"response.completed"}'])
  286. })
  287. test("the public socket decorator replays a provided Effect socket", async () => {
  288. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-"))
  289. await seedCassetteDirectory(directory, "websocket/public-layer", [
  290. {
  291. transport: "websocket",
  292. open: { url: "", headers: {} },
  293. events: [
  294. { direction: "client", kind: "text", body: "hello" },
  295. { direction: "server", kind: "text", body: "hello" },
  296. ],
  297. },
  298. ])
  299. const received: string[] = []
  300. await Effect.runPromise(
  301. Effect.gen(function* () {
  302. const socket = yield* Socket.Socket
  303. const write = yield* socket.writer
  304. yield* socket.runString(
  305. (message) =>
  306. Effect.gen(function* () {
  307. received.push(message)
  308. yield* write(new Socket.CloseEvent(1000))
  309. }),
  310. { onOpen: write("hello") },
  311. )
  312. }).pipe(
  313. Effect.scoped,
  314. Effect.provide(
  315. HttpRecorder.socket("websocket/public-layer", { directory }).pipe(
  316. Layer.provide(
  317. Layer.succeed(
  318. Socket.Socket,
  319. Socket.make({
  320. runRaw: () => Effect.die(new Error("unexpected live WebSocket run")),
  321. writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))),
  322. }),
  323. ),
  324. ),
  325. ),
  326. ),
  327. ),
  328. )
  329. expect(received).toEqual(["hello"])
  330. })
  331. test("WebSocket replay runs message handlers concurrently", async () => {
  332. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-"))
  333. await seedCassetteDirectory(directory, "websocket/concurrent-handlers", [
  334. {
  335. transport: "websocket",
  336. open: { url: "wss://example.test/realtime", headers: {} },
  337. events: [
  338. { direction: "server", kind: "text", body: "first" },
  339. { direction: "server", kind: "text", body: "second" },
  340. ],
  341. },
  342. ])
  343. await Effect.runPromise(
  344. Effect.gen(function* () {
  345. const socket = yield* Socket.Socket
  346. const second = yield* Deferred.make<void>()
  347. yield* socket.runString((message) =>
  348. message === "first" ? Deferred.await(second) : Deferred.succeed(second, undefined),
  349. )
  350. }).pipe(
  351. Effect.scoped,
  352. Effect.provide(
  353. HttpRecorderInternal.socketLayer(
  354. "websocket/concurrent-handlers",
  355. { url: "wss://example.test/realtime" },
  356. { directory, mode: "replay" },
  357. ).pipe(
  358. Layer.provide(
  359. Layer.succeed(
  360. Socket.Socket,
  361. Socket.make({
  362. runRaw: () => Effect.die(new Error("unexpected live WebSocket run")),
  363. writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))),
  364. }),
  365. ),
  366. ),
  367. ),
  368. ),
  369. ),
  370. )
  371. })
  372. test("WebSocket replay rejects close with unconsumed events", async () => {
  373. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-"))
  374. await seedCassetteDirectory(directory, "websocket/early-close", [
  375. {
  376. transport: "websocket",
  377. open: { url: "wss://example.test/realtime", headers: {} },
  378. events: [{ direction: "client", kind: "text", body: "expected" }],
  379. },
  380. ])
  381. const exit = await Effect.runPromise(
  382. Effect.gen(function* () {
  383. const socket = yield* Socket.Socket
  384. const write = yield* socket.writer
  385. return yield* Effect.exit(socket.runRaw(() => {}, { onOpen: write(new Socket.CloseEvent(1000)) }))
  386. }).pipe(
  387. Effect.scoped,
  388. Effect.provide(
  389. HttpRecorderInternal.socketLayer(
  390. "websocket/early-close",
  391. { url: "wss://example.test/realtime" },
  392. { directory, mode: "replay" },
  393. ).pipe(
  394. Layer.provide(
  395. Layer.succeed(
  396. Socket.Socket,
  397. Socket.make({
  398. runRaw: () => Effect.die(new Error("unexpected live WebSocket run")),
  399. writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))),
  400. }),
  401. ),
  402. ),
  403. ),
  404. ),
  405. ),
  406. )
  407. expect(failureText(exit)).toContain("closed with unconsumed events")
  408. })
  409. test("failed WebSocket runs do not write complete cassettes", async () => {
  410. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-"))
  411. const exit = await Effect.runPromise(
  412. Effect.gen(function* () {
  413. const socket = yield* Socket.Socket
  414. return yield* Effect.exit(socket.runRaw(() => {}))
  415. }).pipe(
  416. Effect.scoped,
  417. Effect.provide(
  418. HttpRecorderInternal.socketLayer(
  419. "websocket/failed-run",
  420. { url: "wss://example.test/realtime" },
  421. { directory, mode: "record" },
  422. ).pipe(
  423. Layer.provide(
  424. Layer.succeed(
  425. Socket.Socket,
  426. Socket.make({
  427. runRaw: () => Effect.die(new Error("connection failed")),
  428. writer: Effect.succeed(() => Effect.void),
  429. }),
  430. ),
  431. ),
  432. ),
  433. ),
  434. ),
  435. )
  436. expect(Exit.isFailure(exit)).toBe(true)
  437. expect(fs.existsSync(path.join(directory, "websocket/failed-run.json"))).toBe(false)
  438. })
  439. test("WebSocket replay preserves binary frame kinds across reconnects", async () => {
  440. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-"))
  441. const interaction = {
  442. transport: "websocket" as const,
  443. open: { url: "wss://example.test/binary", headers: {} },
  444. events: [
  445. {
  446. direction: "client" as const,
  447. kind: "binary" as const,
  448. body: Buffer.from([1, 2]).toString("base64"),
  449. bodyEncoding: "base64" as const,
  450. },
  451. {
  452. direction: "server" as const,
  453. kind: "binary" as const,
  454. body: Buffer.from([3, 4]).toString("base64"),
  455. bodyEncoding: "base64" as const,
  456. },
  457. ],
  458. }
  459. await seedCassetteDirectory(directory, "websocket/binary", [interaction, interaction])
  460. const received: number[][] = []
  461. await Effect.runPromise(
  462. Effect.gen(function* () {
  463. const socket = yield* Socket.Socket
  464. const write = yield* socket.writer
  465. const run = socket.runRaw(
  466. (message) => {
  467. if (typeof message === "string") throw new Error("Expected a binary WebSocket frame")
  468. received.push([...message])
  469. },
  470. { onOpen: write(new Uint8Array([1, 2])) },
  471. )
  472. yield* run
  473. yield* run
  474. }).pipe(
  475. Effect.scoped,
  476. Effect.provide(
  477. HttpRecorderInternal.socketLayer(
  478. "websocket/binary",
  479. { url: "wss://example.test/binary" },
  480. { directory, mode: "replay" },
  481. ).pipe(
  482. Layer.provide(
  483. Layer.succeed(
  484. Socket.Socket,
  485. Socket.make({
  486. runRaw: () => Effect.die(new Error("unexpected live WebSocket run")),
  487. writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))),
  488. }),
  489. ),
  490. ),
  491. ),
  492. ),
  493. ),
  494. )
  495. expect(received).toEqual([
  496. [3, 4],
  497. [3, 4],
  498. ])
  499. })
  500. test("replay returns recorded responses in order for identical requests", async () => {
  501. await runWith(
  502. "record-replay/retry",
  503. {},
  504. Effect.gen(function* () {
  505. expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
  506. expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}')
  507. }),
  508. )
  509. })
  510. test("replay reports cursor exhaustion when more requests are made than recorded", async () => {
  511. await run(
  512. Effect.gen(function* () {
  513. yield* post("https://example.test/echo", { step: 1 })
  514. yield* post("https://example.test/echo", { step: 2 })
  515. const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
  516. expect(Exit.isFailure(exit)).toBe(true)
  517. }),
  518. )
  519. })
  520. test("replay validates each recorded request in order", async () => {
  521. await run(
  522. Effect.gen(function* () {
  523. yield* post("https://example.test/echo", { step: 1 })
  524. const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
  525. expect(Exit.isFailure(exit)).toBe(true)
  526. expect(failureText(exit)).toContain("$.step expected 2, received 3")
  527. expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
  528. }),
  529. )
  530. })
  531. test("concurrent replay claims each interaction once", async () => {
  532. const results = await runWith(
  533. "record-replay/retry",
  534. {},
  535. Effect.all(
  536. [post("https://example.test/poll", { id: "job_1" }), post("https://example.test/poll", { id: "job_1" })],
  537. { concurrency: "unbounded" },
  538. ),
  539. )
  540. expect(results.toSorted()).toEqual(['{"status":"complete"}', '{"status":"pending"}'])
  541. })
  542. test("replays when the cassette exists", async () => {
  543. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-"))
  544. await seedCassetteDirectory(directory, "auto-replay", [
  545. {
  546. transport: "http",
  547. request: {
  548. method: "POST",
  549. url: "https://example.test/echo",
  550. headers: { "content-type": "application/json" },
  551. body: JSON.stringify({ step: 1 }),
  552. },
  553. response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' },
  554. },
  555. ])
  556. const result = await runWith("auto-replay", { directory }, post("https://example.test/echo", { step: 1 }))
  557. expect(result).toBe('{"reply":"hi"}')
  558. })
  559. test("forces replay when CI=true even if cassette is missing", async () => {
  560. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-ci-"))
  561. const previous = process.env.CI
  562. process.env.CI = "true"
  563. try {
  564. const exit = await Effect.runPromise(
  565. Effect.exit(
  566. post("https://example.test/echo", { step: 1 }).pipe(
  567. Effect.provide(HttpRecorder.http("missing-cassette", { directory })),
  568. ),
  569. ),
  570. )
  571. expect(Exit.isFailure(exit)).toBe(true)
  572. expect(failureText(exit)).toContain('Fixture "missing-cassette" not found')
  573. } finally {
  574. if (previous === undefined) delete process.env.CI
  575. else process.env.CI = previous
  576. }
  577. })
  578. test("mismatch diagnostics show redacted request differences against the expected interaction", async () => {
  579. await run(
  580. Effect.gen(function* () {
  581. const exit = yield* Effect.exit(
  582. post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }),
  583. )
  584. const message = failureText(exit)
  585. expect(message).toContain("url:")
  586. expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D")
  587. expect(message).toContain("body:")
  588. expect(message).toContain("$.step expected 1, received 3")
  589. expect(message).toContain('$.token expected undefined, received "[REDACTED]"')
  590. expect(message).not.toContain("sk-123456789012345678901234")
  591. }),
  592. )
  593. })
  594. test("records to disk when the cassette is missing", async () => {
  595. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-record-"))
  596. using server = Bun.serve({
  597. port: 0,
  598. fetch: () => new Response('{"reply":"recorded"}', { headers: { "content-type": "application/json" } }),
  599. })
  600. const url = `http://127.0.0.1:${server.port}/echo`
  601. // CI=true forces replay; clear it so we exercise the local-dev auto-record path.
  602. const previous = process.env.CI
  603. delete process.env.CI
  604. try {
  605. const result = await runWith("auto-record", { directory }, post(url, { step: 1 }))
  606. expect(result).toBe('{"reply":"recorded"}')
  607. expect(fs.existsSync(path.join(directory, "auto-record.json"))).toBe(true)
  608. } finally {
  609. if (previous !== undefined) process.env.CI = previous
  610. }
  611. })
  612. test("records concurrent requests in request-start order", async () => {
  613. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-order-"))
  614. const first = Promise.withResolvers<void>()
  615. const completed: string[] = []
  616. using server = Bun.serve({
  617. port: 0,
  618. fetch: async (request) => {
  619. const name = new URL(request.url).pathname.slice(1)
  620. if (name === "first") {
  621. await first.promise
  622. completed.push(name)
  623. return new Response(name)
  624. }
  625. completed.push(name)
  626. first.resolve()
  627. return new Response(name)
  628. },
  629. })
  630. const previous = process.env.CI
  631. delete process.env.CI
  632. try {
  633. const request = (name: string) =>
  634. Effect.gen(function* () {
  635. const http = yield* HttpClient.HttpClient
  636. const response = yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/${name}`))
  637. return yield* response.text
  638. })
  639. const responses = await Effect.runPromise(
  640. Effect.all([request("first"), request("second")], { concurrency: "unbounded" }).pipe(
  641. Effect.provide(HttpRecorder.http("concurrent-order", { directory })),
  642. ),
  643. )
  644. const cassette = JSON.parse(fs.readFileSync(path.join(directory, "concurrent-order.json"), "utf8"))
  645. expect(completed).toEqual(["second", "first"])
  646. expect(responses).toEqual(["first", "second"])
  647. expect(cassette.interactions.map((interaction: Interaction) => interaction.request.url)).toEqual([
  648. `http://127.0.0.1:${server.port}/first`,
  649. `http://127.0.0.1:${server.port}/second`,
  650. ])
  651. } finally {
  652. if (previous !== undefined) process.env.CI = previous
  653. }
  654. })
  655. test("returns the live response while persisting its redacted snapshot", async () => {
  656. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-live-response-"))
  657. using server = Bun.serve({
  658. port: 0,
  659. fetch: () =>
  660. new Response(JSON.stringify({ access_token: "live-secret", safe: true }), {
  661. headers: { "content-type": "application/json", "x-request-id": "request-1" },
  662. }),
  663. })
  664. const previous = process.env.CI
  665. delete process.env.CI
  666. try {
  667. const body = await runWith(
  668. "live-response",
  669. { directory },
  670. post(`http://127.0.0.1:${server.port}/response`, { ok: true }),
  671. )
  672. const cassette = JSON.parse(fs.readFileSync(path.join(directory, "live-response.json"), "utf8"))
  673. expect(body).toBe('{"access_token":"live-secret","safe":true}')
  674. expect(cassette.interactions[0].response.body).toBe('{"access_token":"[REDACTED]","safe":true}')
  675. } finally {
  676. if (previous !== undefined) process.env.CI = previous
  677. }
  678. })
  679. test("reconstructs responses with null-body statuses", async () => {
  680. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-no-content-"))
  681. using server = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 204 }) })
  682. const previous = process.env.CI
  683. delete process.env.CI
  684. try {
  685. const program = Effect.gen(function* () {
  686. const http = yield* HttpClient.HttpClient
  687. return yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/empty`))
  688. })
  689. const response = await Effect.runPromise(
  690. program.pipe(Effect.provide(HttpRecorder.http("no-content", { directory }))),
  691. )
  692. expect(response.status).toBe(204)
  693. } finally {
  694. if (previous !== undefined) process.env.CI = previous
  695. }
  696. })
  697. test("records and replays arbitrary binary responses without changing bytes", async () => {
  698. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-binary-"))
  699. const expected = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00, 0x80])
  700. using server = Bun.serve({
  701. port: 0,
  702. fetch: () => new Response(expected, { headers: { "content-type": "image/png" } }),
  703. })
  704. const url = `http://127.0.0.1:${server.port}/image.png`
  705. const previous = process.env.CI
  706. delete process.env.CI
  707. try {
  708. const program = Effect.gen(function* () {
  709. const http = yield* HttpClient.HttpClient
  710. const response = yield* http.execute(HttpClientRequest.get(url))
  711. return new Uint8Array(yield* response.arrayBuffer)
  712. })
  713. const record = await Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.http("binary", { directory }))))
  714. await server.stop()
  715. const replay = await Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.http("binary", { directory }))))
  716. const cassette = JSON.parse(fs.readFileSync(path.join(directory, "binary.json"), "utf8"))
  717. expect(record).toEqual(expected)
  718. expect(replay).toEqual(expected)
  719. expect(cassette.interactions[0].response.bodyEncoding).toBe("base64")
  720. } finally {
  721. if (previous !== undefined) process.env.CI = previous
  722. }
  723. })
  724. test("UnsafeCassetteError fails the request when a recording would write a known secret", async () => {
  725. using server = Bun.serve({ port: 0, fetch: () => new Response("Bearer abcdefghijklmnopqrstuvwxyz1234") })
  726. const url = `http://127.0.0.1:${server.port}/leaky`
  727. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-unsafe-"))
  728. const exit = await Effect.runPromise(
  729. Effect.exit(
  730. post(url, { ok: true }).pipe(
  731. Effect.provide(HttpRecorderInternal.cassetteLayer("unsafe-record", { directory, mode: "record" })),
  732. ),
  733. ),
  734. )
  735. expect(Exit.isFailure(exit)).toBe(true)
  736. expect(failureText(exit)).toContain("contains possible secrets")
  737. expect(fs.existsSync(path.join(directory, "unsafe-record.json"))).toBe(false)
  738. })
  739. test("failed memory appends leave cassette state unchanged", async () => {
  740. await Effect.runPromise(
  741. Effect.gen(function* () {
  742. const cassette = yield* HttpRecorderInternal.Cassette.Service
  743. const interaction: Interaction = {
  744. transport: "http",
  745. request: { method: "GET", url: "https://example.test", headers: {}, body: "" },
  746. response: { status: 200, headers: {}, body: "safe" },
  747. }
  748. yield* cassette.append("transactional", interaction)
  749. yield* cassette
  750. .append("transactional", {
  751. ...interaction,
  752. response: { ...interaction.response, body: "Bearer abcdefghijklmnopqrstuvwxyz1234" },
  753. })
  754. .pipe(Effect.flip)
  755. expect(yield* cassette.read("transactional")).toEqual([interaction])
  756. }).pipe(Effect.provide(HttpRecorderInternal.Cassette.memory())),
  757. )
  758. })
  759. test("concurrent file appends preserve every interaction", async () => {
  760. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-concurrent-"))
  761. await Effect.runPromise(
  762. Effect.gen(function* () {
  763. const cassette = yield* HttpRecorderInternal.Cassette.Service
  764. yield* Effect.forEach(
  765. Array.from({ length: 20 }, (_, index) => index),
  766. (index) =>
  767. cassette.append("concurrent", {
  768. transport: "http",
  769. request: { method: "GET", url: `https://example.test/${index}`, headers: {}, body: "" },
  770. response: { status: 200, headers: {}, body: String(index) },
  771. }),
  772. { concurrency: "unbounded" },
  773. )
  774. }).pipe(
  775. Effect.provide(HttpRecorderInternal.Cassette.fileSystem({ directory })),
  776. Effect.provide(NodeFileSystem.layer),
  777. ),
  778. )
  779. const cassette = JSON.parse(fs.readFileSync(path.join(directory, "concurrent.json"), "utf8"))
  780. expect(cassette.interactions).toHaveLength(20)
  781. expect(fs.readdirSync(directory).filter((file) => file.endsWith(".tmp"))).toEqual([])
  782. })
  783. test("rejects cassette paths outside the recordings directory", () => {
  784. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-path-"))
  785. expect(() => HttpRecorderInternal.hasCassetteSync("../outside", { directory })).toThrow("Invalid cassette name")
  786. expect(() => HttpRecorderInternal.hasCassetteSync("C:\\outside", { directory })).toThrow("Invalid cassette name")
  787. })
  788. test("Cassette.list enumerates recorded cassette names", async () => {
  789. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-list-"))
  790. await seedCassetteDirectory(directory, "alpha/one", [
  791. {
  792. transport: "http",
  793. request: { method: "GET", url: "https://x.test/a", headers: {}, body: "" },
  794. response: { status: 200, headers: {}, body: "a" },
  795. },
  796. ])
  797. await seedCassetteDirectory(directory, "beta", [
  798. {
  799. transport: "http",
  800. request: { method: "GET", url: "https://x.test/b", headers: {}, body: "" },
  801. response: { status: 200, headers: {}, body: "b" },
  802. },
  803. ])
  804. const names = await Effect.runPromise(
  805. Effect.gen(function* () {
  806. const cassette = yield* HttpRecorderInternal.Cassette.Service
  807. return yield* cassette.list()
  808. }).pipe(
  809. Effect.provide(HttpRecorderInternal.Cassette.fileSystem({ directory })),
  810. Effect.provide(NodeFileSystem.layer),
  811. ),
  812. )
  813. expect(names).toEqual(["alpha/one", "beta"])
  814. })
  815. })