305 lines
16 KiB
PowerShell
305 lines
16 KiB
PowerShell
|
|
[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
|