[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 } }