433 lines
26 KiB
PowerShell
433 lines
26 KiB
PowerShell
|
|
[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
|