/ src / engine.test.ts
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  });