|
1 | 1 | import { describe, expect, test, beforeEach, afterEach, vi } from 'vitest' |
2 | 2 | import { join } from 'path' |
3 | 3 | import { tmpdir } from 'os' |
4 | | -import { mkdir, mkdtemp, rm, readFile, writeFile } from 'fs/promises' |
| 4 | +import { mkdir, mkdtemp, rm, readFile, writeFile, symlink } from 'fs/promises' |
| 5 | +import { promises as fs } from 'fs' |
5 | 6 | import type { OpenCodeClient } from '../../src/services/opencode/client' |
6 | 7 | import type { Repo } from '../../src/types/repo' |
7 | 8 |
|
@@ -341,6 +342,71 @@ describe('SkillService', () => { |
341 | 342 | } |
342 | 343 | }) |
343 | 344 |
|
| 345 | + test('keeps healthy skills when dangling symlinks sit beside them', async () => { |
| 346 | + const { listManagedSkills } = await import('../../src/services/skills') |
| 347 | + const projectPath = join(tempDir, 'project-symlinks') |
| 348 | + const skillsRoot = join(projectPath, '.opencode', 'skills') |
| 349 | + |
| 350 | + const regularDir = join(skillsRoot, 'regular-skill') |
| 351 | + await mkdir(regularDir, { recursive: true }) |
| 352 | + await writeFile( |
| 353 | + join(regularDir, 'SKILL.md'), |
| 354 | + '---\nname: regular-skill\ndescription: Regular skill\n---\nBody', |
| 355 | + ) |
| 356 | + |
| 357 | + const symlinkTargetDir = join(tempDir, 'symlink-target') |
| 358 | + await mkdir(symlinkTargetDir, { recursive: true }) |
| 359 | + await writeFile( |
| 360 | + join(symlinkTargetDir, 'SKILL.md'), |
| 361 | + '---\nname: linked-skill\ndescription: Linked skill\n---\nBody', |
| 362 | + ) |
| 363 | + await symlink(symlinkTargetDir, join(skillsRoot, 'linked-skill'), 'dir') |
| 364 | + |
| 365 | + await symlink(join(tempDir, 'missing-target'), join(skillsRoot, 'dangling-skill'), 'dir') |
| 366 | + |
| 367 | + const plainFile = join(tempDir, 'plain-file.txt') |
| 368 | + await writeFile(plainFile, 'not a directory') |
| 369 | + await symlink(join(plainFile, 'child'), join(skillsRoot, 'notdir-skill'), 'dir') |
| 370 | + |
| 371 | + await symlink(join(skillsRoot, 'loop-b'), join(skillsRoot, 'loop-a'), 'dir') |
| 372 | + await symlink(join(skillsRoot, 'loop-a'), join(skillsRoot, 'loop-b'), 'dir') |
| 373 | + |
| 374 | + const skills = await listManagedSkills(mockDb, createMockClient([]), undefined, projectPath) |
| 375 | + |
| 376 | + expect(skills).toContainEqual(expect.objectContaining({ |
| 377 | + name: 'regular-skill', |
| 378 | + description: 'Regular skill', |
| 379 | + scope: 'project', |
| 380 | + })) |
| 381 | + expect(skills).toContainEqual(expect.objectContaining({ |
| 382 | + name: 'linked-skill', |
| 383 | + description: 'Linked skill', |
| 384 | + scope: 'project', |
| 385 | + })) |
| 386 | + expect(skills.map(s => s.name)).not.toContain('dangling-skill') |
| 387 | + }) |
| 388 | + |
| 389 | + test('listDirectory rejects when the root directory is missing', async () => { |
| 390 | + const { listDirectory } = await import('../../src/services/file-operations') |
| 391 | + await expect(listDirectory(join(tempDir, 'missing-root'))).rejects.toThrow('Failed to list directory') |
| 392 | + }) |
| 393 | + |
| 394 | + test('listDirectory propagates unexpected stat errors', async () => { |
| 395 | + const { listDirectory } = await import('../../src/services/file-operations') |
| 396 | + const dir = join(tempDir, 'stat-error-root') |
| 397 | + await mkdir(dir, { recursive: true }) |
| 398 | + await writeFile(join(dir, 'entry.txt'), 'content') |
| 399 | + |
| 400 | + const statSpy = vi.spyOn(fs, 'stat').mockRejectedValue( |
| 401 | + Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }), |
| 402 | + ) |
| 403 | + try { |
| 404 | + await expect(listDirectory(dir)).rejects.toThrow('Failed to list directory') |
| 405 | + } finally { |
| 406 | + statSpy.mockRestore() |
| 407 | + } |
| 408 | + }) |
| 409 | + |
344 | 410 | test('preserves body content containing --- (horizontal rules)', async () => { |
345 | 411 | const { createSkill, getSkill, deleteSkill } = await import('../../src/services/skills') |
346 | 412 | const name = `hr-test-${Date.now()}` |
|
0 commit comments