From 447af9cfa2b697d98337310ec7fdf72becead530 Mon Sep 17 00:00:00 2001 From: Qiheng He Date: Sat, 17 Oct 2026 20:54:33 +0800 Subject: [PATCH 1/2] test(e2e): speed up Windows CI by running natively with Docker on local disks --- .github/workflows/e2e.yml | 107 ++++++++++++++---- docs/e2e.md | 39 +++++-- tests/e2e/common/common.go | 23 ++++ tests/e2e/common/common_test.go | 21 ++++ tests/e2e/doc.go | 4 +- tests/e2e/linux/e2e_test.go | 8 +- tests/e2e/linux/platform.go | 3 +- tests/e2e/linux/zsh_brew_test.go | 5 +- tests/e2e/windows/bootstrap-tools.ps1 | 48 ++++++--- tests/e2e/windows/compose.amd64.yaml | 1 + tests/e2e/windows/compose.arm64.yaml | 1 + tests/e2e/windows/e2e_test.go | 55 +++++++--- tests/e2e/windows/install-docker-arm64.ps1 | 66 ++++++++---- tests/e2e/windows/setup.go | 57 +++------- tests/e2e/windows/vfoxmain.go | 119 +++++++++++++++++++++ 15 files changed, 423 insertions(+), 134 deletions(-) create mode 100644 tests/e2e/windows/vfoxmain.go diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 59e316d..7b4c436 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -26,12 +26,18 @@ concurrency: group: e2e-${{ github.ref }} cancel-in-progress: true -# The Linux job runs the suite natively, the way the dev container runs it -# locally: Go at the dev container's version (.devcontainer/Dockerfile) plus -# the runner's stock Docker, with the same TESTCONTAINERS_RYUK_DISABLED knob. -# The Windows jobs keep the compose runner image -# (tests/e2e/windows/Dockerfile-runner), which carries its own Go toolchain, -# so a Windows host needs only Docker. See docs/e2e.md. +# Both jobs run the suite natively: Go at the dev container's version +# (.devcontainer/Dockerfile) plus the runner's Docker. The Linux job runs it +# the way the dev container runs it locally, with the same +# TESTCONTAINERS_RYUK_DISABLED knob. The Windows jobs skip the compose +# runner image local Windows hosts use (tests/e2e/windows/Dockerfile-runner): +# baking Go into a Windows image costs minutes per run. See docs/e2e.md. + +env: + GO_VERSION: '1.26.8' + # Runners are discarded with whatever still runs on them, so the suites + # tear containers down in the background (common.AsyncCleanup). + E2E_ASYNC_CLEANUP: 'true' jobs: e2e-linux: @@ -53,7 +59,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: - go-version: '1.26.8' + go-version: ${{ env.GO_VERSION }} cache: false - run: go test -v -timeout 55m -run TestE2E ./linux working-directory: tests/e2e @@ -66,27 +72,86 @@ jobs: matrix: # TODO See https://github.com/actions/runner-images/issues/13729 runs-on: [windows-latest, windows-11-arm] - include: - - runs-on: windows-latest - compose: tests/e2e/windows/compose.amd64.yaml - - runs-on: windows-11-arm - compose: tests/e2e/windows/compose.arm64.yaml runs-on: ${{ matrix.runs-on }} timeout-minutes: 60 + defaults: + run: + shell: pwsh + env: + # As many containers at a time as on Linux, now that the engine works + # off a local disk (windows-11-arm runs two combos anyway). + E2E_MAX_JOBS: '3' steps: - uses: actions/checkout@v7 - # 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`. + # The engine's data-root takes every image pull, layer commit and + # in-container Flutter install, and the runners' C: OS disk is slow + # remote storage. windows-latest keeps the workspace on a local D: + # disk: move the preinstalled engine's data-root next to it. + - if: matrix.runs-on == 'windows-latest' + name: Move the Docker data-root to the workspace disk + run: | + $dataRoot = (Split-Path -Qualifier $env:GITHUB_WORKSPACE) + '\docker' + $config = Join-Path $env:ProgramData 'docker\config\daemon.json' + $settings = if (Test-Path $config) { Get-Content -Raw $config | ConvertFrom-Json } else { [pscustomobject]@{} } + $settings | Add-Member -NotePropertyName 'data-root' -NotePropertyValue $dataRoot -Force + New-Item -ItemType Directory -Force -Path (Split-Path $config) | Out-Null + $settings | ConvertTo-Json | Set-Content -Path $config -Encoding ascii + Restart-Service docker + for ($attempt = 1; $attempt -le 30; $attempt++) { + $current = docker info --format '{{.DockerRootDir}}' 2>$null + if ($LASTEXITCODE -eq 0) { break } + Start-Sleep -Seconds 2 + } + if ($current -ne $dataRoot) { throw "FAIL the engine runs off '$current', want '$dataRoot'" } + Write-Output "PASS the Docker data-root is $current" + # windows-11-arm has no D:, but carries an unformatted local NVMe disk + # (actions/runner-images#14088): format it for the engine's data-root. - if: matrix.runs-on == 'windows-11-arm' + id: disk + name: Format the local NVMe disk + run: | + $disk = Get-Disk | Where-Object PartitionStyle -eq 'RAW' | Select-Object -First 1 + if ($disk) { + $volume = $disk | Initialize-Disk -PartitionStyle GPT -PassThru | + New-Partition -AssignDriveLetter -UseMaximumSize | + Format-Volume -FileSystem NTFS -Confirm:$false + "data-root=$($volume.DriveLetter):\vfox-docker\data" >> $env:GITHUB_OUTPUT + Write-Output "PASS formatted raw disk $($disk.Number) as $($volume.DriveLetter):" + } + else { + Write-Output '::warning::no raw local disk; the Docker data-root stays on C:' + } + # Building the engine from source takes minutes; the script reuses the + # binaries restored here. + - if: matrix.runs-on == 'windows-11-arm' + id: moby + uses: actions/cache@v6 + with: + path: C:\vfox-docker\bin + key: moby-windows-arm64-go${{ env.GO_VERSION }}-${{ hashFiles('tests/e2e/windows/install-docker-arm64.ps1') }} + # Go is needed this early only to build the engine; otherwise it is set + # up below, while the base image pulls. + - if: matrix.runs-on == 'windows-11-arm' && steps.moby.outputs.cache-hit != 'true' uses: actions/setup-go@v7 with: - go-version: 1.26.8 + go-version: ${{ env.GO_VERSION }} cache: false - if: matrix.runs-on == 'windows-11-arm' - run: pwsh -NoProfile -File tests/e2e/windows/install-docker-arm64.ps1 - - if: matrix.runs-on == 'windows-11-arm' - name: Install Compose CLI plugin + run: pwsh -NoProfile -File tests/e2e/windows/install-docker-arm64.ps1 -DataRoot '${{ steps.disk.outputs.data-root }}' + # Unpacking servercore is the longest setup step and needs nothing but + # the engine: start it now so that setting up Go and compiling the + # suite overlap it. The suite's own pull of the image joins this one. + - name: Pull the base image in the background run: | - New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.docker\cli-plugins" | Out-Null - curl.exe -fsSL "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 - - run: docker compose -f ${{ matrix.compose }} run --rm --build e2e + $arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'amd64' } + $match = Select-String -Path tests/e2e/windows/Dockerfile -Pattern '^ARG BASE_IMAGE=(.+)-amd64$' + if (-not $match) { throw 'FAIL tests/e2e/windows/Dockerfile has no ARG BASE_IMAGE=...-amd64 line' } + $image = $match.Matches[0].Groups[1].Value + "-$arch" + Start-Process docker -ArgumentList 'pull', $image -RedirectStandardOutput "$env:RUNNER_TEMP\base-pull.log" -RedirectStandardError "$env:RUNNER_TEMP\base-pull.err.log" + Write-Output "Pulling $image in the background" + - uses: actions/setup-go@v7 + with: + go-version: ${{ env.GO_VERSION }} + cache: false + - run: go test -v -timeout 55m -run TestE2E ./windows + working-directory: tests/e2e diff --git a/docs/e2e.md b/docs/e2e.md index 1604e85..5c99683 100644 --- a/docs/e2e.md +++ b/docs/e2e.md @@ -3,10 +3,10 @@ The offline Lua hook tests (`tests/hooks_test.lua`) run in the **Test Plugin** workflow. The end-to-end suite runs in containers against real vfox on Linux and Windows; each `vfox` x `flavor` x `mirror` combination gets its own throwaway -container. On Linux, CI runs the suite natively on the stock runners (Docker -plus Go from `actions/setup-go`), the way the repository dev container -reproduces it locally. On Windows it runs through a Docker Compose runner -image, since dev containers cannot host Windows container images (see below). +container. CI runs both suites natively on the stock runners (Docker plus Go +from `actions/setup-go`). Locally, the repository dev container reproduces the +Linux run; Windows hosts use a Docker Compose runner image instead, since dev +containers cannot host Windows container images (see below). ## Dev container @@ -40,7 +40,10 @@ devcontainer exec --workspace-folder . bash host daemon and waits for the suite to dial it back, which a Docker-out-of-Docker container cannot do. The suites terminate every container explicitly via `t.Cleanup` instead; leftovers from killed runs -share the `vfox-flutter-e2e-` name prefix for pruning. Rootless Docker users +share the `vfox-flutter-e2e-` name prefix for pruning. CI sets +`E2E_ASYNC_CLEANUP=true` so that teardown runs in the background instead of +holding up each combination, since its runners are discarded anyway; the +last containers may then outlive the suite. Rootless Docker users export `DOCKER_SOCK="$XDG_RUNTIME_DIR/docker.sock"` on the host before opening the container. On Windows hosts, keeping the checkout inside WSL 2 (e.g. under `\\wsl$`) keeps file and bind-mount access fast; a checkout on @@ -89,6 +92,26 @@ named pipe; the host requirement is Docker in Windows-container mode plus the Compose v5 plugin (installed once per machine below). Go never touches the host, so it cannot conflict with a vfox-managed toolchain. +CI skips that runner image, which costs minutes per run to build, and runs +`go test` on the runner with Go from `actions/setup-go`, like the Linux job. +It also moves the engine's data-root off the runners' slow C: OS disk, +where image pulls, layer commits and the in-container Flutter installs would +otherwise crawl: next to the workspace on `windows-latest`'s local D: disk, +and onto the local NVMe disk `windows-11-arm` ships unformatted. With that, +`E2E_MAX_JOBS` is 3 there, as on Linux. Unpacking the servercore base image +remains the longest setup step, so CI starts pulling it in the background +before it sets up Go and compiles the suite. + +The target image (`tests/e2e/windows/Dockerfile`) unpacks PowerShell 7 and +MinGit from their portable zips instead of running their installers: Git +for Windows' installer is an x86 program, which arm64 containers only run +under emulation. MinGit is the same `git.exe` without bash, Perl and the +credential manager, none of which vfox or Flutter calls. + +The `main` combinations share one `vfox.exe`: the suite builds it from vfox's +main branch with its own Go toolchain while the image builds, and copies it +into each `main` container before the container starts. + The `windows-11-arm` runner has no Windows Hypervisor Platform feature, so Hyper-V isolation is unavailable; process isolation needs only the Containers feature. @@ -165,7 +188,11 @@ docker compose -f tests/e2e/windows/compose.arm64.yaml run --rm --build e2e On arm64, Go is also what builds the engine, so `install-docker-arm64.ps1` runs before the suite; the compose runner image then carries its own pinned -Go for the tests, and the host Go is not used again. Scope the run down with +Go for the tests, and the host Go is not used again. The script keeps the +engine binaries in `C:\vfox-docker\bin` and reuses them on later runs while +they match its pinned version (CI caches that directory); `-DataRoot` moves +the engine's images and containers off the default `C:\vfox-docker\data`. +Scope the run down with `$env:VFOX_VERSION = 'latest'; $env:FLAVOR = 'official'` in front of the compose command, as on the other platforms. diff --git a/tests/e2e/common/common.go b/tests/e2e/common/common.go index f1dbb0d..7aaf19c 100644 --- a/tests/e2e/common/common.go +++ b/tests/e2e/common/common.go @@ -202,6 +202,29 @@ func RetryAttempts() int { return 3 } +// AsyncCleanup reports whether containers are torn down in the background +// instead of holding up the test that ran them; honours $E2E_ASYNC_CLEANUP. +// CI sets it, since the runner is discarded with whatever still runs on +// it; local runs keep waiting so no container outlives the suite. +func AsyncCleanup() bool { + v, _ := strconv.ParseBool(strings.TrimSpace(os.Getenv("E2E_ASYNC_CLEANUP"))) + return v +} + +// Cleanup registers terminate to run once t finishes, in the background +// under AsyncCleanup (the suite may then exit before the last containers +// are gone). +func Cleanup(t *testing.T, terminate func()) { + t.Helper() + t.Cleanup(func() { + if AsyncCleanup() { + go terminate() + return + } + terminate() + }) +} + // ResolveFlutterVersion honours $FLUTTER_VERSION / $OHOS_VERSION, else the // defaults above. func ResolveFlutterVersion(t *testing.T, flavor string) string { diff --git a/tests/e2e/common/common_test.go b/tests/e2e/common/common_test.go index ac4d9b0..86edbe7 100644 --- a/tests/e2e/common/common_test.go +++ b/tests/e2e/common/common_test.go @@ -200,3 +200,24 @@ func TestRetryAttemptsDefault(t *testing.T) { t.Errorf("retryAttempts = %d, want 5", got) } } + +func TestAsyncCleanup(t *testing.T) { + for v, want := range map[string]bool{"": false, "0": false, "junk": false, "1": true, "true": true} { + t.Setenv("E2E_ASYNC_CLEANUP", v) + if got := AsyncCleanup(); got != want { + t.Errorf("AsyncCleanup with %q = %v, want %v", v, got, want) + } + } +} + +// Without E2E_ASYNC_CLEANUP a combo only finishes once its container is gone. +func TestCleanupWaitsByDefault(t *testing.T) { + t.Setenv("E2E_ASYNC_CLEANUP", "") + terminated := false + t.Run("combo", func(t *testing.T) { + Cleanup(t, func() { terminated = true }) + }) + if !terminated { + t.Error("the combo finished before its container was terminated") + } +} diff --git a/tests/e2e/doc.go b/tests/e2e/doc.go index 5c9cac4..d702683 100644 --- a/tests/e2e/doc.go +++ b/tests/e2e/doc.go @@ -22,8 +22,8 @@ // defaults, retry counts, name handling); // - linux: the Linux container suite (bash-driven), run in CI and locally // through the repository dev container (.devcontainer/, see docs/e2e.md); -// - windows: the Windows container suite (pwsh-driven), run via -// tests/e2e/windows/compose.amd64.yaml (amd64) or +// - windows: the Windows container suite (pwsh-driven), run natively in CI +// and locally via tests/e2e/windows/compose.amd64.yaml (amd64) or // tests/e2e/windows/compose.arm64.yaml (arm64) as `go test ./windows/`. // // Pure unit tests without containers (matrix expansion and friends) run from diff --git a/tests/e2e/linux/e2e_test.go b/tests/e2e/linux/e2e_test.go index e4fec3e..485ef22 100644 --- a/tests/e2e/linux/e2e_test.go +++ b/tests/e2e/linux/e2e_test.go @@ -48,6 +48,8 @@ func TestE2E(t *testing.T) { if testing.Short() { t.Skip("skip container E2E in short mode") } + // Alongside TestE2EZshBrew, which otherwise waits for every combo here. + t.Parallel() arch, err := common.DetectArch() if err != nil { @@ -156,8 +158,10 @@ func runOne(t *testing.T, ctx context.Context, image, platform string, c common. if err != nil { t.Fatalf("[%s] start container: %v", prefix, err) } - t.Cleanup(func() { - _ = ctr.Terminate(context.Background()) + // sleep infinity, as PID 1, ignores SIGTERM, so a graceful stop only + // waits out its timeout: kill right away instead. + common.Cleanup(t, func() { + _ = ctr.Terminate(context.Background(), tc.StopTimeout(0)) }) // One overall budget per combo; the CI job timeout stays the outer bound. diff --git a/tests/e2e/linux/platform.go b/tests/e2e/linux/platform.go index 77d0509..5fa58e8 100644 --- a/tests/e2e/linux/platform.go +++ b/tests/e2e/linux/platform.go @@ -18,7 +18,8 @@ import ( "github.com/version-fox/vfox-flutter/tests/e2e/common" ) -// maxJobs bounds parallel containers; honours $E2E_MAX_JOBS (default 3). +// maxJobs bounds parallel matrix containers (TestE2EZshBrew runs its own +// alongside); honours $E2E_MAX_JOBS (default 3). func maxJobs() int { return common.MaxJobs(3) } diff --git a/tests/e2e/linux/zsh_brew_test.go b/tests/e2e/linux/zsh_brew_test.go index 9fc50ec..861cb71 100644 --- a/tests/e2e/linux/zsh_brew_test.go +++ b/tests/e2e/linux/zsh_brew_test.go @@ -143,8 +143,9 @@ func TestE2EZshBrew(t *testing.T) { if err != nil { t.Fatalf("start container: %v", err) } - t.Cleanup(func() { - _ = ctr.Terminate(context.Background()) + // sleep infinity, as PID 1, ignores SIGTERM: kill right away. + common.Cleanup(t, func() { + _ = ctr.Terminate(context.Background(), tc.StopTimeout(0)) }) ctx, cancel := context.WithTimeout(ctx, zshBrewTimeout) diff --git a/tests/e2e/windows/bootstrap-tools.ps1 b/tests/e2e/windows/bootstrap-tools.ps1 index 5bc4d34..a33651a 100644 --- a/tests/e2e/windows/bootstrap-tools.ps1 +++ b/tests/e2e/windows/bootstrap-tools.ps1 @@ -35,20 +35,36 @@ function Invoke-Native { $IsArm64 = $env:PROCESSOR_ARCHITECTURE -eq 'ARM64' $pwshArch = if ($IsArm64) { 'arm64' } else { 'x64' } -$gitAsset = if ($IsArm64) { 'Git-2.55.0.5-arm64.exe' } else { 'Git-2.55.0.5-64-bit.exe' } - -$pwshInstaller = "$env:TEMP\pwsh.msi" -$pwshUrl = "https://github.com/PowerShell/PowerShell/releases/download/v7.6.6/PowerShell-7.6.6-win-$pwshArch.msi" -$gitInstaller = "$env:TEMP\git-installer.exe" -$gitUrl = "https://github.com/git-for-windows/git/releases/download/v2.55.0.windows.5/$gitAsset" - -Invoke-Native { & curl.exe -fsSL --retry 3 --retry-delay 5 --retry-all-errors -o $pwshInstaller $pwshUrl } 'the PowerShell download' -Invoke-Native { & curl.exe -fsSL --retry 3 --retry-delay 5 --retry-all-errors -o $gitInstaller $gitUrl } 'the Git download' -$pwshSetup = Start-Process msiexec.exe -Wait -PassThru -ArgumentList '/i', "`"$pwshInstaller`"", '/quiet', '/norestart' -if ($pwshSetup.ExitCode -notin 0, 3010) { throw "FAIL the PowerShell MSI install exited with code $($pwshSetup.ExitCode)" } -$gitSetup = Start-Process $gitInstaller -Wait -PassThru -ArgumentList '/VERYSILENT', '/NORESTART', '/SP-', '/SUPPRESSMSGBOXES' -if ($gitSetup.ExitCode -ne 0) { throw "FAIL the Git installer exited with code $($gitSetup.ExitCode)" } -if (-not (Test-Path 'C:\Program Files\PowerShell\7\pwsh.exe')) { throw 'FAIL pwsh.exe is missing after the PowerShell MSI install' } -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' +$gitArch = if ($IsArm64) { 'arm64' } else { '64-bit' } + +# Portable zips unpacked with the native tar.exe instead of the installers: +# Git for Windows' installer is an x86 program, which arm64 containers only +# run under emulation, at a cost of minutes. MinGit is the same git.exe +# without the extras (bash, Perl, the credential manager) that neither vfox +# nor Flutter calls. It cannot live in C:\Program Files\Git: its +# etc\gitconfig includes the system config of a Git for Windows installed +# there, which would then include itself. +$pwshHome = 'C:\Program Files\PowerShell\7' +$gitHome = 'C:\Program Files\MinGit' +$pwshZip = "$env:TEMP\pwsh.zip" +$pwshUrl = "https://github.com/PowerShell/PowerShell/releases/download/v7.6.6/PowerShell-7.6.6-win-$pwshArch.zip" +$gitZip = "$env:TEMP\mingit.zip" +$gitUrl = "https://github.com/git-for-windows/git/releases/download/v2.55.0.windows.5/MinGit-2.55.0.5-$gitArch.zip" + +Invoke-Native { & curl.exe -fsSL --retry 3 --retry-delay 5 --retry-all-errors -o $pwshZip $pwshUrl } 'the PowerShell download' +Invoke-Native { & curl.exe -fsSL --retry 3 --retry-delay 5 --retry-all-errors -o $gitZip $gitUrl } 'the MinGit download' +New-Item -ItemType Directory -Force -Path $pwshHome, $gitHome | Out-Null +Invoke-Native { & tar.exe -xf $pwshZip -C $pwshHome } 'the PowerShell unpack' +Invoke-Native { & tar.exe -xf $gitZip -C $gitHome } 'the MinGit unpack' +Remove-Item $pwshZip, $gitZip +if (-not (Test-Path "$pwshHome\pwsh.exe")) { throw 'FAIL pwsh.exe is missing after the PowerShell unpack' } +if (-not (Test-Path "$gitHome\cmd\git.exe")) { throw 'FAIL git.exe is missing after the MinGit unpack' } + +# What the installers did besides unpacking: put both on the machine PATH, +# and give git the installer defaults MinGit's etc\gitconfig lacks (it +# already sets core.autocrlf and core.symlinks). +$machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') +[Environment]::SetEnvironmentVariable('Path', "$machinePath;$pwshHome;$gitHome\cmd", 'Machine') +Invoke-Native { & "$gitHome\cmd\git.exe" config --system core.fscache true } 'enable the git file system cache' +Invoke-Native { & "$gitHome\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 diff --git a/tests/e2e/windows/compose.amd64.yaml b/tests/e2e/windows/compose.amd64.yaml index d1136aa..7f29e98 100644 --- a/tests/e2e/windows/compose.amd64.yaml +++ b/tests/e2e/windows/compose.amd64.yaml @@ -57,6 +57,7 @@ services: OHOS_VERSION: ${OHOS_VERSION:-} VFOX_FLUTTER_GITHUB_MIRROR: ${VFOX_FLUTTER_GITHUB_MIRROR:-} E2E_RETRY_ATTEMPTS: ${E2E_RETRY_ATTEMPTS:-} + E2E_ASYNC_CLEANUP: ${E2E_ASYNC_CLEANUP:-} command: ["go", "test", "-v", "-timeout", "55m", "-run", "TestE2E", "./windows"] volumes: diff --git a/tests/e2e/windows/compose.arm64.yaml b/tests/e2e/windows/compose.arm64.yaml index 400451c..bc731ee 100644 --- a/tests/e2e/windows/compose.arm64.yaml +++ b/tests/e2e/windows/compose.arm64.yaml @@ -57,6 +57,7 @@ services: OHOS_VERSION: ${OHOS_VERSION:-} VFOX_FLUTTER_GITHUB_MIRROR: ${VFOX_FLUTTER_GITHUB_MIRROR:-} E2E_RETRY_ATTEMPTS: ${E2E_RETRY_ATTEMPTS:-} + E2E_ASYNC_CLEANUP: ${E2E_ASYNC_CLEANUP:-} command: ["go", "test", "-v", "-timeout", "55m", "-run", "TestE2E", "./windows"] volumes: diff --git a/tests/e2e/windows/e2e_test.go b/tests/e2e/windows/e2e_test.go index ff4f489..de5ef22 100644 --- a/tests/e2e/windows/e2e_test.go +++ b/tests/e2e/windows/e2e_test.go @@ -15,17 +15,19 @@ // Package windows 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. Target-independent logic (matrix -// expansion and friends) is reused from the common package. +// TestE2E builds the image in tests/e2e/windows/Dockerfile once (and vfox +// main alongside it, see vfoxmain.go), 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. Target-independent logic (matrix expansion +// and friends) is reused from the common package. package windows import ( "context" "os" "path/filepath" + "slices" "strings" "testing" "time" @@ -66,9 +68,6 @@ func TestE2E(t *testing.T) { } repoRoot := filepath.Join(here, "..", "..", "..") - ctx := context.Background() - buildImage(t, ctx, repoRoot, image, platform, baseImage) - combos, err := common.Matrix() if err != nil { t.Fatal(err) @@ -77,6 +76,15 @@ func TestE2E(t *testing.T) { t.Fatal("empty matrix: nothing to run") } + // vfox main builds in the background while the image does. + var vfoxMain *vfoxMainBuild + if slices.ContainsFunc(combos, func(c common.Combo) bool { return c.Vfox == "main" }) { + vfoxMain = startVfoxMainBuild(t.Context(), t.TempDir(), arch) + } + + ctx := context.Background() + buildImage(t, ctx, repoRoot, image, platform, baseImage) + sem := make(chan struct{}, maxJobs()) for _, c := range combos { c := c @@ -84,7 +92,7 @@ func TestE2E(t *testing.T) { t.Parallel() sem <- struct{}{} defer func() { <-sem }() - runOne(t, ctx, image, platform, c) + runOne(t, ctx, image, platform, c, vfoxMain) }) } } @@ -133,8 +141,9 @@ func buildImage(t *testing.T, ctx context.Context, repoRoot, image, platform, ba // 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. Every combo runs against a VFOX_HOME -// containing a space (see spacedVfoxHome). -func runOne(t *testing.T, ctx context.Context, image, platform string, c common.Combo) { +// containing a space (see spacedVfoxHome); main combos also receive the +// vfox.exe built from main (vfoxMainStaged). +func runOne(t *testing.T, ctx context.Context, image, platform string, c common.Combo, vfoxMain *vfoxMainBuild) { t.Helper() prefix := c.Prefix(platform) @@ -161,19 +170,31 @@ func runOne(t *testing.T, ctx context.Context, image, platform string, c common. 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{ + req := tc.ContainerRequest{ Image: image, ImagePlatform: platform, Name: common.ContainerName(c.Slug()), Env: env, - }) + } + if c.Vfox == "main" { + // Copied in before the container starts, which Hyper-V isolated + // containers (the Windows client default) also allow. + req.Files = []tc.ContainerFile{{ + HostFilePath: vfoxMain.wait(t), + ContainerFilePath: vfoxMainStaged, + FileMode: 0o755, + }} + } + // NB: no provider.Close here: the container below terminates through + // this very provider in t.Cleanup. + ctr, err := provider.RunContainer(ctx, req) if err != nil { t.Fatalf("[%s] start container: %v", prefix, err) } - t.Cleanup(func() { - _ = ctr.Terminate(context.Background()) + // The idle pwsh loop ignores the stop signal, so a graceful stop only + // waits out its timeout: kill right away instead. + common.Cleanup(t, func() { + _ = ctr.Terminate(context.Background(), tc.StopTimeout(0)) }) // One overall budget per combo; the CI job timeout stays the outer bound. diff --git a/tests/e2e/windows/install-docker-arm64.ps1 b/tests/e2e/windows/install-docker-arm64.ps1 index a4b7160..aabe7b6 100644 --- a/tests/e2e/windows/install-docker-arm64.ps1 +++ b/tests/e2e/windows/install-docker-arm64.ps1 @@ -14,6 +14,15 @@ limitations under the License. #> +# Builds the docker engine for windows/arm64 from source (Docker publishes no +# such build) and starts it. The binaries land in C:\vfox-docker\bin and are +# reused while they match $mobyVersion; CI caches that directory. +param( + # Where the engine keeps images and containers; empty means + # C:\vfox-docker\data. CI points it at the runner's local NVMe disk. + [string] $DataRoot +) + $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true @@ -59,35 +68,46 @@ if (Test-Engine) { else { $mobyVersion = '29.8.1' $root = 'C:\vfox-docker' - New-Item -ItemType Directory -Force -Path $root | Out-Null - Write-Output 'Building docker from source for windows/arm64 ...' - - if (-not (Get-Command go -ErrorAction SilentlyContinue)) { throw 'FAIL go is not on the PATH' } - $env:GOPATH = Join-Path $root 'gopath' - - $moby = Join-Path $root 'moby' - Invoke-Native { git clone --depth 1 --branch "docker-v$mobyVersion" https://github.com/moby/moby.git $moby } 'the moby clone' - Invoke-Native { go install github.com/tc-hib/go-winres@v0.3.1 } 'the go-winres install' - $env:DOCKERCLI_VERSION = $mobyVersion - $env:VERSION = $mobyVersion - Push-Location $moby - try { - & .\hack\make.ps1 -Client -Daemon - } - finally { - Pop-Location + $bin = Join-Path $root 'bin' + $dockerd = Join-Path $bin 'dockerd.exe' + New-Item -ItemType Directory -Force -Path $bin | Out-Null + + if ((Test-Path $dockerd) -and ((& $dockerd --version) -match "\b$([regex]::Escape($mobyVersion))\b")) { + Write-Output "PASS reusing docker $mobyVersion from $bin" } + else { + Write-Output 'Building docker from source for windows/arm64 ...' - $bin = Join-Path $moby 'bundles' - if (-not (Test-Path (Join-Path $bin 'dockerd.exe'))) { throw 'FAIL the moby build produced no dockerd.exe' } - if (-not (Test-Path (Join-Path $bin 'docker.exe'))) { throw 'FAIL the moby build produced no docker.exe' } + if (-not (Get-Command go -ErrorAction SilentlyContinue)) { throw 'FAIL go is not on the PATH' } + $env:GOPATH = Join-Path $root 'gopath' + + $moby = Join-Path $root 'moby' + if (Test-Path $moby) { Remove-Item -Recurse -Force $moby } + Invoke-Native { git clone --depth 1 --branch "docker-v$mobyVersion" https://github.com/moby/moby.git $moby } 'the moby clone' + Invoke-Native { go install github.com/tc-hib/go-winres@v0.3.1 } 'the go-winres install' + $env:DOCKERCLI_VERSION = $mobyVersion + $env:VERSION = $mobyVersion + Push-Location $moby + try { + & .\hack\make.ps1 -Client -Daemon + } + finally { + Pop-Location + } + + foreach ($exe in 'dockerd.exe', 'docker.exe') { + $built = Join-Path $moby "bundles\$exe" + if (-not (Test-Path $built)) { throw "FAIL the moby build produced no $exe" } + Copy-Item -Path $built -Destination $bin -Force + } + } $env:PATH = "$bin;$env:PATH" if ($env:GITHUB_PATH) { Add-Content -Path $env:GITHUB_PATH -Value $bin } - $dataRoot = Join-Path $root 'data' + if (-not $DataRoot) { $DataRoot = Join-Path $root 'data' } $log = Join-Path $root 'dockerd.log' - $dockerdArgs = "--data-root `"$dataRoot`" --debug --exec-opt isolation=process" - Start-Process -FilePath (Join-Path $bin 'dockerd.exe') -ArgumentList $dockerdArgs -RedirectStandardOutput $log -RedirectStandardError (Join-Path $root 'dockerd.err.log') + $dockerdArgs = "--data-root `"$DataRoot`" --debug --exec-opt isolation=process" + Start-Process -FilePath $dockerd -ArgumentList $dockerdArgs -RedirectStandardOutput $log -RedirectStandardError (Join-Path $root 'dockerd.err.log') if (-not (Wait-Engine)) { if (Test-Path $log) { Get-Content $log -Tail 40 | ForEach-Object { "dockerd: $_" } } throw 'FAIL the docker engine never became ready' diff --git a/tests/e2e/windows/setup.go b/tests/e2e/windows/setup.go index 1f146cc..abf9659 100644 --- a/tests/e2e/windows/setup.go +++ b/tests/e2e/windows/setup.go @@ -26,12 +26,13 @@ import ( "github.com/version-fox/vfox-flutter/tests/e2e/common" ) -// Flutter defaults live in the common package; only the Windows-specific -// toolchain pin stays here. +// Flutter defaults live in the common package; only Windows-specific +// locations stay here. const ( - // goWindowsVersion pins the Go toolchain MSI used to build vfox@main - // inside the container (was setup.ps1). - goWindowsVersion = "1.27.1" + // vfoxMainStaged is where a main combo's container receives the vfox.exe + // built from main (vfoxMainBuild): a tar entry relative to the container's + // C:\ root, copied in through ContainerRequest.Files before it starts. + vfoxMainStaged = "vfox-main/vfox.exe" // spacedVfoxHome is the default vfox home of a Windows account whose // name contains a space, the layout reported in @@ -159,49 +160,17 @@ func installVfoxRelease(ctx context.Context, t *testing.T, ctr tc.Container, box 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). +// setupVfox installs vfox (a release, or stages the build of main the +// container received at vfoxMainStaged) 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) + staged := `C:\` + strings.ReplaceAll(vfoxMainStaged, "/", `\`) + execOK(ctx, t, ctr, fmt.Sprintf("Copy-Item -Path %s -Destination %s", + pwshQuote(staged), pwshQuote(box.vfoxExe)), box.vars...) } else { - installVfoxRelease(ctx, t, ctr, box, procArch) + installVfoxRelease(ctx, t, ctr, box, procArchOfContainer(ctx, t, ctr)) } execOK(ctx, t, ctr, fmt.Sprintf( "tar -a -cf %s -C 'C:\\e2e' metadata.lua hooks lib", diff --git a/tests/e2e/windows/vfoxmain.go b/tests/e2e/windows/vfoxmain.go new file mode 100644 index 0000000..9dc9ce8 --- /dev/null +++ b/tests/e2e/windows/vfoxmain.go @@ -0,0 +1,119 @@ +// Copyright 2026 Han Li and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package windows + +import ( + "archive/zip" + "bytes" + "context" + "debug/buildinfo" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "testing" +) + +// vfoxMainArchive serves vfox main as a GitHub source archive, so building +// it needs no git (the compose runner image carries none). +const vfoxMainArchive = "https://github.com/version-fox/vfox/archive/refs/heads/main.zip" + +// vfoxMainBuild is the one vfox.exe built from vfox main per run, shared by +// every main combo. It builds on the test's side while the image builds, +// instead of a Go MSI install, clone and build in every main container. +type vfoxMainBuild struct { + done chan struct{} + exe string + goVersion string + err error +} + +// startVfoxMainBuild fetches vfox main into dir and builds vfox.exe for +// windows/arch in the background, with the suite's own Go toolchain. +func startVfoxMainBuild(ctx context.Context, dir, arch string) *vfoxMainBuild { + b := &vfoxMainBuild{done: make(chan struct{})} + go func() { + defer close(b.done) + b.exe, b.goVersion, b.err = buildVfoxMain(ctx, dir, arch) + }() + return b +} + +// wait blocks until the build is done and fatals the calling combo if it +// failed, so a broken vfox main fails the main combos only. +func (b *vfoxMainBuild) wait(t *testing.T) string { + t.Helper() + <-b.done + if b.err != nil { + t.Fatalf("build vfox from main: %v", b.err) + } + t.Logf("PASS built vfox from main with %s", b.goVersion) + return b.exe +} + +// buildVfoxMain returns the built vfox.exe and the Go version it was built +// with. +func buildVfoxMain(ctx context.Context, dir, arch string) (string, string, error) { + src, err := fetchVfoxMain(ctx, dir) + if err != nil { + return "", "", err + } + exe := filepath.Join(dir, "vfox.exe") + cmd := exec.CommandContext(ctx, "go", "build", "-trimpath", "-o", exe, ".") + cmd.Dir = src + // GOTOOLCHAIN=auto follows vfox's own go.mod should it ever need a newer + // Go than the suite's (setup-go pins GOTOOLCHAIN=local in CI). + cmd.Env = append(os.Environ(), "GOOS=windows", "GOARCH="+arch, "CGO_ENABLED=0", "GOTOOLCHAIN=auto") + if out, err := cmd.CombinedOutput(); err != nil { + return "", "", fmt.Errorf("go build: %w\n%s", err, out) + } + info, err := buildinfo.ReadFile(exe) + if err != nil { + return "", "", err + } + return exe, info.GoVersion, nil +} + +// fetchVfoxMain extracts vfoxMainArchive under dir and returns the source +// root, the archive's single top-level vfox-main directory. +func fetchVfoxMain(ctx context.Context, dir string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, vfoxMainArchive, nil) + if err != nil { + return "", err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GET %s: %s", vfoxMainArchive, resp.Status) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) + if err != nil { + return "", err + } + // The fs.FS view of the archive only yields names inside it. + if err := os.CopyFS(dir, zr); err != nil { + return "", err + } + return filepath.Join(dir, "vfox-main"), nil +} From 4f2a2abc05f63a809f0a09ec0ead0b8aac1e37d7 Mon Sep 17 00:00:00 2001 From: Qiheng He Date: Sat, 17 Oct 2026 22:45:31 +0800 Subject: [PATCH 2/2] fix: retry git fetches that stall --- .github/workflows/e2e.yml | 12 +++++--- docs/arm64.md | 4 ++- docs/e2e.md | 8 ++++-- docs/ohos.md | 3 ++ lib/git.lua | 7 ++++- tests/e2e/common/common.go | 47 +++++++++++++++++++++++++++++++ tests/e2e/common/common_test.go | 50 +++++++++++++++++++++++++++++++++ tests/e2e/linux/e2e_test.go | 15 +++------- tests/e2e/linux/install.go | 6 +++- tests/e2e/windows/e2e_test.go | 15 +++------- tests/e2e/windows/install.go | 6 +++- tests/hooks_test.lua | 21 ++++++++++++++ 12 files changed, 161 insertions(+), 33 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7b4c436..00b64ca 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -78,9 +78,10 @@ jobs: run: shell: pwsh env: - # As many containers at a time as on Linux, now that the engine works - # off a local disk (windows-11-arm runs two combos anyway). - E2E_MAX_JOBS: '3' + # windows-latest runs six combos, slowest first (common.SlowestFirst): + # with four at a time the short ones left over finish within the first + # wave. windows-11-arm runs two. + E2E_MAX_JOBS: '4' steps: - uses: actions/checkout@v7 # The engine's data-root takes every image pull, layer commit and @@ -141,13 +142,16 @@ jobs: # Unpacking servercore is the longest setup step and needs nothing but # the engine: start it now so that setting up Go and compiling the # suite overlap it. The suite's own pull of the image joins this one. + # No output redirection: Start-Process then goes through ShellExecute, + # so the pull does not inherit this step's output pipes, and the runner + # only kills what still holds them when the step ends. - name: Pull the base image in the background run: | $arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'amd64' } $match = Select-String -Path tests/e2e/windows/Dockerfile -Pattern '^ARG BASE_IMAGE=(.+)-amd64$' if (-not $match) { throw 'FAIL tests/e2e/windows/Dockerfile has no ARG BASE_IMAGE=...-amd64 line' } $image = $match.Matches[0].Groups[1].Value + "-$arch" - Start-Process docker -ArgumentList 'pull', $image -RedirectStandardOutput "$env:RUNNER_TEMP\base-pull.log" -RedirectStandardError "$env:RUNNER_TEMP\base-pull.err.log" + Start-Process docker -ArgumentList 'pull', $image Write-Output "Pulling $image in the background" - uses: actions/setup-go@v7 with: diff --git a/docs/arm64.md b/docs/arm64.md index da0d461..11d9247 100644 --- a/docs/arm64.md +++ b/docs/arm64.md @@ -47,7 +47,9 @@ run on Windows ARM64. ## Linux ARM64 and Windows ARM64 - **`git` is required.** The checkout runs `git init` / `fetch` / `checkout` - against `https://github.com/flutter/flutter.git`. + against `https://github.com/flutter/flutter.git`. A fetch that stalls (under + 1 KB/s for a minute) is abandoned and retried, up to three attempts; + `GIT_HTTP_LOW_SPEED_LIMIT` / `GIT_HTTP_LOW_SPEED_TIME` change the threshold. - **The first `flutter` run is slow.** It downloads the native Dart SDK and builds the Flutter tool, so it needs a network connection. - **No checksum is verified.** Git history is the integrity check. diff --git a/docs/e2e.md b/docs/e2e.md index 5c99683..e88a86e 100644 --- a/docs/e2e.md +++ b/docs/e2e.md @@ -98,9 +98,11 @@ It also moves the engine's data-root off the runners' slow C: OS disk, where image pulls, layer commits and the in-container Flutter installs would otherwise crawl: next to the workspace on `windows-latest`'s local D: disk, and onto the local NVMe disk `windows-11-arm` ships unformatted. With that, -`E2E_MAX_JOBS` is 3 there, as on Linux. Unpacking the servercore base image -remains the longest setup step, so CI starts pulling it in the background -before it sets up Go and compiles the suite. +`E2E_MAX_JOBS` is 4 there. Both suites start the combinations slowest first +(the CN mirror, then the official SDK), so the last ones to start are short. +Unpacking the servercore base image remains the longest setup step, so CI +starts pulling it in the background before it sets up Go and compiles the +suite. The target image (`tests/e2e/windows/Dockerfile`) unpacks PowerShell 7 and MinGit from their portable zips instead of running their installers: Git diff --git a/docs/ohos.md b/docs/ohos.md index 210845c..d765a52 100644 --- a/docs/ohos.md +++ b/docs/ohos.md @@ -41,6 +41,9 @@ vfox search flutter - **Mirrors don't apply.** `FLUTTER_STORAGE_BASE_URL` and `VFOX_FLUTTER_GITHUB_MIRROR` (see `mirror.md`) only affect official SDK downloads; OpenHarmony builds are cloned from gitcode, so both variables are ignored. +- **Stalled clones are retried.** A fetch from gitcode that stalls (under 1 KB/s for a + minute) is abandoned and retried, up to three attempts; `GIT_HTTP_LOW_SPEED_LIMIT` / + `GIT_HTTP_LOW_SPEED_TIME` change the threshold. ### Building OpenHarmony applications diff --git a/lib/git.lua b/lib/git.lua index 0aa5070..f2eb2b5 100644 --- a/lib/git.lua +++ b/lib/git.lua @@ -18,6 +18,11 @@ local VFOX_DIR = ".vfox" local TMP_DIR = "tmp" local ENGINE_PIN = "bin/internal/engine.version" local FETCH_ATTEMPTS = 3 +-- A stalled connection never fails on its own, which would leave the retries +-- above unused: abort a fetch that moves under 1 KB/s for a minute, so the next +-- attempt reconnects. GIT_HTTP_LOW_SPEED_LIMIT and GIT_HTTP_LOW_SPEED_TIME +-- still override both values. +local FETCH_STALL_GUARD = "-c http.lowSpeedLimit=1000 -c http.lowSpeedTime=60" local GITHUB_MIRROR_ENV = "VFOX_FLUTTER_GITHUB_MIRROR" local GH_BASE = "https://github.com/" @@ -137,7 +142,7 @@ end function M.fetch(root, remote, ref) for _ = 1, FETCH_ATTEMPTS do - if git(root, "fetch -q --depth 1 " .. remote .. " " .. ref) then + if git(root, FETCH_STALL_GUARD .. " fetch -q --depth 1 " .. remote .. " " .. ref) then return true end end diff --git a/tests/e2e/common/common.go b/tests/e2e/common/common.go index 7aaf19c..6800b67 100644 --- a/tests/e2e/common/common.go +++ b/tests/e2e/common/common.go @@ -28,8 +28,10 @@ import ( "fmt" "os" "runtime" + "slices" "strconv" "strings" + "sync" "testing" "time" ) @@ -180,6 +182,51 @@ func MatrixForArch(arch string) ([]Combo, error) { return combos, nil } +// SlowestFirst returns combos ordered by how long they usually take, the +// slowest first, so a bounded run (RunCombos) ends on short combos instead +// of starting a long one last: non-default mirrors (the CN mirror is far +// from the CI runners) before the default one, official before ohos. The +// order is otherwise kept. +func SlowestFirst(combos []Combo) []Combo { + slowness := func(c Combo) int { + w := 0 + if c.Mirror != "default" { + w += 2 + } + if c.Flavor == "official" { + w++ + } + return w + } + sorted := slices.Clone(combos) + slices.SortStableFunc(sorted, func(a, b Combo) int { return slowness(b) - slowness(a) }) + return sorted +} + +// RunCombos runs run as one subtest per combo, at most jobs at a time, +// starting them in the given order: unlike t.Parallel behind a semaphore, +// whose start order is up to the scheduler, so callers can put the slowest +// combos first. +func RunCombos(t *testing.T, combos []Combo, jobs int, run func(t *testing.T, c Combo)) { + t.Helper() + queue := make(chan Combo, len(combos)) + for _, c := range combos { + queue <- c + } + close(queue) + var wg sync.WaitGroup + for range min(max(jobs, 1), len(combos)) { + wg.Add(1) + go func() { + defer wg.Done() + for c := range queue { + t.Run(c.Slug(), func(t *testing.T) { run(t, c) }) + } + }() + } + wg.Wait() +} + // MaxJobs bounds parallel containers; honours $E2E_MAX_JOBS and falls back // to def when unset or invalid. Linux passes 3, Windows passes 2 (matching // the old e2e.ps1). diff --git a/tests/e2e/common/common_test.go b/tests/e2e/common/common_test.go index 86edbe7..086077a 100644 --- a/tests/e2e/common/common_test.go +++ b/tests/e2e/common/common_test.go @@ -15,7 +15,10 @@ package common import ( + "slices" + "sync" "testing" + "time" ) func setEnv(t *testing.T, kv map[string]string) { @@ -146,6 +149,53 @@ func TestSanitize(t *testing.T) { } } +// The CN mirror and the official SDK download take longest, so they go first. +func TestSlowestFirst(t *testing.T) { + setEnv(t, map[string]string{"VFOX_VERSION": "", "FLAVOR": "", "MIRROR": ""}) + combos, err := MatrixForArch("amd64") + if err != nil { + t.Fatal(err) + } + got := comboKeys(SlowestFirst(combos)) + want := []string{ + "latest/official/" + DefaultMirrorCN, + "latest/ohos/" + DefaultMirrorCN, + "latest/official/default", + "main/official/default", + "latest/ohos/default", + "main/ohos/default", + } + if !slices.Equal(got, want) { + t.Errorf("SlowestFirst = %v, want %v", got, want) + } +} + +// RunCombos runs every combo, never more than jobs at a time. +func TestRunCombos(t *testing.T) { + combos := []Combo{{Vfox: "a"}, {Vfox: "b"}, {Vfox: "c"}, {Vfox: "d"}, {Vfox: "e"}} + var mu sync.Mutex + var ran []string + running, peak := 0, 0 + RunCombos(t, combos, 2, func(t *testing.T, c Combo) { + mu.Lock() + ran = append(ran, c.Vfox) + running++ + peak = max(peak, running) + mu.Unlock() + time.Sleep(10 * time.Millisecond) + mu.Lock() + running-- + mu.Unlock() + }) + slices.Sort(ran) + if !slices.Equal(ran, []string{"a", "b", "c", "d", "e"}) { + t.Errorf("ran %v, want every combo once", ran) + } + if peak > 2 { + t.Errorf("%d combos ran at once, want at most 2", peak) + } +} + func TestMaxJobs(t *testing.T) { t.Setenv("E2E_MAX_JOBS", "") // empty means unset-equivalent for MaxJobs (Atoi fails -> default) diff --git a/tests/e2e/linux/e2e_test.go b/tests/e2e/linux/e2e_test.go index 485ef22..04e3aae 100644 --- a/tests/e2e/linux/e2e_test.go +++ b/tests/e2e/linux/e2e_test.go @@ -40,7 +40,7 @@ import ( ) // TestE2E builds the image once, then runs one container per matrix combo. -// Bounded parallelism via t.Parallel + semaphore (like E2E_MAX_JOBS). +// Bounded parallelism via common.RunCombos (E2E_MAX_JOBS), slowest first. // // Full matrix (~45min) runs in CI; locally scope it down, e.g.: // VFOX_VERSION=latest FLAVOR=official MIRROR=default E2E_MAX_JOBS=1 go test -v ./linux/ @@ -75,16 +75,9 @@ func TestE2E(t *testing.T) { 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) - }) - } + common.RunCombos(t, common.SlowestFirst(combos), maxJobs(), func(t *testing.T, c common.Combo) { + runOne(t, ctx, image, platform, c) + }) } // buildImage replicates `docker build --pull --platform $platform diff --git a/tests/e2e/linux/install.go b/tests/e2e/linux/install.go index 6c661cd..dd106f9 100644 --- a/tests/e2e/linux/install.go +++ b/tests/e2e/linux/install.go @@ -18,15 +18,19 @@ import ( "context" "fmt" "testing" + "time" tc "github.com/testcontainers/testcontainers-go" ) // installFlutterVersion installs one Flutter version and selects it globally -// (was install.sh). +// (was install.sh). The logged duration exposes slow downloads, the usual +// cause of a slow combo. func installFlutterVersion(ctx context.Context, t *testing.T, ctr tc.Container, version string) { t.Helper() + start := time.Now() execRetry(ctx, t, ctr, fmt.Sprintf("vfox install flutter@%s", version), activated(fmt.Sprintf("vfox install flutter@%s", version))) + t.Logf("PASS installed flutter@%s in %s", version, time.Since(start).Round(time.Second)) execOK(ctx, t, ctr, activated(fmt.Sprintf("vfox use --global flutter@%s", version))) } diff --git a/tests/e2e/windows/e2e_test.go b/tests/e2e/windows/e2e_test.go index de5ef22..fed08f6 100644 --- a/tests/e2e/windows/e2e_test.go +++ b/tests/e2e/windows/e2e_test.go @@ -42,7 +42,7 @@ import ( ) // TestE2E builds the image once, then runs one container per matrix combo. -// Bounded parallelism via t.Parallel + semaphore (like E2E_MAX_JOBS). +// Bounded parallelism via common.RunCombos (E2E_MAX_JOBS), slowest first. // // 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 ./windows/ @@ -85,16 +85,9 @@ func TestE2E(t *testing.T) { ctx := context.Background() buildImage(t, ctx, repoRoot, image, platform, baseImage) - 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, vfoxMain) - }) - } + common.RunCombos(t, common.SlowestFirst(combos), maxJobs(), func(t *testing.T, c common.Combo) { + runOne(t, ctx, image, platform, c, vfoxMain) + }) } // buildImage replicates `docker build --pull --platform $platform diff --git a/tests/e2e/windows/install.go b/tests/e2e/windows/install.go index 1941456..9bc5ea8 100644 --- a/tests/e2e/windows/install.go +++ b/tests/e2e/windows/install.go @@ -18,18 +18,22 @@ import ( "context" "fmt" "testing" + "time" 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. +// vfox.exe resolving through the slot PATH override. The logged duration +// exposes slow downloads, the usual cause of a slow combo. func installFlutterVersion(ctx context.Context, t *testing.T, ctr tc.Container, box slotBox, version string) { t.Helper() + start := time.Now() execRetry(ctx, t, ctr, fmt.Sprintf("vfox install flutter@%s", version), fmt.Sprintf("vfox install %s", pwshQuote("flutter@"+version)), box.vars...) + t.Logf("PASS installed flutter@%s in %s", version, time.Since(start).Round(time.Second)) execOK(ctx, t, ctr, fmt.Sprintf("vfox use --global %s", pwshQuote("flutter@"+version)), box.vars...) diff --git a/tests/hooks_test.lua b/tests/hooks_test.lua index 1efca2e..3b3dd05 100644 --- a/tests/hooks_test.lua +++ b/tests/hooks_test.lua @@ -201,6 +201,27 @@ tests[#tests + 1] = { installed("stable-x64", "3.44.0-x64", "x64") end, } +tests[#tests + 1] = { + "fetch gives up on a stalled transfer and retries", + function() + RUNTIME = { osType = "linux", archType = "amd64" } + local git = require("git") + local commands, exits = {}, { 1, 1, 0, 1, 1, 1 } + local execute = os.execute + os.execute = function(command) + table.insert(commands, command) + return table.remove(exits, 1) + end + local first = git.fetch("/sdk", "https://example.invalid/flutter.git", "refs/tags/3.44.0") + local second = git.fetch("/sdk", "https://example.invalid/flutter.git", "refs/tags/3.44.0") + os.execute = execute + equal(first, true) + equal(second, false) + equal(#commands, 6) + local guard = "git -C '/sdk' -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=60 fetch " + assert(commands[1]:find(guard, 1, true), commands[1]) + end, +} local failures = 0 for _, test in ipairs(tests) do