|
| 1 | +/** |
| 2 | + * Unit tests for ecosystem adapters and the IDiscoverableEcosystem interface. |
| 3 | + * Tests cover: isDiscoverable type guard, handles(), getCandidatePaths(), |
| 4 | + * getEditorRoot(), and discover() adapter loop behavior. |
| 5 | + */ |
| 6 | +import test from 'node:test'; |
| 7 | +import * as assert from 'node:assert/strict'; |
| 8 | +import * as path from 'node:path'; |
| 9 | +import * as os from 'node:os'; |
| 10 | + |
| 11 | +import { isDiscoverable } from '../../src/ecosystemAdapter'; |
| 12 | +import type { IEcosystemAdapter } from '../../src/ecosystemAdapter'; |
| 13 | + |
| 14 | +import { OpenCodeAdapter } from '../../src/adapters/openCodeAdapter'; |
| 15 | +import { CrushAdapter } from '../../src/adapters/crushAdapter'; |
| 16 | +import { ContinueAdapter } from '../../src/adapters/continueAdapter'; |
| 17 | +import { ClaudeCodeAdapter } from '../../src/adapters/claudeCodeAdapter'; |
| 18 | +import { ClaudeDesktopAdapter } from '../../src/adapters/claudeDesktopAdapter'; |
| 19 | +import { VisualStudioAdapter } from '../../src/adapters/visualStudioAdapter'; |
| 20 | +import { MistralVibeAdapter } from '../../src/adapters/mistralVibeAdapter'; |
| 21 | + |
| 22 | +import { OpenCodeDataAccess } from '../../src/opencode'; |
| 23 | +import { CrushDataAccess } from '../../src/crush'; |
| 24 | +import { ContinueDataAccess } from '../../src/continue'; |
| 25 | +import { ClaudeCodeDataAccess } from '../../src/claudecode'; |
| 26 | +import { ClaudeDesktopCoworkDataAccess } from '../../src/claudedesktop'; |
| 27 | +import { VisualStudioDataAccess } from '../../src/visualstudio'; |
| 28 | +import { MistralVibeDataAccess } from '../../src/mistralvibe'; |
| 29 | + |
| 30 | +// Stub functions for adapters requiring callbacks |
| 31 | +const noopEstimateTokens = (_text: string, _model?: string) => 0; |
| 32 | +const noopIsMcpTool = (_name: string) => false; |
| 33 | +const noopExtractMcpServerName = (_name: string) => ''; |
| 34 | + |
| 35 | +// Adapter instances |
| 36 | +const openCodeDA = new OpenCodeDataAccess(null as any); |
| 37 | +const crushDA = new CrushDataAccess(null as any); |
| 38 | +const continueDA = new ContinueDataAccess(); |
| 39 | +const claudeCodeDA = new ClaudeCodeDataAccess(); |
| 40 | +const claudeDesktopDA = new ClaudeDesktopCoworkDataAccess(); |
| 41 | +const visualStudioDA = new VisualStudioDataAccess(); |
| 42 | +const mistralVibeDA = new MistralVibeDataAccess(); |
| 43 | + |
| 44 | +const openCodeAdapter = new OpenCodeAdapter(openCodeDA); |
| 45 | +const crushAdapter = new CrushAdapter(crushDA); |
| 46 | +const continueAdapter = new ContinueAdapter(continueDA); |
| 47 | +const claudeCodeAdapter = new ClaudeCodeAdapter(claudeCodeDA); |
| 48 | +const claudeDesktopAdapter = new ClaudeDesktopAdapter(claudeDesktopDA, noopIsMcpTool, noopExtractMcpServerName, noopEstimateTokens); |
| 49 | +const visualStudioAdapter = new VisualStudioAdapter(visualStudioDA, noopEstimateTokens); |
| 50 | +const mistralVibeAdapter = new MistralVibeAdapter(mistralVibeDA); |
| 51 | + |
| 52 | +const allAdapters: IEcosystemAdapter[] = [ |
| 53 | + openCodeAdapter, crushAdapter, continueAdapter, |
| 54 | + claudeCodeAdapter, claudeDesktopAdapter, visualStudioAdapter, mistralVibeAdapter, |
| 55 | +]; |
| 56 | + |
| 57 | +// --------------------------------------------------------------------------- |
| 58 | +// isDiscoverable type guard |
| 59 | +// --------------------------------------------------------------------------- |
| 60 | + |
| 61 | +test('isDiscoverable: returns true for all 7 adapters', () => { |
| 62 | + for (const adapter of allAdapters) { |
| 63 | + assert.ok(isDiscoverable(adapter), `Expected ${adapter.id} to be discoverable`); |
| 64 | + } |
| 65 | +}); |
| 66 | + |
| 67 | +test('isDiscoverable: returns false for plain IEcosystemAdapter without discover()', () => { |
| 68 | + const plainAdapter: IEcosystemAdapter = { |
| 69 | + id: 'plain', displayName: 'Plain', |
| 70 | + handles: () => false, getBackingPath: (f) => f, stat: async (f) => { throw new Error(); }, |
| 71 | + getTokens: async () => ({ tokens: 0, thinkingTokens: 0, actualTokens: 0 }), |
| 72 | + countInteractions: async () => 0, getModelUsage: async () => ({}), |
| 73 | + getMeta: async () => ({ title: undefined, firstInteraction: null, lastInteraction: null }), |
| 74 | + getEditorRoot: () => '', |
| 75 | + }; |
| 76 | + assert.ok(!isDiscoverable(plainAdapter)); |
| 77 | +}); |
| 78 | + |
| 79 | +// --------------------------------------------------------------------------- |
| 80 | +// Adapter IDs and display names |
| 81 | +// --------------------------------------------------------------------------- |
| 82 | + |
| 83 | +test('adapter IDs are stable lowercase identifiers', () => { |
| 84 | + assert.equal(openCodeAdapter.id, 'opencode'); |
| 85 | + assert.equal(crushAdapter.id, 'crush'); |
| 86 | + assert.equal(continueAdapter.id, 'continue'); |
| 87 | + assert.equal(claudeCodeAdapter.id, 'claudecode'); |
| 88 | + assert.equal(claudeDesktopAdapter.id, 'claudedesktop'); |
| 89 | + assert.equal(visualStudioAdapter.id, 'visualstudio'); |
| 90 | + assert.equal(mistralVibeAdapter.id, 'mistralvibe'); |
| 91 | +}); |
| 92 | + |
| 93 | +// --------------------------------------------------------------------------- |
| 94 | +// handles() — path recognition |
| 95 | +// --------------------------------------------------------------------------- |
| 96 | + |
| 97 | +test('OpenCodeAdapter.handles: recognises JSON session paths', () => { |
| 98 | + const p = path.join(openCodeDA.getOpenCodeDataDir(), 'storage', 'session', 'ses_abc123.json'); |
| 99 | + assert.ok(openCodeAdapter.handles(p)); |
| 100 | +}); |
| 101 | + |
| 102 | +test('OpenCodeAdapter.handles: recognises DB virtual paths', () => { |
| 103 | + const p = path.join(openCodeDA.getOpenCodeDataDir(), 'opencode.db#ses_abc123'); |
| 104 | + assert.ok(openCodeAdapter.handles(p)); |
| 105 | +}); |
| 106 | + |
| 107 | +test('OpenCodeAdapter.handles: rejects unrelated paths', () => { |
| 108 | + assert.ok(!openCodeAdapter.handles(path.join(os.homedir(), '.continue', 'sessions', 'abc.json'))); |
| 109 | + assert.ok(!openCodeAdapter.handles(path.join(os.homedir(), '.claude', 'projects', 'hash', 'abc.jsonl'))); |
| 110 | +}); |
| 111 | + |
| 112 | +test('ContinueAdapter.handles: recognises ~/.continue/sessions paths', () => { |
| 113 | + const p = path.join(os.homedir(), '.continue', 'sessions', 'abc123.json'); |
| 114 | + assert.ok(continueAdapter.handles(p)); |
| 115 | +}); |
| 116 | + |
| 117 | +test('ContinueAdapter.handles: rejects unrelated paths', () => { |
| 118 | + assert.ok(!continueAdapter.handles(path.join(os.homedir(), '.claude', 'projects', 'hash', 'abc.jsonl'))); |
| 119 | +}); |
| 120 | + |
| 121 | +test('ClaudeCodeAdapter.handles: recognises ~/.claude/projects paths', () => { |
| 122 | + const p = path.join(os.homedir(), '.claude', 'projects', 'my-project', 'abc123.jsonl'); |
| 123 | + assert.ok(claudeCodeAdapter.handles(p)); |
| 124 | +}); |
| 125 | + |
| 126 | +test('ClaudeCodeAdapter.handles: rejects ~/.claude/stats-cache.json', () => { |
| 127 | + assert.ok(!claudeCodeAdapter.handles(path.join(os.homedir(), '.claude', 'stats-cache.json'))); |
| 128 | +}); |
| 129 | + |
| 130 | +test('MistralVibeAdapter.handles: recognises ~/.vibe/logs/session paths', () => { |
| 131 | + const p = path.join(os.homedir(), '.vibe', 'logs', 'session', 'session_20240101_120000_abc12345', 'meta.json'); |
| 132 | + assert.ok(mistralVibeAdapter.handles(p)); |
| 133 | +}); |
| 134 | + |
| 135 | +test('MistralVibeAdapter.handles: rejects unrelated paths', () => { |
| 136 | + assert.ok(!mistralVibeAdapter.handles(path.join(os.homedir(), '.claude', 'projects', 'hash', 'abc.jsonl'))); |
| 137 | +}); |
| 138 | + |
| 139 | +// --------------------------------------------------------------------------- |
| 140 | +// getCandidatePaths() — structure validation |
| 141 | +// --------------------------------------------------------------------------- |
| 142 | + |
| 143 | +test('getCandidatePaths: all adapters return array of {path, source}', () => { |
| 144 | + for (const adapter of allAdapters) { |
| 145 | + if (!isDiscoverable(adapter)) { continue; } |
| 146 | + const paths = adapter.getCandidatePaths(); |
| 147 | + assert.ok(Array.isArray(paths), `${adapter.id}: expected array`); |
| 148 | + for (const cp of paths) { |
| 149 | + assert.ok(typeof cp.path === 'string' && cp.path.length > 0, `${adapter.id}: path should be non-empty string`); |
| 150 | + assert.ok(typeof cp.source === 'string' && cp.source.length > 0, `${adapter.id}: source should be non-empty string`); |
| 151 | + } |
| 152 | + } |
| 153 | +}); |
| 154 | + |
| 155 | +test('OpenCodeAdapter.getCandidatePaths: returns both JSON dir and DB paths', () => { |
| 156 | + const paths = openCodeAdapter.getCandidatePaths(); |
| 157 | + const sources = paths.map(p => p.source); |
| 158 | + assert.ok(sources.some(s => s.includes('JSON')), 'Should include JSON path'); |
| 159 | + assert.ok(sources.some(s => s.includes('DB')), 'Should include DB path'); |
| 160 | + assert.equal(paths.length, 2); |
| 161 | +}); |
| 162 | + |
| 163 | +test('CrushAdapter.getCandidatePaths: always includes projects.json path', () => { |
| 164 | + const paths = crushAdapter.getCandidatePaths(); |
| 165 | + assert.ok(paths.length >= 1); |
| 166 | + assert.ok(paths[0].path.endsWith('projects.json')); |
| 167 | + assert.ok(paths[0].source.includes('Crush')); |
| 168 | +}); |
| 169 | + |
| 170 | +test('ContinueAdapter.getCandidatePaths: returns sessions directory path', () => { |
| 171 | + const paths = continueAdapter.getCandidatePaths(); |
| 172 | + assert.equal(paths.length, 1); |
| 173 | + assert.ok(paths[0].path.length > 0); |
| 174 | + assert.equal(paths[0].source, 'Continue'); |
| 175 | +}); |
| 176 | + |
| 177 | +test('ClaudeCodeAdapter.getCandidatePaths: returns Claude Code projects directory', () => { |
| 178 | + const paths = claudeCodeAdapter.getCandidatePaths(); |
| 179 | + assert.equal(paths.length, 1); |
| 180 | + assert.ok(paths[0].path.includes('.claude')); |
| 181 | + assert.equal(paths[0].source, 'Claude Code'); |
| 182 | +}); |
| 183 | + |
| 184 | +test('MistralVibeAdapter.getCandidatePaths: returns session log directory', () => { |
| 185 | + const paths = mistralVibeAdapter.getCandidatePaths(); |
| 186 | + assert.equal(paths.length, 1); |
| 187 | + assert.ok(paths[0].path.includes('.vibe')); |
| 188 | + assert.equal(paths[0].source, 'Mistral Vibe'); |
| 189 | +}); |
| 190 | + |
| 191 | +// --------------------------------------------------------------------------- |
| 192 | +// getEditorRoot() — returns non-empty string |
| 193 | +// --------------------------------------------------------------------------- |
| 194 | + |
| 195 | +test('getEditorRoot: all adapters return non-empty string', () => { |
| 196 | + const dummyFile = '/dummy/path/session.json'; |
| 197 | + for (const adapter of allAdapters) { |
| 198 | + const root = adapter.getEditorRoot(dummyFile); |
| 199 | + assert.ok(typeof root === 'string' && root.length > 0, `${adapter.id}: getEditorRoot should return non-empty string`); |
| 200 | + } |
| 201 | +}); |
| 202 | + |
| 203 | +test('OpenCodeAdapter.getEditorRoot: returns opencode data directory', () => { |
| 204 | + const root = openCodeAdapter.getEditorRoot('/any/path'); |
| 205 | + assert.ok(root.includes('opencode')); |
| 206 | +}); |
| 207 | + |
| 208 | +test('ContinueAdapter.getEditorRoot: returns continue data directory', () => { |
| 209 | + const root = continueAdapter.getEditorRoot('/any/path'); |
| 210 | + assert.ok(root.includes('.continue')); |
| 211 | +}); |
| 212 | + |
| 213 | +test('ClaudeCodeAdapter.getEditorRoot: returns claude data directory', () => { |
| 214 | + const root = claudeCodeAdapter.getEditorRoot('/any/path'); |
| 215 | + assert.ok(root.includes('.claude')); |
| 216 | +}); |
| 217 | + |
| 218 | +// --------------------------------------------------------------------------- |
| 219 | +// discover() — adapter loop behavior (empty directories) |
| 220 | +// --------------------------------------------------------------------------- |
| 221 | + |
| 222 | +test('discover: returns DiscoveryResult shape when no sessions exist', async () => { |
| 223 | + // All adapters should gracefully return empty sessionFiles when data dirs don't exist |
| 224 | + for (const adapter of allAdapters) { |
| 225 | + if (!isDiscoverable(adapter)) { continue; } |
| 226 | + const result = await adapter.discover(() => { /* noop */ }); |
| 227 | + assert.ok(typeof result === 'object', `${adapter.id}: should return object`); |
| 228 | + assert.ok(Array.isArray(result.sessionFiles), `${adapter.id}: sessionFiles should be array`); |
| 229 | + assert.ok(Array.isArray(result.candidatePaths), `${adapter.id}: candidatePaths should be array`); |
| 230 | + // candidatePaths from discover() should match getCandidatePaths() (or be a superset for crush multi-project) |
| 231 | + const syncPaths = adapter.getCandidatePaths(); |
| 232 | + for (const cp of syncPaths) { |
| 233 | + const found = result.candidatePaths.some(rp => rp.path === cp.path && rp.source === cp.source); |
| 234 | + assert.ok(found, `${adapter.id}: discover() candidatePaths should include getCandidatePaths() entry: ${cp.path}`); |
| 235 | + } |
| 236 | + } |
| 237 | +}); |
| 238 | + |
| 239 | +// --------------------------------------------------------------------------- |
| 240 | +// getCandidatePaths() / discover() consistency |
| 241 | +// --------------------------------------------------------------------------- |
| 242 | + |
| 243 | +test('getCandidatePaths paths are consistent with discover candidatePaths', async () => { |
| 244 | + // For all adapters, getCandidatePaths() should be a subset of discover().candidatePaths |
| 245 | + // (Crush can return MORE paths from discover if projects are found) |
| 246 | + for (const adapter of allAdapters) { |
| 247 | + if (!isDiscoverable(adapter)) { continue; } |
| 248 | + const syncPaths = adapter.getCandidatePaths().map(cp => cp.path); |
| 249 | + const discoverResult = await adapter.discover(() => { /* noop */ }); |
| 250 | + const discoverPaths = discoverResult.candidatePaths.map(cp => cp.path); |
| 251 | + for (const sp of syncPaths) { |
| 252 | + assert.ok(discoverPaths.includes(sp), `${adapter.id}: sync path '${sp}' missing from discover() result`); |
| 253 | + } |
| 254 | + } |
| 255 | +}); |
0 commit comments