location-mutation.test.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  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. canonical: yield* Effect.promise(() => fs.realpath(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. const root = yield* Effect.promise(() => fs.realpath(directory))
  49. expect(target).toMatchObject({
  50. canonical: path.join(root, "src", "new.txt"),
  51. resource: "src/new.txt",
  52. })
  53. }).pipe(provide(directory)),
  54. ),
  55. )
  56. it.live("requires external-directory authorization for a relative lexical escape", () =>
  57. withTmp((directory) =>
  58. Effect.gen(function* () {
  59. const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
  60. const root = yield* Effect.promise(() => fs.realpath(path.dirname(directory)))
  61. expect(target).toMatchObject({
  62. canonical: path.join(root, "outside.txt"),
  63. resource: path.join(root, "outside.txt").replaceAll("\\", "/"),
  64. })
  65. expect(target.externalDirectory).toMatchObject({
  66. directory: root,
  67. resource: path.join(root, "*").replaceAll("\\", "/"),
  68. })
  69. }).pipe(provide(directory)),
  70. ),
  71. )
  72. it.live("authorizes a prospective target below an external symlink by its in-location path", () =>
  73. withTmp((directory) => {
  74. const outside = `${directory}-outside`
  75. return Effect.gen(function* () {
  76. if (process.platform === "win32") return
  77. yield* Effect.promise(async () => {
  78. await fs.mkdir(outside)
  79. await fs.symlink(outside, path.join(directory, "escape"))
  80. })
  81. const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
  82. expect(target).toMatchObject({
  83. canonical: path.join(yield* Effect.promise(() => fs.realpath(outside)), "new.txt"),
  84. resource: "escape/new.txt",
  85. })
  86. expect(target.externalDirectory).toBeUndefined()
  87. yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
  88. }).pipe(provide(directory))
  89. }),
  90. )
  91. it.live("follows an in-location symlink using ordinary filesystem semantics", () =>
  92. withTmp((directory) =>
  93. Effect.gen(function* () {
  94. if (process.platform === "win32") return
  95. yield* Effect.promise(async () => {
  96. await fs.mkdir(path.join(directory, "actual"))
  97. await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked"))
  98. })
  99. expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
  100. canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "actual", "new.txt"),
  101. resource: "linked/new.txt",
  102. })
  103. }).pipe(provide(directory)),
  104. ),
  105. )
  106. it.live("accepts an explicit absolute in-location target without external approval", () =>
  107. withTmp((directory) =>
  108. Effect.gen(function* () {
  109. const targetPath = path.join(directory, "new.txt")
  110. const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  111. expect(target).toMatchObject({
  112. canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "new.txt"),
  113. resource: "new.txt",
  114. })
  115. expect(target.externalDirectory).toBeUndefined()
  116. }).pipe(provide(directory)),
  117. ),
  118. )
  119. it.live("requires external-directory authorization for an explicit external absolute target", () =>
  120. withTmp((directory) =>
  121. withTmp((outside) =>
  122. Effect.gen(function* () {
  123. const targetPath = path.join(outside, "new.txt")
  124. const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  125. const root = yield* Effect.promise(() => fs.realpath(outside))
  126. expect(target).toMatchObject({
  127. canonical: path.join(root, "new.txt"),
  128. resource: path.join(root, "new.txt").replaceAll("\\", "/"),
  129. })
  130. expect(target.externalDirectory).toMatchObject({
  131. directory: root,
  132. resource: path.join(root, "*").replaceAll("\\", "/"),
  133. })
  134. }).pipe(provide(directory)),
  135. ),
  136. ),
  137. )
  138. it.live("resolves an existing external file target", () =>
  139. withTmp((directory) =>
  140. withTmp((outside) =>
  141. Effect.gen(function* () {
  142. const targetPath = path.join(outside, "existing.txt")
  143. yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
  144. const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  145. const root = yield* Effect.promise(() => fs.realpath(outside))
  146. expect(target).toMatchObject({ canonical: path.join(root, "existing.txt") })
  147. expect(target.externalDirectory?.directory).toBe(root)
  148. }).pipe(provide(directory)),
  149. ),
  150. ),
  151. )
  152. it.live("anchors prospective external descendants at their stable existing directory", () =>
  153. withTmp((directory) =>
  154. withTmp((outside) =>
  155. Effect.gen(function* () {
  156. const targetPath = path.join(outside, "new", "nested", "file.txt")
  157. const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  158. const root = yield* Effect.promise(() => fs.realpath(outside))
  159. expect(target.externalDirectory).toMatchObject({
  160. directory: root,
  161. resource: path.join(root, "*").replaceAll("\\", "/"),
  162. })
  163. }).pipe(provide(directory)),
  164. ),
  165. ),
  166. )
  167. test("ignores unknown mutation input fields", () => {
  168. expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
  169. expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
  170. path: "README.md",
  171. })
  172. })
  173. })