// @vitest-environment node import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => { const files = new Map(); const dirs = new Set(['/']); let tempCounter = 0; const normalize = (input: string): string => { const raw = String(input || '').replace(/\\/g, '/'); const absolute = raw.startsWith('/'); const parts = raw.split('/').filter(Boolean); const stack: string[] = []; for (const part of parts) { if (part === '.') continue; if (part === '..') { if (stack.length > 0) { stack.pop(); } continue; } stack.push(part); } const normalized = `${absolute ? '/' : ''}${stack.join('/')}`; return normalized || (absolute ? '/' : '.'); }; const dirname = (input: string): string => { const normalized = normalize(input); if (normalized === '/' || normalized === '.') return normalized; const parts = normalized.split('/').filter(Boolean); parts.pop(); return parts.length === 0 ? '/' : `/${parts.join('/')}`; }; const basename = (input: string): string => { const normalized = normalize(input); if (normalized === '/' || normalized === '.') return normalized; const parts = normalized.split('/').filter(Boolean); return parts[parts.length - 1] || normalized; }; const ensureDir = (input: string): string => { const normalized = normalize(input); const parts = normalized.split('/').filter(Boolean); let current = normalized.startsWith('/') ? '/' : ''; if (normalized === '/' || normalized === '.') { dirs.add(normalized); return normalized; } for (const part of parts) { current = current === '/' ? `/${part}` : `${current}/${part}`; dirs.add(current); } return normalized; }; const writeFile = (input: string, content: string): void => { const normalized = normalize(input); ensureDir(dirname(normalized)); files.set(normalized, content); }; const existsSync = vi.fn((input: string) => { const normalized = normalize(input); return dirs.has(normalized) || files.has(normalized); }); const mkdir = vi.fn(async (input: string) => { ensureDir(input); }); const mkdtemp = vi.fn(async (prefix: string) => { tempCounter += 1; const dir = normalize(`${prefix}${tempCounter}`); ensureDir(dir); return dir; }); const readFile = vi.fn(async (input: string) => { const normalized = normalize(input); const value = files.get(normalized); if (typeof value !== 'string') { throw new Error(`ENOENT: ${normalized}`); } return value; }); const rm = vi.fn(async (input: string) => { const normalized = normalize(input); for (const key of [...files.keys()]) { if (key === normalized || key.startsWith(`${normalized}/`)) { files.delete(key); } } for (const key of [...dirs]) { if (key === normalized || key.startsWith(`${normalized}/`)) { dirs.delete(key); } } dirs.add('/'); }); const cp = vi.fn(async (source: string, destination: string) => { const src = normalize(source); const dest = normalize(destination); ensureDir(dest); for (const dir of [...dirs]) { if (dir === src || dir.startsWith(`${src}/`)) { const relative = dir === src ? '' : dir.slice(src.length + 1); ensureDir(relative ? `${dest}/${relative}` : dest); } } for (const [filePath, content] of [...files.entries()]) { if (filePath === src || filePath.startsWith(`${src}/`)) { const relative = filePath === src ? basename(filePath) : filePath.slice(src.length + 1); writeFile(relative ? `${dest}/${relative}` : dest, content); } } }); const reset = () => { files.clear(); dirs.clear(); dirs.add('/'); tempCounter = 0; existsSync.mockClear(); mkdir.mockClear(); mkdtemp.mockClear(); readFile.mockClear(); rm.mockClear(); cp.mockClear(); }; return { files, dirs, normalize, dirname, basename, ensureDir, writeFile, existsSync, mkdir, mkdtemp, readFile, rm, cp, reset, }; }); vi.mock('node:fs', () => ({ createWriteStream: vi.fn(() => ({ on: vi.fn() })), existsSync: mocks.existsSync, })); vi.mock('node:fs/promises', () => ({ cp: mocks.cp, mkdir: mocks.mkdir, mkdtemp: mocks.mkdtemp, readFile: mocks.readFile, readdir: vi.fn(async () => []), rm: mocks.rm, })); vi.mock('node:path', () => ({ basename: mocks.basename, dirname: mocks.dirname, join: (...parts: string[]) => mocks.normalize(parts.join('/')), resolve: (...parts: string[]) => mocks.normalize(`/${parts.join('/')}`), sep: '/', })); vi.mock('node:os', () => ({ tmpdir: () => '/tmp', })); vi.mock('node:child_process', () => ({ spawn: vi.fn(), })); vi.mock('node:stream/promises', () => ({ pipeline: vi.fn(async () => undefined), })); vi.mock('axios', () => ({ default: { get: vi.fn(), }, })); vi.mock('extract-zip', () => ({ default: vi.fn(async () => undefined), })); vi.mock('@electron/utils/skill-config', () => ({ updateSkillConfig: vi.fn(async () => ({ success: true })), })); vi.mock('@electron/utils/paths', () => ({ ensureDir: (input: string) => mocks.ensureDir(input), getOpenClawConfigDir: () => '/openclaw', })); import { SkillInstallService, SkillInstallServiceError, parseGitHubSkillUrl, } from '../electron/service/skill-install-service'; describe('SkillInstallService', () => { beforeEach(() => { mocks.reset(); }); it('parses GitHub blob and tree skill URLs', () => { expect(parseGitHubSkillUrl('https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md')).toMatchObject({ owner: 'MiniMax-AI', repo: 'skills', ref: 'main', skillPath: 'skills/minimax-xlsx', defaultSlug: 'minimax-xlsx', }); expect(parseGitHubSkillUrl('https://github.com/MiniMax-AI/skills/tree/main/skills/minimax-xlsx')).toMatchObject({ owner: 'MiniMax-AI', repo: 'skills', ref: 'main', skillPath: 'skills/minimax-xlsx', defaultSlug: 'minimax-xlsx', }); }); it('installs marketplace skills and enables them', async () => { const enableSkill = vi.fn(async () => undefined); const install = vi.fn(async ({ slug }: { slug: string }) => { mocks.writeFile(`/workspace/skills/${slug}/SKILL.md`, '---\nname: demo-skill\n---\n'); }); const service = new SkillInstallService({ clawHubService: { install } as any, configDir: '/workspace', skillsRootDir: '/workspace/skills', enableSkill, }); const result = await service.install({ kind: 'marketplace', slug: 'demo-skill', version: '1.2.3' }); expect(install).toHaveBeenCalledWith({ slug: 'demo-skill', version: '1.2.3', force: undefined, }); expect(enableSkill).toHaveBeenCalledWith('demo-skill'); expect(result).toMatchObject({ success: true, slug: 'demo-skill', source: 'marketplace', enabled: true, }); }); it('installs GitHub skill directories, prefers frontmatter name, and enables them', async () => { const enableSkill = vi.fn(async () => undefined); mocks.writeFile('/workspace/source-skill/SKILL.md', '---\nname: minimax-xlsx\ndescription: Spreadsheet helper\n---\n'); const service = new SkillInstallService({ clawHubService: { install: vi.fn() } as any, configDir: '/workspace', skillsRootDir: '/workspace/skills', enableSkill, createTempDir: async () => '/workspace/temp-1', resolveGitHubSkillDirectory: async () => '/workspace/source-skill', }); const result = await service.install({ kind: 'github-url', url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md', }); expect(result).toMatchObject({ success: true, slug: 'minimax-xlsx', source: 'github-url', enabled: true, }); expect(enableSkill).toHaveBeenCalledWith('minimax-xlsx'); expect(mocks.files.get('/workspace/skills/minimax-xlsx/SKILL.md')).toContain('Spreadsheet helper'); }); it('rejects GitHub installs when SKILL.md is missing', async () => { mocks.writeFile('/workspace/source-skill/README.md', '# not a skill\n'); const service = new SkillInstallService({ clawHubService: { install: vi.fn() } as any, configDir: '/workspace', skillsRootDir: '/workspace/skills', enableSkill: async () => undefined, createTempDir: async () => '/workspace/temp-1', resolveGitHubSkillDirectory: async () => '/workspace/source-skill', }); await expect(service.install({ kind: 'github-url', url: 'https://github.com/MiniMax-AI/skills/tree/main/skills/missing-skill', })).rejects.toThrow('SKILL.md not found'); }); it('rejects GitHub installs when the target directory already exists and force is false', async () => { mocks.writeFile('/workspace/source-skill/SKILL.md', '---\nname: minimax-xlsx\n---\n'); mocks.writeFile('/workspace/skills/minimax-xlsx/SKILL.md', '---\nname: minimax-xlsx\n---\n'); const service = new SkillInstallService({ clawHubService: { install: vi.fn() } as any, configDir: '/workspace', skillsRootDir: '/workspace/skills', enableSkill: async () => undefined, createTempDir: async () => '/workspace/temp-1', resolveGitHubSkillDirectory: async () => '/workspace/source-skill', }); await expect(service.install({ kind: 'github-url', url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md', })).rejects.toMatchObject>({ status: 409, code: 'skill_exists', }); }); it('overwrites GitHub installs when force is true', async () => { mocks.writeFile('/workspace/source-skill/SKILL.md', '---\nname: minimax-xlsx\n---\nnew-content'); mocks.writeFile('/workspace/skills/minimax-xlsx/SKILL.md', '---\nname: minimax-xlsx\n---\nold-content'); const service = new SkillInstallService({ clawHubService: { install: vi.fn() } as any, configDir: '/workspace', skillsRootDir: '/workspace/skills', enableSkill: async () => undefined, createTempDir: async () => '/workspace/temp-1', resolveGitHubSkillDirectory: async () => '/workspace/source-skill', }); await service.install({ kind: 'github-url', url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md', force: true, }); expect(mocks.files.get('/workspace/skills/minimax-xlsx/SKILL.md')).toContain('new-content'); }); });