microsoft/hve-core

Public

mirrored fromhttps://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/skill-validator-python-support

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

scripts/tests/linting/Validate-SkillStructure.Tests.ps1

1414lines · modecode

1# Copyright (c) Microsoft Corporation.
2# SPDX-License-Identifier: MIT
3
4#Requires -Modules Pester
5
6BeforeAll {
7 # Dot-source the main script
8 $scriptPath = Join-Path $PSScriptRoot '../../linting/Validate-SkillStructure.ps1'
9 . $scriptPath
10
11 $mockPath = Join-Path $PSScriptRoot '../Mocks/GitMocks.psm1'
12 Import-Module $mockPath -Force
13
14 # Temp directory for test isolation
15 $script:TempTestDir = Join-Path ([System.IO.Path]::GetTempPath()) "SkillStructureTests_$([guid]::NewGuid().ToString('N'))"
16 New-Item -ItemType Directory -Path $script:TempTestDir -Force | Out-Null
17
18 function New-TestSkillDirectory {
19 param(
20 [string]$SkillName,
21 [string]$FrontmatterContent,
22 [switch]$NoSkillMd,
23 [switch]$WithScriptsDir,
24 [switch]$WithEmptyScriptsDir,
25 [switch]$WithUnrecognizedDir,
26 [switch]$WithPyprojectToml,
27 [switch]$WithPythonScripts,
28 [string]$WithPythonPackageDir,
29 [string[]]$OptionalDirs = @()
30 )
31
32 $skillDir = Join-Path $script:TempTestDir $SkillName
33 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
34
35 if (-not $NoSkillMd) {
36 $skillMdPath = Join-Path $skillDir 'SKILL.md'
37 if ($FrontmatterContent) {
38 Set-Content -Path $skillMdPath -Value $FrontmatterContent
39 }
40 else {
41 Set-Content -Path $skillMdPath -Value '# Test Skill'
42 }
43 }
44
45 if ($WithScriptsDir) {
46 $scriptsDir = Join-Path $skillDir 'scripts'
47 New-Item -ItemType Directory -Path $scriptsDir -Force | Out-Null
48 Set-Content -Path (Join-Path $scriptsDir 'test.sh') -Value '#!/bin/bash'
49 }
50
51 if ($WithEmptyScriptsDir) {
52 New-Item -ItemType Directory -Path (Join-Path $skillDir 'scripts') -Force | Out-Null
53 }
54
55 if ($WithUnrecognizedDir) {
56 New-Item -ItemType Directory -Path (Join-Path $skillDir 'random-dir') -Force | Out-Null
57 }
58
59 if ($WithPyprojectToml) {
60 Set-Content -Path (Join-Path $skillDir 'pyproject.toml') -Value @"
61[project]
62name = "test-python-skill"
63version = "0.0.0"
64requires-python = ">=3.11"
65"@
66 }
67
68 if ($WithPythonScripts) {
69 $scriptsDir = Join-Path $skillDir 'scripts'
70 New-Item -ItemType Directory -Path $scriptsDir -Force | Out-Null
71 Set-Content -Path (Join-Path $scriptsDir 'main.py') -Value '# Python script'
72 }
73
74 if ($WithPythonPackageDir) {
75 $pkgDir = Join-Path $skillDir $WithPythonPackageDir
76 New-Item -ItemType Directory -Path $pkgDir -Force | Out-Null
77 Set-Content -Path (Join-Path $pkgDir '__init__.py') -Value '# Package init'
78 }
79
80 foreach ($dir in $OptionalDirs) {
81 New-Item -ItemType Directory -Path (Join-Path $skillDir $dir) -Force | Out-Null
82 }
83
84 return Get-Item $skillDir
85 }
86}
87
88AfterAll {
89 if ($script:TempTestDir -and (Test-Path $script:TempTestDir)) {
90 Remove-Item -Path $script:TempTestDir -Recurse -Force -ErrorAction SilentlyContinue
91 }
92 Remove-Module CIHelpers -Force -ErrorAction SilentlyContinue
93 Remove-Module GitMocks -Force -ErrorAction SilentlyContinue
94 Remove-Module LintingHelpers -Force -ErrorAction SilentlyContinue
95}
96
97#region Get-SkillFrontmatter Tests
98
99Describe 'Get-SkillFrontmatter' -Tag 'Unit' {
100 Context 'Valid frontmatter' {
101 It 'Returns hashtable for valid frontmatter with name and description' {
102 $content = @"
103---
104name: test-skill
105description: A test skill for validation
106---
107
108# Test Skill
109"@
110 $filePath = Join-Path $script:TempTestDir 'valid-fm.md'
111 Set-Content -Path $filePath -Value $content
112
113 $result = Get-SkillFrontmatter -Path $filePath
114 $result | Should -Not -BeNullOrEmpty
115 $result | Should -BeOfType [hashtable]
116 $result['name'] | Should -BeExactly 'test-skill'
117 $result['description'] | Should -BeExactly 'A test skill for validation'
118 }
119
120 It 'Strips single-quoted values correctly' {
121 $content = @"
122---
123name: 'my-skill'
124description: 'A skill with single quotes - Brought to you by microsoft/hve-core'
125---
126
127# Skill
128"@
129 $filePath = Join-Path $script:TempTestDir 'single-quoted.md'
130 Set-Content -Path $filePath -Value $content
131
132 $result = Get-SkillFrontmatter -Path $filePath
133 $result | Should -Not -BeNullOrEmpty
134 $result['name'] | Should -BeExactly 'my-skill'
135 $result['description'] | Should -BeExactly 'A skill with single quotes - Brought to you by microsoft/hve-core'
136 }
137
138 It 'Strips double-quoted values correctly' {
139 $content = @"
140---
141name: "double-skill"
142description: "A skill with double quotes"
143---
144
145# Skill
146"@
147 $filePath = Join-Path $script:TempTestDir 'double-quoted.md'
148 Set-Content -Path $filePath -Value $content
149
150 $result = Get-SkillFrontmatter -Path $filePath
151 $result | Should -Not -BeNullOrEmpty
152 $result['name'] | Should -BeExactly 'double-skill'
153 $result['description'] | Should -BeExactly 'A skill with double quotes'
154 }
155
156 It 'Returns all fields including optional ones' {
157 $content = @"
158---
159name: advanced-skill
160description: An advanced skill
161user-invocable: true
162argument-hint: provide a URL
163---
164
165# Advanced Skill
166"@
167 $filePath = Join-Path $script:TempTestDir 'optional-fields.md'
168 Set-Content -Path $filePath -Value $content
169
170 $result = Get-SkillFrontmatter -Path $filePath
171 $result | Should -Not -BeNullOrEmpty
172 $result['name'] | Should -BeExactly 'advanced-skill'
173 $result['description'] | Should -BeExactly 'An advanced skill'
174 $result['user-invocable'] | Should -BeExactly 'true'
175 $result['argument-hint'] | Should -BeExactly 'provide a URL'
176 }
177
178 It 'Parses boolean values as strings (regex-based parser)' {
179 $content = @"
180---
181name: bool-skill
182description: Skill with booleans
183user-invocable: false
184---
185
186# Bool Skill
187"@
188 $filePath = Join-Path $script:TempTestDir 'bool-values.md'
189 Set-Content -Path $filePath -Value $content
190
191 $result = Get-SkillFrontmatter -Path $filePath
192 $result | Should -Not -BeNullOrEmpty
193 $result['user-invocable'] | Should -BeOfType [string]
194 $result['user-invocable'] | Should -BeExactly 'false'
195 }
196
197 }
198
199 Context 'Invalid or missing frontmatter' {
200 It 'Returns null for plain markdown without frontmatter' {
201 $content = @"
202# Just a Heading
203
204Some content without frontmatter.
205"@
206 $filePath = Join-Path $script:TempTestDir 'no-frontmatter.md'
207 Set-Content -Path $filePath -Value $content
208
209 $result = Get-SkillFrontmatter -Path $filePath
210 $result | Should -BeNullOrEmpty
211 }
212
213 It 'Returns null for malformed frontmatter (missing closing ---)' {
214 $content = @"
215---
216name: broken-skill
217description: Missing closing delimiter
218
219# Some content
220"@
221 $filePath = Join-Path $script:TempTestDir 'malformed-fm.md'
222 Set-Content -Path $filePath -Value $content
223
224 $result = Get-SkillFrontmatter -Path $filePath
225 $result | Should -BeNullOrEmpty
226 }
227
228 It 'Returns null for empty file' {
229 $filePath = Join-Path $script:TempTestDir 'empty.md'
230 Set-Content -Path $filePath -Value ''
231
232 $result = Get-SkillFrontmatter -Path $filePath
233 $result | Should -BeNullOrEmpty
234 }
235
236 It 'Returns null when file does not exist' {
237 $filePath = Join-Path $script:TempTestDir 'nonexistent-file.md'
238
239 $result = Get-SkillFrontmatter -Path $filePath
240 $result | Should -BeNullOrEmpty
241 }
242
243 It 'Returns null for frontmatter block with no valid key-value pairs' {
244 $content = @"
245---
246 just some random text
247 no key value pairs here
248---
249
250# Content
251"@
252 $filePath = Join-Path $script:TempTestDir 'no-kv-pairs.md'
253 Set-Content -Path $filePath -Value $content
254
255 $result = Get-SkillFrontmatter -Path $filePath
256 $result | Should -BeNullOrEmpty
257 }
258 }
259}
260
261#endregion
262
263#region Test-SkillDirectory Tests
264
265Describe 'Test-SkillDirectory' -Tag 'Unit' {
266 BeforeAll {
267 $script:SkillTestDir = Join-Path $script:TempTestDir 'skill-dir-tests'
268 New-Item -ItemType Directory -Path $script:SkillTestDir -Force | Out-Null
269
270 # Override TempTestDir for fixture helper within this Describe
271 $script:TempTestDir = $script:SkillTestDir
272 }
273
274 AfterAll {
275 $script:TempTestDir = (Split-Path $script:SkillTestDir -Parent)
276 }
277
278 Context 'Valid skill directory' {
279 It 'Passes validation with proper SKILL.md and matching name' {
280 $frontmatter = @"
281---
282name: test-skill
283description: 'A test skill for validation - Brought to you by microsoft/hve-core'
284---
285
286# Test Skill
287"@
288 $dir = New-TestSkillDirectory -SkillName 'test-skill' -FrontmatterContent $frontmatter
289
290 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
291 $result.IsValid | Should -BeTrue
292 $result.Errors | Should -HaveCount 0
293 $result.Warnings | Should -HaveCount 0
294 $result.SkillName | Should -BeExactly 'test-skill'
295 }
296
297 It 'Passes with valid optional directories and no warnings' {
298 $frontmatter = @"
299---
300name: dirs-skill
301description: 'Skill with optional dirs'
302---
303
304# Dirs Skill
305"@
306 $dir = New-TestSkillDirectory -SkillName 'dirs-skill' -FrontmatterContent $frontmatter -OptionalDirs @('scripts', 'references', 'assets', 'examples')
307 # Add both script types so scripts/ passes validation
308 Set-Content -Path (Join-Path $dir.FullName 'scripts/run.sh') -Value '#!/bin/bash'
309 Set-Content -Path (Join-Path $dir.FullName 'scripts/run.ps1') -Value 'Write-Host "hello"'
310
311 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
312 $result.IsValid | Should -BeTrue
313 $result.Warnings | Should -HaveCount 0
314 }
315 }
316
317 Context 'Missing SKILL.md' {
318 It 'Reports error when SKILL.md is missing' {
319 $dir = New-TestSkillDirectory -SkillName 'no-skillmd' -NoSkillMd
320
321 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
322 $result.IsValid | Should -BeFalse
323 $result.Errors | Should -HaveCount 1
324 $result.Errors[0] | Should -BeLike '*SKILL.md is missing*'
325 }
326 }
327
328 Context 'Frontmatter issues' {
329 It 'Reports error when SKILL.md has no frontmatter' {
330 $dir = New-TestSkillDirectory -SkillName 'no-fm-skill'
331 # Default content is just "# Test Skill" without frontmatter
332
333 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
334 $result.IsValid | Should -BeFalse
335 $result.Errors | Should -HaveCount 1
336 $result.Errors[0] | Should -BeLike '*missing or malformed frontmatter*'
337 }
338
339 It 'Reports error when frontmatter is missing name field' {
340 $frontmatter = @"
341---
342description: 'A skill without a name'
343---
344
345# No Name
346"@
347 $dir = New-TestSkillDirectory -SkillName 'missing-name' -FrontmatterContent $frontmatter
348
349 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
350 $result.IsValid | Should -BeFalse
351 $result.Errors | Should -Contain ($result.Errors | Where-Object { $_ -like "*missing required 'name'*" })
352 }
353
354 It 'Reports error when frontmatter is missing description field' {
355 $frontmatter = @"
356---
357name: missing-desc
358---
359
360# Missing Desc
361"@
362 $dir = New-TestSkillDirectory -SkillName 'missing-desc' -FrontmatterContent $frontmatter
363
364 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
365 $result.IsValid | Should -BeFalse
366 $result.Errors | Should -Contain ($result.Errors | Where-Object { $_ -like "*missing required 'description'*" })
367 }
368
369 It 'Reports error when name does not match directory name' {
370 $frontmatter = @"
371---
372name: wrong-name
373description: 'Mismatched name skill'
374---
375
376# Wrong Name
377"@
378 $dir = New-TestSkillDirectory -SkillName 'actual-name' -FrontmatterContent $frontmatter
379
380 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
381 $result.IsValid | Should -BeFalse
382 $result.Errors | Should -Contain ($result.Errors | Where-Object { $_ -like "*does not match directory name*" })
383 }
384
385 It 'Reports both errors when name and description are missing' {
386 $frontmatter = @"
387---
388some-other-key: value
389---
390
391# Both Missing
392"@
393 $dir = New-TestSkillDirectory -SkillName 'both-missing' -FrontmatterContent $frontmatter
394
395 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
396 $result.IsValid | Should -BeFalse
397 $result.Errors.Count | Should -BeGreaterOrEqual 2
398 $result.Errors | Where-Object { $_ -like "*missing required 'name'*" } | Should -Not -BeNullOrEmpty
399 $result.Errors | Where-Object { $_ -like "*missing required 'description'*" } | Should -Not -BeNullOrEmpty
400 }
401
402 It 'Reports error when name is empty string' {
403 $frontmatter = @"
404---
405name: ''
406description: 'Has empty name'
407---
408
409# Empty Name
410"@
411 $dir = New-TestSkillDirectory -SkillName 'empty-name' -FrontmatterContent $frontmatter
412
413 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
414 $result.IsValid | Should -BeFalse
415 $result.Errors | Where-Object { $_ -like "*missing required 'name'*" } | Should -Not -BeNullOrEmpty
416 }
417 }
418
419 Context 'Scripts subdirectory checks' {
420 It 'Reports error when scripts/ directory is empty (no .ps1 or .sh files)' {
421 $frontmatter = @"
422---
423name: empty-scripts
424description: 'Skill with empty scripts dir'
425---
426
427# Empty Scripts
428"@
429 $dir = New-TestSkillDirectory -SkillName 'empty-scripts' -FrontmatterContent $frontmatter -WithEmptyScriptsDir
430
431 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
432 $result.IsValid | Should -BeFalse
433 $result.Errors | Should -HaveCount 1
434 $result.Errors[0] | Should -BeLike '*scripts*no .ps1 or .sh*'
435 }
436
437 It 'Reports error when scripts/ contains only .sh file (missing .ps1)' {
438 $frontmatter = @"
439---
440name: sh-only-scripts
441description: 'Skill with sh script only'
442---
443
444# SH Only Scripts
445"@
446 $dir = New-TestSkillDirectory -SkillName 'sh-only-scripts' -FrontmatterContent $frontmatter -WithScriptsDir
447
448 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
449 $result.IsValid | Should -BeFalse
450 $result.Errors | Should -HaveCount 1
451 $result.Errors[0] | Should -BeLike '*scripts*missing a required .ps1*'
452 }
453
454 It 'Reports error when scripts/ contains only .ps1 file (missing .sh)' {
455 $frontmatter = @"
456---
457name: ps1-only-scripts
458description: 'Skill with ps1 script only'
459---
460
461# PS1 Only Scripts
462"@
463 $dir = New-TestSkillDirectory -SkillName 'ps1-only-scripts' -FrontmatterContent $frontmatter
464 $scriptsDir = Join-Path $dir.FullName 'scripts'
465 New-Item -ItemType Directory -Path $scriptsDir -Force | Out-Null
466 Set-Content -Path (Join-Path $scriptsDir 'run.ps1') -Value 'Write-Host "hello"'
467
468 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
469 $result.IsValid | Should -BeFalse
470 $result.Errors | Should -HaveCount 1
471 $result.Errors[0] | Should -BeLike '*scripts*missing a required .sh*'
472 }
473
474 It 'Passes when scripts/ contains both .ps1 and .sh files' {
475 $frontmatter = @"
476---
477name: both-scripts
478description: 'Skill with both script types'
479---
480
481# Both Scripts
482"@
483 $dir = New-TestSkillDirectory -SkillName 'both-scripts' -FrontmatterContent $frontmatter
484 $scriptsDir = Join-Path $dir.FullName 'scripts'
485 New-Item -ItemType Directory -Path $scriptsDir -Force | Out-Null
486 Set-Content -Path (Join-Path $scriptsDir 'run.ps1') -Value 'Write-Host "hello"'
487 Set-Content -Path (Join-Path $scriptsDir 'run.sh') -Value '#!/bin/bash'
488
489 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
490 $result.IsValid | Should -BeTrue
491 $result.Errors | Should -HaveCount 0
492 }
493
494 It 'Passes when no scripts/ directory exists (scripts are optional)' {
495 $frontmatter = @"
496---
497name: no-scripts-dir
498description: 'Skill without scripts directory'
499---
500
501# No Scripts Dir
502"@
503 $dir = New-TestSkillDirectory -SkillName 'no-scripts-dir' -FrontmatterContent $frontmatter
504
505 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
506 $result.IsValid | Should -BeTrue
507 $result.Errors | Should -HaveCount 0
508 }
509 }
510
511 Context 'Unrecognized subdirectories' {
512 It 'Warns about unrecognized subdirectory' {
513 $frontmatter = @"
514---
515name: unrecognized-dir
516description: 'Skill with unknown dir'
517---
518
519# Unrecognized Dir
520"@
521 $dir = New-TestSkillDirectory -SkillName 'unrecognized-dir' -FrontmatterContent $frontmatter -WithUnrecognizedDir
522
523 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
524 $result.IsValid | Should -BeTrue
525 $result.Warnings | Should -HaveCount 1
526 $result.Warnings[0] | Should -BeLike "*Unrecognized subdirectory 'random-dir'*"
527 }
528
529 It 'Does not warn about recognized optional directories' {
530 $frontmatter = @"
531---
532name: recognized-dirs
533description: 'Skill with recognized dirs'
534---
535
536# Recognized Dirs
537"@
538 $dir = New-TestSkillDirectory -SkillName 'recognized-dirs' -FrontmatterContent $frontmatter -OptionalDirs @('scripts', 'references', 'assets', 'examples')
539 # Add both script types so scripts/ passes validation
540 Set-Content -Path (Join-Path $dir.FullName 'scripts/run.sh') -Value '#!/bin/bash'
541 Set-Content -Path (Join-Path $dir.FullName 'scripts/run.ps1') -Value 'Write-Host "hello"'
542
543 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
544 $result.IsValid | Should -BeTrue
545 $result.Warnings | Should -HaveCount 0
546 }
547
548 It 'Does not warn about tests/ subdirectory' {
549 $frontmatter = @"
550---
551name: tests-dir-skill
552description: 'Skill with co-located tests directory'
553---
554
555# Tests Dir Skill
556"@
557 $dir = New-TestSkillDirectory -SkillName 'tests-dir-skill' -FrontmatterContent $frontmatter -OptionalDirs @('tests')
558
559 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
560 $result.IsValid | Should -BeTrue
561 $result.Warnings | Should -HaveCount 0
562 }
563 }
564
565 Context 'Result object structure' {
566 It 'Returns correct SkillPath as relative path' {
567 $frontmatter = @"
568---
569name: path-check
570description: 'Path check skill'
571---
572
573# Path Check
574"@
575 $dir = New-TestSkillDirectory -SkillName 'path-check' -FrontmatterContent $frontmatter
576
577 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
578 $result.SkillPath | Should -BeExactly 'path-check'
579 }
580 }
581}
582
583#endregion
584
585#region Get-ChangedSkillDirectories Tests
586
587Describe 'Get-ChangedSkillDirectories' -Tag 'Unit' {
588 Context 'Changed files in skill directories' {
589 It 'Returns skill name for changed file in skill directory' {
590 Mock Get-ChangedFilesFromGit {
591 return @('.github/skills/video-to-gif/SKILL.md')
592 }
593
594 $result = Get-ChangedSkillDirectories -BaseBranch 'origin/main' -SkillsPath '.github/skills'
595 $result | Should -Contain 'video-to-gif'
596 }
597
598 It 'Returns empty when changed files are outside skills directory' {
599 Mock Get-ChangedFilesFromGit {
600 return @('scripts/linting/Test.ps1', 'docs/README.md')
601 }
602
603 $result = Get-ChangedSkillDirectories -BaseBranch 'origin/main' -SkillsPath '.github/skills'
604 @($result).Count | Should -Be 0
605 }
606
607 It 'Returns unique skill name for multiple changed files in same skill' {
608 Mock Get-ChangedFilesFromGit {
609 return @(
610 '.github/skills/my-skill/SKILL.md',
611 '.github/skills/my-skill/scripts/run.sh',
612 '.github/skills/my-skill/references/doc.md'
613 )
614 }
615
616 $result = Get-ChangedSkillDirectories -BaseBranch 'origin/main' -SkillsPath '.github/skills'
617 $result | Should -HaveCount 1
618 $result | Should -Contain 'my-skill'
619 }
620
621 It 'Returns empty when no files are changed' {
622 Mock Get-ChangedFilesFromGit { return @() }
623
624 $result = Get-ChangedSkillDirectories -BaseBranch 'origin/main' -SkillsPath '.github/skills'
625 @($result).Count | Should -Be 0
626 }
627
628 It 'Returns multiple skill names for changes across different skills' {
629 Mock Get-ChangedFilesFromGit {
630 return @(
631 '.github/skills/skill-a/SKILL.md',
632 '.github/skills/skill-b/scripts/run.sh'
633 )
634 }
635
636 $result = Get-ChangedSkillDirectories -BaseBranch 'origin/main' -SkillsPath '.github/skills'
637 $result | Should -HaveCount 2
638 $result | Should -Contain 'skill-a'
639 $result | Should -Contain 'skill-b'
640 }
641 }
642
643 Context 'Delegation to Get-ChangedFilesFromGit' {
644 It 'Passes BaseBranch to Get-ChangedFilesFromGit' {
645 Mock Get-ChangedFilesFromGit { return @() }
646
647 Get-ChangedSkillDirectories -BaseBranch 'develop' -SkillsPath '.github/skills'
648 Should -Invoke Get-ChangedFilesFromGit -Times 1 -ParameterFilter {
649 $BaseBranch -eq 'develop'
650 }
651 }
652 }
653
654 Context 'Path normalization' {
655 It 'Handles backslash paths in changed files' {
656 Mock Get-ChangedFilesFromGit {
657 return @('.github\skills\backslash-skill\SKILL.md')
658 }
659
660 $result = Get-ChangedSkillDirectories -BaseBranch 'origin/main' -SkillsPath '.github/skills'
661 $result | Should -Contain 'backslash-skill'
662 }
663 }
664}
665
666#endregion
667
668#region Write-SkillValidationResults Tests
669
670Describe 'Write-SkillValidationResults' -Tag 'Unit' {
671 BeforeAll {
672 $script:ResultsTestDir = Join-Path $script:TempTestDir 'results-tests'
673 New-Item -ItemType Directory -Path $script:ResultsTestDir -Force | Out-Null
674
675 # Clear CI env so Test-CIEnvironment returns false
676 Clear-MockCIEnvironment
677 }
678
679 Context 'JSON output' {
680 It 'Creates JSON file in logs directory for passing results' {
681 $repoRoot = Join-Path $script:ResultsTestDir 'pass-repo'
682 New-Item -ItemType Directory -Path $repoRoot -Force | Out-Null
683
684 $results = @(
685 [PSCustomObject]@{
686 SkillName = 'passing-skill'
687 SkillPath = '.github/skills/passing-skill'
688 IsValid = $true
689 Errors = [string[]]@()
690 Warnings = [string[]]@()
691 }
692 )
693
694 Write-SkillValidationResults -Results $results -RepoRoot $repoRoot
695
696 $jsonPath = Join-Path $repoRoot 'logs/skill-validation-results.json'
697 Test-Path $jsonPath | Should -BeTrue
698
699 $json = Get-Content $jsonPath -Raw | ConvertFrom-Json
700 $json.totalSkills | Should -Be 1
701 $json.skillErrors | Should -Be 0
702 $json.skillWarnings | Should -Be 0
703 $json.results[0].skillName | Should -BeExactly 'passing-skill'
704 $json.results[0].isValid | Should -BeTrue
705 }
706
707 It 'Creates JSON file with error details for failing results' {
708 $repoRoot = Join-Path $script:ResultsTestDir 'fail-repo'
709 New-Item -ItemType Directory -Path $repoRoot -Force | Out-Null
710
711 $results = @(
712 [PSCustomObject]@{
713 SkillName = 'failing-skill'
714 SkillPath = '.github/skills/failing-skill'
715 IsValid = $false
716 Errors = [string[]]@('SKILL.md is missing')
717 Warnings = [string[]]@()
718 },
719 [PSCustomObject]@{
720 SkillName = 'warning-skill'
721 SkillPath = '.github/skills/warning-skill'
722 IsValid = $true
723 Errors = [string[]]@()
724 Warnings = [string[]]@('Unrecognized subdirectory')
725 }
726 )
727
728 Write-SkillValidationResults -Results $results -RepoRoot $repoRoot
729
730 $jsonPath = Join-Path $repoRoot 'logs/skill-validation-results.json'
731 Test-Path $jsonPath | Should -BeTrue
732
733 $json = Get-Content $jsonPath -Raw | ConvertFrom-Json
734 $json.totalSkills | Should -Be 2
735 $json.skillErrors | Should -Be 1
736 $json.skillWarnings | Should -Be 1
737 $json.results[0].isValid | Should -BeFalse
738 $json.results[0].errors | Should -HaveCount 1
739 }
740
741 It 'Creates logs directory if it does not exist' {
742 $repoRoot = Join-Path $script:ResultsTestDir 'new-logs-repo'
743 New-Item -ItemType Directory -Path $repoRoot -Force | Out-Null
744 # Ensure logs dir does not exist
745 $logsDir = Join-Path $repoRoot 'logs'
746 if (Test-Path $logsDir) {
747 Remove-Item $logsDir -Recurse -Force
748 }
749
750 $results = @(
751 [PSCustomObject]@{
752 SkillName = 'create-logs'
753 SkillPath = '.github/skills/create-logs'
754 IsValid = $true
755 Errors = [string[]]@()
756 Warnings = [string[]]@()
757 }
758 )
759
760 Write-SkillValidationResults -Results $results -RepoRoot $repoRoot
761
762 Test-Path $logsDir | Should -BeTrue
763 Test-Path (Join-Path $logsDir 'skill-validation-results.json') | Should -BeTrue
764 }
765
766 It 'Includes timestamp in JSON output' {
767 $repoRoot = Join-Path $script:ResultsTestDir 'timestamp-repo'
768 New-Item -ItemType Directory -Path $repoRoot -Force | Out-Null
769
770 $results = @(
771 [PSCustomObject]@{
772 SkillName = 'ts-skill'
773 SkillPath = '.github/skills/ts-skill'
774 IsValid = $true
775 Errors = [string[]]@()
776 Warnings = [string[]]@()
777 }
778 )
779
780 Write-SkillValidationResults -Results $results -RepoRoot $repoRoot
781
782 $jsonPath = Join-Path $repoRoot 'logs/skill-validation-results.json'
783 $json = Get-Content $jsonPath -Raw | ConvertFrom-Json
784 $json.timestamp | Should -Not -BeNullOrEmpty
785 }
786 }
787
788 Context 'CI annotations' {
789 It 'Emits CI annotations when in CI environment' {
790 $repoRoot = Join-Path $script:ResultsTestDir 'ci-repo'
791 New-Item -ItemType Directory -Path $repoRoot -Force | Out-Null
792 $mockFiles = Initialize-MockCIEnvironment
793
794 try {
795 $results = @(
796 [PSCustomObject]@{
797 SkillName = 'ci-fail'
798 SkillPath = '.github/skills/ci-fail'
799 IsValid = $false
800 Errors = [string[]]@('Missing SKILL.md')
801 Warnings = [string[]]@('Empty scripts dir')
802 }
803 )
804
805 # Capture all output; CI annotations go to stdout via Write-Output
806 $null = Write-SkillValidationResults -Results $results -RepoRoot $repoRoot 6>&1
807
808 $jsonPath = Join-Path $repoRoot 'logs/skill-validation-results.json'
809 Test-Path $jsonPath | Should -BeTrue
810 }
811 finally {
812 Clear-MockCIEnvironment
813 Remove-MockCIFiles -MockFiles $mockFiles
814 }
815 }
816 }
817}
818
819#endregion
820
821#region Console output verification
822
823Describe 'Write-SkillValidationResults console output' -Tag 'Unit' {
824 BeforeAll {
825 Clear-MockCIEnvironment
826 }
827
828 Context 'Status indicators' {
829 It 'Shows green check for fully passing skill' {
830 $repoRoot = Join-Path $script:TempTestDir 'console-pass'
831 New-Item -ItemType Directory -Path $repoRoot -Force | Out-Null
832
833 $results = @(
834 [PSCustomObject]@{
835 SkillName = 'good-skill'
836 SkillPath = '.github/skills/good-skill'
837 IsValid = $true
838 Errors = [string[]]@()
839 Warnings = [string[]]@()
840 }
841 )
842
843 # Should not throw
844 { Write-SkillValidationResults -Results $results -RepoRoot $repoRoot } | Should -Not -Throw
845 }
846
847 It 'Shows warning indicator for skill with warnings only' {
848 $repoRoot = Join-Path $script:TempTestDir 'console-warn'
849 New-Item -ItemType Directory -Path $repoRoot -Force | Out-Null
850
851 $results = @(
852 [PSCustomObject]@{
853 SkillName = 'warn-skill'
854 SkillPath = '.github/skills/warn-skill'
855 IsValid = $true
856 Errors = [string[]]@()
857 Warnings = [string[]]@('Some warning')
858 }
859 )
860
861 { Write-SkillValidationResults -Results $results -RepoRoot $repoRoot } | Should -Not -Throw
862 }
863
864 It 'Shows error indicator for failing skill' {
865 $repoRoot = Join-Path $script:TempTestDir 'console-fail'
866 New-Item -ItemType Directory -Path $repoRoot -Force | Out-Null
867
868 $results = @(
869 [PSCustomObject]@{
870 SkillName = 'bad-skill'
871 SkillPath = '.github/skills/bad-skill'
872 IsValid = $false
873 Errors = [string[]]@('Something broke')
874 Warnings = [string[]]@()
875 }
876 )
877
878 { Write-SkillValidationResults -Results $results -RepoRoot $repoRoot } | Should -Not -Throw
879 }
880 }
881}
882
883#endregion
884
885#region Invoke-SkillStructureValidation Tests
886
887Describe 'Invoke-SkillStructureValidation' -Tag 'Unit' {
888 BeforeAll {
889 $script:ValidationDir = Join-Path ([System.IO.Path]::GetTempPath()) "SkillValidation_$([guid]::NewGuid().ToString('N'))"
890 New-Item -ItemType Directory -Path $script:ValidationDir -Force | Out-Null
891
892 # Clear CI env to avoid annotation output interference
893 Clear-MockCIEnvironment
894 }
895
896 AfterAll {
897 if ($script:ValidationDir -and (Test-Path $script:ValidationDir)) {
898 Remove-Item -Path $script:ValidationDir -Recurse -Force -ErrorAction SilentlyContinue
899 }
900 }
901
902 Context 'Skills directory does not exist' {
903 It 'Returns 0 when skills path does not exist' {
904 Mock git {
905 $global:LASTEXITCODE = 0
906 return $script:ValidationDir
907 } -ParameterFilter { $args[0] -eq 'rev-parse' }
908
909 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'nonexistent-skills'
910 $exitCode | Should -Be 0
911 }
912 }
913
914 Context 'Empty skills directory' {
915 It 'Returns 0 when skills directory has no subdirectories' {
916 $emptyDir = Join-Path $script:ValidationDir 'empty-skills'
917 New-Item -ItemType Directory -Path $emptyDir -Force | Out-Null
918
919 Mock git {
920 $global:LASTEXITCODE = 0
921 return $script:ValidationDir
922 } -ParameterFilter { $args[0] -eq 'rev-parse' }
923
924 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'empty-skills'
925 $exitCode | Should -Be 0
926 }
927 }
928
929 Context 'Valid skill directories' {
930 It 'Returns 0 for a valid skill with proper SKILL.md' {
931 $skillsDir = Join-Path $script:ValidationDir 'valid-skills'
932 $skillDir = Join-Path $skillsDir 'good-skill'
933 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
934 $content = @"
935---
936name: good-skill
937description: 'A valid skill for integration testing'
938---
939
940# Good Skill
941"@
942 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value $content
943
944 Mock git {
945 $global:LASTEXITCODE = 0
946 return $script:ValidationDir
947 } -ParameterFilter { $args[0] -eq 'rev-parse' }
948
949 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'valid-skills'
950 $exitCode | Should -Be 0
951 }
952 }
953
954 Context 'Nested collection-based skill directories' {
955 It 'Returns 0 for a valid skill nested under a collection-id directory' {
956 $skillsDir = Join-Path $script:ValidationDir 'nested-valid'
957 $skillDir = Join-Path $skillsDir 'my-collection/my-nested-skill'
958 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
959 $content = @"
960---
961name: my-nested-skill
962description: 'A valid nested skill under a collection directory'
963---
964
965# My Nested Skill
966"@
967 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value $content
968
969 Mock git {
970 $global:LASTEXITCODE = 0
971 return $script:ValidationDir
972 } -ParameterFilter { $args[0] -eq 'rev-parse' }
973
974 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'nested-valid'
975 $exitCode | Should -Be 0
976 }
977 }
978
979 Context 'Invalid skill directories' {
980 It 'Returns 0 when a directory has no SKILL.md (file-driven discovery)' {
981 $skillsDir = Join-Path $script:ValidationDir 'invalid-missing'
982 $skillDir = Join-Path $skillsDir 'broken-skill'
983 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
984
985 Mock git {
986 $global:LASTEXITCODE = 0
987 return $script:ValidationDir
988 } -ParameterFilter { $args[0] -eq 'rev-parse' }
989
990 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'invalid-missing'
991 $exitCode | Should -Be 0
992 }
993
994 It 'Returns 1 when SKILL.md has no frontmatter' {
995 $skillsDir = Join-Path $script:ValidationDir 'invalid-nofm'
996 $skillDir = Join-Path $skillsDir 'no-fm-skill'
997 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
998 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value '# Just a heading'
999
1000 Mock git {
1001 $global:LASTEXITCODE = 0
1002 return $script:ValidationDir
1003 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1004
1005 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'invalid-nofm'
1006 $exitCode | Should -Be 1
1007 }
1008
1009 It 'Returns 1 when frontmatter name does not match directory' {
1010 $skillsDir = Join-Path $script:ValidationDir 'invalid-mismatch'
1011 $skillDir = Join-Path $skillsDir 'real-name'
1012 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
1013 $content = @"
1014---
1015name: wrong-name
1016description: 'Mismatched name'
1017---
1018
1019# Wrong Name
1020"@
1021 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value $content
1022
1023 Mock git {
1024 $global:LASTEXITCODE = 0
1025 return $script:ValidationDir
1026 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1027
1028 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'invalid-mismatch'
1029 $exitCode | Should -Be 1
1030 }
1031 }
1032
1033 Context 'WarningsAsErrors flag' {
1034 It 'Returns 1 when WarningsAsErrors and skill has warnings' {
1035 $skillsDir = Join-Path $script:ValidationDir 'warn-as-error'
1036 $skillDir = Join-Path $skillsDir 'warn-skill'
1037 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
1038 $content = @"
1039---
1040name: warn-skill
1041description: 'Skill with unrecognized dir'
1042---
1043
1044# Warn Skill
1045"@
1046 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value $content
1047 New-Item -ItemType Directory -Path (Join-Path $skillDir 'custom-dir') -Force | Out-Null
1048
1049 Mock git {
1050 $global:LASTEXITCODE = 0
1051 return $script:ValidationDir
1052 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1053
1054 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'warn-as-error' -WarningsAsErrors
1055 $exitCode | Should -Be 1
1056 }
1057
1058 It 'Returns 0 when WarningsAsErrors but no warnings' {
1059 $skillsDir = Join-Path $script:ValidationDir 'nowarn'
1060 $skillDir = Join-Path $skillsDir 'clean-skill'
1061 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
1062 $content = @"
1063---
1064name: clean-skill
1065description: 'Clean skill with no warnings'
1066---
1067
1068# Clean Skill
1069"@
1070 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value $content
1071
1072 Mock git {
1073 $global:LASTEXITCODE = 0
1074 return $script:ValidationDir
1075 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1076
1077 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'nowarn' -WarningsAsErrors
1078 $exitCode | Should -Be 0
1079 }
1080 }
1081
1082 Context 'ChangedFilesOnly mode' {
1083 It 'Returns 0 when no skill files changed' {
1084 Mock git {
1085 $global:LASTEXITCODE = 0
1086 return $script:ValidationDir
1087 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1088
1089 Mock Get-ChangedSkillDirectories {
1090 return @()
1091 }
1092
1093 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'skills' -ChangedFilesOnly
1094 $exitCode | Should -Be 0
1095 }
1096
1097 It 'Returns 0 for valid changed skill' {
1098 $skillsDir = Join-Path $script:ValidationDir 'changed-valid'
1099 $skillDir = Join-Path $skillsDir 'my-skill'
1100 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
1101 $content = @"
1102---
1103name: my-skill
1104description: 'Valid changed skill'
1105---
1106
1107# My Skill
1108"@
1109 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value $content
1110
1111 Mock git {
1112 $global:LASTEXITCODE = 0
1113 return $script:ValidationDir
1114 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1115
1116 Mock Get-ChangedSkillDirectories {
1117 return [string[]]@('my-skill')
1118 }
1119
1120 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'changed-valid' -ChangedFilesOnly
1121 $exitCode | Should -Be 0
1122 }
1123
1124 It 'Returns 1 when changed skill has errors' {
1125 $skillsDir = Join-Path $script:ValidationDir 'changed-invalid'
1126 $skillDir = Join-Path $skillsDir 'bad-skill'
1127 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
1128 $content = @"
1129---
1130name: bad-skill
1131---
1132
1133# Bad Skill
1134"@
1135 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value $content
1136
1137 Mock git {
1138 $global:LASTEXITCODE = 0
1139 return $script:ValidationDir
1140 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1141
1142 Mock Get-ChangedSkillDirectories {
1143 return [string[]]@('bad-skill')
1144 }
1145
1146 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'changed-invalid' -ChangedFilesOnly
1147 $exitCode | Should -Be 1
1148 }
1149
1150 It 'Returns 0 and skips validation when changed skill was deleted' {
1151 $skillsDir = Join-Path $script:ValidationDir 'changed-deleted'
1152 New-Item -ItemType Directory -Path $skillsDir -Force | Out-Null
1153
1154 Mock git {
1155 $global:LASTEXITCODE = 0
1156 return $script:ValidationDir
1157 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1158
1159 Mock Get-ChangedSkillDirectories {
1160 return [string[]]@('deleted-skill')
1161 }
1162
1163 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'changed-deleted' -ChangedFilesOnly
1164 $exitCode | Should -Be 0
1165 }
1166 }
1167
1168 Context 'Multiple skills with mixed results' {
1169 It 'Returns 0 when valid skills exist alongside empty directories' {
1170 $skillsDir = Join-Path $script:ValidationDir 'mixed'
1171 New-Item -ItemType Directory -Path $skillsDir -Force | Out-Null
1172
1173 # Valid skill
1174 $validDir = Join-Path $skillsDir 'alpha-skill'
1175 New-Item -ItemType Directory -Path $validDir -Force | Out-Null
1176 $validContent = @"
1177---
1178name: alpha-skill
1179description: 'Valid skill'
1180---
1181
1182# Alpha Skill
1183"@
1184 Set-Content -Path (Join-Path $validDir 'SKILL.md') -Value $validContent
1185
1186 # Empty directory (no SKILL.md) — file-driven discovery skips it
1187 $emptyDir = Join-Path $skillsDir 'beta-skill'
1188 New-Item -ItemType Directory -Path $emptyDir -Force | Out-Null
1189
1190 Mock git {
1191 $global:LASTEXITCODE = 0
1192 return $script:ValidationDir
1193 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1194
1195 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'mixed'
1196 $exitCode | Should -Be 0
1197 }
1198
1199 It 'Returns 1 when at least one skill has frontmatter errors' {
1200 $skillsDir = Join-Path $script:ValidationDir 'mixed-errors'
1201 New-Item -ItemType Directory -Path $skillsDir -Force | Out-Null
1202
1203 # Valid skill
1204 $validDir = Join-Path $skillsDir 'good-skill'
1205 New-Item -ItemType Directory -Path $validDir -Force | Out-Null
1206 $validContent = @"
1207---
1208name: good-skill
1209description: 'Valid skill'
1210---
1211
1212# Good Skill
1213"@
1214 Set-Content -Path (Join-Path $validDir 'SKILL.md') -Value $validContent
1215
1216 # Invalid skill (SKILL.md exists but has bad frontmatter)
1217 $invalidDir = Join-Path $skillsDir 'bad-skill'
1218 New-Item -ItemType Directory -Path $invalidDir -Force | Out-Null
1219 Set-Content -Path (Join-Path $invalidDir 'SKILL.md') -Value '# No frontmatter'
1220
1221 Mock git {
1222 $global:LASTEXITCODE = 0
1223 return $script:ValidationDir
1224 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1225
1226 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'mixed-errors'
1227 $exitCode | Should -Be 1
1228 }
1229 }
1230
1231 Context 'Repo root resolution' {
1232 It 'Uses git rev-parse when available' {
1233 $repoRoot = Join-Path $script:ValidationDir 'git-repo'
1234 $skillsDir = Join-Path $repoRoot '.github/skills'
1235 $skillDir = Join-Path $skillsDir 'repo-skill'
1236 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
1237 $content = @"
1238---
1239name: repo-skill
1240description: 'Skill in git repo'
1241---
1242
1243# Repo Skill
1244"@
1245 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value $content
1246
1247 Mock git {
1248 $global:LASTEXITCODE = 0
1249 return $repoRoot
1250 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1251
1252 $exitCode = Invoke-SkillStructureValidation -SkillsPath '.github/skills'
1253 $exitCode | Should -Be 0
1254 }
1255
1256 It 'Falls back to current directory when git rev-parse fails' {
1257 $repoRoot = Join-Path $script:ValidationDir 'fallback-repo'
1258 $skillsDir = Join-Path $repoRoot 'my-skills'
1259 $skillDir = Join-Path $skillsDir 'fb-skill'
1260 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
1261 $content = @"
1262---
1263name: fb-skill
1264description: 'Fallback skill'
1265---
1266
1267# Fallback Skill
1268"@
1269 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value $content
1270
1271 Mock git {
1272 $global:LASTEXITCODE = 128
1273 return $null
1274 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1275
1276 Push-Location $repoRoot
1277 try {
1278 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'my-skills'
1279 $exitCode | Should -Be 0
1280 }
1281 finally {
1282 Pop-Location
1283 }
1284 }
1285 }
1286
1287 Context 'Error handling' {
1288 It 'Returns 1 when an unexpected error occurs' {
1289 Mock git {
1290 $global:LASTEXITCODE = 0
1291 return $script:ValidationDir
1292 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1293
1294 # Create the skills directory so the first Test-Path passes
1295 $errSkillsDir = Join-Path $script:ValidationDir 'error-skills'
1296 New-Item -ItemType Directory -Path $errSkillsDir -Force | Out-Null
1297
1298 # Mock Get-ChildItem to throw to trigger catch block
1299 Mock Get-ChildItem {
1300 throw 'Simulated filesystem error'
1301 }
1302
1303 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'error-skills' 2>&1 |
1304 Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] }
1305 $exitCode | Should -Contain 1
1306 }
1307 }
1308}
1309
1310Describe 'Python Skill Validation' -Tag 'Unit' {
1311 BeforeAll {
1312 $script:PythonTestDir = Join-Path $script:TempTestDir 'python-tests'
1313 New-Item -ItemType Directory -Path $script:PythonTestDir -Force | Out-Null
1314
1315 # Override TempTestDir for fixture helper within this Describe
1316 $script:TempTestDir = $script:PythonTestDir
1317 }
1318
1319 AfterAll {
1320 $script:TempTestDir = (Split-Path $script:PythonTestDir -Parent)
1321 }
1322
1323 Context 'Python skill with scripts directory' {
1324 It 'Passes validation for Python skill with .py-only scripts directory' {
1325 $dir = New-TestSkillDirectory -SkillName 'py-only-scripts' -WithPyprojectToml -WithPythonScripts -FrontmatterContent @"
1326---
1327name: py-only-scripts
1328description: 'Test Python skill with .py-only scripts'
1329---
1330# Test
1331"@
1332 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:PythonTestDir
1333 $result.IsValid | Should -BeTrue
1334 $result.Errors | Should -HaveCount 0
1335 }
1336
1337 It 'Passes validation for Python skill with hybrid scripts (.py + .ps1 + .sh)' {
1338 $dir = New-TestSkillDirectory -SkillName 'py-hybrid-scripts' -WithPyprojectToml -WithPythonScripts -WithScriptsDir -FrontmatterContent @"
1339---
1340name: py-hybrid-scripts
1341description: 'Test Python skill with hybrid scripts'
1342---
1343# Test
1344"@
1345 Set-Content -Path (Join-Path $dir.FullName 'scripts/helper.ps1') -Value 'Write-Host "hello"'
1346 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:PythonTestDir
1347 $result.IsValid | Should -BeTrue
1348 $result.Errors | Should -HaveCount 0
1349 }
1350
1351 It 'Passes validation for Python skill with no scripts directory' {
1352 $dir = New-TestSkillDirectory -SkillName 'py-no-scripts' -WithPyprojectToml -FrontmatterContent @"
1353---
1354name: py-no-scripts
1355description: 'Test Python skill without scripts dir'
1356---
1357# Test
1358"@
1359 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:PythonTestDir
1360 $result.IsValid | Should -BeTrue
1361 $result.Errors | Should -HaveCount 0
1362 }
1363 }
1364
1365 Context 'Python skill with package directories' {
1366 It 'Produces no warning for Python package directory with __init__.py' {
1367 $dir = New-TestSkillDirectory -SkillName 'py-pkg-dir' -WithPyprojectToml -WithPythonPackageDir 'my_utils' -FrontmatterContent @"
1368---
1369name: py-pkg-dir
1370description: 'Test Python skill with package directory'
1371---
1372# Test
1373"@
1374 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:PythonTestDir
1375 $result.IsValid | Should -BeTrue
1376 $result.Warnings | Should -HaveCount 0
1377 }
1378
1379 It 'Still warns on unrecognized directory without __init__.py in Python skill' {
1380 $dir = New-TestSkillDirectory -SkillName 'py-bad-dir' -WithPyprojectToml -WithUnrecognizedDir -FrontmatterContent @"
1381---
1382name: py-bad-dir
1383description: 'Test Python skill with unrecognized dir'
1384---
1385# Test
1386"@
1387 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:PythonTestDir
1388 $result.IsValid | Should -BeTrue
1389 $result.Warnings | Should -Not -BeNullOrEmpty
1390 $result.Warnings[0] | Should -Match 'Unrecognized subdirectory'
1391 }
1392 }
1393
1394 Context 'Non-Python skill regression guard' {
1395 It 'Fails validation for non-Python skill with .py-only scripts directory' {
1396 $skillName = 'non-py-with-py-scripts'
1397 $dir = New-TestSkillDirectory -SkillName $skillName -FrontmatterContent @"
1398---
1399name: $skillName
1400description: 'Non-Python skill should not accept .py-only scripts'
1401---
1402# Test
1403"@
1404 $scriptsDir = Join-Path $dir.FullName 'scripts'
1405 New-Item -ItemType Directory -Path $scriptsDir -Force | Out-Null
1406 Set-Content -Path (Join-Path $scriptsDir 'orphan.py') -Value '# orphan'
1407 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:PythonTestDir
1408 $result.IsValid | Should -BeFalse
1409 $result.Errors | Should -Not -BeNullOrEmpty
1410 }
1411 }
1412}
1413
1414#endregion
1415