| import { describe, expect, it } from 'bun:test' |
| import { mkdtemp, rm, writeFile } from 'node:fs/promises' |
| import { join } from 'node:path' |
| import { tmpdir } from 'node:os' |
| import { createOpenTargetService } from '../services/openTargetService.js' |
|
|
| async function makeDir(prefix = 'cc-haha-open-target-') { |
| return mkdtemp(join(tmpdir(), prefix)) |
| } |
|
|
| function createService( |
| platform: NodeJS.Platform, |
| options: { |
| commands?: Record<string, boolean> |
| commandPaths?: Record<string, string | null> |
| paths?: Record<string, boolean> |
| plistValues?: Record<string, string | null> |
| dirNames?: Record<string, string[]> |
| textFiles?: Record<string, string | null> |
| launchResult?: { code: number; stdout: string; stderr: string } |
| iconData?: Uint8Array |
| ttlMs?: number |
| now?: { value: number } |
| } = {}, |
| ) { |
| const launched: Array<{ command: string; args: string[] }> = [] |
| const convertedIcons: Array<{ iconPath: string; size: number }> = [] |
| let commandProbes = 0 |
| let pathProbes = 0 |
| const now = options.now ?? { value: 100 } |
|
|
| const service = createOpenTargetService({ |
| platform, |
| ttlMs: options.ttlMs ?? 1_000, |
| now: () => now.value, |
| commandExists: async (command) => { |
| commandProbes += 1 |
| return options.commands?.[command] === true || Boolean(options.commandPaths?.[command]) |
| }, |
| resolveCommand: async (command) => { |
| const commandPath = options.commandPaths?.[command] |
| if (commandPath !== undefined) return commandPath |
| return options.commands?.[command] === true ? command : null |
| }, |
| pathExists: async (targetPath) => { |
| pathProbes += 1 |
| return options.paths?.[targetPath] === true |
| }, |
| launch: async (command, args) => { |
| launched.push({ command, args }) |
| return options.launchResult ?? { code: 0, stdout: '', stderr: '' } |
| }, |
| readDirNames: async (targetPath) => options.dirNames?.[targetPath] ?? [], |
| readTextFile: async (targetPath) => options.textFiles?.[targetPath] ?? null, |
| readPlistValue: async (plistPath) => options.plistValues?.[plistPath] ?? null, |
| convertIconToPng: async (iconPath, size) => { |
| convertedIcons.push({ iconPath, size }) |
| return options.iconData ?? new Uint8Array([1, 2, 3]) |
| }, |
| }) |
|
|
| return { |
| service, |
| launched, |
| convertedIcons, |
| now, |
| get commandProbes() { |
| return commandProbes |
| }, |
| get pathProbes() { |
| return pathProbes |
| }, |
| } |
| } |
|
|
| describe('openTargetService', () => { |
| it('returns only detected IDE targets plus Finder on macOS', async () => { |
| const { service } = createService('darwin', { |
| paths: { |
| '/Applications/Visual Studio Code.app': true, |
| '/Applications/Sublime Text.app': true, |
| }, |
| }) |
|
|
| const result = await service.listTargets() |
|
|
| expect(result.platform).toBe('darwin') |
| expect(result.targets.map((target) => target.id)).toEqual([ |
| 'vscode', |
| 'sublime', |
| 'finder', |
| ]) |
| expect(result.primaryTargetId).toBe('vscode') |
| expect(result.targets.find((target) => target.id === 'finder')?.kind).toBe('file_manager') |
| expect(result.targets.find((target) => target.id === 'vscode')?.iconUrl) |
| .toBe('/api/open-targets/icons/vscode') |
| }) |
|
|
| it('does not treat macOS command shims as installed IDEs without the app bundle', async () => { |
| const { service } = createService('darwin', { |
| commands: { |
| code: true, |
| goland: true, |
| pycharm: true, |
| }, |
| }) |
|
|
| const result = await service.listTargets() |
|
|
| expect(result.targets.map((target) => target.id)).toEqual(['finder']) |
| expect(result.primaryTargetId).toBe('finder') |
| }) |
|
|
| it('falls back to Explorer when no Windows IDE is detected', async () => { |
| const { service } = createService('win32') |
|
|
| const result = await service.listTargets() |
|
|
| expect(result.targets.map((target) => target.id)).toEqual(['explorer']) |
| expect(result.primaryTargetId).toBe('explorer') |
| expect(result.targets[0]?.iconUrl).toBe('/api/open-targets/icons/explorer') |
| }) |
|
|
| it('does not include stale Windows command shims without the app executable', async () => { |
| const { service } = createService('win32', { |
| commandPaths: { |
| 'code.cmd': 'C:\\Users\\nanmi\\AppData\\Local\\Programs\\Microsoft VS Code\\bin\\code.cmd', |
| 'goland.cmd': 'C:\\Users\\nanmi\\AppData\\Local\\JetBrains\\Toolbox\\scripts\\goland.cmd', |
| }, |
| }) |
|
|
| const result = await service.listTargets() |
|
|
| expect(result.targets.map((target) => target.id)).toEqual(['explorer']) |
| expect(result.primaryTargetId).toBe('explorer') |
| }) |
|
|
| it('detects Windows IDEs only when a real executable is resolved', async () => { |
| const commandPath = 'C:\\Users\\nanmi\\AppData\\Local\\Programs\\Microsoft VS Code\\bin\\code.cmd' |
| const executablePath = 'C:\\Users\\nanmi\\AppData\\Local\\Programs\\Microsoft VS Code\\Code.exe' |
| const { service } = createService('win32', { |
| commandPaths: { |
| 'code.cmd': commandPath, |
| }, |
| paths: { |
| [executablePath]: true, |
| }, |
| }) |
|
|
| const result = await service.listTargets() |
|
|
| expect(result.targets.map((target) => target.id)).toEqual(['vscode', 'explorer']) |
| expect(result.primaryTargetId).toBe('vscode') |
| }) |
|
|
| it('only includes the Linux file-manager fallback when xdg-open is available', async () => { |
| const withoutXdg = createService('linux') |
| expect((await withoutXdg.service.listTargets()).targets).toEqual([]) |
|
|
| const withXdg = createService('linux', { |
| commands: { 'xdg-open': true }, |
| }) |
| expect((await withXdg.service.listTargets()).targets.map((target) => target.id)).toEqual([ |
| 'file-manager', |
| ]) |
| }) |
|
|
| it('caches detection results until the TTL expires', async () => { |
| const now = { value: 100 } |
| const state = createService('linux', { |
| commands: { code: true }, |
| now, |
| }) |
|
|
| await state.service.listTargets() |
| const initialProbes = state.commandProbes |
| expect(initialProbes).toBeGreaterThan(0) |
|
|
| await state.service.listTargets() |
| expect(state.commandProbes).toBe(initialProbes) |
|
|
| now.value = 5_000 |
| await state.service.listTargets() |
| expect(state.commandProbes).toBeGreaterThan(initialProbes) |
| }) |
|
|
| it('rejects unknown targets', async () => { |
| const dir = await makeDir() |
| const { service } = createService('darwin', { commands: { code: true } }) |
|
|
| try { |
| await expect(service.openTarget({ targetId: 'terminal', path: dir })) |
| .rejects.toMatchObject({ code: 'OPEN_TARGET_UNKNOWN' }) |
| } finally { |
| await rm(dir, { recursive: true, force: true }) |
| } |
| }) |
|
|
| it('rejects non-directory paths', async () => { |
| const dir = await makeDir() |
| const file = join(dir, 'note.txt') |
| await writeFile(file, 'not a directory') |
| const { service } = createService('darwin', { |
| paths: { '/Applications/Visual Studio Code.app': true }, |
| }) |
|
|
| try { |
| await expect(service.openTarget({ targetId: 'vscode', path: file })) |
| .rejects.toMatchObject({ code: 'OPEN_TARGET_PATH_NOT_DIRECTORY' }) |
| } finally { |
| await rm(dir, { recursive: true, force: true }) |
| } |
| }) |
|
|
| it('launches command-first targets with argument arrays and the path as one argument', async () => { |
| const dir = await makeDir('cc-haha open-target-') |
| const { service, launched } = createService('linux', { |
| commands: { code: true }, |
| }) |
|
|
| try { |
| await service.openTarget({ targetId: 'vscode', path: dir }) |
|
|
| expect(launched).toEqual([{ command: 'code', args: [dir] }]) |
| } finally { |
| await rm(dir, { recursive: true, force: true }) |
| } |
| }) |
|
|
| it('opens macOS app bundles through open -a instead of command shims', async () => { |
| const dir = await makeDir() |
| const { service, launched } = createService('darwin', { |
| commands: { cursor: true }, |
| paths: { '/Applications/Cursor.app': true }, |
| }) |
|
|
| try { |
| await service.openTarget({ targetId: 'cursor', path: dir }) |
|
|
| expect(launched).toEqual([ |
| { command: 'open', args: ['-a', '/Applications/Cursor.app', dir] }, |
| ]) |
| } finally { |
| await rm(dir, { recursive: true, force: true }) |
| } |
| }) |
|
|
| it('opens Windows command-shim targets through the resolved executable', async () => { |
| const dir = await makeDir() |
| const commandPath = 'C:\\Users\\nanmi\\AppData\\Local\\Programs\\Microsoft VS Code\\bin\\code.cmd' |
| const executablePath = 'C:\\Users\\nanmi\\AppData\\Local\\Programs\\Microsoft VS Code\\Code.exe' |
| const { service, launched } = createService('win32', { |
| commandPaths: { |
| 'code.cmd': commandPath, |
| }, |
| paths: { |
| [executablePath]: true, |
| }, |
| }) |
|
|
| try { |
| await service.openTarget({ targetId: 'vscode', path: dir }) |
|
|
| expect(launched).toEqual([{ command: executablePath, args: [dir] }]) |
| } finally { |
| await rm(dir, { recursive: true, force: true }) |
| } |
| }) |
|
|
| it('opens Windows Explorer through the file-manager fallback', async () => { |
| const dir = await makeDir() |
| const { service, launched } = createService('win32') |
|
|
| try { |
| await service.openTarget({ targetId: 'explorer', path: dir }) |
|
|
| expect(launched).toEqual([{ command: 'cmd.exe', args: ['/d', '/c', 'start', '', dir] }]) |
| } finally { |
| await rm(dir, { recursive: true, force: true }) |
| } |
| }) |
|
|
| it('reports launch failures instead of returning success', async () => { |
| const dir = await makeDir() |
| const { service } = createService('darwin', { |
| paths: { '/Applications/Visual Studio Code.app': true }, |
| launchResult: { code: 1, stdout: '', stderr: 'failed' }, |
| }) |
|
|
| try { |
| await expect(service.openTarget({ targetId: 'vscode', path: dir })) |
| .rejects.toMatchObject({ code: 'OPEN_TARGET_LAUNCH_FAILED' }) |
| } finally { |
| await rm(dir, { recursive: true, force: true }) |
| } |
| }) |
|
|
| it('extracts macOS target icons from the detected app bundle icon file', async () => { |
| const iconPath = '/Applications/Visual Studio Code.app/Contents/Resources/Code.icns' |
| const state = createService('darwin', { |
| paths: { |
| '/Applications/Visual Studio Code.app': true, |
| [iconPath]: true, |
| }, |
| plistValues: { |
| '/Applications/Visual Studio Code.app/Contents/Info.plist': 'Code.icns', |
| }, |
| iconData: new Uint8Array([9, 8, 7]), |
| }) |
|
|
| const icon = await state.service.getTargetIcon('vscode') |
|
|
| expect(icon.contentType).toBe('image/png') |
| expect(Array.from(icon.data)).toEqual([9, 8, 7]) |
| expect(state.convertedIcons).toEqual([{ iconPath, size: 64 }]) |
|
|
| await state.service.getTargetIcon('vscode') |
| expect(state.convertedIcons).toHaveLength(1) |
| }) |
|
|
| it('uses Finder system icon for the macOS file-manager fallback', async () => { |
| const finderIcon = '/System/Library/CoreServices/Finder.app/Contents/Resources/Finder.icns' |
| const state = createService('darwin', { |
| paths: { |
| [finderIcon]: true, |
| }, |
| }) |
|
|
| await state.service.getTargetIcon('finder') |
|
|
| expect(state.convertedIcons).toEqual([{ iconPath: finderIcon, size: 64 }]) |
| }) |
|
|
| it('extracts Windows target icons from the resolved application executable', async () => { |
| const commandPath = 'C:\\Users\\nanmi\\AppData\\Local\\Programs\\Microsoft VS Code\\bin\\code.cmd' |
| const iconPath = 'C:\\Users\\nanmi\\AppData\\Local\\Programs\\Microsoft VS Code\\Code.exe' |
| const state = createService('win32', { |
| commandPaths: { |
| 'code.cmd': commandPath, |
| }, |
| paths: { |
| [iconPath]: true, |
| }, |
| iconData: new Uint8Array([4, 5, 6]), |
| }) |
|
|
| const icon = await state.service.getTargetIcon('vscode') |
|
|
| expect(icon.contentType).toBe('image/png') |
| expect(Array.from(icon.data)).toEqual([4, 5, 6]) |
| expect(state.convertedIcons).toEqual([{ iconPath, size: 64 }]) |
| }) |
|
|
| it('uses the Windows Explorer executable icon for the file-manager fallback', async () => { |
| const explorerPath = 'C:\\Windows\\explorer.exe' |
| const state = createService('win32', { |
| commandPaths: { |
| 'explorer.exe': explorerPath, |
| }, |
| paths: { |
| [explorerPath]: true, |
| }, |
| }) |
|
|
| await state.service.getTargetIcon('explorer') |
|
|
| expect(state.convertedIcons).toEqual([{ iconPath: explorerPath, size: 64 }]) |
| }) |
|
|
| it('extracts Linux target icons from matching desktop entries', async () => { |
| const desktopPath = '/usr/share/applications/code.desktop' |
| const iconPath = '/usr/share/pixmaps/code.png' |
| const state = createService('linux', { |
| commands: { |
| code: true, |
| }, |
| dirNames: { |
| '/usr/share/applications': ['code.desktop'], |
| }, |
| textFiles: { |
| [desktopPath]: [ |
| '[Desktop Entry]', |
| 'Name=Visual Studio Code', |
| 'Exec=/usr/bin/code --reuse-window %F', |
| 'Icon=code', |
| ].join('\n'), |
| }, |
| paths: { |
| [iconPath]: true, |
| }, |
| }) |
|
|
| await state.service.getTargetIcon('vscode') |
|
|
| expect(state.convertedIcons).toEqual([{ iconPath, size: 64 }]) |
| }) |
|
|
| it('uses the Linux folder icon for the file-manager fallback when available', async () => { |
| const folderIcon = '/usr/share/icons/hicolor/64x64/apps/folder.png' |
| const state = createService('linux', { |
| commands: { |
| 'xdg-open': true, |
| }, |
| dirNames: { |
| '/usr/share/icons': ['hicolor'], |
| }, |
| paths: { |
| [folderIcon]: true, |
| }, |
| }) |
|
|
| await state.service.getTargetIcon('file-manager') |
|
|
| expect(state.convertedIcons).toEqual([{ iconPath: folderIcon, size: 64 }]) |
| }) |
| }) |
|
|