convert.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
  2. import { readFile, writeFile, mkdir, rm, access } from "fs/promises";
  3. import { join, dirname } from "path";
  4. import { fileURLToPath } from "url";
  5. import { executeConvert } from "../commands/convert.js";
  6. import type { GlobalOptions } from "../types.js";
  7. /**
  8. * Integration tests for the convert CLI command
  9. *
  10. * Test strategy:
  11. * 1. Successful conversions - OAC to/from Cursor, Claude, Windsurf
  12. * 2. Error handling - Non-existent files, invalid formats
  13. * 3. Output options - stdout, file output, --force flag
  14. * 4. Format detection - Auto-detect input formats
  15. * 5. Roundtrip - Data integrity checks
  16. */
  17. const __filename = fileURLToPath(import.meta.url);
  18. const __dirname = dirname(__filename);
  19. const FIXTURES_DIR = join(__dirname, "fixtures");
  20. const TEMP_DIR = join(__dirname, "temp-convert");
  21. // Default global options for tests
  22. const defaultGlobalOptions: GlobalOptions = {
  23. verbose: false,
  24. quiet: true,
  25. outputFormat: "text",
  26. };
  27. describe("convert command", () => {
  28. // Setup temp directory before each test
  29. beforeEach(async () => {
  30. await mkdir(TEMP_DIR, { recursive: true });
  31. });
  32. // Cleanup temp directory after each test
  33. afterEach(async () => {
  34. try {
  35. await rm(TEMP_DIR, { recursive: true, force: true });
  36. } catch {
  37. // Ignore cleanup errors
  38. }
  39. });
  40. // ============================================================================
  41. // SUCCESSFUL CONVERSIONS
  42. // ============================================================================
  43. describe("successful conversions", () => {
  44. it("converts OAC to Cursor format", async () => {
  45. // Arrange
  46. const inputPath = join(FIXTURES_DIR, "sample-oac-agent.md");
  47. // Act
  48. const result = await executeConvert(
  49. inputPath,
  50. { format: "cursor" },
  51. defaultGlobalOptions
  52. );
  53. // Assert
  54. expect(result.success).toBe(true);
  55. expect(result.data).toBeDefined();
  56. expect(result.data?.configs).toBeDefined();
  57. expect(result.data?.configs.length).toBeGreaterThan(0);
  58. expect(result.data?.configs[0].fileName).toBe(".cursorrules");
  59. });
  60. it("converts OAC to Claude format", async () => {
  61. // Arrange
  62. const inputPath = join(FIXTURES_DIR, "sample-oac-agent.md");
  63. // Act
  64. const result = await executeConvert(
  65. inputPath,
  66. { format: "claude" },
  67. defaultGlobalOptions
  68. );
  69. // Assert
  70. expect(result.success).toBe(true);
  71. expect(result.data).toBeDefined();
  72. expect(result.data?.configs).toBeDefined();
  73. // Claude Code's real target is the committed plugin tree, not `.claude/`. The old
  74. // `.claude/config.json` was a fabricated agent-config file that nothing reads.
  75. expect(result.data?.configs[0].fileName).toContain("plugins/claude-code/agents/");
  76. });
  77. it("converts Cursor to OAC format", async () => {
  78. // Arrange
  79. const inputPath = join(FIXTURES_DIR, "sample-cursorrules");
  80. // Act
  81. const result = await executeConvert(
  82. inputPath,
  83. { format: "oac", from: "cursor" },
  84. defaultGlobalOptions
  85. );
  86. // Assert
  87. expect(result.success).toBe(true);
  88. expect(result.data).toBeDefined();
  89. expect(result.data?.configs).toBeDefined();
  90. expect(result.data?.configs[0].content).toContain("---");
  91. });
  92. it("converts Claude to OAC format", async () => {
  93. // Arrange
  94. const inputPath = join(FIXTURES_DIR, "sample-claude-config.json");
  95. // Act
  96. const result = await executeConvert(
  97. inputPath,
  98. { format: "oac", from: "claude" },
  99. defaultGlobalOptions
  100. );
  101. // Assert
  102. expect(result.success).toBe(true);
  103. expect(result.data).toBeDefined();
  104. expect(result.data?.configs).toBeDefined();
  105. expect(result.data?.configs[0].content).toContain("---");
  106. expect(result.data?.configs[0].content).toContain("sample-claude-agent");
  107. });
  108. it("auto-detects OAC input format", async () => {
  109. // Arrange
  110. const inputPath = join(FIXTURES_DIR, "sample-oac-agent.md");
  111. // Act - no --from specified
  112. const result = await executeConvert(
  113. inputPath,
  114. { format: "cursor" },
  115. defaultGlobalOptions
  116. );
  117. // Assert
  118. expect(result.success).toBe(true);
  119. expect(result.data?.configs[0].fileName).toBe(".cursorrules");
  120. });
  121. it("auto-detects Cursor input format from .cursorrules filename", async () => {
  122. // Arrange
  123. const inputPath = join(FIXTURES_DIR, "sample-cursorrules");
  124. // Act
  125. const result = await executeConvert(
  126. inputPath,
  127. { format: "oac" },
  128. defaultGlobalOptions
  129. );
  130. // Assert
  131. expect(result.success).toBe(true);
  132. });
  133. it("preserves agent name through conversion", async () => {
  134. // Arrange
  135. const inputPath = join(FIXTURES_DIR, "sample-oac-agent.md");
  136. // Act
  137. const result = await executeConvert(
  138. inputPath,
  139. { format: "cursor" },
  140. defaultGlobalOptions
  141. );
  142. // Assert
  143. expect(result.success).toBe(true);
  144. expect(result.data?.configs[0].content).toContain("sample-oac-agent");
  145. });
  146. it("preserves system prompt content through conversion", async () => {
  147. // Arrange
  148. const inputPath = join(FIXTURES_DIR, "sample-oac-agent.md");
  149. // Act
  150. const result = await executeConvert(
  151. inputPath,
  152. { format: "cursor" },
  153. defaultGlobalOptions
  154. );
  155. // Assert
  156. expect(result.success).toBe(true);
  157. expect(result.data?.configs[0].content).toContain("helpful assistant");
  158. });
  159. });
  160. // ============================================================================
  161. // ERROR HANDLING
  162. // ============================================================================
  163. describe("error handling", () => {
  164. it("returns error for non-existent file", async () => {
  165. // Arrange
  166. const inputPath = join(FIXTURES_DIR, "does-not-exist.md");
  167. // Act
  168. const result = await executeConvert(
  169. inputPath,
  170. { format: "cursor" },
  171. defaultGlobalOptions
  172. );
  173. // Assert
  174. expect(result.success).toBe(false);
  175. expect(result.error).toBeDefined();
  176. });
  177. it("returns error for same source/target format", async () => {
  178. // Arrange
  179. const inputPath = join(FIXTURES_DIR, "sample-oac-agent.md");
  180. // Act
  181. const result = await executeConvert(
  182. inputPath,
  183. { format: "oac", from: "oac" },
  184. defaultGlobalOptions
  185. );
  186. // Assert
  187. expect(result.success).toBe(false);
  188. expect(result.error).toContain("same");
  189. });
  190. it("handles conversion errors gracefully", async () => {
  191. // Arrange - create a file that will cause parsing issues
  192. const brokenFile = join(TEMP_DIR, "broken.md");
  193. await writeFile(brokenFile, "---\ninvalid: yaml: content:\n---\nContent");
  194. // Act
  195. const result = await executeConvert(
  196. brokenFile,
  197. { format: "cursor" },
  198. defaultGlobalOptions
  199. );
  200. // Assert - should either succeed with degraded content or fail gracefully
  201. // The adapter should handle malformed YAML
  202. expect(typeof result.success).toBe("boolean");
  203. });
  204. });
  205. // ============================================================================
  206. // OUTPUT OPTIONS
  207. // ============================================================================
  208. describe("output options", () => {
  209. it("writes to specified output path", async () => {
  210. // Arrange
  211. const inputPath = join(FIXTURES_DIR, "sample-oac-agent.md");
  212. const outputPath = join(TEMP_DIR, "output.cursorrules");
  213. // Act
  214. const result = await executeConvert(
  215. inputPath,
  216. { format: "cursor", output: outputPath },
  217. defaultGlobalOptions
  218. );
  219. // Assert
  220. expect(result.success).toBe(true);
  221. // Verify file was written
  222. const content = await readFile(outputPath, "utf-8");
  223. expect(content).toContain("sample-oac-agent");
  224. });
  225. it("outputs to stdout when no output specified", async () => {
  226. // Arrange
  227. const inputPath = join(FIXTURES_DIR, "sample-oac-agent.md");
  228. const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
  229. // Act
  230. const result = await executeConvert(
  231. inputPath,
  232. { format: "cursor" },
  233. defaultGlobalOptions
  234. );
  235. // Assert
  236. expect(result.success).toBe(true);
  237. // Console.log should have been called with the content
  238. expect(consoleSpy).toHaveBeenCalled();
  239. // Cleanup
  240. consoleSpy.mockRestore();
  241. });
  242. it("respects --force flag to overwrite existing file", async () => {
  243. // Arrange
  244. const inputPath = join(FIXTURES_DIR, "sample-oac-agent.md");
  245. const outputPath = join(TEMP_DIR, "existing.cursorrules");
  246. // Create existing file
  247. await writeFile(outputPath, "existing content");
  248. // Act - with force flag
  249. const result = await executeConvert(
  250. inputPath,
  251. { format: "cursor", output: outputPath, force: true },
  252. defaultGlobalOptions
  253. );
  254. // Assert
  255. expect(result.success).toBe(true);
  256. const content = await readFile(outputPath, "utf-8");
  257. expect(content).not.toBe("existing content");
  258. expect(content).toContain("sample-oac-agent");
  259. });
  260. it("returns error when output file exists and --force not set", async () => {
  261. // Arrange
  262. const inputPath = join(FIXTURES_DIR, "sample-oac-agent.md");
  263. const outputPath = join(TEMP_DIR, "existing2.cursorrules");
  264. // Create existing file
  265. await writeFile(outputPath, "existing content");
  266. // Act - without force flag
  267. const result = await executeConvert(
  268. inputPath,
  269. { format: "cursor", output: outputPath, force: false },
  270. defaultGlobalOptions
  271. );
  272. // Assert
  273. expect(result.success).toBe(false);
  274. expect(result.error).toContain("exists");
  275. });
  276. it("creates output directory if it does not exist", async () => {
  277. // Arrange
  278. const inputPath = join(FIXTURES_DIR, "sample-oac-agent.md");
  279. const outputPath = join(TEMP_DIR, "nested", "deep", "output.cursorrules");
  280. // Act
  281. const result = await executeConvert(
  282. inputPath,
  283. { format: "cursor", output: outputPath },
  284. defaultGlobalOptions
  285. );
  286. // Assert
  287. expect(result.success).toBe(true);
  288. const content = await readFile(outputPath, "utf-8");
  289. expect(content).toContain("sample-oac-agent");
  290. });
  291. });
  292. // ============================================================================
  293. // FORMAT DETECTION
  294. // ============================================================================
  295. describe("format detection", () => {
  296. it("detects OAC format from frontmatter", async () => {
  297. // Arrange
  298. const inputPath = join(FIXTURES_DIR, "sample-oac-agent.md");
  299. // Act
  300. const result = await executeConvert(
  301. inputPath,
  302. { format: "cursor" },
  303. defaultGlobalOptions
  304. );
  305. // Assert
  306. expect(result.success).toBe(true);
  307. });
  308. it("detects Claude format from JSON structure", async () => {
  309. // Arrange
  310. const inputPath = join(FIXTURES_DIR, "sample-claude-config.json");
  311. // Act
  312. const result = await executeConvert(
  313. inputPath,
  314. { format: "oac" },
  315. { ...defaultGlobalOptions, verbose: true }
  316. );
  317. // Assert
  318. expect(result.success).toBe(true);
  319. });
  320. it("uses specified --from format over auto-detection", async () => {
  321. // Arrange
  322. const inputPath = join(FIXTURES_DIR, "sample-cursorrules");
  323. // Act - explicitly specify cursor format
  324. const result = await executeConvert(
  325. inputPath,
  326. { format: "oac", from: "cursor" },
  327. defaultGlobalOptions
  328. );
  329. // Assert
  330. expect(result.success).toBe(true);
  331. });
  332. });
  333. // ============================================================================
  334. // ROUNDTRIP CONVERSION
  335. // ============================================================================
  336. describe("roundtrip conversion", () => {
  337. it("roundtrip OAC -> Claude -> OAC preserves core properties", async () => {
  338. // Arrange
  339. const inputPath = join(FIXTURES_DIR, "sample-oac-agent.md");
  340. const claudeOutputPath = join(TEMP_DIR, "claude-config.json");
  341. const oacOutputPath = join(TEMP_DIR, "roundtrip-agent.md");
  342. // Act - Step 1: Convert OAC to Claude
  343. const toClaude = await executeConvert(
  344. inputPath,
  345. { format: "claude", output: claudeOutputPath },
  346. defaultGlobalOptions
  347. );
  348. expect(toClaude.success).toBe(true);
  349. // Act - Step 2: Convert Claude back to OAC
  350. const toOAC = await executeConvert(
  351. claudeOutputPath,
  352. { format: "oac", from: "claude", output: oacOutputPath },
  353. defaultGlobalOptions
  354. );
  355. // Assert
  356. expect(toOAC.success).toBe(true);
  357. const originalContent = await readFile(inputPath, "utf-8");
  358. const roundtripContent = await readFile(oacOutputPath, "utf-8");
  359. // Core properties should be preserved
  360. expect(roundtripContent).toContain("sample-oac-agent");
  361. expect(roundtripContent).toContain("helpful assistant");
  362. });
  363. it("roundtrip OAC -> Cursor -> OAC preserves core properties", async () => {
  364. // Arrange
  365. const inputPath = join(FIXTURES_DIR, "sample-oac-agent.md");
  366. const cursorOutputPath = join(TEMP_DIR, ".cursorrules");
  367. const oacOutputPath = join(TEMP_DIR, "roundtrip-cursor.md");
  368. // Act - Step 1: Convert OAC to Cursor
  369. const toCursor = await executeConvert(
  370. inputPath,
  371. { format: "cursor", output: cursorOutputPath },
  372. defaultGlobalOptions
  373. );
  374. expect(toCursor.success).toBe(true);
  375. // Act - Step 2: Convert Cursor back to OAC
  376. const toOAC = await executeConvert(
  377. cursorOutputPath,
  378. { format: "oac", from: "cursor", output: oacOutputPath },
  379. defaultGlobalOptions
  380. );
  381. // Assert
  382. expect(toOAC.success).toBe(true);
  383. const roundtripContent = await readFile(oacOutputPath, "utf-8");
  384. expect(roundtripContent).toContain("sample-oac-agent");
  385. });
  386. });
  387. // ============================================================================
  388. // WARNINGS HANDLING
  389. // ============================================================================
  390. describe("warnings handling", () => {
  391. it("includes warnings in result when features are lost", async () => {
  392. // Arrange - OAC with advanced features that Cursor doesn't support
  393. const inputPath = join(FIXTURES_DIR, "sample-oac-agent.md");
  394. // Act
  395. const result = await executeConvert(
  396. inputPath,
  397. { format: "cursor" },
  398. defaultGlobalOptions
  399. );
  400. // Assert
  401. expect(result.success).toBe(true);
  402. // Warnings may or may not be present depending on feature parity
  403. expect(Array.isArray(result.warnings) || result.warnings === undefined).toBe(true);
  404. });
  405. });
  406. // ============================================================================
  407. // VERBOSE OUTPUT
  408. // ============================================================================
  409. describe("verbose output", () => {
  410. it("respects verbose flag", async () => {
  411. // Arrange
  412. const inputPath = join(FIXTURES_DIR, "sample-oac-agent.md");
  413. // Act
  414. const result = await executeConvert(
  415. inputPath,
  416. { format: "cursor" },
  417. { ...defaultGlobalOptions, verbose: true }
  418. );
  419. // Assert
  420. expect(result.success).toBe(true);
  421. });
  422. });
  423. });