microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/update-workflow-file-and-script

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

1754lines · modecode

1#Requires -Modules Pester
2# Copyright (c) Microsoft Corporation.
3# SPDX-License-Identifier: MIT
4# Import module with 'using' to make PowerShell class types (FileTypeInfo, ValidationSummary, etc.) available at parse time
5using module ..\..\linting\Modules\FrontmatterValidation.psm1
6
7BeforeAll {
8 # Dot-source the main script
9 $scriptPath = Join-Path $PSScriptRoot '../../linting/Validate-MarkdownFrontmatter.ps1'
10 . $scriptPath
11
12 $mockPath = Join-Path $PSScriptRoot '../Mocks/GitMocks.psm1'
13 Import-Module $mockPath -Force
14 $script:SchemaDir = Join-Path $PSScriptRoot '../../linting/schemas'
15 $script:FixtureDir = Join-Path $PSScriptRoot '../Fixtures/Frontmatter'
16 $script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../../..')).Path
17}
18
19#region Get-FileTypeInfo Tests
20
21Describe 'Get-FileTypeInfo' -Tag 'Unit' {
22 BeforeAll {
23 # Create temporary test files for FileInfo objects
24 $script:TempTestDir = Join-Path ([System.IO.Path]::GetTempPath()) "FrontmatterTests_$([guid]::NewGuid().ToString('N'))"
25 New-Item -ItemType Directory -Path $script:TempTestDir -Force | Out-Null
26
27 # Create subdirectories to simulate repo structure
28 @(
29 'docs/guide',
30 '.github/instructions',
31 '.github/prompts',
32 '.github/chatmodes',
33 '.devcontainer',
34 '.vscode',
35 'random/path'
36 ) | ForEach-Object {
37 New-Item -ItemType Directory -Path (Join-Path $script:TempTestDir $_) -Force | Out-Null
38 }
39 }
40
41 AfterAll {
42 if (Test-Path $script:TempTestDir) {
43 Remove-Item -Path $script:TempTestDir -Recurse -Force -ErrorAction SilentlyContinue
44 }
45 }
46
47 Context 'Root community files' {
48 It 'Identifies README.md as root community' {
49 $filePath = Join-Path $script:TempTestDir 'README.md'
50 Set-Content -Path $filePath -Value 'test'
51 $file = Get-Item $filePath
52 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
53 $result.GetType().Name | Should -Be 'FileTypeInfo'
54 $result.IsRootCommunityFile | Should -BeTrue
55 }
56
57 It 'Identifies CONTRIBUTING.md as root community' {
58 $filePath = Join-Path $script:TempTestDir 'CONTRIBUTING.md'
59 Set-Content -Path $filePath -Value 'test'
60 $file = Get-Item $filePath
61 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
62 $result.IsRootCommunityFile | Should -BeTrue
63 }
64
65 It 'Identifies CODE_OF_CONDUCT.md as root community' {
66 $filePath = Join-Path $script:TempTestDir 'CODE_OF_CONDUCT.md'
67 Set-Content -Path $filePath -Value 'test'
68 $file = Get-Item $filePath
69 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
70 $result.IsRootCommunityFile | Should -BeTrue
71 }
72
73 It 'Identifies SECURITY.md as root community' {
74 $filePath = Join-Path $script:TempTestDir 'SECURITY.md'
75 Set-Content -Path $filePath -Value 'test'
76 $file = Get-Item $filePath
77 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
78 $result.IsRootCommunityFile | Should -BeTrue
79 }
80
81 It 'Identifies SUPPORT.md as root community' {
82 $filePath = Join-Path $script:TempTestDir 'SUPPORT.md'
83 Set-Content -Path $filePath -Value 'test'
84 $file = Get-Item $filePath
85 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
86 $result.IsRootCommunityFile | Should -BeTrue
87 }
88 }
89
90 Context 'Documentation files' {
91 It 'Identifies docs/**/*.md as docs file' {
92 $filePath = Join-Path $script:TempTestDir 'docs/guide/readme.md'
93 Set-Content -Path $filePath -Value 'test'
94 $file = Get-Item $filePath
95 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
96 $result.IsDocsFile | Should -BeTrue
97 }
98
99 It 'Does not mark root README as docs file' {
100 $filePath = Join-Path $script:TempTestDir 'README.md'
101 Set-Content -Path $filePath -Value 'test'
102 $file = Get-Item $filePath
103 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
104 $result.IsDocsFile | Should -BeFalse
105 }
106 }
107
108 Context 'Instruction files' {
109 It 'Identifies *.instructions.md as instruction file' {
110 $filePath = Join-Path $script:TempTestDir '.github/instructions/test.instructions.md'
111 Set-Content -Path $filePath -Value 'test'
112 $file = Get-Item $filePath
113 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
114 $result.IsInstruction | Should -BeTrue
115 }
116 }
117
118 Context 'Prompt files' {
119 It 'Identifies *.prompt.md as prompt file' {
120 $filePath = Join-Path $script:TempTestDir '.github/prompts/build.prompt.md'
121 Set-Content -Path $filePath -Value 'test'
122 $file = Get-Item $filePath
123 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
124 $result.IsPrompt | Should -BeTrue
125 }
126 }
127
128 Context 'Chatmode files' {
129 It 'Identifies *.chatmode.md as chatmode file' {
130 $filePath = Join-Path $script:TempTestDir '.github/chatmodes/helper.chatmode.md'
131 Set-Content -Path $filePath -Value 'test'
132 $file = Get-Item $filePath
133 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
134 $result.IsChatMode | Should -BeTrue
135 }
136 }
137
138 Context 'Special locations' {
139 It 'Identifies .devcontainer README' {
140 $filePath = Join-Path $script:TempTestDir '.devcontainer/README.md'
141 Set-Content -Path $filePath -Value 'test'
142 $file = Get-Item $filePath
143 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
144 $result.IsDevContainer | Should -BeTrue
145 }
146
147 It 'Identifies .vscode README' {
148 $filePath = Join-Path $script:TempTestDir '.vscode/README.md'
149 Set-Content -Path $filePath -Value 'test'
150 $file = Get-Item $filePath
151 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
152 $result.IsVSCodeReadme | Should -BeTrue
153 }
154 }
155
156 Context 'Unknown file types' {
157 It 'Returns all false for random markdown file' {
158 $filePath = Join-Path $script:TempTestDir 'random/path/file.md'
159 Set-Content -Path $filePath -Value 'test'
160 $file = Get-Item $filePath
161 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
162 $result.IsRootCommunityFile | Should -BeFalse
163 $result.IsDocsFile | Should -BeFalse
164 $result.IsInstruction | Should -BeFalse
165 $result.IsPrompt | Should -BeFalse
166 $result.IsChatMode | Should -BeFalse
167 }
168 }
169}
170
171#endregion
172
173#region Test-MarkdownFooter Tests
174
175Describe 'Test-MarkdownFooter' -Tag 'Unit' {
176 BeforeAll {
177 # Standard Copilot attribution footer
178 $script:ValidFooter = '🤖 Crafted with precision by ✨Copilot following brilliant human instruction, carefully refined by our team of discerning human reviewers.'
179 $script:ValidFooterAlternate = '🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.'
180 }
181
182 Context 'Valid footer patterns' {
183 It 'Returns true for standard Copilot attribution footer' {
184 $content = "# Document`n`nSome content here.`n`n$script:ValidFooter"
185 Test-MarkdownFooter -Content $content | Should -BeTrue
186 }
187
188 It 'Returns true for alternate footer with "then" phrasing' {
189 $content = "# Document`n`nContent.`n`n$script:ValidFooterAlternate"
190 Test-MarkdownFooter -Content $content | Should -BeTrue
191 }
192
193 It 'Returns true when footer has trailing period' {
194 $content = "Content`n`n🤖 Crafted with precision by ✨Copilot following brilliant human instruction, carefully refined by our team of discerning human reviewers."
195 Test-MarkdownFooter -Content $content | Should -BeTrue
196 }
197
198 It 'Returns true when footer has no trailing period' {
199 $content = "Content`n`n🤖 Crafted with precision by ✨Copilot following brilliant human instruction, carefully refined by our team of discerning human reviewers"
200 Test-MarkdownFooter -Content $content | Should -BeTrue
201 }
202 }
203
204 Context 'Missing footer' {
205 It 'Returns false for content without Copilot attribution' {
206 $content = 'Content without the attribution footer'
207 Test-MarkdownFooter -Content $content | Should -BeFalse
208 }
209
210 It 'Returns false for empty content' {
211 Test-MarkdownFooter -Content '' | Should -BeFalse
212 }
213
214 It 'Returns false for partial attribution text' {
215 $content = "Content`n`n🤖 Crafted with precision"
216 Test-MarkdownFooter -Content $content | Should -BeFalse
217 }
218 }
219
220 Context 'Footer variations and normalization' {
221 It 'Handles footer with extra whitespace between words' {
222 $content = "Content`n`n🤖 Crafted with precision by ✨Copilot following brilliant human instruction, carefully refined by our team of discerning human reviewers."
223 Test-MarkdownFooter -Content $content | Should -BeTrue
224 }
225
226 It 'Handles footer after multiple blank lines' {
227 $content = "Content`n`n`n`n$script:ValidFooter"
228 Test-MarkdownFooter -Content $content | Should -BeTrue
229 }
230 }
231}
232
233#endregion
234
235#region Initialize-JsonSchemaValidation Tests
236
237Describe 'Initialize-JsonSchemaValidation' -Tag 'Unit' {
238 Context 'Normal operation' {
239 It 'Returns true when JSON processing is available' {
240 $result = Initialize-JsonSchemaValidation
241 $result | Should -BeTrue
242 }
243
244 It 'Validates JSON can be parsed' {
245 # Function internally tests JSON parsing
246 $result = Initialize-JsonSchemaValidation
247 $result | Should -BeOfType [bool]
248 }
249 }
250
251 Context 'Error handling' {
252 It 'Returns false and warns when JSON parsing fails' {
253 # Arrange - Mock ConvertFrom-Json to throw an error
254 Mock ConvertFrom-Json { throw "Simulated JSON parse error" }
255
256 # Act
257 $result = Initialize-JsonSchemaValidation -WarningVariable warnings -WarningAction SilentlyContinue
258
259 # Assert
260 $result | Should -BeFalse
261 }
262
263 It 'Warning message contains error details on exception' {
264 # Arrange - Mock ConvertFrom-Json to throw specific error
265 Mock ConvertFrom-Json { throw "Detailed parse failure" }
266
267 # Act
268 $null = Initialize-JsonSchemaValidation -WarningVariable warnings 3>$null
269
270 # Assert - Warning should contain the error context
271 $warnings | Should -Not -BeNullOrEmpty
272 $warnings[0] | Should -Match 'Error initializing schema validation'
273 }
274
275 It 'Handles null result from ConvertFrom-Json' {
276 # Arrange - Mock ConvertFrom-Json to return null
277 Mock ConvertFrom-Json { return $null }
278
279 # Act
280 $result = Initialize-JsonSchemaValidation
281
282 # Assert
283 $result | Should -BeFalse
284 }
285 }
286}
287
288#endregion
289
290#region Get-SchemaForFile Tests
291
292Describe 'Get-SchemaForFile' -Tag 'Unit' {
293 Context 'Schema mapping' {
294 It 'Returns docs schema for docs files' {
295 $result = Get-SchemaForFile -FilePath 'docs/guide/readme.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
296 $result | Should -Match 'docs-frontmatter\.schema\.json'
297 }
298
299 It 'Returns instruction schema for instruction files' {
300 $result = Get-SchemaForFile -FilePath '.github/instructions/test.instructions.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
301 $result | Should -Match 'instruction-frontmatter\.schema\.json'
302 }
303
304 It 'Returns prompt schema for prompt files' {
305 $result = Get-SchemaForFile -FilePath '.github/prompts/build.prompt.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
306 $result | Should -Match 'prompt-frontmatter\.schema\.json'
307 }
308
309 It 'Returns chatmode schema for chatmode files' {
310 $result = Get-SchemaForFile -FilePath '.github/chatmodes/helper.chatmode.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
311 $result | Should -Match 'chatmode-frontmatter\.schema\.json'
312 }
313
314 It 'Returns agent schema for agent files' {
315 $result = Get-SchemaForFile -FilePath '.github/agents/worker.agent.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
316 $result | Should -Match 'agent-frontmatter\.schema\.json'
317 }
318
319 It 'Returns root-community schema for root community files' {
320 $result = Get-SchemaForFile -FilePath 'README.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
321 $result | Should -Match 'root-community-frontmatter\.schema\.json'
322 }
323
324 It 'Returns base schema for unknown file types' {
325 $result = Get-SchemaForFile -FilePath 'random/file.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
326 $result | Should -Match 'base-frontmatter\.schema\.json'
327 }
328 }
329
330 Context 'Pipe-separated pattern matching' {
331 It 'Matches root file from pipe-separated pattern' {
332 # Test CONTRIBUTING.md which should match the pipe-separated pattern in schema-mapping.json
333 $result = Get-SchemaForFile -FilePath 'CONTRIBUTING.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
334 $result | Should -Match 'root-community-frontmatter\.schema\.json'
335 }
336
337 It 'Matches CODE_OF_CONDUCT.md from pipe-separated pattern' {
338 $result = Get-SchemaForFile -FilePath 'CODE_OF_CONDUCT.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
339 $result | Should -Match 'root-community-frontmatter\.schema\.json'
340 }
341
342 It 'Matches SECURITY.md from pipe-separated pattern' {
343 $result = Get-SchemaForFile -FilePath 'SECURITY.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
344 $result | Should -Match 'root-community-frontmatter\.schema\.json'
345 }
346
347 It 'Falls back to base schema for unlisted root files' {
348 # LICENSE is not in the pipe-separated pattern, so should fall back to base
349 $result = Get-SchemaForFile -FilePath 'LICENSE' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
350 $result | Should -Match 'base-frontmatter\.schema\.json'
351 }
352 }
353
354 Context 'Simple glob pattern matching' {
355 It 'Matches skill file using simple glob pattern' {
356 $result = Get-SchemaForFile -FilePath '.github/skills/test-skill/SKILL.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
357 $result | Should -Match 'skill-frontmatter\.schema\.json'
358 }
359
360 It 'Falls back to base schema for paths not matching any pattern' {
361 # A path that doesn't match any defined patterns
362 $result = Get-SchemaForFile -FilePath 'misc/random/file.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
363 $result | Should -Match 'base-frontmatter\.schema\.json'
364 }
365 }
366
367 Context 'Auto RepoRoot resolution' {
368 It 'Auto-detects repo root when RepoRoot is not specified' {
369 $result = Get-SchemaForFile -FilePath 'docs/guide/readme.md' -SchemaDirectory $script:SchemaDir
370 $result | Should -Match 'docs-frontmatter\.schema\.json'
371 }
372
373 It 'Returns null when no .git directory is found' {
374 $isolatedDir = Join-Path $TestDrive 'isolated-schemas'
375 New-Item -ItemType Directory -Path $isolatedDir -Force | Out-Null
376 '{"mappings": [], "defaultSchema": "base.schema.json"}' | Set-Content -Path (Join-Path $isolatedDir 'schema-mapping.json')
377
378 Mock Test-Path { return $false } -ParameterFilter { $Path -like '*\.git' -or $Path -like '*/.git' }
379
380 $result = Get-SchemaForFile -FilePath 'test.md' -SchemaDirectory $isolatedDir 3>$null
381 $result | Should -BeNullOrEmpty
382 }
383 }
384}
385
386#endregion
387
388#region Test-JsonSchemaValidation Tests
389
390Describe 'Test-JsonSchemaValidation' -Tag 'Unit' {
391 BeforeAll {
392 $script:DocsSchemaPath = Join-Path $script:SchemaDir 'docs-frontmatter.schema.json'
393 $script:DocsSchema = Get-Content -Path $script:DocsSchemaPath -Raw | ConvertFrom-Json
394 $script:BaseSchemaPath = Join-Path $script:SchemaDir 'base-frontmatter.schema.json'
395 $script:BaseSchema = Get-Content -Path $script:BaseSchemaPath -Raw | ConvertFrom-Json
396 }
397
398 Context 'Required fields validation' {
399 It 'Fails when required field is missing' {
400 $frontmatter = @{ title = 'Test' }
401 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:DocsSchema
402 $result.GetType().Name | Should -Be 'SchemaValidationResult'
403 $result.IsValid | Should -BeFalse
404 }
405
406 It 'Passes with all required fields' {
407 $frontmatter = @{
408 title = 'Test'
409 description = 'Valid description'
410 }
411 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:DocsSchema
412 $result.IsValid | Should -BeTrue
413 }
414 }
415
416 Context 'Pattern validation' {
417 BeforeAll {
418 # Create inline schema since $ref is not resolved by Test-JsonSchemaValidation
419 $script:PatternTestSchema = @{
420 required = @('title', 'description')
421 properties = @{
422 title = @{ type = 'string'; minLength = 1 }
423 description = @{ type = 'string'; minLength = 1 }
424 'ms.date' = @{ type = 'string'; pattern = '^\d{4}-\d{2}-\d{2}$' }
425 }
426 } | ConvertTo-Json -Depth 10 | ConvertFrom-Json
427 }
428
429 It 'Fails for invalid date format' {
430 $frontmatter = @{
431 title = 'Test'
432 description = 'Valid'
433 'ms.date' = '2025/01/16'
434 }
435 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:PatternTestSchema
436 $result.IsValid | Should -BeFalse
437 }
438
439 It 'Passes for valid date format' {
440 $frontmatter = @{
441 title = 'Test'
442 description = 'Valid'
443 'ms.date' = '2025-01-16'
444 }
445 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:PatternTestSchema
446 $result.IsValid | Should -BeTrue
447 }
448 }
449
450 Context 'Enum validation' {
451 It 'Fails for invalid ms.topic value' {
452 $frontmatter = @{
453 title = 'Test'
454 description = 'Valid'
455 'ms.topic' = 'invalid-topic-type'
456 }
457 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:DocsSchema
458 $result.IsValid | Should -BeFalse
459 }
460
461 It 'Passes for valid ms.topic value' {
462 $frontmatter = @{
463 title = 'Test'
464 description = 'Valid'
465 'ms.topic' = 'overview'
466 }
467 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:DocsSchema
468 $result.IsValid | Should -BeTrue
469 }
470 }
471
472 Context 'Return type structure' {
473 It 'Returns SchemaValidationResult with expected properties' {
474 $frontmatter = @{ description = 'Test' }
475 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:BaseSchema
476 $result.PSObject.Properties.Name | Should -Contain 'IsValid'
477 $result.PSObject.Properties.Name | Should -Contain 'Errors'
478 $result.PSObject.Properties.Name | Should -Contain 'Warnings'
479 $result.PSObject.Properties.Name | Should -Contain 'SchemaUsed'
480 }
481 }
482
483 Context 'Array type validation' {
484 BeforeAll {
485 $script:ArrayTestSchema = @{
486 required = @('description')
487 properties = @{
488 description = @{ type = 'string'; minLength = 1 }
489 applyTo = @{ type = 'array'; items = @{ type = 'string' } }
490 }
491 } | ConvertTo-Json -Depth 10 | ConvertFrom-Json
492 }
493
494 It 'Validates array field with empty array' {
495 $frontmatter = @{
496 description = 'test'
497 applyTo = @()
498 }
499 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:ArrayTestSchema
500 $result.Errors | Where-Object { $_ -like '*applyTo*' } | Should -BeNullOrEmpty
501 }
502
503 It 'Validates array field with valid string items' {
504 $frontmatter = @{
505 description = 'test'
506 applyTo = @('*.md', '*.txt')
507 }
508 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:ArrayTestSchema
509 $result.Errors | Where-Object { $_ -like '*applyTo*' } | Should -BeNullOrEmpty
510 }
511
512 It 'Reports error when string value used for array field' {
513 # Strings implement IEnumerable but should not pass array validation
514 $frontmatter = @{
515 description = 'test'
516 applyTo = 'single-value'
517 }
518 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:ArrayTestSchema
519 $result.IsValid | Should -BeFalse
520 $result.Errors | Should -Contain "Field 'applyTo' must be an array"
521 }
522
523 It 'Reports error when array field has numeric value' {
524 $frontmatter = @{
525 description = 'test'
526 applyTo = 123
527 }
528 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:ArrayTestSchema
529 $result.IsValid | Should -BeFalse
530 $result.Errors | Should -Contain "Field 'applyTo' must be an array"
531 }
532
533 It 'Reports error when hashtable provided for array field' {
534 # Hashtables/dictionaries are IEnumerable, but semantically objects, not arrays.
535 $frontmatter = @{
536 description = 'test'
537 applyTo = @{ pattern = '*.md' }
538 }
539 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:ArrayTestSchema
540 $result.IsValid | Should -BeFalse
541 $result.Errors | Should -Contain "Field 'applyTo' must be an array"
542 }
543 }
544
545 Context 'Boolean type validation' {
546 BeforeAll {
547 $script:BoolTestSchema = @{
548 required = @('description')
549 properties = @{
550 description = @{ type = 'string'; minLength = 1 }
551 deprecated = @{ type = 'boolean' }
552 }
553 } | ConvertTo-Json -Depth 10 | ConvertFrom-Json
554 }
555
556 It 'Accepts valid boolean true value' {
557 $frontmatter = @{
558 description = 'test'
559 deprecated = $true
560 }
561 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:BoolTestSchema
562 $result.Errors | Where-Object { $_ -like '*deprecated*' } | Should -BeNullOrEmpty
563 }
564
565 It 'Accepts valid boolean false value' {
566 $frontmatter = @{
567 description = 'test'
568 deprecated = $false
569 }
570 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:BoolTestSchema
571 $result.Errors | Where-Object { $_ -like '*deprecated*' } | Should -BeNullOrEmpty
572 }
573
574 It 'Accepts string true/false as boolean' {
575 $frontmatter = @{
576 description = 'test'
577 deprecated = 'true'
578 }
579 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:BoolTestSchema
580 $result.Errors | Where-Object { $_ -like '*deprecated*' } | Should -BeNullOrEmpty
581 }
582
583 It 'Reports error when boolean field has invalid string value' {
584 $frontmatter = @{
585 description = 'test'
586 deprecated = 'yes'
587 }
588 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:BoolTestSchema
589 $result.IsValid | Should -BeFalse
590 $result.Errors | Should -Contain "Field 'deprecated' must be a boolean"
591 }
592
593 It 'Reports error when boolean field has numeric value' {
594 $frontmatter = @{
595 description = 'test'
596 deprecated = 1
597 }
598 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:BoolTestSchema
599 $result.IsValid | Should -BeFalse
600 $result.Errors | Should -Contain "Field 'deprecated' must be a boolean"
601 }
602 }
603
604 Context 'Enum validation with arrays' {
605 BeforeAll {
606 $script:EnumArraySchema = @{
607 required = @('description')
608 properties = @{
609 description = @{ type = 'string'; minLength = 1 }
610 tags = @{
611 type = 'array'
612 items = @{ type = 'string' }
613 enum = @('stable', 'preview', 'deprecated')
614 }
615 }
616 } | ConvertTo-Json -Depth 10 | ConvertFrom-Json
617 }
618
619 It 'Passes when array contains only valid enum values' {
620 $frontmatter = @{
621 description = 'test'
622 tags = @('stable', 'preview')
623 }
624 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:EnumArraySchema
625 $result.Errors | Where-Object { $_ -like '*tags*' } | Should -BeNullOrEmpty
626 }
627
628 It 'Reports error when array contains invalid enum value' {
629 $frontmatter = @{
630 description = 'test'
631 tags = @('stable', 'invalid-value')
632 }
633 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:EnumArraySchema
634 $result.IsValid | Should -BeFalse
635 $result.Errors | Where-Object { $_ -like '*invalid-value*' } | Should -Not -BeNullOrEmpty
636 }
637 }
638
639 Context 'MinLength validation' {
640 BeforeAll {
641 $script:MinLengthSchema = @{
642 required = @('description')
643 properties = @{
644 description = @{ type = 'string'; minLength = 10 }
645 title = @{ type = 'string'; minLength = 5 }
646 }
647 } | ConvertTo-Json -Depth 10 | ConvertFrom-Json
648 }
649
650 It 'Passes when string meets minimum length requirement' {
651 $frontmatter = @{
652 description = 'This is a sufficiently long description'
653 title = 'Valid Title'
654 }
655 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:MinLengthSchema
656 $result.IsValid | Should -BeTrue
657 }
658
659 It 'Reports error when string is shorter than minLength' {
660 $frontmatter = @{
661 description = 'Short'
662 }
663 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:MinLengthSchema
664 $result.IsValid | Should -BeFalse
665 $result.Errors | Should -Contain "Field 'description' must have minimum length of 10"
666 }
667
668 It 'Reports error for empty string when minLength is set' {
669 $frontmatter = @{
670 description = ''
671 }
672 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:MinLengthSchema
673 $result.IsValid | Should -BeFalse
674 $result.Errors | Where-Object { $_ -like '*description*' -and $_ -like '*length*' } | Should -Not -BeNullOrEmpty
675 }
676 }
677
678 Context 'oneOf validation' {
679 BeforeAll {
680 $script:OneOfSchema = @{
681 required = @('description')
682 properties = @{
683 description = @{ type = 'string'; minLength = 1 }
684 model = @{
685 oneOf = @(
686 @{ type = 'string' },
687 @{ type = 'array'; items = @{ type = 'string' } }
688 )
689 }
690 }
691 } | ConvertTo-Json -Depth 10 | ConvertFrom-Json
692 }
693
694 It 'Accepts string for oneOf string|array field' {
695 $frontmatter = @{
696 description = 'test'
697 model = 'gpt-5'
698 }
699 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:OneOfSchema
700 $result.IsValid | Should -BeTrue
701 }
702
703 It 'Accepts array for oneOf string|array field' {
704 $frontmatter = @{
705 description = 'test'
706 model = @('gpt-5', 'claude-sonnet')
707 }
708 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:OneOfSchema
709 $result.IsValid | Should -BeTrue
710 }
711
712 It 'Rejects invalid type for oneOf string|array field' {
713 $frontmatter = @{
714 description = 'test'
715 model = 123
716 }
717 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:OneOfSchema
718 $result.IsValid | Should -BeFalse
719 }
720
721 It 'Rejects when value matches multiple oneOf subschemas' {
722 $schema = @{
723 required = @('description')
724 properties = @{
725 description = @{ type = 'string'; minLength = 1 }
726 model = @{
727 oneOf = @(
728 @{ type = 'string' },
729 @{ type = 'string'; minLength = 1 }
730 )
731 }
732 }
733 } | ConvertTo-Json -Depth 10 | ConvertFrom-Json
734
735 $frontmatter = @{
736 description = 'test'
737 model = 'x'
738 }
739
740 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $schema
741 $result.IsValid | Should -BeFalse
742 $result.Errors | Where-Object { $_ -like "*exactly one*" } | Should -Not -BeNullOrEmpty
743 }
744 }
745
746 Context 'Nested object and array validation' {
747 BeforeAll {
748 $script:NestedSchema = @{
749 required = @('description')
750 properties = @{
751 description = @{ type = 'string'; minLength = 1 }
752 agents = @{
753 oneOf = @(
754 @{ type = 'array'; items = @{ type = 'string' } },
755 @{ type = 'string'; enum = @('*') }
756 )
757 }
758 handoffs = @{
759 type = 'array'
760 items = @{
761 type = 'object'
762 required = @('label', 'agent')
763 properties = @{
764 label = @{ type = 'string'; minLength = 1 }
765 agent = @{ type = 'string'; minLength = 1 }
766 prompt = @{ type = 'string' }
767 model = @{ type = 'string' }
768 send = @{ type = 'boolean' }
769 }
770 }
771 }
772 }
773 } | ConvertTo-Json -Depth 10 | ConvertFrom-Json
774 }
775
776 It 'Accepts agents as * string' {
777 $frontmatter = @{
778 description = 'test'
779 agents = '*'
780 }
781 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:NestedSchema
782 $result.IsValid | Should -BeTrue
783 }
784
785 It 'Accepts agents as array of strings' {
786 $frontmatter = @{
787 description = 'test'
788 agents = @('task-researcher', 'task-planner')
789 }
790 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:NestedSchema
791 $result.IsValid | Should -BeTrue
792 }
793
794 It 'Rejects agents as non-wildcard string' {
795 $frontmatter = @{
796 description = 'test'
797 agents = 'all'
798 }
799 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:NestedSchema
800 $result.IsValid | Should -BeFalse
801 $result.Errors | Where-Object { $_ -match 'must match one of the allowed schemas' } | Should -Not -BeNullOrEmpty
802 }
803
804 It 'Accepts handoff without prompt (prompt is optional)' {
805 $frontmatter = @{
806 description = 'test'
807 handoffs = @(
808 @{ label = 'Next'; agent = 'task-planner' }
809 )
810 }
811 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:NestedSchema
812 $result.IsValid | Should -BeTrue
813 }
814
815 It 'Accepts nested object values provided as PSCustomObject' {
816 $frontmatter = @{
817 description = 'test'
818 handoffs = @(
819 [pscustomobject]@{ label = 'Next'; agent = 'task-planner' }
820 )
821 }
822 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:NestedSchema
823 $result.IsValid | Should -BeTrue
824 }
825
826 It 'Rejects handoff item when item is not an object' {
827 $frontmatter = @{
828 description = 'test'
829 handoffs = @('not-an-object')
830 }
831 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:NestedSchema
832 $result.IsValid | Should -BeFalse
833 $result.Errors | Where-Object { $_ -match 'handoffs\[0\].*object' } | Should -Not -BeNullOrEmpty
834 }
835
836 It 'Rejects handoff with empty label due to nested minLength' {
837 $frontmatter = @{
838 description = 'test'
839 handoffs = @(
840 @{ label = ''; agent = 'task-planner' }
841 )
842 }
843 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:NestedSchema
844 $result.IsValid | Should -BeFalse
845 $result.Errors | Where-Object { $_ -match 'handoffs\[0\]\.label.*minimum length' } | Should -Not -BeNullOrEmpty
846 }
847
848 It 'Accepts complex object values provided as hashtable (covers conversion of arrays and nested objects)' {
849 $schema = @{
850 required = @('description', 'meta')
851 properties = @{
852 description = @{ type = 'string'; minLength = 1 }
853 meta = @{
854 type = 'object'
855 required = @('tags', 'items', 'child')
856 properties = @{
857 tags = @{ type = 'array'; items = @{ type = 'string' } }
858 items = @{
859 type = 'array'
860 items = @{
861 type = 'object'
862 required = @('name')
863 properties = @{
864 name = @{ type = 'string'; minLength = 1 }
865 }
866 }
867 }
868 child = @{
869 type = 'object'
870 required = @('id')
871 properties = @{
872 id = @{ type = 'string'; minLength = 1 }
873 }
874 }
875 }
876 }
877 }
878 } | ConvertTo-Json -Depth 20 | ConvertFrom-Json
879
880 $frontmatter = @{
881 description = 'test'
882 meta = @{
883 tags = @('a', 'b')
884 items = @(
885 [pscustomobject]@{ name = 'x' }
886 @{ name = 'y' }
887 )
888 child = [pscustomobject]@{ id = 'c1' }
889 }
890 }
891
892 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $schema
893 $result.IsValid | Should -BeTrue
894 }
895
896 It 'Accepts complex object values provided as PSCustomObject (covers conversion of arrays and nested objects)' {
897 $schema = @{
898 required = @('description', 'meta')
899 properties = @{
900 description = @{ type = 'string'; minLength = 1 }
901 meta = @{
902 type = 'object'
903 required = @('tags', 'items', 'child')
904 properties = @{
905 tags = @{ type = 'array'; items = @{ type = 'string' } }
906 items = @{
907 type = 'array'
908 items = @{
909 type = 'object'
910 required = @('name')
911 properties = @{
912 name = @{ type = 'string'; minLength = 1 }
913 }
914 }
915 }
916 child = @{
917 type = 'object'
918 required = @('id')
919 properties = @{
920 id = @{ type = 'string'; minLength = 1 }
921 }
922 }
923 }
924 }
925 }
926 } | ConvertTo-Json -Depth 20 | ConvertFrom-Json
927
928 $frontmatter = @{
929 description = 'test'
930 meta = [pscustomobject]@{
931 tags = @('a')
932 items = @(
933 [pscustomobject]@{ name = 'x' }
934 )
935 child = @{ id = 'c1' }
936 }
937 }
938
939 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $schema
940 $result.IsValid | Should -BeTrue
941 }
942
943 It 'Rejects handoff missing required label' {
944 $frontmatter = @{
945 description = 'test'
946 handoffs = @(
947 @{ agent = 'task-planner'; prompt = '/task-plan' }
948 )
949 }
950 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:NestedSchema
951 $result.IsValid | Should -BeFalse
952 }
953
954 It 'Rejects handoff with invalid send type' {
955 $frontmatter = @{
956 description = 'test'
957 handoffs = @(
958 @{ label = 'Next'; agent = 'task-planner'; send = 'yes' }
959 )
960 }
961 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:NestedSchema
962 $result.IsValid | Should -BeFalse
963 }
964 }
965}
966
967#endregion
968
969
970#region Test-FrontmatterValidation Integration Tests
971
972Describe 'Test-FrontmatterValidation' -Tag 'Integration' {
973 BeforeAll {
974 Save-CIEnvironment
975 $script:TestRepoRoot = Join-Path $TestDrive 'test-repo'
976 }
977
978 BeforeEach {
979 New-Item -Path "$script:TestRepoRoot/docs" -ItemType Directory -Force | Out-Null
980 New-Item -Path "$script:TestRepoRoot/.github/instructions" -ItemType Directory -Force | Out-Null
981 New-Item -Path "$script:TestRepoRoot/scripts/linting/schemas" -ItemType Directory -Force | Out-Null
982
983 Copy-Item -Path "$script:SchemaDir/*" -Destination "$script:TestRepoRoot/scripts/linting/schemas/" -Force
984
985 $schemaMappingSource = Join-Path $script:SchemaDir 'schema-mapping.json'
986 if (Test-Path $schemaMappingSource) {
987 Copy-Item -Path $schemaMappingSource -Destination "$script:TestRepoRoot/scripts/linting/schemas/schema-mapping.json" -Force
988 }
989
990 # Change to test repo root so function detects it as repo root
991 Push-Location $script:TestRepoRoot
992 # Initialize minimal git repo for function's repo root detection
993 git init --quiet
994 }
995
996 AfterEach {
997 Pop-Location
998 }
999
1000 AfterAll {
1001 Restore-CIEnvironment
1002 }
1003
1004 Context 'Valid files pass validation' {
1005 BeforeEach {
1006 @"
1007---
1008title: Test Documentation
1009description: Valid documentation file
1010ms.date: 2025-01-16
1011ms.topic: overview
1012---
1013
1014# Test
1015
1016Content here.
1017"@ | Set-Content -Path "$script:TestRepoRoot/docs/test.md" -Encoding UTF8
1018 }
1019
1020 It 'Returns ValidationSummary type' {
1021 $result = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/test.md")
1022 $result.GetType().Name | Should -Be 'ValidationSummary'
1023 }
1024
1025 It 'Reports no errors for valid frontmatter' {
1026 $result = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/test.md")
1027 $result.GetExitCode($false) | Should -Be 0
1028 $result.TotalErrors | Should -Be 0
1029 }
1030 }
1031
1032 Context 'Missing frontmatter fails' {
1033 BeforeEach {
1034 @"
1035# No Frontmatter
1036
1037Just content without any YAML.
1038"@ | Set-Content -Path "$script:TestRepoRoot/docs/no-frontmatter.md" -Encoding UTF8
1039 }
1040
1041 It 'Reports warning for missing frontmatter' {
1042 $result = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/no-frontmatter.md")
1043 # Missing frontmatter in docs is a warning, not an error
1044 $result.TotalWarnings | Should -BeGreaterThan 0
1045 $warningMessages = $result.Results | ForEach-Object { $_.Issues | Where-Object Type -eq 'Warning' } | ForEach-Object { $_.Message }
1046 $warningMessages | Where-Object { $_ -match 'No frontmatter found' } | Should -Not -BeNullOrEmpty
1047 }
1048 }
1049
1050 Context 'Empty description fails' {
1051 BeforeEach {
1052 @"
1053---
1054title: Has Title
1055description: ""
1056---
1057
1058Content
1059"@ | Set-Content -Path "$script:TestRepoRoot/docs/empty-desc.md" -Encoding UTF8
1060 }
1061
1062 It 'Reports error for empty description' {
1063 # Missing required description field is a validation error
1064 $result = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/empty-desc.md")
1065 # Empty required field causes validation error
1066 $result.TotalErrors | Should -BeGreaterThan 0
1067 }
1068 }
1069
1070 Context 'Invalid date format fails' {
1071 BeforeEach {
1072 # docs-frontmatter.schema.json requires BOTH title AND description
1073 @"
1074---
1075title: Bad Date File
1076description: Valid description
1077ms.date: 2025/01/16
1078---
1079
1080Content
1081"@ | Set-Content -Path "$script:TestRepoRoot/docs/bad-date.md" -Encoding UTF8
1082 }
1083
1084 It 'Reports warning for invalid date format' {
1085 # Invalid date format is a warning, not an error
1086 $result = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/bad-date.md")
1087 $result.GetExitCode($false) | Should -Be 0
1088 $warningMessages = $result.Results | ForEach-Object { $_.Issues | Where-Object Type -eq 'Warning' } | ForEach-Object { $_.Message }
1089 ($warningMessages -join "`n") | Should -Match 'Invalid date format'
1090 }
1091 }
1092
1093 Context 'Multiple file validation' {
1094 BeforeEach {
1095 # docs-frontmatter.schema.json requires BOTH title AND description
1096 @"
1097---
1098title: Valid File 1
1099description: Valid file 1
1100---
1101Content
1102"@ | Set-Content -Path "$script:TestRepoRoot/docs/valid1.md" -Encoding UTF8
1103
1104 @"
1105---
1106title: Valid File 2
1107description: Valid file 2
1108---
1109Content
1110"@ | Set-Content -Path "$script:TestRepoRoot/docs/valid2.md" -Encoding UTF8
1111 }
1112
1113 It 'Validates multiple files in directory' {
1114 $result = Test-FrontmatterValidation -Paths @("$script:TestRepoRoot/docs")
1115 $result.TotalFiles | Should -BeGreaterOrEqual 2
1116 }
1117
1118 It 'Uses Paths parameter when Files is not provided' {
1119 # Test the else branch in main execution that uses Paths
1120 $result = Test-FrontmatterValidation -Paths @("$script:TestRepoRoot/docs")
1121 $result | Should -Not -BeNullOrEmpty
1122 $result.TotalFiles | Should -BeGreaterThan 0
1123 }
1124 }
1125
1126 Context 'Result aggregation' {
1127 It 'Aggregates results in ValidationSummary' {
1128 # docs-frontmatter.schema.json requires BOTH title AND description
1129 @"
1130---
1131title: Test File
1132description: Valid
1133---
1134Content
1135"@ | Set-Content -Path "$script:TestRepoRoot/docs/test.md" -Encoding UTF8
1136
1137 $result = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/test.md")
1138 $result.PSObject.Properties.Name | Should -Contain 'Results'
1139 $result.PSObject.Properties.Name | Should -Contain 'TotalFiles'
1140 $result.PSObject.Properties.Name | Should -Contain 'FilesWithErrors'
1141 $result.PSObject.Properties.Name | Should -Contain 'FilesWithWarnings'
1142 }
1143 }
1144
1145 Context 'ChangedFilesOnly mode' {
1146 BeforeEach {
1147 # Create valid test file
1148 @"
1149---
1150title: Changed File
1151description: A file detected as changed by git
1152---
1153Content
1154"@ | Set-Content -Path "$script:TestRepoRoot/docs/changed.md" -Encoding UTF8
1155 }
1156
1157 It 'Returns success ValidationSummary when no changed files found' {
1158 # Mock Get-ChangedFilesFromGit to return empty
1159 Mock Get-ChangedFilesFromGit { return @() } -ParameterFilter { $FileExtensions -contains '*.md' }
1160
1161 $result = Test-FrontmatterValidation -ChangedFilesOnly
1162
1163 # TotalFiles=0 accurately represents no files were validated
1164 # This is a successful no-op, not a validation failure
1165 $result.TotalFiles | Should -Be 0
1166 $result.FilesValid | Should -Be 0
1167 # Verify the summary was completed
1168 $result.Duration | Should -Not -BeNullOrEmpty
1169 }
1170
1171 It 'Validates only files returned by Get-ChangedFilesFromGit' {
1172 # Mock Get-ChangedFilesFromGit to return specific file
1173 Mock Get-ChangedFilesFromGit {
1174 return @("$script:TestRepoRoot/docs/changed.md")
1175 } -ParameterFilter { $FileExtensions -contains '*.md' }
1176
1177 $result = Test-FrontmatterValidation -ChangedFilesOnly
1178
1179 $result.TotalFiles | Should -Be 1
1180 }
1181
1182 It 'Passes BaseBranch parameter to Get-ChangedFilesFromGit' {
1183 Mock Get-ChangedFilesFromGit {
1184 return @()
1185 } -ParameterFilter { $BaseBranch -eq 'develop' -and $FileExtensions -contains '*.md' }
1186
1187 $null = Test-FrontmatterValidation -ChangedFilesOnly -BaseBranch 'develop'
1188
1189 Should -Invoke Get-ChangedFilesFromGit -ParameterFilter { $BaseBranch -eq 'develop' -and $FileExtensions -contains '*.md' }
1190 }
1191 }
1192
1193 Context 'EnableSchemaValidation mode' {
1194 BeforeEach {
1195 @"
1196---
1197title: Schema Test Doc
1198description: Valid test document for schema overlay
1199---
1200
1201# Test Content
1202"@ | Set-Content -Path "$script:TestRepoRoot/docs/schema-test.md" -Encoding UTF8
1203 }
1204
1205 It 'Invokes schema validation on files with frontmatter' {
1206 Mock Initialize-JsonSchemaValidation { return $true }
1207 Mock Get-SchemaForFile { return (Join-Path $script:SchemaDir 'docs-frontmatter.schema.json') }
1208 Mock Test-JsonSchemaValidation {
1209 return [PSCustomObject]@{ IsValid = $true; Errors = @(); Warnings = @() }
1210 }
1211
1212 $null = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/schema-test.md") -EnableSchemaValidation -SkipFooterValidation
1213
1214 Should -Invoke Get-SchemaForFile -Times 1
1215 Should -Invoke Test-JsonSchemaValidation -Times 1
1216 }
1217
1218 It 'Writes warnings when schema validation reports errors' {
1219 Mock Initialize-JsonSchemaValidation { return $true }
1220 Mock Get-SchemaForFile { return (Join-Path $script:SchemaDir 'docs-frontmatter.schema.json') }
1221 Mock Test-JsonSchemaValidation {
1222 return [PSCustomObject]@{ IsValid = $false; Errors = @('Missing required field: ms.date'); Warnings = @() }
1223 }
1224
1225 $null = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/schema-test.md") -EnableSchemaValidation -SkipFooterValidation -WarningVariable warnings 3>$null
1226
1227 $schemaWarnings = $warnings | Where-Object { $_ -match 'JSON Schema validation errors' -or $_ -match 'ms\.date' }
1228 $schemaWarnings | Should -Not -BeNullOrEmpty
1229 }
1230
1231 It 'Skips schema check when file has no frontmatter' {
1232 @"
1233# No Frontmatter
1234
1235Just content without YAML.
1236"@ | Set-Content -Path "$script:TestRepoRoot/docs/no-fm-schema.md" -Encoding UTF8
1237
1238 Mock Initialize-JsonSchemaValidation { return $true }
1239 Mock Get-SchemaForFile {}
1240 Mock Test-JsonSchemaValidation {}
1241
1242 $null = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/no-fm-schema.md") -EnableSchemaValidation -SkipFooterValidation
1243
1244 Should -Invoke Get-SchemaForFile -Times 0
1245 }
1246
1247 It 'Skips Test-JsonSchemaValidation when no schema matches file' {
1248 Mock Initialize-JsonSchemaValidation { return $true }
1249 Mock Get-SchemaForFile { return $null }
1250 Mock Test-JsonSchemaValidation {}
1251
1252 $null = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/schema-test.md") -EnableSchemaValidation -SkipFooterValidation
1253
1254 Should -Invoke Get-SchemaForFile -Times 1
1255 Should -Invoke Test-JsonSchemaValidation -Times 0
1256 }
1257
1258 It 'Skips overlay entirely when Initialize-JsonSchemaValidation returns false' {
1259 Mock Initialize-JsonSchemaValidation { return $false }
1260 Mock Get-SchemaForFile {}
1261
1262 $null = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/schema-test.md") -EnableSchemaValidation -SkipFooterValidation
1263
1264 Should -Invoke Get-SchemaForFile -Times 0
1265 }
1266 }
1267}
1268
1269#endregion
1270
1271#region ExcludePaths Filtering Tests
1272
1273Describe 'ExcludePaths Filtering' -Tag 'Unit' {
1274 BeforeAll {
1275 # Create test directory structure with files to include and exclude
1276 $script:ExcludeTestDir = Join-Path $TestDrive 'exclude-test'
1277 New-Item -ItemType Directory -Path "$script:ExcludeTestDir/docs" -Force | Out-Null
1278 New-Item -ItemType Directory -Path "$script:ExcludeTestDir/tests/fixtures" -Force | Out-Null
1279
1280 # Valid file that should be included
1281 @"
1282---
1283title: Include This
1284description: File that should be validated
1285---
1286Content
1287"@ | Set-Content -Path "$script:ExcludeTestDir/docs/include.md" -Encoding UTF8
1288
1289 # File in tests directory that should be excluded
1290 @"
1291---
1292title: Exclude This
1293description: File in tests folder
1294---
1295Content
1296"@ | Set-Content -Path "$script:ExcludeTestDir/tests/fixtures/exclude.md" -Encoding UTF8
1297 }
1298
1299 Context 'Excludes files matching single pattern' {
1300 It 'Excludes files matching pattern with wildcard prefix' {
1301 # Use wildcard prefix since ExcludePaths computes relative path from repo root
1302 # For files outside repo, the full path is used, so we match with *tests*
1303 $result = Test-FrontmatterValidation -Paths @($script:ExcludeTestDir) -ExcludePaths @('*tests*')
1304 # Should only check docs/include.md, not tests/fixtures/exclude.md
1305 $result.TotalFiles | Should -Be 1
1306 }
1307 }
1308
1309 Context 'Excludes files matching multiple patterns' {
1310 BeforeAll {
1311 # Add another directory to exclude
1312 New-Item -ItemType Directory -Path "$script:ExcludeTestDir/vendor" -Force | Out-Null
1313 @"
1314---
1315title: Vendor File
1316description: Third party content
1317---
1318Content
1319"@ | Set-Content -Path "$script:ExcludeTestDir/vendor/third-party.md" -Encoding UTF8
1320 }
1321
1322 It 'Excludes files matching multiple patterns' {
1323 $result = Test-FrontmatterValidation -Paths @($script:ExcludeTestDir) -ExcludePaths @('*tests*', '*vendor*')
1324 # Should only check docs/include.md
1325 $result.TotalFiles | Should -Be 1
1326 }
1327 }
1328
1329 Context 'Processes all files when ExcludePaths is empty' {
1330 It 'Validates all markdown files without exclusions' {
1331 $result = Test-FrontmatterValidation -Paths @($script:ExcludeTestDir) -ExcludePaths @()
1332 # Should check all markdown files (docs + tests + vendor)
1333 $result.TotalFiles | Should -BeGreaterOrEqual 2
1334 }
1335 }
1336
1337 Context 'Pattern matching behavior' {
1338 It 'Matches glob pattern with double asterisk for relative paths' {
1339 $relativePath = 'tests/fixtures/exclude.md'
1340 $pattern = 'tests/**'
1341 $relativePath -like $pattern | Should -BeTrue
1342 }
1343
1344 It 'Does not match non-matching patterns' {
1345 $relativePath = 'docs/include.md'
1346 $pattern = 'tests/**'
1347 $relativePath -like $pattern | Should -BeFalse
1348 }
1349
1350 It 'Matches pattern with single asterisk for file names' {
1351 $relativePath = 'docs/README.md'
1352 $pattern = 'docs/*.md'
1353 $relativePath -like $pattern | Should -BeTrue
1354 }
1355 }
1356
1357 Context 'FooterExcludePaths integration' {
1358 It 'Passes FooterExcludePaths to Invoke-FrontmatterValidation' {
1359 $testFile = Join-Path $TestDrive 'CHANGELOG.md'
1360 Set-Content $testFile "---`ndescription: Release history`n---`n# Changelog`n`nNo footer here"
1361
1362 # File should not have footer error when excluded (use wildcard to match filename in any path)
1363 $result = Test-FrontmatterValidation -Files @($testFile) -FooterExcludePaths @('*CHANGELOG.md')
1364 $footerErrors = $result.Results | ForEach-Object { $_.Issues } | Where-Object { $_.Field -eq 'footer' }
1365 $footerErrors | Should -BeNullOrEmpty
1366 }
1367
1368 It 'Applies footer validation to non-excluded files' {
1369 $testFile = Join-Path $TestDrive 'docs' 'guide.md'
1370 New-Item -ItemType Directory -Path (Join-Path $TestDrive 'docs') -Force | Out-Null
1371 Set-Content $testFile "---`ndescription: Test guide`n---`n# Guide`n`nNo footer here"
1372
1373 # Non-excluded file should have footer error
1374 $result = Test-FrontmatterValidation -Files @($testFile) -FooterExcludePaths @('*CHANGELOG.md')
1375 $footerErrors = $result.Results | ForEach-Object { $_.Issues } | Where-Object { $_.Field -eq 'footer' }
1376 $footerErrors | Should -Not -BeNullOrEmpty
1377 }
1378 }
1379}
1380
1381#endregion
1382
1383#region Error Handling Path Tests
1384
1385Describe 'Error handling paths' -Tag 'Unit' {
1386 Context 'Schema file error handling' {
1387 It 'Test-JsonSchemaValidation returns error for missing schema file' {
1388 $frontmatter = @{ title = 'Test'; description = 'Valid' }
1389 $missingSchemaPath = Join-Path $TestDrive 'does-not-exist.json'
1390 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaPath $missingSchemaPath
1391 $result.IsValid | Should -BeFalse
1392 $result.Errors | Should -Contain "Schema file not found: $missingSchemaPath"
1393 }
1394
1395 It 'Returns proper SchemaValidationResult on schema not found' {
1396 $frontmatter = @{ title = 'Test' }
1397 $missingSchemaPath = Join-Path $TestDrive 'missing-schema.json'
1398 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaPath $missingSchemaPath
1399 $result.GetType().Name | Should -Be 'SchemaValidationResult'
1400 $result.SchemaUsed | Should -Be $missingSchemaPath
1401 }
1402
1403 It 'Returns error for malformed JSON schema' {
1404 $badSchemaPath = Join-Path $TestDrive 'bad-schema.json'
1405 '{ invalid json }' | Set-Content -Path $badSchemaPath -Encoding UTF8
1406
1407 $frontmatter = @{ title = 'Test' }
1408 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaPath $badSchemaPath
1409 $result.IsValid | Should -BeFalse
1410 $result.Errors[0] | Should -Match 'Failed to parse schema'
1411 }
1412
1413 It 'Get-SchemaForFile returns null when mapping file is missing' {
1414 # Use platform-agnostic path for cross-platform compatibility
1415 $nonexistentPath = Join-Path $TestDrive 'nonexistent-schemas-dir'
1416 $result = Get-SchemaForFile -FilePath 'test.md' -SchemaDirectory $nonexistentPath
1417 $result | Should -BeNullOrEmpty
1418 }
1419
1420 It 'Get-SchemaForFile handles schema-mapping.json read errors gracefully' {
1421 $badMappingDir = Join-Path $TestDrive 'bad-mapping-dir'
1422 New-Item -ItemType Directory -Path $badMappingDir -Force | Out-Null
1423 '{ invalid json content }' | Set-Content -Path (Join-Path $badMappingDir 'schema-mapping.json') -Encoding UTF8
1424
1425 $null = Get-SchemaForFile -FilePath 'test.md' -SchemaDirectory $badMappingDir -WarningVariable warnings 3>$null
1426 $warnings | Should -Not -BeNullOrEmpty
1427 $warnings[0] | Should -Match 'Error reading schema mapping'
1428 }
1429 }
1430}
1431
1432Describe 'CI Environment Integration' -Tag 'Unit' {
1433 BeforeAll {
1434 . $PSScriptRoot/../../linting/Validate-MarkdownFrontmatter.ps1
1435 Import-Module $PSScriptRoot/../../linting/Modules/FrontmatterValidation.psm1 -Force
1436
1437 # Save original environment
1438 $script:OriginalGHA = $env:GITHUB_ACTIONS
1439 $script:OriginalStepSummary = $env:GITHUB_STEP_SUMMARY
1440 }
1441
1442 AfterAll {
1443 # Restore original environment
1444 $env:GITHUB_ACTIONS = $script:OriginalGHA
1445 $env:GITHUB_STEP_SUMMARY = $script:OriginalStepSummary
1446 }
1447
1448 Context 'Write-CIAnnotations execution path' {
1449 It 'Calls Write-CIAnnotations when CI is set' {
1450 $env:GITHUB_ACTIONS = 'true'
1451
1452 # Create test file with error
1453 $testFile = Join-Path $TestDrive 'ci-test.md'
1454 Set-Content $testFile "---`ndescription: x`n---`n# Test"
1455
1456 Mock Write-CIAnnotations { return '::error file=ci-test.md::' }
1457
1458 $null = Test-FrontmatterValidation -Files @($testFile) -SkipFooterValidation
1459
1460 # Annotation function should be called in CI environment
1461 Should -Invoke Write-CIAnnotations -Times 1 -Exactly
1462 }
1463 }
1464
1465 Context 'Step summary generation' {
1466 It 'Writes to step summary file when GITHUB_STEP_SUMMARY is set' {
1467 $env:GITHUB_ACTIONS = 'true'
1468 $stepSummaryPath = Join-Path $TestDrive 'step-summary.md'
1469 $env:GITHUB_STEP_SUMMARY = $stepSummaryPath
1470
1471 # Create valid test file
1472 $testFile = Join-Path $TestDrive 'valid-ci.md'
1473 Set-Content $testFile "---`ndescription: Valid test file`n---`n# Test"
1474
1475 $null = Test-FrontmatterValidation -Files @($testFile) -SkipFooterValidation
1476
1477 # Step summary should be written
1478 Test-Path $stepSummaryPath | Should -BeTrue
1479 }
1480
1481 It 'Writes fail step summary and sets FRONTMATTER_VALIDATION_FAILED env var' {
1482 Mock Set-CIEnv { }
1483
1484 $env:GITHUB_ACTIONS = 'true'
1485 $stepSummaryPath = Join-Path $TestDrive 'step-summary-fail.md'
1486 $env:GITHUB_STEP_SUMMARY = $stepSummaryPath
1487
1488 # File without frontmatter generates warning; -WarningsAsErrors makes GetExitCode non-zero
1489 $testFile = Join-Path $TestDrive 'fail-ci.md'
1490 Set-Content $testFile "# No Frontmatter`n`nContent without YAML front matter."
1491
1492 $null = Test-FrontmatterValidation -Files @($testFile) -WarningsAsErrors -SkipFooterValidation
1493
1494 Test-Path $stepSummaryPath | Should -BeTrue
1495 $content = Get-Content $stepSummaryPath -Raw
1496 $content | Should -Match 'Failed'
1497
1498 # Set-CIEnv writes to GITHUB_ENV file, not in-process env vars
1499 Should -Invoke Set-CIEnv -Times 1 -Exactly -ParameterFilter {
1500 $Name -eq 'FRONTMATTER_VALIDATION_FAILED' -and $Value -eq 'true'
1501 }
1502 }
1503 }
1504
1505 Context 'Main execution error handling with GitHub Actions' {
1506 It 'Outputs GitHub error annotation when validation throws exception in CI' {
1507 $env:GITHUB_ACTIONS = 'true'
1508
1509 # Create a file that will cause validation to fail
1510 $errorFile = Join-Path $TestDrive 'error-test.md'
1511 # Create malformed content
1512 Set-Content $errorFile "Malformed content"
1513
1514 # Mock a critical function to throw
1515 Mock Test-SingleFileFrontmatter { throw 'Validation critical error' }
1516
1517 # Act
1518 $output = Test-FrontmatterValidation -Files @($errorFile) 2>&1 3>&1
1519
1520 # Assert - Should attempt to output GitHub annotation on error
1521 # The error annotation is in the catch block
1522 $hasErrorOutput = $output | Where-Object { $_ -match 'error' }
1523 $hasErrorOutput | Should -Not -BeNullOrEmpty
1524 }
1525 }
1526}
1527
1528#endregion
1529
1530
1531#region Integration Modes Tests
1532
1533Describe 'Write-CIAnnotations' -Tag 'Unit' {
1534 BeforeAll {
1535 Import-Module (Join-Path $PSScriptRoot '../../linting/Modules/FrontmatterValidation.psm1') -Force
1536 }
1537
1538 Context 'GitHub Actions annotation output' {
1539 BeforeEach {
1540 $script:OriginalGHActions = $env:GITHUB_ACTIONS
1541 $env:GITHUB_ACTIONS = 'true'
1542 }
1543
1544 AfterEach {
1545 if ($null -eq $script:OriginalGHActions) {
1546 Remove-Item Env:GITHUB_ACTIONS -ErrorAction SilentlyContinue
1547 }
1548 else {
1549 $env:GITHUB_ACTIONS = $script:OriginalGHActions
1550 }
1551 }
1552
1553 It 'Outputs error annotation format for file errors' {
1554 # Arrange - Create summary with errors
1555 $summary = & (Get-Module FrontmatterValidation) { [ValidationSummary]::new() }
1556 $fileResult = & (Get-Module FrontmatterValidation) {
1557 $result = [FileValidationResult]::new('test/error.md')
1558 $result.AddError('Missing required field: description', 'description')
1559 $result
1560 }
1561 $summary.AddResult($fileResult)
1562
1563 # Act - Capture Write-Output
1564 $output = Write-CIAnnotations -Summary $summary
1565
1566 # Assert - Should output ::error:: annotation
1567 $output | Where-Object { $_ -like '::error*' } | Should -Not -BeNullOrEmpty
1568 }
1569
1570 It 'Outputs warning annotation format for file warnings' {
1571 # Arrange - Create summary with warnings only
1572 $summary = & (Get-Module FrontmatterValidation) { [ValidationSummary]::new() }
1573 $fileResult = & (Get-Module FrontmatterValidation) {
1574 $result = [FileValidationResult]::new('test/warning.md')
1575 $result.AddWarning('Suggested field missing: author', 'author')
1576 $result
1577 }
1578 $summary.AddResult($fileResult)
1579
1580 # Act - Capture Write-Output
1581 $output = Write-CIAnnotations -Summary $summary
1582
1583 # Assert - Should output ::warning:: annotation
1584 $output | Where-Object { $_ -like '::warning*' } | Should -Not -BeNullOrEmpty
1585 }
1586
1587 It 'Includes file path in annotations' {
1588 # Arrange
1589 $summary = & (Get-Module FrontmatterValidation) { [ValidationSummary]::new() }
1590 $fileResult = & (Get-Module FrontmatterValidation) {
1591 $result = [FileValidationResult]::new('docs/specific-file.md')
1592 $result.AddError('Test error', 'test')
1593 $result
1594 }
1595 $summary.AddResult($fileResult)
1596
1597 # Act - Capture Write-Output
1598 $output = Write-CIAnnotations -Summary $summary
1599
1600 # Assert - Annotation should include file path
1601 $output | Where-Object { $_ -like '*file=*specific-file*' } | Should -Not -BeNullOrEmpty
1602 }
1603 }
1604}
1605
1606Describe 'Empty Input Handling' -Tag 'Unit' {
1607 Context 'No files to validate' {
1608 It 'Warns when path contains no markdown files' {
1609 # Arrange
1610 $emptyDir = Join-Path $TestDrive 'empty-dir'
1611 New-Item -ItemType Directory -Path $emptyDir -Force | Out-Null
1612
1613 # Act & Assert - Test-FrontmatterValidation should handle empty gracefully
1614 $result = Test-FrontmatterValidation -Paths @($emptyDir) -WarningVariable warnings 3>$null
1615 $result.TotalFiles | Should -Be 0
1616 }
1617
1618 It 'Returns empty summary when all files are excluded' {
1619 # Arrange
1620 $excludeDir = Join-Path $TestDrive 'exclude-all'
1621 $nodeModules = Join-Path $excludeDir 'node_modules'
1622 New-Item -ItemType Directory -Path $nodeModules -Force | Out-Null
1623 Set-Content -Path (Join-Path $nodeModules 'readme.md') -Value "---`ndescription: excluded`n---"
1624
1625 # Act
1626 $result = Test-FrontmatterValidation -Paths @($excludeDir) -ExcludePaths @('**/node_modules/**')
1627
1628 # Assert
1629 $result.TotalFiles | Should -Be 0
1630 }
1631 }
1632}
1633
1634
1635#region Schema Pattern Matching Tests
1636
1637Describe 'Schema Pattern Matching' -Tag 'Unit' {
1638 BeforeAll {
1639 $script:MainScript = Join-Path $PSScriptRoot '../../linting/Validate-MarkdownFrontmatter.ps1'
1640 }
1641
1642 Context 'Pipe-separated and Array patterns' {
1643 It 'Validates pipe-separated patterns in applyTo' {
1644 # Arrange
1645 $testFile = Join-Path $TestDrive 'pipe-patterns.md'
1646 Set-Content -Path $testFile -Value @"
1647---
1648description: test
1649applyTo: "**/*.ts | **/*.tsx | **/*.js"
1650---
1651"@
1652
1653 # Act
1654 $result = Test-SingleFileFrontmatter -FilePath $testFile -RepoRoot $TestDrive
1655
1656 # Assert - Should accept pipe-separated patterns
1657 $result.Issues | Where-Object { $_.Field -eq 'applyTo' -and $_.Type -eq 'Error' } | Should -BeNullOrEmpty
1658 }
1659
1660 It 'Validates comma-separated patterns in applyTo array' {
1661 # Arrange
1662 $testFile = Join-Path $TestDrive 'array-patterns.md'
1663 Set-Content -Path $testFile -Value @"
1664---
1665description: test
1666applyTo:
1667 - "**/*.ts"
1668 - "**/*.tsx"
1669 - "**/components/**"
1670---
1671"@
1672
1673 # Act
1674 $result = Test-SingleFileFrontmatter -FilePath $testFile -RepoRoot $TestDrive
1675
1676 # Assert - Array format should be valid
1677 $result.Issues | Where-Object { $_.Field -eq 'applyTo' -and $_.Type -eq 'Error' } | Should -BeNullOrEmpty
1678 }
1679 }
1680
1681 Context 'Glob pattern validation' {
1682 It 'Validates double-star glob patterns' {
1683 # Arrange
1684 $testFile = Join-Path $TestDrive 'glob-doublestar.md'
1685 Set-Content -Path $testFile -Value @"
1686---
1687description: test
1688applyTo: "**/src/**/*.ts"
1689---
1690"@
1691
1692 # Act
1693 $result = Test-SingleFileFrontmatter -FilePath $testFile -RepoRoot $TestDrive
1694
1695 # Assert
1696 $result.Issues | Where-Object { $_.Field -eq 'applyTo' -and $_.Type -eq 'Error' } | Should -BeNullOrEmpty
1697 }
1698
1699 It 'Validates single-star glob patterns' {
1700 # Arrange
1701 $testFile = Join-Path $TestDrive 'glob-singlestar.md'
1702 Set-Content -Path $testFile -Value @"
1703---
1704description: test
1705applyTo: "src/*.ts"
1706---
1707"@
1708
1709 # Act
1710 $result = Test-SingleFileFrontmatter -FilePath $testFile -RepoRoot $TestDrive
1711
1712 # Assert
1713 $result.Issues | Where-Object { $_.Field -eq 'applyTo' -and $_.Type -eq 'Error' } | Should -BeNullOrEmpty
1714 }
1715
1716 It 'Validates question mark wildcard patterns' {
1717 # Arrange
1718 $testFile = Join-Path $TestDrive 'glob-question.md'
1719 Set-Content -Path $testFile -Value @"
1720---
1721description: test
1722applyTo: "src/file?.ts"
1723---
1724"@
1725
1726 # Act
1727 $result = Test-SingleFileFrontmatter -FilePath $testFile -RepoRoot $TestDrive
1728
1729 # Assert
1730 $result.Issues | Where-Object { $_.Field -eq 'applyTo' -and $_.Type -eq 'Error' } | Should -BeNullOrEmpty
1731 }
1732
1733 It 'Validates brace expansion patterns' {
1734 # Arrange
1735 $testFile = Join-Path $TestDrive 'glob-braces.md'
1736 Set-Content -Path $testFile -Value @"
1737---
1738description: test
1739applyTo: "**/*.{ts,tsx,js,jsx}"
1740---
1741"@
1742
1743 # Act
1744 $result = Test-SingleFileFrontmatter -FilePath $testFile -RepoRoot $TestDrive
1745
1746 # Assert
1747 $result.Issues | Where-Object { $_.Field -eq 'applyTo' -and $_.Type -eq 'Error' } | Should -BeNullOrEmpty
1748 }
1749 }
1750}
1751
1752#endregion
1753
1754#endregion
1755