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