/ src / plugin-manifest.test.ts
plugin-manifest.test.ts
  1  /**
  2   * Tests for plugin manifest: reading, validating, and compatibility checks.
  3   */
  4  
  5  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  6  import * as fs from 'node:fs';
  7  import * as path from 'node:path';
  8  import * as os from 'node:os';
  9  import {
 10    _readPluginManifest as readPluginManifest,
 11    _isMonorepo as isMonorepo,
 12    _getEnabledPlugins as getEnabledPlugins,
 13    _parseVersion as parseVersion,
 14    _satisfiesRange as satisfiesRange,
 15    MANIFEST_FILENAME,
 16    type PluginManifest,
 17  } from './plugin-manifest.js';
 18  
 19  // ── readPluginManifest ──────────────────────────────────────────────────────
 20  
 21  describe('readPluginManifest', () => {
 22    let tmpDir: string;
 23  
 24    beforeEach(() => {
 25      tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-test-'));
 26    });
 27  
 28    afterEach(() => {
 29      fs.rmSync(tmpDir, { recursive: true, force: true });
 30    });
 31  
 32    it('returns null when no manifest file exists', () => {
 33      expect(readPluginManifest(tmpDir)).toBeNull();
 34    });
 35  
 36    it('returns null for malformed JSON', () => {
 37      fs.writeFileSync(path.join(tmpDir, MANIFEST_FILENAME), 'not json {{{');
 38      expect(readPluginManifest(tmpDir)).toBeNull();
 39    });
 40  
 41    it('returns null for non-object JSON (array)', () => {
 42      fs.writeFileSync(path.join(tmpDir, MANIFEST_FILENAME), '["a","b"]');
 43      expect(readPluginManifest(tmpDir)).toBeNull();
 44    });
 45  
 46    it('returns null for non-object JSON (string)', () => {
 47      fs.writeFileSync(path.join(tmpDir, MANIFEST_FILENAME), '"hello"');
 48      expect(readPluginManifest(tmpDir)).toBeNull();
 49    });
 50  
 51    it('reads a single-plugin manifest', () => {
 52      const manifest: PluginManifest = {
 53        name: 'polymarket',
 54        version: '1.2.0',
 55        opencli: '>=1.0.0',
 56        description: 'Prediction market analysis',
 57      };
 58      fs.writeFileSync(path.join(tmpDir, MANIFEST_FILENAME), JSON.stringify(manifest));
 59      const result = readPluginManifest(tmpDir);
 60      expect(result).toEqual(manifest);
 61    });
 62  
 63    it('reads a monorepo manifest', () => {
 64      const manifest: PluginManifest = {
 65        version: '1.0.0',
 66        opencli: '>=0.9.0',
 67        description: 'My plugin collection',
 68        plugins: {
 69          polymarket: {
 70            path: 'packages/polymarket',
 71            description: 'Prediction market',
 72            version: '1.2.0',
 73          },
 74          defi: {
 75            path: 'packages/defi',
 76            description: 'DeFi data',
 77            version: '0.8.0',
 78            disabled: true,
 79          },
 80        },
 81      };
 82      fs.writeFileSync(path.join(tmpDir, MANIFEST_FILENAME), JSON.stringify(manifest));
 83      const result = readPluginManifest(tmpDir);
 84      expect(result).toEqual(manifest);
 85      expect(result!.plugins!.polymarket.path).toBe('packages/polymarket');
 86      expect(result!.plugins!.defi.disabled).toBe(true);
 87    });
 88  });
 89  
 90  // ── isMonorepo ──────────────────────────────────────────────────────────────
 91  
 92  describe('isMonorepo', () => {
 93    it('returns false for single-plugin manifest', () => {
 94      expect(isMonorepo({ name: 'test', version: '1.0.0' })).toBe(false);
 95    });
 96  
 97    it('returns false for empty plugins object', () => {
 98      expect(isMonorepo({ plugins: {} })).toBe(false);
 99    });
100  
101    it('returns true for manifest with plugins', () => {
102      expect(
103        isMonorepo({
104          plugins: {
105            foo: { path: 'packages/foo' },
106          },
107        }),
108      ).toBe(true);
109    });
110  });
111  
112  // ── getEnabledPlugins ───────────────────────────────────────────────────────
113  
114  describe('getEnabledPlugins', () => {
115    it('returns empty array for no plugins', () => {
116      expect(getEnabledPlugins({ name: 'test' })).toEqual([]);
117    });
118  
119    it('filters out disabled plugins', () => {
120      const manifest: PluginManifest = {
121        plugins: {
122          foo: { path: 'packages/foo' },
123          bar: { path: 'packages/bar', disabled: true },
124          baz: { path: 'packages/baz' },
125        },
126      };
127      const result = getEnabledPlugins(manifest);
128      expect(result).toHaveLength(2);
129      expect(result.map((r) => r.name)).toEqual(['baz', 'foo']); // sorted
130    });
131  
132    it('returns all when none disabled', () => {
133      const manifest: PluginManifest = {
134        plugins: {
135          charlie: { path: 'packages/charlie' },
136          alpha: { path: 'packages/alpha' },
137        },
138      };
139      const result = getEnabledPlugins(manifest);
140      expect(result).toHaveLength(2);
141      expect(result[0].name).toBe('alpha');
142      expect(result[1].name).toBe('charlie');
143    });
144  });
145  
146  // ── parseVersion ────────────────────────────────────────────────────────────
147  
148  describe('parseVersion', () => {
149    it('parses standard versions', () => {
150      expect(parseVersion('1.2.3')).toEqual([1, 2, 3]);
151      expect(parseVersion('0.0.0')).toEqual([0, 0, 0]);
152      expect(parseVersion('10.20.30')).toEqual([10, 20, 30]);
153    });
154  
155    it('parses versions with prerelease suffix', () => {
156      expect(parseVersion('1.2.3-beta.1')).toEqual([1, 2, 3]);
157    });
158  
159    it('returns null for invalid versions', () => {
160      expect(parseVersion('abc')).toBeNull();
161      expect(parseVersion('')).toBeNull();
162      expect(parseVersion('1.2')).toBeNull();
163    });
164  });
165  
166  // ── satisfiesRange ──────────────────────────────────────────────────────────
167  
168  describe('satisfiesRange', () => {
169    it('handles >= constraint', () => {
170      expect(satisfiesRange('1.4.1', '>=1.0.0')).toBe(true);
171      expect(satisfiesRange('1.0.0', '>=1.0.0')).toBe(true);
172      expect(satisfiesRange('0.9.9', '>=1.0.0')).toBe(false);
173    });
174  
175    it('handles <= constraint', () => {
176      expect(satisfiesRange('1.0.0', '<=1.0.0')).toBe(true);
177      expect(satisfiesRange('0.9.0', '<=1.0.0')).toBe(true);
178      expect(satisfiesRange('1.0.1', '<=1.0.0')).toBe(false);
179    });
180  
181    it('handles > constraint', () => {
182      expect(satisfiesRange('1.0.1', '>1.0.0')).toBe(true);
183      expect(satisfiesRange('1.0.0', '>1.0.0')).toBe(false);
184    });
185  
186    it('handles < constraint', () => {
187      expect(satisfiesRange('0.9.9', '<1.0.0')).toBe(true);
188      expect(satisfiesRange('1.0.0', '<1.0.0')).toBe(false);
189    });
190  
191    it('handles ^ (caret) constraint', () => {
192      expect(satisfiesRange('1.2.0', '^1.2.0')).toBe(true);
193      expect(satisfiesRange('1.9.9', '^1.2.0')).toBe(true);
194      expect(satisfiesRange('2.0.0', '^1.2.0')).toBe(false);
195      expect(satisfiesRange('1.1.0', '^1.2.0')).toBe(false);
196    });
197  
198    it('handles ~ (tilde) constraint', () => {
199      expect(satisfiesRange('1.2.0', '~1.2.0')).toBe(true);
200      expect(satisfiesRange('1.2.9', '~1.2.0')).toBe(true);
201      expect(satisfiesRange('1.3.0', '~1.2.0')).toBe(false);
202    });
203  
204    it('handles exact match', () => {
205      expect(satisfiesRange('1.2.3', '1.2.3')).toBe(true);
206      expect(satisfiesRange('1.2.4', '1.2.3')).toBe(false);
207    });
208  
209    it('handles compound range (AND)', () => {
210      expect(satisfiesRange('1.5.0', '>=1.0.0 <2.0.0')).toBe(true);
211      expect(satisfiesRange('2.0.0', '>=1.0.0 <2.0.0')).toBe(false);
212      expect(satisfiesRange('0.9.0', '>=1.0.0 <2.0.0')).toBe(false);
213    });
214  
215    it('returns true for empty range', () => {
216      expect(satisfiesRange('1.0.0', '')).toBe(true);
217      expect(satisfiesRange('1.0.0', '  ')).toBe(true);
218    });
219  
220    it('returns true for unparseable version', () => {
221      expect(satisfiesRange('dev', '>=1.0.0')).toBe(true);
222    });
223  });