Files

433 lines
26 KiB
PowerShell
Raw Permalink Normal View History

2026-09-20 15:59:30 +08:00
[CmdletBinding()]
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$repositoryRoot = Split-Path -Parent $PSScriptRoot
$modulePath = Join-Path $repositoryRoot 'scripts\XasSkills.psm1'
if (-not (Test-Path -LiteralPath $modulePath -PathType Leaf)) {
throw "Installation module does not exist: $modulePath"
}
Import-Module $modulePath -Force
$installationModule = Get-Module | Where-Object { $_.Path -eq $modulePath } | Select-Object -First 1
$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('xas-install-tests-' + [guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory -Path $testRoot
$script:TestCount = 0
$script:FailureCount = 0
$script:SkipCount = 0
$script:CaseCount = 0
$script:TestLinks = New-Object 'System.Collections.Generic.List[string]'
$utf8WithoutBom = New-Object Text.UTF8Encoding($false)
function Write-TestFile {
param([string]$Path, [string]$Content)
$parent = Split-Path -Parent $Path
if (-not (Test-Path -LiteralPath $parent)) {
$null = New-Item -ItemType Directory -Path $parent -Force
}
[IO.File]::WriteAllText($Path, $Content, $utf8WithoutBom)
}
function New-TestContext {
$script:CaseCount++
$unicodeName = ([string][char]0x4E2D) + ([string][char]0x6587) + ' space'
$root = Join-Path $testRoot (('{0:D2}-' -f $script:CaseCount) + $unicodeName)
$source = Join-Path $root 'source checkout'
$null = New-Item -ItemType Directory -Path (Join-Path $source 'skills') -Force
[pscustomobject]@{
Root = $root
RepositoryRoot = $source
SkillsRoot = Join-Path $root 'personal skills'
}
}
function New-TestSkill {
param($Context, [string]$Name = 'xas-one')
$skillPath = Join-Path (Join-Path $Context.RepositoryRoot 'skills') $Name
$markdown = "---`nname: $Name`ndescription: A self-contained installation test skill.`n---`n`n# Test skill`n`nRead the bundled references before proceeding.`n"
Write-TestFile (Join-Path $skillPath 'SKILL.md') $markdown
Write-TestFile (Join-Path $skillPath 'references\guide.md') 'original guide'
Write-TestFile (Join-Path $skillPath 'scripts\example.ps1') "Write-Output 'fixture only'"
Write-TestFile (Join-Path $skillPath 'assets\.hidden-fixture') 'hidden resource'
$hiddenPath = Join-Path $skillPath 'assets\.hidden-fixture'
[IO.File]::SetAttributes($hiddenPath, [IO.FileAttributes]::Hidden)
$null = New-Item -ItemType Directory -Path (Join-Path $skillPath 'assets\empty directory') -Force
$skillPath
}
function Assert-True {
param([bool]$Condition, [string]$Message)
if (-not $Condition) { throw $Message }
}
function Assert-Equal {
param($Actual, $Expected, [string]$Message)
if ($Actual -cne $Expected) {
throw ($Message + ". Expected: [$Expected]; actual: [$Actual].")
}
}
function Assert-Throws {
param([scriptblock]$Action, [string]$Message)
$didThrow = $false
try { $null = & $Action } catch { $didThrow = $true }
Assert-True $didThrow $Message
}
function Get-TestSnapshot {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return '<missing>' }
$base = [IO.Path]::GetFullPath($Path).TrimEnd('\', '/')
$lines = foreach ($item in (Get-ChildItem -LiteralPath $Path -Recurse -Force | Sort-Object FullName)) {
$relative = $item.FullName.Substring($base.Length + 1).Replace('\', '/')
if ($item.PSIsContainer) { 'D:' + $relative }
else { 'F:' + $relative + ':' + (Get-FileHash -LiteralPath $item.FullName -Algorithm SHA256).Hash }
}
(@($lines) -join "`n")
}
function Invoke-Test {
param([string]$Name, [scriptblock]$Action)
$script:TestCount++
try {
& $Action
Write-Host ('PASS ' + $Name)
}
catch {
$script:FailureCount++
Write-Host ('FAIL ' + $Name + ': ' + $_.Exception.Message) -ForegroundColor Red
Write-Host $_.ScriptStackTrace
}
}
function Invoke-TestEntryPoint {
param([string]$ScriptPath, [string]$Arguments, [string]$ProfilePath)
$executableName = if ($PSVersionTable.PSEdition -eq 'Core') { 'pwsh.exe' } else { 'powershell.exe' }
$literalPath = $ScriptPath.Replace("'", "''")
$command = "`$ErrorActionPreference = 'Stop'; & '$literalPath' $Arguments"
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command))
$startInfo = New-Object Diagnostics.ProcessStartInfo
$startInfo.FileName = Join-Path $PSHOME $executableName
$startInfo.Arguments = '-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ' + $encoded
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
$startInfo.EnvironmentVariables['USERPROFILE'] = $ProfilePath
$process = New-Object Diagnostics.Process
$process.StartInfo = $startInfo
$null = $process.Start()
$outputTask = $process.StandardOutput.ReadToEndAsync()
$errorTask = $process.StandardError.ReadToEndAsync()
$process.WaitForExit()
$output = $outputTask.GetAwaiter().GetResult()
$errorOutput = $errorTask.GetAwaiter().GetResult()
$result = [pscustomobject]@{ ExitCode = $process.ExitCode; Output = $output; Error = $errorOutput }
$process.Dispose()
$result
}
try {
Invoke-Test 'catalog uses direct children and excludes templates' {
$context = New-TestContext
$one = New-TestSkill $context 'xas-one'
$null = New-TestSkill $context 'xas-two'
Write-TestFile (Join-Path $context.RepositoryRoot 'templates\skill-template\SKILL.md') "---`nname: xas-template`ndescription: Template only.`n---`n"
Write-TestFile (Join-Path $context.RepositoryRoot 'skills\nested-holder\nested-skill\SKILL.md') "---`nname: nested-skill`ndescription: Nested fixture.`n---`n"
$catalog = @(Get-XasSkillCatalog -RepositoryRoot $context.RepositoryRoot)
Assert-Equal $catalog.Count 2 'Only the two direct skills should be discovered'
Assert-Equal ((@($catalog.Name | Sort-Object)) -join ',') 'xas-one,xas-two' 'Catalog names should match'
$entry = $catalog | Where-Object Name -eq 'xas-one'
Assert-Equal $entry.Path $one 'Catalog should return the full source path'
Assert-True (-not [string]::IsNullOrWhiteSpace($entry.Description)) 'Catalog description should be populated'
}
Invoke-Test 'catalog accepts comments, quoted strings and nonempty description blocks' {
$examples = @(
@{ Body = "name: xas-one # name comment`ndescription: A plain description # description comment"; Expected = 'A plain description' },
@{ Body = "name: 'xas-one' # comment`ndescription: 'It''s a # quoted description' # comment"; Expected = "It's a # quoted description" },
@{ Body = ('name: "xas-one" # comment' + "`n" + 'description: "Use \"quotes\", # markers and C:\\work\\notes" # comment'); Expected = 'Use "quotes", # markers and C:\work\notes' },
@{ Body = "name: xas-one`ndescription: Document#fragment"; Expected = 'Document#fragment' },
@{ Body = "name: xas-one`ndescription: '123'"; Expected = '123' },
@{ Body = "name: xas-one`ndescription: | # literal block`n First line`n Second line"; Expected = "First line`nSecond line`n" },
@{ Body = "name: xas-one`ndescription: |-`n First line`n Second line"; Expected = "First line`nSecond line" },
@{ Body = "name: xas-one`ndescription: >`n First line`n continues here"; Expected = "First line continues here`n" },
@{ Body = "name: xas-one`ndescription: >- # folded block`n First line`n continues here`n`n Next paragraph"; Expected = "First line continues here`nNext paragraph" }
)
foreach ($example in $examples) {
$context = New-TestContext
$source = New-TestSkill $context
Write-TestFile (Join-Path $source 'SKILL.md') ("---`n" + $example.Body + "`n---`n`n# Fixture`n")
$catalog = @(Get-XasSkillCatalog -RepositoryRoot $context.RepositoryRoot)
Assert-Equal $catalog.Count 1 'Valid scalar syntax should remain discoverable'
Assert-Equal $catalog[0].Name 'xas-one' 'YAML comments and quotes should not become part of the skill name'
Assert-Equal $catalog[0].Description $example.Expected ('Description should be parsed correctly: ' + $example.Body)
}
}
Invoke-Test 'catalog rejects empty, non-string, duplicate and unsupported metadata' {
$bodies = @(
"name: xas-one`ndescription: null",
"name: xas-one`ndescription: ~",
"name: xas-one`ndescription: true",
"name: xas-one`ndescription: False",
"name: xas-one`ndescription: yes",
"name: xas-one`ndescription: 123",
"name: xas-one`ndescription: -1.5",
"name: xas-one`ndescription: 1e3",
"name: xas-one`ndescription: 0xFF",
"name: xas-one`ndescription: |",
"name: xas-one`ndescription: >- # empty block`n ",
"name: xas-one`nname: xas-one`ndescription: Description",
"name: xas-one`ndescription: First`ndescription: Second",
"name: xas-one`ndescription: [one, two]",
"name: xas-one`ndescription: &anchor Description",
"name: xas-one`ndescription: *alias",
"name: xas-one`ndescription: !!str Description",
"name: xas-one`ndescription: |2`n Description",
('name: xas-one' + "`n" + 'description: "\x41"'),
"name: xas-one`ndescription: First line`n unsupported continuation"
)
foreach ($body in $bodies) {
$context = New-TestContext
$source = New-TestSkill $context
Write-TestFile (Join-Path $source 'SKILL.md') ("---`n" + $body + "`n---`n`n# Fixture`n")
Assert-Throws { Get-XasSkillCatalog -RepositoryRoot $context.RepositoryRoot } ('Invalid or unsupported YAML metadata should be rejected: ' + $body)
}
}
Invoke-Test 'complete resource copy, managed state and repeated install' {
$context = New-TestContext
$source = New-TestSkill $context
$result = @(Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $context.SkillsRoot -Skill @('xas-one'))
Assert-Equal $result.Count 1 'Installation should return one result'
Assert-Equal $result[0].Status 'Installed' 'First install should report Installed'
$destination = Join-Path $context.SkillsRoot 'xas-one'
foreach ($file in @('SKILL.md', 'references\guide.md', 'scripts\example.ps1', 'assets\.hidden-fixture')) {
Assert-Equal (Get-FileHash -LiteralPath (Join-Path $destination $file)).Hash (Get-FileHash -LiteralPath (Join-Path $source $file)).Hash "Resource should be copied: $file"
}
Assert-True (Test-Path -LiteralPath (Join-Path $destination 'assets\empty directory') -PathType Container) 'Empty resource directory should be copied'
$state = Get-Content -LiteralPath (Join-Path $destination '.xas-install.json') -Raw | ConvertFrom-Json
Assert-True ($null -ne $state.schemaVersion) 'Managed state should have a schema version'
Assert-Equal $state.repository 'https://code.it.aisuan.cn/wenhuadian/xas-skills.git' 'Managed state should use the company repository'
Assert-Equal $state.skill 'xas-one' 'Managed state should identify the skill'
Assert-True (@($state.files).Count -ge 4) 'Managed state should inventory resource files'
Assert-True (@($state.directories).Count -ge 3) 'Managed state should inventory directories'
foreach ($file in $state.files) {
Assert-True (-not [string]::IsNullOrWhiteSpace($file.path)) 'Inventory file should have a path'
Assert-True ($file.sha256 -match '^[a-fA-F0-9]{64}$') 'Inventory file should have a SHA-256 hash'
}
$before = Get-TestSnapshot $context.SkillsRoot
$again = @(Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $context.SkillsRoot -Skill @('xas-one'))
Assert-Equal $again[0].Status 'Skipped' 'Identical install should be skipped'
Assert-Equal (Get-TestSnapshot $context.SkillsRoot) $before 'Skipped installation should not change files'
Assert-Equal ((@(Get-XasInstalledSkillNames -SkillsRoot $context.SkillsRoot)) -join ',') 'xas-one' 'Installed skill discovery should find managed skill'
}
Invoke-Test 'selection and arbitrary current directory' {
$context = New-TestContext
$null = New-TestSkill $context 'xas-one'
$null = New-TestSkill $context 'xas-two'
Push-Location ([IO.Path]::GetTempPath())
try {
$result = @(Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $context.SkillsRoot -Skill @('xas-two'))
}
finally { Pop-Location }
Assert-Equal $result[0].Name 'xas-two' 'Selected skill should be installed'
Assert-True (-not (Test-Path -LiteralPath (Join-Path $context.SkillsRoot 'xas-one'))) 'Unselected skill should remain absent'
Assert-True (Test-Path -LiteralPath (Join-Path $context.SkillsRoot 'xas-two\SKILL.md')) 'Selected skill should be present'
}
Invoke-Test 'unmanaged destination collision is preserved' {
$context = New-TestContext
$null = New-TestSkill $context
Write-TestFile (Join-Path $context.SkillsRoot 'xas-one\keep.txt') 'belongs to somebody else'
$before = Get-TestSnapshot $context.SkillsRoot
Assert-Throws { Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $context.SkillsRoot -Skill @('xas-one') } 'Unmanaged destination must be rejected'
Assert-Equal (Get-TestSnapshot $context.SkillsRoot) $before 'Unmanaged destination must be preserved'
}
Invoke-Test 'foreign repository state and malformed state both block replacement' {
foreach ($change in @('foreign-source', 'malformed-json')) {
$context = New-TestContext
$null = New-TestSkill $context
$null = Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $context.SkillsRoot -Skill @('xas-one')
$recordPath = Join-Path $context.SkillsRoot 'xas-one\.xas-install.json'
if ($change -eq 'foreign-source') {
$state = Get-Content -LiteralPath $recordPath -Raw | ConvertFrom-Json
$state.repository = 'https://example.invalid/another-team/skills.git'
Write-TestFile $recordPath ($state | ConvertTo-Json -Depth 8)
} else { Write-TestFile $recordPath '{ invalid json' }
$before = Get-TestSnapshot $context.SkillsRoot
Assert-Throws { Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $context.SkillsRoot -Skill @('xas-one') } "Installation should reject $change state"
Assert-Equal (Get-TestSnapshot $context.SkillsRoot) $before "Rejected $change state and installed files must remain unchanged"
}
}
foreach ($localChange in @('modified', 'added', 'deleted', 'empty-directory-added', 'empty-directory-deleted')) {
Invoke-Test ("local $localChange resources block update") {
$context = New-TestContext
$source = New-TestSkill $context
$null = Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $context.SkillsRoot -Skill @('xas-one')
$destination = Join-Path $context.SkillsRoot 'xas-one'
switch ($localChange) {
'modified' { Write-TestFile (Join-Path $destination 'references\guide.md') 'user edit' }
'added' { Write-TestFile (Join-Path $destination 'user-added.txt') 'user note' }
'deleted' { Remove-Item -LiteralPath (Join-Path $destination 'references\guide.md') }
'empty-directory-added' { $null = New-Item -ItemType Directory -Path (Join-Path $destination 'user empty folder') }
'empty-directory-deleted' { Remove-Item -LiteralPath (Join-Path $destination 'assets\empty directory') }
}
Write-TestFile (Join-Path $source 'references\guide.md') 'upstream update'
$before = Get-TestSnapshot $context.SkillsRoot
Assert-Throws { Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $context.SkillsRoot -Skill @('xas-one') } "Local $localChange resources must block update"
Assert-Equal (Get-TestSnapshot $context.SkillsRoot) $before 'Local state must survive the failed update'
}
}
Invoke-Test 'update replaces changed content and removes deleted upstream resources' {
$context = New-TestContext
$source = New-TestSkill $context
$null = Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $context.SkillsRoot -Skill @('xas-one')
Remove-Item -LiteralPath (Join-Path $source 'scripts\example.ps1')
Remove-Item -LiteralPath (Join-Path $source 'assets\empty directory')
Write-TestFile (Join-Path $source 'references\guide.md') 'updated guide'
Write-TestFile (Join-Path $source 'assets\new-resource.txt') 'new upstream resource'
$result = @(Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $context.SkillsRoot -Skill @('xas-one'))
Assert-Equal $result[0].Status 'Updated' 'Changed source should report Updated'
$destination = Join-Path $context.SkillsRoot 'xas-one'
Assert-Equal ([IO.File]::ReadAllText((Join-Path $destination 'references\guide.md'))) 'updated guide' 'Changed resource should be replaced'
Assert-True (-not (Test-Path -LiteralPath (Join-Path $destination 'scripts\example.ps1'))) 'Upstream deleted file should be removed'
Assert-True (-not (Test-Path -LiteralPath (Join-Path $destination 'assets\empty directory'))) 'Upstream deleted empty directory should be removed'
Assert-True (Test-Path -LiteralPath (Join-Path $destination 'assets\new-resource.txt')) 'New upstream file should be copied'
$again = @(Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $context.SkillsRoot -Skill @('xas-one'))
Assert-Equal $again[0].Status 'Skipped' 'Updated installation state should validate on the next run'
}
Invoke-Test 'batch preflight prevents earlier skill writes when a later skill conflicts' {
$context = New-TestContext
$null = New-TestSkill $context 'xas-one'
$null = New-TestSkill $context 'xas-two'
$null = Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $context.SkillsRoot -Skill @('xas-one')
Write-TestFile (Join-Path $context.SkillsRoot 'xas-one\references\guide.md') 'user edit'
$before = Get-TestSnapshot $context.SkillsRoot
Assert-Throws { Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $context.SkillsRoot -Skill @('xas-two', 'xas-one') } 'Batch should fail preflight on conflicting second skill'
Assert-Equal (Get-TestSnapshot $context.SkillsRoot) $before 'Batch preflight failure should perform no writes'
}
Invoke-Test 'preview install and preview update do not write' {
$context = New-TestContext
$source = New-TestSkill $context
$before = Get-TestSnapshot $context.Root
$result = @(Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $context.SkillsRoot -Skill @('xas-one') -Preview)
Assert-Equal $result[0].Status 'WouldInstall' 'Preview should report WouldInstall'
Assert-Equal (Get-TestSnapshot $context.Root) $before 'Preview install should not create even the target root'
$null = Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $context.SkillsRoot -Skill @('xas-one')
Write-TestFile (Join-Path $source 'references\guide.md') 'updated guide'
$before = Get-TestSnapshot $context.Root
$result = @(Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $context.SkillsRoot -Skill @('xas-one') -Preview)
Assert-Equal $result[0].Status 'WouldUpdate' 'Preview should report WouldUpdate'
Assert-Equal (Get-TestSnapshot $context.Root) $before 'Preview update should not change files'
}
Invoke-Test 'invalid names and overlapping roots are rejected' {
$context = New-TestContext
$source = New-TestSkill $context
foreach ($name in @('../xas-one', '..\xas-one', 'xas-one/child', '.', 'missing-skill', $source)) {
Assert-Throws { Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $context.SkillsRoot -Skill @($name) } "Invalid or unknown skill name must be rejected: $name"
}
foreach ($target in @((Join-Path $context.RepositoryRoot 'skills'), (Join-Path $source 'nested target'), $context.Root)) {
Assert-Throws { Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $target -Skill @('xas-one') } "Overlapping roots must be rejected: $target"
}
Assert-True (-not (Test-Path -LiteralPath $context.SkillsRoot)) 'Invalid requests should not create target root'
}
Invoke-Test 'source directory link is rejected' {
$context = New-TestContext
$source = New-TestSkill $context
$external = Join-Path $context.Root 'external fixture'
$null = New-Item -ItemType Directory -Path $external
Write-TestFile (Join-Path $external 'outside.txt') 'not a bundled resource'
$link = Join-Path $source 'assets\linked directory'
try {
$null = New-Item -ItemType Junction -Path $link -Target $external -ErrorAction Stop
$script:TestLinks.Add($link)
}
catch {
$script:SkipCount++
Write-Host ('SKIP Junction fixture unavailable: ' + $_.Exception.Message)
return
}
Assert-Throws { Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $context.SkillsRoot -Skill @('xas-one') } 'Linked source resource must be rejected'
Assert-True (-not (Test-Path -LiteralPath $context.SkillsRoot)) 'Rejected source link should not create target root'
Assert-Equal ([IO.File]::ReadAllText((Join-Path $external 'outside.txt'))) 'not a bundled resource' 'Link target must remain unchanged'
}
Invoke-Test 'replacement failure restores the old installed skill' {
$context = New-TestContext
$source = New-TestSkill $context
$null = Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $context.SkillsRoot -Skill @('xas-one')
$before = Get-TestSnapshot $context.SkillsRoot
Write-TestFile (Join-Path $source 'references\guide.md') 'new upstream version'
& $installationModule {
$script:XasTestOriginalMove = (Get-Item Function:Move-XasDirectory).ScriptBlock
$script:XasTestMoveCount = 0
Set-Item -Path Function:script:Move-XasDirectory -Value {
param([string]$Source, [string]$Destination)
$script:XasTestMoveCount++
if ($script:XasTestMoveCount -eq 2) { throw 'Injected failure while replacing installed skill.' }
& $script:XasTestOriginalMove -Source $Source -Destination $Destination
}
}
try {
Assert-Throws { Invoke-XasInstall -RepositoryRoot $context.RepositoryRoot -SkillsRoot $context.SkillsRoot -Skill @('xas-one') } 'Injected replacement failure should be reported'
}
finally {
& $installationModule {
Set-Item -Path Function:script:Move-XasDirectory -Value $script:XasTestOriginalMove
Remove-Variable -Scope Script -Name XasTestOriginalMove, XasTestMoveCount
}
}
Assert-Equal (Get-TestSnapshot $context.SkillsRoot) $before 'Replacement failure should restore old files and state without leftovers'
}
Invoke-Test 'install entry point lists and previews with an isolated user profile' {
$context = New-TestContext
$null = New-TestSkill $context 'xas-one'
$installerPath = Join-Path $repositoryRoot 'install.ps1'
Assert-True (Test-Path -LiteralPath $installerPath -PathType Leaf) 'Root install.ps1 should exist'
$null = New-Item -ItemType Directory -Path (Join-Path $context.RepositoryRoot 'scripts') -Force
Copy-Item -LiteralPath $modulePath -Destination (Join-Path $context.RepositoryRoot 'scripts\XasSkills.psm1')
Copy-Item -LiteralPath $installerPath -Destination (Join-Path $context.RepositoryRoot 'install.ps1')
$profilePath = Join-Path $context.Root 'isolated profile'
$null = New-Item -ItemType Directory -Path $profilePath
$before = Get-TestSnapshot $context.RepositoryRoot
$listed = Invoke-TestEntryPoint -ScriptPath (Join-Path $context.RepositoryRoot 'install.ps1') -Arguments '-List' -ProfilePath $profilePath
Assert-Equal $listed.ExitCode 0 ('List entry point failed: ' + $listed.Error)
Assert-True ($listed.Output -match 'xas-one') 'List entry point should include fixture skill'
$previewed = Invoke-TestEntryPoint -ScriptPath (Join-Path $context.RepositoryRoot 'install.ps1') -Arguments '-Skill xas-one -WhatIf' -ProfilePath $profilePath
Assert-Equal $previewed.ExitCode 0 ('WhatIf entry point failed: ' + $previewed.Error)
Assert-True ($previewed.Output -match 'xas-one') 'WhatIf entry point should identify the planned skill'
Assert-Equal (Get-TestSnapshot $context.RepositoryRoot) $before 'List and WhatIf entry points should not change the source checkout'
Assert-True (-not (Test-Path -LiteralPath (Join-Path $profilePath '.agents'))) 'List and WhatIf entry points should not create the skills destination'
}
}
finally {
foreach ($link in $script:TestLinks) {
if (Test-Path -LiteralPath $link) { [IO.Directory]::Delete($link) }
}
$resolvedRoot = [IO.Path]::GetFullPath($testRoot)
$resolvedTemp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar
if (-not $resolvedRoot.StartsWith($resolvedTemp, [StringComparison]::OrdinalIgnoreCase) -or
-not ([IO.Path]::GetFileName($resolvedRoot)).StartsWith('xas-install-tests-', [StringComparison]::Ordinal)) {
throw "Refusing to clean unexpected test path: $resolvedRoot"
}
if (Test-Path -LiteralPath $resolvedRoot) {
Remove-Item -LiteralPath $resolvedRoot -Recurse -Force
}
}
Write-Host ("Tests: $script:TestCount; failures: $script:FailureCount; skipped link checks: $script:SkipCount")
if ($script:FailureCount -gt 0) { exit 1 }
exit 0