chore: initialize xas skills repository

This commit is contained in:
2026-09-20 15:59:30 +08:00
commit 1c92431021
15 changed files with 1983 additions and 0 deletions
+304
View File
@@ -0,0 +1,304 @@
[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
+432
View File
@@ -0,0 +1,432 @@
[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
+273
View File
@@ -0,0 +1,273 @@
[CmdletBinding()]
param()
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path (Split-Path $PSScriptRoot -Parent) 'scripts/XasUpdate.psm1') -Force
$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('xas-update-tests-' + [Guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory -Path $testRoot
$repository = Join-Path $testRoot '中文 仓库'
$target = Join-Path $testRoot '中文 技能'
$null = New-Item -ItemType Directory -Path $repository
$script:passed = 0
function Assert-True {
param([bool]$Value, [string]$Message)
if (-not $Value) { throw ('断言失败:' + $Message) }
}
function New-Fixture {
$context = @{
Root = $repository; Calls = (New-Object 'System.Collections.Generic.List[string]')
Url = 'https://code.it.aisuan.cn/wenhuadian/xas-skills.git'; Branch = 'main'; Upstream = 'origin/main'
Head = ('a' * 40); Dirty = ''; BeforeCounts = "0`t0"; AfterCounts = "0`t1"
Fetched = $false; Merged = $false; FetchCode = 0; MergeCode = 0; HeadCode = 0; UpstreamCode = 0
FetchOutput = ''; Restarted = 0; Synced = 0; Preview = $null; RestartHead = ''
}
$git = {
param($root, $arguments)
$command = $arguments -join ' '
$context.Calls.Add($command)
$code = 0
$output = ''
switch ($command) {
'rev-parse --show-toplevel' { $output = $context.Root }
'remote get-url origin' { $output = $context.Url }
'branch --show-current' { $output = $context.Branch }
'rev-parse --verify HEAD' { $output = $context.Head; $code = $context.HeadCode }
'rev-parse --abbrev-ref --symbolic-full-name @{u}' { $output = $context.Upstream; $code = $context.UpstreamCode }
'status --porcelain --untracked-files=all' { $output = $context.Dirty }
'rev-list --left-right --count HEAD...origin/main' {
if ($context.Merged) { $output = "0`t0" }
elseif ($context.Fetched) { $output = $context.AfterCounts }
else { $output = $context.BeforeCounts }
}
'fetch --no-tags origin refs/heads/main:refs/remotes/origin/main' {
$context.Fetched = $true; $code = $context.FetchCode; $output = $context.FetchOutput
}
'merge --ff-only --no-edit origin/main' {
$context.Merged = $true; $context.Head = ('b' * 40); $code = $context.MergeCode
}
default { throw ('未预期 Git 命令:' + $command) }
}
[pscustomobject]@{ ExitCode = $code; Output = $output }
}.GetNewClosure()
$restart = {
param($root, $skills, $head)
$context.Restarted++
$context.RestartHead = $head
if ($root -ne $context.Root) { throw '重启目录错误。' }
}.GetNewClosure()
$sync = {
param($root, $skills, $preview)
$context.Synced++
$context.Preview = $preview
}.GetNewClosure()
return @{ Context = $context; Git = $git; Restart = $restart; Sync = $sync }
}
function Invoke-Fixture {
param($Fixture, [switch]$Preview)
Invoke-XasUpdate -RepositoryRoot $repository -SkillsRoot $target -Preview:$Preview -GitRunner $Fixture.Git -RestartRunner $Fixture.Restart -SyncRunner $Fixture.Sync
}
function Assert-Failure {
param([scriptblock]$Action, [string]$Pattern)
$caught = $false
try { & $Action }
catch {
$caught = $true
Assert-True ($_.Exception.Message -match $Pattern) ('错误应匹配 ' + $Pattern + ',实际:' + $_.Exception.Message)
}
Assert-True $caught '预期失败,实际成功。'
}
function Test-Case {
param([string]$Name, [scriptblock]$Action)
& $Action
$script:passed++
Write-Host ('PASS ' + $Name)
}
function Set-Receipt {
param([string]$Token, [string]$Head = ('a' * 40))
[Environment]::SetEnvironmentVariable('XAS_SKILLS_UPDATE_TOKEN', $Token, 'Process')
[Environment]::SetEnvironmentVariable('XAS_SKILLS_UPDATE_ROOT', $repository, 'Process')
[Environment]::SetEnvironmentVariable('XAS_SKILLS_UPDATE_TARGET', $target, 'Process')
[Environment]::SetEnvironmentVariable('XAS_SKILLS_UPDATE_HEAD', $Head, 'Process')
}
try {
Test-Case 'fast-forward then restart exactly once, parent only previews installation' {
$f = New-Fixture
Invoke-Fixture $f
Assert-True $f.Context.Fetched '没有 fetch。'
Assert-True $f.Context.Merged '没有快进 merge。'
Assert-True ($f.Context.Restarted -eq 1) '应重启一次。'
Assert-True ($f.Context.Synced -eq 1 -and $f.Context.Preview) '父进程仅预检已安装技能。'
Assert-True ($f.Context.RestartHead -eq ('b' * 40)) '重启必须使用更新后的 HEAD。'
}
Test-Case 'installation conflict preflight stops before fetch' {
$f = New-Fixture
$f.Sync = { throw '已安装技能被修改。' }
Assert-Failure { Invoke-Fixture $f } '已安装技能被修改'
Assert-True (-not $f.Context.Fetched) '目标冲突时不应 fetch。'
}
Test-Case 'preview does not fetch, merge, restart, or create destination' {
$f = New-Fixture
Invoke-Fixture $f -Preview
Assert-True (-not $f.Context.Fetched -and -not $f.Context.Merged) '预览执行了 Git 写操作。'
Assert-True ($f.Context.Restarted -eq 0 -and $f.Context.Synced -eq 1 -and $f.Context.Preview) '预览流程不正确。'
Assert-True (-not (Test-Path -LiteralPath $target)) '预览创建了安装目录。'
}
Test-Case 'dirty workspace rejected before network' {
$f = New-Fixture; $f.Context.Dirty = '?? local.txt'
Assert-Failure { Invoke-Fixture $f } '工作区'
Assert-True (-not $f.Context.Fetched) '不应访问远端。'
}
Test-Case 'local ahead rejected before network' {
$f = New-Fixture; $f.Context.BeforeCounts = "1`t0"
Assert-Failure { Invoke-Fixture $f } '尚未推送'
Assert-True (-not $f.Context.Fetched) '不应访问远端。'
}
Test-Case 'local divergence rejected before network' {
$f = New-Fixture; $f.Context.BeforeCounts = "1`t2"
Assert-Failure { Invoke-Fixture $f } '分叉'
}
Test-Case 'newly discovered divergence never merges' {
$f = New-Fixture; $f.Context.AfterCounts = "1`t2"
Assert-Failure { Invoke-Fixture $f } '分叉'
Assert-True (-not $f.Context.Merged -and $f.Context.Restarted -eq 0) '分叉后不应合并或重启。'
}
Test-Case 'newly discovered ahead never merges' {
$f = New-Fixture; $f.Context.AfterCounts = "2`t0"
Assert-Failure { Invoke-Fixture $f } '尚未推送'
Assert-True (-not $f.Context.Merged) '本地领先后不应合并。'
}
Test-Case 'unborn repository rejected' {
$f = New-Fixture; $f.Context.HeadCode = 128
Assert-Failure { Invoke-Fixture $f } '尚无提交'
}
Test-Case 'missing upstream rejected' {
$f = New-Fixture; $f.Context.UpstreamCode = 128
Assert-Failure { Invoke-Fixture $f } '尚未跟踪'
}
Test-Case 'unexpected branch and upstream rejected' {
$f = New-Fixture; $f.Context.Branch = 'feature'
Assert-Failure { Invoke-Fixture $f } 'main 分支'
$f = New-Fixture; $f.Context.Upstream = 'other/main'
Assert-Failure { Invoke-Fixture $f } '必须跟踪'
}
Test-Case 'wrong origin rejected without leaking credentials' {
$f = New-Fixture; $f.Context.Url = 'https://user:SECRET@example.org/repo.git'
Assert-Failure { Invoke-Fixture $f } '^origin 不是'
Assert-True (-not $f.Context.Fetched) '错误远端不应 fetch。'
}
foreach ($scenario in @(
@{ Name = 'empty remote'; Detail = "fatal: couldn't find remote ref main"; Expected = '^远端 main 尚未发布或已删除;请联系仓库维护者。$' },
@{ Name = 'authentication error'; Detail = 'fatal: Authentication failed for https://SECRET:password@example.org/repo'; Expected = '^Git 认证或权限检查失败;请确认已登录公司 Git 且有仓库读取权限。$' },
@{ Name = 'network error'; Detail = 'fatal: Could not resolve host: SECRET.example.org'; Expected = '^连接公司 Git 失败;请检查网络、代理以及 TLS 证书配置。$' },
@{ Name = 'unclassified fetch error'; Detail = 'fatal: SECRET'; Expected = '^获取 origin/main 失败;请检查 Git 连接状态和远端 main,然后重试。$' }
)) {
Test-Case $scenario.Name {
$f = New-Fixture; $f.Context.FetchCode = 128
$f.Context.FetchOutput = $scenario.Detail
Assert-Failure { Invoke-Fixture $f } $scenario.Expected
Assert-True (-not $f.Context.Merged -and $f.Context.Restarted -eq 0) 'fetch 失败不应合并或重启。'
}
}
Test-Case 'failed fast-forward does not restart' {
$f = New-Fixture; $f.Context.MergeCode = 1
Assert-Failure { Invoke-Fixture $f } '快进更新失败'
Assert-True ($f.Context.Restarted -eq 0) 'merge 失败不应重启。'
}
Test-Case 'continuation rejects direct invocation without receipt' {
$f = New-Fixture
Assert-Failure { Invoke-XasUpdateContinuation -RepositoryRoot $repository -Token ('0' * 32) -GitRunner $f.Git -SyncRunner $f.Sync } '内部更新续接无效'
Assert-True ($f.Context.Synced -eq 0) '无效续接不应同步。'
}
Test-Case 'continuation verifies repository then synchronizes once without fetch' {
$f = New-Fixture; $token = [Guid]::NewGuid().ToString('N'); Set-Receipt $token
Invoke-XasUpdateContinuation -RepositoryRoot $repository -Token $token -GitRunner $f.Git -SyncRunner $f.Sync
Assert-True ($f.Context.Synced -eq 1 -and -not $f.Context.Preview) '续接应真正同步。'
Assert-True (-not $f.Context.Fetched -and -not $f.Context.Merged) '续接不能再次 fetch/merge。'
Assert-Failure { Invoke-XasUpdateContinuation -RepositoryRoot $repository -Token $token -GitRunner $f.Git -SyncRunner $f.Sync } '内部更新续接无效'
}
Test-Case 'continuation rejects changed HEAD and dirty workspace' {
$f = New-Fixture; $token = [Guid]::NewGuid().ToString('N'); Set-Receipt $token ('b' * 40)
Assert-Failure { Invoke-XasUpdateContinuation -RepositoryRoot $repository -Token $token -GitRunner $f.Git -SyncRunner $f.Sync } '状态已改变'
Set-Receipt $token; $f.Context.Dirty = ' M update.ps1'
Assert-Failure { Invoke-XasUpdateContinuation -RepositoryRoot $repository -Token $token -GitRunner $f.Git -SyncRunner $f.Sync } '工作区'
Assert-True ($f.Context.Synced -eq 0) '状态改变后不应同步。'
}
Test-Case 'continuation rejects behind HEAD and wrong root' {
$f = New-Fixture; $token = [Guid]::NewGuid().ToString('N'); Set-Receipt $token
$f.Context.BeforeCounts = "0`t1"
Assert-Failure { Invoke-XasUpdateContinuation -RepositoryRoot $repository -Token $token -GitRunner $f.Git -SyncRunner $f.Sync } '状态已改变'
Set-Receipt $token
[Environment]::SetEnvironmentVariable('XAS_SKILLS_UPDATE_ROOT', $target, 'Process')
Assert-Failure { Invoke-XasUpdateContinuation -RepositoryRoot $repository -Token $token -GitRunner $f.Git -SyncRunner $f.Sync } '目录不匹配'
}
Test-Case 'real child process runs the current entrypoint and propagates failure' {
$childRoot = Join-Path $testRoot '子进程 脚本'
$null = New-Item -ItemType Directory -Path $childRoot
$entrypoint = Join-Path $childRoot 'update.ps1'
$childScript = @'
param([string]$ContinueUpdate)
[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)
if ($ContinueUpdate -cne $env:XAS_SKILLS_UPDATE_TOKEN) { exit 2 }
$record = @{ ProcessId = $PID; Token = $ContinueUpdate; Head = $env:XAS_SKILLS_UPDATE_HEAD; Target = $env:XAS_SKILLS_UPDATE_TARGET; Message = '新版入口已运行' }
[IO.File]::WriteAllText((Join-Path $PSScriptRoot 'receipt.json'), ($record | ConvertTo-Json), (New-Object Text.UTF8Encoding($false)))
Write-Output '新版入口已运行'
exit 0
'@
[IO.File]::WriteAllText($entrypoint, $childScript, (New-Object Text.UTF8Encoding($true)))
$module = Get-Module XasUpdate
& $module { param($root, $skills) Start-XasUpdateContinuation $root $skills ('a' * 40) } $childRoot $target
$receipt = [IO.File]::ReadAllText((Join-Path $childRoot 'receipt.json')) | ConvertFrom-Json
Assert-True ($receipt.ProcessId -ne $PID) '必须启动新的 PowerShell 进程。'
Assert-True ($receipt.Head -eq ('a' * 40) -and $receipt.Target -eq $target) '续接收据未正确传递。'
Assert-True ($receipt.Message -eq '新版入口已运行') '新版入口没有正确执行。'
Assert-True ([string]::IsNullOrEmpty([Environment]::GetEnvironmentVariable('XAS_SKILLS_UPDATE_TOKEN', 'Process'))) '子进程收据不应污染父进程环境。'
[IO.File]::WriteAllText($entrypoint, 'param([string]$ContinueUpdate)' + "`nexit 7", (New-Object Text.UTF8Encoding($true)))
Assert-Failure { & $module { param($root, $skills) Start-XasUpdateContinuation $root $skills ('a' * 40) } $childRoot $target } '技能同步失败'
}
Test-Case 'core integration updates only installed skills and retains upstream removals' {
$syncRoot = Join-Path $testRoot '同步 仓库'
$syncTarget = Join-Path $testRoot '同步 安装'
$scriptsRoot = Join-Path $syncRoot 'scripts'
$null = New-Item -ItemType Directory -Path $scriptsRoot -Force
Copy-Item -LiteralPath (Join-Path (Split-Path $PSScriptRoot -Parent) 'scripts/XasSkills.psm1') -Destination (Join-Path $scriptsRoot 'XasSkills.psm1')
foreach ($name in @('test-update-a', 'test-update-b')) {
$skillDirectory = Join-Path $syncRoot ('skills/' + $name)
$null = New-Item -ItemType Directory -Path $skillDirectory -Force
[IO.File]::WriteAllText((Join-Path $skillDirectory 'SKILL.md'), "---`nname: $name`ndescription: update test`n---`nVersion one")
}
Import-Module (Join-Path $scriptsRoot 'XasSkills.psm1') -Force
$null = Invoke-XasInstall -RepositoryRoot $syncRoot -SkillsRoot $syncTarget -Skill @('test-update-a')
$entrypoint = Join-Path $syncRoot 'skills/test-update-a/SKILL.md'
[IO.File]::WriteAllText($entrypoint, "---`nname: test-update-a`ndescription: update test`n---`nVersion two")
$module = Get-Module XasUpdate
$null = & $module { param($root, $skills) Invoke-XasSyncInstalled $root $skills } $syncRoot $syncTarget
Assert-True ([IO.File]::ReadAllText((Join-Path $syncTarget 'test-update-a/SKILL.md')) -match 'Version two') '已安装技能未更新。'
Assert-True (-not (Test-Path -LiteralPath (Join-Path $syncTarget 'test-update-b'))) '不应自动安装上游新增技能。'
Remove-Item -LiteralPath $entrypoint
$warnings = @()
$null = & $module { param($root, $skills) Invoke-XasSyncInstalled $root $skills } $syncRoot $syncTarget 3>&1 | ForEach-Object { if ($_ -is [Management.Automation.WarningRecord]) { $warnings += $_ } }
Assert-True ((@($warnings | ForEach-Object { $_.Message }) -join ' ') -match '上游已移除技能 test-update-a') '缺少上游删除提示。'
Assert-True (Test-Path -LiteralPath (Join-Path $syncTarget 'test-update-a/SKILL.md')) '上游移除不能自动卸载。'
}
Write-Host ("Update tests passed: {0} (PowerShell {1})" -f $script:passed, $PSVersionTable.PSVersion)
}
finally {
foreach ($key in @('TOKEN', 'ROOT', 'TARGET', 'HEAD')) {
[Environment]::SetEnvironmentVariable(('XAS_SKILLS_UPDATE_' + $key), $null, 'Process')
}
# Only this test's verified, unique child of the system temp directory is removed.
$resolved = [IO.Path]::GetFullPath($testRoot)
$tempBase = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd([char[]]'\/') + [IO.Path]::DirectorySeparatorChar
if ($resolved.StartsWith($tempBase, [StringComparison]::OrdinalIgnoreCase) -and (Split-Path $resolved -Leaf) -like 'xas-update-tests-*') {
Remove-Item -LiteralPath $resolved -Recurse -Force
}
}