Ctrl K
ComunidadGuías…

scripts/install.ps1.test.ps1

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env pwsh
# Port-contract tests for scripts/install.ps1 — the PowerShell counterpart of the
# `--with-server` cases in scripts/install.test.sh.
#
# The installer used to derive the backend/frontend host port from .env with its
# own copy of the alias chain. Docker Compose gives the calling process
# environment precedence over .env, so any ambient PORT / BACKEND_PORT /
# API_PORT / SERVER_PORT / FRONTEND_PORT moved the published port while the
# installer kept probing and printing the file value (#6145).
#
# Here the docker stub plays Compose: it answers `port` from the same resolution
# Compose performs — environment first, then .env. The installer must take that
# answer as given for both the health check and the summary, which a .env-only
# derivation cannot do because the two disagree in every ambient case below.
# That real Compose resolves this way is proven against real `docker compose
# config` in scripts/selfhost-config.test.sh.

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

$RepoRoot = Split-Path (Split-Path $PSCommandPath -Parent) -Parent
$InstallerPath = Join-Path $RepoRoot "scripts/install.ps1"
$PowerShellPath = (Get-Process -Id $PID).Path

# install.ps1 resolves its default install directory from USERPROFILE at load
# time. Provide one so these tests also run on a non-Windows agent.
if (-not $env:USERPROFILE) { $env:USERPROFILE = [System.IO.Path]::GetTempPath() }

function Fail-Test {
    param([string]$Message)
    Write-Host "FAIL: $Message" -ForegroundColor Red
    exit 1
}

# install.ps1 dispatches at the bottom on import, so load only the definitions.
function Get-InstallerDefinitions {
    $source = Get-Content -Raw -Path $InstallerPath
    $marker = "# Entry point"
    $index = $source.IndexOf($marker)
    if ($index -lt 0) {
        Fail-Test "could not find the entry-point marker in install.ps1"
    }
    # Trim back to the comment banner that precedes the marker.
    $banner = $source.LastIndexOf("# ---", $index)
    if ($banner -ge 0) { $index = $banner }
    return $source.Substring(0, $index)
}

# ---------------------------------------------------------------------------
# 1. install.ps1 must parse, and must no longer derive ports from .env
# ---------------------------------------------------------------------------
$parseErrors = $null
# Parse the file directly: Windows PowerShell 5.1 reads BOM-less files as ANSI,
# unlike PowerShell 7. Decoding as UTF-8 first would hide regressions like #8660.
[System.Management.Automation.Language.Parser]::ParseFile($InstallerPath, [ref]$null, [ref]$parseErrors) | Out-Null
if ($parseErrors) {
    $parseErrors | ForEach-Object { Write-Host "  $($_.Message) (line $($_.Extent.StartLineNumber))" }
    Fail-Test "install.ps1 has parse errors"
}

$installerSource = Get-Content -Raw -Path $InstallerPath
foreach ($banned in @("Get-SelfHostBackendPort", "Get-SelfHostFrontendPort", "Get-EnvFileValue")) {
    if ($installerSource -match [regex]::Escape($banned)) {
        Fail-Test "install.ps1 must not re-derive host ports from .env (found $banned)"
    }
}
foreach ($required in @(
        'Get-ComposePublishedPort -Service "backend" -ContainerPort 8080',
        'Get-ComposePublishedPort -Service "frontend" -ContainerPort 3000',
        'http://localhost:$($script:SelfHostBackendPort)/health')) {
    if ($installerSource -notmatch [regex]::Escape($required)) {
        Fail-Test "install.ps1 is missing the Compose-published-port wiring: $required"
    }
}

# ---------------------------------------------------------------------------
# 2. Get-ComposePublishedPort parses what `docker compose port` returns
# ---------------------------------------------------------------------------
$definitions = Get-InstallerDefinitions

$portReaderCases = @(
    @{ Label = "loopback binding"; Output = @("127.0.0.1:9500"); Expected = "9500" }
    @{ Label = "wildcard binding"; Output = @("0.0.0.0:9600"); Expected = "9600" }
    @{ Label = "trailing blank line"; Output = @("127.0.0.1:9700", ""); Expected = "9700" }
    @{ Label = "no output"; Output = @(); Expected = $null }
    @{ Label = "non-numeric"; Output = @("127.0.0.1:notaport"); Expected = $null }
)

foreach ($case in $portReaderCases) {
    $script:stubOutput = $case.Output
    $result = & {
        Invoke-Expression $definitions
        function docker {
            $global:LASTEXITCODE = 0
            return $script:stubOutput
        }
        Get-ComposePublishedPort -Service "backend" -ContainerPort 8080
    }
    if ($result -ne $case.Expected) {
        Fail-Test "Get-ComposePublishedPort [$($case.Label)]: expected '$($case.Expected)' got '$result'"
    }
}

# A failing `docker compose port` must produce no port, not a guess.
$failureResult = & {
    Invoke-Expression $definitions
    function docker {
        $global:LASTEXITCODE = 1
        return @()
    }
    Get-ComposePublishedPort -Service "backend" -ContainerPort 8080
}
if ($null -ne $failureResult) {
    Fail-Test "Get-ComposePublishedPort must return null when docker compose port fails, got '$failureResult'"
}

