diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 91bb08d..48d0159 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -35,20 +35,15 @@ jobs: strategy: fail-fast: false matrix: + # TODO See https://github.com/actions/runner-images/issues/13729 runs-on: [windows-latest, windows-11-arm] runs-on: ${{ matrix.runs-on }} timeout-minutes: 60 steps: - uses: actions/checkout@v7 - # TODO See https://github.com/actions/runner-images/issues/13729 - - if: matrix.runs-on == 'windows-latest' - run: | - for ($i = 1; $i -le 18; $i++) { - docker info *> $null - if ($LASTEXITCODE -eq 0) { exit 0 } - Start-Sleep -Seconds 10 - } - throw 'FAIL the docker daemon never became ready' + # Host Go on windows-11-arm is for compiling moby only + # (install-docker-arm64.ps1); the suite itself runs from the Compose + # runner service with no host Go anywhere. # TODO For compiling moby only. # Because https://github.com/actions/runner-images/blob/win11-arm64/20260920.174/images/windows/Windows11-Arm64-Readme.md is still using `Go 1.24.13`. - if: matrix.runs-on == 'windows-11-arm' @@ -58,4 +53,18 @@ jobs: cache: false - if: matrix.runs-on == 'windows-11-arm' run: pwsh -NoProfile -File tests/e2e/windows/install-docker-arm64.ps1 - - run: pwsh -NoProfile -File tests/e2e/windows/e2e.ps1 + # windows-latest ships Docker Compose; windows-11-arm builds its engine + # from source with no plugin, so pin one explicitly there. + - if: matrix.runs-on == 'windows-11-arm' + name: Install Compose v5.5.1 CLI plugin + run: | + New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.docker\cli-plugins" | Out-Null + curl.exe -fsSL --retry 3 --retry-delay 5 --retry-all-errors "https://github.com/docker/compose/releases/download/v5.5.1/docker-compose-windows-aarch64.exe" -o "$env:USERPROFILE\.docker\cli-plugins\docker-compose.exe" + docker compose version + # Both runners drive the daemon from the Compose runner service + # (Dockerfile-runner: servercore plus the pinned Go MSI, built for the + # host arch): no host Go is needed by the suite on any arch. Host Go on + # windows-11-arm exists only to compile moby above. + - run: docker compose -f tests/e2e/windows/compose.yaml run --rm --build e2e + env: + ARCH: ${{ contains(matrix.runs-on, 'arm') && 'arm64' || 'amd64' }} diff --git a/docs/e2e.md b/docs/e2e.md index 2df99d4..a5ec5f7 100644 --- a/docs/e2e.md +++ b/docs/e2e.md @@ -67,9 +67,9 @@ winget install --id Microsoft.PowerShell --source winget --exact ```powershell winget install --id Git.Git --source winget --exact -Invoke-WebRequest -UseBasicParsing ` +curl.exe -fsSL --retry 3 --retry-delay 5 --retry-all-errors ` "https://raw.githubusercontent.com/microsoft/Windows-Containers/Main/helpful_tools/Install-DockerCE/install-docker-ce.ps1" ` - -OutFile ./install-docker-ce.ps1 + -o ./install-docker-ce.ps1 ``` 3. Execute in PowerShell 7 with administrator privileges, @@ -80,10 +80,26 @@ Invoke-WebRequest -UseBasicParsing ` 4. Execute in PowerShell 7, +```powershell +New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.docker\cli-plugins" | Out-Null +curl.exe -fsSL --retry 3 --retry-delay 5 --retry-all-errors ` + "https://github.com/docker/compose/releases/download/v5.5.1/docker-compose-windows-x86_64.exe" ` + -o "$env:USERPROFILE\.docker\cli-plugins\docker-compose.exe" +``` + +5. Execute in PowerShell 7, + ```powershell git clone git@github.com:version-fox/vfox-flutter.git cd ./vfox-flutter/ -pwsh -NoProfile -File .\tests\e2e\windows\e2e.ps1 +docker compose -f tests/e2e/windows/compose.yaml run --rm --build e2e +``` + +To check the `vfox` x `flavor` x `mirror` matrix expansion without +starting containers, override the service command: + +```powershell +docker compose -f tests/e2e/windows/compose.yaml run --rm e2e go test -short ./... ``` ### arm64 @@ -98,9 +114,16 @@ winget install --id Microsoft.PowerShell --source winget --exact ```powershell winget install --id Git.Git --source winget --exact +# Host Go stays here: building the moby engine from source needs it. The +# suite itself runs from a container (see below), no host Go involved. winget install --id GoLang.Go --source winget --exact git clone git@github.com:version-fox/vfox-flutter.git cd ./vfox-flutter/ pwsh -NoProfile -File .\tests\e2e\windows\install-docker-arm64.ps1 -pwsh -NoProfile -File .\tests\e2e\windows\e2e.ps1 +New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.docker\cli-plugins" | Out-Null +curl.exe -fsSL --retry 3 --retry-delay 5 --retry-all-errors ` + "https://github.com/docker/compose/releases/download/v5.5.1/docker-compose-windows-aarch64.exe" ` + -o "$env:USERPROFILE\.docker\cli-plugins\docker-compose.exe" +$env:ARCH = 'arm64' +docker compose -f tests/e2e/windows/compose.yaml run --rm --build e2e ``` diff --git a/tests/e2e/linux/setup.go b/tests/e2e/linux/setup.go index f7341ff..e3b73aa 100644 --- a/tests/e2e/linux/setup.go +++ b/tests/e2e/linux/setup.go @@ -11,8 +11,8 @@ import ( tc "github.com/testcontainers/testcontainers-go" ) -// NOTE: keep in sync with tests/e2e/windows/lib.ps1, which duplicates these -// defaults (Go and PowerShell share no config format). Bump both together. +// NOTE: keep in sync with tests/e2e/windows/setup.go, which duplicates these +// defaults (the two Go modules share no config format). Bump both together. const ( defaultOfficialVersion = "3.47.4" defaultOhosVersion = "3.41.10-ohos-1.0.0" diff --git a/tests/e2e/linux/verify.go b/tests/e2e/linux/verify.go index 61e9b37..76d706e 100644 --- a/tests/e2e/linux/verify.go +++ b/tests/e2e/linux/verify.go @@ -14,7 +14,7 @@ import ( // NOTE: the drift-warning strings asserted here are part of the plugin's // user-facing contract (see hooks/ lib/). If the Lua side rewords them, -// update this file and tests/e2e/windows/verify.Tests.ps1 together. +// update this file and tests/e2e/windows/verify.go together. // sdkDir prints the SDK root for the active flutter on PATH // (was lib.sh sdk_dir). diff --git a/tests/e2e/windows/Dockerfile b/tests/e2e/windows/Dockerfile index 2e17c80..7168d49 100644 --- a/tests/e2e/windows/Dockerfile +++ b/tests/e2e/windows/Dockerfile @@ -4,7 +4,6 @@ FROM ${BASE_IMAGE} SHELL ["powershell", "-NoProfile", "-Command"] -COPY tests/e2e/windows/lib.ps1 C:\lib.ps1 COPY tests/e2e/windows/bootstrap-tools.ps1 C:\bootstrap-tools.ps1 RUN powershell -NoProfile -ExecutionPolicy Bypass -File 'C:\bootstrap-tools.ps1' @@ -13,7 +12,8 @@ WORKDIR C:\e2e COPY metadata.lua C:\e2e\metadata.lua COPY hooks C:\e2e\hooks COPY lib C:\e2e\lib -COPY tests/e2e/windows C:\e2e\tests\e2e\windows -ENTRYPOINT ["C:/Program Files/PowerShell/7/pwsh.exe", "-File"] -CMD ["C:/e2e/tests/e2e/windows/run.ps1"] +# Long-lived container: the Go test (e2e_test.go) drives every phase through +# pwsh exec calls, so the main process just idles here. +ENTRYPOINT ["C:/Program Files/PowerShell/7/pwsh.exe", "-NoProfile", "-Command"] +CMD ["while ($true) { Start-Sleep 3600 }"] diff --git a/tests/e2e/windows/Dockerfile-runner b/tests/e2e/windows/Dockerfile-runner new file mode 100644 index 0000000..9c8e63d --- /dev/null +++ b/tests/e2e/windows/Dockerfile-runner @@ -0,0 +1,26 @@ +# escape=` +# Runner image for the Windows container E2E suite: stock servercore plus the +# pinned Go toolchain, so no host Go is needed on any arch. (There is no +# stock golang Windows image for arm64, hence this file instead of pinning +# one.) Built via compose --build (see compose.yaml). +ARG BASE_IMAGE=mcr.microsoft.com/windows/servercore:ltsc2025-KB5122871-amd64 +FROM ${BASE_IMAGE} + +SHELL ["powershell", "-NoProfile", "-Command"] + +ARG GO_VERSION=1.26.8 +# NB: no double quotes or backticks below. The classic builder wraps the +# joined lines in powershell -Command "..." through cmd.exe, where both +# break parsing. Single quotes, ${var} braces and array arguments only. +RUN $goArch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'amd64' }; ` + $goMsi = $env:TEMP + '\go.msi'; ` + curl.exe -fsSL --retry 3 --retry-delay 5 --retry-all-errors -o $goMsi https://go.dev/dl/go${env:GO_VERSION}.windows-${goArch}.msi; ` + $goInstall = Start-Process msiexec.exe -Wait -PassThru -ArgumentList '/i', $goMsi, '/quiet', '/norestart'; ` + if ($goInstall.ExitCode -notin 0, 3010, 1937) { throw 'FAIL the Go MSI install exited with code ' + $goInstall.ExitCode }; ` + Remove-Item $goMsi; ` + New-Item -ItemType Directory -Force -Path 'C:\m', 'C:\b' | Out-Null; ` + & 'C:\Program Files\Go\bin\go.exe' version + +# Short module/build caches: Windows long paths are off in this image. +ENV GOMODCACHE=C:\m +ENV GOCACHE=C:\b diff --git a/tests/e2e/windows/bootstrap-tools.ps1 b/tests/e2e/windows/bootstrap-tools.ps1 index b8a9ced..f0fcea5 100644 --- a/tests/e2e/windows/bootstrap-tools.ps1 +++ b/tests/e2e/windows/bootstrap-tools.ps1 @@ -1,5 +1,21 @@ $ErrorActionPreference = 'Stop' -. "$PSScriptRoot\lib.ps1" + +# Local copy of the native-command guard (the e2e/lib.ps1 helper is gone; +# the suite is driven from Go now). Fails unless the command exits 0. +function Invoke-Native { + param( + [Parameter(Mandatory)] [scriptblock] $Cmd, + [Parameter(Mandatory)] [string] $What, + [int[]] $Ok = 0 + ) + $prev = $PSNativeCommandUseErrorActionPreference + $PSNativeCommandUseErrorActionPreference = $false + & $Cmd + $code = $LASTEXITCODE + $PSNativeCommandUseErrorActionPreference = $prev + if ($null -eq $code) { throw "FAIL $What did not run" } + if ($code -notin $Ok) { throw "FAIL $What exited with code $code" } +} $IsArm64 = $env:PROCESSOR_ARCHITECTURE -eq 'ARM64' $pwshArch = if ($IsArm64) { 'arm64' } else { 'x64' } @@ -20,11 +36,3 @@ if (-not (Test-Path 'C:\Program Files\PowerShell\7\pwsh.exe')) { throw 'FAIL pws if (-not (Test-Path 'C:\Program Files\Git\cmd\git.exe')) { throw 'FAIL git.exe is missing after the Git installer' } Invoke-Native { & 'C:\Program Files\Git\cmd\git.exe' config --system core.longpaths true } 'enable system-wide git long paths' New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -Value 1 -PropertyType DWORD -Force | Out-Null - -# Pester 5 runs the *.Tests.ps1 suites (see run.ps1). Pinned, because the -# container image must stay reproducible; run.ps1 only requires v5 or newer. -$PesterVersion = '5.7.1' -$pwsh = 'C:\Program Files\PowerShell\7\pwsh.exe' -Invoke-Native { & $pwsh -NoProfile -Command "Set-PSRepository -Name 'PSGallery' -InstallationPolicy 'Trusted'" } 'trust the PSGallery repository' -Invoke-Native { & $pwsh -NoProfile -Command "Install-Module -Name 'Pester' -RequiredVersion '$PesterVersion' -Scope 'AllUsers' -Force -SkipPublisherCheck" } "the Pester $PesterVersion module install" -Invoke-Native { & $pwsh -NoProfile -Command "& { Import-Module 'Pester' -RequiredVersion '$PesterVersion'; (Get-Module 'Pester').Version.ToString() }" } "importing Pester $PesterVersion" diff --git a/tests/e2e/windows/compose.yaml b/tests/e2e/windows/compose.yaml new file mode 100644 index 0000000..4c094c8 --- /dev/null +++ b/tests/e2e/windows/compose.yaml @@ -0,0 +1,45 @@ +# Host-side runner for the Windows container E2E suite (see docs/e2e.md). +# +# docker compose -f tests/e2e/windows/compose.yaml run --rm --build e2e +# +# The host requirement is Docker plus the Compose v5 plugin (installed once +# per machine, see docs/e2e.md); Go never touches the host, so it cannot +# conflict with a vfox-managed toolchain. The runner image +# (Dockerfile-runner: stock servercore plus the pinned Go MSI) is built for +# the host arch, because there is no stock golang Windows image for arm64. +# It drives the host daemon through the mounted named pipe. Ryuk is disabled +# in code (it has no Windows image); every container is terminated explicitly +# via t.Cleanup instead. +# +# Every knob below forwards only when set in the caller's env; empty means +# "use the built-in default" (see matrix.go). + +services: + e2e: + build: + context: ../../.. + dockerfile: tests/e2e/windows/Dockerfile-runner + args: + BASE_IMAGE: mcr.microsoft.com/windows/servercore:ltsc2025-KB5122871-${ARCH:-amd64} + image: vfox-flutter/e2e-runner:1.26.8 + working_dir: C:/work/tests/e2e/windows + volumes: + - type: npipe + source: \\.\pipe\docker_engine + target: \\.\pipe\docker_engine + - ../../..:C:/work + - e2e-go-modcache:C:/m + environment: + ARCH: ${ARCH:-} + E2E_MAX_JOBS: ${E2E_MAX_JOBS:-} + VFOX_VERSION: ${VFOX_VERSION:-} + FLAVOR: ${FLAVOR:-} + MIRROR: ${MIRROR:-} + FLUTTER_VERSION: ${FLUTTER_VERSION:-} + OHOS_VERSION: ${OHOS_VERSION:-} + VFOX_FLUTTER_GITHUB_MIRROR: ${VFOX_FLUTTER_GITHUB_MIRROR:-} + E2E_RETRY_ATTEMPTS: ${E2E_RETRY_ATTEMPTS:-} + command: ["go", "test", "-v", "-timeout", "55m", "-run", "TestE2E", "./..."] + +volumes: + e2e-go-modcache: diff --git a/tests/e2e/windows/e2e.ps1 b/tests/e2e/windows/e2e.ps1 deleted file mode 100644 index cd168b3..0000000 --- a/tests/e2e/windows/e2e.ps1 +++ /dev/null @@ -1,99 +0,0 @@ -$ErrorActionPreference = 'Stop' -$PSNativeCommandUseErrorActionPreference = $false -. "$PSScriptRoot\lib.ps1" - -$Repo = (Resolve-Path "$PSScriptRoot\..\..\..").Path - -$hostArch = $env:PROCESSOR_ARCHITECTURE -if ($env:ARCH) { $hostArch = $env:ARCH } -$arch = $hostArch.ToLowerInvariant() -if ($arch -eq 'aarch64') { $arch = 'arm64' } -if ($arch -eq 'x86_64' -or $arch -eq 'x64') { $arch = 'amd64' } -if (($arch -ne 'amd64') -and ($arch -ne 'arm64')) { throw "FAIL unknown architecture $hostArch" } -$platform = "windows/$arch" -$Image = "vfox-flutter-e2e:windows-$arch" -$baseImage = "mcr.microsoft.com/windows/servercore:ltsc2025-KB5122871-$arch" - -Invoke-Native { & docker build --pull --platform $platform --build-arg "BASE_IMAGE=$baseImage" -f "$PSScriptRoot\Dockerfile" -t $Image $Repo } 'docker build' - -$foxes = if ($env:VFOX_VERSION) { @($env:VFOX_VERSION) } else { @('latest', 'main') } -$defaultFlavours = @('official', 'ohos') -$defaultMirrors = @('default', 'https://storage.flutter-io.cn') -if ($arch -eq 'arm64') { - $defaultFlavours = @('official') - $defaultMirrors = @('default') -} -$flavours = if ($env:FLAVOR) { @($env:FLAVOR) } else { $defaultFlavours } -$mirrorList = if ($env:MIRROR) { @($env:MIRROR -split '\s+' | Where-Object { $_ -ne '' }) } else { $defaultMirrors } -$mirrorExplicit = -not [string]::IsNullOrWhiteSpace($env:MIRROR) - -$combos = [System.Collections.Generic.List[object]]::new() -foreach ($vfox in $foxes) { - foreach ($flavor in $flavours) { - foreach ($mirror in $mirrorList) { - if ((-not $mirrorExplicit) -and ($mirror -ne 'default') -and ($vfox -ne 'latest')) { continue } - $combos.Add([pscustomobject]@{ Vfox = $vfox; Flavor = $flavor; Mirror = $mirror }) - } - } -} - -$maxJobs = 2 -if ($env:E2E_MAX_JOBS) { $maxJobs = [int]$env:E2E_MAX_JOBS } -if ($maxJobs -lt 1) { $maxJobs = 1 } - -function Get-ComboSlug { - param([object] $Combo) - return ($Combo.Vfox + '-' + $Combo.Flavor + '-' + ($Combo.Mirror -replace '[^A-Za-z0-9]', '_')) -} - -function Get-ContainerExitCode { - param([string] $Id) - $code = & docker wait $Id - $code = if ($null -eq $code) { '' } else { ($code | Select-Object -Last 1).Trim() } - if ($code -notmatch '^-?\d+$') { throw "FAIL docker wait $Id reported no exit code" } - return [long]$code -} - -$failures = [System.Collections.Generic.List[string]]::new() -for ($start = 0; $start -lt $combos.Count; $start += $maxJobs) { - $end = [math]::Min($start + $maxJobs - 1, $combos.Count - 1) - $batch = [System.Collections.Generic.List[object]]::new() - - for ($i = $start; $i -le $end; $i++) { - $combo = $combos[$i] - $vfox = $combo.Vfox - $flavor = $combo.Flavor - $mirror = $combo.Mirror - $prefix = "vfox $vfox, $flavor, mirror $mirror, $platform" - Write-Output "[$prefix] === start ===" - - $slug = Get-ComboSlug $combo - $runArgs = @( - 'run', '-d', '--platform', $platform, - '-e', "VFOX_VERSION=$vfox", '-e', "FLAVOR=$flavor", - '-e', "VFOX_E2E_SLOT=$slug") - if ($mirror -ne 'default') { $runArgs += @('-e', "FLUTTER_STORAGE_BASE_URL=$mirror") } - $runArgs += $Image - - $cid = & docker @runArgs - if ($LASTEXITCODE -ne 0) { throw "FAIL docker run ($prefix) failed to start" } - $batch.Add([pscustomobject]@{ Prefix = $prefix; Id = $cid }) - } - - foreach ($entry in $batch) { - $prefix = $entry.Prefix - $exitCode = Get-ContainerExitCode $entry.Id - $logs = (& docker logs $entry.Id) -join "`r`n" - Write-Output $logs - & docker rm $entry.Id | Out-Null - - if ($exitCode -ne 0) { - $failures.Add("FAIL docker run ($prefix) exited with code $exitCode") - } - elseif ($logs -notmatch '=== vfox') { - $failures.Add("FAIL docker run ($prefix) produced no test output") - } - } -} - -if ($failures.Count -gt 0) { throw ($failures -join "`r`n") } diff --git a/tests/e2e/windows/e2e_test.go b/tests/e2e/windows/e2e_test.go new file mode 100644 index 0000000..fe2a190 --- /dev/null +++ b/tests/e2e/windows/e2e_test.go @@ -0,0 +1,202 @@ +package e2e + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/containerd/platforms" + specs "github.com/opencontainers/image-spec/specs-go/v1" + tc "github.com/testcontainers/testcontainers-go" + + client "github.com/moby/moby/client" +) + +// passThroughEnvs are host env knobs the suite honours. Forwarded only when +// set, so unset stays default. (The old e2e.ps1 only passed VFOX_VERSION, +// FLAVOR, VFOX_E2E_SLOT and FLUTTER_STORAGE_BASE_URL into the container; +// forwarding the rest is a strict improvement shared with the Linux suite.) +var passThroughEnvs = []string{ + "FLUTTER_VERSION", + "OHOS_VERSION", + "VFOX_FLUTTER_GITHUB_MIRROR", + "E2E_RETRY_ATTEMPTS", +} + +// TestE2E builds the image once, then runs one container per matrix combo. +// Bounded parallelism via t.Parallel + semaphore (like E2E_MAX_JOBS). +// +// Full matrix runs in CI; locally scope it down, e.g.: +// VFOX_VERSION=latest FLAVOR=official MIRROR=default E2E_MAX_JOBS=1 go test -v +func TestE2E(t *testing.T) { + if testing.Short() { + t.Skip("skip container E2E in short mode") + } + // Ryuk has no Windows image, so the reaper can never start here. + // Every container is terminated explicitly via t.Cleanup instead. + t.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true") + + arch, err := detectArch() + if err != nil { + t.Fatal(err) + } + platform := "windows/" + arch + image := "vfox-flutter-e2e:windows-" + arch + baseImage := "mcr.microsoft.com/windows/servercore:ltsc2025-KB5122871-" + arch + + here, err := filepath.Abs(".") + if err != nil { + t.Fatal(err) + } + repoRoot := filepath.Join(here, "..", "..", "..") + + ctx := context.Background() + buildImage(t, ctx, repoRoot, image, platform, baseImage) + + combos, err := matrix() + if err != nil { + t.Fatal(err) + } + if len(combos) == 0 { + t.Fatal("empty matrix: nothing to run") + } + + sem := make(chan struct{}, maxJobs()) + for _, c := range combos { + c := c + t.Run(c.slug(), func(t *testing.T) { + t.Parallel() + sem <- struct{}{} + defer func() { <-sem }() + runOne(t, ctx, image, platform, c) + }) + } +} + +// buildImage replicates `docker build --pull --platform $platform +// --build-arg BASE_IMAGE=$baseImage -f tests/e2e/windows/Dockerfile +// -t $image $repoRoot`. +func buildImage(t *testing.T, ctx context.Context, repoRoot, image, platform, baseImage string) { + t.Helper() + + provider, err := tc.NewDockerProvider() + if err != nil { + t.Fatalf("new docker provider: %v", err) + } + t.Cleanup(func() { _ = provider.Close() }) + + pf, err := platforms.Parse(platform) + if err != nil { + t.Fatalf("invalid platform %s: %v", platform, err) + } + + tag, err := provider.BuildImage(ctx, &tc.ContainerRequest{ + FromDockerfile: tc.FromDockerfile{ + Context: repoRoot, + Dockerfile: "tests/e2e/windows/Dockerfile", + Repo: "vfox-flutter-e2e", + Tag: image[strings.LastIndex(image, ":")+1:], + BuildArgs: map[string]*string{"BASE_IMAGE": &baseImage}, + KeepImage: true, + BuildLogWriter: os.Stdout, + BuildOptionsModifier: func(opts *client.ImageBuildOptions) { + opts.Platforms = []specs.Platform{pf} + opts.PullParent = true + }, + }, + }) + if err != nil { + t.Fatalf("build image %s: %v", image, err) + } + if tag != image { + t.Fatalf("built tag %s, want %s", tag, image) + } +} + +// containerName returns a unique container name per run: the combo slug stays +// greppable in `docker ps`, the random suffix avoids clashes with leftovers +// from killed runs (the old ps1 runner detached anonymous names per batch). +func containerName(c combo) string { + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("vfox-flutter-e2e-%s-%d", c.slug(), time.Now().UnixNano()) + } + return "vfox-flutter-e2e-" + c.slug() + "-" + hex.EncodeToString(b[:]) +} + +// runOne starts one long-lived container per combo and drives the whole +// E2E flow (setup -> preflight -> install -> verify) through pwsh exec +// calls, asserting in Go. A combo passes when every phase exits 0 and the +// outputs carry the expected markers (same bar as the old e2e.ps1). +func runOne(t *testing.T, ctx context.Context, image, platform string, c combo) { + t.Helper() + prefix := c.prefix(platform) + + env := map[string]string{ + "VFOX_VERSION": c.vfox, + "FLAVOR": c.flavor, + "VFOX_E2E_SLOT": c.slug(), + } + if c.mirror != "default" { + env["FLUTTER_STORAGE_BASE_URL"] = c.mirror + } + for _, name := range passThroughEnvs { + if v, ok := os.LookupEnv(name); ok && v != "" { + env[name] = v + } + } + + // Stock Windows daemons have no "bridge" network (only "nat"), and + // CreateContainer unconditionally ensures the provider's default bridge + // network, creating it when missing. Point it at "nat" — the daemon + // default that plain `docker run` also uses — or every start fails with + // "could not find plugin bridge". + provider, err := tc.NewDockerProvider(tc.WithDefaultBridgeNetwork("nat")) + if err != nil { + t.Fatalf("[%s] new docker provider: %v", prefix, err) + } + // NB: no provider.Close here: the container below terminates through + // this very provider in t.Cleanup. + ctr, err := provider.RunContainer(ctx, tc.ContainerRequest{ + Image: image, + ImagePlatform: platform, + Name: containerName(c), + Env: env, + }) + if err != nil { + t.Fatalf("[%s] start container: %v", prefix, err) + } + t.Cleanup(func() { + _ = ctr.Terminate(context.Background()) + }) + + // One overall budget per combo; the CI job timeout stays the outer bound. + ctx, cancel := context.WithTimeout(ctx, 45*time.Minute) + defer cancel() + + flavor := c.flavor + mirror := c.mirror + version := resolveFlutterVersion(t, flavor) + procArch := procArchOfContainer(ctx, t, ctr) + t.Logf("=== vfox %s, flutter %s, %s, mirror %s, %s ===", c.vfox, version, flavor, mirror, procArch) + + box := setupSlotEnv(ctx, t, ctr, c.slug()) + checkMirrorReachable(ctx, t, ctr, flavor, mirror) + setupVfox(ctx, t, ctr, box, c.vfox) + checkBogusMirrorRejected(ctx, t, ctr, box, flavor, version) + checkBogusGithubMirrorRejected(ctx, t, ctr, box, flavor, version) + + activation := fetchActivation(ctx, t, ctr, box) + installFlutterVersion(ctx, t, ctr, box, version) + + sdk := sdkDir(ctx, t, ctr, box, activation) + verifySdkLayout(ctx, t, ctr, box, flavor, sdk) + verifyManifestDrift(ctx, t, ctr, box, activation, version, sdk) + verifyToolchain(ctx, t, ctr, box, activation, sdk) +} diff --git a/tests/e2e/windows/exec.go b/tests/e2e/windows/exec.go new file mode 100644 index 0000000..e07e56f --- /dev/null +++ b/tests/e2e/windows/exec.go @@ -0,0 +1,88 @@ +package e2e + +import ( + "context" + "io" + "os" + "strconv" + "strings" + "testing" + "time" + + tc "github.com/testcontainers/testcontainers-go" + tcexec "github.com/testcontainers/testcontainers-go/exec" +) + +// retryAttempts caps exec retries; honours $E2E_RETRY_ATTEMPTS (default 3, +// matching Invoke-WithRetry). Kept on the standard library: the established +// retry libraries (cenkalti/backoff, avast/retry-go) have no release in the +// last 6 months, so no third-party candidate qualifies. +func retryAttempts() int { + if v, ok := os.LookupEnv("E2E_RETRY_ATTEMPTS"); ok && strings.TrimSpace(v) != "" { + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { + return n + } + } + return 3 +} + +// pwshPrelude mirrors the run.ps1/lib.ps1 headers: any cmdlet error aborts +// and native failures propagate. Without it, pwsh exits 0 despite script +// errors (non-terminating by default) and execOK would pass silently. +const pwshPrelude = "$ErrorActionPreference = 'Stop'; $PSNativeCommandUseErrorActionPreference = $true;" + +// execRun runs script via `pwsh -NoProfile -Command` in the container and +// returns its exit code with the combined stdout+stderr output. extraEnv +// entries ("K=V") override single variables; the rest of the container env +// is inherited. +func execRun(ctx context.Context, t *testing.T, ctr tc.Container, script string, extraEnv ...string) (int, string) { + t.Helper() + opts := []tcexec.ProcessOption{tcexec.Multiplexed()} + if len(extraEnv) > 0 { + opts = append(opts, tcexec.WithEnv(extraEnv)) + } + code, r, err := ctr.Exec(ctx, []string{"pwsh", "-NoProfile", "-Command", pwshPrelude + " " + script}, opts...) + if err != nil { + t.Fatalf("exec: %v\n--- script ---\n%s", err, script) + } + out, err := io.ReadAll(r) + if err != nil { + t.Fatalf("exec read output: %v\n--- script ---\n%s", err, script) + } + return code, string(out) +} + +// execOK runs script and fatals unless it exits 0, returning the output. +func execOK(ctx context.Context, t *testing.T, ctr tc.Container, script string, extraEnv ...string) string { + t.Helper() + code, out := execRun(ctx, t, ctr, script, extraEnv...) + if code != 0 { + t.Fatalf("exec exited with code %d\n--- script ---\n%s\n--- output ---\n%s", code, script, out) + } + return out +} + +// execRetry mirrors Invoke-WithRetry: run script up to retryAttempts() times +// with linear backoff (10s * attempt), logging each failed attempt. +func execRetry(ctx context.Context, t *testing.T, ctr tc.Container, label, script string, extraEnv ...string) string { + t.Helper() + max := retryAttempts() + var out string + for attempt := 1; ; attempt++ { + code, o := execRun(ctx, t, ctr, script, extraEnv...) + out = o + if code == 0 { + return out + } + if attempt >= max { + t.Fatalf("%s failed after %d attempt(s) (exit %d)\n--- output ---\n%s", label, attempt, code, out) + } + t.Logf("retrying %s, attempt %d exited %d", label, attempt, code) + t.Logf("--- output ---\n%s", out) + select { + case <-ctx.Done(): + t.Fatalf("%s: %v", label, ctx.Err()) + case <-time.After(time.Duration(10*attempt) * time.Second): + } + } +} diff --git a/tests/e2e/windows/go.mod b/tests/e2e/windows/go.mod new file mode 100644 index 0000000..7cca9fa --- /dev/null +++ b/tests/e2e/windows/go.mod @@ -0,0 +1,58 @@ +module github.com/version-fox/vfox-flutter/tests/e2e/windows + +go 1.25.0 + +require ( + github.com/containerd/platforms v0.2.1 + github.com/moby/moby/client v0.5.0 + github.com/opencontainers/image-spec v1.1.1 + github.com/stretchr/testify v1.12.1 + github.com/testcontainers/testcontainers-go v0.44.0 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.26.6 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/sys v0.47.0 // indirect +) diff --git a/tests/e2e/windows/go.sum b/tests/e2e/windows/go.sum new file mode 100644 index 0000000..5001fb9 --- /dev/null +++ b/tests/e2e/windows/go.sum @@ -0,0 +1,122 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= +github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/tests/e2e/windows/install-docker-arm64.ps1 b/tests/e2e/windows/install-docker-arm64.ps1 index f6d4a1a..2f16456 100644 --- a/tests/e2e/windows/install-docker-arm64.ps1 +++ b/tests/e2e/windows/install-docker-arm64.ps1 @@ -1,6 +1,22 @@ $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true -. "$PSScriptRoot\lib.ps1" + +# Local copy of the native-command guard (the e2e/lib.ps1 helper is gone; +# the suite is driven from Go now). Fails unless the command exits 0. +function Invoke-Native { + param( + [Parameter(Mandatory)] [scriptblock] $Cmd, + [Parameter(Mandatory)] [string] $What, + [int[]] $Ok = 0 + ) + $prev = $PSNativeCommandUseErrorActionPreference + $PSNativeCommandUseErrorActionPreference = $false + & $Cmd + $code = $LASTEXITCODE + $PSNativeCommandUseErrorActionPreference = $prev + if ($null -eq $code) { throw "FAIL $What did not run" } + if ($code -notin $Ok) { throw "FAIL $What exited with code $code" } +} function Test-Engine { if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { return $false } diff --git a/tests/e2e/windows/install.go b/tests/e2e/windows/install.go new file mode 100644 index 0000000..d802b24 --- /dev/null +++ b/tests/e2e/windows/install.go @@ -0,0 +1,22 @@ +package e2e + +import ( + "context" + "fmt" + "testing" + + tc "github.com/testcontainers/testcontainers-go" +) + +// installFlutterVersion installs one Flutter version and selects it globally +// (was install.ps1). Needs no activation: like the old script it runs plain, +// vfox.exe resolving through the slot PATH override. +func installFlutterVersion(ctx context.Context, t *testing.T, ctr tc.Container, box slotBox, version string) { + t.Helper() + execRetry(ctx, t, ctr, fmt.Sprintf("vfox install flutter@%s", version), + fmt.Sprintf("vfox install %s", pwshQuote("flutter@"+version)), + box.vars...) + execOK(ctx, t, ctr, + fmt.Sprintf("vfox use --global %s", pwshQuote("flutter@"+version)), + box.vars...) +} diff --git a/tests/e2e/windows/install.ps1 b/tests/e2e/windows/install.ps1 deleted file mode 100644 index 784677d..0000000 --- a/tests/e2e/windows/install.ps1 +++ /dev/null @@ -1,10 +0,0 @@ -param( - [Parameter(Mandatory = $true)] [string] $Version -) - -$ErrorActionPreference = 'Stop' -$PSNativeCommandUseErrorActionPreference = $true -. "$PSScriptRoot\lib.ps1" - -Invoke-WithRetry { vfox install flutter@$Version } "vfox install flutter@$Version" -Invoke-Native { vfox use --global flutter@$Version } "vfox use --global flutter@$Version" diff --git a/tests/e2e/windows/lib.ps1 b/tests/e2e/windows/lib.ps1 deleted file mode 100644 index 5e23ae5..0000000 --- a/tests/e2e/windows/lib.ps1 +++ /dev/null @@ -1,55 +0,0 @@ -# NOTE: the default versions below duplicate tests/e2e/linux/setup.go -# (PowerShell and Go share no config format). Bump both sides together. -function Resolve-FlutterVersion { - param( - [Parameter(Mandatory)] [string] $Flavor - ) - if ($Flavor -eq 'official') { - if ($env:FLUTTER_VERSION) { $env:FLUTTER_VERSION } else { '3.47.4' } - } elseif ($Flavor -eq 'ohos') { - if ($env:OHOS_VERSION) { $env:OHOS_VERSION } else { '3.41.10-ohos-1.0.0' } - } else { - throw "FAIL unknown flavor $Flavor" - } -} - -function Invoke-Native { - param( - [Parameter(Mandatory)] [scriptblock] $Cmd, - [Parameter(Mandatory)] [string] $What, - [int[]] $Ok = 0 - ) - $prev = $PSNativeCommandUseErrorActionPreference - $PSNativeCommandUseErrorActionPreference = $false - & $Cmd - $code = $LASTEXITCODE - $PSNativeCommandUseErrorActionPreference = $prev - if ($null -eq $code) { throw "FAIL $What did not run" } - if ($code -notin $Ok) { throw "FAIL $What exited with code $code" } -} - -# Invoke-WithRetry runs a native command streaming its output (so callers -# can still capture it) and retries up to $Attempts times with linear -# backoff. Progress notices go to stderr to stay out of captures. -function Invoke-WithRetry { - param( - [Parameter(Mandatory)] [scriptblock] $Cmd, - [Parameter(Mandatory)] [string] $What, - [int] $Attempts = 3, - [int] $BackoffSeconds = 10 - ) - $code = 1 - for ($attempt = 1; $attempt -le $Attempts; $attempt++) { - $prev = $PSNativeCommandUseErrorActionPreference - $PSNativeCommandUseErrorActionPreference = $false - & $Cmd - $code = $LASTEXITCODE - $PSNativeCommandUseErrorActionPreference = $prev - if ($code -eq 0) { return } - if ($attempt -lt $Attempts) { - [Console]::Error.WriteLine("retrying $What, attempt $attempt exited $code") - Start-Sleep -Seconds ($BackoffSeconds * $attempt) - } - } - throw "FAIL $What failed after $Attempts attempts (exit $code)" -} diff --git a/tests/e2e/windows/matrix.go b/tests/e2e/windows/matrix.go new file mode 100644 index 0000000..ca1dc83 --- /dev/null +++ b/tests/e2e/windows/matrix.go @@ -0,0 +1,155 @@ +// Package e2e is the Windows container E2E suite, driven entirely from Go +// via testcontainers-go. +// +// TestE2E builds the image in tests/e2e/windows/Dockerfile once, then runs +// one long-lived container per (vfox version x flavor x mirror) combination +// and drives setup -> install -> verify through pwsh exec calls (setup.go, +// install.go, verify.go), asserting in Go. matrix.go holds the matrix +// expansion. +package e2e + +import ( + "fmt" + "os" + "runtime" + "strconv" + "strings" +) + +// defaultMirrorCN is the default mainland-China Flutter storage mirror. +const defaultMirrorCN = "https://storage.flutter-io.cn" + +// combo is one matrix cell: a vfox version, a flutter flavor and a mirror. +type combo struct { + vfox string + flavor string + mirror string +} + +// prefix renders the log prefix shared with the old e2e.ps1: +// "vfox , , mirror , ". +func (c combo) prefix(platform string) string { + return fmt.Sprintf("vfox %s, %s, mirror %s, %s", c.vfox, c.flavor, c.mirror, platform) +} + +// slug renders a docker-safe container name fragment for the combo. +func (c combo) slug() string { + return c.vfox + "-" + c.flavor + "-" + sanitize(c.mirror) +} + +// sanitize replaces every non-ASCII-alphanumeric byte with '_' so arbitrary +// mirror URLs stay valid in container names (mirrors e2e.ps1). +func sanitize(s string) string { + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + ch := s[i] + if ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9' { + b.WriteByte(ch) + } else { + b.WriteByte('_') + } + } + return b.String() +} + +// normalizeArch maps PROCESSOR_ARCHITECTURE values and ARCH aliases to +// docker arches. +func normalizeArch(s string) (string, error) { + switch strings.ToLower(s) { + case "x86_64", "x64", "amd64": + return "amd64", nil + case "aarch64", "arm64": + return "arm64", nil + default: + return "", fmt.Errorf("unknown architecture %s", s) + } +} + +// detectArch honours $ARCH, then $PROCESSOR_ARCHITECTURE (set on Windows +// hosts to AMD64/ARM64/x86), then falls back to the test binary's own arch. +func detectArch() (string, error) { + if arch, ok := os.LookupEnv("ARCH"); ok && strings.TrimSpace(arch) != "" { + return normalizeArch(strings.TrimSpace(arch)) + } + if arch, ok := os.LookupEnv("PROCESSOR_ARCHITECTURE"); ok && strings.TrimSpace(arch) != "" { + return normalizeArch(strings.TrimSpace(arch)) + } + return normalizeArch(runtime.GOARCH) +} + +// fields splits space-separated env lists (e.g. MIRROR="default "). +// Kept for the MIRROR knob, which e2e.ps1 splits on whitespace. +func fields(s string) []string { + return strings.Fields(s) +} + +// defaultFlavours mirrors the old e2e.ps1 rule: ohos publishes no +// windows-arm64 artefacts, so arm64 runs official only. +func defaultFlavours(arch string) []string { + if arch == "arm64" { + return []string{"official"} + } + return []string{"official", "ohos"} +} + +// defaultMirrors mirrors the old e2e.ps1 rule: the extra mirror only runs +// on amd64, where the full matrix is affordable. +func defaultMirrors(arch string) []string { + if arch == "arm64" { + return []string{"default"} + } + return []string{"default", defaultMirrorCN} +} + +// matrix expands the vfox x flavor x mirror combinations. Env overrides: +// VFOX_VERSION (default latest + main), FLAVOR and MIRROR (all split on +// whitespace, so FLAVOR="official ohos" works like on Linux). The mirror +// matrix only runs against latest vfox unless the caller explicitly pins +// MIRROR, to keep CI time bounded (same rule as the old e2e.ps1 and the +// Linux suite). +func matrix() ([]combo, error) { + arch, err := detectArch() + if err != nil { + return nil, err + } + + foxes := []string{"latest", "main"} + if v, ok := os.LookupEnv("VFOX_VERSION"); ok && len(fields(v)) > 0 { + foxes = fields(v) + } + flavours := defaultFlavours(arch) + if v, ok := os.LookupEnv("FLAVOR"); ok && len(fields(v)) > 0 { + flavours = fields(v) + } + mirrors := defaultMirrors(arch) + if v, ok := os.LookupEnv("MIRROR"); ok && len(fields(v)) > 0 { + mirrors = fields(v) + } + _, mirrorExplicit := os.LookupEnv("MIRROR") + mirrorExplicit = mirrorExplicit && len(fields(os.Getenv("MIRROR"))) > 0 + + var combos []combo + for _, vfox := range foxes { + for _, flavor := range flavours { + for _, mirror := range mirrors { + if !mirrorExplicit && mirror != "default" && vfox != "latest" { + continue + } + combos = append(combos, combo{vfox: vfox, flavor: flavor, mirror: mirror}) + } + } + } + return combos, nil +} + +// maxJobs bounds parallel containers; honours $E2E_MAX_JOBS (default 2, +// matching the old e2e.ps1). +func maxJobs() int { + if v, ok := os.LookupEnv("E2E_MAX_JOBS"); ok { + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 1 { + return n + } + } + return 2 +} diff --git a/tests/e2e/windows/matrix_test.go b/tests/e2e/windows/matrix_test.go new file mode 100644 index 0000000..0ea9404 --- /dev/null +++ b/tests/e2e/windows/matrix_test.go @@ -0,0 +1,199 @@ +package e2e + +import ( + "testing" +) + +func setEnv(t *testing.T, kv map[string]string) { + t.Helper() + for k, v := range kv { + t.Setenv(k, v) + } +} + +func comboKeys(combos []combo) []string { + keys := make([]string, 0, len(combos)) + for _, c := range combos { + keys = append(keys, c.vfox+"/"+c.flavor+"/"+c.mirror) + } + return keys +} + +// Default amd64 matrix: latest runs both mirrors, main runs default only. +func TestMatrixDefaultAmd64(t *testing.T) { + setEnv(t, map[string]string{"ARCH": "amd64"}) + t.Setenv("VFOX_VERSION", "") + t.Setenv("FLAVOR", "") + t.Setenv("MIRROR", "") + + combos, err := matrix() + if err != nil { + t.Fatal(err) + } + if len(combos) == 0 { + t.Fatal("empty matrix") + } + t.Logf("combos: %v", comboKeys(combos)) + for _, c := range combos { + if c.mirror != "default" && c.vfox != "latest" { + t.Errorf("non-default mirror runs against %s vfox: %+v", c.vfox, c) + } + } +} + +// arm64 defaults: official flavor, default mirror only. +func TestMatrixDefaultArm64(t *testing.T) { + setEnv(t, map[string]string{ + "ARCH": "arm64", + "VFOX_VERSION": "latest main", + "FLAVOR": "", + "MIRROR": "", + }) + + combos, err := matrix() + if err != nil { + t.Fatal(err) + } + for _, c := range combos { + if c.flavor != "official" { + t.Errorf("arm64 flavor = %s, want official", c.flavor) + } + if c.mirror != "default" { + t.Errorf("arm64 mirror = %s, want default", c.mirror) + } + } + if len(combos) == 0 { + t.Fatal("empty matrix") + } +} + +// Explicit MIRROR pins the full mirror matrix even for non-latest vfox. +func TestMatrixExplicitMirror(t *testing.T) { + setEnv(t, map[string]string{ + "ARCH": "amd64", + "VFOX_VERSION": "main", + "FLAVOR": "official", + "MIRROR": "default https://storage.flutter-io.cn", + }) + + combos, err := matrix() + if err != nil { + t.Fatal(err) + } + if len(combos) != 2 { + t.Fatalf("combos = %v, want 2", comboKeys(combos)) + } +} + +// PROCESSOR_ARCHITECTURE values from real Windows hosts resolve correctly. +func TestMatrixProcessorArchitecture(t *testing.T) { + t.Setenv("ARCH", "") + t.Setenv("PROCESSOR_ARCHITECTURE", "ARM64") + t.Setenv("VFOX_VERSION", "latest") + t.Setenv("FLAVOR", "official") + t.Setenv("MIRROR", "default") + combos, err := matrix() + if err != nil { + t.Fatal(err) + } + if len(combos) != 1 { + t.Fatalf("combos = %v, want 1", comboKeys(combos)) + } +} + +func TestNormalizeArch(t *testing.T) { + for in, want := range map[string]string{ + "AMD64": "amd64", "x86_64": "amd64", "x64": "amd64", "amd64": "amd64", + "ARM64": "arm64", "aarch64": "arm64", "arm64": "arm64", + } { + got, err := normalizeArch(in) + if err != nil || got != want { + t.Errorf("normalizeArch(%q) = %q, %v; want %q", in, got, err, want) + } + } + if _, err := normalizeArch("x86"); err == nil { + t.Error("normalizeArch(x86) succeeded, want error") + } +} + +func TestSanitize(t *testing.T) { + got := combo{vfox: "latest", flavor: "official", mirror: "https://storage.flutter-io.cn"}.slug() + want := "latest-official-https___storage_flutter_io_cn" + if got != want { + t.Errorf("slug = %q, want %q", got, want) + } +} + +func TestMaxJobsDefault(t *testing.T) { + t.Setenv("E2E_MAX_JOBS", "") + // empty means unset-equivalent for maxJobs (Atoi fails -> default 2) + if got := maxJobs(); got != 2 { + t.Errorf("maxJobs = %d, want 2", got) + } + t.Setenv("E2E_MAX_JOBS", "0") + if got := maxJobs(); got != 2 { + t.Errorf("maxJobs(0) = %d, want 2 (clamped to default)", got) + } +} + +func TestResolveFlutterVersion(t *testing.T) { + t.Setenv("FLUTTER_VERSION", "") + t.Setenv("OHOS_VERSION", "") + if got := resolveFlutterVersion(t, "official"); got != "3.47.4" { + t.Errorf("official default = %q", got) + } + if got := resolveFlutterVersion(t, "ohos"); got != "3.41.10-ohos-1.0.0" { + t.Errorf("ohos default = %q", got) + } + t.Setenv("FLUTTER_VERSION", "3.99.0") + if got := resolveFlutterVersion(t, "official"); got != "3.99.0" { + t.Errorf("official override = %q", got) + } +} + +func TestReleasesIndex(t *testing.T) { + got := releasesIndex("https://storage.flutter-io.cn/") + want := "https://storage.flutter-io.cn/flutter_infra_release/releases/releases_windows.json" + if got != want { + t.Errorf("releasesIndex = %q, want %q", got, want) + } +} + +func TestToolsDart(t *testing.T) { + out := "Flutter 3.47.4 • channel stable • https://github.com/flutter/flutter.git\n" + + "Framework • revision abc123 (4 weeks ago) • 2026-01-01\n" + + "Engine • revision def456\n" + + "Tools • Dart 3.10.0 • DevTools 2.48.0\n" + if got := toolsDart(out); got != "3.10.0" { + t.Errorf("toolsDart = %q, want 3.10.0", got) + } + if got := toolsDart("no tools here\n"); got != "" { + t.Errorf("toolsDart without Tools line = %q, want empty", got) + } +} + +func TestRetryAttemptsDefault(t *testing.T) { + t.Setenv("E2E_RETRY_ATTEMPTS", "") + if got := retryAttempts(); got != 3 { + t.Errorf("retryAttempts = %d, want 3", got) + } + t.Setenv("E2E_RETRY_ATTEMPTS", "5") + if got := retryAttempts(); got != 5 { + t.Errorf("retryAttempts = %d, want 5", got) + } +} + +func TestPwshQuote(t *testing.T) { + if got := pwshQuote(`C:\a\b`); got != `'C:\a\b'` { + t.Errorf("pwshQuote = %q", got) + } + if got := pwshQuote(`it's`); got != `'it''s'` { + t.Errorf("pwshQuote escape = %q", got) + } + if got := winDir(`C:\sdk\bin\flutter.exe`); got != `C:\sdk\bin` { + t.Errorf("winDir = %q", got) + } + if got := winDir(winDir(`C:\sdk\bin\flutter.exe`)); got != `C:\sdk` { + t.Errorf("winDir x2 = %q", got) + } +} diff --git a/tests/e2e/windows/preflight.Tests.ps1 b/tests/e2e/windows/preflight.Tests.ps1 deleted file mode 100644 index d421e37..0000000 --- a/tests/e2e/windows/preflight.Tests.ps1 +++ /dev/null @@ -1,64 +0,0 @@ -# Pre-install guards: mirror reachability and proof that the plugin really -# honours $env:FLUTTER_STORAGE_BASE_URL. Runs via Invoke-Pester after -# setup.ps1 but before install.ps1 (see run.ps1). - -$ErrorActionPreference = 'Stop' -$PSNativeCommandUseErrorActionPreference = $true - -Describe 'mirror preflight' { - BeforeAll { - # NB: $PSScriptRoot must be used directly here. A $here variable set - # at file top-level is NOT visible inside BeforeAll/It: Pester runs - # discovery and execution in different sessions. - . "$PSScriptRoot\lib.ps1" - $Flavor = if ($env:FLAVOR) { $env:FLAVOR } else { throw 'FAIL FLAVOR is not set' } - $Mirror = if ($env:FLUTTER_STORAGE_BASE_URL) { $env:FLUTTER_STORAGE_BASE_URL } else { 'default' } - } - - It 'serves the releases index' { - if (($Flavor -ne 'official') -or ($Mirror -eq 'default')) { - Set-ItResult -Skipped -Because 'only official flavours with a configured mirror probe the index' - } - $index = $Mirror.TrimEnd('/') + '/flutter_infra_release/releases/releases_windows.json' - Invoke-WithRetry { & curl.exe -fsSL --max-time 20 -o NUL $index } "mirror $index" - } - - It 'rejects a bogus mirror naming the host' { - if ($Flavor -ne 'official') { - Set-ItResult -Skipped -Because 'only the official flavour honours the download mirror' - } - $origMirror = $env:FLUTTER_STORAGE_BASE_URL - try { - $env:FLUTTER_STORAGE_BASE_URL = 'https://invalid.example.invalid' - $PSNativeCommandUseErrorActionPreference = $false - $bogusOutput = (& vfox install "flutter@$(Resolve-FlutterVersion $Flavor)" 2>&1 | Out-String) - $bogusCode = $LASTEXITCODE - $PSNativeCommandUseErrorActionPreference = $true - } finally { - if ($null -eq $origMirror) { Remove-Item Env:\FLUTTER_STORAGE_BASE_URL } else { $env:FLUTTER_STORAGE_BASE_URL = $origMirror } - } - $bogusCode | Should -Not -Be 0 - $bogusOutput | Should -Match ([regex]::Escape('invalid.example.invalid')) - } - - It 'rejects a bogus GitHub mirror naming the host' { - if ($Flavor -ne 'official') { - Set-ItResult -Skipped -Because 'only the official flavour installs from git source' - } - if ($env:PROCESSOR_ARCHITECTURE -ne 'ARM64') { - Set-ItResult -Skipped -Because 'only ARM64 installs from git source' - } - $origMirror = $env:VFOX_FLUTTER_GITHUB_MIRROR - try { - $env:VFOX_FLUTTER_GITHUB_MIRROR = 'https://invalid.example.invalid' - $PSNativeCommandUseErrorActionPreference = $false - $bogusOutput = (& vfox install "flutter@$(Resolve-FlutterVersion $Flavor)" 2>&1 | Out-String) - $bogusCode = $LASTEXITCODE - $PSNativeCommandUseErrorActionPreference = $true - } finally { - if ($null -eq $origMirror) { Remove-Item Env:\VFOX_FLUTTER_GITHUB_MIRROR } else { $env:VFOX_FLUTTER_GITHUB_MIRROR = $origMirror } - } - $bogusCode | Should -Not -Be 0 - $bogusOutput | Should -Match ([regex]::Escape('invalid.example.invalid')) - } -} diff --git a/tests/e2e/windows/run.ps1 b/tests/e2e/windows/run.ps1 deleted file mode 100644 index 019eb4c..0000000 --- a/tests/e2e/windows/run.ps1 +++ /dev/null @@ -1,48 +0,0 @@ -$ErrorActionPreference = 'Stop' -$PSNativeCommandUseErrorActionPreference = $true -# CI hygiene: container stdout is redirected, where PowerShell would otherwise -# serialize progress records (e.g. "Preparing modules for first use" on the -# first Pester import) as CLIXML noise into the log. Our code uses no -# Write-Progress, so silencing it loses nothing. -$ProgressPreference = 'SilentlyContinue' -. "$PSScriptRoot\lib.ps1" - -function Invoke-E2EFixture { - param( - [Parameter(Mandatory)] [string] $Name - ) - $result = Invoke-Pester -Path "$PSScriptRoot\$Name.Tests.ps1" -Output Detailed -PassThru - if ($result.FailedCount -gt 0) { - throw "FAIL $Name.Tests.ps1: $($result.FailedCount) test(s) failed ($($result.PassedCount) passed, $($result.SkippedCount) skipped)" - } -} - -$flavor = if ($env:FLAVOR) { $env:FLAVOR } else { throw 'FAIL FLAVOR is not set' } -$mirror = if ($env:FLUTTER_STORAGE_BASE_URL) { $env:FLUTTER_STORAGE_BASE_URL } else { 'default' } -$version = Resolve-FlutterVersion $flavor -$vfox = if ($env:VFOX_VERSION) { $env:VFOX_VERSION } else { 'latest' } - -Write-Output ("=== vfox {0}, flutter {1}, {2}, mirror {3}, {4} ===" -f $vfox, $version, $flavor, $mirror, $env:PROCESSOR_ARCHITECTURE) - -. "$PSScriptRoot\setup.ps1" -Invoke-E2EFixture 'preflight' - -Invoke-Native { & pwsh -NoProfile -File "$PSScriptRoot\install.ps1" -Version $version } "install.ps1 (flutter $version)" - -$PSNativeCommandUseErrorActionPreference = $false -$activation = @(vfox activate pwsh) -join "`r`n" -$activationCode = $LASTEXITCODE -$PSNativeCommandUseErrorActionPreference = $true -if ($activationCode -ne 0) { throw "FAIL vfox activate pwsh exited with code $activationCode" } -# Dot-source via a temp file instead of Invoke-Expression: same effect -# (runs vfox's assignments in this scope), but auditable on disk and -# PSScriptAnalyzer-clean. -$activationFile = Join-Path ([System.IO.Path]::GetTempPath()) "vfox-activate-$([System.Guid]::NewGuid()).ps1" -try { - $activation | Set-Content -Path $activationFile - . $activationFile -} finally { - Remove-Item $activationFile -ErrorAction SilentlyContinue -} - -Invoke-E2EFixture 'verify' diff --git a/tests/e2e/windows/setup.go b/tests/e2e/windows/setup.go new file mode 100644 index 0000000..b570b6e --- /dev/null +++ b/tests/e2e/windows/setup.go @@ -0,0 +1,273 @@ +package e2e + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" + tc "github.com/testcontainers/testcontainers-go" +) + +// NOTE: keep the Flutter defaults in sync with tests/e2e/linux/setup.go. +// The two Go modules share no config format, so the versions are duplicated +// on purpose. Bump both together. +const ( + defaultOfficialVersion = "3.47.4" + defaultOhosVersion = "3.41.10-ohos-1.0.0" + // goWindowsVersion pins the Go toolchain MSI used to build vfox@main + // inside the container (was setup.ps1). + goWindowsVersion = "1.27.1" +) + +const bogusMirror = "https://invalid.example.invalid" + +// pwshQuote renders a single-quoted PowerShell string literal. +func pwshQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + +// resolveFlutterVersion honours $FLUTTER_VERSION / $OHOS_VERSION, else the +// defaults above (was lib.ps1 Resolve-FlutterVersion). +func resolveFlutterVersion(t *testing.T, flavor string) string { + t.Helper() + switch flavor { + case "official": + if v := os.Getenv("FLUTTER_VERSION"); v != "" { + return v + } + return defaultOfficialVersion + case "ohos": + if v := os.Getenv("OHOS_VERSION"); v != "" { + return v + } + return defaultOhosVersion + default: + t.Fatalf("unknown flavor %s", flavor) + return "" + } +} + +// releasesIndex mirrors the preflight probe for windows. +func releasesIndex(mirror string) string { + return strings.TrimSuffix(mirror, "/") + "/flutter_infra_release/releases/releases_windows.json" +} + +// checkMirrorReachable fails fast when the mirror does not serve the release +// index. Official flavours download from $FLUTTER_STORAGE_BASE_URL when set. +func checkMirrorReachable(ctx context.Context, t *testing.T, ctr tc.Container, flavor, mirror string) { + t.Helper() + if flavor != "official" || mirror == "default" { + return + } + index := releasesIndex(mirror) + execRetry(ctx, t, ctr, "mirror "+index, + fmt.Sprintf("curl.exe -fsSL --max-time 20 -o NUL %s", pwshQuote(index))) + t.Logf("PASS mirror %s serves the releases index", mirror) +} + +// withEnv returns box.vars plus extra entries, without mutating the shared +// backing array. +func withEnv(box slotBox, extra ...string) []string { + out := make([]string, 0, len(box.vars)+len(extra)) + out = append(out, box.vars...) + return append(out, extra...) +} + +// slotBox carries the per-combo isolated profile locations from setup.ps1 +// plus the exec env overrides that apply them (each exec starts a fresh +// process, so the $env: assignments cannot persist and must ride along). +type slotBox struct { + slot string + slotRoot string + tmp string + workDir string + vfoxExe string + pluginZw string + vars []string +} + +// setupSlotEnv replicates the slot isolation header of setup.ps1: +// USERPROFILE/VFOX_HOME/TEMP/TMP point under vfox-e2e-runs\ so +// parallel combos on one daemon never share vfox state. +func setupSlotEnv(ctx context.Context, t *testing.T, ctr tc.Container, slot string) slotBox { + t.Helper() + userProfile := strings.TrimSpace(execOK(ctx, t, ctr, "$env:USERPROFILE")) + containerPath := strings.TrimSpace(execOK(ctx, t, ctr, "$env:PATH")) + procArch := strings.TrimSpace(execOK(ctx, t, ctr, "$env:PROCESSOR_ARCHITECTURE")) + + box := slotBox{slot: slot} + box.slotRoot = userProfile + `\vfox-e2e-runs\` + slot + box.tmp = box.slotRoot + `\tmp` + box.workDir = box.tmp + `\vfox-flutter-e2e` + box.vfoxExe = box.workDir + `\vfox.exe` + box.pluginZw = box.workDir + `\flutter.zip` + box.vars = []string{ + "USERPROFILE=" + box.slotRoot, + "VFOX_HOME=" + box.slotRoot + `\.vfox`, + "TEMP=" + box.tmp, + "TMP=" + box.tmp, + "PATH=" + box.workDir + ";" + containerPath, + } + + execOK(ctx, t, ctr, fmt.Sprintf( + "New-Item -ItemType Directory -Force -Path %s | Out-Null; "+ + "New-Item -ItemType Directory -Force -Path %s | Out-Null", + pwshQuote(box.tmp), pwshQuote(box.workDir))) + t.Logf("PASS slot %s isolated at %s (arch %s)", slot, box.slotRoot, procArch) + return box +} + +// procArchOfContainer reports $env:PROCESSOR_ARCHITECTURE (AMD64/ARM64/x86). +func procArchOfContainer(ctx context.Context, t *testing.T, ctr tc.Container) string { + t.Helper() + return strings.TrimSpace(execOK(ctx, t, ctr, "$env:PROCESSOR_ARCHITECTURE")) +} + +// curlRetry flags shared by every curl.exe download (was setup.ps1). +const curlRetry = "--retry 3 --retry-delay 5 --retry-all-errors" + +// installVfoxRelease replicates Install-VfoxRelease: resolve the latest tag, +// download the windows zip, extract, and stage vfox.exe into the work dir. +func installVfoxRelease(ctx context.Context, t *testing.T, ctr tc.Container, box slotBox, procArch string) { + t.Helper() + effective := strings.TrimSpace(execOK(ctx, t, ctr, + `curl.exe `+curlRetry+` -fsSLI -o NUL -w '%{url_effective}' https://github.com/version-fox/vfox/releases/latest`, + box.vars...)) + parts := strings.Split(effective, "/tag/") + tag := strings.TrimSpace(parts[len(parts)-1]) + if tag == "" { + t.Fatalf("could not resolve the latest vfox tag from %q", effective) + } + vfoxArch := "x86_64" + if procArch == "ARM64" { + vfoxArch = "aarch64" + } + zipPath := box.workDir + `\vfox.zip` + url := fmt.Sprintf("https://github.com/version-fox/vfox/releases/download/%s/vfox_%s_windows_%s.zip", + tag, strings.TrimPrefix(tag, "v"), vfoxArch) + execOK(ctx, t, ctr, fmt.Sprintf( + "curl.exe %s -fsSL -o %s %s", + curlRetry, pwshQuote(zipPath), pwshQuote(url)), box.vars...) + execOK(ctx, t, ctr, fmt.Sprintf( + "tar -xf %s -C %s; Remove-Item %s; "+ + "Copy-Item -Path (Get-ChildItem -Path %s -Recurse -Filter 'vfox.exe') -Destination %s", + pwshQuote(zipPath), pwshQuote(box.workDir), pwshQuote(zipPath), + pwshQuote(box.workDir), pwshQuote(box.vfoxExe)), box.vars...) + t.Logf("PASS installed vfox %s (%s)", tag, vfoxArch) +} + +// installVfoxMain replicates Install-VfoxMain: install the pinned Go MSI, +// clone vfox, and build vfox.exe. msiexec exit codes 0/3010/1937 all mean +// success (ok / reboot-required / in-use-files), same as setup.ps1. +func installVfoxMain(ctx context.Context, t *testing.T, ctr tc.Container, box slotBox, procArch string) { + t.Helper() + goArch := "amd64" + if procArch == "ARM64" { + goArch = "arm64" + } + goMsi := box.tmp + `\go.msi` + execOK(ctx, t, ctr, fmt.Sprintf( + "curl.exe %s -fsSL -o %s %s", + curlRetry, pwshQuote(goMsi), + pwshQuote(fmt.Sprintf("https://go.dev/dl/go%s.windows-%s.msi", goWindowsVersion, goArch))), + box.vars...) + execOK(ctx, t, ctr, fmt.Sprintf(`$goInstall = $null +for ($attempt = 1; $attempt -le 5; $attempt++) { + $goInstall = Start-Process msiexec.exe -Wait -PassThru -ArgumentList '/i', %s, '/quiet', '/norestart' + if ($goInstall.ExitCode -in 0, 3010, 1937) { break } + Start-Sleep -Seconds 15 +} +if ($goInstall.ExitCode -notin 0, 3010, 1937) { throw ('FAIL the Go MSI install exited with code ' + $goInstall.ExitCode) } +Remove-Item %s`, pwshQuote(goMsi), pwshQuote(goMsi)), box.vars...) + + src := box.workDir + `\vfox-src` + execOK(ctx, t, ctr, + fmt.Sprintf("git clone --depth 1 https://github.com/version-fox/vfox %s", pwshQuote(src)), + box.vars...) + execOK(ctx, t, ctr, fmt.Sprintf( + `& 'C:\Program Files\Go\bin\go.exe' -C %s build -trimpath -o %s .`, + pwshQuote(src), pwshQuote(box.vfoxExe)), box.vars...) + t.Logf("PASS built vfox from main with Go %s (%s)", goWindowsVersion, goArch) +} + +// setupVfox installs vfox (release, or builds main) and registers the local +// flutter plugin (was setup.ps1). +func setupVfox(ctx context.Context, t *testing.T, ctr tc.Container, box slotBox, vfoxVersion string) { + t.Helper() + procArch := procArchOfContainer(ctx, t, ctr) + if vfoxVersion == "main" { + installVfoxMain(ctx, t, ctr, box, procArch) + } else { + installVfoxRelease(ctx, t, ctr, box, procArch) + } + execOK(ctx, t, ctr, fmt.Sprintf( + "tar -a -cf %s -C 'C:\\e2e' metadata.lua hooks lib", + pwshQuote(box.pluginZw)), box.vars...) + execOK(ctx, t, ctr, + fmt.Sprintf("vfox add flutter --source %s", pwshQuote(box.pluginZw)), box.vars...) +} + +// fetchActivation captures `vfox activate pwsh` once per container; the +// output is prepended to every later script via activated(), since each exec +// starts a fresh process (same reason run.ps1 dot-sourced it per process). +func fetchActivation(ctx context.Context, t *testing.T, ctr tc.Container, box slotBox) string { + t.Helper() + out := execOK(ctx, t, ctr, "vfox activate pwsh", box.vars...) + if strings.TrimSpace(out) == "" { + t.Fatal("vfox activate pwsh produced no output") + } + return out +} + +// activated prepends captured activation code to a script. +func activated(activation, script string) string { + return activation + "\r\n" + script +} + +// activatedEnv is activated plus a fresh `vfox env` export. vfox's pwsh +// activation refreshes the toolchain env lazily through a prompt hook, which +// never runs in non-interactive `pwsh -Command` execs, so each call must pull +// the export explicitly (mirrors what the prompt hook itself runs). +// Only valid after a version is selected via `vfox use`. +func activatedEnv(activation, script string) string { + return activation + "\r\n$__vfoxExport = vfox env -s pwsh | Out-String; " + + "if ($__vfoxExport.Trim()) { Invoke-Expression -Command $__vfoxExport }\r\n" + script +} + +// checkBogusMirrorRejected proves the plugin honours $FLUTTER_STORAGE_BASE_URL: +// installing through a bogus mirror must fail naming the host. +func checkBogusMirrorRejected(ctx context.Context, t *testing.T, ctr tc.Container, box slotBox, flavor, version string) { + t.Helper() + if flavor != "official" { + return + } + code, out := execRun(ctx, t, ctr, + fmt.Sprintf("vfox install %s", pwshQuote("flutter@"+version)), + withEnv(box, "FLUTTER_STORAGE_BASE_URL="+bogusMirror)...) + if code == 0 { + t.Fatalf("bogus mirror install unexpectedly succeeded\n--- output ---\n%s", out) + } + require.Contains(t, out, "invalid.example.invalid", "bogus mirror error") +} + +// checkBogusGithubMirrorRejected proves the plugin honours +// $VFOX_FLUTTER_GITHUB_MIRROR on ARM64, where official installs from git. +func checkBogusGithubMirrorRejected(ctx context.Context, t *testing.T, ctr tc.Container, box slotBox, flavor, version string) { + t.Helper() + if flavor != "official" { + return + } + if procArchOfContainer(ctx, t, ctr) != "ARM64" { + return + } + code, out := execRun(ctx, t, ctr, + fmt.Sprintf("vfox install %s", pwshQuote("flutter@"+version)), + withEnv(box, "VFOX_FLUTTER_GITHUB_MIRROR="+bogusMirror)...) + if code == 0 { + t.Fatalf("bogus GitHub mirror install unexpectedly succeeded\n--- output ---\n%s", out) + } + require.Contains(t, out, "invalid.example.invalid", "bogus GitHub mirror error") +} diff --git a/tests/e2e/windows/setup.ps1 b/tests/e2e/windows/setup.ps1 deleted file mode 100644 index 4968e79..0000000 --- a/tests/e2e/windows/setup.ps1 +++ /dev/null @@ -1,60 +0,0 @@ -$ErrorActionPreference = 'Stop' -$PSNativeCommandUseErrorActionPreference = $true -. "$PSScriptRoot\lib.ps1" - -$VfoxVersion = if ($env:VFOX_VERSION) { $env:VFOX_VERSION } else { 'latest' } - -$slot = if ($env:VFOX_E2E_SLOT) { $env:VFOX_E2E_SLOT } else { 'default' } -$slotRoot = Join-Path $env:USERPROFILE "vfox-e2e-runs\$slot" -New-Item -ItemType Directory -Force -Path (Join-Path $slotRoot 'tmp') | Out-Null -$env:USERPROFILE = $slotRoot -$env:VFOX_HOME = Join-Path $slotRoot '.vfox' -$env:TEMP = Join-Path $slotRoot 'tmp' -$env:TMP = $env:TEMP - -$RepoRoot = (Resolve-Path "$PSScriptRoot\..\..\..").Path -$WorkDir = "$env:TEMP\vfox-flutter-e2e" -New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null -$VfoxExe = "$WorkDir\vfox.exe" -$PluginZip = "$WorkDir\flutter.zip" -$env:PATH = "$WorkDir;$env:PATH" - -$CurlRetry = @('--retry', '3', '--retry-delay', '5', '--retry-all-errors') - -function Install-VfoxRelease { - $PSNativeCommandUseErrorActionPreference = $false - $tag = ((& curl.exe @CurlRetry -fsSLI -o NUL -w '%{url_effective}' 'https://github.com/version-fox/vfox/releases/latest') -split '/tag/')[-1].Trim() - $PSNativeCommandUseErrorActionPreference = $true - $vfoxArch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'aarch64' } else { 'x86_64' } - $zip = "$WorkDir\vfox.zip" - Invoke-Native { & curl.exe @CurlRetry -fsSL -o $zip "https://github.com/version-fox/vfox/releases/download/$tag/vfox_$($tag.TrimStart('v'))_windows_$vfoxArch.zip" } 'the vfox release download' - Invoke-Native { tar -xf $zip -C $WorkDir } 'extracting the vfox release' - Remove-Item $zip - Copy-Item -Path (Get-ChildItem -Path $WorkDir -Recurse -Filter 'vfox.exe') -Destination $VfoxExe -} - -function Install-VfoxMain { - $goArch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'amd64' } - $goMsi = "$env:TEMP\go.msi" - Invoke-Native { & curl.exe @CurlRetry -fsSL -o $goMsi "https://go.dev/dl/go1.27.1.windows-$goArch.msi" } 'the Go download' - $goInstall = $null - for ($attempt = 1; $attempt -le 5; $attempt++) { - $goInstall = Start-Process msiexec.exe -Wait -PassThru -ArgumentList '/i', "`"$goMsi`"", '/quiet', '/norestart' - if ($goInstall.ExitCode -in 0, 3010, 1937) { break } - Start-Sleep -Seconds 15 - } - if ($goInstall.ExitCode -notin 0, 3010, 1937) { throw "FAIL the Go MSI install exited with code $($goInstall.ExitCode)" } - Remove-Item $goMsi - $src = "$WorkDir\vfox-src" - Invoke-Native { git clone --depth 1 https://github.com/version-fox/vfox $src } 'cloning vfox from main' - $buildScript = @" -go build -C "$src" -trimpath -o "$VfoxExe" . -"@ - $build = Start-Process pwsh -Wait -PassThru -UseNewEnvironment -ArgumentList '-Command', $buildScript - if ($build.ExitCode -ne 0) { throw "FAIL vfox build from main exited with code $($build.ExitCode)" } -} - -if ($VfoxVersion -eq 'main') { Install-VfoxMain } else { Install-VfoxRelease } - -Invoke-Native { tar -a -cf $PluginZip -C $RepoRoot metadata.lua hooks lib } 'tar packaging' -Invoke-Native { vfox add flutter --source $PluginZip } 'vfox add flutter --source' diff --git a/tests/e2e/windows/verify.Tests.ps1 b/tests/e2e/windows/verify.Tests.ps1 deleted file mode 100644 index ede25ea..0000000 --- a/tests/e2e/windows/verify.Tests.ps1 +++ /dev/null @@ -1,103 +0,0 @@ -# Assert on an installed SDK, run via Invoke-Pester after install.ps1 -# (see run.ps1). Self-contained: BeforeAll re-derives config and SDK state, -# so the file also runs standalone for debugging a live container. -# -# NOTE: the drift-warning strings asserted here are part of the plugin's -# user-facing contract (see hooks/ / lib/). If the Lua side rewords them, -# update both this file and tests/e2e/linux/verify.bats together. -# (Pester runs tests in file order; the drift test below still keeps its -# commit + reset inside one test so it never leaks state to neighbours.) - -$ErrorActionPreference = 'Stop' -$PSNativeCommandUseErrorActionPreference = $true - -Describe 'installed SDK' { - BeforeAll { - # NB: $PSScriptRoot must be used directly here. A $here variable set - # at file top-level is NOT visible inside BeforeAll/It: Pester runs - # discovery and execution in different sessions. - . "$PSScriptRoot\lib.ps1" - $Flavor = if ($env:FLAVOR) { $env:FLAVOR } else { throw 'FAIL FLAVOR is not set' } - $Version = Resolve-FlutterVersion $Flavor - $Sdk = Split-Path (Split-Path ((Get-Command flutter -CommandType Application | Select-Object -First 1).Source)) - } - - It 'carries no vfox commit on the official flavour' { - if ($Flavor -ne 'official') { Set-ItResult -Skipped -Because 'official-only check' } - $subject = git -C $Sdk log -1 --format=%s - $subject | Should -Not -Match 'vfox install' - } - - It 'is a git checkout with its engine pins tracked on the ohos flavour' { - if ($Flavor -ne 'ohos') { Set-ItResult -Skipped -Because 'ohos-only check' } - Test-Path "$Sdk\.git" | Should -BeTrue - git -C $Sdk ls-files bin/internal/engine.version | Should -Match ([regex]::Escape('bin/internal/engine.version')) - } - - It 'carries a .vfox-manifest anchored at the installed HEAD' { - $manifestPath = Join-Path $Sdk '.vfox-manifest' - Test-Path $manifestPath | Should -BeTrue - $installedHead = @(git -C $Sdk rev-parse HEAD)[0] - $anchorMatch = [regex]::Match((Get-Content $manifestPath -Raw), '"expected_head"\s*:\s*"([^"]+)"') - $anchorMatch.Success | Should -BeTrue - $anchorMatch.Groups[1].Value | Should -Be $installedHead - } - - It 'reports no drift when freshly installed' { - $PSNativeCommandUseErrorActionPreference = $false - $cleanOutput = (& vfox use --global "flutter@$Version" 2>&1 | Out-String) - $cleanCode = $LASTEXITCODE - $PSNativeCommandUseErrorActionPreference = $true - $cleanCode | Should -Be 0 - $cleanOutput | Should -Not -Match 'has drifted' - } - - It 'detects an in-place upgrade and goes quiet after restore' { - $manifestPath = Join-Path $Sdk '.vfox-manifest' - $anchor = [regex]::Match((Get-Content $manifestPath -Raw), '"expected_head"\s*:\s*"([^"]+)"').Groups[1].Value - $anchor | Should -Not -BeNullOrEmpty - - $env:GIT_AUTHOR_NAME = 'e2e' - $env:GIT_AUTHOR_EMAIL = 'e2e@vfox.flutter' - $env:GIT_COMMITTER_NAME = 'e2e' - $env:GIT_COMMITTER_EMAIL = 'e2e@vfox.flutter' - try { - $PSNativeCommandUseErrorActionPreference = $false - git -C $Sdk commit --allow-empty -q -m 'simulated flutter upgrade' - if ($LASTEXITCODE -ne 0) { throw "FAIL failed to record a simulated flutter upgrade commit (git exit $LASTEXITCODE)" } - - $driftedHead = @(git -C $Sdk rev-parse HEAD)[0] - $driftOutput = (& vfox use --global "flutter@$Version" 2>&1 | Out-String) - $driftCode = $LASTEXITCODE - $PSNativeCommandUseErrorActionPreference = $true - $driftCode | Should -Be 0 - $driftOutput | Should -Match ([regex]::Escape('has drifted from the version vfox installed')) - $driftOutput | Should -Match ([regex]::Escape($anchor)) - $driftOutput | Should -Match ([regex]::Escape($driftedHead)) - $driftOutput | Should -Match ([regex]::Escape("vfox uninstall flutter@$Version")) - $driftOutput | Should -Match ([regex]::Escape("vfox install flutter@$Version")) - $driftOutput | Should -Match ([regex]::Escape("vfox use flutter@$Version")) - $driftOutput | Should -Match ([regex]::Escape('vfox install flutter@')) - } finally { - $PSNativeCommandUseErrorActionPreference = $true - git -C $Sdk reset -q --hard $anchor - } - - $PSNativeCommandUseErrorActionPreference = $false - $restoredOutput = (& vfox use --global "flutter@$Version" 2>&1 | Out-String) - $restoredCode = $LASTEXITCODE - $PSNativeCommandUseErrorActionPreference = $true - $restoredCode | Should -Be 0 - $restoredOutput | Should -Not -Match 'has drifted' - } - - It 'bootstraps the toolchain with agreeing dart/flutter versions' { - Invoke-WithRetry { & curl.exe -fsSL --max-time 20 -o NUL 'https://pub.dev/api/packages/args' } 'pub.dev' - $dart = (dart --version 2>&1 | Out-String) - $flutter = (flutter --version --no-version-check 2>&1 | Out-String) - $flutter | Should -Match ([regex]::Escape((git -C $Sdk rev-parse HEAD).Substring(0, 10))) - $dartVersion = (($flutter -split "`n" | Where-Object { $_ -like '*Tools*' }) -split ' ')[3] - $dartVersion | Should -Not -BeNullOrEmpty - $dart | Should -Match ([regex]::Escape($dartVersion)) - } -} diff --git a/tests/e2e/windows/verify.go b/tests/e2e/windows/verify.go new file mode 100644 index 0000000..b254c3a --- /dev/null +++ b/tests/e2e/windows/verify.go @@ -0,0 +1,154 @@ +package e2e + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + tc "github.com/testcontainers/testcontainers-go" +) + +// NOTE: the drift-warning strings asserted here are part of the plugin's +// user-facing contract (see hooks/ lib/). If the Lua side rewords them, +// update this file and tests/e2e/linux/verify.go together. + +// winDir is filepath.Dir for container paths, which always use backslashes +// even when the test binary itself runs on a non-Windows host. +func winDir(p string) string { + p = strings.TrimSuffix(strings.TrimSpace(p), `\`) + if i := strings.LastIndexByte(p, '\\'); i >= 0 { + return p[:i] + } + return p +} + +// sdkDir prints the SDK root for the active flutter on PATH: the parent of +// the parent of the flutter application (was verify.Tests.ps1 BeforeAll). +func sdkDir(ctx context.Context, t *testing.T, ctr tc.Container, box slotBox, activation string) string { + t.Helper() + flutter := strings.TrimSpace(execOK(ctx, t, ctr, + activatedEnv(activation, "(Get-Command flutter -CommandType Application | Select-Object -First 1).Source"), + box.vars...)) + return winDir(winDir(flutter)) +} + +// verifySdkLayout asserts the installed SDK shape: pristine for official, a +// git checkout with tracked engine pins for OpenHarmony. +func verifySdkLayout(ctx context.Context, t *testing.T, ctr tc.Container, box slotBox, flavor, sdk string) { + t.Helper() + if flavor == "official" { + subject := execOK(ctx, t, ctr, + "git -C "+pwshQuote(sdk)+" log -1 --format=%s", box.vars...) + require.NotContains(t, subject, "vfox install", "the official SDK carries no vfox commit") + return + } + isGit := strings.TrimSpace(execOK(ctx, t, ctr, + "Test-Path "+pwshQuote(sdk+`\.git`), box.vars...)) + require.Equal(t, "True", isGit, "the OpenHarmony SDK is not a git checkout") + pins := execOK(ctx, t, ctr, + "git -C "+pwshQuote(sdk)+" ls-files bin/internal/engine.version", box.vars...) + require.Contains(t, pins, "bin/internal/engine.version", "the OpenHarmony engine version pin is not tracked") + t.Log("PASS the OpenHarmony SDK is a git checkout with its engine pins tracked") +} + +// manifest is the anchored-install record the plugin writes into the SDK. +type manifest struct { + ExpectedHead string `json:"expected_head"` +} + +// manifestAnchor reads .vfox-manifest and returns its expected_head. +func manifestAnchor(ctx context.Context, t *testing.T, ctr tc.Container, box slotBox, sdk string) string { + t.Helper() + raw := execOK(ctx, t, ctr, + "Get-Content -Raw "+pwshQuote(sdk+`\.vfox-manifest`), box.vars...) + var m manifest + if err := json.Unmarshal([]byte(raw), &m); err != nil { + t.Fatalf("the .vfox-manifest is not valid JSON: %v\n--- actual ---\n%s", err, raw) + } + require.NotEmpty(t, m.ExpectedHead, "the .vfox-manifest has no expected_head") + return m.ExpectedHead +} + +// verifyManifestDrift anchors the installed HEAD via .vfox-manifest, proves +// `vfox use` reports drift exactly when the SDK moves (simulated +// `flutter upgrade`), and goes quiet again once restored. +func verifyManifestDrift(ctx context.Context, t *testing.T, ctr tc.Container, box slotBox, activation, version, sdk string) { + t.Helper() + anchor := manifestAnchor(ctx, t, ctr, box, sdk) + t.Log("PASS the SDK carries a .vfox-manifest") + + installedHead := strings.TrimSpace(execOK(ctx, t, ctr, + "git -C "+pwshQuote(sdk)+" rev-parse HEAD", box.vars...)) + require.Equal(t, installedHead, anchor, "the .vfox-manifest anchor does not match the installed git HEAD") + t.Log("PASS the .vfox-manifest anchor matches the installed git HEAD") + + use := func() string { + return execOK(ctx, t, ctr, + activatedEnv(activation, fmt.Sprintf("vfox use --global %s", pwshQuote("flutter@"+version))), + box.vars...) + } + + require.NotContains(t, use(), "has drifted", "a freshly installed SDK reports no drift") + + execOK(ctx, t, ctr, + "git -C "+pwshQuote(sdk)+" commit --allow-empty -q -m 'simulated flutter upgrade'", + withEnv(box, + "GIT_AUTHOR_NAME=e2e", + "GIT_AUTHOR_EMAIL=e2e@vfox.flutter", + "GIT_COMMITTER_NAME=e2e", + "GIT_COMMITTER_EMAIL=e2e@vfox.flutter")...) + driftedHead := strings.TrimSpace(execOK(ctx, t, ctr, + "git -C "+pwshQuote(sdk)+" rev-parse HEAD", box.vars...)) + + drifted := use() + require.Contains(t, drifted, "has drifted from the version vfox installed", "drift warning") + require.Contains(t, drifted, anchor, "drift warning expected head") + require.Contains(t, drifted, driftedHead, "drift warning current head") + require.Contains(t, drifted, "vfox uninstall flutter@"+version, "drift warning restore command") + require.Contains(t, drifted, "vfox install flutter@"+version, "drift warning restore command") + require.Contains(t, drifted, "vfox use flutter@"+version, "drift warning restore command") + require.Contains(t, drifted, "vfox install flutter@", "drift warning upgrade command") + + execOK(ctx, t, ctr, + "git -C "+pwshQuote(sdk)+" reset -q --hard "+pwshQuote(anchor), box.vars...) + require.NotContains(t, use(), "has drifted", "a restored SDK reports no drift") +} + +// toolsDart extracts the Dart version from `flutter --version` output: +// (($flutter -split "`n" | Where-Object { $_ -like '*Tools*' }) -split ' ')[3]. +// (Duplicated from tests/e2e/linux/verify.go; the two Go modules share no +// helpers.) +func toolsDart(flutterVersion string) string { + for _, line := range strings.Split(flutterVersion, "\n") { + if strings.Contains(line, "Tools") { + if f := strings.Fields(line); len(f) >= 4 { + return f[3] + } + } + } + return "" +} + +// verifyToolchain boots the toolchain (pub.dev reachable) and checks the +// dart/flutter versions agree with each other and the SDK checkout. +func verifyToolchain(ctx context.Context, t *testing.T, ctr tc.Container, box slotBox, activation, sdk string) { + t.Helper() + execRetry(ctx, t, ctr, "pub.dev", + "curl.exe -fsSL --max-time 20 -o NUL https://pub.dev/api/packages/args", + box.vars...) + dart := execRetry(ctx, t, ctr, "dart --version", + activatedEnv(activation, "dart --version"), box.vars...) + flutter := execRetry(ctx, t, ctr, "flutter --version", + activatedEnv(activation, "flutter --version --no-version-check"), box.vars...) + + head := strings.TrimSpace(execOK(ctx, t, ctr, + "git -C "+pwshQuote(sdk)+" rev-parse HEAD", box.vars...)) + require.Contains(t, flutter, head[:10], "flutter --version revision") + + dartWant := toolsDart(flutter) + require.NotEmpty(t, dartWant, "flutter --version has no Tools line:\n"+flutter) + require.Contains(t, dart, dartWant, "dart --version") +}