formatter.test.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  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(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
  41. ],
  42. [Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })],
  43. ])
  44. }
  45. function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>) {
  46. return Effect.acquireUseRelease(
  47. Effect.promise(() => tmpdir()),
  48. (tmp) => body(tmp.path),
  49. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  50. )
  51. }
  52. describe("Formatter", () => {
  53. it.live("does not run formatters marked as disabled in config", () =>
  54. withTemp((directory) =>
  55. Effect.gen(function* () {
  56. const file = path.join(directory, "test.disabled")
  57. expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
  58. }).pipe(
  59. Effect.provide(
  60. formatterLayer(directory, {
  61. disabled: {
  62. disabled: true,
  63. command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
  64. extensions: [".disabled"],
  65. },
  66. }),
  67. ),
  68. ),
  69. ),
  70. )
  71. it.live("file() returns false when no formatter runs", () =>
  72. withTemp((directory) =>
  73. Effect.gen(function* () {
  74. const file = path.join(directory, "test.txt")
  75. yield* Effect.promise(() => fs.writeFile(file, "x"))
  76. expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
  77. }).pipe(Effect.provide(formatterLayer(directory, false))),
  78. ),
  79. )
  80. it.live("loads formatter state per directory", () =>
  81. withTemp((off) =>
  82. withTemp((on) =>
  83. Effect.gen(function* () {
  84. const offFile = path.join(off, "test.isolated")
  85. const onFile = path.join(on, "test.isolated")
  86. const disabled = yield* Formatter.Service.use((formatter) => formatter.file(offFile)).pipe(
  87. Effect.provide(formatterLayer(off, false)),
  88. )
  89. const enabled = yield* Formatter.Service.use((formatter) => formatter.file(onFile)).pipe(
  90. Effect.provide(
  91. formatterLayer(on, {
  92. isolated: {
  93. command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
  94. extensions: [".isolated"],
  95. },
  96. }),
  97. ),
  98. )
  99. expect(disabled).toBe(false)
  100. expect(enabled).toBe(true)
  101. }),
  102. ),
  103. ),
  104. )
  105. it.live("stops after the first matching formatter succeeds", () =>
  106. withTemp((directory) =>
  107. Effect.gen(function* () {
  108. const file = path.join(directory, "test.seq")
  109. yield* Effect.promise(() => fs.writeFile(file, "x"))
  110. expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
  111. expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA")
  112. }).pipe(
  113. Effect.provide(
  114. formatterLayer(directory, {
  115. first: {
  116. command: [
  117. process.execPath,
  118. "-e",
  119. "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')",
  120. "$FILE",
  121. ],
  122. extensions: [".seq"],
  123. },
  124. second: {
  125. command: [
  126. process.execPath,
  127. "-e",
  128. "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
  129. "$FILE",
  130. ],
  131. extensions: [".seq"],
  132. },
  133. }),
  134. ),
  135. ),
  136. ),
  137. )
  138. it.live("tries the next matching formatter when the first fails", () =>
  139. withTemp((directory) =>
  140. Effect.gen(function* () {
  141. const file = path.join(directory, "test.fallback")
  142. yield* Effect.promise(() => fs.writeFile(file, "x"))
  143. expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
  144. expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB")
  145. }).pipe(
  146. Effect.provide(
  147. formatterLayer(directory, {
  148. first: {
  149. command: [process.execPath, "-e", "process.exit(1)", "$FILE"],
  150. extensions: [".fallback"],
  151. },
  152. second: {
  153. command: [
  154. process.execPath,
  155. "-e",
  156. "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
  157. "$FILE",
  158. ],
  159. extensions: [".fallback"],
  160. },
  161. }),
  162. ),
  163. ),
  164. ),
  165. )
  166. })