# ---------------------------------------------------------------------------
# 3. End to end: the probed URL and the printed URLs are the published ports
# ---------------------------------------------------------------------------
$cases = @(
    @{ Label = "defaults"; Env = @{}; Mutation = $null; Backend = "8080"; Frontend = "3000" }
    @{ Label = "env-file PORT"; Env = @{}; Mutation = @{ PORT = "9100" }; Backend = "9100"; Frontend = "3000" }
    @{ Label = "env-file BACKEND_PORT"; Env = @{}; Mutation = @{ BACKEND_PORT = "9200" }; Backend = "9200"; Frontend = "3000" }
    @{ Label = "env-file API_PORT"; Env = @{}; Mutation = @{ API_PORT = "9300" }; Backend = "9300"; Frontend = "3000" }
    @{ Label = "env-file SERVER_PORT"; Env = @{}; Mutation = @{ SERVER_PORT = "9400" }; Backend = "9400"; Frontend = "3000" }
    @{ Label = "env-file FRONTEND_PORT"; Env = @{}; Mutation = @{ FRONTEND_PORT = "3100" }; Backend = "8080"; Frontend = "3100" }
    @{ Label = "ambient PORT beats .env"; Env = @{ PORT = "9500" }; Mutation = @{ PORT = "9100" }; Backend = "9500"; Frontend = "3000" }
    @{ Label = "ambient BACKEND_PORT beats .env"; Env = @{ BACKEND_PORT = "9600" }; Mutation = @{ PORT = "9100" }; Backend = "9600"; Frontend = "3000" }
    @{ Label = "ambient API_PORT beats .env"; Env = @{ API_PORT = "9700" }; Mutation = @{ PORT = "9100" }; Backend = "9700"; Frontend = "3000" }
    @{ Label = "ambient SERVER_PORT beats .env"; Env = @{ SERVER_PORT = "9800" }; Mutation = @{ PORT = "9100" }; Backend = "9800"; Frontend = "3000" }
    @{ Label = "ambient FRONTEND_PORT beats .env"; Env = @{ FRONTEND_PORT = "3200" }; Mutation = @{ FRONTEND_PORT = "3100" }; Backend = "8080"; Frontend = "3200" }
    # An explicitly empty higher-priority alias contributes no port, so the chain
    # continues. Expressed through .env because whether an *ambient* empty value
    # survives into a child process is platform-dependent; the Bash suite covers
    # the ambient-empty case on POSIX.
    @{ Label = "env-file empty BACKEND_PORT falls back"; Env = @{}; Mutation = @{ BACKEND_PORT = ""; PORT = "9100" }; Backend = "9100"; Frontend = "3000" }
)

$runnerScript = Join-Path ([System.IO.Path]::GetTempPath()) "multica-install-ps1-case.ps1"

