bundled.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
  2. import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
  3. import { join } from 'node:path';
  4. import { tmpdir } from 'node:os';
  5. import {
  6. classifyBundledFile,
  7. findPackageRoot,
  8. getBundledFilePath,
  9. listBundledFiles,
  10. bundledFileExists,
  11. type BundledFileType,
  12. } from './bundled.js';
  13. // ── classifyBundledFile ───────────────────────────────────────────────────────
  14. // Pure function — no I/O, no setup needed.
  15. describe('classifyBundledFile', () => {
  16. // ✅ Positive: agent prefix
  17. test('returns "agent" for .opencode/agent/ paths', () => {
  18. // Arrange
  19. const path = '.opencode/agent/core/openagent.md';
  20. // Act
  21. const result: BundledFileType = classifyBundledFile(path);
  22. // Assert
  23. expect(result).toBe('agent');
  24. });
  25. // ✅ Positive: agent prefix — nested deeply
  26. test('returns "agent" for deeply nested agent paths', () => {
  27. expect(classifyBundledFile('.opencode/agent/sub/dir/file.md')).toBe('agent');
  28. });
  29. // ✅ Positive: context prefix
  30. test('returns "context" for .opencode/context/ paths', () => {
  31. expect(classifyBundledFile('.opencode/context/standards.md')).toBe('context');
  32. });
  33. // ✅ Positive: context prefix — nested
  34. test('returns "context" for nested context paths', () => {
  35. expect(classifyBundledFile('.opencode/context/sub/file.md')).toBe('context');
  36. });
  37. // ✅ Positive: skill prefix
  38. test('returns "skill" for .opencode/skills/ paths', () => {
  39. expect(classifyBundledFile('.opencode/skills/my-skill.md')).toBe('skill');
  40. });
  41. // ✅ Positive: skill prefix — nested
  42. test('returns "skill" for nested skills paths', () => {
  43. expect(classifyBundledFile('.opencode/skills/category/skill.md')).toBe('skill');
  44. });
  45. // ✅ Positive: config fallback — arbitrary path
  46. test('returns "config" for unrecognised paths', () => {
  47. expect(classifyBundledFile('some/other/file.json')).toBe('config');
  48. });
  49. // ✅ Positive: config fallback — root-level file
  50. test('returns "config" for a root-level file', () => {
  51. expect(classifyBundledFile('README.md')).toBe('config');
  52. });
  53. // ❌ Negative: path that starts with .opencode/ but not a known subdir
  54. test('returns "config" for .opencode/ paths with unknown subdir', () => {
  55. expect(classifyBundledFile('.opencode/unknown/file.md')).toBe('config');
  56. });
  57. // ❌ Negative: partial prefix match should NOT classify as agent
  58. test('returns "config" for path that only partially matches agent prefix', () => {
  59. // ".opencode/agentX/" is NOT ".opencode/agent/"
  60. expect(classifyBundledFile('.opencode/agentX/file.md')).toBe('config');
  61. });
  62. // ❌ Negative: partial prefix match should NOT classify as context
  63. test('returns "config" for path that only partially matches context prefix', () => {
  64. expect(classifyBundledFile('.opencode/contexts/file.md')).toBe('config');
  65. });
  66. // ❌ Negative: empty string
  67. test('returns "config" for an empty string', () => {
  68. expect(classifyBundledFile('')).toBe('config');
  69. });
  70. });
  71. // ── getBundledFilePath ────────────────────────────────────────────────────────
  72. // Pure function — no I/O.
  73. describe('getBundledFilePath', () => {
  74. // ✅ Positive: joins packageRoot and relativePath
  75. test('joins packageRoot and relativePath correctly', () => {
  76. // Arrange
  77. const packageRoot = '/usr/local/lib/oac';
  78. const relativePath = '.opencode/agent/core/openagent.md';
  79. // Act
  80. const result = getBundledFilePath(packageRoot, relativePath);
  81. // Assert
  82. expect(result).toBe('/usr/local/lib/oac/.opencode/agent/core/openagent.md');
  83. });
  84. // ✅ Positive: works with nested relative paths
  85. test('handles nested relative paths', () => {
  86. const result = getBundledFilePath('/root', '.opencode/skills/sub/skill.md');
  87. expect(result).toBe('/root/.opencode/skills/sub/skill.md');
  88. });
  89. // ❌ Negative: empty relative path returns just the packageRoot
  90. test('returns packageRoot when relativePath is empty', () => {
  91. const result = getBundledFilePath('/root', '');
  92. expect(result).toBe('/root');
  93. });
  94. });
  95. // ── findPackageRoot ───────────────────────────────────────────────────────────
  96. describe('findPackageRoot', () => {
  97. let tmpDir: string;
  98. beforeAll(async () => {
  99. tmpDir = await mkdtemp(join(tmpdir(), 'oac-bundled-test-'));
  100. });
  101. afterAll(async () => {
  102. await rm(tmpDir, { recursive: true, force: true });
  103. });
  104. // ✅ Positive: finds a directory that has both .opencode/ and package.json
  105. test('returns the directory that has both .opencode/ and package.json', async () => {
  106. // Arrange — create a fake package root
  107. const fakeRoot = join(tmpDir, 'fake-pkg');
  108. await mkdir(join(fakeRoot, '.opencode'), { recursive: true });
  109. await writeFile(join(fakeRoot, 'package.json'), '{}', 'utf8');
  110. // Also create a subdirectory to start the walk from
  111. const startDir = join(fakeRoot, 'dist', 'lib');
  112. await mkdir(startDir, { recursive: true });
  113. // Act
  114. const result = findPackageRoot(startDir);
  115. // Assert
  116. expect(result).toBe(fakeRoot);
  117. });
  118. // ✅ Positive: finds root when starting exactly at the package root
  119. test('returns the start directory itself when it is the package root', async () => {
  120. // Arrange
  121. const fakeRoot = join(tmpDir, 'exact-root');
  122. await mkdir(join(fakeRoot, '.opencode'), { recursive: true });
  123. await writeFile(join(fakeRoot, 'package.json'), '{}', 'utf8');
  124. // Act
  125. const result = findPackageRoot(fakeRoot);
  126. // Assert
  127. expect(result).toBe(fakeRoot);
  128. });
  129. // ❌ Negative: throws when no package root is found (isolated tmp dir with no markers)
  130. test('throws an error when no package root is found walking to filesystem root', async () => {
  131. // Arrange — a directory with neither .opencode/ nor package.json
  132. const isolated = join(tmpDir, 'isolated-no-markers');
  133. await mkdir(isolated, { recursive: true });
  134. // Act & Assert — we cannot actually walk to the real filesystem root in a
  135. // test (it would find the monorepo's package.json), so we test the error
  136. // message shape by checking that a directory missing .opencode throws when
  137. // the walk terminates. We use a path that IS the filesystem root equivalent
  138. // by mocking: instead, we verify the thrown error message format by calling
  139. // with a path that has package.json but no .opencode, and one that has
  140. // .opencode but no package.json — neither should match, but the walk will
  141. // eventually reach the real monorepo root. So we test the error path by
  142. // verifying the function throws when given a path that cannot possibly
  143. // resolve (we use the OS tmpdir itself, which has no .opencode).
  144. //
  145. // The safest approach: create a temp dir tree that is self-contained and
  146. // has no .opencode anywhere. We can't prevent the walk from going above
  147. // tmpdir, so we test the error message by checking it contains the
  148. // expected substring when we know it will throw.
  149. //
  150. // NOTE: In CI / a clean environment this will throw because there is no
  151. // .opencode above the tmpdir. In a monorepo dev environment the walk may
  152. // find the repo root. We therefore test the error *shape* by directly
  153. // calling with a path that we know will fail: the filesystem root '/'.
  154. expect(() => findPackageRoot('/')).toThrow(
  155. 'getPackageRoot: could not find a directory with ".opencode/" and "package.json"',
  156. );
  157. });
  158. // ❌ Negative: error message includes the start directory
  159. test('error message includes the starting directory', () => {
  160. // Arrange & Act & Assert
  161. let thrownMessage = '';
  162. try {
  163. findPackageRoot('/');
  164. } catch (err) {
  165. thrownMessage = err instanceof Error ? err.message : String(err);
  166. }
  167. expect(thrownMessage).toContain('"/"');
  168. });
  169. // ❌ Negative: directory with only package.json (no .opencode) does not match
  170. test('does not match a directory that has package.json but no .opencode', async () => {
  171. // Arrange — a directory with only package.json, no .opencode
  172. const noOpencode = join(tmpDir, 'no-opencode');
  173. await mkdir(noOpencode, { recursive: true });
  174. await writeFile(join(noOpencode, 'package.json'), '{}', 'utf8');
  175. // Start from a child — the walk will pass through noOpencode (no match)
  176. // and continue upward until it finds the monorepo root or throws.
  177. // We just verify it does NOT return noOpencode.
  178. let result: string | undefined;
  179. try {
  180. result = findPackageRoot(noOpencode);
  181. } catch {
  182. result = undefined;
  183. }
  184. // If it found something, it must NOT be noOpencode (which lacks .opencode)
  185. if (result !== undefined) {
  186. expect(result).not.toBe(noOpencode);
  187. }
  188. // Either it threw (correct) or found a higher-level root (also acceptable)
  189. expect(true).toBe(true); // test passes either way — the key is it didn't return noOpencode
  190. });
  191. });
  192. // ── listBundledFiles ──────────────────────────────────────────────────────────
  193. describe('listBundledFiles', () => {
  194. let packageRoot: string;
  195. beforeAll(async () => {
  196. packageRoot = await mkdtemp(join(tmpdir(), 'oac-list-bundled-'));
  197. // Create a fake package structure with files in all three subdirs
  198. await mkdir(join(packageRoot, '.opencode', 'agent', 'core'), { recursive: true });
  199. await mkdir(join(packageRoot, '.opencode', 'context'), { recursive: true });
  200. await mkdir(join(packageRoot, '.opencode', 'skills', 'sub'), { recursive: true });
  201. await writeFile(join(packageRoot, '.opencode', 'agent', 'core', 'openagent.md'), '# Agent', 'utf8');
  202. await writeFile(join(packageRoot, '.opencode', 'agent', 'helper.md'), '# Helper', 'utf8');
  203. await writeFile(join(packageRoot, '.opencode', 'context', 'standards.md'), '# Standards', 'utf8');
  204. await writeFile(join(packageRoot, '.opencode', 'skills', 'sub', 'skill.md'), '# Skill', 'utf8');
  205. });
  206. afterAll(async () => {
  207. await rm(packageRoot, { recursive: true, force: true });
  208. });
  209. // ✅ Positive: returns relative paths for all files in all three subdirs
  210. test('returns relative paths for all bundled files', async () => {
  211. // Act
  212. const files = await listBundledFiles(packageRoot);
  213. // Assert — all four files should be present
  214. expect(files).toContain('.opencode/agent/core/openagent.md');
  215. expect(files).toContain('.opencode/agent/helper.md');
  216. expect(files).toContain('.opencode/context/standards.md');
  217. expect(files).toContain('.opencode/skills/sub/skill.md');
  218. expect(files).toHaveLength(4);
  219. });
  220. // ✅ Positive: paths are relative (not absolute)
  221. test('returns relative paths, not absolute paths', async () => {
  222. const files = await listBundledFiles(packageRoot);
  223. for (const f of files) {
  224. expect(f.startsWith('/')).toBe(false);
  225. }
  226. });
  227. // ✅ Positive: paths start with .opencode/
  228. test('all returned paths start with .opencode/', async () => {
  229. const files = await listBundledFiles(packageRoot);
  230. for (const f of files) {
  231. expect(f.startsWith('.opencode/')).toBe(true);
  232. }
  233. });
  234. // ❌ Negative: missing subdirectories are silently skipped
  235. test('silently skips subdirectories that do not exist', async () => {
  236. // Arrange — a package root with only the agent subdir
  237. const sparseRoot = await mkdtemp(join(tmpdir(), 'oac-sparse-pkg-'));
  238. try {
  239. await mkdir(join(sparseRoot, '.opencode', 'agent'), { recursive: true });
  240. await writeFile(join(sparseRoot, '.opencode', 'agent', 'only.md'), '# Only', 'utf8');
  241. // Act — context/ and skills/ don't exist
  242. const files = await listBundledFiles(sparseRoot);
  243. // Assert — only the agent file, no errors
  244. expect(files).toHaveLength(1);
  245. expect(files[0]).toBe('.opencode/agent/only.md');
  246. } finally {
  247. await rm(sparseRoot, { recursive: true, force: true });
  248. }
  249. });
  250. // ❌ Negative: empty package root returns empty array
  251. test('returns empty array when no bundled subdirs exist', async () => {
  252. // Arrange — completely empty package root
  253. const emptyRoot = await mkdtemp(join(tmpdir(), 'oac-empty-pkg-'));
  254. try {
  255. const files = await listBundledFiles(emptyRoot);
  256. expect(files).toHaveLength(0);
  257. } finally {
  258. await rm(emptyRoot, { recursive: true, force: true });
  259. }
  260. });
  261. });
  262. // ── bundledFileExists ─────────────────────────────────────────────────────────
  263. describe('bundledFileExists', () => {
  264. let packageRoot: string;
  265. beforeAll(async () => {
  266. packageRoot = await mkdtemp(join(tmpdir(), 'oac-exists-test-'));
  267. await mkdir(join(packageRoot, '.opencode', 'agent'), { recursive: true });
  268. await writeFile(join(packageRoot, '.opencode', 'agent', 'present.md'), '# Present', 'utf8');
  269. });
  270. afterAll(async () => {
  271. await rm(packageRoot, { recursive: true, force: true });
  272. });
  273. // ✅ Positive: returns true for a file that exists
  274. test('returns true when the bundled file exists', async () => {
  275. const exists = await bundledFileExists(packageRoot, '.opencode/agent/present.md');
  276. expect(exists).toBe(true);
  277. });
  278. // ❌ Negative: returns false for a file that does not exist
  279. test('returns false when the bundled file does not exist', async () => {
  280. const exists = await bundledFileExists(packageRoot, '.opencode/agent/missing.md');
  281. expect(exists).toBe(false);
  282. });
  283. });