Files
xas-skills/tests/Test-Cli.ps1
T

305 lines
16 KiB
PowerShell
Raw Normal View History

2026-09-20 15:59:30 +08:00
[CmdletBinding()]
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$repositoryRoot = Split-Path -Parent $PSScriptRoot
$requiredFiles = @('skills.cmd', 'scripts\Invoke-XasCli.ps1', 'scripts\XasSkills.psm1', 'install.ps1')
foreach ($relativePath in $requiredFiles) {
if (-not (Test-Path -LiteralPath (Join-Path $repositoryRoot $relativePath) -PathType Leaf)) {
throw "Required CLI file does not exist: $relativePath"
}
}
$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('xas-cli-tests-' + [guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory -Path $testRoot
$utf8WithoutBom = New-Object Text.UTF8Encoding($false)
$script:CaseCount = 0
$script:TestCount = 0
$script:FailureCount = 0
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-CliContext {
$script:CaseCount++
$unicodeName = ([string][char]0x4E2D) + ([string][char]0x6587) + ' cli space'
$root = Join-Path $testRoot (('{0:D2}-' -f $script:CaseCount) + $unicodeName)
$checkout = Join-Path $root 'source checkout'
$profilePath = Join-Path $root 'isolated profile'
$outsideDirectory = Join-Path $root 'unrelated working directory'
foreach ($directory in @($checkout, $profilePath, $outsideDirectory)) {
$null = New-Item -ItemType Directory -Path $directory -Force
}
foreach ($relativePath in $requiredFiles) {
$destination = Join-Path $checkout $relativePath
$null = New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force
Copy-Item -LiteralPath (Join-Path $repositoryRoot $relativePath) -Destination $destination
}
foreach ($name in @('xas-one', 'xas-two', 'xas-three')) {
$skillRoot = Join-Path (Join-Path $checkout 'skills') $name
Write-TestFile (Join-Path $skillRoot 'SKILL.md') "---`nname: $name`ndescription: A CLI fixture skill.`n---`n`nRead the bundled reference.`n"
Write-TestFile (Join-Path $skillRoot 'references\guide.md') ('Reference for ' + $name)
}
Write-TestFile (Join-Path $checkout 'templates\skill-template\SKILL.md') "---`nname: hidden-template`ndescription: Template only.`n---`n"
# This stub is the only update entry point in every fixture. It never invokes Git.
$updateStub = @'
[CmdletBinding()]
param([switch]$WhatIf)
$ErrorActionPreference = 'Stop'
$record = [bool]$WhatIf
[IO.File]::AppendAllText($env:XAS_CLI_TEST_UPDATE_LOG, ($record.ToString() + [Environment]::NewLine))
Write-Output 'fixture-update-called'
exit [int]$env:XAS_CLI_TEST_UPDATE_EXIT
'@
Write-TestFile (Join-Path $checkout 'update.ps1') $updateStub
[pscustomobject]@{
Root = $root
RepositoryRoot = $checkout
ProfilePath = $profilePath
SkillsRoot = Join-Path $profilePath '.agents\skills'
OutsideDirectory = $outsideDirectory
EntryPath = Join-Path $checkout 'skills.cmd'
UpdateLog = Join-Path $root 'update-calls.log'
}
}
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 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-CliChild {
param($Context, [string]$Executable, [string]$Arguments, [string]$WorkingDirectory, [int]$UpdateExitCode = 0)
$startInfo = New-Object Diagnostics.ProcessStartInfo
$startInfo.FileName = $Executable
$startInfo.Arguments = $Arguments
$startInfo.WorkingDirectory = $WorkingDirectory
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
$startInfo.EnvironmentVariables['USERPROFILE'] = $Context.ProfilePath
$startInfo.EnvironmentVariables['XAS_CLI_TEST_UPDATE_LOG'] = $Context.UpdateLog
$startInfo.EnvironmentVariables['XAS_CLI_TEST_UPDATE_EXIT'] = [string]$UpdateExitCode
$process = New-Object Diagnostics.Process
$process.StartInfo = $startInfo
$null = $process.Start()
$outputTask = $process.StandardOutput.ReadToEndAsync()
$errorTask = $process.StandardError.ReadToEndAsync()
if (-not $process.WaitForExit(30000)) {
$process.Kill()
$process.Dispose()
throw 'CLI fixture process did not finish within 30 seconds.'
}
$result = [pscustomobject]@{
ExitCode = $process.ExitCode
Output = $outputTask.GetAwaiter().GetResult()
Error = $errorTask.GetAwaiter().GetResult()
}
$process.Dispose()
$result
}
function Invoke-Cli {
param($Context, [string[]]$Tokens = @(), [int]$UpdateExitCode = 0)
# All tokens below are fixed fixture values, with no shell metacharacters.
foreach ($token in $Tokens) {
if ($token -notmatch '^[a-zA-Z0-9_./-]+$') { throw "Unsafe CLI test token: $token" }
}
$arguments = '/d /s /c ""' + $Context.EntryPath + '" ' + ($Tokens -join ' ') + '"'
Invoke-CliChild -Context $Context -Executable $env:ComSpec -Arguments $arguments -WorkingDirectory $Context.OutsideDirectory -UpdateExitCode $UpdateExitCode
}
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 Assert-CliSuccess {
param($Result, [string]$Message)
Assert-Equal $Result.ExitCode 0 ($Message + ' Output: ' + $Result.Output + ' Error: ' + $Result.Error)
}
try {
Invoke-Test 'actual cmd entry shows help and lists only real skills from an unrelated cwd' {
$context = New-CliContext
$before = Get-TestSnapshot $context.RepositoryRoot
foreach ($tokens in @(@(), @('help'), @('list'))) {
$result = Invoke-Cli -Context $context -Tokens $tokens
Assert-CliSuccess $result 'Help/list should succeed'
Assert-True (-not [string]::IsNullOrWhiteSpace($result.Output)) 'Help/list should print useful output'
}
$listed = Invoke-Cli -Context $context -Tokens @('list')
foreach ($name in @('xas-one', 'xas-two', 'xas-three')) {
Assert-True ($listed.Output -match [regex]::Escape($name)) "List should include $name"
}
Assert-True ($listed.Output -notmatch 'hidden-template') 'Template must not appear in CLI skill list'
Assert-Equal (Get-TestSnapshot $context.RepositoryRoot) $before 'Help/list should preserve checkout files'
Assert-True (-not (Test-Path -LiteralPath (Join-Path $context.ProfilePath '.agents'))) 'Help/list should not create skills directories'
Assert-True (-not (Test-Path -LiteralPath $context.UpdateLog)) 'Help/list should not invoke update'
}
Invoke-Test 'single install and repeat install use the isolated user destination' {
$context = New-CliContext
$installed = Invoke-Cli -Context $context -Tokens @('install', 'xas-one')
Assert-CliSuccess $installed 'Single skill install should succeed'
Assert-True (Test-Path -LiteralPath (Join-Path $context.SkillsRoot 'xas-one\SKILL.md')) 'Selected skill should be installed in isolated profile'
Assert-Equal ([IO.File]::ReadAllText((Join-Path $context.SkillsRoot 'xas-one\references\guide.md'))) 'Reference for xas-one' 'Bundled resources should reach the installed skill'
Assert-True (-not (Test-Path -LiteralPath (Join-Path $context.SkillsRoot 'xas-two'))) 'Other skills should remain uninstalled'
$before = Get-TestSnapshot $context.SkillsRoot
$repeated = Invoke-Cli -Context $context -Tokens @('install', 'xas-one')
Assert-CliSuccess $repeated 'Repeated skill install should succeed'
Assert-True ($repeated.Output -match 'Skipped') 'Repeated install should report Skipped'
Assert-Equal (Get-TestSnapshot $context.SkillsRoot) $before 'Repeated install should preserve installed files'
}
Invoke-Test 'multiple skill names are forwarded as separate array entries' {
$context = New-CliContext
$result = Invoke-Cli -Context $context -Tokens @('install', 'xas-one', 'xas-two')
Assert-CliSuccess $result 'Multiple skill install should succeed'
foreach ($name in @('xas-one', 'xas-two')) {
Assert-True (Test-Path -LiteralPath (Join-Path $context.SkillsRoot "$name\SKILL.md")) "Selected skill should be installed: $name"
}
Assert-True (-not (Test-Path -LiteralPath (Join-Path $context.SkillsRoot 'xas-three'))) 'Third skill must remain uninstalled'
}
Invoke-Test 'install all installs every formal skill without the template' {
$context = New-CliContext
$result = Invoke-Cli -Context $context -Tokens @('install', '--all')
Assert-CliSuccess $result 'Install all should succeed'
$names = @((Get-ChildItem -LiteralPath $context.SkillsRoot -Directory).Name | Sort-Object)
Assert-Equal ($names -join ',') 'xas-one,xas-three,xas-two' 'Install all should contain exactly the three formal skills'
}
Invoke-Test 'preview forwards WhatIf and creates no installed files' {
$context = New-CliContext
$before = Get-TestSnapshot $context.RepositoryRoot
foreach ($tokens in @(@('install', 'xas-one', '--preview'), @('install', '--all', '--preview'))) {
$result = Invoke-Cli -Context $context -Tokens $tokens
Assert-CliSuccess $result 'Install preview should succeed'
Assert-True ($result.Output -match 'xas-one') 'Preview should identify the planned skill'
Assert-True (-not (Test-Path -LiteralPath (Join-Path $context.ProfilePath '.agents'))) 'Preview must not create the skills destination'
}
Assert-Equal (Get-TestSnapshot $context.RepositoryRoot) $before 'Preview must preserve source files'
}
Invoke-Test 'invalid commands and arguments fail before install or update' {
$context = New-CliContext
$invalid = @(
@('unknown-command'),
@('list', 'unexpected'),
@('help', 'unexpected'),
@('install'),
@('install', '--preview'),
@('install', 'missing-skill'),
@('install', 'xas-one', 'missing-skill'),
@('install', '--all', 'xas-one'),
@('install', 'xas-one', '--all'),
@('install', 'xas-one', '--unknown'),
@('install', '../xas-one'),
@('update', '--unknown'),
@('update', 'xas-one')
)
$before = Get-TestSnapshot $context.RepositoryRoot
foreach ($tokens in $invalid) {
$result = Invoke-Cli -Context $context -Tokens $tokens
Assert-True ($result.ExitCode -ne 0) ('Invalid command must fail: ' + ($tokens -join ' '))
Assert-True (-not (Test-Path -LiteralPath (Join-Path $context.ProfilePath '.agents'))) ('Invalid command must not install: ' + ($tokens -join ' '))
Assert-True (-not (Test-Path -LiteralPath $context.UpdateLog)) ('Invalid command must not invoke update: ' + ($tokens -join ' '))
}
Assert-Equal (Get-TestSnapshot $context.RepositoryRoot) $before 'Invalid commands should preserve fixture checkout'
}
Invoke-Test 'update routes exactly once, forwards preview and preserves nonzero exit codes' {
$context = New-CliContext
$updated = Invoke-Cli -Context $context -Tokens @('update') -UpdateExitCode 37
Assert-Equal $updated.ExitCode 37 ('Update exit code should be preserved. Output: ' + $updated.Output + ' Error: ' + $updated.Error)
$calls = @(Get-Content -LiteralPath $context.UpdateLog)
Assert-Equal $calls.Count 1 'Normal update should invoke the stub exactly once'
Assert-Equal $calls[0] 'False' 'Normal update should not enable WhatIf'
$previewed = Invoke-Cli -Context $context -Tokens @('update', '--preview') -UpdateExitCode 29
Assert-Equal $previewed.ExitCode 29 'Preview update exit code should be preserved'
$calls = @(Get-Content -LiteralPath $context.UpdateLog)
Assert-Equal $calls.Count 2 'Preview update should add exactly one stub invocation'
Assert-Equal $calls[1] 'True' 'Preview update should pass WhatIf'
Assert-True (-not (Test-Path -LiteralPath (Join-Path $context.ProfilePath '.agents'))) 'Update routing stub should not install skills'
}
Invoke-Test 'update can replace its own batch entrypoint and preserve the exit code' {
$context = New-CliContext
Write-TestFile (Join-Path $context.RepositoryRoot 'update.ps1') @'
param([switch]$WhatIf)
[IO.File]::WriteAllText((Join-Path $PSScriptRoot 'skills.cmd'), "@echo off`r`nexit /b 99`r`n", [Text.Encoding]::ASCII)
exit 37
'@
$result = Invoke-Cli -Context $context -Tokens @('update')
Assert-Equal $result.ExitCode 37 'The active wrapper must finish with the update result even if replaced'
Assert-True ([IO.File]::ReadAllText((Join-Path $context.RepositoryRoot 'skills.cmd')) -match 'exit /b 99') 'The fixture update must actually replace the wrapper'
}
Invoke-Test 'PowerShell 5.1 and 7 resolve dot-backslash-skills without an extension' {
$context = New-CliContext
$runnerPath = Join-Path $context.Root 'invoke-short-name.ps1'
Write-TestFile $runnerPath @'
$ErrorActionPreference = 'Stop'
& .\skills list
exit $LASTEXITCODE
'@
$shells = @(
(Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'),
(Get-Command pwsh.exe -ErrorAction Stop).Source
)
foreach ($shellPath in $shells) {
$arguments = '-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "' + $runnerPath + '"'
$result = Invoke-CliChild -Context $context -Executable $shellPath -Arguments $arguments -WorkingDirectory $context.RepositoryRoot
Assert-CliSuccess $result ('Short command should work from ' + $shellPath)
Assert-True ($result.Output -match 'xas-one') 'Short command should list fixture skills'
}
Assert-True (-not (Test-Path -LiteralPath (Join-Path $context.ProfilePath '.agents'))) 'Short command list should not install skills'
}
}
finally {
$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-cli-tests-', [StringComparison]::Ordinal)) {
throw "Refusing to clean unexpected CLI test path: $resolvedRoot"
}
if (Test-Path -LiteralPath $resolvedRoot) { Remove-Item -LiteralPath $resolvedRoot -Recurse -Force }
}
Write-Host ("CLI tests: $script:TestCount; failures: $script:FailureCount")
if ($script:FailureCount -gt 0) { exit 1 }
exit 0