location-mutation.test.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { describe, expect, test } from "bun:test"
  4. import { Effect, Layer, Schema } from "effect"
  5. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  6. import { Location } from "@opencode-ai/core/location"
  7. import { LocationMutation } from "@opencode-ai/core/location-mutation"
  8. import { AbsolutePath } from "@opencode-ai/core/schema"
  9. import { tmpdir } from "./fixture/tmpdir"
  10. import { location } from "./fixture/location"
  11. import { it } from "./lib/effect"
  12. function provide(directory: string) {
  13. return Effect.provide(
  14. LayerNode.compile(LocationMutation.node, [
  15. [
  16. Location.node,
  17. Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
  18. ],
  19. ]),
  20. )
  21. }
  22. function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
  23. return Effect.acquireRelease(
  24. Effect.promise(() => tmpdir()),
  25. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  26. ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
  27. }
  28. describe("LocationMutation", () => {
  29. it.live("resolves an active relative existing file target", () =>
  30. withTmp((directory) =>
  31. Effect.gen(function* () {
  32. const targetPath = path.join(directory, "hello.txt")
  33. yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
  34. const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
  35. expect(target).toMatchObject({
  36. absolute: targetPath,
  37. resource: "hello.txt",
  38. })
  39. expect(target.externalDirectory).toBeUndefined()
  40. }).pipe(provide(directory)),
  41. ),
  42. )
  43. it.live("resolves an active relative prospective file target", () =>
  44. withTmp((directory) =>
  45. Effect.gen(function* () {
  46. yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
  47. const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
  48. expect(target).toMatchObject({
  49. absolute: path.join(directory, "src", "new.txt"),
  50. resource: "src/new.txt",
  51. })
  52. }).pipe(provide(directory)),
  53. ),
  54. )
  55. it.live("requires external-directory authorization for a relative lexical escape", () =>
  56. withTmp((directory) =>
  57. Effect.gen(function* () {
  58. const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
  59. const root = path.dirname(directory)
  60. expect(target).toMatchObject({
  61. absolute: path.join(root, "outside.txt"),
  62. resource: path.join(root, "outside.txt").replaceAll("\\", "/"),
  63. })
  64. expect(target.externalDirectory).toMatchObject({
  65. directory: root,
  66. resource: path.join(root, "*").replaceAll("\\", "/"),
  67. })
  68. }).pipe(provide(directory)),
  69. ),
  70. )
  71. it.live("resolves a prospective target below an external symlink lexically", () =>
  72. withTmp((directory) => {
  73. const outside = `${directory}-outside`
  74. return Effect.gen(function* () {
  75. if (process.platform === "win32") return
  76. yield* Effect.promise(async () => {
  77. await fs.mkdir(outside)
  78. await fs.symlink(outside, path.join(directory, "escape"))
  79. })
  80. const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
  81. expect(target).toMatchObject({
  82. absolute: path.join(directory, "escape", "new.txt"),
  83. resource: "escape/new.txt",
  84. })
  85. expect(target.externalDirectory).toBeUndefined()
  86. yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
  87. }).pipe(provide(directory))
  88. }),
  89. )
  90. it.live("follows an in-location symlink using ordinary filesystem semantics", () =>
  91. withTmp((directory) =>
  92. Effect.gen(function* () {
  93. if (process.platform === "win32") return
  94. yield* Effect.promise(async () => {
  95. await fs.mkdir(path.join(directory, "actual"))
  96. await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked"))
  97. })
  98. expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
  99. absolute: path.join(directory, "linked", "new.txt"),
  100. resource: "linked/new.txt",
  101. })
  102. }).pipe(provide(directory)),
  103. ),
  104. )
  105. it.live("accepts an explicit absolute in-location target without external approval", () =>
  106. withTmp((directory) =>
  107. Effect.gen(function* () {
  108. const targetPath = path.join(directory, "new.txt")
  109. const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  110. expect(target).toMatchObject({
  111. absolute: targetPath,
  112. resource: "new.txt",
  113. })
  114. expect(target.externalDirectory).toBeUndefined()
  115. }).pipe(provide(directory)),
  116. ),
  117. )
  118. it.live("requires external-directory authorization for an explicit external absolute target", () =>
  119. withTmp((directory) =>
  120. withTmp((outside) =>
  121. Effect.gen(function* () {
  122. const targetPath = path.join(outside, "new.txt")
  123. const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  124. const root = outside
  125. expect(target).toMatchObject({
  126. absolute: path.join(root, "new.txt"),
  127. resource: path.join(root, "new.txt").replaceAll("\\", "/"),
  128. })
  129. expect(target.externalDirectory).toMatchObject({
  130. directory: root,
  131. resource: path.join(root, "*").replaceAll("\\", "/"),
  132. })
  133. }).pipe(provide(directory)),
  134. ),
  135. ),
  136. )
  137. it.live("resolves an existing external file target", () =>
  138. withTmp((directory) =>
  139. withTmp((outside) =>
  140. Effect.gen(function* () {
  141. const targetPath = path.join(outside, "existing.txt")
  142. yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
  143. const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  144. expect(target).toMatchObject({ absolute: targetPath })
  145. expect(target.externalDirectory?.directory).toBe(outside)
  146. }).pipe(provide(directory)),
  147. ),
  148. ),
  149. )
  150. it.live("uses an explicit file kind without treating an existing directory as the target boundary", () =>
  151. withTmp((directory) =>
  152. withTmp((outside) =>
  153. Effect.gen(function* () {
  154. const target = yield* (yield* LocationMutation.Service).resolve({ path: outside, kind: "file" })
  155. expect(target.externalDirectory).toMatchObject({
  156. directory: path.dirname(outside),
  157. resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"),
  158. })
  159. }).pipe(provide(directory)),
  160. ),
  161. ),
  162. )
  163. it.live("authorizes prospective external descendants at their lexical parent", () =>
  164. withTmp((directory) =>
  165. withTmp((outside) =>
  166. Effect.gen(function* () {
  167. const targetPath = path.join(outside, "new", "nested", "file.txt")
  168. const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  169. const parent = path.dirname(targetPath)
  170. expect(target.externalDirectory).toMatchObject({
  171. directory: parent,
  172. resource: path.join(parent, "*").replaceAll("\\", "/"),
  173. })
  174. }).pipe(provide(directory)),
  175. ),
  176. ),
  177. )
  178. test("ignores unknown mutation input fields", () => {
  179. expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
  180. expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
  181. path: "README.md",
  182. })
  183. })
  184. })