config.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629
  1. import { test, expect } from "bun:test"
  2. import { Config } from "../../src/config/config"
  3. import { Instance } from "../../src/project/instance"
  4. import { tmpdir } from "../fixture/fixture"
  5. import path from "path"
  6. import fs from "fs/promises"
  7. import { pathToFileURL } from "url"
  8. test("loads config with defaults when no files exist", async () => {
  9. await using tmp = await tmpdir()
  10. await Instance.provide({
  11. directory: tmp.path,
  12. fn: async () => {
  13. const config = await Config.get()
  14. expect(config.username).toBeDefined()
  15. },
  16. })
  17. })
  18. test("loads JSON config file", async () => {
  19. await using tmp = await tmpdir({
  20. init: async (dir) => {
  21. await Bun.write(
  22. path.join(dir, "opencode.json"),
  23. JSON.stringify({
  24. $schema: "https://opencode.ai/config.json",
  25. model: "test/model",
  26. username: "testuser",
  27. }),
  28. )
  29. },
  30. })
  31. await Instance.provide({
  32. directory: tmp.path,
  33. fn: async () => {
  34. const config = await Config.get()
  35. expect(config.model).toBe("test/model")
  36. expect(config.username).toBe("testuser")
  37. },
  38. })
  39. })
  40. test("loads JSONC config file", async () => {
  41. await using tmp = await tmpdir({
  42. init: async (dir) => {
  43. await Bun.write(
  44. path.join(dir, "opencode.jsonc"),
  45. `{
  46. // This is a comment
  47. "$schema": "https://opencode.ai/config.json",
  48. "model": "test/model",
  49. "username": "testuser"
  50. }`,
  51. )
  52. },
  53. })
  54. await Instance.provide({
  55. directory: tmp.path,
  56. fn: async () => {
  57. const config = await Config.get()
  58. expect(config.model).toBe("test/model")
  59. expect(config.username).toBe("testuser")
  60. },
  61. })
  62. })
  63. test("merges multiple config files with correct precedence", async () => {
  64. await using tmp = await tmpdir({
  65. init: async (dir) => {
  66. await Bun.write(
  67. path.join(dir, "opencode.jsonc"),
  68. JSON.stringify({
  69. $schema: "https://opencode.ai/config.json",
  70. model: "base",
  71. username: "base",
  72. }),
  73. )
  74. await Bun.write(
  75. path.join(dir, "opencode.json"),
  76. JSON.stringify({
  77. $schema: "https://opencode.ai/config.json",
  78. model: "override",
  79. }),
  80. )
  81. },
  82. })
  83. await Instance.provide({
  84. directory: tmp.path,
  85. fn: async () => {
  86. const config = await Config.get()
  87. expect(config.model).toBe("override")
  88. expect(config.username).toBe("base")
  89. },
  90. })
  91. })
  92. test("handles environment variable substitution", async () => {
  93. const originalEnv = process.env["TEST_VAR"]
  94. process.env["TEST_VAR"] = "test_theme"
  95. try {
  96. await using tmp = await tmpdir({
  97. init: async (dir) => {
  98. await Bun.write(
  99. path.join(dir, "opencode.json"),
  100. JSON.stringify({
  101. $schema: "https://opencode.ai/config.json",
  102. theme: "{env:TEST_VAR}",
  103. }),
  104. )
  105. },
  106. })
  107. await Instance.provide({
  108. directory: tmp.path,
  109. fn: async () => {
  110. const config = await Config.get()
  111. expect(config.theme).toBe("test_theme")
  112. },
  113. })
  114. } finally {
  115. if (originalEnv !== undefined) {
  116. process.env["TEST_VAR"] = originalEnv
  117. } else {
  118. delete process.env["TEST_VAR"]
  119. }
  120. }
  121. })
  122. test("handles file inclusion substitution", async () => {
  123. await using tmp = await tmpdir({
  124. init: async (dir) => {
  125. await Bun.write(path.join(dir, "included.txt"), "test_theme")
  126. await Bun.write(
  127. path.join(dir, "opencode.json"),
  128. JSON.stringify({
  129. $schema: "https://opencode.ai/config.json",
  130. theme: "{file:included.txt}",
  131. }),
  132. )
  133. },
  134. })
  135. await Instance.provide({
  136. directory: tmp.path,
  137. fn: async () => {
  138. const config = await Config.get()
  139. expect(config.theme).toBe("test_theme")
  140. },
  141. })
  142. })
  143. test("validates config schema and throws on invalid fields", async () => {
  144. await using tmp = await tmpdir({
  145. init: async (dir) => {
  146. await Bun.write(
  147. path.join(dir, "opencode.json"),
  148. JSON.stringify({
  149. $schema: "https://opencode.ai/config.json",
  150. invalid_field: "should cause error",
  151. }),
  152. )
  153. },
  154. })
  155. await Instance.provide({
  156. directory: tmp.path,
  157. fn: async () => {
  158. // Strict schema should throw an error for invalid fields
  159. await expect(Config.get()).rejects.toThrow()
  160. },
  161. })
  162. })
  163. test("throws error for invalid JSON", async () => {
  164. await using tmp = await tmpdir({
  165. init: async (dir) => {
  166. await Bun.write(path.join(dir, "opencode.json"), "{ invalid json }")
  167. },
  168. })
  169. await Instance.provide({
  170. directory: tmp.path,
  171. fn: async () => {
  172. await expect(Config.get()).rejects.toThrow()
  173. },
  174. })
  175. })
  176. test("handles agent configuration", async () => {
  177. await using tmp = await tmpdir({
  178. init: async (dir) => {
  179. await Bun.write(
  180. path.join(dir, "opencode.json"),
  181. JSON.stringify({
  182. $schema: "https://opencode.ai/config.json",
  183. agent: {
  184. test_agent: {
  185. model: "test/model",
  186. temperature: 0.7,
  187. description: "test agent",
  188. },
  189. },
  190. }),
  191. )
  192. },
  193. })
  194. await Instance.provide({
  195. directory: tmp.path,
  196. fn: async () => {
  197. const config = await Config.get()
  198. expect(config.agent?.["test_agent"]).toEqual({
  199. model: "test/model",
  200. temperature: 0.7,
  201. description: "test agent",
  202. })
  203. },
  204. })
  205. })
  206. test("handles command configuration", async () => {
  207. await using tmp = await tmpdir({
  208. init: async (dir) => {
  209. await Bun.write(
  210. path.join(dir, "opencode.json"),
  211. JSON.stringify({
  212. $schema: "https://opencode.ai/config.json",
  213. command: {
  214. test_command: {
  215. template: "test template",
  216. description: "test command",
  217. agent: "test_agent",
  218. },
  219. },
  220. }),
  221. )
  222. },
  223. })
  224. await Instance.provide({
  225. directory: tmp.path,
  226. fn: async () => {
  227. const config = await Config.get()
  228. expect(config.command?.["test_command"]).toEqual({
  229. template: "test template",
  230. description: "test command",
  231. agent: "test_agent",
  232. })
  233. },
  234. })
  235. })
  236. test("migrates autoshare to share field", async () => {
  237. await using tmp = await tmpdir({
  238. init: async (dir) => {
  239. await Bun.write(
  240. path.join(dir, "opencode.json"),
  241. JSON.stringify({
  242. $schema: "https://opencode.ai/config.json",
  243. autoshare: true,
  244. }),
  245. )
  246. },
  247. })
  248. await Instance.provide({
  249. directory: tmp.path,
  250. fn: async () => {
  251. const config = await Config.get()
  252. expect(config.share).toBe("auto")
  253. expect(config.autoshare).toBe(true)
  254. },
  255. })
  256. })
  257. test("migrates mode field to agent field", async () => {
  258. await using tmp = await tmpdir({
  259. init: async (dir) => {
  260. await Bun.write(
  261. path.join(dir, "opencode.json"),
  262. JSON.stringify({
  263. $schema: "https://opencode.ai/config.json",
  264. mode: {
  265. test_mode: {
  266. model: "test/model",
  267. temperature: 0.5,
  268. },
  269. },
  270. }),
  271. )
  272. },
  273. })
  274. await Instance.provide({
  275. directory: tmp.path,
  276. fn: async () => {
  277. const config = await Config.get()
  278. expect(config.agent?.["test_mode"]).toEqual({
  279. model: "test/model",
  280. temperature: 0.5,
  281. mode: "primary",
  282. })
  283. },
  284. })
  285. })
  286. test("loads config from .opencode directory", async () => {
  287. await using tmp = await tmpdir({
  288. init: async (dir) => {
  289. const opencodeDir = path.join(dir, ".opencode")
  290. await fs.mkdir(opencodeDir, { recursive: true })
  291. const agentDir = path.join(opencodeDir, "agent")
  292. await fs.mkdir(agentDir, { recursive: true })
  293. await Bun.write(
  294. path.join(agentDir, "test.md"),
  295. `---
  296. model: test/model
  297. ---
  298. Test agent prompt`,
  299. )
  300. },
  301. })
  302. await Instance.provide({
  303. directory: tmp.path,
  304. fn: async () => {
  305. const config = await Config.get()
  306. expect(config.agent?.["test"]).toEqual({
  307. name: "test",
  308. model: "test/model",
  309. prompt: "Test agent prompt",
  310. })
  311. },
  312. })
  313. })
  314. test("updates config and writes to file", async () => {
  315. await using tmp = await tmpdir()
  316. await Instance.provide({
  317. directory: tmp.path,
  318. fn: async () => {
  319. const newConfig = { model: "updated/model" }
  320. await Config.update(newConfig as any)
  321. const writtenConfig = JSON.parse(await Bun.file(path.join(tmp.path, "config.json")).text())
  322. expect(writtenConfig.model).toBe("updated/model")
  323. },
  324. })
  325. })
  326. test("gets config directories", async () => {
  327. await using tmp = await tmpdir()
  328. await Instance.provide({
  329. directory: tmp.path,
  330. fn: async () => {
  331. const dirs = await Config.directories()
  332. expect(dirs.length).toBeGreaterThanOrEqual(1)
  333. },
  334. })
  335. })
  336. test("resolves scoped npm plugins in config", async () => {
  337. await using tmp = await tmpdir({
  338. init: async (dir) => {
  339. const pluginDir = path.join(dir, "node_modules", "@scope", "plugin")
  340. await fs.mkdir(pluginDir, { recursive: true })
  341. await Bun.write(
  342. path.join(dir, "package.json"),
  343. JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2),
  344. )
  345. await Bun.write(
  346. path.join(pluginDir, "package.json"),
  347. JSON.stringify(
  348. {
  349. name: "@scope/plugin",
  350. version: "1.0.0",
  351. type: "module",
  352. main: "./index.js",
  353. },
  354. null,
  355. 2,
  356. ),
  357. )
  358. await Bun.write(path.join(pluginDir, "index.js"), "export default {}\n")
  359. await Bun.write(
  360. path.join(dir, "opencode.json"),
  361. JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["@scope/plugin"] }, null, 2),
  362. )
  363. },
  364. })
  365. await Instance.provide({
  366. directory: tmp.path,
  367. fn: async () => {
  368. const config = await Config.get()
  369. const pluginEntries = config.plugin ?? []
  370. const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
  371. const expected = import.meta.resolve("@scope/plugin", baseUrl)
  372. expect(pluginEntries.includes(expected)).toBe(true)
  373. const scopedEntry = pluginEntries.find((entry) => entry === expected)
  374. expect(scopedEntry).toBeDefined()
  375. expect(scopedEntry?.includes("/node_modules/@scope/plugin/")).toBe(true)
  376. },
  377. })
  378. })
  379. test("merges plugin arrays from global and local configs", async () => {
  380. await using tmp = await tmpdir({
  381. init: async (dir) => {
  382. // Create a nested project structure with local .opencode config
  383. const projectDir = path.join(dir, "project")
  384. const opencodeDir = path.join(projectDir, ".opencode")
  385. await fs.mkdir(opencodeDir, { recursive: true })
  386. // Global config with plugins
  387. await Bun.write(
  388. path.join(dir, "opencode.json"),
  389. JSON.stringify({
  390. $schema: "https://opencode.ai/config.json",
  391. plugin: ["global-plugin-1", "global-plugin-2"],
  392. }),
  393. )
  394. // Local .opencode config with different plugins
  395. await Bun.write(
  396. path.join(opencodeDir, "opencode.json"),
  397. JSON.stringify({
  398. $schema: "https://opencode.ai/config.json",
  399. plugin: ["local-plugin-1"],
  400. }),
  401. )
  402. },
  403. })
  404. await Instance.provide({
  405. directory: path.join(tmp.path, "project"),
  406. fn: async () => {
  407. const config = await Config.get()
  408. const plugins = config.plugin ?? []
  409. // Should contain both global and local plugins
  410. expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
  411. expect(plugins.some((p) => p.includes("global-plugin-2"))).toBe(true)
  412. expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
  413. // Should have all 3 plugins (not replaced, but merged)
  414. const pluginNames = plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin"))
  415. expect(pluginNames.length).toBeGreaterThanOrEqual(3)
  416. },
  417. })
  418. })
  419. test("does not error when only custom agent is a subagent", async () => {
  420. await using tmp = await tmpdir({
  421. init: async (dir) => {
  422. const opencodeDir = path.join(dir, ".opencode")
  423. await fs.mkdir(opencodeDir, { recursive: true })
  424. const agentDir = path.join(opencodeDir, "agent")
  425. await fs.mkdir(agentDir, { recursive: true })
  426. await Bun.write(
  427. path.join(agentDir, "helper.md"),
  428. `---
  429. model: test/model
  430. mode: subagent
  431. ---
  432. Helper subagent prompt`,
  433. )
  434. },
  435. })
  436. await Instance.provide({
  437. directory: tmp.path,
  438. fn: async () => {
  439. const config = await Config.get()
  440. expect(config.agent?.["helper"]).toEqual({
  441. name: "helper",
  442. model: "test/model",
  443. mode: "subagent",
  444. prompt: "Helper subagent prompt",
  445. })
  446. },
  447. })
  448. })
  449. test("deduplicates duplicate plugins from global and local configs", async () => {
  450. await using tmp = await tmpdir({
  451. init: async (dir) => {
  452. // Create a nested project structure with local .opencode config
  453. const projectDir = path.join(dir, "project")
  454. const opencodeDir = path.join(projectDir, ".opencode")
  455. await fs.mkdir(opencodeDir, { recursive: true })
  456. // Global config with plugins
  457. await Bun.write(
  458. path.join(dir, "opencode.json"),
  459. JSON.stringify({
  460. $schema: "https://opencode.ai/config.json",
  461. plugin: ["duplicate-plugin", "global-plugin-1"],
  462. }),
  463. )
  464. // Local .opencode config with some overlapping plugins
  465. await Bun.write(
  466. path.join(opencodeDir, "opencode.json"),
  467. JSON.stringify({
  468. $schema: "https://opencode.ai/config.json",
  469. plugin: ["duplicate-plugin", "local-plugin-1"],
  470. }),
  471. )
  472. },
  473. })
  474. await Instance.provide({
  475. directory: path.join(tmp.path, "project"),
  476. fn: async () => {
  477. const config = await Config.get()
  478. const plugins = config.plugin ?? []
  479. // Should contain all unique plugins
  480. expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
  481. expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
  482. expect(plugins.some((p) => p.includes("duplicate-plugin"))).toBe(true)
  483. // Should deduplicate the duplicate plugin
  484. const duplicatePlugins = plugins.filter((p) => p.includes("duplicate-plugin"))
  485. expect(duplicatePlugins.length).toBe(1)
  486. // Should have exactly 3 unique plugins
  487. const pluginNames = plugins.filter(
  488. (p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"),
  489. )
  490. expect(pluginNames.length).toBe(3)
  491. },
  492. })
  493. })
  494. test("compaction config defaults to true when not specified", async () => {
  495. await using tmp = await tmpdir({
  496. init: async (dir) => {
  497. await Bun.write(
  498. path.join(dir, "opencode.json"),
  499. JSON.stringify({
  500. $schema: "https://opencode.ai/config.json",
  501. }),
  502. )
  503. },
  504. })
  505. await Instance.provide({
  506. directory: tmp.path,
  507. fn: async () => {
  508. const config = await Config.get()
  509. // When not specified, compaction should be undefined (defaults handled in usage)
  510. expect(config.compaction).toBeUndefined()
  511. },
  512. })
  513. })
  514. test("compaction config can disable auto compaction", async () => {
  515. await using tmp = await tmpdir({
  516. init: async (dir) => {
  517. await Bun.write(
  518. path.join(dir, "opencode.json"),
  519. JSON.stringify({
  520. $schema: "https://opencode.ai/config.json",
  521. compaction: {
  522. auto: false,
  523. },
  524. }),
  525. )
  526. },
  527. })
  528. await Instance.provide({
  529. directory: tmp.path,
  530. fn: async () => {
  531. const config = await Config.get()
  532. expect(config.compaction?.auto).toBe(false)
  533. expect(config.compaction?.prune).toBeUndefined()
  534. },
  535. })
  536. })
  537. test("compaction config can disable prune", async () => {
  538. await using tmp = await tmpdir({
  539. init: async (dir) => {
  540. await Bun.write(
  541. path.join(dir, "opencode.json"),
  542. JSON.stringify({
  543. $schema: "https://opencode.ai/config.json",
  544. compaction: {
  545. prune: false,
  546. },
  547. }),
  548. )
  549. },
  550. })
  551. await Instance.provide({
  552. directory: tmp.path,
  553. fn: async () => {
  554. const config = await Config.get()
  555. expect(config.compaction?.prune).toBe(false)
  556. expect(config.compaction?.auto).toBeUndefined()
  557. },
  558. })
  559. })
  560. test("compaction config can disable both auto and prune", async () => {
  561. await using tmp = await tmpdir({
  562. init: async (dir) => {
  563. await Bun.write(
  564. path.join(dir, "opencode.json"),
  565. JSON.stringify({
  566. $schema: "https://opencode.ai/config.json",
  567. compaction: {
  568. auto: false,
  569. prune: false,
  570. },
  571. }),
  572. )
  573. },
  574. })
  575. await Instance.provide({
  576. directory: tmp.path,
  577. fn: async () => {
  578. const config = await Config.get()
  579. expect(config.compaction?.auto).toBe(false)
  580. expect(config.compaction?.prune).toBe(false)
  581. },
  582. })
  583. })