config-io.test.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. /// <reference types="bun-types" />
  2. import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
  3. import {
  4. existsSync,
  5. mkdtempSync,
  6. readFileSync,
  7. rmSync,
  8. writeFileSync,
  9. } from 'node:fs';
  10. import { tmpdir } from 'node:os';
  11. import { join } from 'node:path';
  12. import {
  13. addChutesProvider,
  14. addPluginToOpenCodeConfig,
  15. detectCurrentConfig,
  16. disableDefaultAgents,
  17. parseConfig,
  18. parseConfigFile,
  19. stripJsonComments,
  20. writeConfig,
  21. writeLiteConfig,
  22. } from './config-io';
  23. import * as paths from './paths';
  24. describe('config-io', () => {
  25. let tmpDir: string;
  26. const originalEnv = { ...process.env };
  27. beforeEach(() => {
  28. tmpDir = mkdtempSync(join(tmpdir(), 'opencode-io-test-'));
  29. process.env.XDG_CONFIG_HOME = tmpDir;
  30. });
  31. afterEach(() => {
  32. process.env = { ...originalEnv };
  33. if (tmpDir && existsSync(tmpDir)) {
  34. rmSync(tmpDir, { recursive: true, force: true });
  35. }
  36. mock.restore();
  37. });
  38. test('stripJsonComments strips comments and trailing commas', () => {
  39. const jsonc = `{
  40. // comment
  41. "a": 1, /* multi
  42. line */
  43. "b": [2,],
  44. }`;
  45. const stripped = stripJsonComments(jsonc);
  46. expect(JSON.parse(stripped)).toEqual({ a: 1, b: [2] });
  47. });
  48. test('parseConfigFile parses valid JSON', () => {
  49. const path = join(tmpDir, 'test.json');
  50. writeFileSync(path, '{"a": 1}');
  51. const result = parseConfigFile(path);
  52. expect(result.config).toEqual({ a: 1 } as any);
  53. expect(result.error).toBeUndefined();
  54. });
  55. test('parseConfigFile returns null for non-existent file', () => {
  56. const result = parseConfigFile(join(tmpDir, 'nonexistent.json'));
  57. expect(result.config).toBeNull();
  58. });
  59. test('parseConfigFile returns null for empty or whitespace-only file', () => {
  60. const emptyPath = join(tmpDir, 'empty.json');
  61. writeFileSync(emptyPath, '');
  62. expect(parseConfigFile(emptyPath).config).toBeNull();
  63. const whitespacePath = join(tmpDir, 'whitespace.json');
  64. writeFileSync(whitespacePath, ' \n ');
  65. expect(parseConfigFile(whitespacePath).config).toBeNull();
  66. });
  67. test('parseConfigFile returns error for invalid JSON', () => {
  68. const path = join(tmpDir, 'invalid.json');
  69. writeFileSync(path, '{"a": 1');
  70. const result = parseConfigFile(path);
  71. expect(result.config).toBeNull();
  72. expect(result.error).toBeDefined();
  73. });
  74. test('parseConfig tries .jsonc if .json is missing', () => {
  75. const jsoncPath = join(tmpDir, 'test.jsonc');
  76. writeFileSync(jsoncPath, '{"a": 1}');
  77. // We pass .json path, it should try .jsonc
  78. const result = parseConfig(join(tmpDir, 'test.json'));
  79. expect(result.config).toEqual({ a: 1 } as any);
  80. });
  81. test('writeConfig writes JSON and creates backup', () => {
  82. const path = join(tmpDir, 'test.json');
  83. writeFileSync(path, '{"old": true}');
  84. writeConfig(path, { new: true } as any);
  85. expect(JSON.parse(readFileSync(path, 'utf-8'))).toEqual({ new: true });
  86. expect(JSON.parse(readFileSync(`${path}.bak`, 'utf-8'))).toEqual({
  87. old: true,
  88. });
  89. });
  90. test('addPluginToOpenCodeConfig adds plugin and removes duplicates', async () => {
  91. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  92. paths.ensureConfigDir();
  93. writeFileSync(
  94. configPath,
  95. JSON.stringify({ plugin: ['other', 'oh-my-opencode-slim@1.0.0'] }),
  96. );
  97. const result = await addPluginToOpenCodeConfig();
  98. expect(result.success).toBe(true);
  99. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  100. expect(saved.plugin).toContain('oh-my-opencode-slim');
  101. expect(saved.plugin).not.toContain('oh-my-opencode-slim@1.0.0');
  102. expect(saved.plugin.length).toBe(2);
  103. });
  104. test('writeLiteConfig writes lite config', () => {
  105. const litePath = join(tmpDir, 'opencode', 'oh-my-opencode-slim.json');
  106. paths.ensureConfigDir();
  107. const result = writeLiteConfig({
  108. hasKimi: true,
  109. hasOpenAI: false,
  110. hasAntigravity: false,
  111. hasOpencodeZen: false,
  112. hasTmux: true,
  113. installSkills: false,
  114. installCustomSkills: false,
  115. });
  116. expect(result.success).toBe(true);
  117. const saved = JSON.parse(readFileSync(litePath, 'utf-8'));
  118. expect(saved.preset).toBe('kimi');
  119. expect(saved.presets.kimi).toBeDefined();
  120. expect(saved.tmux.enabled).toBe(true);
  121. });
  122. test('disableDefaultAgents disables explore and general agents', () => {
  123. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  124. paths.ensureConfigDir();
  125. writeFileSync(configPath, JSON.stringify({}));
  126. const result = disableDefaultAgents();
  127. expect(result.success).toBe(true);
  128. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  129. expect(saved.agent.explore.disable).toBe(true);
  130. expect(saved.agent.general.disable).toBe(true);
  131. });
  132. test('detectCurrentConfig detects installed status', () => {
  133. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  134. const litePath = join(tmpDir, 'opencode', 'oh-my-opencode-slim.json');
  135. paths.ensureConfigDir();
  136. writeFileSync(
  137. configPath,
  138. JSON.stringify({
  139. plugin: ['oh-my-opencode-slim'],
  140. provider: {
  141. kimi: {
  142. npm: '@ai-sdk/openai-compatible',
  143. },
  144. },
  145. }),
  146. );
  147. writeFileSync(
  148. litePath,
  149. JSON.stringify({
  150. preset: 'openai',
  151. presets: {
  152. openai: {
  153. orchestrator: { model: 'openai/gpt-4' },
  154. oracle: { model: 'anthropic/claude-opus-4-6' },
  155. explorer: { model: 'github-copilot/grok-code-fast-1' },
  156. librarian: { model: 'zai-coding-plan/glm-4.7' },
  157. },
  158. },
  159. tmux: { enabled: true },
  160. }),
  161. );
  162. const detected = detectCurrentConfig();
  163. expect(detected.isInstalled).toBe(true);
  164. expect(detected.hasKimi).toBe(true);
  165. expect(detected.hasOpenAI).toBe(true);
  166. expect(detected.hasAnthropic).toBe(true);
  167. expect(detected.hasCopilot).toBe(true);
  168. expect(detected.hasZaiPlan).toBe(true);
  169. expect(detected.hasTmux).toBe(true);
  170. });
  171. test('addChutesProvider keeps OpenCode auth-based chutes flow intact', () => {
  172. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  173. const litePath = join(tmpDir, 'opencode', 'oh-my-opencode-slim.json');
  174. paths.ensureConfigDir();
  175. writeFileSync(
  176. configPath,
  177. JSON.stringify({ plugin: ['oh-my-opencode-slim'] }),
  178. );
  179. writeFileSync(
  180. litePath,
  181. JSON.stringify({
  182. preset: 'chutes',
  183. presets: {
  184. chutes: {
  185. orchestrator: { model: 'chutes/kimi-k2.5' },
  186. },
  187. },
  188. }),
  189. );
  190. const result = addChutesProvider();
  191. expect(result.success).toBe(true);
  192. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  193. expect(saved.plugin).toContain('oh-my-opencode-slim');
  194. expect(saved.provider).toBeUndefined();
  195. const detected = detectCurrentConfig();
  196. expect(detected.hasChutes).toBe(true);
  197. });
  198. });