formatter.test.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { describe, expect } from "bun:test"
  4. import { Effect, Layer, Schema, Stream } from "effect"
  5. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  6. import { AbsolutePath } from "@opencode-ai/core/schema"
  7. import { Npm } from "@opencode-ai/util/npm"
  8. import { Config } from "../src/config"
  9. import { Formatter } from "../src/formatter"
  10. import { Location } from "../src/location"
  11. import { location } from "./fixture/location"
  12. import { tmpdir } from "./fixture/tmpdir"
  13. import { testEffect } from "./lib/effect"
  14. const it = testEffect(Layer.empty)
  15. type ConfigInput = typeof Config.Info.Encoded
  16. function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
  17. const entries =
  18. configured === undefined
  19. ? []
  20. : [
  21. new Config.Document({
  22. type: "document",
  23. info: Schema.decodeUnknownSync(Config.Info)({ formatter: configured }),
  24. }),
  25. ]
  26. return AppNodeBuilder.build(Formatter.node, [
  27. [
  28. Config.node,
  29. Layer.succeed(
  30. Config.Service,
  31. Config.Service.of({
  32. entries: () => Effect.succeed(entries),
  33. changes: () => Stream.empty,
  34. }),
  35. ),
  36. ],
  37. [
  38. Location.node,
  39. Layer.succeed(
  40. Location.Service,
  41. Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
  42. ),
  43. ],
  44. [Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })],
  45. ])
  46. }
  47. function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>) {
  48. return Effect.acquireUseRelease(
  49. Effect.promise(() => tmpdir()),
  50. (tmp) => body(tmp.path),
  51. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  52. )
  53. }
  54. describe("Formatter", () => {
  55. it.live("status() returns empty list when no formatters are configured", () =>
  56. withTemp((directory) =>
  57. Formatter.Service.use((formatter) => formatter.status()).pipe(Effect.provide(formatterLayer(directory))),
  58. ),
  59. )
  60. it.live("status() returns built-in formatters when formatter is true", () =>
  61. withTemp((directory) =>
  62. Formatter.Service.use((formatter) =>
  63. Effect.gen(function* () {
  64. const statuses = yield* formatter.status()
  65. const gofmt = statuses.find((item) => item.name === "gofmt")
  66. expect(gofmt).toBeDefined()
  67. expect(gofmt?.extensions).toContain(".go")
  68. }),
  69. ).pipe(Effect.provide(formatterLayer(directory, true))),
  70. ),
  71. )
  72. it.live("status() keeps built-in formatters when config object is provided", () =>
  73. withTemp((directory) =>
  74. Formatter.Service.use((formatter) =>
  75. Effect.gen(function* () {
  76. const statuses = yield* formatter.status()
  77. expect(statuses.find((item) => item.name === "gofmt")?.extensions).toContain(".go")
  78. expect(statuses.find((item) => item.name === "mix")).toBeDefined()
  79. }),
  80. ).pipe(Effect.provide(formatterLayer(directory, { gofmt: {} }))),
  81. ),
  82. )
  83. it.live("status() excludes formatters marked as disabled in config", () =>
  84. withTemp((directory) =>
  85. Formatter.Service.use((formatter) =>
  86. Effect.gen(function* () {
  87. const statuses = yield* formatter.status()
  88. expect(statuses.find((item) => item.name === "gofmt")).toBeUndefined()
  89. expect(statuses.find((item) => item.name === "mix")).toBeDefined()
  90. }),
  91. ).pipe(Effect.provide(formatterLayer(directory, { gofmt: { disabled: true } }))),
  92. ),
  93. )
  94. it.live("service initializes without error", () =>
  95. withTemp((directory) =>
  96. Formatter.Service.use((formatter) => formatter.init()).pipe(Effect.provide(formatterLayer(directory))),
  97. ),
  98. )
  99. it.live("file() returns false when no formatter runs", () =>
  100. withTemp((directory) =>
  101. Effect.gen(function* () {
  102. const file = path.join(directory, "test.txt")
  103. yield* Effect.promise(() => fs.writeFile(file, "x"))
  104. expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
  105. }).pipe(Effect.provide(formatterLayer(directory, false))),
  106. ),
  107. )
  108. it.live("status() initializes formatter state per directory", () =>
  109. Effect.acquireUseRelease(
  110. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  111. ([off, on]) =>
  112. Effect.gen(function* () {
  113. const disabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
  114. Effect.provide(formatterLayer(off.path, false)),
  115. )
  116. const enabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
  117. Effect.provide(formatterLayer(on.path, true)),
  118. )
  119. expect(disabled).toEqual([])
  120. expect(enabled.find((item) => item.name === "gofmt")).toBeDefined()
  121. }),
  122. (directories) =>
  123. Effect.promise(() => Promise.all(directories.map((tmp) => tmp[Symbol.asyncDispose]())).then(() => undefined)),
  124. ),
  125. )
  126. it.live("stops after the first matching formatter succeeds", () =>
  127. withTemp((directory) =>
  128. Effect.gen(function* () {
  129. const file = path.join(directory, "test.seq")
  130. yield* Effect.promise(() => fs.writeFile(file, "x"))
  131. expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
  132. expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA")
  133. }).pipe(
  134. Effect.provide(
  135. formatterLayer(directory, {
  136. first: {
  137. command: [
  138. process.execPath,
  139. "-e",
  140. "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')",
  141. "$FILE",
  142. ],
  143. extensions: [".seq"],
  144. },
  145. second: {
  146. command: [
  147. process.execPath,
  148. "-e",
  149. "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
  150. "$FILE",
  151. ],
  152. extensions: [".seq"],
  153. },
  154. }),
  155. ),
  156. ),
  157. ),
  158. )
  159. it.live("tries the next matching formatter when the first fails", () =>
  160. withTemp((directory) =>
  161. Effect.gen(function* () {
  162. const file = path.join(directory, "test.fallback")
  163. yield* Effect.promise(() => fs.writeFile(file, "x"))
  164. expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
  165. expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB")
  166. }).pipe(
  167. Effect.provide(
  168. formatterLayer(directory, {
  169. first: {
  170. command: [process.execPath, "-e", "process.exit(1)", "$FILE"],
  171. extensions: [".fallback"],
  172. },
  173. second: {
  174. command: [
  175. process.execPath,
  176. "-e",
  177. "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
  178. "$FILE",
  179. ],
  180. extensions: [".fallback"],
  181. },
  182. }),
  183. ),
  184. ),
  185. ),
  186. )
  187. })