environment-conformance.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. import { describe, expect } from "bun:test"
  2. import { Effect } from "effect"
  3. import { Failed, NotFound, WrongKind, type Files } from "../../src/environment/index"
  4. import { it } from "./effect"
  5. export interface EnvironmentHarness {
  6. readonly files: Files
  7. readonly root: string
  8. readonly symlink?: (target: string, path: string) => Effect.Effect<void, Failed>
  9. readonly dispose?: Effect.Effect<void>
  10. }
  11. export const environmentConformance = <E>(
  12. name: string,
  13. makeHarness: () => Effect.Effect<EnvironmentHarness, E>,
  14. skip = false,
  15. ) => {
  16. const check = <A, E2>(title: string, body: (harness: EnvironmentHarness) => Effect.Effect<A, E2>) =>
  17. it.live(title, () =>
  18. Effect.gen(function* () {
  19. const harness = yield* Effect.acquireRelease(makeHarness(), (harness) =>
  20. Effect.gen(function* () {
  21. yield* Effect.ignore(harness.files.remove(harness.root))
  22. if (harness.dispose) yield* harness.dispose
  23. }),
  24. )
  25. yield* harness.files.mkdir(harness.root)
  26. return yield* body(harness)
  27. }),
  28. )
  29. const bytes = (value: string) => new TextEncoder().encode(value)
  30. const text = (value: Uint8Array) => new TextDecoder().decode(value)
  31. const suite = skip ? describe.skip : describe
  32. suite(name, () => {
  33. check("writes, stats, and reads a file with its info", (harness) =>
  34. Effect.gen(function* () {
  35. const target = `${harness.root}/hello.txt`
  36. yield* harness.files.write(target, bytes("hello"))
  37. const result = yield* harness.files.read(target)
  38. expect(text(result.bytes)).toBe("hello")
  39. expect(result.info.type).toBe("file")
  40. expect(result.info.size).toBe(5)
  41. expect(yield* harness.files.stat(target)).toEqual(result.info)
  42. }),
  43. )
  44. check("reports missing paths", (harness) =>
  45. Effect.gen(function* () {
  46. const target = `${harness.root}/missing`
  47. expect(yield* Effect.flip(harness.files.read(target))).toBeInstanceOf(NotFound)
  48. expect(yield* Effect.flip(harness.files.stat(target))).toBeInstanceOf(NotFound)
  49. expect(yield* Effect.flip(harness.files.list(target))).toBeInstanceOf(NotFound)
  50. expect(yield* Effect.flip(harness.files.move(target, `${harness.root}/other`))).toBeInstanceOf(NotFound)
  51. }),
  52. )
  53. check("reports the actual kind", (harness) =>
  54. Effect.gen(function* () {
  55. const directory = `${harness.root}/directory`
  56. const file = `${harness.root}/file`
  57. yield* harness.files.mkdir(directory)
  58. yield* harness.files.write(file, bytes("data"))
  59. const readError = yield* Effect.flip(harness.files.read(directory))
  60. const listError = yield* Effect.flip(harness.files.list(file))
  61. expect(readError).toBeInstanceOf(WrongKind)
  62. expect((readError as WrongKind).actual).toBe("directory")
  63. expect(listError).toBeInstanceOf(WrongKind)
  64. expect((listError as WrongKind).actual).toBe("file")
  65. }),
  66. )
  67. check("write creates parent directories", (harness) =>
  68. Effect.gen(function* () {
  69. const target = `${harness.root}/one/two/file`
  70. yield* harness.files.write(target, bytes("nested"))
  71. yield* harness.files.write(`${harness.root}/empty`, new Uint8Array())
  72. expect((yield* harness.files.stat(`${harness.root}/one/two`)).type).toBe("directory")
  73. expect(yield* harness.files.stat(`${harness.root}/empty`)).toMatchObject({ type: "file", size: 0 })
  74. expect(text((yield* harness.files.read(target)).bytes)).toBe("nested")
  75. }),
  76. )
  77. check("reads byte ranges", (harness) =>
  78. Effect.gen(function* () {
  79. const target = `${harness.root}/range`
  80. yield* harness.files.write(target, bytes("0123456789"))
  81. expect(text((yield* harness.files.read(target, { offset: 2, length: 4 })).bytes)).toBe("2345")
  82. expect(text((yield* harness.files.read(target, { offset: 8, length: 8 })).bytes)).toBe("89")
  83. expect(text((yield* harness.files.read(target, { offset: 20, length: 4 })).bytes)).toBe("")
  84. }),
  85. )
  86. check("lists immediate entries with their kinds", (harness) =>
  87. Effect.gen(function* () {
  88. yield* harness.files.write(`${harness.root}/file name`, bytes("data"))
  89. yield* harness.files.mkdir(`${harness.root}/directory`)
  90. yield* harness.files.write(`${harness.root}/directory/nested`, bytes("nested"))
  91. const entries = yield* harness.files.list(harness.root)
  92. expect(entries.toSorted((a, b) => a.name.localeCompare(b.name))).toEqual([
  93. { name: "directory", type: "directory" },
  94. { name: "file name", type: "file" },
  95. ])
  96. }),
  97. )
  98. check("preserves symlink metadata while following symlinks for content", (harness) =>
  99. Effect.gen(function* () {
  100. if (!harness.symlink) return
  101. yield* harness.files.write(`${harness.root}/target`, bytes("target"))
  102. yield* harness.files.write(`${harness.root}/target-dir/file`, bytes("through link"))
  103. yield* harness.symlink("../target", `${harness.root}/target-dir/entry-link`)
  104. yield* harness.symlink("target", `${harness.root}/link`)
  105. yield* harness.symlink("target-dir", `${harness.root}/link-dir`)
  106. yield* harness.symlink("missing", `${harness.root}/dangling-link`)
  107. expect((yield* harness.files.stat(`${harness.root}/link`)).type).toBe("symlink")
  108. expect(yield* harness.files.list(harness.root)).toContainEqual({ name: "link", type: "symlink" })
  109. expect(text((yield* harness.files.read(`${harness.root}/link-dir/file`)).bytes)).toBe("through link")
  110. expect(
  111. (yield* harness.files.list(`${harness.root}/link-dir`)).toSorted((a, b) => a.name.localeCompare(b.name)),
  112. ).toEqual([
  113. { name: "entry-link", type: "symlink" },
  114. { name: "file", type: "file" },
  115. ])
  116. const fileError = yield* Effect.flip(harness.files.list(`${harness.root}/link`))
  117. expect(fileError).toBeInstanceOf(WrongKind)
  118. expect((fileError as WrongKind).actual).toBe("file")
  119. expect(yield* Effect.flip(harness.files.list(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
  120. }),
  121. )
  122. check("follows symlinks when reading", (harness) =>
  123. Effect.gen(function* () {
  124. if (!harness.symlink) return
  125. yield* harness.files.write(`${harness.root}/target`, bytes("target content"))
  126. yield* harness.files.mkdir(`${harness.root}/directory`)
  127. yield* harness.symlink("target", `${harness.root}/file-link`)
  128. yield* harness.symlink("directory", `${harness.root}/directory-link`)
  129. yield* harness.symlink("missing", `${harness.root}/dangling-link`)
  130. const result = yield* harness.files.read(`${harness.root}/file-link`)
  131. expect(text(result.bytes)).toBe("target content")
  132. expect(result.info.type).toBe("file")
  133. expect(result.info.size).toBe(bytes("target content").length)
  134. const directoryError = yield* Effect.flip(harness.files.read(`${harness.root}/directory-link`))
  135. expect(directoryError).toBeInstanceOf(WrongKind)
  136. expect((directoryError as WrongKind).actual).toBe("directory")
  137. expect(yield* Effect.flip(harness.files.read(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
  138. }),
  139. )
  140. check("moves files and removes trees idempotently", (harness) =>
  141. Effect.gen(function* () {
  142. const source = `${harness.root}/source/file`
  143. const destination = `${harness.root}/destination`
  144. yield* harness.files.write(source, bytes("moved"))
  145. yield* harness.files.move(source, destination)
  146. expect(text((yield* harness.files.read(destination)).bytes)).toBe("moved")
  147. expect(yield* Effect.flip(harness.files.stat(source))).toBeInstanceOf(NotFound)
  148. yield* harness.files.remove(`${harness.root}/source`)
  149. yield* harness.files.remove(`${harness.root}/source`)
  150. expect(yield* Effect.flip(harness.files.stat(`${harness.root}/source`))).toBeInstanceOf(NotFound)
  151. }),
  152. )
  153. })
  154. }