test/Release.Tests.ps1

#requires -Version 7.6
BeforeAll {
    $script:ReleaseRoot = Split-Path -Parent $PSScriptRoot
    $script:ReleaseCheck = Join-Path $ReleaseRoot 'scripts/Test-QuietCliRelease.ps1'
    $script:ArchiveBuilder = Join-Path $ReleaseRoot 'scripts/New-QuietCliArchive.ps1'
    $script:PackageBuilder = Join-Path $ReleaseRoot 'scripts/New-QuietCliPackage.ps1'
    $script:Publisher = Join-Path $ReleaseRoot 'scripts/Publish-QuietCliPackage.ps1'
    Import-Module (Join-Path $ReleaseRoot 'FrenchExDev.QuietCli.PoSh.psd1') -DisableNameChecking
    Import-Module Microsoft.PowerShell.PSResourceGet -RequiredVersion 1.2.0
    function ReleaseFixture {
        $path = Join-Path $TestDrive ([guid]::NewGuid().ToString('N'))
        foreach ($folder in @('src','examples','fixtures','skills','test','scripts','verification','doc','resources','third_party')) {
            [void][IO.Directory]::CreateDirectory((Join-Path $path $folder))
        }
        foreach ($name in @('FrenchExDev.QuietCli.PoSh.psm1','src/QuietCli.psm1','README.md','LICENSE','THIRD-PARTY-NOTICES.md')) {
            [IO.File]::WriteAllText((Join-Path $path $name), '# release fixture')
        }
        foreach ($name in @('FrenchExDev.QuietCli.PoSh','src/QuietCli')) {
            New-ModuleManifest -Path (Join-Path $path ($name + '.psd1')) -RootModule ((Split-Path -Leaf $name) + '.psm1') -ModuleVersion '2.0.0' -Author 'Fixture' -Description 'Release fixture' -PowerShellVersion '7.6' -CompatiblePSEditions Core
        }
        $path
    }
}
Describe 'Stable release identity' {
    BeforeEach { $script:VersionFixture = ReleaseFixture }
    It 'accepts matching root and implementation versions' {
        $result = & $ReleaseCheck -ModuleRoot $VersionFixture -Tag v2.0.0
        $result.Name | Should -BeExactly 'FrenchExDev.QuietCli.PoSh'
        $result.Version | Should -BeExactly '2.0.0'
    }
    It 'rejects an invalid stable tag <Tag>' -ForEach @(
        @{Tag='2.0.0'}, @{Tag='quietcli-v2.0.0'}, @{Tag='v2.0'}, @{Tag='v2.0.0-beta1'}, @{Tag='v02.0.0'}, @{Tag='v2.0.0.0'}
    ) {
        { & $ReleaseCheck -ModuleRoot $VersionFixture -Tag $Tag } | Should -Throw '*vX.Y.Z*'
    }
    It 'rejects a root version different from the tag' {
        { & $ReleaseCheck -ModuleRoot $VersionFixture -Tag v2.0.1 } | Should -Throw '*Tag/version mismatch*'
    }
    It 'rejects an implementation version different from the root' {
        $path = Join-Path $VersionFixture 'src/QuietCli.psd1'
        (Get-Content $path -Raw).Replace("'2.0.0'", "'2.0.1'") | Set-Content $path
        { & $ReleaseCheck -ModuleRoot $VersionFixture -Tag v2.0.0 } | Should -Throw '*src/QuietCli.psd1*'
    }
    It 'rejects a missing implementation manifest' {
        Remove-Item (Join-Path $VersionFixture 'src/QuietCli.psd1')
        { & $ReleaseCheck -ModuleRoot $VersionFixture -Tag v2.0.0 } | Should -Throw '*Missing release manifest*'
    }
    It 'rejects prerelease metadata under a stable tag' {
        Update-ModuleManifest -Path (Join-Path $VersionFixture 'FrenchExDev.QuietCli.PoSh.psd1') -Prerelease beta1
        { & $ReleaseCheck -ModuleRoot $VersionFixture -Tag v2.0.0 } | Should -Throw '*prerelease manifest*'
    }
}
Describe 'Clean distribution inventory' {
    BeforeEach { $script:InventoryFixture = ReleaseFixture }
    It 'keeps runtime resources and notices while excluding nested local state' {
        foreach ($relative in @('src/.git/config','src/artifacts/run.json','resources/local.sqlite3','resources/local.sqlite3-wal','fixtures/downloads/old.zip','resources/runtime/probe.dat')) {
            $path = Join-Path $InventoryFixture $relative
            [void][IO.Directory]::CreateDirectory((Split-Path -Parent $path))
            [IO.File]::WriteAllText($path,'fixture')
        }
        $report = & $ArchiveBuilder -ModuleRoot $InventoryFixture -ValidateOnly | ConvertFrom-Json
        $paths = @($report.Data.Manifest.Files.Path)
        $paths | Should -Contain 'resources/runtime/probe.dat'
        $paths | Should -Contain 'THIRD-PARTY-NOTICES.md'
        @($paths | Where-Object { $_ -match '\.git/|artifacts/|downloads/|local\.sqlite' }).Count | Should -Be 0
        $report.Data.Manifest.ModulePath | Should -BeExactly '.'
    }
    It 'rejects a missing required license file' {
        Remove-Item (Join-Path $InventoryFixture 'LICENSE')
        { & $ArchiveBuilder -ModuleRoot $InventoryFixture -ValidateOnly } | Should -Throw '*Missing package file: LICENSE*'
    }
}
Describe 'Verified ZIP to NuGet package' {
    BeforeAll {
        $script:BuildActions = $env:GITHUB_ACTIONS
        $env:GITHUB_ACTIONS = 'true'
        $script:BuildVersion = (Import-PowerShellDataFile (Join-Path $ReleaseRoot 'FrenchExDev.QuietCli.PoSh.psd1')).ModuleVersion
        $script:BuildTag = 'v' + $BuildVersion
        $script:BuildOutput = Join-Path $TestDrive 'release-assets'
        $null = & $ArchiveBuilder -OutputDirectory $BuildOutput -SourceRef $BuildTag -SourceCommitBase ('a' * 40)
        $script:BuildManifest = Join-Path $BuildOutput 'QuietCli.manifest.json'
        $script:BuildArchive = Join-Path $BuildOutput 'QuietCli.zip'
        $script:BuildResult = & $PackageBuilder -ArchivePath $BuildArchive -ManifestPath $BuildManifest -OutputDirectory $BuildOutput -Tag $BuildTag | ConvertFrom-Json
    }
    AfterAll { $env:GITHUB_ACTIONS = $BuildActions }
    It 'creates an installable package from the tested archive' {
        $BuildResult.Outcome | Should -Be Success
        $metadata = Get-Content $BuildManifest -Raw | ConvertFrom-Json
        $metadata.PackageFile | Should -BeExactly ('FrenchExDev.QuietCli.PoSh.' + $BuildVersion + '.nupkg')
        (Get-FileHash $BuildResult.Data.Package -Algorithm SHA256).Hash | Should -Be $metadata.PackageSHA256
        $zipReport = Get-Content (Join-Path $BuildOutput 'archive-verification.json') -Raw | ConvertFrom-Json
        $packageReport = Get-Content (Join-Path $BuildOutput 'package-verification.json') -Raw | ConvertFrom-Json
        $zipReport.FreshProcess | Should -BeTrue
        $packageReport.FreshProcess | Should -BeTrue
        $packageReport.VerifiedFiles | Should -Be $zipReport.VerifiedFiles
        $packageReport.Observation.Output | Should -BeExactly 'standalone'
        $packageReport.Observation.Version | Should -BeExactly $BuildVersion
        $packageReport.Observation.Tokenizer | Should -BeExactly $zipReport.Observation.Tokenizer
    }
    It 'contains the same source files as the ZIP, with no checkout or downloads' {
        $zip = [IO.Compression.ZipFile]::OpenRead($BuildResult.Data.Package)
        try {
            $entries = @($zip.Entries.FullName)
            $entries | Should -Contain 'FrenchExDev.QuietCli.PoSh.psd1'
            $entries | Should -Contain 'src/QuietCli.psm1'
            $entries | Should -Contain 'THIRD-PARTY-NOTICES.md'
            @($entries | Where-Object { $_ -match '(^|/)(?:\.git|downloads|artifacts|modules)/' }).Count | Should -Be 0
        } finally { $zip.Dispose() }
    }
    It 'installs from a local NuGet feed and imports by module name in a fresh process' {
        $repository = 'QuietCliTest-' + [guid]::NewGuid().ToString('N')
        $saved = Join-Path $TestDrive 'saved-module'
        [void][IO.Directory]::CreateDirectory($saved)
        Register-PSResourceRepository -Name $repository -Uri $BuildOutput -Trusted -ErrorAction Stop
        try {
            Save-PSResource -Name FrenchExDev.QuietCli.PoSh -Version $BuildVersion -Repository $repository -Path $saved -TrustRepository -ErrorAction Stop
            $installedRoot = Join-Path $saved ('FrenchExDev.QuietCli.PoSh/' + $BuildVersion)
            $metadata = Get-Content $BuildManifest -Raw | ConvertFrom-Json
            foreach ($file in @($metadata.Files | Where-Object { $_.Path -match '^resources/runtime/|^LICENSE$|^THIRD-PARTY-NOTICES.md$' })) {
                (Get-FileHash -LiteralPath (Join-Path $installedRoot $file.Path) -Algorithm SHA256).Hash | Should -Be $file.SHA256
            }
            $body = @'
$ErrorActionPreference='Stop'
$env:PSModulePath='__SAVED__'+[IO.Path]::PathSeparator+$env:PSModulePath
$env:QUIETCLI_TRACKING='0';$env:QUIETCLI_RECALL='0'
Import-Module FrenchExDev.QuietCli.PoSh -RequiredVersion '__VERSION__' -DisableNameChecking
$module=Get-Module FrenchExDev.QuietCli.PoSh
$measurement=Measure-QuietToken -Text hello
@{Version=$module.Version.ToString();ModuleBase=$module.ModuleBase;Tokens=$measurement.Tokens}|ConvertTo-Json -Compress
'@

            $body = $body.Replace('__SAVED__',$saved.Replace("'","''")).Replace('__VERSION__',$BuildVersion)
            $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($body))
            $result = pump -AsObject -Parse {param($capture) $capture.Stdout|ConvertFrom-Json} (Get-Process -Id $PID).Path -NoProfile -EncodedCommand $encoded
            $result.Outcome | Should -Be Success
            $result.Data.Version | Should -BeExactly $BuildVersion
            $result.Data.ModuleBase | Should -BeExactly $installedRoot
            $result.Data.Tokens | Should -Be 1
        } finally { Unregister-PSResourceRepository -Name $repository -ErrorAction Stop }
    }
    It 'rejects a corrupted ZIP before creating a package' {
        $corrupt = Join-Path $TestDrive 'corrupt.zip'
        [IO.File]::WriteAllText($corrupt,'not the verified ZIP')
        { & $PackageBuilder -ArchivePath $corrupt -ManifestPath $BuildManifest -OutputDirectory (Join-Path $TestDrive 'corrupt-output') -Tag $BuildTag } | Should -Throw '*Archive SHA256 mismatch*'
    }
    It 'rejects the wrong source tag' {
        { & $PackageBuilder -ArchivePath $BuildArchive -ManifestPath $BuildManifest -OutputDirectory (Join-Path $TestDrive 'wrong-tag') -Tag v99.0.0 } | Should -Throw '*source tag*'
    }
}
Describe 'Gallery publication without network access' {
    BeforeAll {
        $script:PublishActions = $env:GITHUB_ACTIONS
        $script:PublishKey = $env:PSGALLERY_API_KEY
        $script:PublishRoot = ReleaseFixture
        $script:PublishOutput = Join-Path $TestDrive 'publish-assets'
        [void][IO.Directory]::CreateDirectory($PublishOutput)
        $specification = '<package><metadata><id>FrenchExDev.QuietCli.PoSh</id><version>2.0.0</version></metadata></package>'
        [IO.File]::WriteAllText((Join-Path $PublishRoot 'FrenchExDev.QuietCli.PoSh.nuspec'),$specification)
        $script:PublishPackage = Join-Path $PublishOutput 'FrenchExDev.QuietCli.PoSh.2.0.0.nupkg'
        [IO.Compression.ZipFile]::CreateFromDirectory($PublishRoot,$PublishPackage)
        $script:PublishManifest = Join-Path $PublishOutput 'QuietCli.manifest.json'
        @{ ModuleVersion='2.0.0'; SourceRef='v2.0.0'; PackageFile=(Split-Path -Leaf $PublishPackage); PackageSHA256=(Get-FileHash $PublishPackage -Algorithm SHA256).Hash } | ConvertTo-Json | Set-Content $PublishManifest
    }
    BeforeEach {
        $env:GITHUB_ACTIONS = 'true'
        $env:PSGALLERY_API_KEY = 'unit-test-placeholder'
        Mock Invoke-WebRequest { [pscustomobject]@{ StatusCode=404 } }
        Mock Publish-PSResource {}
    }
    AfterAll {
        $env:GITHUB_ACTIONS = $PublishActions
        $env:PSGALLERY_API_KEY = $PublishKey
    }
    It 'publishes the exact verified nupkg when the version is absent' {
        $result = & $Publisher -PackagePath $PublishPackage -ManifestPath $PublishManifest -ModuleRoot $PublishRoot -Tag v2.0.0 -Confirm:$false | ConvertFrom-Json
        $result.Outcome | Should -Be Success
        Should -Invoke Publish-PSResource -Exactly -Times 1 -ParameterFilter { $NupkgPath -eq $script:PublishPackage -and $Repository -eq 'PSGallery' }
    }
    It 'rejects a missing API key before contacting the Gallery' {
        $env:PSGALLERY_API_KEY = ''
        { & $Publisher -PackagePath $PublishPackage -ManifestPath $PublishManifest -ModuleRoot $PublishRoot -Tag v2.0.0 } | Should -Throw '*PSGALLERY_API_KEY*'
        Should -Invoke Invoke-WebRequest -Times 0
        Should -Invoke Publish-PSResource -Times 0
    }
    It 'rejects an already published version without overwriting it' {
        Mock Invoke-WebRequest { [pscustomobject]@{ StatusCode=200 } }
        { & $Publisher -PackagePath $PublishPackage -ManifestPath $PublishManifest -ModuleRoot $PublishRoot -Tag v2.0.0 } | Should -Throw '*already published*'
        Should -Invoke Publish-PSResource -Times 0
    }
    It 'fails closed on a Gallery service error' {
        Mock Invoke-WebRequest { [pscustomobject]@{ StatusCode=503 } }
        { & $Publisher -PackagePath $PublishPackage -ManifestPath $PublishManifest -ModuleRoot $PublishRoot -Tag v2.0.0 } | Should -Throw '*HTTP 503*'
        Should -Invoke Publish-PSResource -Times 0
    }
    It 'does not publish during a WhatIf execution' {
        & $Publisher -PackagePath $PublishPackage -ManifestPath $PublishManifest -ModuleRoot $PublishRoot -Tag v2.0.0 -WhatIf | Out-Null
        Should -Invoke Publish-PSResource -Times 0
    }
    It 'rejects a changed package before contacting the Gallery' {
        $changed = Join-Path $TestDrive 'changed'
        [void][IO.Directory]::CreateDirectory($changed)
        $path = Join-Path $changed (Split-Path -Leaf $PublishPackage)
        [IO.File]::WriteAllText($path,'changed package')
        { & $Publisher -PackagePath $path -ManifestPath $PublishManifest -ModuleRoot $PublishRoot -Tag v2.0.0 } | Should -Throw '*Package SHA256 mismatch*'
        Should -Invoke Invoke-WebRequest -Times 0
    }
}
Describe 'Release workflow connections' {
    BeforeAll {
        Import-Module powershell-yaml -RequiredVersion 0.4.12
        $script:ReleaseWorkflow = Get-Content (Join-Path $ReleaseRoot '.github/workflows/release.yml') -Raw | ConvertFrom-Yaml
        $script:TestWorkflow = Get-Content (Join-Path $ReleaseRoot '.github/workflows/quietcli.yml') -Raw | ConvertFrom-Yaml
    }
    It 'uses the Linux CI as a reusable gate and passes its source location' {
        $TestWorkflow.on.workflow_call.outputs.'module-dir'.value | Should -BeExactly '${{ jobs.linux.outputs.module-dir }}'
        $ReleaseWorkflow.jobs.test.uses | Should -BeExactly './.github/workflows/quietcli.yml'
        $ReleaseWorkflow.jobs.package.needs | Should -Be test
        $ReleaseWorkflow.jobs.publish.needs | Should -Contain package
        $TestWorkflow.jobs.linux.container.options | Should -BeExactly '--init'
        $ReleaseWorkflow.jobs.package.container.options | Should -BeExactly '--init'
        $ReleaseWorkflow.jobs.publish.container.options | Should -BeExactly '--init'
    }
    It 'starts every Linux job on Alpine without requiring a custom image variable' {
        $jobs = @($TestWorkflow.jobs.linux, $ReleaseWorkflow.jobs.package, $ReleaseWorkflow.jobs.publish)
        foreach ($job in $jobs) {
            $job.container.image | Should -BeExactly '${{ vars.QUIETCLI_LINUX_IMAGE || ''alpine:3.24'' }}'
            $job.steps[0].name | Should -BeExactly 'Prepare Git for checkout'
            $job.steps[0].shell | Should -BeExactly sh
            $checkout = -1
            $initialize = -1
            for ($index = 0; $index -lt $job.steps.Count; $index++) {
                if ($job.steps[$index].uses -eq 'actions/checkout@v4') { $checkout = $index }
                if ($job.steps[$index].name -eq 'Initialize PowerShell 7.6+ Core') { $initialize = $index }
            }
            $checkout | Should -BeGreaterThan 0
            $initialize | Should -BeGreaterThan $checkout
            $job.steps[$initialize].shell | Should -BeExactly sh
            $job.steps[$initialize].run | Should -Match 'Initialize-QuietCliLinux.sh'
        }
        $TestWorkflow.jobs.Keys | Should -Not -Contain runtime
    }
    It 'triggers on version tags and limits release write permission to publication' {
        $ReleaseWorkflow.on.push.tags | Should -Contain 'v*'
        $ReleaseWorkflow.permissions.contents | Should -Be read
        $ReleaseWorkflow.jobs.publish.permissions.contents | Should -Be write
        @($TestWorkflow.jobs.Keys | Where-Object { $_ -in @('gallery','release-assets') }).Count | Should -Be 0
    }
}