tui.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. import { afterEach, expect, test } from "bun:test"
  2. import path from "path"
  3. import fs from "fs/promises"
  4. import { tmpdir } from "../fixture/fixture"
  5. import { Instance } from "../../src/project/instance"
  6. import { TuiConfig } from "../../src/config/tui"
  7. import { Global } from "../../src/global"
  8. import { Filesystem } from "../../src/util/filesystem"
  9. const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
  10. afterEach(async () => {
  11. delete process.env.OPENCODE_CONFIG
  12. delete process.env.OPENCODE_TUI_CONFIG
  13. await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
  14. await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
  15. await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
  16. })
  17. test("loads tui config with the same precedence order as server config paths", async () => {
  18. await using tmp = await tmpdir({
  19. init: async (dir) => {
  20. await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2))
  21. await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project" }, null, 2))
  22. await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
  23. await Bun.write(
  24. path.join(dir, ".opencode", "tui.json"),
  25. JSON.stringify({ theme: "local", diff_style: "stacked" }, null, 2),
  26. )
  27. },
  28. })
  29. await Instance.provide({
  30. directory: tmp.path,
  31. fn: async () => {
  32. const config = await TuiConfig.get()
  33. expect(config.theme).toBe("local")
  34. expect(config.diff_style).toBe("stacked")
  35. },
  36. })
  37. })
  38. test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => {
  39. await using tmp = await tmpdir({
  40. init: async (dir) => {
  41. await Bun.write(
  42. path.join(dir, "opencode.json"),
  43. JSON.stringify(
  44. {
  45. theme: "migrated-theme",
  46. tui: { scroll_speed: 5 },
  47. keybinds: { app_exit: "ctrl+q" },
  48. },
  49. null,
  50. 2,
  51. ),
  52. )
  53. },
  54. })
  55. await Instance.provide({
  56. directory: tmp.path,
  57. fn: async () => {
  58. const config = await TuiConfig.get()
  59. expect(config.theme).toBe("migrated-theme")
  60. expect(config.scroll_speed).toBe(5)
  61. expect(config.keybinds?.app_exit).toBe("ctrl+q")
  62. const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
  63. expect(JSON.parse(text)).toMatchObject({
  64. theme: "migrated-theme",
  65. scroll_speed: 5,
  66. })
  67. const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
  68. expect(server.theme).toBeUndefined()
  69. expect(server.keybinds).toBeUndefined()
  70. expect(server.tui).toBeUndefined()
  71. expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(true)
  72. expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
  73. },
  74. })
  75. })
  76. test("migrates project legacy tui keys even when global tui.json already exists", async () => {
  77. await using tmp = await tmpdir({
  78. init: async (dir) => {
  79. await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2))
  80. await Bun.write(
  81. path.join(dir, "opencode.json"),
  82. JSON.stringify(
  83. {
  84. theme: "project-migrated",
  85. tui: { scroll_speed: 2 },
  86. },
  87. null,
  88. 2,
  89. ),
  90. )
  91. },
  92. })
  93. await Instance.provide({
  94. directory: tmp.path,
  95. fn: async () => {
  96. const config = await TuiConfig.get()
  97. expect(config.theme).toBe("project-migrated")
  98. expect(config.scroll_speed).toBe(2)
  99. expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
  100. const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
  101. expect(server.theme).toBeUndefined()
  102. expect(server.tui).toBeUndefined()
  103. },
  104. })
  105. })
  106. test("drops unknown legacy tui keys during migration", async () => {
  107. await using tmp = await tmpdir({
  108. init: async (dir) => {
  109. await Bun.write(
  110. path.join(dir, "opencode.json"),
  111. JSON.stringify(
  112. {
  113. theme: "migrated-theme",
  114. tui: { scroll_speed: 2, foo: 1 },
  115. },
  116. null,
  117. 2,
  118. ),
  119. )
  120. },
  121. })
  122. await Instance.provide({
  123. directory: tmp.path,
  124. fn: async () => {
  125. const config = await TuiConfig.get()
  126. expect(config.theme).toBe("migrated-theme")
  127. expect(config.scroll_speed).toBe(2)
  128. const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
  129. const migrated = JSON.parse(text)
  130. expect(migrated.scroll_speed).toBe(2)
  131. expect(migrated.foo).toBeUndefined()
  132. },
  133. })
  134. })
  135. test("skips migration when opencode.jsonc is syntactically invalid", async () => {
  136. await using tmp = await tmpdir({
  137. init: async (dir) => {
  138. await Bun.write(
  139. path.join(dir, "opencode.jsonc"),
  140. `{
  141. "theme": "broken-theme",
  142. "tui": { "scroll_speed": 2 }
  143. "username": "still-broken"
  144. }`,
  145. )
  146. },
  147. })
  148. await Instance.provide({
  149. directory: tmp.path,
  150. fn: async () => {
  151. const config = await TuiConfig.get()
  152. expect(config.theme).toBeUndefined()
  153. expect(config.scroll_speed).toBeUndefined()
  154. expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(false)
  155. expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))).toBe(false)
  156. const source = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc"))
  157. expect(source).toContain('"theme": "broken-theme"')
  158. expect(source).toContain('"tui": { "scroll_speed": 2 }')
  159. },
  160. })
  161. })
  162. test("skips migration when tui.json already exists", async () => {
  163. await using tmp = await tmpdir({
  164. init: async (dir) => {
  165. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "legacy" }, null, 2))
  166. await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
  167. },
  168. })
  169. await Instance.provide({
  170. directory: tmp.path,
  171. fn: async () => {
  172. const config = await TuiConfig.get()
  173. expect(config.diff_style).toBe("stacked")
  174. expect(config.theme).toBeUndefined()
  175. const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
  176. expect(server.theme).toBe("legacy")
  177. expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(false)
  178. },
  179. })
  180. })
  181. test("continues loading tui config when legacy source cannot be stripped", async () => {
  182. await using tmp = await tmpdir({
  183. init: async (dir) => {
  184. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "readonly-theme" }, null, 2))
  185. },
  186. })
  187. const source = path.join(tmp.path, "opencode.json")
  188. await fs.chmod(source, 0o444)
  189. try {
  190. await Instance.provide({
  191. directory: tmp.path,
  192. fn: async () => {
  193. const config = await TuiConfig.get()
  194. expect(config.theme).toBe("readonly-theme")
  195. expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
  196. const server = JSON.parse(await Filesystem.readText(source))
  197. expect(server.theme).toBe("readonly-theme")
  198. },
  199. })
  200. } finally {
  201. await fs.chmod(source, 0o644)
  202. }
  203. })
  204. test("migration backup preserves JSONC comments", async () => {
  205. await using tmp = await tmpdir({
  206. init: async (dir) => {
  207. await Bun.write(
  208. path.join(dir, "opencode.jsonc"),
  209. `{
  210. // top-level comment
  211. "theme": "jsonc-theme",
  212. "tui": {
  213. // nested comment
  214. "scroll_speed": 1.5
  215. }
  216. }`,
  217. )
  218. },
  219. })
  220. await Instance.provide({
  221. directory: tmp.path,
  222. fn: async () => {
  223. await TuiConfig.get()
  224. const backup = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))
  225. expect(backup).toContain("// top-level comment")
  226. expect(backup).toContain("// nested comment")
  227. expect(backup).toContain('"theme": "jsonc-theme"')
  228. expect(backup).toContain('"scroll_speed": 1.5')
  229. },
  230. })
  231. })
  232. test("migrates legacy tui keys across multiple opencode.json levels", async () => {
  233. await using tmp = await tmpdir({
  234. init: async (dir) => {
  235. const nested = path.join(dir, "apps", "client")
  236. await fs.mkdir(nested, { recursive: true })
  237. await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "root-theme" }, null, 2))
  238. await Bun.write(path.join(nested, "opencode.json"), JSON.stringify({ theme: "nested-theme" }, null, 2))
  239. },
  240. })
  241. await Instance.provide({
  242. directory: path.join(tmp.path, "apps", "client"),
  243. fn: async () => {
  244. const config = await TuiConfig.get()
  245. expect(config.theme).toBe("nested-theme")
  246. expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
  247. expect(await Filesystem.exists(path.join(tmp.path, "apps", "client", "tui.json"))).toBe(true)
  248. },
  249. })
  250. })
  251. test("flattens nested tui key inside tui.json", async () => {
  252. await using tmp = await tmpdir({
  253. init: async (dir) => {
  254. await Bun.write(
  255. path.join(dir, "tui.json"),
  256. JSON.stringify({
  257. theme: "outer",
  258. tui: { scroll_speed: 3, diff_style: "stacked" },
  259. }),
  260. )
  261. },
  262. })
  263. await Instance.provide({
  264. directory: tmp.path,
  265. fn: async () => {
  266. const config = await TuiConfig.get()
  267. expect(config.scroll_speed).toBe(3)
  268. expect(config.diff_style).toBe("stacked")
  269. // top-level keys take precedence over nested tui keys
  270. expect(config.theme).toBe("outer")
  271. },
  272. })
  273. })
  274. test("top-level keys in tui.json take precedence over nested tui key", async () => {
  275. await using tmp = await tmpdir({
  276. init: async (dir) => {
  277. await Bun.write(
  278. path.join(dir, "tui.json"),
  279. JSON.stringify({
  280. diff_style: "auto",
  281. tui: { diff_style: "stacked", scroll_speed: 2 },
  282. }),
  283. )
  284. },
  285. })
  286. await Instance.provide({
  287. directory: tmp.path,
  288. fn: async () => {
  289. const config = await TuiConfig.get()
  290. expect(config.diff_style).toBe("auto")
  291. expect(config.scroll_speed).toBe(2)
  292. },
  293. })
  294. })
  295. test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE_CONFIG)", async () => {
  296. await using tmp = await tmpdir({
  297. init: async (dir) => {
  298. await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project", diff_style: "auto" }))
  299. const custom = path.join(dir, "custom-tui.json")
  300. await Bun.write(custom, JSON.stringify({ theme: "custom", diff_style: "stacked" }))
  301. process.env.OPENCODE_TUI_CONFIG = custom
  302. },
  303. })
  304. await Instance.provide({
  305. directory: tmp.path,
  306. fn: async () => {
  307. const config = await TuiConfig.get()
  308. // project tui.json overrides the custom path, same as server config precedence
  309. expect(config.theme).toBe("project")
  310. // project also set diff_style, so that wins
  311. expect(config.diff_style).toBe("auto")
  312. },
  313. })
  314. })
  315. test("merges keybind overrides across precedence layers", async () => {
  316. await using tmp = await tmpdir({
  317. init: async (dir) => {
  318. await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ keybinds: { app_exit: "ctrl+q" } }))
  319. await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { theme_list: "ctrl+k" } }))
  320. },
  321. })
  322. await Instance.provide({
  323. directory: tmp.path,
  324. fn: async () => {
  325. const config = await TuiConfig.get()
  326. expect(config.keybinds?.app_exit).toBe("ctrl+q")
  327. expect(config.keybinds?.theme_list).toBe("ctrl+k")
  328. },
  329. })
  330. })
  331. test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => {
  332. await using tmp = await tmpdir({
  333. init: async (dir) => {
  334. const custom = path.join(dir, "custom-tui.json")
  335. await Bun.write(custom, JSON.stringify({ theme: "from-env", diff_style: "stacked" }))
  336. process.env.OPENCODE_TUI_CONFIG = custom
  337. },
  338. })
  339. await Instance.provide({
  340. directory: tmp.path,
  341. fn: async () => {
  342. const config = await TuiConfig.get()
  343. expect(config.theme).toBe("from-env")
  344. expect(config.diff_style).toBe("stacked")
  345. },
  346. })
  347. })
  348. test("does not derive tui path from OPENCODE_CONFIG", async () => {
  349. await using tmp = await tmpdir({
  350. init: async (dir) => {
  351. const customDir = path.join(dir, "custom")
  352. await fs.mkdir(customDir, { recursive: true })
  353. await Bun.write(path.join(customDir, "opencode.json"), JSON.stringify({ model: "test/model" }))
  354. await Bun.write(path.join(customDir, "tui.json"), JSON.stringify({ theme: "should-not-load" }))
  355. process.env.OPENCODE_CONFIG = path.join(customDir, "opencode.json")
  356. },
  357. })
  358. await Instance.provide({
  359. directory: tmp.path,
  360. fn: async () => {
  361. const config = await TuiConfig.get()
  362. expect(config.theme).toBeUndefined()
  363. },
  364. })
  365. })
  366. test("applies env and file substitutions in tui.json", async () => {
  367. const original = process.env.TUI_THEME_TEST
  368. process.env.TUI_THEME_TEST = "env-theme"
  369. try {
  370. await using tmp = await tmpdir({
  371. init: async (dir) => {
  372. await Bun.write(path.join(dir, "keybind.txt"), "ctrl+q")
  373. await Bun.write(
  374. path.join(dir, "tui.json"),
  375. JSON.stringify({
  376. theme: "{env:TUI_THEME_TEST}",
  377. keybinds: { app_exit: "{file:keybind.txt}" },
  378. }),
  379. )
  380. },
  381. })
  382. await Instance.provide({
  383. directory: tmp.path,
  384. fn: async () => {
  385. const config = await TuiConfig.get()
  386. expect(config.theme).toBe("env-theme")
  387. expect(config.keybinds?.app_exit).toBe("ctrl+q")
  388. },
  389. })
  390. } finally {
  391. if (original === undefined) delete process.env.TUI_THEME_TEST
  392. else process.env.TUI_THEME_TEST = original
  393. }
  394. })
  395. test("applies file substitutions when first identical token is in a commented line", async () => {
  396. await using tmp = await tmpdir({
  397. init: async (dir) => {
  398. await Bun.write(path.join(dir, "theme.txt"), "resolved-theme")
  399. await Bun.write(
  400. path.join(dir, "tui.jsonc"),
  401. `{
  402. // "theme": "{file:theme.txt}",
  403. "theme": "{file:theme.txt}"
  404. }`,
  405. )
  406. },
  407. })
  408. await Instance.provide({
  409. directory: tmp.path,
  410. fn: async () => {
  411. const config = await TuiConfig.get()
  412. expect(config.theme).toBe("resolved-theme")
  413. },
  414. })
  415. })
  416. test("loads managed tui config and gives it highest precedence", async () => {
  417. await using tmp = await tmpdir({
  418. init: async (dir) => {
  419. await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project-theme" }, null, 2))
  420. await fs.mkdir(managedConfigDir, { recursive: true })
  421. await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-theme" }, null, 2))
  422. },
  423. })
  424. await Instance.provide({
  425. directory: tmp.path,
  426. fn: async () => {
  427. const config = await TuiConfig.get()
  428. expect(config.theme).toBe("managed-theme")
  429. },
  430. })
  431. })
  432. test("loads .opencode/tui.json", async () => {
  433. await using tmp = await tmpdir({
  434. init: async (dir) => {
  435. await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
  436. await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
  437. },
  438. })
  439. await Instance.provide({
  440. directory: tmp.path,
  441. fn: async () => {
  442. const config = await TuiConfig.get()
  443. expect(config.diff_style).toBe("stacked")
  444. },
  445. })
  446. })
  447. test("gracefully falls back when tui.json has invalid JSON", async () => {
  448. await using tmp = await tmpdir({
  449. init: async (dir) => {
  450. await Bun.write(path.join(dir, "tui.json"), "{ invalid json }")
  451. await fs.mkdir(managedConfigDir, { recursive: true })
  452. await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-fallback" }, null, 2))
  453. },
  454. })
  455. await Instance.provide({
  456. directory: tmp.path,
  457. fn: async () => {
  458. const config = await TuiConfig.get()
  459. expect(config.theme).toBe("managed-fallback")
  460. expect(config.keybinds).toBeDefined()
  461. },
  462. })
  463. })