formatter.test.ts 5.6 KB

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