write.test.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. import { describe, expect } from "bun:test"
  2. import { Effect, FileSystem, Option } from "effect"
  3. import { write, type Output } from "../src"
  4. import { it } from "./effect"
  5. describe("HttpApiCodegen.write", () => {
  6. it.effect("writes compiled files beneath the output directory", () => {
  7. const writes: Array<{ readonly path: string; readonly content: string }> = []
  8. const output: Output = {
  9. operations: [],
  10. files: [{ path: "session.ts", content: "export const session = {}" }],
  11. }
  12. return Effect.gen(function* () {
  13. yield* write(output, "/generated")
  14. expect(writes).toEqual([
  15. { path: "/generated/session.ts", content: "export const session = {}\n" },
  16. { path: "/generated/.httpapi-codegen.json", content: '[\n "session.ts"\n]\n' },
  17. ])
  18. }).pipe(
  19. Effect.provideService(
  20. FileSystem.FileSystem,
  21. FileSystem.makeNoop({
  22. exists: () => Effect.succeed(false),
  23. makeDirectory: () => Effect.void,
  24. writeFileString: (path, content) => {
  25. writes.push({ path, content })
  26. return Effect.void
  27. },
  28. }),
  29. ),
  30. )
  31. })
  32. it.effect("removes only stale files owned by the previous manifest", () => {
  33. const removed: Array<string> = []
  34. return write(
  35. {
  36. operations: [],
  37. files: [{ path: "session.ts", content: "" }],
  38. },
  39. "/generated",
  40. ).pipe(
  41. Effect.provideService(
  42. FileSystem.FileSystem,
  43. FileSystem.makeNoop({
  44. exists: (path) => Effect.succeed(path.endsWith(".httpapi-codegen.json")),
  45. makeDirectory: () => Effect.void,
  46. readFileString: () => Effect.succeed('["old.ts", "session.ts"]'),
  47. remove: (path) => {
  48. removed.push(path)
  49. return Effect.void
  50. },
  51. writeFileString: () => Effect.void,
  52. }),
  53. ),
  54. Effect.tap(() => Effect.sync(() => expect(removed).toEqual(["/generated/old.ts"]))),
  55. )
  56. })
  57. it.effect("rejects unsafe and duplicate output paths before writing", () => {
  58. const writes: Array<string> = []
  59. return Effect.gen(function* () {
  60. const error = yield* write(
  61. {
  62. operations: [],
  63. files: [
  64. { path: "../outside.ts", content: "" },
  65. { path: "client.ts", content: "" },
  66. { path: "CLIENT.ts", content: "" },
  67. ],
  68. },
  69. "/generated",
  70. ).pipe(Effect.flip)
  71. expect(error._tag).toBe("GenerationError")
  72. expect(writes).toEqual([])
  73. }).pipe(
  74. Effect.provideService(
  75. FileSystem.FileSystem,
  76. FileSystem.makeNoop({
  77. writeFileString: (path) => {
  78. writes.push(path)
  79. return Effect.void
  80. },
  81. }),
  82. ),
  83. )
  84. })
  85. it.effect("rejects case-insensitive duplicate output paths", () => {
  86. const writes: Array<string> = []
  87. return Effect.gen(function* () {
  88. const error = yield* write(
  89. {
  90. operations: [],
  91. files: [
  92. { path: "client.ts", content: "" },
  93. { path: "CLIENT.ts", content: "" },
  94. ],
  95. },
  96. "/generated",
  97. ).pipe(Effect.flip)
  98. expect(error._tag).toBe("GenerationError")
  99. expect(error.reason).toBe("Duplicate output path: CLIENT.ts")
  100. expect(writes).toEqual([])
  101. }).pipe(
  102. Effect.provideService(
  103. FileSystem.FileSystem,
  104. FileSystem.makeNoop({
  105. writeFileString: (path) => {
  106. writes.push(path)
  107. return Effect.void
  108. },
  109. }),
  110. ),
  111. )
  112. })
  113. it.effect("reserves the private manifest path", () =>
  114. write({ operations: [], files: [{ path: ".httpapi-codegen.json", content: "" }] }, "/generated").pipe(
  115. Effect.flip,
  116. Effect.tap((error) => Effect.sync(() => expect(error.reason).toContain("Unsafe output path"))),
  117. Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})),
  118. ),
  119. )
  120. it.effect("rejects existing symbolic-link output targets", () =>
  121. write({ operations: [], files: [{ path: "session.ts", content: "" }] }, "/generated").pipe(
  122. Effect.flip,
  123. Effect.tap((error) => Effect.sync(() => expect(error.reason).toBe("Unsafe output path: session.ts"))),
  124. Effect.provideService(
  125. FileSystem.FileSystem,
  126. FileSystem.makeNoop({
  127. exists: (path) => Effect.succeed(path.endsWith("session.ts")),
  128. makeDirectory: () => Effect.void,
  129. stat: () =>
  130. Effect.succeed({
  131. type: "SymbolicLink",
  132. mtime: Option.none(),
  133. atime: Option.none(),
  134. birthtime: Option.none(),
  135. dev: 0,
  136. ino: Option.none(),
  137. mode: 0,
  138. nlink: Option.none(),
  139. uid: Option.none(),
  140. gid: Option.none(),
  141. rdev: Option.none(),
  142. size: FileSystem.Size(0),
  143. blksize: Option.none(),
  144. blocks: Option.none(),
  145. }),
  146. }),
  147. ),
  148. ),
  149. )
  150. })