Skip to content

Commit c29ca5a

Browse files
Merge pull request #213 from laststance/feat/coverage-100
test: drive coverage to a pragmatic 100% bar with a thresholds gate
2 parents 864618a + 475e7dd commit c29ca5a

110 files changed

Lines changed: 19601 additions & 39 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/main/services/agentScanner.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,4 +324,48 @@ describe('scanAgents', () => {
324324
const claude = agents.find((a) => a.id === 'claude-code')!
325325
expect(claude.localSkillCount).toBe(0)
326326
})
327+
328+
it('falls back to zero skill counts when an existing agent dir cannot be read', async () => {
329+
// Arrange
330+
// Agent dir passes the existence probe (access resolves)...
331+
accessMock.mockResolvedValue(undefined)
332+
// ...but enumerating its contents fails (e.g. EACCES / transient I/O error),
333+
// so both the symlink tally and the local-folder tally must degrade to 0
334+
// instead of throwing and crashing the whole scan.
335+
readdirMock.mockImplementation(async (path: string) => {
336+
if (path === '/mock/agents/claude/skills') {
337+
throw new Error('EACCES: permission denied')
338+
}
339+
return []
340+
})
341+
342+
// Act
343+
const { scanAgents } = await import('./agentScanner')
344+
const agents = await scanAgents()
345+
346+
// Assert
347+
const claude = agents.find((a) => a.id === 'claude-code')!
348+
expect(claude.exists).toBe(true)
349+
expect(claude.skillCount).toBe(0)
350+
expect(claude.localSkillCount).toBe(0)
351+
})
352+
353+
it('omits filesystem identity for an agent whose directory stats cannot be read', async () => {
354+
// Arrange
355+
// Agent dir exists and is empty, but lstat fails after the existence probe
356+
// (e.g. the dir is unstattable), so filesystemIdentity must be left
357+
// undefined rather than propagating the lstat rejection.
358+
accessMock.mockResolvedValue(undefined)
359+
readdirMock.mockResolvedValue([])
360+
lstatMock.mockRejectedValue(new Error('EACCES: permission denied'))
361+
362+
// Act
363+
const { scanAgents } = await import('./agentScanner')
364+
const agents = await scanAgents()
365+
366+
// Assert
367+
const claude = agents.find((a) => a.id === 'claude-code')!
368+
expect(claude.exists).toBe(true)
369+
expect(claude.filesystemIdentity).toBeUndefined()
370+
})
327371
})

src/main/services/cliCommandService.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as nodeFs from 'node:fs'
12
import { mkdtempSync, realpathSync } from 'node:fs'
23
import {
34
lstat,
@@ -31,6 +32,8 @@ const servicePromise = (async () => import('./cliCommandService'))()
3132

3233
describe('cliCommandService', () => {
3334
afterEach(async () => {
35+
// Drop any per-test fs.promises spies so later tests hit the real filesystem.
36+
vi.restoreAllMocks()
3437
await rm(join(sharedHome, '.local'), { recursive: true, force: true })
3538
})
3639

@@ -173,4 +176,116 @@ exec open -b "io.laststance.skills-desktop"
173176
})
174177
expect((await lstat(commandPath)).isSymbolicLink()).toBe(true)
175178
})
179+
180+
it('blocks management when a directory squats on the command path', async () => {
181+
// Arrange
182+
const { getCliCommandStatus } = await servicePromise
183+
await mkdir(commandPath, { recursive: true })
184+
185+
// Act
186+
const status = await getCliCommandStatus()
187+
188+
// Assert
189+
expect(status).toEqual({
190+
status: 'blocked',
191+
commandName: 'skills-desktop',
192+
commandPath,
193+
message: `${commandPath} is already occupied by another filesystem entry.`,
194+
})
195+
})
196+
197+
it('blocks management when the command path cannot be inspected', async () => {
198+
// Arrange
199+
const { getCliCommandStatus } = await servicePromise
200+
// Make ~/.local/bin a regular file so lstat-ing a child path throws ENOTDIR.
201+
await mkdir(join(sharedHome, '.local'), { recursive: true })
202+
await writeFile(
203+
join(sharedHome, '.local', 'bin'),
204+
'not a directory',
205+
'utf-8',
206+
)
207+
208+
// Act
209+
const status = await getCliCommandStatus()
210+
211+
// Assert
212+
expect(status.status).toBe('blocked')
213+
expect(status.commandName).toBe('skills-desktop')
214+
expect(status.commandPath).toBe(commandPath)
215+
expect(status.message).toContain(`Could not inspect ${commandPath}:`)
216+
})
217+
218+
it('reports success without rewriting when the command is already installed', async () => {
219+
// Arrange
220+
const { installCliCommand } = await servicePromise
221+
await installCliCommand()
222+
223+
// Act
224+
const result = await installCliCommand()
225+
226+
// Assert
227+
expect(result).toEqual({
228+
ok: true,
229+
status: {
230+
status: 'installed',
231+
commandName: 'skills-desktop',
232+
commandPath,
233+
message: 'Command is installed.',
234+
},
235+
message: 'Command is already installed.',
236+
})
237+
})
238+
239+
it('surfaces a failure message when writing the shim throws', async () => {
240+
// Arrange
241+
const { installCliCommand } = await servicePromise
242+
vi.spyOn(nodeFs.promises, 'writeFile').mockRejectedValueOnce(
243+
new Error('disk full'),
244+
)
245+
246+
// Act
247+
const result = await installCliCommand()
248+
249+
// Assert
250+
expect(result.ok).toBe(false)
251+
expect(result.status.status).toBe('not-installed')
252+
expect(result.message).toBe('Could not install command: disk full')
253+
})
254+
255+
it('reports success when the command is already absent', async () => {
256+
// Arrange
257+
const { removeCliCommand } = await servicePromise
258+
259+
// Act
260+
const result = await removeCliCommand()
261+
262+
// Assert
263+
expect(result).toEqual({
264+
ok: true,
265+
status: {
266+
status: 'not-installed',
267+
commandName: 'skills-desktop',
268+
commandPath,
269+
message: 'Command is not installed.',
270+
},
271+
message: 'Command is already removed.',
272+
})
273+
})
274+
275+
it('surfaces a failure message when deleting the managed shim throws', async () => {
276+
// Arrange
277+
const { installCliCommand, removeCliCommand } = await servicePromise
278+
await installCliCommand()
279+
vi.spyOn(nodeFs.promises, 'unlink').mockRejectedValueOnce(
280+
new Error('disk full'),
281+
)
282+
283+
// Act
284+
const result = await removeCliCommand()
285+
286+
// Assert
287+
expect(result.ok).toBe(false)
288+
expect(result.status.status).toBe('installed')
289+
expect(result.message).toBe('Could not remove command: disk full')
290+
})
176291
})

