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 });