engine.test.ts
1 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 2 import { discoverClis, discoverPlugins, ensureUserCliCompatShims, ensureUserAdapters, PLUGINS_DIR } from './discovery.js'; 3 import { executeCommand } from './execution.js'; 4 import { getRegistry, cli, Strategy } from './registry.js'; 5 import { clearAllHooks, onAfterExecute } from './hooks.js'; 6 import * as fs from 'node:fs'; 7 import * as os from 'node:os'; 8 import * as path from 'node:path'; 9 import { pathToFileURL } from 'node:url'; 10 11 describe('discoverClis', () => { 12 it('handles non-existent directories gracefully', async () => { 13 // Should not throw for missing directories 14 await expect(discoverClis(path.join(os.tmpdir(), 'nonexistent-opencli-test-dir'))).resolves.not.toThrow(); 15 }); 16 17 it('imports only CLI command modules during filesystem discovery', async () => { 18 const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-discovery-')); 19 const siteDir = path.join(tempRoot, 'temp-site'); 20 const helperPath = path.join(siteDir, 'helper.js'); 21 const commandPath = path.join(siteDir, 'hello.js'); 22 23 try { 24 await fs.promises.mkdir(siteDir, { recursive: true }); 25 await fs.promises.writeFile(helperPath, ` 26 globalThis.__opencli_helper_loaded__ = true; 27 export const helper = true; 28 `); 29 await fs.promises.writeFile(commandPath, ` 30 import { cli, Strategy } from '${pathToFileURL(path.join(process.cwd(), 'src', 'registry.ts')).href}'; 31 cli({ 32 site: 'temp-site', 33 name: 'hello', 34 description: 'hello command', 35 strategy: Strategy.PUBLIC, 36 browser: false, 37 func: async () => [{ ok: true }], 38 }); 39 `); 40 41 delete (globalThis as { __opencli_helper_loaded__?: unknown }).__opencli_helper_loaded__; 42 await discoverClis(tempRoot); 43 44 expect((globalThis as { __opencli_helper_loaded__?: unknown }).__opencli_helper_loaded__).toBeUndefined(); 45 expect(getRegistry().get('temp-site/hello')).toBeDefined(); 46 } finally { 47 delete (globalThis as { __opencli_helper_loaded__?: unknown }).__opencli_helper_loaded__; 48 await fs.promises.rm(tempRoot, { recursive: true, force: true }); 49 } 50 }); 51 52 it('falls back to filesystem discovery when the manifest is invalid', async () => { 53 const tempBuildRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-manifest-fallback-')); 54 const distDir = path.join(tempBuildRoot, 'dist'); 55 const siteDir = path.join(distDir, 'fallback-site'); 56 const commandPath = path.join(siteDir, 'hello.js'); 57 const manifestPath = path.join(tempBuildRoot, 'cli-manifest.json'); 58 59 try { 60 await fs.promises.mkdir(siteDir, { recursive: true }); 61 await fs.promises.writeFile(manifestPath, '{ invalid json'); 62 await fs.promises.writeFile(commandPath, ` 63 import { cli, Strategy } from '${pathToFileURL(path.join(process.cwd(), 'src', 'registry.ts')).href}'; 64 cli({ 65 site: 'fallback-site', 66 name: 'hello', 67 description: 'hello command', 68 strategy: Strategy.PUBLIC, 69 browser: false, 70 func: async () => [{ ok: true }], 71 }); 72 `); 73 74 await discoverClis(distDir); 75 76 expect(getRegistry().get('fallback-site/hello')).toBeDefined(); 77 } finally { 78 await fs.promises.rm(tempBuildRoot, { recursive: true, force: true }); 79 } 80 }); 81 82 it('loads user CLI modules via package exports symlink', async () => { 83 const tempOpencliRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-user-clis-')); 84 const userClisDir = path.join(tempOpencliRoot, 'clis'); 85 const siteDir = path.join(userClisDir, 'legacy-site'); 86 const commandPath = path.join(siteDir, 'hello.js'); 87 88 try { 89 await ensureUserCliCompatShims(tempOpencliRoot); 90 await fs.promises.mkdir(siteDir, { recursive: true }); 91 await fs.promises.writeFile(commandPath, ` 92 import { cli, Strategy } from '@jackwener/opencli/registry'; 93 import { CommandExecutionError } from '@jackwener/opencli/errors'; 94 import { htmlToMarkdown } from '@jackwener/opencli/utils'; 95 96 cli({ 97 site: 'legacy-site', 98 name: 'hello', 99 description: 'hello command', 100 strategy: Strategy.PUBLIC, 101 browser: false, 102 func: async () => [{ ok: true, errorName: new CommandExecutionError('boom').name, markdown: htmlToMarkdown('<p>hello</p>') }], 103 }); 104 `); 105 106 await discoverClis(userClisDir); 107 108 const cmd = getRegistry().get('legacy-site/hello'); 109 expect(cmd).toBeDefined(); 110 await expect(executeCommand(cmd!, {})).resolves.toEqual([{ ok: true, errorName: 'CommandExecutionError', markdown: 'hello' }]); 111 } finally { 112 await fs.promises.rm(tempOpencliRoot, { recursive: true, force: true }); 113 } 114 }); 115 }); 116 117 describe('ensureUserAdapters', () => { 118 it('creates user clis directory without triggering full copy', async () => { 119 const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-ensure-')); 120 const clisDir = path.join(tempDir, 'clis'); 121 try { 122 // Patch USER_CLIS_DIR is not easy, so we test the function behavior indirectly: 123 // ensureUserAdapters should not throw and should be very fast (no fetch script) 124 const start = Date.now(); 125 await ensureUserAdapters(); 126 const elapsed = Date.now() - start; 127 // Should complete quickly (< 1s) since it only creates a directory 128 expect(elapsed).toBeLessThan(1000); 129 } finally { 130 await fs.promises.rm(tempDir, { recursive: true, force: true }); 131 } 132 }); 133 134 it('discoverClis handles empty user directory gracefully', async () => { 135 const emptyDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-empty-')); 136 try { 137 // Should not throw for an empty directory (no adapters to discover) 138 await expect(discoverClis(emptyDir)).resolves.not.toThrow(); 139 } finally { 140 await fs.promises.rm(emptyDir, { recursive: true, force: true }); 141 } 142 }); 143 }); 144 145 describe('discoverPlugins', () => { 146 const testPluginDir = path.join(PLUGINS_DIR, '__test-plugin__'); 147 const yamlPath = path.join(testPluginDir, 'greeting.yaml'); 148 const symlinkTargetDir = path.join(os.tmpdir(), '__test-plugin-symlink-target__'); 149 const symlinkPluginDir = path.join(PLUGINS_DIR, '__test-plugin-symlink__'); 150 const brokenSymlinkDir = path.join(PLUGINS_DIR, '__test-plugin-broken__'); 151 152 afterEach(async () => { 153 try { await fs.promises.rm(testPluginDir, { recursive: true }); } catch {} 154 try { await fs.promises.rm(symlinkPluginDir, { recursive: true, force: true }); } catch {} 155 try { await fs.promises.rm(symlinkTargetDir, { recursive: true, force: true }); } catch {} 156 try { await fs.promises.rm(brokenSymlinkDir, { recursive: true, force: true }); } catch {} 157 }); 158 159 it('ignores YAML files in plugin directories (YAML format removed)', async () => { 160 await fs.promises.mkdir(testPluginDir, { recursive: true }); 161 await fs.promises.writeFile(yamlPath, ` 162 site: __test-plugin__ 163 name: greeting 164 description: Test plugin greeting 165 strategy: public 166 browser: false 167 `); 168 169 await discoverPlugins(); 170 171 const registry = getRegistry(); 172 const cmd = registry.get('__test-plugin__/greeting'); 173 expect(cmd).toBeUndefined(); 174 }); 175 176 it('handles non-existent plugins directory gracefully', async () => { 177 // discoverPlugins should not throw if ~/.opencli/plugins/ does not exist 178 await expect(discoverPlugins()).resolves.not.toThrow(); 179 }); 180 181 it('ignores YAML files in symlinked plugin directories (YAML format removed)', async () => { 182 await fs.promises.mkdir(PLUGINS_DIR, { recursive: true }); 183 await fs.promises.mkdir(symlinkTargetDir, { recursive: true }); 184 await fs.promises.writeFile(path.join(symlinkTargetDir, 'hello.yaml'), ` 185 site: __test-plugin-symlink__ 186 name: hello 187 description: Test plugin greeting via symlink 188 strategy: public 189 browser: false 190 `); 191 await fs.promises.symlink(symlinkTargetDir, symlinkPluginDir, 'dir'); 192 193 await discoverPlugins(); 194 195 const cmd = getRegistry().get('__test-plugin-symlink__/hello'); 196 expect(cmd).toBeUndefined(); 197 }); 198 199 it('skips broken plugin symlinks without throwing', async () => { 200 await fs.promises.mkdir(PLUGINS_DIR, { recursive: true }); 201 await fs.promises.symlink(path.join(os.tmpdir(), '__missing-plugin-target__'), brokenSymlinkDir, 'dir'); 202 203 await expect(discoverPlugins()).resolves.not.toThrow(); 204 expect(getRegistry().get('__test-plugin-broken__/hello')).toBeUndefined(); 205 }); 206 }); 207 208 describe('executeCommand', () => { 209 beforeEach(() => { 210 clearAllHooks(); 211 vi.unstubAllEnvs(); 212 }); 213 214 it('accepts kebab-case option names after Commander camelCases them', async () => { 215 const cmd = cli({ 216 site: 'test-engine', 217 name: 'kebab-arg-test', 218 description: 'test command with kebab-case arg', 219 browser: false, 220 strategy: Strategy.PUBLIC, 221 args: [ 222 { name: 'note-id', required: true, help: 'Note ID' }, 223 ], 224 func: async (_page, kwargs) => [{ noteId: kwargs['note-id'] }], 225 }); 226 227 const result = await executeCommand(cmd, { 'note-id': 'abc123' }); 228 expect(result).toEqual([{ noteId: 'abc123' }]); 229 }); 230 231 it('executes a command with func', async () => { 232 const cmd = cli({ 233 site: 'test-engine', 234 name: 'func-test', 235 description: 'test command with func', 236 browser: false, 237 strategy: Strategy.PUBLIC, 238 func: async (_page, kwargs) => { 239 return [{ title: kwargs.query ?? 'default' }]; 240 }, 241 }); 242 243 const result = await executeCommand(cmd, { query: 'hello' }); 244 expect(result).toEqual([{ title: 'hello' }]); 245 }); 246 247 it('executes a command with pipeline', async () => { 248 const cmd = cli({ 249 site: 'test-engine', 250 name: 'pipe-test', 251 description: 'test command with pipeline', 252 browser: false, 253 strategy: Strategy.PUBLIC, 254 pipeline: [ 255 { evaluate: '() => [{ n: 1 }, { n: 2 }, { n: 3 }]' }, 256 { limit: '2' }, 257 ], 258 }); 259 260 // Pipeline commands require page for evaluate step, so we'll test the error path 261 await expect(executeCommand(cmd, {})).rejects.toThrow(); 262 }); 263 264 it('throws for command with no func or pipeline', async () => { 265 const cmd = cli({ 266 site: 'test-engine', 267 name: 'empty-test', 268 description: 'empty command', 269 browser: false, 270 }); 271 272 await expect(executeCommand(cmd, {})).rejects.toThrow('has no func or pipeline'); 273 }); 274 275 it('passes debug flag to func', async () => { 276 let receivedDebug = false; 277 const cmd = cli({ 278 site: 'test-engine', 279 name: 'debug-test', 280 description: 'debug test', 281 browser: false, 282 func: async (_page, _kwargs, debug) => { 283 receivedDebug = debug ?? false; 284 return []; 285 }, 286 }); 287 288 await executeCommand(cmd, {}, true); 289 expect(receivedDebug).toBe(true); 290 }); 291 292 it('fires onAfterExecute even when command execution throws', async () => { 293 const seen: Array<{ error?: unknown; finishedAt?: number }> = []; 294 onAfterExecute((ctx) => { 295 seen.push({ error: ctx.error, finishedAt: ctx.finishedAt }); 296 }); 297 298 const cmd = cli({ 299 site: 'test-engine', 300 name: 'failing-test', 301 description: 'failing command', 302 browser: false, 303 strategy: Strategy.PUBLIC, 304 func: async () => { 305 throw new Error('boom'); 306 }, 307 }); 308 309 await expect(executeCommand(cmd, {})).rejects.toThrow('boom'); 310 expect(seen).toHaveLength(1); 311 expect(seen[0].error).toBeInstanceOf(Error); 312 expect((seen[0].error as Error).message).toBe('boom'); 313 expect(typeof seen[0].finishedAt).toBe('number'); 314 }); 315 316 it('uses launcher for registered Electron apps (chatwise)', async () => { 317 // Mock the launcher to return a fake endpoint (avoids real HTTP/process calls) 318 const launcher = await import('./launcher.js'); 319 const spy = vi.spyOn(launcher, 'resolveElectronEndpoint') 320 .mockResolvedValue('http://127.0.0.1:9228'); 321 322 const cmd = cli({ 323 site: 'chatwise', 324 name: 'status', 325 description: 'chatwise status', 326 browser: true, 327 strategy: Strategy.PUBLIC, 328 func: async () => [{ ok: true }], 329 }); 330 331 // CDPBridge.connect() will fail (no actual CDP server), but the launcher 332 // should have been called with 'chatwise'. 333 await expect(executeCommand(cmd, {})).rejects.toThrow(); 334 expect(spy).toHaveBeenCalledWith('chatwise'); 335 spy.mockRestore(); 336 }); 337 });