# Each case runs in its own process using the current PowerShell host, so the
# Windows PowerShell 5.1 suite cannot silently delegate its cases to PowerShell 7.
# install.ps1 uses `exit` on failure, and
# a child process is also the only way to control the ambient environment
# cleanly rather than inheriting a CI runner's PORT.
@'
param(
    [string]$InstallerPath,
    [string]$WorkDir,
    [string]$ProbeLog
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

if (-not $env:USERPROFILE) { $env:USERPROFILE = [System.IO.Path]::GetTempPath() }
$env:MULTICA_INSTALL_DIR = $WorkDir
$env:MULTICA_SELFHOST_REF = "main"

$source = Get-Content -Raw -Path $InstallerPath
$index = $source.IndexOf("# Entry point")
$banner = $source.LastIndexOf("# ---", $index)
if ($banner -ge 0) { $index = $banner }
Invoke-Expression $source.Substring(0, $index)

function Get-EnvFilePortValue {
    param([string]$Key)
    $line = Get-Content (Join-Path $WorkDir ".env") |
        Where-Object { $_ -match "^$Key=" } |
        Select-Object -Last 1
    if (-not $line) { return $null }
    return $line.Substring($Key.Length + 1)
}

# Compose resolution: the process environment wins over .env.
function Resolve-ComposeValue {
    param([string]$Key)
    $fromEnv = [System.Environment]::GetEnvironmentVariable($Key)
    if ($null -ne $fromEnv) { return $fromEnv }
    return Get-EnvFilePortValue -Key $Key
}

function Get-StubBackendPort {
    foreach ($key in @("BACKEND_PORT", "API_PORT", "SERVER_PORT", "PORT")) {
        $value = Resolve-ComposeValue -Key $key
        if (-not [string]::IsNullOrEmpty($value)) { return $value }
    }
    return "8080"
}

function Get-StubFrontendPort {
    $value = Resolve-ComposeValue -Key "FRONTEND_PORT"
    if (-not [string]::IsNullOrEmpty($value)) { return $value }
    return "3000"
}

# Stand in for the external commands the installer shells out to.
function docker {
    $global:LASTEXITCODE = 0
    $args_ = @($args)
    if ($args_.Count -ge 1 -and $args_[0] -eq "info") { return }
    if ($args_.Count -ge 1 -and $args_[0] -eq "compose") {
        if ($args_ -contains "port") {
            if ($args_ -contains "backend") { return "127.0.0.1:$(Get-StubBackendPort)" }
            if ($args_ -contains "frontend") { return "127.0.0.1:$(Get-StubFrontendPort)" }
            $global:LASTEXITCODE = 1
            return
        }
        if ($args_ -contains "version") { return "2.30.0" }
    }
    return
}

function git { $global:LASTEXITCODE = 0 }

# Record every probed URL instead of making a request.
function Invoke-WebRequest {
    param(
        [Parameter(Position = 0)][string]$Uri,
        [switch]$UseBasicParsing,
        [int]$TimeoutSec
    )
    Add-Content -Path $ProbeLog -Value $Uri
    return [pscustomobject]@{ StatusCode = 200 }
}

# The CLI install itself is out of scope for the port contract.
function Install-Cli { }
function Test-Docker { }

Start-LocalInstall
'@ | Set-Content -Path $runnerScript -Encoding UTF8

foreach ($case in $cases) {
    $workDir = Join-Path ([System.IO.Path]::GetTempPath()) ("multica-ps1-" + [guid]::NewGuid().ToString("N"))
    New-Item -ItemType Directory -Path (Join-Path $workDir ".git") -Force | Out-Null

    $envLines = @(
        "PORT=8080"
        "# BACKEND_PORT=8080"
        "# API_PORT=8080"
        "# SERVER_PORT=8080"
        "FRONTEND_PORT=3000"
        "JWT_SECRET=change-me-in-production"
        "POSTGRES_PASSWORD=multica"
    )
    if ($case.Mutation) {
        foreach ($key in $case.Mutation.Keys) {
            $value = $case.Mutation[$key]
            $envLines = $envLines | ForEach-Object {
                if ($_ -match "^#?\s*$key=") { "$key=$value" } else { $_ }
            }
        }
    }
    $envLines | Set-Content -Path (Join-Path $workDir ".env") -Encoding UTF8
    Copy-Item (Join-Path $workDir ".env") (Join-Path $workDir ".env.example")
    New-Item -ItemType File -Path (Join-Path $workDir "docker-compose.selfhost.yml") -Force | Out-Null

    $probeLog = Join-Path $workDir "probes.log"
    New-Item -ItemType File -Path $probeLog -Force | Out-Null

    # Control the ambient environment explicitly so a CI runner's own PORT
    # cannot leak into the matrix. Absent must mean absent: an empty string is a
    # different input, and the chain treats it as "set but no value".
    $portVars = @("PORT", "BACKEND_PORT", "API_PORT", "SERVER_PORT", "FRONTEND_PORT")
    $previous = @{}
    foreach ($key in $portVars) {
        $previous[$key] = [System.Environment]::GetEnvironmentVariable($key)
        if ($case.Env.ContainsKey($key)) {
            Set-Item -Path "Env:$key" -Value $case.Env[$key]
        } else {
            Remove-Item -Path "Env:$key" -ErrorAction SilentlyContinue
        }
    }

    try {
        $output = & $PowerShellPath -NoProfile -File $runnerScript -InstallerPath $InstallerPath -WorkDir $workDir -ProbeLog $probeLog 2>&1
        $exitCode = $LASTEXITCODE
    } finally {
        foreach ($key in $portVars) {
            if ($null -eq $previous[$key]) {
                Remove-Item -Path "Env:$key" -ErrorAction SilentlyContinue
            } else {
                Set-Item -Path "Env:$key" -Value $previous[$key]
            }
        }
    }

    $rendered = ($output | Out-String)
    if ($exitCode -ne 0) {
        Write-Host $rendered
        Fail-Test "[$($case.Label)] install.ps1 exited $exitCode"
    }

    $probed = (Get-Content $probeLog | Select-Object -First 1)
    $probedPort = if ($probed -match "localhost:(\d+)/health") { $Matches[1] } else { $null }
    $printedBackend = if ($rendered -match "Backend:\s+http://localhost:(\d+)") { $Matches[1] } else { $null }
    $printedFrontend = if ($rendered -match "Frontend:\s+http://localhost:(\d+)") { $Matches[1] } else { $null }

    if ($probedPort -ne $case.Backend -or $printedBackend -ne $case.Backend -or $printedFrontend -ne $case.Frontend) {
        Write-Host $rendered
        Write-Host "  compose published:   backend=$($case.Backend) frontend=$($case.Frontend)"
        Write-Host "  health check probed: $probedPort"
        Write-Host "  printed:             backend=$printedBackend frontend=$printedFrontend"
        Fail-Test "[$($case.Label)] installer ports disagree with the port Compose published"
    }

    Remove-Item $workDir -Recurse -Force -ErrorAction SilentlyContinue
}

Remove-Item $runnerScript -Force -ErrorAction SilentlyContinue
Write-Host "install.ps1 tests passed" -ForegroundColor Green