dsl.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. import { Effect } from "effect"
  2. import { looksJson } from "./assertions"
  3. import type {
  4. ActiveScenario,
  5. AuthPolicy,
  6. BuilderState,
  7. CallResult,
  8. Comparison,
  9. Method,
  10. ProjectOptions,
  11. RequestSpec,
  12. ScenarioContext,
  13. SeededContext,
  14. TodoScenario,
  15. } from "./types"
  16. class ScenarioBuilder<S = undefined> {
  17. private readonly state: BuilderState<S>
  18. constructor(method: Method, path: string, name: string, auth: AuthPolicy) {
  19. this.state = {
  20. method,
  21. path,
  22. name,
  23. project: { git: true },
  24. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The unseeded builder state is intentionally undefined until `.seeded(...)` narrows it.
  25. seed: () => Effect.succeed(undefined as S),
  26. request: (ctx) => ({ path, headers: ctx.headers() }),
  27. authProbe: undefined,
  28. capture: "full",
  29. mutates: false,
  30. reset: true,
  31. auth,
  32. }
  33. }
  34. global() {
  35. return this.clone({ project: undefined, request: () => ({ path: this.state.path }) })
  36. }
  37. inProject(project: ProjectOptions = { git: true }) {
  38. return this.clone({ project })
  39. }
  40. withLlm() {
  41. return this.clone({ project: { ...(this.state.project ?? { git: true }), llm: true } })
  42. }
  43. at(request: BuilderState<S>["request"]) {
  44. return this.clone({ request })
  45. }
  46. probe(authProbe: RequestSpec) {
  47. return this.clone({ authProbe })
  48. }
  49. mutating() {
  50. return this.clone({ mutates: true })
  51. }
  52. preserveDatabase() {
  53. return this.clone({ reset: false })
  54. }
  55. stream() {
  56. return this.clone({ capture: "stream" })
  57. }
  58. protected() {
  59. return this.auth("protected")
  60. }
  61. public() {
  62. return this.auth("public")
  63. }
  64. publicBypass() {
  65. return this.auth("public-bypass")
  66. }
  67. ticketBypass() {
  68. return this.auth("ticket-bypass")
  69. }
  70. private auth(auth: AuthPolicy) {
  71. return this.clone({ auth })
  72. }
  73. /** Assert a non-JSON or shape-only response. */
  74. ok(status = 200, compare: Comparison = "status") {
  75. return this.done(compare, (_ctx, result) =>
  76. Effect.sync(() => {
  77. if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`)
  78. }),
  79. )
  80. }
  81. status(
  82. status = 200,
  83. inspect?: (ctx: SeededContext<S>, result: CallResult) => Effect.Effect<void>,
  84. compare: Comparison = "status",
  85. ) {
  86. return this.done(compare, (ctx, result) =>
  87. Effect.gen(function* () {
  88. if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`)
  89. if (inspect) yield* inspect(ctx, result)
  90. }),
  91. )
  92. }
  93. /** Assert JSON status/content-type plus an optional synchronous body check. */
  94. json(status = 200, inspect?: (body: unknown, ctx: SeededContext<S>) => void, compare: Comparison = "json") {
  95. return this.jsonEffect(status, inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, compare)
  96. }
  97. /** Assert JSON status/content-type plus optional Effect assertions, e.g. DB side effects. */
  98. jsonEffect(
  99. status = 200,
  100. inspect?: (body: unknown, ctx: SeededContext<S>) => Effect.Effect<void>,
  101. compare: Comparison = "json",
  102. ) {
  103. return this.done(compare, (ctx, result) =>
  104. Effect.gen(function* () {
  105. if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`)
  106. if (!looksJson(result))
  107. throw new Error(`expected JSON response, got ${result.contentType || "no content-type"}`)
  108. if (inspect) yield* inspect(result.body, ctx)
  109. }),
  110. )
  111. }
  112. private clone(next: Partial<BuilderState<S>>) {
  113. const builder = new ScenarioBuilder<S>(this.state.method, this.state.path, this.state.name, this.state.auth)
  114. Object.assign(builder.state, this.state, next)
  115. return builder
  116. }
  117. /**
  118. * Seed typed state before the HTTP request. The returned value becomes `ctx.state`
  119. * for `.at(...)` and assertions, giving stateful route tests type-safe setup.
  120. */
  121. seeded<Next>(seed: (ctx: ScenarioContext) => Effect.Effect<Next>) {
  122. const builder = new ScenarioBuilder<Next>(this.state.method, this.state.path, this.state.name, this.state.auth)
  123. Object.assign(builder.state, this.state, { seed })
  124. return builder
  125. }
  126. private done(
  127. compare: Comparison,
  128. expect: (ctx: SeededContext<S>, result: CallResult) => Effect.Effect<void>,
  129. ): ActiveScenario {
  130. const state = this.state
  131. return {
  132. kind: "active",
  133. method: state.method,
  134. path: state.path,
  135. name: state.name,
  136. project: state.project,
  137. seed: state.seed,
  138. authProbe: state.authProbe,
  139. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired request/state type inside the builder.
  140. request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }),
  141. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired assertion/state type inside the builder.
  142. expect: (ctx, seeded, result) => expect({ ...ctx, state: seeded as S }, result),
  143. compare,
  144. capture: state.capture,
  145. mutates: state.mutates,
  146. reset: state.reset,
  147. auth: state.auth,
  148. }
  149. }
  150. }
  151. const routes = (auth: AuthPolicy) => ({
  152. get: (path: string, name: string) => new ScenarioBuilder("GET", path, name, auth),
  153. post: (path: string, name: string) => new ScenarioBuilder("POST", path, name, auth),
  154. put: (path: string, name: string) => new ScenarioBuilder("PUT", path, name, auth),
  155. patch: (path: string, name: string) => new ScenarioBuilder("PATCH", path, name, auth),
  156. delete: (path: string, name: string) => new ScenarioBuilder("DELETE", path, name, auth),
  157. })
  158. export const http = {
  159. protected: routes("protected"),
  160. public: routes("public"),
  161. publicBypass: routes("public-bypass"),
  162. ticketBypass: routes("ticket-bypass"),
  163. }
  164. export const pending = (method: Method, path: string, name: string, reason: string): TodoScenario => ({
  165. kind: "todo",
  166. method,
  167. path,
  168. name,
  169. reason,
  170. })
  171. export function route(template: string, params: Record<string, string>) {
  172. return Object.entries(params).reduce(
  173. (next, [key, value]) => next.replaceAll(`{${key}}`, value).replaceAll(`:${key}`, value),
  174. template,
  175. )
  176. }
  177. export function controlledPtyInput(title: string | undefined) {
  178. return {
  179. command: "/bin/sh",
  180. args: ["-c", "sleep 30"],
  181. ...(title ? { title } : {}),
  182. }
  183. }