microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
docs/659-role-based-docs-lifecycle-guides

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

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