Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 22 additions & 19 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ concurrency:
# 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.
# The Windows jobs run it natively too, with the same Go, rather than through
# the compose runner image (tests/e2e/windows/Dockerfile-runner) that lets a
# local Windows host need only Docker: on windows-11-arm, extracting Go into
# that image alone took two and a half minutes. See docs/e2e.md.

jobs:
e2e-linux:
Expand Down Expand Up @@ -68,30 +69,32 @@ 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
env:
# Three containers at a time, as on Linux, once Docker is off C:.
E2E_MAX_JOBS: "3"
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
# The windows-11-arm runner ships neither Docker for Windows containers
# nor a Go new enough to build moby, so both are provisioned here.
- if: matrix.runs-on == 'windows-11-arm'
uses: actions/setup-go@v6
# C: is a network-attached disk on these runners, where extracting the
# servercore image took five minutes; Docker, Go's caches and the temp
# directory go to a local disk instead.
- run: pwsh -NoProfile -File tests/e2e/windows/use-local-disk.ps1
- uses: actions/setup-go@v6
with:
go-version: 1.27.1
cache: false
# The windows-11-arm runner ships no Docker for Windows containers, so
# the engine is built here with that Go; the build, minutes long, is
# cached on the script that pins it.
- if: matrix.runs-on == 'windows-11-arm'
run: pwsh -NoProfile -File tests/e2e/windows/install-docker-arm64.ps1
uses: actions/cache@v6
with:
path: C:\vfox-docker\moby\bundles
key: moby-windows-arm64-${{ hashFiles('tests/e2e/windows/install-docker-arm64.ps1') }}
- if: matrix.runs-on == 'windows-11-arm'
name: Install Compose CLI plugin
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
run: pwsh -NoProfile -File tests/e2e/windows/install-docker-arm64.ps1
- run: go test -v -timeout 55m -run TestE2E ./windows
working-directory: tests/e2e
40 changes: 29 additions & 11 deletions docs/e2e.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
The offline Lua hook tests (`tests/*_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 `distribution` 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 the suite natively on the stock runners (Docker plus Go
from `actions/setup-go`). Locally, the repository dev container reproduces
that on Linux; on Windows a Docker Compose runner image does, since dev
containers cannot host Windows container images (see below).

## Dev container

Expand Down Expand Up @@ -60,11 +60,16 @@ JDK, so `java` can only come from what vfox installs:

1. vfox is installed from the requested channel — the latest release
(`latest`) or built from source (`main`) — and the local `java` plugin is
registered from this checkout.
registered from this checkout. On Windows, the runner builds `main` once,
while the `latest` combinations run, and the `main` containers start from
the e2e image plus that `vfox.exe`.
2. A release no distribution has published (`<major>.0.99`) fails to install,
with `PreInstall` naming the platform it searched (`linux/amd64`,
`windows/arm64`, ...), so the Foojay lookup is checked to target the
runner.
runner. So does a build of the pinned release that was never published
(`<version>+99`, e.g. `21.0.8+99-tem`), proving the lookup keeps the
build number: Foojay reads an unencoded `+` as a space and drops the
build, and the release's newest build would install instead.
3. The pinned release installs under the name `PreInstall` gives it: the
requested version plus the build number (`21.0.8-tem` installs as
`21.0.8+9-tem`). `vfox search java <distribution>` (the `Available` hook)
Expand Down Expand Up @@ -150,15 +155,26 @@ suite needs an ARM64 host (a Copilot+ PC or the `windows-11-arm` CI runner).
There is no dev container for this suite — the Dev Containers spec rejects
Windows container images (see [Dev container](#dev-container)) — so it keeps
its Docker Compose runner. The runner image (`Dockerfile-runner`: stock
servercore plus the pinned Go MSI) drives the host daemon through the mounted
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.
servercore plus the pinned Go, which also builds vfox `main`) drives the host
daemon through the mounted 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.

The `windows-11-arm` runner has no Windows Hypervisor Platform feature, so
Hyper-V isolation is unavailable; process isolation needs only the
Containers feature.

CI skips the runner image: extracting its Go took two and a half minutes on
`windows-11-arm`, where `actions/setup-go` already provides the same Go. It
runs `go test ./windows` on the runner with `E2E_MAX_JOBS=3`, after
`use-local-disk.ps1` moves Docker's data root, Go's caches and the temp
directory off the runners' network-attached C: onto a local disk:
`windows-latest`'s D:, or the unformatted NVMe disk of `windows-11-arm`,
which it formats. Image layers and the containers' files are written file by
file, so on C: the servercore pull alone took five minutes. The script
refuses to run outside GitHub Actions.

The `main` combos run with `VFOX_HOME` pointing inside a path containing a
space (`C:\Users\John Doe\.vfox`), mirroring
[vfox-flutter#33](https://github.com/version-fox/vfox-flutter/issues/33).
Expand Down Expand Up @@ -237,7 +253,9 @@ 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 reuses an
engine of its pinned version that it built before; CI restores one from a
cache keyed on the script. Scope the run down with
`$env:VFOX_VERSION = 'latest'; $env:DISTRIBUTION = 'tem'` in front of the
compose command, as on the other platforms.

Expand Down
12 changes: 11 additions & 1 deletion lib/foojay.lua
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ local function detect_lib_c_type()
return "glibc"
end

--- Percent-encodes a query string value. Foojay reads a bare + as a space and
--- drops the build after it, so 21.0.5+63130 would match every 21.0.5 build.
--- @param value string
--- @return string
local function encode(value)
return (value:gsub("[^%w%-._~]", function(c)
return ("%%%02X"):format(c:byte())
end))
end

foojay.fetchtJdkList = function(distribution, version)
local os = RUNTIME.osType
local arch = RUNTIME.archType
Expand Down Expand Up @@ -66,7 +76,7 @@ foojay.fetchtJdkList = function(distribution, version)
-- latest=available would look at the major alone.
local latest = version:match("^%d+$") and "&latest=available" or ""

local reqUrl = URL:format(version, distribution, arch, archive_types, os, lib_c_type) .. latest
local reqUrl = URL:format(encode(version), distribution, arch, archive_types, os, lib_c_type) .. latest

local resp, err = http.get({
url = reqUrl,
Expand Down
9 changes: 9 additions & 0 deletions tests/e2e/common/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,15 @@ func MissingSpec(distribution, version string) string {
return Spec(distribution, Major(version)+".0.99")
}

// MissingBuildSpec renders a build of the release that no distribution has
// published (<version>+99). Foojay answers it with an empty package list
// only when the + reaches it encoded: read as a space, it drops the build
// and lists every build of the release, so PreInstall installed the newest
// (vfox-java#73).
func MissingBuildSpec(distribution, version string) string {
return Spec(distribution, version+"+99")
}

// Major returns the leading version component: "21.0.8" -> "21".
func Major(version string) string {
return strings.SplitN(version, ".", 2)[0]
Expand Down
13 changes: 8 additions & 5 deletions tests/e2e/common/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,18 +184,21 @@ func TestRetryAttemptsDefault(t *testing.T) {
}

func TestSpecs(t *testing.T) {
for _, c := range []struct{ dist, version, spec, missing, legacy string }{
{"open", "25.0.2", "25.0.2", "25.0.99", "25.0.2-open"},
{"tem", "21.0.8", "21.0.8-tem", "21.0.99-tem", "21.0.8-tem"},
{"librca-fx", "25.0.4", "25.0.4-librca-fx", "25.0.99-librca-fx", "25.0.4.fx-librca"},
{"jb", "21.0.5", "21.0.5-jb", "21.0.99-jb", "21.0.5-jbr"},
for _, c := range []struct{ dist, version, spec, missing, missingBuild, legacy string }{
{"open", "25.0.2", "25.0.2", "25.0.99", "25.0.2+99", "25.0.2-open"},
{"tem", "21.0.8", "21.0.8-tem", "21.0.99-tem", "21.0.8+99-tem", "21.0.8-tem"},
{"librca-fx", "25.0.4", "25.0.4-librca-fx", "25.0.99-librca-fx", "25.0.4+99-librca-fx", "25.0.4.fx-librca"},
{"jb", "21.0.5", "21.0.5-jb", "21.0.99-jb", "21.0.5+99-jb", "21.0.5-jbr"},
} {
if got := Spec(c.dist, c.version); got != c.spec {
t.Errorf("Spec(%s, %s) = %q, want %q", c.dist, c.version, got, c.spec)
}
if got := MissingSpec(c.dist, c.version); got != c.missing {
t.Errorf("MissingSpec(%s, %s) = %q, want %q", c.dist, c.version, got, c.missing)
}
if got := MissingBuildSpec(c.dist, c.version); got != c.missingBuild {
t.Errorf("MissingBuildSpec(%s, %s) = %q, want %q", c.dist, c.version, got, c.missingBuild)
}
if got := LegacyIdentifier(c.dist, c.version); got != c.legacy {
t.Errorf("LegacyIdentifier(%s, %s) = %q, want %q", c.dist, c.version, got, c.legacy)
}
Expand Down
41 changes: 21 additions & 20 deletions tests/e2e/linux/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,26 +51,27 @@ func setupVfox(ctx context.Context, t *testing.T, ctr tc.Container, vfoxVersion
t.Logf("PASS %s with the local java plugin", strings.TrimSpace(execOK(ctx, t, ctr, "vfox --version")))
}

// checkMissingVersionRejected proves an unpublished release fails loudly
// instead of installing something else: PreInstall finds no Foojay package
// and names the platform it searched, which must be the runner's
// (RUNTIME.osType/archType).
// checkMissingVersionRejected proves an unpublished release, and an
// unpublished build of the pinned one, fail loudly instead of installing
// something else: PreInstall finds no Foojay package and names the platform
// it searched, which must be the runner's (RUNTIME.osType/archType).
func checkMissingVersionRejected(ctx context.Context, t *testing.T, ctr tc.Container, distribution, version, arch string) {
t.Helper()
spec := common.MissingSpec(distribution, version)
want := "No JDK found for " + spec
out := common.Retry(ctx, t, "vfox install java@"+spec+" (must fail)", func() (int, string) {
code, out := execRun(ctx, t, ctr, "vfox install java@"+spec)
if code == 0 {
t.Fatalf("installing the unpublished %s unexpectedly succeeded\n--- output ---\n%s", spec, out)
}
// Any other failure (e.g. Foojay unreachable) is retried like the
// other network-touching calls.
if strings.Contains(out, want) {
return 0, out
}
return code, out
})
require.Contains(t, out, " on linux/"+arch, "the lookup must target the runner platform")
t.Logf("PASS java@%s is rejected on linux/%s", spec, arch)
for _, spec := range []string{common.MissingSpec(distribution, version), common.MissingBuildSpec(distribution, version)} {
want := "No JDK found for " + spec
out := common.Retry(ctx, t, "vfox install java@"+spec+" (must fail)", func() (int, string) {
code, out := execRun(ctx, t, ctr, "vfox install java@"+spec)
if code == 0 {
t.Fatalf("installing the unpublished %s unexpectedly succeeded\n--- output ---\n%s", spec, out)
}
// Any other failure (e.g. Foojay unreachable) is retried like the
// other network-touching calls.
if strings.Contains(out, want) {
return 0, out
}
return code, out
})
require.Contains(t, out, " on linux/"+arch, "the lookup must target the runner platform")
t.Logf("PASS java@%s is rejected on linux/%s", spec, arch)
}
}
14 changes: 9 additions & 5 deletions tests/e2e/windows/Dockerfile-runner
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
# 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.amd64.yaml / compose.arm64.yaml).
# Its Go also builds vfox from main for the suite's main combos.
ARG BASE_IMAGE=mcr.microsoft.com/windows/servercore:ltsc2025-KB5122871-amd64
FROM ${BASE_IMAGE}

Expand All @@ -26,12 +27,15 @@ ARG GO_VERSION=1.27.1
# 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.
# A zip, not the MSI, which took over five minutes to install on arm64; the
# zip only extracts, and leaves the machine PATH to us.
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; `
$goZip = $env:TEMP + '\go.zip'; `
curl.exe -fsSL --retry 3 --retry-delay 5 --retry-all-errors -o $goZip https://go.dev/dl/go${env:GO_VERSION}.windows-${goArch}.zip; `
tar.exe -xf $goZip -C 'C:\Program Files'; `
if ($LASTEXITCODE -ne 0) { throw 'FAIL the Go zip extraction exited with code ' + $LASTEXITCODE }; `
Remove-Item $goZip; `
[Environment]::SetEnvironmentVariable('PATH', [Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';C:\Program Files\Go\bin', 'Machine'); `
New-Item -ItemType Directory -Force -Path 'C:\m', 'C:\b' | Out-Null; `
& 'C:\Program Files\Go\bin\go.exe' version

Expand Down
33 changes: 15 additions & 18 deletions tests/e2e/windows/bootstrap-tools.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,19 @@ function Invoke-Native {
if ($code -notin $Ok) { throw "FAIL $What exited with code $code" }
}

$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'
$pwshArch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x64' }

# A zip, not the MSI: installers took minutes here on arm64, while the zip
# only extracts, and leaves the machine PATH to us. No Git: vfox from main
# is built on the runner (setup.go), and nothing in the containers clones.
$pwshZip = "$env:TEMP\pwsh.zip"
$pwshUrl = "https://github.com/PowerShell/PowerShell/releases/download/v7.6.6/PowerShell-7.6.6-win-$pwshArch.zip"
$pwshHome = 'C:\Program Files\PowerShell\7'

Invoke-Native { & curl.exe -fsSL --retry 3 --retry-delay 5 --retry-all-errors -o $pwshZip $pwshUrl } 'the PowerShell download'
New-Item -ItemType Directory -Force -Path $pwshHome | Out-Null
Invoke-Native { & tar.exe -xf $pwshZip -C $pwshHome } 'the PowerShell extraction'
Remove-Item $pwshZip
if (-not (Test-Path "$pwshHome\pwsh.exe")) { throw 'FAIL pwsh.exe is missing after the PowerShell extraction' }
[Environment]::SetEnvironmentVariable('PATH', [Environment]::GetEnvironmentVariable('PATH', 'Machine') + ";$pwshHome", 'Machine')
New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -Value 1 -PropertyType DWORD -Force | Out-Null
4 changes: 2 additions & 2 deletions tests/e2e/windows/compose.amd64.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
# 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.
# (Dockerfile-runner: stock servercore plus the pinned Go) 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.
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/windows/compose.arm64.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
# 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.
# (Dockerfile-runner: stock servercore plus the pinned Go) 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.
Expand Down
Loading
Loading