microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/prerelease-version-override

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/tests/plugins/PluginHelpers.Tests.ps1

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