formatter.test.ts 5.8 KB

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