microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/1637-l2-skill

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/tests/plugins/PluginHelpers.Tests.ps1

524lines · modecode

1#Requires -Modules Pester
2# Copyright (c) Microsoft Corporation.
3# SPDX-License-Identifier: MIT
4
5[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')]
6param()
7
8BeforeAll {
9 Import-Module $PSScriptRoot/../../plugins/Modules/PluginHelpers.psm1 -Force
10}
11
12Describe 'New-PluginReadmeContent - maturity notice' {
13 It 'Includes experimental notice when maturity is experimental' {
14 $collection = @{
15 id = 'test-exp'
16 name = 'Test Experimental'
17 description = 'An experimental collection'
18 }
19 $items = @(@{ Name = 'test-agent'; Description = 'desc'; Kind = 'agent' })
20 $result = New-PluginReadmeContent -Collection $collection -Items $items -Maturity 'experimental'
21 $result | Should -Match '\u26A0' # warning sign emoji
22 }
23
24 It 'Has no notice when maturity is stable' {
25 $collection = @{
26 id = 'test-stable'
27 name = 'Test Stable'
28 description = 'A stable collection'
29 }
30 $items = @(@{ Name = 'test-agent'; Description = 'desc'; Kind = 'agent' })
31 $result = New-PluginReadmeContent -Collection $collection -Items $items -Maturity 'stable'
32 $result | Should -Not -Match '\u26A0'
33 }
34
35 It 'Has no notice when maturity is omitted' {
36 $collection = @{
37 id = 'test-default'
38 name = 'Test Default'
39 description = 'A default collection'
40 }
41 $items = @(@{ Name = 'test-agent'; Description = 'desc'; Kind = 'agent' })
42 $result = New-PluginReadmeContent -Collection $collection -Items $items
43 $result | Should -Not -Match '\u26A0'
44 }
45
46 It 'Has no notice when maturity is null' {
47 $collection = @{
48 id = 'test-null'
49 name = 'Test Null'
50 description = 'A null maturity collection'
51 }
52 $items = @(@{ Name = 'test-agent'; Description = 'desc'; Kind = 'agent' })
53 $result = New-PluginReadmeContent -Collection $collection -Items $items -Maturity $null
54 $result | Should -Not -Match '\u26A0'
55 }
56}
57
58Describe 'New-PluginReadmeContent - CollectionContent H1 stripping' {
59 BeforeAll {
60 $baseCollection = @{
61 id = 'test-h1'
62 name = 'Test Collection'
63 description = 'A test collection'
64 }
65 $items = @(@{ Name = 'test-agent'; Description = 'desc'; Kind = 'agent' })
66 }
67
68 It 'Strips leading H1 from CollectionContent to avoid duplicate title' {
69 $content = "# Test Collection`n`nBody text here.`n"
70 $result = New-PluginReadmeContent -Collection $baseCollection -Items $items -CollectionContent $content
71 $h1Matches = [regex]::Matches($result, '(?m)^# ')
72 $h1Matches.Count | Should -Be 1
73 $result | Should -Match 'Body text here\.'
74 }
75
76 It 'Preserves artifact markers and tables in CollectionContent' {
77 $content = "# Test Collection`n`nBody text.`n`n## Included Artifacts`n`n<!-- BEGIN AUTO-GENERATED ARTIFACTS -->`n`n### Chat Agents`n`n<!-- END AUTO-GENERATED ARTIFACTS -->`n"
78 $result = New-PluginReadmeContent -Collection $baseCollection -Items $items -CollectionContent $content
79 $result | Should -Match '<!-- BEGIN AUTO-GENERATED ARTIFACTS -->'
80 $result | Should -Match '<!-- END AUTO-GENERATED ARTIFACTS -->'
81 $result | Should -Match '## Included Artifacts'
82 $includedArtifactMatches = [regex]::Matches($result, '(?m)^## Included Artifacts$')
83 $includedArtifactMatches.Count | Should -Be 1
84 $result | Should -Not -Match '(?m)^## Agents$'
85 }
86
87 It 'Does not duplicate sections when CollectionContent already holds rendered artifacts' {
88 $content = "# Test Collection`n`nBody text.`n`n## Included Artifacts`n`n<!-- BEGIN AUTO-GENERATED ARTIFACTS -->`n`n### Chat Agents`n`n| Agent | Description |`n|-------|-------------|`n| test-agent | desc |`n`n<!-- END AUTO-GENERATED ARTIFACTS -->`n"
89 $result = New-PluginReadmeContent -Collection $baseCollection -Items $items -CollectionContent $content
90 [regex]::Matches($result, '(?m)^## Included Artifacts\r?$').Count | Should -Be 1
91 [regex]::Matches($result, '(?m)^## Overview\r?$').Count | Should -Be 1
92 [regex]::Matches($result, '(?m)^## Install\r?$').Count | Should -Be 1
93 [regex]::Matches($result, '(?m)^# ').Count | Should -Be 1
94 [regex]::Matches($result, '<!-- BEGIN AUTO-GENERATED ARTIFACTS -->').Count | Should -Be 1
95 $result | Should -Not -Match '(?m)^## Agents\r?$'
96 $result | Should -Not -Match '(?m)^## Commands\r?$'
97 }
98
99 It 'Emits Overview section when CollectionContent has body text' {
100 $content = "# Test Collection`n`nSome description.`n"
101 $result = New-PluginReadmeContent -Collection $baseCollection -Items $items -CollectionContent $content
102 $result | Should -Match '## Overview'
103 $result | Should -Match 'Some description\.'
104 }
105
106 It 'Omits Overview section when CollectionContent is null' {
107 $result = New-PluginReadmeContent -Collection $baseCollection -Items $items -CollectionContent $null
108 $result | Should -Not -Match '## Overview'
109 }
110
111 It 'Omits Overview section when CollectionContent is whitespace' {
112 $result = New-PluginReadmeContent -Collection $baseCollection -Items $items -CollectionContent ' '
113 $result | Should -Not -Match '## Overview'
114 }
115}
116
117Describe 'Get-PluginItemName' {
118 It 'Strips .agent.md to .md for agents' {
119 $result = Get-PluginItemName -FileName 'task-researcher.agent.md' -Kind 'agent'
120 $result | Should -Be 'task-researcher.md'
121 }
122
123 It 'Strips .prompt.md to .md for prompts' {
124 $result = Get-PluginItemName -FileName 'gen-plan.prompt.md' -Kind 'prompt'
125 $result | Should -Be 'gen-plan.md'
126 }
127
128 It 'Preserves .instructions.md suffix' {
129 $result = Get-PluginItemName -FileName 'csharp.instructions.md' -Kind 'instruction'
130 $result | Should -Be 'csharp.instructions.md'
131 }
132
133 It 'Returns skill directory name unchanged' {
134 $result = Get-PluginItemName -FileName 'video-to-gif' -Kind 'skill'
135 $result | Should -Be 'video-to-gif'
136 }
137}
138
139Describe 'Get-PluginItemSubpath' {
140 It 'Extracts single-level collection subdirectory for agents' {
141 $result = Get-PluginItemSubpath -Path '.github/agents/hve-core/rpi-agent.agent.md' -Kind 'agent'
142 $result | Should -Be 'hve-core'
143 }
144
145 It 'Extracts nested subdirectory path for agent subagents' {
146 $result = Get-PluginItemSubpath -Path '.github/agents/hve-core/subagents/researcher-subagent.agent.md' -Kind 'agent'
147 $result | Should -Be 'hve-core/subagents'
148 }
149
150 It 'Returns empty string when item is at kind root' {
151 $result = Get-PluginItemSubpath -Path '.github/agents/root-agent.agent.md' -Kind 'agent'
152 $result | Should -Be ''
153 }
154
155 It 'Extracts subdirectory for instructions' {
156 $result = Get-PluginItemSubpath -Path '.github/instructions/shared/hve-core-location.instructions.md' -Kind 'instruction'
157 $result | Should -Be 'shared'
158 }
159
160 It 'Extracts subdirectory for skills' {
161 $result = Get-PluginItemSubpath -Path '.github/skills/shared/pr-reference' -Kind 'skill'
162 $result | Should -Be 'shared'
163 }
164
165 It 'Handles backslash-separated paths' {
166 $result = Get-PluginItemSubpath -Path '.github\agents\hve-core\rpi-agent.agent.md' -Kind 'agent'
167 $result | Should -Be 'hve-core'
168 }
169
170 It 'Extracts subdirectory for prompts' {
171 $result = Get-PluginItemSubpath -Path '.github/prompts/hve-core/git-commit-message.prompt.md' -Kind 'prompt'
172 $result | Should -Be 'hve-core'
173 }
174
175 It 'Returns empty string when path does not match kind prefix' {
176 $result = Get-PluginItemSubpath -Path 'some/other/path/file.md' -Kind 'agent'
177 $result | Should -Be ''
178 }
179}
180
181Describe 'New-PluginManifestContent' {
182 It 'Returns hashtable with name, description, and version' {
183 $result = New-PluginManifestContent -CollectionId 'test-plugin' -Description 'A test plugin' -Version '2.0.0'
184 $result.name | Should -Be 'test-plugin'
185 $result.description | Should -Be 'A test plugin'
186 $result.version | Should -Be '2.0.0'
187 }
188
189 It 'Includes explicit path arrays when provided' {
190 $result = New-PluginManifestContent `
191 -CollectionId 'with-paths' -Description 'desc' -Version '1.0.0' `
192 -AgentPaths @('agents/core/') `
193 -CommandPaths @('commands/core/', 'commands/ado/') `
194 -SkillPaths @('skills/shared/')
195 $result.agents | Should -Be @('agents/core/')
196 $result.commands | Should -Be @('commands/ado/', 'commands/core/')
197 $result.skills | Should -Be @('skills/shared/')
198 }
199
200 It 'Omits component keys when no paths provided' {
201 $result = New-PluginManifestContent -CollectionId 'minimal' -Description 'desc' -Version '1.0.0'
202 $result.Contains('agents') | Should -BeFalse
203 $result.Contains('commands') | Should -BeFalse
204 $result.Contains('skills') | Should -BeFalse
205 }
206
207 It 'Returns ordered hashtable' {
208 $result = New-PluginManifestContent -CollectionId 'ordered-test' -Description 'desc' -Version '1.0.0'
209 $result | Should -BeOfType [System.Collections.Specialized.OrderedDictionary]
210 }
211}
212
213Describe 'Get-PluginSubdirectory' {
214 It 'Maps agent to agents' {
215 $result = Get-PluginSubdirectory -Kind 'agent'
216 $result | Should -Be 'agents'
217 }
218
219 It 'Maps prompt to commands' {
220 $result = Get-PluginSubdirectory -Kind 'prompt'
221 $result | Should -Be 'commands'
222 }
223
224 It 'Maps instruction to instructions' {
225 $result = Get-PluginSubdirectory -Kind 'instruction'
226 $result | Should -Be 'instructions'
227 }
228
229 It 'Maps skill to skills' {
230 $result = Get-PluginSubdirectory -Kind 'skill'
231 $result | Should -Be 'skills'
232 }
233}
234
235Describe 'Write-PluginDirectory - DryRun mode' {
236 BeforeAll {
237 $script:repoRoot = Join-Path $TestDrive 'wpd-repo'
238 $script:pluginsDir = Join-Path $TestDrive 'wpd-plugins'
239 New-Item -ItemType Directory -Path $script:repoRoot -Force | Out-Null
240 New-Item -ItemType Directory -Path $script:pluginsDir -Force | Out-Null
241
242 # Create a valid agent file with frontmatter
243 $agentDir = Join-Path $script:repoRoot '.github/agents/test'
244 New-Item -ItemType Directory -Path $agentDir -Force | Out-Null
245 Set-Content -Path (Join-Path $agentDir 'example.agent.md') -Value "---`ndescription: An example agent`n---`nAgent body"
246
247 # Create a valid skill directory with SKILL.md
248 $skillDir = Join-Path $script:repoRoot '.github/skills/test/my-skill'
249 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
250 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value "---`ndescription: A skill`n---`nSkill body"
251
252 # Create shared dirs
253 New-Item -ItemType Directory -Path (Join-Path $script:repoRoot 'docs/templates') -Force | Out-Null
254 New-Item -ItemType Directory -Path (Join-Path $script:repoRoot 'scripts/lib') -Force | Out-Null
255 }
256
257 It 'Completes DryRun without creating files for agents' {
258 $collection = @{
259 id = 'dryrun-test'
260 name = 'DryRun Test'
261 description = 'Testing DryRun mode'
262 items = @(
263 @{
264 path = '.github/agents/test/example.agent.md'
265 kind = 'agent'
266 }
267 )
268 }
269
270 $result = Write-PluginDirectory -Collection $collection -PluginsDir $script:pluginsDir `
271 -RepoRoot $script:repoRoot -Version '1.0.0' -DryRun
272
273 $result.Success | Should -BeTrue
274 $result.AgentCount | Should -Be 1
275
276 # Verify no actual files were created
277 $pluginDir = Join-Path $script:pluginsDir 'dryrun-test'
278 Test-Path -Path $pluginDir | Should -BeFalse
279 }
280
281 It 'Includes collection subdirectory in GeneratedFiles path' {
282 $collection = @{
283 id = 'subpath-test'
284 name = 'Subpath Test'
285 description = 'Testing subpath in destination'
286 items = @(
287 @{
288 path = '.github/agents/test/example.agent.md'
289 kind = 'agent'
290 }
291 )
292 }
293
294 $result = Write-PluginDirectory -Collection $collection -PluginsDir $script:pluginsDir `
295 -RepoRoot $script:repoRoot -Version '1.0.0' -DryRun
296
297 $result.Success | Should -BeTrue
298 # GeneratedFiles should contain a path with the 'test' subdirectory preserved
299 $agentPaths = @($result.GeneratedFiles | Where-Object { $_ -match 'agents' -and $_ -match 'example' })
300 $agentPaths | Should -Not -BeNullOrEmpty
301 $agentPaths[0] | Should -Match 'agents[/\\]test[/\\]example\.md$'
302 }
303
304 It 'Completes DryRun with skill items' {
305 $collection = @{
306 id = 'dryrun-skill'
307 name = 'DryRun Skill'
308 description = 'Testing DryRun with skills'
309 items = @(
310 @{
311 path = '.github/skills/test/my-skill'
312 kind = 'skill'
313 }
314 )
315 }
316
317 $result = Write-PluginDirectory -Collection $collection -PluginsDir $script:pluginsDir `
318 -RepoRoot $script:repoRoot -Version '1.0.0' -DryRun
319
320 $result.Success | Should -BeTrue
321 $result.SkillCount | Should -Be 1
322 }
323
324 It 'Handles source file not found for non-skill items' {
325 $collection = @{
326 id = 'missing-source'
327 name = 'Missing Source'
328 description = 'Non-existent source file'
329 items = @(
330 @{
331 path = '.github/agents/test/nonexistent.agent.md'
332 kind = 'agent'
333 }
334 )
335 }
336
337 $result = Write-PluginDirectory -Collection $collection -PluginsDir $script:pluginsDir `
338 -RepoRoot $script:repoRoot -Version '1.0.0' -DryRun
339
340 $result.Success | Should -BeTrue
341 $result.AgentCount | Should -Be 1
342 }
343
344 It 'Warns when shared directory is missing' {
345 $emptyRepo = Join-Path $TestDrive 'empty-repo'
346 New-Item -ItemType Directory -Path $emptyRepo -Force | Out-Null
347
348 # Create agent file but no shared directories
349 $agentDir = Join-Path $emptyRepo '.github/agents/test'
350 New-Item -ItemType Directory -Path $agentDir -Force | Out-Null
351 Set-Content -Path (Join-Path $agentDir 'a.agent.md') -Value "---`ndescription: test`n---"
352
353 $collection = @{
354 id = 'no-shared'
355 name = 'No Shared'
356 description = 'Missing shared dirs'
357 items = @(
358 @{
359 path = '.github/agents/test/a.agent.md'
360 kind = 'agent'
361 }
362 )
363 }
364
365 $result = Write-PluginDirectory -Collection $collection -PluginsDir $script:pluginsDir `
366 -RepoRoot $emptyRepo -Version '1.0.0' -DryRun
367
368 $result.Success | Should -BeTrue
369 }
370}
371
372Describe 'Test-SymlinkCapability' {
373 It 'Returns a boolean' {
374 $result = Test-SymlinkCapability
375 $result | Should -BeOfType [bool]
376 }
377
378 It 'Cleans up probe directory' {
379 $probeDirPattern = Join-Path ([System.IO.Path]::GetTempPath()) "hve-symlink-probe-$PID"
380 Test-SymlinkCapability | Out-Null
381 Test-Path $probeDirPattern | Should -BeFalse
382 }
383}
384
385Describe 'New-PluginLink' {
386 BeforeAll {
387 $script:linkRoot = Join-Path $TestDrive 'link-test'
388 New-Item -ItemType Directory -Path $script:linkRoot -Force | Out-Null
389 }
390
391 It 'Writes text stub when SymlinkCapable is false' {
392 $src = Join-Path $script:linkRoot 'src-stub.txt'
393 Set-Content -Path $src -Value 'content' -NoNewline
394 $dest = Join-Path $script:linkRoot 'dest-stub.txt'
395
396 New-PluginLink -SourcePath $src -DestinationPath $dest
397
398 Test-Path $dest | Should -BeTrue
399 $stubContent = [System.IO.File]::ReadAllText($dest)
400 $expectedPath = [System.IO.Path]::GetRelativePath((Split-Path -Parent $dest), $src) -replace '\\', '/'
401 $stubContent | Should -Be $expectedPath
402 }
403
404 It 'Creates parent directory when destination parent does not exist' {
405 $src = Join-Path $script:linkRoot 'src-parent.txt'
406 Set-Content -Path $src -Value 'data' -NoNewline
407 $dest = Join-Path $script:linkRoot 'nested/deep/dest-parent.txt'
408
409 New-PluginLink -SourcePath $src -DestinationPath $dest
410
411 Test-Path $dest | Should -BeTrue
412 }
413}
414
415Describe 'Repair-PluginSymlinkIndex' {
416 Context 'When PluginsDir does not exist' {
417 It 'Returns 0' {
418 $result = Repair-PluginSymlinkIndex `
419 -PluginsDir (Join-Path $TestDrive 'nonexistent') `
420 -RepoRoot $TestDrive
421 $result | Should -Be 0
422 }
423 }
424
425 Context 'In a git repository with text stubs' {
426 BeforeAll {
427 $script:repoRoot = Join-Path $TestDrive 'symlink-repo'
428 New-Item -ItemType Directory -Path $script:repoRoot -Force | Out-Null
429
430 Push-Location $script:repoRoot
431 try {
432 git init --quiet 2>$null
433 git config user.email 'test@test.com'
434 git config user.name 'Test'
435
436 $script:pluginsDir = Join-Path $script:repoRoot 'plugins'
437 New-Item -ItemType Directory -Path $script:pluginsDir -Force | Out-Null
438
439 # Valid text stub: small, starts with ../, no newlines
440 [System.IO.File]::WriteAllText(
441 (Join-Path $script:pluginsDir 'valid-stub.md'),
442 '../some/source.md'
443 )
444
445 # Large file (>500 bytes) — skipped by size filter
446 [System.IO.File]::WriteAllText(
447 (Join-Path $script:pluginsDir 'large-file.md'),
448 ('x' * 501)
449 )
450
451 # Non-stub content — skipped by pattern filter
452 [System.IO.File]::WriteAllText(
453 (Join-Path $script:pluginsDir 'non-stub.md'),
454 '# Regular markdown'
455 )
456
457 # Stub with newline — skipped by newline filter
458 [System.IO.File]::WriteAllText(
459 (Join-Path $script:pluginsDir 'newline-stub.md'),
460 "../path/file.md`n"
461 )
462
463 git add -- plugins/ 2>$null
464 git commit -m 'initial' --quiet 2>$null
465 } finally {
466 Pop-Location
467 }
468 }
469
470 It 'Counts only valid stubs in DryRun mode' {
471 Push-Location $script:repoRoot
472 try {
473 $result = Repair-PluginSymlinkIndex `
474 -PluginsDir $script:pluginsDir `
475 -RepoRoot $script:repoRoot -DryRun
476 $result | Should -Be 1
477 } finally {
478 Pop-Location
479 }
480 }
481
482 It 'Does not modify index in DryRun mode' {
483 Push-Location $script:repoRoot
484 try {
485 $before = git ls-files --stage -- plugins/valid-stub.md 2>$null
486 Repair-PluginSymlinkIndex `
487 -PluginsDir $script:pluginsDir `
488 -RepoRoot $script:repoRoot -DryRun | Out-Null
489 $after = git ls-files --stage -- plugins/valid-stub.md 2>$null
490 $before | Should -Be $after
491 } finally {
492 Pop-Location
493 }
494 }
495
496 It 'Re-indexes tracked text stub as mode 120000' {
497 Push-Location $script:repoRoot
498 try {
499 $result = Repair-PluginSymlinkIndex `
500 -PluginsDir $script:pluginsDir `
501 -RepoRoot $script:repoRoot
502 $result | Should -Be 1
503
504 $lsOutput = git ls-files --stage -- plugins/valid-stub.md 2>$null
505 $lsOutput | Should -Match '^120000'
506 } finally {
507 Pop-Location
508 }
509 }
510
511 It 'Skips entries already at mode 120000' {
512 Push-Location $script:repoRoot
513 try {
514 # Previous test fixed the stub; second run finds nothing new
515 $result = Repair-PluginSymlinkIndex `
516 -PluginsDir $script:pluginsDir `
517 -RepoRoot $script:repoRoot
518 $result | Should -Be 0
519 } finally {
520 Pop-Location
521 }
522 }
523 }
524}