src/main/services/fileReader.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,53 @@ describe('listSkillFiles', () => {
303303
// Assert
304304
expect(result).toEqual([])
305305
})
306+
307+
it('falls back to an empty listing when traversal throws on a malformed entry', async () => {
308+
// Arrange
309+
// readdir resolves, but the entry's own type-check throws mid-walk —
310+
// this escapes the readdir try/catch and must be swallowed by the
311+
// top-level listSkillFiles guard rather than crashing the caller.
312+
const explodingEntry = {
313+
name: 'corrupt',
314+
isFile: () => false,
315+
isDirectory: () => false,
316+
isSymbolicLink: () => {
317+
throw new Error('EIO: corrupt dirent')
318+
},
319+
}
320+
;(mockFs.readdir as ReturnType<typeof vi.fn>).mockResolvedValue([
321+
explodingEntry,
322+
])
323+
mockStat()
324+
325+
// Act
326+
const result = await listSkillFiles('/skills/my-skill')
327+
328+
// Assert
329+
expect(result).toEqual([])
330+
})
331+
332+
it('drops a file from the listing when its stat call fails', async () => {
333+
// Arrange
334+
mockTree({
335+
'/skills/my-skill': [makeDirent('SKILL.md'), makeDirent('vanished.md')],
336+
})
337+
// SKILL.md stats fine; vanished.md disappears between readdir and stat.
338+
;(mockFs.stat as ReturnType<typeof vi.fn>).mockImplementation(
339+
async (path: string) => {
340+
if (path === '/skills/my-skill/vanished.md') {
341+
throw new Error('ENOENT: stat after readdir')
342+
}
343+
return { size: 100 }
344+
},
345+
)
346+
347+
// Act
348+
const names = (await listSkillFiles('/skills/my-skill')).map((f) => f.name)
349+
350+
// Assert
351+
expect(names).toEqual(['SKILL.md'])
352+
})
306353
})
307354

308355
describe('readSkillFile', () => {

src/main/services/filesystemIdentity.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import type { Stats } from 'node:fs'
2+
13
import { describe, expect, it } from 'vitest'
24

35
import type { FilesystemEntryIdentity } from '@/shared/types'
46

57
import {
8+
filesystemIdentityFromStats,
69
isReviewedEntryUnchangedIdentity,
710
isSameFilesystemEntryIdentity,
811
} from './filesystemIdentity'
@@ -186,4 +189,69 @@ describe('filesystem identity guards for destructive deletes', () => {
186189
expect(isSame).toBe(false)
187190
})
188191
})
192+
193+
describe('filesystemIdentityFromStats (kind discriminant from fs.Stats)', () => {
194+
// A fully-shaped Stats fixture with every predicate defaulting to false, so
195+
// each arm test only flips the single predicate the ternary branches on.
196+
const baseStats = {
197+
isFile: () => false,
198+
isDirectory: () => false,
199+
isBlockDevice: () => false,
200+
isCharacterDevice: () => false,
201+
isSymbolicLink: () => false,
202+
isFIFO: () => false,
203+
isSocket: () => false,
204+
dev: 16777233,
205+
ino: 99,
206+
mode: 0,
207+
nlink: 0,
208+
uid: 0,
209+
gid: 0,
210+
rdev: 0,
211+
size: 96,
212+
blksize: 0,
213+
blocks: 0,
214+
atimeMs: 0,
215+
mtimeMs: 2_000,
216+
ctimeMs: 1_000,
217+
birthtimeMs: 0,
218+
atime: new Date(0),
219+
mtime: new Date(0),
220+
ctime: new Date(0),
221+
birthtime: new Date(0),
222+
} satisfies Stats
223+
224+
it('labels an entry as a symlink so destructive UI treats it as a link, not its target', () => {
225+
// Arrange: lstat saw a symbolic link (directory/file predicates irrelevant).
226+
const symlinkStats = { ...baseStats, isSymbolicLink: () => true }
227+
228+
// Act
229+
const identity = filesystemIdentityFromStats(symlinkStats)
230+
231+
// Assert
232+
expect(identity.kind).toBe('symlink')
233+
})
234+
235+
it('labels a non-symlink directory as a directory so a folder delete is gated as a folder', () => {
236+
// Arrange: a real directory — symlink predicate false, directory predicate true.
237+
const directoryStats = { ...baseStats, isDirectory: () => true }
238+
239+
// Act
240+
const identity = filesystemIdentityFromStats(directoryStats)
241+
242+
// Assert
243+
expect(identity.kind).toBe('directory')
244+
})
245+
246+
it('labels a plain file as a file when it is neither a symlink nor a directory', () => {
247+
// Arrange: a regular file — only isFile is true.
248+
const fileStats = { ...baseStats, isFile: () => true }
249+
250+
// Act
251+
const identity = filesystemIdentityFromStats(fileStats)
252+
253+
// Assert
254+
expect(identity.kind).toBe('file')
255+
})
256+
})
189257
})
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { BrowserWindow } from 'electron'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
import { getMainWindow, setMainWindow } from './mainWindowState'
5+
6+
/**
7+
* Build the minimal BrowserWindow surface `getMainWindow` queries (only
8+
* `isDestroyed`), so node-lane tests can drive the live/destroyed branches
9+
* without instantiating a real Electron window.
10+
* @param isDestroyed - Whether the stub reports itself as destroyed.
11+
* @returns A BrowserWindow-typed stub exposing a controllable `isDestroyed`.
12+
* @example
13+
* setMainWindow(makeWindowStub(false)) // window stays handed out
14+
*/
15+
function makeWindowStub(isDestroyed: boolean): BrowserWindow {
16+
// Only `isDestroyed` is read by the module; cast is necessary because a real
17+
// BrowserWindow cannot be constructed in the node lane.
18+
return {
19+
isDestroyed: vi.fn(() => isDestroyed),
20+
} as unknown as BrowserWindow
21+
}
22+
23+
describe('main window reference store', () => {
24+
beforeEach(() => {
25+
// Reset the module-scoped reference so each spec starts from "no window".
26+
setMainWindow(null)
27+
})
28+
29+
it('reports no window before any main window has been created', () => {
30+
// Arrange — fresh state from beforeEach: no window has been stored
31+
32+
// Act
33+
const currentWindow = getMainWindow()
34+
35+
// Assert
36+
expect(currentWindow).toBeNull()
37+
})
38+
39+
it('hands back the live main window while it is open', () => {
40+
// Arrange
41+
const liveWindow = makeWindowStub(false)
42+
setMainWindow(liveWindow)
43+
44+
// Act
45+
const currentWindow = getMainWindow()
46+
47+
// Assert
48+
expect(currentWindow).toBe(liveWindow)
49+
})
50+
51+
it('stops handing out the window once Electron has destroyed it', () => {
52+
// Arrange
53+
const destroyedWindow = makeWindowStub(true)
54+
setMainWindow(destroyedWindow)
55+
56+
// Act
57+
const currentWindow = getMainWindow()
58+
59+
// Assert
60+
expect(currentWindow).toBeNull()
61+
})
62+
63+
it('clears the stored window when passed null on the close event', () => {
64+
// Arrange
65+
setMainWindow(makeWindowStub(false))
66+
67+
// Act
68+
setMainWindow(null)
69+
70+
// Assert
71+
expect(getMainWindow()).toBeNull()
72+
})
73+
})

0 commit comments

Comments
 (0)