diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..96f22bb --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,82 @@ +# 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. + +# The repository dev container (see docs/e2e.md): a Linux userland that is +# identical on every host platform — Linux amd64/arm64 natively, Windows +# amd64/arm64 through Docker Desktop's Linux-container mode. It carries the +# pinned Go toolchain the E2E suites and the local checks need, matching the +# setup-go version CI uses, plus the Lua and license tooling pinned in the +# lint and license workflows. +# +# The suites themselves run testcontainers against the host daemon through +# the socket mounted by devcontainer.json (Docker-out-of-Docker), so the +# target containers are spawned exactly as a native CI runner spawns them. +# +# The image is multi-arch (linux/amd64 and linux/arm64); the Lua downloads +# below select their asset per target arch. + +FROM golang:1.27.1 + +# Docker CLI (client only): drives the host daemon from the shell, the same +# daemon the suites drive through the Docker Go API. Engine 29 is the line +# tests/e2e/windows/install-docker-arm64.ps1 builds for Windows arm64. +COPY --from=docker:29-cli /usr/local/bin/docker /usr/local/bin/docker + +# Keep the tool versions below in step with .github/workflows/lint.yml +# (stylua-action's version and the `mise x aqua:...` line). +ARG STYLUA_VERSION=2.5.2 +ARG LUA_LANGUAGE_SERVER_VERSION=3.19.1 + +# Heredocs need BuildKit, which the dev container tooling uses by default. The +# E2E target Dockerfiles stay on the chained-`;` form: testcontainers-go's +# build path cannot use BuildKit (see tests/e2e/linux/Dockerfile). + +RUN <&2; exit 1 ;; +esac +curl --fail --silent --show-error --location --output /tmp/stylua.zip \ + "https://github.com/JohnnyMorganz/StyLua/releases/download/v${STYLUA_VERSION}/${stylua_asset}" +unzip -q /tmp/stylua.zip -d /usr/local/bin +rm --force /tmp/stylua.zip +curl --fail --silent --show-error --location --output /tmp/luals.tar.gz \ + "https://github.com/LuaLS/lua-language-server/releases/download/${LUA_LANGUAGE_SERVER_VERSION}/${luals_asset}" +mkdir --parents /opt/lua-language-server +tar --extract --gzip --file /tmp/luals.tar.gz --directory /opt/lua-language-server +rm --force /tmp/luals.tar.gz +ln --symbolic /opt/lua-language-server/bin/lua-language-server /usr/local/bin/lua-language-server +stylua --version +lua-language-server --version +EOF + +# License-header tooling at the versions pinned by .github/workflows/license.yml +# (SkyWalking Eyes v0.9.0 for the header check, addlicense v1.2.0 for the +# PowerShell files). +RUN <` fix support upstream): +# tests/e2e/windows/*.ps1 is fixed and checked with addlicense instead (see +# the addlicense step in .github/workflows/license.yml and docs/e2e.md). +# CI runs the check via .github/workflows/license.yml; the local commands +# (Docker only) are documented in docs/e2e.md. +header: + license: + spdx-id: Apache-2.0 + copyright-owner: Han Li and contributors + content: | + 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. + + paths-ignore: + # Files without the header by design; go.sum cannot take one at all (the + # go tool parses every non-empty line as a hash record). + - '**/*.md' + - '**/*.json' + - '**/go.sum' + - '.tool-versions' + - 'LICENSE' + # Plain-text template for addlicense (no comment markers in it; keep its + # text in sync with the `content` block above). + - '.license.tpl' + # Offline copies of the upstream Loongnix directory listings the hook + # tests parse (see tests/fixtures/loongnix/README.md); they keep the + # upstream markup rather than taking a header. + - 'tests/fixtures/**' + + comment: on-failure diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000..0a05bb1 --- /dev/null +++ b/.luarc.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json", + "Lua.runtime.version": "Lua 5.1", + "Lua.diagnostics.globals": ["PLUGIN", "RUNTIME"], + "Lua.diagnostics.disable": ["duplicate-set-field"] +} diff --git a/Injection.lua b/Injection.lua index 798d95a..466ade8 100644 --- a/Injection.lua +++ b/Injection.lua @@ -1,3 +1,17 @@ +-- 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. + --[[ Do not change any thing in the current file, it's just there to show what objects are injected by vfox and what they do. @@ -12,4 +26,3 @@ RUNTIME = { --- vfox runtime version version = "", } - diff --git a/README.md b/README.md index 0c06321..8b39f97 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,10 @@ $ vfox search java jb - [SDKMAN](https://github.com/sdkman/) - [foojayio](https://github.com/foojayio/discoapi) +## Testing + +See [docs/e2e.md](docs/e2e.md) for details. + ## Releasing this plugin Maintainers can publish from **Actions → Plugin → Run workflow** on the default diff --git a/README_CN.md b/README_CN.md index 702cef1..f0f52ad 100644 --- a/README_CN.md +++ b/README_CN.md @@ -260,4 +260,8 @@ $ vfox search java jb # 感谢 - [SDKMAN](https://github.com/sdkman/) -- [foojayio](https://github.com/foojayio/discoapi) \ No newline at end of file +- [foojayio](https://github.com/foojayio/discoapi) + +## 测试 + +详见 [docs/e2e.md](docs/e2e.md)。 \ No newline at end of file diff --git a/docs/e2e.md b/docs/e2e.md new file mode 100644 index 0000000..3327b1d --- /dev/null +++ b/docs/e2e.md @@ -0,0 +1,285 @@ +# E2E + +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). + +## Dev container + +`.devcontainer/` opens a Linux development environment that works on all four +host platforms: + +| Host | How the dev container runs | +| --------------------- | ---------------------------------------------- | +| Linux amd64 / arm64 | natively | +| Windows amd64 / arm64 | Docker Desktop in Linux-container mode (WSL 2) | + +It carries Go 1.27.1 (the version CI installs), the Docker CLI, stylua, +lua-language-server, and the license tools — all pinned to the versions in +`.github/workflows/lint.yml` and `license.yml` — and mounts the host Docker +daemon socket. The suites then run testcontainers against that daemon +(Docker-out-of-Docker), spawning the same target containers a native CI +runner spawns; this is deliberately not Docker-in-Docker, so image cache and +networking stay the host's. The image needs no workspace files, so the build +context stays at `.devcontainer/`; `postCreateCommand` warms the Go module +cache for `tests/e2e` so the first test run does not stop to download. + +Open it with any Dev Containers spec tool — **Dev Containers: Reopen in +Container** in VS Code/Cursor, or the CLI: + +```bash +devcontainer up --workspace-folder . +devcontainer exec --workspace-folder . bash +``` + +`TESTCONTAINERS_RYUK_DISABLED=true` is preset: Ryuk publishes a port on the +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-java-e2e-` name prefix for pruning. 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 +the Windows filesystem works too. + +Windows *container* coverage (`tests/e2e/windows`) cannot run in a dev +container: the Dev Containers spec does not support Windows container images +([vscode-remote-release#445](https://github.com/microsoft/vscode-remote-release/issues/445)), +and Windows containers only run on a Windows daemon. That suite keeps its +Docker Compose runner on a Windows host; see [Windows 11](#windows-11) below. + +## What the suite verifies + +Every combination runs the same flow (one long-lived container per combo, +driven from Go through `docker exec`-style calls); the target images carry no +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. +2. A release no distribution has published (`.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. +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 ` (the `Available` hook) + lists that same name as installed, noting JavaFX on `-fx` builds. On + Windows, a JDK installed through the tar.gz fallback is exempt (logged as + a SKIP when absent): `Available` lists zip packages and reaches tar.gz + ones only when a distribution has no zip at all, so JetBrains Runtime + 21.0.5 installs but is not listed. +4. Before any `vfox use`, a `.sdkmanrc` holding the SDKMAN-style identifier + (`java=21.0.8-tem`) selects the JDK through `ParseLegacyFile`. On Windows + the file is written with CRLF line endings. JavaFX builds skip this probe: + SDKMAN spells them `.fx-`, which the legacy parser + does not resolve. +5. After `vfox use --global`, the JDK on `PATH` reports the requested + `java.version`, the runner's `os.arch` (`amd64` / `aarch64`, which also + catches an x64 JDK emulated on Windows ARM64) and the distribution's + `java.vendor`. `JAVA_HOME` (and `GRAALVM_HOME` for GraalVM) holds that + same JDK, `javac` compiles a program that `java` then runs, the module + list carries JavaFX exactly for `-fx` builds, and `vfox current java` + reports the installed version. +6. `PreUse` maps the spellings users type to the installed version without + another download: the bare major (`java@21`, left to vfox core's fuzzy + matching) and the asdf-style `-[-fx]` + (`java@temurin-21.0.8+9`, rewritten to `21.0.8+9-tem`). + +The default matrix runs every distribution against both vfox channels +(`latest`, `main`): + +| Distribution | Release | Covers | amd64 | arm64 | +| ------------ | ------- | -------------------------------------------------------- | ----- | ----- | +| `open` | 25.0.2 | the default distribution, versions without a suffix | ✓ | | +| `tem` | 21.0.8 | the common path (tar.gz on Linux, zip on Windows) | ✓ | ✓ | +| `librca-fx` | 25.0.4 | JavaFX bundled builds (`-fx`) | ✓ | ✓ | +| `jb` | 21.0.5 | the tar.gz fallback on Windows, which has no zip for it | ✓ | ✓ | +| `graalce` | 25.0.2 | `GRAALVM_HOME` | ✓ | | + +Every pinned release is published through Foojay for each platform its row +runs on. OpenJDK and GraalVM Community ship no `windows-aarch64` build, so +arm64 runs the other three on both Linux and Windows. + +## Environment knobs + +| Variable | Default | Meaning | +| -------------------- | -------------------------- | ----------------------------------------------------------------------- | +| `VFOX_VERSION` | `latest main` | Space-separated vfox channels (`latest`, `main`) | +| `DISTRIBUTION` | see matrix above | Space-separated plugin distributions, `-fx` for JavaFX (`tem zulu-fx`) | +| `JAVA_VERSION` | per distribution | Release installed for every selected distribution (Java 9+ numbering) | +| `E2E_MAX_JOBS` | `3` (Linux), `2` (Windows) | Parallel containers per host | +| `E2E_RETRY_ATTEMPTS` | `3` | Attempts per network-touching exec (linear 10s backoff) | + +`JAVA_VERSION` must be a release `java.version` reports verbatim (e.g. +`21.0.8`, not Java 8's `1.8.0_462` scheme). Distributions outside the +default matrix still run every check except the `java.vendor` assertion and +the asdf-style `PreUse` spelling, which need facts the suite only keeps for +the defaults (`tests/e2e/common`). + +## Linux (Ubuntu 26.04) + +Inside the dev container, from the repository root: + +```bash +cd tests/e2e +go test -v -timeout 55m -run TestE2E ./linux +``` + +Scope it down while iterating the same way: + +```bash +VFOX_VERSION=latest DISTRIBUTION=tem E2E_MAX_JOBS=1 go test -v -timeout 55m -run TestE2E ./linux +``` + +On a native host the same command works with two prerequisites — Docker +Engine (rootful or rootless) and Go 1.27.1 — which is exactly what CI +provisions. Rootless Docker users point the Docker client at their socket +(`export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/docker.sock"`); the dev +container's socket mount covers that case for you. + +## Windows 11 + +Windows containers cannot be emulated across architectures, so the ARM64 +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. + +The `windows-11-arm` runner has no Windows Hypervisor Platform feature, so +Hyper-V isolation is unavailable; process isolation needs only the +Containers feature. + +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). +Only vfox `main` carries the fix for such paths +([vfox#712](https://github.com/version-fox/vfox/pull/712)), so `latest` +combinations still run with an unspaced home. + +### x64 + +All commands below run in PowerShell 7. If it is not installed yet, install +it first (this single bootstrap command may run in the built-in Windows +PowerShell): + +```powershell +winget install --id Microsoft.PowerShell --source winget --exact +``` + +1. Execute in PowerShell 7, + +```powershell +winget install --id Git.Git --source winget --exact +# Configure Git appropriately. + +curl.exe -fsSL ` + "https://raw.githubusercontent.com/microsoft/Windows-Containers/Main/helpful_tools/Install-DockerCE/install-docker-ce.ps1" ` + -o ./install-docker-ce.ps1 +``` + +2. Execute in PowerShell 7 with administrator privileges, + +```powershell +./install-docker-ce.ps1 +``` + +3. Execute in PowerShell 7, + +```powershell +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-x86_64.exe" ` + -o "$env:USERPROFILE\.docker\cli-plugins\docker-compose.exe" + +git clone git@github.com:version-fox/vfox-java.git +cd ./vfox-java/ +docker compose -f tests/e2e/windows/compose.amd64.yaml run --rm --build e2e +``` + +### arm64 + +All commands below run in PowerShell 7. If it is not installed yet, install +it first (this single bootstrap command may run in the built-in Windows +PowerShell): + +```powershell +winget install --id Microsoft.PowerShell --source winget --exact +``` + +1. Execute in PowerShell 7, + +```powershell +winget install --id Git.Git --source winget --exact +# Configure Git appropriately. +winget install --id GoLang.Go --source winget --exact +git clone git@github.com:version-fox/vfox-java.git +cd ./vfox-java/ +pwsh -NoProfile -File .\tests\e2e\windows\install-docker-arm64.ps1 +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 -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 +`$env:VFOX_VERSION = 'latest'; $env:DISTRIBUTION = 'tem'` in front of the +compose command, as on the other platforms. + +## Local checks + +Execute from the dev container — everything below is preinstalled and pinned +to the versions the workflows use — or provision the same tools on a host. + +```bash +# Lua and Go checks (.github/workflows/lint.yml). +cd tests/e2e +go test -short ./... +test -z "$(gofmt -l .)" +go vet ./... +cd ../.. + +stylua --check . +lua-language-server --check . --checklevel=Warning +``` + +```bash +# Offline hook tests (.github/workflows/test.yml). +go run github.com/yuin/gopher-lua/cmd/glua@v1.1.1 tests/pre_use_test.lua +go run github.com/yuin/gopher-lua/cmd/glua@v1.1.1 tests/parse_legacy_file_test.lua +go run github.com/yuin/gopher-lua/cmd/glua@v1.1.1 tests/loongnix_test.lua +go run github.com/yuin/gopher-lua/cmd/glua@v1.1.1 tests/post_install_test.lua +``` + +```bash +# License headers (.github/workflows/license.yml). +license-eye header check +license-eye header fix +addlicense -check -f .license.tpl tests/e2e/windows/*.ps1 +addlicense -f .license.tpl tests/e2e/windows/*.ps1 +``` + +On a Docker-only host the license checks run as containers instead (the same +images CI uses): + +```bash +docker run --rm -v "$PWD:/work" -w /work apache/skywalking-eyes:0.9.0 header check +docker run --rm -v "$PWD:/work" -w /work apache/skywalking-eyes:0.9.0 header fix +docker run --rm -v "$PWD:/src" ghcr.io/google/addlicense:v1.2.0 -check -f .license.tpl tests/e2e/windows/*.ps1 +docker run --rm -v "$PWD:/src" ghcr.io/google/addlicense:v1.2.0 -f .license.tpl tests/e2e/windows/*.ps1 +``` diff --git a/hooks/available.lua b/hooks/available.lua index 8f5a7c0..5e73e20 100644 --- a/hooks/available.lua +++ b/hooks/available.lua @@ -1,3 +1,17 @@ +-- 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. + local foojay = require("foojay") local loongnix = require("loongnix") local distribution_version_parser = require("distribution_version") @@ -14,7 +28,7 @@ function PLUGIN:Available(ctx) error("Foojay does not support loong64; use vfox search java loongnix") end local jdks = {} - local distribution = nil + local short_name = nil if query == "all" then for _, dist in ipairs(distribution_version_parser.distributions) do @@ -28,10 +42,11 @@ function PLUGIN:Available(ctx) end end else - distribution = distribution_version_parser.parse_distribution(query) + local distribution = distribution_version_parser.parse_distribution(query) if not distribution then error("Unsupported distribution: " .. query) end + short_name = distribution.short_name jdks = foojay.fetchtJdkList(distribution.name, "") end @@ -40,19 +55,19 @@ function PLUGIN:Available(ctx) for _, jdk in ipairs(jdks) do local v = jdk.java_version local short = jdk.short - + -- Add -fx suffix for JavaFX bundled versions (after distribution name) local fx_suffix = "" if jdk.javafx_bundled == true then fx_suffix = "-fx" end - + if query == "all" then v = v .. "-" .. short .. fx_suffix elseif query == "open" then v = v .. fx_suffix else - v = v .. "-" .. distribution.short_name .. fx_suffix + v = v .. "-" .. short_name .. fx_suffix end if not seen[v] then @@ -64,10 +79,9 @@ function PLUGIN:Available(ctx) end table.insert(result, { version = v, - note = note + note = note, }) end - end return result end diff --git a/hooks/env_keys.lua b/hooks/env_keys.lua index 0dab808..1f19a26 100644 --- a/hooks/env_keys.lua +++ b/hooks/env_keys.lua @@ -1,3 +1,17 @@ +-- 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. + local strings = require("vfox.strings") --- Each SDK may have different environment variable configurations. --- This allows plugins to define custom environment variables (including PATH settings) @@ -9,18 +23,18 @@ function PLUGIN:EnvKeys(ctx) local envTable = { { key = "JAVA_HOME", - value = path + value = path, }, { key = "PATH", - value = path .. "/bin" - } + value = path .. "/bin", + }, } if shortname == "graal" or shortname == "graalce" then table.insert(envTable, { key = "GRAALVM_HOME", - value = path + value = path, }) end return envTable -end \ No newline at end of file +end diff --git a/hooks/parse_legacy_file.lua b/hooks/parse_legacy_file.lua index bacfd6d..9783ff9 100644 --- a/hooks/parse_legacy_file.lua +++ b/hooks/parse_legacy_file.lua @@ -1,3 +1,17 @@ +-- 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. + local strings = require("vfox.strings") local distribution_version_parser = require("distribution_version") @@ -21,6 +35,9 @@ function PLUGIN:ParseLegacyFile(ctx) end local distributionVersion = distribution_version_parser.parse_version(javaVersion) + if not distributionVersion then + return {} + end local fullVersion = distributionVersion.version local vendor = distributionVersion.distribution.short_name if not vendor then @@ -34,8 +51,9 @@ function PLUGIN:ParseLegacyFile(ctx) local majorVersion = strings.split(version, ".")[1] --- Get the list of versions of the current plugin installed + --- (a plain call: vfox 1.x rejects the extra ctx argument of ctx:...). local targetVersion - local versions = ctx:getInstalledVersions() + local versions = ctx.getInstalledVersions() for _, v in ipairs(versions) do --- It may be exactly the same, try it first. if v == javaVersion then @@ -58,7 +76,6 @@ function PLUGIN:ParseLegacyFile(ctx) if not targetVersion then for _, v in ipairs(versions) do - if vendor == "open" and not strings.contains(v, "-") and strings.has_prefix(v, majorVersion .. ".") then targetVersion = v break @@ -74,6 +91,6 @@ function PLUGIN:ParseLegacyFile(ctx) end return { - version = targetVersion + version = targetVersion, } end diff --git a/hooks/post_install.lua b/hooks/post_install.lua index 8ce3fba..a22b7ea 100644 --- a/hooks/post_install.lua +++ b/hooks/post_install.lua @@ -1,3 +1,17 @@ +-- 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. + local function shellQuote(value) return "'" .. value:gsub("'", "'\\''") .. "'" end @@ -54,7 +68,7 @@ for entry in "$home"/* "$home"/.[!.]* "$home"/..?*; do done ]] print("Checking Java home layout...") - if os.execute('sh -c ' .. shellQuote(script) .. ' sh ' .. shellQuote(path)) ~= 0 then - error('Failed to normalize Java home: ' .. path) + if os.execute("sh -c " .. shellQuote(script) .. " sh " .. shellQuote(path)) ~= 0 then + error("Failed to normalize Java home: " .. path) end end diff --git a/hooks/pre_install.lua b/hooks/pre_install.lua index 33234bf..ddb2920 100644 --- a/hooks/pre_install.lua +++ b/hooks/pre_install.lua @@ -1,13 +1,29 @@ +-- 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. + local http = require("http") local json = require("json") local foojay = require("foojay") local loongnix = require("loongnix") local distribution_version_parser = require("distribution_version") +--- @class PreInstallContext +--- @field version string User-input version + --- Returns some pre-installed information, such as version number, download address, local files, etc. --- If checksum is provided, vfox will automatically check it for you. ---- @param ctx table ---- @field ctx.version string User-input version +--- @param ctx PreInstallContext --- @return table Version information function PLUGIN:PreInstall(ctx) local distribution_version = distribution_version_parser.parse_version(ctx.version) @@ -19,9 +35,17 @@ function PLUGIN:PreInstall(ctx) end local jdks = foojay.fetchtJdkList(distribution_version.distribution.name, distribution_version.version) if not jdks or #jdks == 0 then - error("No JDK found for " .. ctx.version .. " on " .. RUNTIME.osType .. "/" .. RUNTIME.archType .. ". Please check available versions with 'vfox search java'") + error( + "No JDK found for " + .. ctx.version + .. " on " + .. RUNTIME.osType + .. "/" + .. RUNTIME.archType + .. ". Please check available versions with 'vfox search java'" + ) end - + -- Filter JDKs based on JavaFX requirement local filtered_jdks = {} for _, jdk in ipairs(jdks) do @@ -30,12 +54,21 @@ function PLUGIN:PreInstall(ctx) table.insert(filtered_jdks, jdk) end end - + if #filtered_jdks == 0 then local fx_msg = distribution_version.javafx_bundled and " with JavaFX" or " without JavaFX" - error("No JDK found for " .. ctx.version .. fx_msg .. " on " .. RUNTIME.osType .. "/" .. RUNTIME.archType .. ". Please check available versions with 'vfox search java'") + error( + "No JDK found for " + .. ctx.version + .. fx_msg + .. " on " + .. RUNTIME.osType + .. "/" + .. RUNTIME.archType + .. ". Please check available versions with 'vfox search java'" + ) end - + local jdk = filtered_jdks[1] if not jdk.links or not jdk.links.pkg_info_uri then error("Invalid JDK package info: missing download links for " .. ctx.version) @@ -46,13 +79,13 @@ function PLUGIN:PreInstall(ctx) -- if checksum == "" and info.checksum_uri ~= "" then -- checksum = httpGet(info.checksum_uri, "Failed to fetch checksum") -- end - + -- Build final version string with fx suffix if needed (after distribution name) local fx_suffix = "" if jdk.javafx_bundled == true then fx_suffix = "-fx" end - + local finalV if distribution_version.distribution.short_name == "open" then finalV = jdk.java_version .. fx_suffix @@ -63,13 +96,13 @@ function PLUGIN:PreInstall(ctx) -- [info.checksum_type] = checksum, url = info.direct_download_uri, version = finalV, - note = jdk.major_version + note = jdk.major_version, } end function httpGet(url, errMsg) local resp, err = http.get({ - url = url + url = url, }) if err ~= nil then error(errMsg .. ": " .. err) diff --git a/hooks/pre_use.lua b/hooks/pre_use.lua index e6f3644..21fa798 100644 --- a/hooks/pre_use.lua +++ b/hooks/pre_use.lua @@ -1,21 +1,38 @@ +-- 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. + local distribution_version_parser = require("distribution_version") function PLUGIN:PreUse(ctx) local input_version = ctx.version local distribution_version = distribution_version_parser.parse_version(input_version) - + -- If it is a pure numeric major version (such as "8"), and the distribution is openjdk, let the core perform fuzzy matching. if distribution_version and distribution_version.distribution.short_name == "open" then - if not string.find(distribution_version.version, "%.") then -- Pure numbers, no minor versions - return "" -- Return empty, let vfox core perform fuzzy matching + if not string.find(distribution_version.version, "%.") then -- Pure numbers, no minor versions + -- Return no result, let vfox core perform fuzzy matching. Not "": vfox + -- after 1.0.12 rejects a bare string ("cannot unmarshal string"). + return nil end end - if distribution_version and distribution_version.distribution.short_name ~= "open" then return { - version = distribution_version.version .. "-" .. distribution_version.distribution.short_name - .. (distribution_version.javafx_bundled and "-fx" or "") + version = distribution_version.version + .. "-" + .. distribution_version.distribution.short_name + .. (distribution_version.javafx_bundled and "-fx" or ""), } else return { diff --git a/lib/distribution_version.lua b/lib/distribution_version.lua index 9b2e799..6671acf 100644 --- a/lib/distribution_version.lua +++ b/lib/distribution_version.lua @@ -1,3 +1,17 @@ +-- 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. + local strings = require("vfox.strings") local short_name = { @@ -23,40 +37,39 @@ local short_name = { ["jb"] = "jetbrains", } -local long_name={} -local distribution_version={ - distributions={} +local long_name = {} +local distribution_version = { + distributions = {}, } -for k,v in pairs(short_name) do - long_name[v]=k - table.insert(distribution_version.distributions,{ +for k, v in pairs(short_name) do + long_name[v] = k + table.insert(distribution_version.distributions, { name = v, - short_name = k + short_name = k, }) end -function distribution_version.parse_distribution (name) - local distribution_name=long_name[name] +function distribution_version.parse_distribution(name) + local distribution_name = long_name[name] if distribution_name then -- already a valid long name, just return it return { name = name, - short_name = distribution_name + short_name = distribution_name, } end - distribution_name=short_name[name] + distribution_name = short_name[name] if distribution_name then -- already a valid short name, just return it return { name = distribution_name, - short_name = name + short_name = name, } end return nil end - --- Converts user-input version format to Foojay API format --- Examples: "26.ea.1" -> "26-ea+1", "26.ea" -> "26-ea", "25.0.3" -> "25.0.3" --- @param version string User-input version @@ -67,18 +80,18 @@ function distribution_version.convert_version_for_api(version) if ea_version and ea_build then return ea_version .. "-ea+" .. ea_build end - + -- Handle EA version format without build number: "X.ea" -> "X-ea" local ea_version_only = version:match("^(%d+)%.ea$") if ea_version_only then return ea_version_only .. "-ea" end - + -- Return as-is for normal versions return version end -function distribution_version.parse_version (arg) +function distribution_version.parse_version(arg) local version_parts = strings.split(arg, "-") local version local distribution @@ -95,7 +108,7 @@ function distribution_version.parse_version (arg) distribution = distribution_version.parse_distribution(version_parts[1]) if not distribution then -- no valid distribution found, handle as version with distribution "openjdk" - version=version_parts[1] + version = version_parts[1] distribution = distribution_version.parse_distribution("openjdk") end else @@ -103,13 +116,13 @@ function distribution_version.parse_version (arg) distribution = distribution_version.parse_distribution(version_parts[2]) if distribution then -- valid distribution found, treat first part as version - version=version_parts[1] + version = version_parts[1] else -- check if the distribution is in the first part of the string (asdf default) distribution = distribution_version.parse_distribution(version_parts[1]) if distribution then -- valid distribution found, treat second part as version - version=version_parts[2] + version = version_parts[2] else -- invalid distribution name return nil diff --git a/lib/foojay.lua b/lib/foojay.lua index 3b6eb89..ffc56fb 100644 --- a/lib/foojay.lua +++ b/lib/foojay.lua @@ -1,10 +1,24 @@ +-- 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. + local http = require("http") local json = require("json") local foojay = {} local URL = -"https://api.foojay.io/disco/v3.0/packages/jdks?version=%s&distribution=%s&architecture=%s&archive_type=%s&operating_system=%s&lib_c_type=%s&directly_downloadable=true" + "https://api.foojay.io/disco/v3.0/packages/jdks?version=%s&distribution=%s&architecture=%s&archive_type=%s&operating_system=%s&lib_c_type=%s&directly_downloadable=true" --- Detects the libc type on Linux systems (glibc or musl) --- @return string "glibc" or "musl" @@ -20,8 +34,7 @@ local function detect_lib_c_type() return "glibc" end -foojay.fetchtJdkList= function (distribution, version) - +foojay.fetchtJdkList = function(distribution, version) local os = RUNTIME.osType local arch = RUNTIME.archType -- Convert arm64 to aarch64 for foojay API compatibility @@ -36,7 +49,7 @@ foojay.fetchtJdkList= function (distribution, version) local archive_type = "tar.gz" if os == "windows" then archive_type = "zip" - if (arch == "amd64") then + if arch == "amd64" then arch = "x64" end end @@ -49,7 +62,7 @@ foojay.fetchtJdkList= function (distribution, version) local reqUrl = URL:format(version, distribution, arch, archive_type, os, lib_c_type) local resp, err = http.get({ - url = reqUrl + url = reqUrl, }) if err ~= nil then error("Failed to fetch jdk info from foojay: " .. err) @@ -66,7 +79,7 @@ foojay.fetchtJdkList= function (distribution, version) archive_type = "tar.gz" reqUrl = URL:format(version, distribution, arch, archive_type, os, lib_c_type) resp, err = http.get({ - url = reqUrl + url = reqUrl, }) if err ~= nil then error("Failed to fetch jdk info from foojay: " .. err) diff --git a/lib/loongnix.lua b/lib/loongnix.lua index 8f8d7b6..30982c2 100644 --- a/lib/loongnix.lua +++ b/lib/loongnix.lua @@ -1,79 +1,122 @@ -local http = require('http') +-- 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. + +local http = require("http") local loongnix = {} -local base_url = 'https://ftp.loongnix.cn/Java/' +local base_url = "https://ftp.loongnix.cn/Java/" function loongnix.supported() - return RUNTIME.osType == 'linux' and RUNTIME.archType == 'loong64' + return RUNTIME.osType == "linux" and RUNTIME.archType == "loong64" end function loongnix.is_distribution(name) - return name == 'lnxabi1' or name == 'lnxabi2' - or name == 'loongnix_abi1' or name == 'loongnix_abi2' + return name == "lnxabi1" or name == "lnxabi2" or name == "loongnix_abi1" or name == "loongnix_abi2" end local function require_platform() if not loongnix.supported() then - error('Loongnix JDKs require linux/loong64') + error("Loongnix JDKs require linux/loong64") end end local function get(url) - local response, err = http.get({url = url}) - if err then error('Failed to fetch Loongnix JDKs: ' .. err) end + local response, err = http.get({ url = url }) + if err then + error("Failed to fetch Loongnix JDKs: " .. err) + end if response.status_code ~= 200 then - error('Failed to fetch Loongnix JDKs: status_code => ' .. response.status_code) + error("Failed to fetch Loongnix JDKs: status_code => " .. response.status_code) end return response.body end local function numbers(version) local result = {} - for number in version:gmatch('%d+') do table.insert(result, tonumber(number)) end + for number in version:gmatch("%d+") do + table.insert(result, tonumber(number)) + end return result end local function newer(left, right) local a, b = numbers(left), numbers(right) for i = 1, math.max(#a, #b) do - if (a[i] or 0) ~= (b[i] or 0) then return (a[i] or 0) > (b[i] or 0) end + if (a[i] or 0) ~= (b[i] or 0) then + return (a[i] or 0) > (b[i] or 0) + end end return false end local function newer_java(left, right) - local av, ab = left:match('^(.-)%+(%d+)$') - local bv, bb = right:match('^(.-)%+(%d+)$') - if newer(av, bv) then return true end - if newer(bv, av) then return false end + local av, ab = left:match("^(.-)%+(%d+)$") + local bv, bb = right:match("^(.-)%+(%d+)$") + if newer(av, bv) then + return true + end + if newer(bv, av) then + return false + end return tonumber(ab) > tonumber(bb) end -- Parse only GA LoongArch tarballs; exclude MIPS, EA, installers and checksum files. local function parse(filename, major) - local release, rest = filename:match('^loongson([%d%.]+)%-(.+)$') - if not release then release, rest = filename:match('^loongson_openjdk([%d%.]+)%-(.+)$') end - if not release then return nil end - local fx = rest:sub(1, 3) == 'fx-' - if fx then rest = rest:sub(4) end - local jdk, suffix = rest:match('^jdk([%w%._]+)%-linux%-loongarch64(.*)$') + local release, rest = filename:match("^loongson([%d%.]+)%-(.+)$") + if not release then + release, rest = filename:match("^loongson_openjdk([%d%.]+)%-(.+)$") + end + if not release then + return nil + end + local fx = rest:sub(1, 3) == "fx-" + if fx then + rest = rest:sub(4) + end + local jdk, suffix = rest:match("^jdk([%w%._]+)%-linux%-loongarch64(.*)$") local abi - if suffix == '.tar.gz' then abi = 'lnxabi1' - elseif suffix == '-glibc2.34.tar.gz' then abi = 'lnxabi2' - else return nil end - local version, build = jdk:match('^(%d+%.?[%d%.]*)_(%d+)$') + if suffix == ".tar.gz" then + abi = "lnxabi1" + elseif suffix == "-glibc2.34.tar.gz" then + abi = "lnxabi2" + else + return nil + end + local version, build = jdk:match("^(%d+%.?[%d%.]*)_(%d+)$") if not version then local update - update, build = jdk:match('^8u(%d+)b(%d+)$') - if update then version = '8.0.' .. update end + update, build = jdk:match("^8u(%d+)b(%d+)$") + if update then + version = "8.0." .. update + end end - if not version or tonumber(version:match('^%d+')) ~= tonumber(major) then return nil end - version = version .. '+' .. tonumber(build) + if not version or tonumber(version:match("^%d+")) ~= tonumber(major) then + return nil + end + version = version .. "+" .. tonumber(build) return { - version = version .. '-' .. abi .. (fx and '-fx' or ''), - java_version = version, release = release, abi = abi, fx = fx, - url = base_url .. 'openjdk' .. major .. '/' .. filename, - note = 'JDK ' .. version .. '; ' .. (abi == 'lnxabi1' and 'ABI1 (Linux 4.19 UAPI)' or 'ABI2 (Linux 5.10 UAPI, glibc 2.34)') - .. (fx and '; JavaFX' or ''), + version = version .. "-" .. abi .. (fx and "-fx" or ""), + java_version = version, + release = release, + abi = abi, + fx = fx, + url = base_url .. "openjdk" .. major .. "/" .. filename, + note = "JDK " + .. version + .. "; " + .. (abi == "lnxabi1" and "ABI1 (Linux 4.19 UAPI)" or "ABI2 (Linux 5.10 UAPI, glibc 2.34)") + .. (fx and "; JavaFX" or ""), } end @@ -81,8 +124,8 @@ local function packages(version) require_platform() local index = get(base_url) local majors, seen = {}, {} - local requested_major = version and version:match('^(%d+)') - for major in index:gmatch('href=["\']openjdk(%d+)/["\']') do + local requested_major = version and version:match("^(%d+)") + for major in index:gmatch("href=[\"']openjdk(%d+)/[\"']") do if not seen[major] and (not requested_major or tonumber(major) == tonumber(requested_major)) then seen[major] = true table.insert(majors, major) @@ -90,54 +133,66 @@ local function packages(version) end local result, by_version = {}, {} for _, major in ipairs(majors) do - local body = get(base_url .. 'openjdk' .. major .. '/') - for filename in body:gmatch('href=["\']([^"\'/]+)["\']') do + local body = get(base_url .. "openjdk" .. major .. "/") + for filename in body:gmatch("href=[\"']([^\"'/]+)[\"']") do local item = parse(filename, major) if item then local previous = by_version[item.version] - if not previous or newer(item.release, previous.release) then by_version[item.version] = item end + if not previous or newer(item.release, previous.release) then + by_version[item.version] = item + end end end end - for _, item in pairs(by_version) do table.insert(result, item) end + for _, item in pairs(by_version) do + table.insert(result, item) + end table.sort(result, function(a, b) - if a.java_version ~= b.java_version then return newer_java(a.java_version, b.java_version) end + if a.java_version ~= b.java_version then + return newer_java(a.java_version, b.java_version) + end return a.version < b.version end) - if #result == 0 then error('No Loongnix JDK packages found; check the requested version and upstream directory format') end + if #result == 0 then + error("No Loongnix JDK packages found; check the requested version and upstream directory format") + end return result end function loongnix.available(query) - local abi = query == 'loongnix_abi1' and 'lnxabi1' or query == 'loongnix_abi2' and 'lnxabi2' or query + local abi = query == "loongnix_abi1" and "lnxabi1" or query == "loongnix_abi2" and "lnxabi2" or query local result = {} for _, item in ipairs(packages()) do if not loongnix.is_distribution(abi) or item.abi == abi then - table.insert(result, {version = item.version, note = item.note}) + table.insert(result, { version = item.version, note = item.note }) end end return result end local function matches(candidate, requested) - if candidate == requested then return true end - if candidate:sub(1, #requested) ~= requested then return false end + if candidate == requested then + return true + end + if candidate:sub(1, #requested) ~= requested then + return false + end local boundary = candidate:sub(#requested + 1, #requested + 1) - return boundary == '.' or boundary == '+' + return boundary == "." or boundary == "+" end function loongnix.pre_install(parsed) require_platform() local abi = parsed.distribution.short_name if not loongnix.is_distribution(abi) then - error('Choose a Loongnix ABI explicitly: run vfox search java and select lnxabi1 or lnxabi2') + error("Choose a Loongnix ABI explicitly: run vfox search java and select lnxabi1 or lnxabi2") end for _, item in ipairs(packages(parsed.version)) do if item.abi == abi and item.fx == parsed.javafx_bundled and matches(item.java_version, parsed.version) then - return {version = item.version, url = item.url, note = item.note} + return { version = item.version, url = item.url, note = item.note } end end - error('No Loongnix JDK found for ' .. parsed.version .. '-' .. abi .. (parsed.javafx_bundled and '-fx' or '')) + error("No Loongnix JDK found for " .. parsed.version .. "-" .. abi .. (parsed.javafx_bundled and "-fx" or "")) end return loongnix diff --git a/metadata.lua b/metadata.lua index 22fa5dd..ee9d20d 100644 --- a/metadata.lua +++ b/metadata.lua @@ -1,3 +1,17 @@ +-- 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. + --- !!! DO NOT EDIT OR RENAME !!! PLUGIN = {} @@ -13,7 +27,6 @@ PLUGIN.license = "Apache 2.0" --- Plugin description PLUGIN.description = "Support for multiple JDK distributions, such as: Oracle, Graalvm, Eclipse & more." - --- !!! OPTIONAL !!! --[[ NOTE: @@ -24,7 +37,7 @@ NOTE: PLUGIN.minRuntimeVersion = "0.4.0" PLUGIN.legacyFilenames = { - '.sdkmanrc', + ".sdkmanrc", } -- Some things that need user to be attention! PLUGIN.notes = { @@ -34,6 +47,5 @@ PLUGIN.notes = { " - GraalVM: x.y.z-graal", " - Temurin: x.y.z-tem", " - Zulu: x.y.z-zulu", - "Others please see: https://github.com/version-fox/vfox-java" - + "Others please see: https://github.com/version-fox/vfox-java", } diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..254403c --- /dev/null +++ b/stylua.toml @@ -0,0 +1,19 @@ +# 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. + +column_width = 120 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 4 +quote_style = "AutoPreferDouble" diff --git a/tests/e2e/common/common.go b/tests/e2e/common/common.go new file mode 100644 index 0000000..6bc5000 --- /dev/null +++ b/tests/e2e/common/common.go @@ -0,0 +1,436 @@ +// 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 common holds the shared logic of the Linux and Windows container +// E2E suites. +// +// The two suites target different container systems (bash vs pwsh, different +// vfox installation and isolation), so the code interacting with containers +// stays in the linux / windows packages; the target-independent parts +// (matrix expansion, version defaults, distribution facts, output parsing, +// retry counts, name handling) live here and are reused by both platform +// packages, instead of being copied. +package common + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "regexp" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// DefaultVersions pins the Java release each default distribution installs, +// maintained in one place for both packages. Every pin is published through +// Foojay for each platform its distribution runs on (see +// DefaultDistributions), so all runners install the same release. +var DefaultVersions = map[string]string{ + // OpenJDK (jdk.java.net), the plugin's default distribution: its + // versions carry no distribution suffix. + "open": "25.0.2", + // Temurin: the common path (tar.gz on Linux, zip on Windows). + "tem": "21.0.8", + // Liberica with JavaFX: the -fx selection in PreInstall and PreUse. + "librca-fx": "25.0.4", + // JetBrains Runtime 21.0.5 ships no Windows zip, so on Windows it takes + // the tar.gz fallback in lib/foojay.lua (vfox-java#58). + "jb": "21.0.5", + // GraalVM Community: the GRAALVM_HOME branch of hooks/env_keys.lua. + "graalce": "25.0.2", +} + +// distribution holds the facts the verifiers assert per distribution, keyed +// by the plugin's short name without the -fx suffix. +type distribution struct { + // longName is the Foojay name the hooks accept in place of the short + // one (lib/distribution_version.lua). + longName string + // vendor is the java.vendor property the distribution's builds report. + vendor string +} + +var distributions = map[string]distribution{ + "open": {longName: "openjdk", vendor: "Oracle Corporation"}, + "tem": {longName: "temurin", vendor: "Eclipse Adoptium"}, + "librca": {longName: "liberica", vendor: "BellSoft"}, + "jb": {longName: "jetbrains", vendor: "JetBrains s.r.o."}, + "graalce": {longName: "graalvm_community", vendor: "GraalVM Community"}, +} + +// PassThroughEnvs are host env knobs recorded in each target container's +// environment, so `docker inspect` shows what a combo ran with. Forwarded +// only when set, so unset stays default. +var PassThroughEnvs = []string{ + "JAVA_VERSION", + "E2E_RETRY_ATTEMPTS", +} + +// Combo is one matrix cell: a vfox version and a JDK distribution. +type Combo struct { + Vfox string + Distribution string +} + +// Prefix renders the log prefix: "vfox , , ". +func (c Combo) Prefix(platform string) string { + return fmt.Sprintf("vfox %s, %s, %s", c.Vfox, c.Distribution, platform) +} + +// Slug renders a docker-safe container name fragment for the combo. +func (c Combo) Slug() string { + return c.Vfox + "-" + Sanitize(c.Distribution) +} + +// Sanitize replaces every non-ASCII-alphanumeric byte with '_' so arbitrary +// distribution names stay valid in container names. +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 uname-style names, Windows PROCESSOR_ARCHITECTURE values +// and common aliases to docker arch names. +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 reports the runner architecture: $PROCESSOR_ARCHITECTURE on +// Windows hosts (AMD64/ARM64/x86), else the test binary's own arch. There is +// deliberately no override knob: emulated runs (arm64 on an x64 host) are +// not supported, and CI covers each arch on native runners. +func DetectArch() (string, error) { + 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. DISTRIBUTION="tem jb"). +func Fields(s string) []string { + return strings.Fields(s) +} + +// DefaultDistributions picks the distributions per runner architecture. +// arm64 runs the three Foojay publishes for both linux-aarch64 and +// windows-aarch64; OpenJDK and GraalVM Community ship no windows-aarch64 +// build, so they only join on amd64. +func DefaultDistributions(arch string) []string { + if arch == "arm64" { + return []string{"tem", "librca-fx", "jb"} + } + return []string{"open", "tem", "librca-fx", "jb", "graalce"} +} + +// Matrix expands the vfox x distribution combinations for the runner +// architecture. Env overrides: VFOX_VERSION (default "latest main") and +// DISTRIBUTION (default per DefaultDistributions). +func Matrix() ([]Combo, error) { + arch, err := DetectArch() + if err != nil { + return nil, err + } + return MatrixForArch(arch) +} + +// MatrixForArch expands the matrix for an explicit arch. Tests use it +// directly so each arch branch stays covered on any host. +func MatrixForArch(arch string) ([]Combo, error) { + foxes := []string{"latest", "main"} + if v, ok := os.LookupEnv("VFOX_VERSION"); ok && len(Fields(v)) > 0 { + foxes = Fields(v) + } + dists := DefaultDistributions(arch) + if v, ok := os.LookupEnv("DISTRIBUTION"); ok && len(Fields(v)) > 0 { + dists = Fields(v) + } + + var combos []Combo + for _, vfox := range foxes { + for _, dist := range dists { + combos = append(combos, Combo{Vfox: vfox, Distribution: dist}) + } + } + return combos, nil +} + +// MaxJobs bounds parallel containers; honours $E2E_MAX_JOBS and falls back +// to def when unset or invalid. Linux passes 3, Windows passes 2. +func MaxJobs(def int) 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 def +} + +// RetryAttempts caps exec retries; honours $E2E_RETRY_ATTEMPTS (default 3). +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 +} + +// ResolveJavaVersion honours $JAVA_VERSION (applied to every distribution +// of the run), else the distribution's pin in DefaultVersions. +func ResolveJavaVersion(t *testing.T, distribution string) string { + t.Helper() + if v := os.Getenv("JAVA_VERSION"); v != "" { + return v + } + v, ok := DefaultVersions[distribution] + if !ok { + t.Fatalf("no pinned Java version for distribution %s; set JAVA_VERSION", distribution) + } + return v +} + +// Base strips the -fx suffix: "librca-fx" -> "librca". +func Base(distribution string) string { + return strings.TrimSuffix(distribution, "-fx") +} + +// JavaFX reports whether the distribution asks for a JavaFX bundled build. +func JavaFX(distribution string) bool { + return strings.HasSuffix(distribution, "-fx") +} + +// Spec renders the version argument the plugin documents for a release: +// "[-fx]" for OpenJDK, the default distribution, and +// "-" otherwise (e.g. "25.0.4-librca-fx"). +func Spec(distribution, version string) string { + if Base(distribution) == "open" { + return version + strings.TrimPrefix(distribution, "open") + } + return version + "-" + distribution +} + +// MissingSpec renders a release in the version's line that no distribution +// has published (.0.99): Foojay answers it with an empty package +// list, so PreInstall must fail naming the platform it searched. +func MissingSpec(distribution, version string) string { + return Spec(distribution, Major(version)+".0.99") +} + +// Major returns the leading version component: "21.0.8" -> "21". +func Major(version string) string { + return strings.SplitN(version, ".", 2)[0] +} + +// LegacyIdentifier renders the SDKMAN-style identifier a .sdkmanrc holds for +// a release: "-", without the build number. +func LegacyIdentifier(distribution, version string) string { + return version + "-" + Base(distribution) +} + +// Alias renders the asdf-style spelling "-[-fx]" of an +// installed version, which PreUse must rewrite back to installed. OpenJDK +// has none: its versions carry no suffix for PreUse to rewrite. +func Alias(distribution, installed string) (string, bool) { + d, ok := distributions[Base(distribution)] + if !ok || Base(distribution) == "open" { + return "", false + } + core, found := strings.CutSuffix(installed, "-"+distribution) + if !found { + return "", false + } + alias := d.longName + "-" + core + if JavaFX(distribution) { + alias += "-fx" + } + return alias, true +} + +// Vendor returns the java.vendor property the distribution's builds report, +// when the suite knows it. +func Vendor(distribution string) (string, bool) { + d, ok := distributions[Base(distribution)] + return d.vendor, ok +} + +// SetsGraalVMHome mirrors hooks/env_keys.lua: GraalVM distributions also +// export GRAALVM_HOME. +func SetsGraalVMHome(distribution string) bool { + switch Base(distribution) { + case "graal", "graalce": + return true + } + return false +} + +// SearchArgs renders the `vfox search java` arguments listing the +// distribution: none for OpenJDK (Available's default query), else the +// short name, which lists the JavaFX builds too. +func SearchArgs(distribution string) string { + if Base(distribution) == "open" { + return "" + } + return " " + Base(distribution) +} + +// JavaArch maps the docker architecture to the os.arch property a JVM built +// for it reports. An x64 JDK emulated on Windows ARM64 reports amd64, so the +// check also catches the wrong package being installed. +func JavaArch(arch string) string { + if arch == "arm64" { + return "aarch64" + } + return arch +} + +// javaProperty matches one `java -XshowSettings:properties` entry. Values +// spanning several lines (java.library.path and friends) continue on deeper +// indented lines, which the pattern skips. +var javaProperty = regexp.MustCompile(`^ {4}(\S+) = (.*)$`) + +// JavaProperties parses `java -XshowSettings:properties -version` output +// into a map; multi-line values keep their first line. +func JavaProperties(output string) map[string]string { + props := map[string]string{} + for _, line := range strings.Split(StripANSI(output), "\n") { + if m := javaProperty.FindStringSubmatch(strings.TrimRight(line, "\r")); m != nil { + props[m[1]] = m[2] + } + } + return props +} + +// CheckJava parses `java -XshowSettings:properties -version` output and +// asserts the JVM is the requested release (java.version), built for the +// runner (os.arch), by the distribution's vendor (java.vendor, when known). +// It returns the properties for further checks. +func CheckJava(t *testing.T, output, distribution, version, arch, what string) map[string]string { + t.Helper() + props := JavaProperties(output) + require.Equalf(t, version, props["java.version"], "%s: java.version\n--- output ---\n%s", what, output) + require.Equalf(t, JavaArch(arch), props["os.arch"], + "%s: os.arch (the %s package must be installed)\n--- output ---\n%s", what, arch, output) + if vendor, ok := Vendor(distribution); ok { + require.Equalf(t, vendor, props["java.vendor"], "%s: java.vendor\n--- output ---\n%s", what, output) + } + return props +} + +// HelloJava is the program both suites compile with javac and run with +// java; it prints HelloOutput for the running JVM. +const HelloJava = `public class Hello { + public static void main(String[] args) { + System.out.println("hello " + System.getProperty("java.version") + " " + System.getProperty("os.arch")); + } +} +` + +// HelloOutput is what HelloJava prints on the requested release and arch. +func HelloOutput(version, arch string) string { + return "hello " + version + " " + JavaArch(arch) +} + +// ansiEscape matches the SGR colour sequences pterm emits even when it +// writes to a pipe. +var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// StripANSI removes SGR colour sequences from command output. +func StripANSI(s string) string { + return ansiEscape.ReplaceAllString(s, "") +} + +// installSuccess matches the "Install java@ success!" line vfox +// prints with the final version PreInstall returned. +var installSuccess = regexp.MustCompile(`Install java@(\S+) success!`) + +// InstalledVersion extracts the version vfox recorded from `vfox install` +// output (e.g. 21.0.8+9-tem for 21.0.8-tem), or "" when the line is absent. +func InstalledVersion(output string) string { + if m := installSuccess.FindStringSubmatch(StripANSI(output)); m != nil { + return m[1] + } + return "" +} + +// unpackedTarball matches the "Unpacking ..." line vfox prints +// before extracting a .tar.gz. +var unpackedTarball = regexp.MustCompile(`(?m)^Unpacking .*\.tar\.gz\.\.\.\r?$`) + +// FromTarball reports whether `vfox install` output unpacked a .tar.gz, i.e. +// on Windows the JDK came from lib/foojay.lua's tar.gz fallback. +func FromTarball(output string) bool { + return unpackedTarball.MatchString(StripANSI(output)) +} + +// buildNumber matches the "+" part of a Foojay java_version. +var buildNumber = regexp.MustCompile(`\+[^-]*`) + +// CheckInstalled extracts the version vfox recorded from `vfox install` +// output and asserts PreInstall named it after the requested spec plus at +// most a build number (21.0.8-tem -> 21.0.8+9-tem, 25.0.2 -> 25.0.2+10), +// the same name `vfox search` lists. +func CheckInstalled(t *testing.T, output, spec string) string { + t.Helper() + installed := InstalledVersion(output) + require.NotEmptyf(t, installed, "vfox install java@%s did not report the installed version\n--- output ---\n%s", spec, output) + require.Equalf(t, spec, buildNumber.ReplaceAllString(installed, ""), + "java@%s was installed as %s, not as the requested release plus a build number", spec, installed) + return installed +} + +// SearchEntry finds the `vfox search` entry listing exactly version +// (" - [ ()][ (installed)]") and returns it without the +// leading " - ". +func SearchEntry(output, version string) (string, bool) { + for _, line := range strings.Split(StripANSI(output), "\n") { + entry, ok := strings.CutPrefix(strings.TrimRight(line, "\r"), " - ") + if ok && (entry == version || strings.HasPrefix(entry, version+" ")) { + return entry, true + } + } + return "", false +} + +// 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. +func ContainerName(slug string) string { + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("vfox-java-e2e-%s-%d", slug, time.Now().UnixNano()) + } + return "vfox-java-e2e-" + slug + "-" + hex.EncodeToString(b[:]) +} diff --git a/tests/e2e/common/common_test.go b/tests/e2e/common/common_test.go new file mode 100644 index 0000000..22a871a --- /dev/null +++ b/tests/e2e/common/common_test.go @@ -0,0 +1,335 @@ +// 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 common + +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.Distribution) + } + return keys +} + +// Default amd64 matrix: every distribution against both vfox channels. +func TestMatrixDefaultAmd64(t *testing.T) { + t.Setenv("VFOX_VERSION", "") + t.Setenv("DISTRIBUTION", "") + + // t.Setenv sets the var to "" which LookupEnv reports as set; the empty + // values above exercise the real defaults via Fields dropping empties. + combos, err := MatrixForArch("amd64") + if err != nil { + t.Fatal(err) + } + t.Logf("combos: %v", comboKeys(combos)) + if want := 2 * len(DefaultDistributions("amd64")); len(combos) != want { + t.Fatalf("combos = %v, want %d", comboKeys(combos), want) + } +} + +// arm64 defaults: only distributions published for windows-aarch64 too. +func TestMatrixDefaultArm64(t *testing.T) { + setEnv(t, map[string]string{ + "VFOX_VERSION": "latest main", + "DISTRIBUTION": "", + }) + + combos, err := MatrixForArch("arm64") + if err != nil { + t.Fatal(err) + } + if len(combos) == 0 { + t.Fatal("empty matrix") + } + for _, c := range combos { + switch c.Distribution { + case "open", "graalce": + t.Errorf("arm64 runs %s, which has no windows-aarch64 build", c.Distribution) + } + } +} + +// Every default distribution has a pinned version. +func TestDefaultVersionsCoverDefaults(t *testing.T) { + for _, arch := range []string{"amd64", "arm64"} { + for _, d := range DefaultDistributions(arch) { + if _, ok := DefaultVersions[d]; !ok { + t.Errorf("%s default distribution %s has no pinned version", arch, d) + } + } + } +} + +// Explicit VFOX_VERSION and DISTRIBUTION narrow the matrix. +func TestMatrixExplicit(t *testing.T) { + setEnv(t, map[string]string{ + "VFOX_VERSION": "main", + "DISTRIBUTION": "tem zulu-fx", + }) + + combos, err := MatrixForArch("amd64") + if err != nil { + t.Fatal(err) + } + if got := comboKeys(combos); len(got) != 2 || got[0] != "main/tem" || got[1] != "main/zulu-fx" { + t.Fatalf("combos = %v, want [main/tem main/zulu-fx]", got) + } +} + +// PROCESSOR_ARCHITECTURE values from real Windows hosts resolve correctly. +func TestMatrixProcessorArchitecture(t *testing.T) { + t.Setenv("PROCESSOR_ARCHITECTURE", "ARM64") + t.Setenv("VFOX_VERSION", "latest") + t.Setenv("DISTRIBUTION", "") + combos, err := Matrix() + if err != nil { + t.Fatal(err) + } + if want := len(DefaultDistributions("arm64")); len(combos) != want { + t.Fatalf("combos = %v, want %d", comboKeys(combos), want) + } +} + +// Unknown architectures fail fast instead of running a wrong matrix. +func TestMatrixUnknownArch(t *testing.T) { + t.Setenv("PROCESSOR_ARCHITECTURE", "bogus64") + if _, err := Matrix(); err == nil { + t.Error("Matrix() with bogus PROCESSOR_ARCHITECTURE succeeded, want error") + } +} + +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) + } + } + for _, bad := range []string{"riscv64", "x86"} { + if _, err := NormalizeArch(bad); err == nil { + t.Errorf("NormalizeArch(%q) succeeded, want error", bad) + } + } +} + +func TestSlug(t *testing.T) { + got := Combo{Vfox: "latest", Distribution: "librca-fx"}.Slug() + if want := "latest-librca_fx"; got != want { + t.Errorf("slug = %q, want %q", got, want) + } +} + +func TestMaxJobs(t *testing.T) { + t.Setenv("E2E_MAX_JOBS", "") + // empty means unset-equivalent for MaxJobs (Atoi fails -> default) + if got := MaxJobs(3); got != 3 { + t.Errorf("MaxJobs = %d, want 3", got) + } + t.Setenv("E2E_MAX_JOBS", "0") + if got := MaxJobs(3); got != 3 { + t.Errorf("MaxJobs(0) = %d, want 3 (clamped to default)", got) + } + t.Setenv("E2E_MAX_JOBS", "2") + if got := MaxJobs(3); got != 2 { + t.Errorf("MaxJobs(2) = %d, want 2", got) + } +} + +func TestResolveJavaVersion(t *testing.T) { + t.Setenv("JAVA_VERSION", "") + if got := ResolveJavaVersion(t, "tem"); got != DefaultVersions["tem"] { + t.Errorf("tem default = %q, want %q", got, DefaultVersions["tem"]) + } + t.Setenv("JAVA_VERSION", "17.0.12") + if got := ResolveJavaVersion(t, "zulu"); got != "17.0.12" { + t.Errorf("override = %q, want 17.0.12", 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 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-librca"}, + } { + 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 := LegacyIdentifier(c.dist, c.version); got != c.legacy { + t.Errorf("LegacyIdentifier(%s, %s) = %q, want %q", c.dist, c.version, got, c.legacy) + } + } +} + +func TestAlias(t *testing.T) { + for _, c := range []struct{ dist, installed, want string }{ + {"tem", "21.0.8+9-tem", "temurin-21.0.8+9"}, + {"librca-fx", "25.0.4+9-librca-fx", "liberica-25.0.4+9-fx"}, + {"jb", "21.0.5+79248-jb", "jetbrains-21.0.5+79248"}, + {"graalce", "25.0.2-graalce", "graalvm_community-25.0.2"}, + } { + if got, ok := Alias(c.dist, c.installed); !ok || got != c.want { + t.Errorf("Alias(%s, %s) = %q, %v; want %q", c.dist, c.installed, got, ok, c.want) + } + } + for _, c := range []struct{ dist, installed string }{ + {"open", "25.0.2+10"}, // no suffix to rewrite + {"zulu", "21.0.8+9-zulu"}, // long name unknown to the suite + {"tem", "21.0.8+9-librca"}, // not this distribution's version + } { + if got, ok := Alias(c.dist, c.installed); ok { + t.Errorf("Alias(%s, %s) = %q, want none", c.dist, c.installed, got) + } + } +} + +func TestDistributionFacts(t *testing.T) { + if v, ok := Vendor("librca-fx"); !ok || v != "BellSoft" { + t.Errorf("Vendor(librca-fx) = %q, %v", v, ok) + } + if _, ok := Vendor("zulu"); ok { + t.Error("Vendor(zulu) is known, want unknown") + } + if !SetsGraalVMHome("graalce") || SetsGraalVMHome("tem") { + t.Error("SetsGraalVMHome must follow hooks/env_keys.lua") + } + if !JavaFX("librca-fx") || JavaFX("tem") || Base("librca-fx") != "librca" { + t.Error("JavaFX/Base mishandle the -fx suffix") + } + if SearchArgs("open") != "" || SearchArgs("librca-fx") != " librca" { + t.Errorf("SearchArgs = %q, %q", SearchArgs("open"), SearchArgs("librca-fx")) + } + if JavaArch("amd64") != "amd64" || JavaArch("arm64") != "aarch64" { + t.Error("JavaArch must report the JVM os.arch names") + } + if got := HelloOutput("21.0.8", "arm64"); got != "hello 21.0.8 aarch64" { + t.Errorf("HelloOutput = %q", got) + } +} + +func TestJavaProperties(t *testing.T) { + // Abridged `java -XshowSettings:properties -version` output, with a + // multi-line value and Windows line endings. + out := "Property settings:\r\n" + + " java.class.path = \r\n" + + " java.home = /root/.vfox/cache/java/v-21.0.8+9-tem/java-21.0.8+9-tem\r\n" + + " java.library.path = /usr/java/packages/lib\r\n" + + " /usr/lib64\r\n" + + " java.vendor = Eclipse Adoptium\r\n" + + " java.version = 21.0.8\r\n" + + " os.arch = amd64\r\n" + + "\r\n" + + "openjdk version \"21.0.8\" 2025-07-15 LTS\r\n" + props := JavaProperties(out) + for k, want := range map[string]string{ + "java.class.path": "", + "java.home": "/root/.vfox/cache/java/v-21.0.8+9-tem/java-21.0.8+9-tem", + "java.library.path": "/usr/java/packages/lib", + "java.vendor": "Eclipse Adoptium", + "java.version": "21.0.8", + "os.arch": "amd64", + } { + if got, ok := props[k]; !ok || got != want { + t.Errorf("%s = %q, %v; want %q", k, got, ok, want) + } + } + if len(props) != 6 { + t.Errorf("props = %v, want 6 entries", props) + } +} + +func TestInstalledVersion(t *testing.T) { + out := "Preinstalling java@21.0.8+9-tem...\n" + + "Install \x1b[92mjava@21.0.8+9-tem\x1b[0m success! \n" + + "Please use `vfox use java@21.0.8+9-tem` to use it.\n" + if got := InstalledVersion(out); got != "21.0.8+9-tem" { + t.Errorf("InstalledVersion = %q, want 21.0.8+9-tem", got) + } + if got := InstalledVersion("java@21.0.8+9-tem is already installed"); got != "" { + t.Errorf("InstalledVersion = %q, want empty", got) + } +} + +func TestFromTarball(t *testing.T) { + tarball := "Preinstalling java@21.0.5+79248-jb...\r\n" + + "Unpacking C:\\Users\\John Doe\\.vfox\\cache\\java\\v-21.0.5+79248-jb\\jbrsdk_jcef-21.0.5-windows-x64-b792.48.tar.gz...\r\n" + if !FromTarball(tarball) { + t.Error("FromTarball missed a .tar.gz unpack") + } + if FromTarball("Unpacking C:\\tmp\\OpenJDK21U-jdk_x64_windows_hotspot_21.0.8_9.zip...\n") { + t.Error("FromTarball matched a .zip unpack") + } +} + +func TestCheckInstalled(t *testing.T) { + for spec, installed := range map[string]string{ + "21.0.8-tem": "21.0.8+9-tem", + "25.0.2": "25.0.2+10", + "25.0.4-librca-fx": "25.0.4+9-librca-fx", + "25.0.2-graalce": "25.0.2-graalce", + } { + if got := CheckInstalled(t, "Install java@"+installed+" success! \n", spec); got != installed { + t.Errorf("CheckInstalled(%s) = %q, want %q", spec, got, installed) + } + } +} + +func TestSearchEntry(t *testing.T) { + out := "Available versions:\n" + + " - 25.0.4+9-librca (LTS)\n" + + " - 25.0.4+9-librca-fx (LTS, JavaFX) (installed)\n" + + " - 25.0.2+10\n" + if got, ok := SearchEntry(out, "25.0.4+9-librca-fx"); !ok || got != "25.0.4+9-librca-fx (LTS, JavaFX) (installed)" { + t.Errorf("SearchEntry(fx) = %q, %v", got, ok) + } + // The non-FX build must not match the FX entry, nor vice versa. + if got, ok := SearchEntry(out, "25.0.4+9-librca"); !ok || got != "25.0.4+9-librca (LTS)" { + t.Errorf("SearchEntry(non-fx) = %q, %v", got, ok) + } + if got, ok := SearchEntry(out, "25.0.2+10"); !ok || got != "25.0.2+10" { + t.Errorf("SearchEntry(bare) = %q, %v", got, ok) + } + if _, ok := SearchEntry(out, "25.0.4"); ok { + t.Error("SearchEntry matched a version prefix") + } +} diff --git a/tests/e2e/common/retry.go b/tests/e2e/common/retry.go new file mode 100644 index 0000000..cc93280 --- /dev/null +++ b/tests/e2e/common/retry.go @@ -0,0 +1,90 @@ +// 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. + +// Retry helpers shared by the Linux and Windows container suites. +// +// Both suites shell out into throwaway containers where downloads and +// daemon calls flake, so every network-touching exec goes through Retry: +// linear 10s*attempt backoff with per-attempt logging, driven by +// github.com/cenkalti/backoff/v7 instead of a hand-rolled loop. Only the +// backoff schedule itself stays local (linearAttemptBackOff): no third-party +// policy reproduces the exact 10s/20s/... cadence the suite's timeouts were +// tuned against. +package common + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/cenkalti/backoff/v7" +) + +// linearAttemptBackOff waits 10s after the first failure, 20s after the +// second, and so on. It exists only to preserve the suite's historical +// cadence; the retry loop, logging fan-out and context handling come from +// the backoff library. +type linearAttemptBackOff struct { + attempt int +} + +func (b *linearAttemptBackOff) NextBackOff() time.Duration { + b.attempt++ + return time.Duration(10*b.attempt) * time.Second +} + +func (b *linearAttemptBackOff) Reset() { + b.attempt = 0 +} + +// Retry runs run until it reports exit code 0, giving up after +// RetryAttempts() attempts. It returns the successful run's output and +// fatals (with the last output attached) once the budget is exhausted. +func Retry(ctx context.Context, t *testing.T, label string, run func() (code int, output string)) string { + t.Helper() + max := RetryAttempts() + if max < 1 { + max = 1 + } + var out string + var code, attempts int + notify := func(_ error, _ time.Duration) { + t.Logf("retrying %s, attempt %d exited %d", label, attempts, code) + t.Logf("--- output ---\n%s", out) + } + result, err := backoff.Retry(ctx, func() (string, error) { + attempts++ + code, out = run() + if code == 0 { + return out, nil + } + return "", fmt.Errorf("%s: attempt %d exited %d", label, attempts, code) + }, + backoff.WithBackOff(&linearAttemptBackOff{}), + backoff.WithMaxTries(uint(max)), + // Bound retries by attempt count and the caller's context only; + // the library's default 15-minute elapsed budget must not cut + // long downloads short. + backoff.WithMaxElapsedTime(0), + backoff.WithNotify(notify), + ) + if err != nil { + if ctx.Err() != nil { + t.Fatalf("%s: %v", label, ctx.Err()) + } + t.Fatalf("%s failed after %d attempt(s) (exit %d)\n--- output ---\n%s", label, attempts, code, out) + } + return result +} diff --git a/tests/e2e/common/retry_test.go b/tests/e2e/common/retry_test.go new file mode 100644 index 0000000..fbea965 --- /dev/null +++ b/tests/e2e/common/retry_test.go @@ -0,0 +1,67 @@ +// 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 common + +import ( + "context" + "testing" + "time" +) + +// The linear schedule is the one behavior the suite pins down: 10s, 20s, +// ... . Everything else (loop, logging, cancellation) belongs to the +// backoff library. +func TestLinearAttemptBackOffSchedule(t *testing.T) { + b := &linearAttemptBackOff{} + for i, want := range []time.Duration{10 * time.Second, 20 * time.Second, 30 * time.Second} { + if got := b.NextBackOff(); got != want { + t.Fatalf("attempt %d backoff = %v, want %v", i+1, got, want) + } + } + b.Reset() + if got := b.NextBackOff(); got != 10*time.Second { + t.Fatalf("after Reset backoff = %v, want 10s", got) + } +} + +// Immediate success passes the output through with a single run. +func TestRetryImmediateSuccess(t *testing.T) { + t.Setenv("E2E_RETRY_ATTEMPTS", "") + calls := 0 + out := Retry(context.Background(), t, "immediate", func() (int, string) { + calls++ + return 0, "ok" + }) + if out != "ok" || calls != 1 { + t.Fatalf("out = %q after %d calls, want %q after 1", out, calls, "ok") + } +} + +// One failure then success exercises the library round trip (notify + +// backoff wait + second attempt) end to end; it takes ~10s of real wait. +func TestRetryRecoversAfterFailure(t *testing.T) { + t.Setenv("E2E_RETRY_ATTEMPTS", "") + calls := 0 + out := Retry(context.Background(), t, "recover", func() (int, string) { + calls++ + if calls == 1 { + return 1, "first failed" + } + return 0, "second ok" + }) + if out != "second ok" || calls != 2 { + t.Fatalf("out = %q after %d calls, want %q after 2", out, calls, "second ok") + } +} diff --git a/tests/e2e/doc.go b/tests/e2e/doc.go new file mode 100644 index 0000000..1df936c --- /dev/null +++ b/tests/e2e/doc.go @@ -0,0 +1,34 @@ +// 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 e2e is the single Go module root of the container E2E suites. +// +// This directory holds no test logic, only the module definition +// (go.mod/go.sum) and the orchestration files; the code is split by +// subpackage: +// +// - common: target-independent shared logic (matrix expansion, version +// defaults, distribution facts, output parsing, 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 +// tests/e2e/windows/compose.arm64.yaml (arm64) as `go test ./windows/`. +// +// Pure unit tests without containers (matrix expansion and friends) run from +// the module root: +// +// go test -short ./... +package e2e diff --git a/tests/e2e/go.mod b/tests/e2e/go.mod new file mode 100644 index 0000000..be57c76 --- /dev/null +++ b/tests/e2e/go.mod @@ -0,0 +1,73 @@ +// 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. + +module github.com/version-fox/vfox-java/tests/e2e + +go 1.25.0 + +require ( + github.com/cenkalti/backoff/v7 v7.0.1 + 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/go.sum b/tests/e2e/go.sum new file mode 100644 index 0000000..d529f76 --- /dev/null +++ b/tests/e2e/go.sum @@ -0,0 +1,124 @@ +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/cenkalti/backoff/v7 v7.0.1 h1:+ulHti5s1d9QnfyOnLWVmQ/SaZ+TJQF9OdOI0i9BGZI= +github.com/cenkalti/backoff/v7 v7.0.1/go.mod h1:qcKBGwsu4hpxHtQ8tWYsQ+ifzx2+sS+Xx/3jfe30lI8= +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/linux/Dockerfile b/tests/e2e/linux/Dockerfile new file mode 100644 index 0000000..f4ace65 --- /dev/null +++ b/tests/e2e/linux/Dockerfile @@ -0,0 +1,42 @@ +# 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. + +FROM ubuntu:26.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# NOTE: no heredoc here. This image is built through testcontainers-go, whose +# classic-builder path accepts `RUN < install -> verify through 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 linux + +import ( + "context" + "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" + + "github.com/version-fox/vfox-java/tests/e2e/common" +) + +// 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 DISTRIBUTION=tem E2E_MAX_JOBS=1 go test -v ./linux/ +func TestE2E(t *testing.T) { + if testing.Short() { + t.Skip("skip container E2E in short mode") + } + + arch, err := common.DetectArch() + if err != nil { + t.Fatal(err) + } + platform := "linux/" + arch + image := "vfox-java-e2e:linux-" + arch + + here, err := filepath.Abs(".") + if err != nil { + t.Fatal(err) + } + repoRoot := filepath.Join(here, "..", "..", "..") + + ctx := context.Background() + buildImage(t, ctx, repoRoot, image, platform) + + combos, err := common.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, arch, c) + }) + } +} + +// buildImage replicates `docker build --pull --platform $platform +// --file tests/e2e/linux/Dockerfile --tag $image $repoRoot`. +func buildImage(t *testing.T, ctx context.Context, repoRoot, image, platform 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/linux/Dockerfile", + Repo: "vfox-java-e2e", + Tag: image[strings.LastIndex(image, ":")+1:], + 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) + } +} + +// runOne starts one long-lived container per combo and drives the whole +// E2E flow (setup -> install -> verify) through exec calls, asserting in Go. +// A combo passes when every phase exits 0 and the outputs carry the expected +// markers. +func runOne(t *testing.T, ctx context.Context, image, platform, arch string, c common.Combo) { + t.Helper() + prefix := c.Prefix(platform) + + env := map[string]string{ + "VFOX_VERSION": c.Vfox, + "DISTRIBUTION": c.Distribution, + } + for _, name := range common.PassThroughEnvs { + if v, ok := os.LookupEnv(name); ok && v != "" { + env[name] = v + } + } + + ctr, err := tc.GenericContainer(ctx, tc.GenericContainerRequest{ + ContainerRequest: tc.ContainerRequest{ + Image: image, + ImagePlatform: platform, + Name: common.ContainerName(c.Slug()), + Env: env, + }, + Started: true, + }) + 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() + + version := common.ResolveJavaVersion(t, c.Distribution) + machine := strings.TrimSpace(execOK(ctx, t, ctr, "uname --machine")) + t.Logf("=== vfox %s, java@%s, %s (%s) ===", c.Vfox, common.Spec(c.Distribution, version), platform, machine) + + setupVfox(ctx, t, ctr, c.Vfox) + checkMissingVersionRejected(ctx, t, ctr, c.Distribution, version, arch) + + installed := installJava(ctx, t, ctr, common.Spec(c.Distribution, version)) + verifySearch(ctx, t, ctr, c.Distribution, installed) + // Before any `vfox use`, so java can only come from the legacy file. + verifyLegacyFile(ctx, t, ctr, c.Distribution, version, arch) + + useJava(ctx, t, ctr, installed) + verifyToolchain(ctx, t, ctr, c.Distribution, version, installed, arch) + verifyPreUse(ctx, t, ctr, c.Distribution, version, installed) +} diff --git a/tests/e2e/linux/exec.go b/tests/e2e/linux/exec.go new file mode 100644 index 0000000..4eca14e --- /dev/null +++ b/tests/e2e/linux/exec.go @@ -0,0 +1,79 @@ +// 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 linux + +import ( + "context" + "io" + "strings" + "testing" + + tc "github.com/testcontainers/testcontainers-go" + tcexec "github.com/testcontainers/testcontainers-go/exec" + + "github.com/version-fox/vfox-java/tests/e2e/common" +) + +// execRun runs script via `bash -c` 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{"bash", "-c", 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 runs script through the shared common.Retry helper (linear +// 10s*attempt backoff, per-attempt logging). +func execRetry(ctx context.Context, t *testing.T, ctr tc.Container, label, script string, extraEnv ...string) string { + t.Helper() + return common.Retry(ctx, t, label, func() (int, string) { + return execRun(ctx, t, ctr, script, extraEnv...) + }) +} + +// activated prefixes script with the vfox env setup (`eval "$(vfox activate +// bash)"`). Every exec starts a fresh shell, so activation cannot persist +// and must wrap each call; `vfox use` in particular fails without it +// ("vfox requires hook support"). +func activated(script string) string { + return `eval "$(vfox activate bash)"; ` + script +} + +// shQuote renders a single-quoted POSIX shell string literal. +func shQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/tests/e2e/linux/install.go b/tests/e2e/linux/install.go new file mode 100644 index 0000000..cef0b7a --- /dev/null +++ b/tests/e2e/linux/install.go @@ -0,0 +1,41 @@ +// 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 linux + +import ( + "context" + "testing" + + tc "github.com/testcontainers/testcontainers-go" + + "github.com/version-fox/vfox-java/tests/e2e/common" +) + +// installJava installs one JDK and returns the version vfox recorded for it +// (see common.CheckInstalled). +func installJava(ctx context.Context, t *testing.T, ctr tc.Container, spec string) string { + t.Helper() + out := execRetry(ctx, t, ctr, "vfox install java@"+spec, "vfox install java@"+spec) + installed := common.CheckInstalled(t, out, spec) + t.Logf("PASS java@%s installed as %s", spec, installed) + return installed +} + +// useJava selects the installed JDK globally; `vfox use` needs the activate +// context (see exec.go). +func useJava(ctx context.Context, t *testing.T, ctr tc.Container, installed string) { + t.Helper() + execOK(ctx, t, ctr, activated("vfox use --global java@"+installed)) +} diff --git a/tests/e2e/linux/matrix_test.go b/tests/e2e/linux/matrix_test.go new file mode 100644 index 0000000..8ffd74c --- /dev/null +++ b/tests/e2e/linux/matrix_test.go @@ -0,0 +1,40 @@ +// 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 linux + +import ( + "testing" +) + +func TestMaxJobsDefault(t *testing.T) { + t.Setenv("E2E_MAX_JOBS", "") + // empty means unset-equivalent for maxJobs (Atoi fails -> default 3) + if got := maxJobs(); got != 3 { + t.Errorf("maxJobs = %d, want 3", got) + } + t.Setenv("E2E_MAX_JOBS", "0") + if got := maxJobs(); got != 3 { + t.Errorf("maxJobs(0) = %d, want 3 (clamped to default)", got) + } +} + +func TestShQuote(t *testing.T) { + if got := shQuote(`a "b" c`); got != `'a "b" c'` { + t.Errorf("shQuote = %q", got) + } + if got := shQuote(`it's`); got != `'it'\''s'` { + t.Errorf("shQuote escape = %q", got) + } +} diff --git a/tests/e2e/linux/platform.go b/tests/e2e/linux/platform.go new file mode 100644 index 0000000..c3982d3 --- /dev/null +++ b/tests/e2e/linux/platform.go @@ -0,0 +1,24 @@ +// 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 linux + +import ( + "github.com/version-fox/vfox-java/tests/e2e/common" +) + +// maxJobs bounds parallel containers; honours $E2E_MAX_JOBS (default 3). +func maxJobs() int { + return common.MaxJobs(3) +} diff --git a/tests/e2e/linux/setup.go b/tests/e2e/linux/setup.go new file mode 100644 index 0000000..832b39e --- /dev/null +++ b/tests/e2e/linux/setup.go @@ -0,0 +1,76 @@ +// 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 linux + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + tc "github.com/testcontainers/testcontainers-go" + + "github.com/version-fox/vfox-java/tests/e2e/common" +) + +// setupVfox installs vfox (release, or builds main with the in-image Go +// toolchain) and registers the local java plugin. +func setupVfox(ctx context.Context, t *testing.T, ctr tc.Container, vfoxVersion string) { + t.Helper() + work := strings.TrimSpace(execOK(ctx, t, ctr, "mktemp --directory")) + // The container is removed after the test, but clean up anyway so + // repeated local runs don't leak /tmp. + defer func() { + _, _ = execRun(ctx, t, ctr, fmt.Sprintf("rm --recursive --force %q", work)) + }() + + if vfoxVersion == "main" { + execOK(ctx, t, ctr, fmt.Sprintf( + "git clone --depth 1 https://github.com/version-fox/vfox %q/src && (cd %q/src && CGO_ENABLED=0 go build -trimpath -o /usr/local/bin/vfox .)", + work, work)) + } else { + execOK(ctx, t, ctr, + "curl --fail --silent --show-error --location https://raw.githubusercontent.com/version-fox/vfox/main/install.sh | bash") + } + execOK(ctx, t, ctr, fmt.Sprintf( + "(cd /e2e && zip -qr %q/java.zip metadata.lua hooks lib) && vfox add java --source %q/java.zip", + work, work)) + 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). +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) +} diff --git a/tests/e2e/linux/verify.go b/tests/e2e/linux/verify.go new file mode 100644 index 0000000..b39f8f2 --- /dev/null +++ b/tests/e2e/linux/verify.go @@ -0,0 +1,143 @@ +// 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 linux + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + tc "github.com/testcontainers/testcontainers-go" + + "github.com/version-fox/vfox-java/tests/e2e/common" +) + +// NOTE: the markers asserted here are part of the plugin's or vfox's +// user-facing contract (see hooks/ lib/). If either side rewords them, +// update this file and tests/e2e/windows/verify.go together. + +// showSettings prints the JVM properties plus the version banner; both go +// to stderr. +const showSettings = "java -XshowSettings:properties -version 2>&1" + +// verifySearch checks `vfox search java` (the plugin's Available hook) lists +// the installed version under the name PreInstall gave it, marked +// installed, with the JavaFX note for -fx builds. +func verifySearch(ctx context.Context, t *testing.T, ctr tc.Container, distribution, installed string) { + t.Helper() + cmd := "vfox search java" + common.SearchArgs(distribution) + out := execRetry(ctx, t, ctr, cmd, cmd) + entry, ok := common.SearchEntry(out, installed) + require.Truef(t, ok, "%s does not list %s\n--- output ---\n%s", cmd, installed, out) + require.Containsf(t, entry, "(installed)", "%s must mark %s installed", cmd, installed) + if common.JavaFX(distribution) { + require.Containsf(t, entry, "JavaFX", "%s must note JavaFX on %s", cmd, installed) + } + t.Logf("PASS %s lists %s", cmd, entry) +} + +// verifyLegacyFile proves ParseLegacyFile selects the installed JDK from a +// .sdkmanrc (PLUGIN.legacyFilenames) holding the SDKMAN-style identifier +// without the build number (e.g. java=21.0.8-tem). It runs before any +// `vfox use`, so java can only come from the legacy file. JavaFX builds are +// skipped: SDKMAN spells them .fx-, which +// hooks/parse_legacy_file.lua does not resolve. +func verifyLegacyFile(ctx context.Context, t *testing.T, ctr tc.Container, distribution, version, arch string) { + t.Helper() + if common.JavaFX(distribution) { + t.Logf("SKIP .sdkmanrc probe: SDKMAN spells JavaFX builds .fx-") + return + } + const dir = "/tmp/e2e-legacy-sdkmanrc" + identifier := common.LegacyIdentifier(distribution, version) + execOK(ctx, t, ctr, fmt.Sprintf("mkdir --parents %s && printf 'java=%%s\\n' %s > %s/.sdkmanrc", + dir, shQuote(identifier), dir)) + // The probe activates inside its own exec, a fresh shell session, so no + // `vfox env` state cached by an earlier call can answer for the file. + out := execRetry(ctx, t, ctr, ".sdkmanrc java="+identifier, fmt.Sprintf( + `cd %s && eval "$(vfox activate bash)" && eval "$(vfox env -s bash)" && %s`, dir, showSettings)) + common.CheckJava(t, out, distribution, version, arch, ".sdkmanrc java="+identifier) + t.Logf("PASS .sdkmanrc java=%s selects java %s", identifier, version) +} + +// verifyToolchain runs java and javac through the activate hook and checks +// the JDK on PATH is the requested release, distribution, architecture and +// JavaFX flavour; that JAVA_HOME (and GRAALVM_HOME for GraalVM) resolves to +// that same JDK; that it compiles and runs a program; and that `vfox +// current` reports the installed version. +func verifyToolchain(ctx context.Context, t *testing.T, ctr tc.Container, distribution, version, installed, arch string) { + t.Helper() + out := execRetry(ctx, t, ctr, "java on PATH", activated(showSettings)) + props := common.CheckJava(t, out, distribution, version, arch, "java on PATH") + + // java.home is the canonical JDK root: the launcher resolves its own + // symlinks, while JAVA_HOME names vfox's link (~/.vfox/sdks/java). + viaHome := common.JavaProperties(execOK(ctx, t, ctr, activated(`"$JAVA_HOME"/bin/`+showSettings))) + require.Equal(t, props["java.home"], viaHome["java.home"], "$JAVA_HOME/bin/java must be the java on PATH") + homes := []string{"JAVA_HOME"} + if common.SetsGraalVMHome(distribution) { + homes = append(homes, "GRAALVM_HOME") + } + for _, name := range homes { + home := strings.TrimSpace(execOK(ctx, t, ctr, activated(fmt.Sprintf( + `test -n "$%s" && readlink --canonicalize "$%s"`, name, name)))) + require.Equalf(t, props["java.home"], home, "%s must resolve to the JDK root", name) + } + + javac := strings.TrimSpace(execOK(ctx, t, ctr, activated("javac -version 2>&1"))) + require.Equal(t, "javac "+version, javac, "javac -version") + execOK(ctx, t, ctr, "mkdir --parents /tmp/e2e-hello && printf '%s' "+shQuote(common.HelloJava)+" > /tmp/e2e-hello/Hello.java") + hello := execOK(ctx, t, ctr, activated( + "javac -d /tmp/e2e-hello/classes /tmp/e2e-hello/Hello.java && java -cp /tmp/e2e-hello/classes Hello")) + require.Equal(t, common.HelloOutput(version, arch), strings.TrimSpace(hello), "javac + java Hello") + + modules := execOK(ctx, t, ctr, activated("java --list-modules")) + if common.JavaFX(distribution) { + require.Contains(t, modules, "javafx.base@", "an -fx build must bundle JavaFX") + } else { + require.NotContains(t, modules, "javafx.", "a build without -fx must not bundle JavaFX") + } + + current := execOK(ctx, t, ctr, activated("vfox current java")) + require.Contains(t, current, installed, "vfox current java") + t.Logf("PASS java %s (%s, %s) runs from %s", version, props["java.vendor"], props["os.arch"], props["java.home"]) +} + +// verifyPreUse proves PreUse turns the spellings users type into the +// installed version, without another download: +// +// 1. the bare major (e.g. java@21), for which PreUse returns no result and +// vfox core fuzzy-matches the installed versions; +// 2. the asdf-style -[-fx] spelling (e.g. +// java@temurin-21.0.8+9), which PreUse rewrites to -. +// +// After each `vfox use`, java on PATH must still be the installed JDK. +func verifyPreUse(ctx context.Context, t *testing.T, ctr tc.Container, distribution, version, installed string) { + t.Helper() + spellings := []string{common.Major(version)} + if alias, ok := common.Alias(distribution, installed); ok { + spellings = append(spellings, alias) + } + for _, spelling := range spellings { + out := execOK(ctx, t, ctr, activated("vfox use --global java@"+spelling)) + require.Containsf(t, common.StripANSI(out), "Now using java@"+installed+".", + "java@%s must select the installed %s", spelling, installed) + props := common.JavaProperties(execOK(ctx, t, ctr, activated(showSettings))) + require.Equalf(t, version, props["java.version"], "java on PATH after java@%s", spelling) + } + t.Logf("PASS java@%s select %s", strings.Join(spellings, ", java@"), installed) +} diff --git a/tests/e2e/windows/Dockerfile b/tests/e2e/windows/Dockerfile new file mode 100644 index 0000000..1452dc3 --- /dev/null +++ b/tests/e2e/windows/Dockerfile @@ -0,0 +1,32 @@ +# escape=` +# 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. +ARG BASE_IMAGE=mcr.microsoft.com/windows/servercore:ltsc2025-KB5122871-amd64 +FROM ${BASE_IMAGE} + +SHELL ["powershell", "-NoProfile", "-Command"] + +COPY tests/e2e/windows/bootstrap-tools.ps1 C:\bootstrap-tools.ps1 +RUN powershell -NoProfile -ExecutionPolicy Bypass -File 'C:\bootstrap-tools.ps1' + +WORKDIR C:\e2e + +COPY metadata.lua C:\e2e\metadata.lua +COPY hooks C:\e2e\hooks +COPY lib C:\e2e\lib + +# 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..8bacead --- /dev/null +++ b/tests/e2e/windows/Dockerfile-runner @@ -0,0 +1,40 @@ +# escape=` +# 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. + +# 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.amd64.yaml / compose.arm64.yaml). +ARG BASE_IMAGE=mcr.microsoft.com/windows/servercore:ltsc2025-KB5122871-amd64 +FROM ${BASE_IMAGE} + +SHELL ["powershell", "-NoProfile", "-Command"] + +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. +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 new file mode 100644 index 0000000..7d04931 --- /dev/null +++ b/tests/e2e/windows/bootstrap-tools.ps1 @@ -0,0 +1,53 @@ +<# + 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. +#> + +$ErrorActionPreference = 'Stop' + +# Local copy of the native-command guard. 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' } +$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' +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 new file mode 100644 index 0000000..4439dcc --- /dev/null +++ b/tests/e2e/windows/compose.amd64.yaml @@ -0,0 +1,60 @@ +# 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. + +# Host-side runner for the Windows container E2E suite on amd64 hosts +# (see docs/e2e.md). +# +# docker compose -f tests/e2e/windows/compose.amd64.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 common/common.go). +# +# The module root is the working dir (single Go module for common + linux + +# windows); the command targets the windows package only, because Linux +# containers cannot run on a Windows host. + +services: + e2e: + build: + context: ../../.. + dockerfile: tests/e2e/windows/Dockerfile-runner + args: + BASE_IMAGE: mcr.microsoft.com/windows/servercore:ltsc2025-KB5122871-amd64 + image: vfox-java/e2e-runner:1.27.1 + working_dir: C:/work/tests/e2e + volumes: + - type: npipe + source: \\.\pipe\docker_engine + target: \\.\pipe\docker_engine + - ../../..:C:/work + - e2e-go-modcache:C:/m + environment: + E2E_MAX_JOBS: ${E2E_MAX_JOBS:-} + VFOX_VERSION: ${VFOX_VERSION:-} + DISTRIBUTION: ${DISTRIBUTION:-} + JAVA_VERSION: ${JAVA_VERSION:-} + E2E_RETRY_ATTEMPTS: ${E2E_RETRY_ATTEMPTS:-} + command: ["go", "test", "-v", "-timeout", "55m", "-run", "TestE2E", "./windows"] + +volumes: + e2e-go-modcache: diff --git a/tests/e2e/windows/compose.arm64.yaml b/tests/e2e/windows/compose.arm64.yaml new file mode 100644 index 0000000..1a0901a --- /dev/null +++ b/tests/e2e/windows/compose.arm64.yaml @@ -0,0 +1,60 @@ +# 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. + +# Host-side runner for the Windows container E2E suite on arm64 hosts +# (see docs/e2e.md). +# +# docker compose -f tests/e2e/windows/compose.arm64.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 common/common.go). +# +# The module root is the working dir (single Go module for common + linux + +# windows); the command targets the windows package only, because Linux +# containers cannot run on a Windows host. + +services: + e2e: + build: + context: ../../.. + dockerfile: tests/e2e/windows/Dockerfile-runner + args: + BASE_IMAGE: mcr.microsoft.com/windows/servercore:ltsc2025-KB5122871-arm64 + image: vfox-java/e2e-runner:1.27.1 + working_dir: C:/work/tests/e2e + volumes: + - type: npipe + source: \\.\pipe\docker_engine + target: \\.\pipe\docker_engine + - ../../..:C:/work + - e2e-go-modcache:C:/m + environment: + E2E_MAX_JOBS: ${E2E_MAX_JOBS:-} + VFOX_VERSION: ${VFOX_VERSION:-} + DISTRIBUTION: ${DISTRIBUTION:-} + JAVA_VERSION: ${JAVA_VERSION:-} + E2E_RETRY_ATTEMPTS: ${E2E_RETRY_ATTEMPTS:-} + command: ["go", "test", "-v", "-timeout", "55m", "-run", "TestE2E", "./windows"] + +volumes: + e2e-go-modcache: diff --git a/tests/e2e/windows/e2e_test.go b/tests/e2e/windows/e2e_test.go new file mode 100644 index 0000000..cfac072 --- /dev/null +++ b/tests/e2e/windows/e2e_test.go @@ -0,0 +1,197 @@ +// 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 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 distribution) 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" + "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" + + "github.com/version-fox/vfox-java/tests/e2e/common" +) + +// 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 DISTRIBUTION=tem E2E_MAX_JOBS=1 go test -v ./windows/ +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 := common.DetectArch() + if err != nil { + t.Fatal(err) + } + platform := "windows/" + arch + image := "vfox-java-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 := common.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, arch, 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-java-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) + } +} + +// runOne starts one long-lived container per combo and drives the whole +// E2E flow (setup -> install -> verify) through pwsh exec calls, asserting +// in Go. A combo passes when every phase exits 0 and the outputs carry the +// expected markers. vfox main combos run against a VFOX_HOME containing a +// space (see vfoxHome). +func runOne(t *testing.T, ctx context.Context, image, platform, arch string, c common.Combo) { + t.Helper() + prefix := c.Prefix(platform) + + env := map[string]string{ + "VFOX_VERSION": c.Vfox, + "DISTRIBUTION": c.Distribution, + "VFOX_E2E_SLOT": c.Slug(), + } + for _, name := range common.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: common.ContainerName(c.Slug()), + 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() + + version := common.ResolveJavaVersion(t, c.Distribution) + procArch := procArchOfContainer(ctx, t, ctr) + t.Logf("=== vfox %s, java@%s, %s (%s) ===", c.Vfox, common.Spec(c.Distribution, version), platform, procArch) + + box := setupSlotEnv(ctx, t, ctr, c.Slug(), c.Vfox) + setupVfox(ctx, t, ctr, box, c.Vfox) + checkMissingVersionRejected(ctx, t, ctr, box, c.Distribution, version, arch) + + activation := fetchActivation(ctx, t, ctr, box) + installed, tarball := installJava(ctx, t, ctr, box, common.Spec(c.Distribution, version)) + verifySearch(ctx, t, ctr, box, c.Distribution, installed, tarball) + // Before any `vfox use`, so java can only come from the legacy file. + verifyLegacyFile(ctx, t, ctr, box, c.Distribution, version, arch) + + useJava(ctx, t, ctr, box, activation, installed) + verifyToolchain(ctx, t, ctr, box, activation, c.Distribution, version, installed, arch) + verifyPreUse(ctx, t, ctr, box, activation, c.Distribution, version, installed) +} diff --git a/tests/e2e/windows/exec.go b/tests/e2e/windows/exec.go new file mode 100644 index 0000000..d95cd84 --- /dev/null +++ b/tests/e2e/windows/exec.go @@ -0,0 +1,71 @@ +// 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 ( + "context" + "io" + "testing" + + tc "github.com/testcontainers/testcontainers-go" + tcexec "github.com/testcontainers/testcontainers-go/exec" + + "github.com/version-fox/vfox-java/tests/e2e/common" +) + +// pwshPrelude enforces strict error handling: 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 runs script through the shared common.Retry helper (linear +// 10s*attempt backoff, per-attempt logging). +func execRetry(ctx context.Context, t *testing.T, ctr tc.Container, label, script string, extraEnv ...string) string { + t.Helper() + return common.Retry(ctx, t, label, func() (int, string) { + return execRun(ctx, t, ctr, script, extraEnv...) + }) +} diff --git a/tests/e2e/windows/install-docker-arm64.ps1 b/tests/e2e/windows/install-docker-arm64.ps1 new file mode 100644 index 0000000..6523c35 --- /dev/null +++ b/tests/e2e/windows/install-docker-arm64.ps1 @@ -0,0 +1,98 @@ +<# + 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. +#> + +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true + +# Local copy of the native-command guard. 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 } + $PSNativeCommandUseErrorActionPreference = $false + & docker version 2>&1 | Out-Null + $code = $LASTEXITCODE + $PSNativeCommandUseErrorActionPreference = $true + return $code -eq 0 +} + +function Wait-Engine { + for ($attempt = 1; $attempt -le 60; $attempt++) { + if (Test-Engine) { return $true } + Start-Sleep -Seconds 5 + } + return $false +} + +if ($env:PROCESSOR_ARCHITECTURE -ne 'ARM64') { throw "FAIL this script builds a windows/arm64 engine and needs an arm64 host" } + +if (Test-Engine) { + Write-Output 'PASS docker is already installed' +} +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 $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' } + $env:PATH = "$bin;$env:PATH" + if ($env:GITHUB_PATH) { Add-Content -Path $env:GITHUB_PATH -Value $bin } + + $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') + if (-not (Wait-Engine)) { + if (Test-Path $log) { Get-Content $log -Tail 40 | ForEach-Object { "dockerd: $_" } } + throw 'FAIL the docker engine never became ready' + } +} + +$info = ((& docker info --format '{{.OSType}}|{{.Architecture}}') -join '').Trim().ToLowerInvariant() +if ($info -ne 'windows|arm64') { throw "FAIL the docker engine runs $info containers" } +Write-Output 'PASS the docker engine runs windows/arm64 containers' diff --git a/tests/e2e/windows/install.go b/tests/e2e/windows/install.go new file mode 100644 index 0000000..0d5b93e --- /dev/null +++ b/tests/e2e/windows/install.go @@ -0,0 +1,49 @@ +// 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 ( + "context" + "testing" + + tc "github.com/testcontainers/testcontainers-go" + + "github.com/version-fox/vfox-java/tests/e2e/common" +) + +// installJava installs one JDK and returns the version vfox recorded for it +// (see common.CheckInstalled), plus whether it came from the tar.gz fallback +// rather than a zip. Install runs plain: vfox.exe resolves through the slot +// PATH override. +func installJava(ctx context.Context, t *testing.T, ctr tc.Container, box slotBox, spec string) (string, bool) { + t.Helper() + out := execRetry(ctx, t, ctr, "vfox install java@"+spec, + "vfox install "+pwshQuote("java@"+spec), box.vars...) + installed := common.CheckInstalled(t, out, spec) + tarball := common.FromTarball(out) + archive := "zip" + if tarball { + archive = "tar.gz fallback" + } + t.Logf("PASS java@%s installed as %s (%s)", spec, installed, archive) + return installed, tarball +} + +// useJava selects the installed JDK globally; `vfox use` needs the +// activation context captured by fetchActivation. +func useJava(ctx context.Context, t *testing.T, ctr tc.Container, box slotBox, activation, installed string) { + t.Helper() + execOK(ctx, t, ctr, activated(activation, "vfox use --global "+pwshQuote("java@"+installed)), box.vars...) +} diff --git a/tests/e2e/windows/matrix_test.go b/tests/e2e/windows/matrix_test.go new file mode 100644 index 0000000..a758fda --- /dev/null +++ b/tests/e2e/windows/matrix_test.go @@ -0,0 +1,56 @@ +// 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 ( + "strings" + "testing" +) + +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 TestVfoxHome(t *testing.T) { + profile := `C:\Users\ContainerAdministrator` + if got, want := vfoxHome(profile, "latest-tem", "latest"), + `C:\Users\ContainerAdministrator\vfox-e2e-runs\latest-tem\.vfox`; got != want { + t.Errorf("vfoxHome(latest) = %q, want %q", got, want) + } + got := vfoxHome(profile, "main-tem", "main") + if got != spacedVfoxHome { + t.Errorf("vfoxHome(main) = %q, want %q", got, spacedVfoxHome) + } + if !strings.Contains(got, " ") { + t.Errorf("vfoxHome(main) = %q, want a path containing a space", 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) + } +} diff --git a/tests/e2e/windows/platform.go b/tests/e2e/windows/platform.go new file mode 100644 index 0000000..55ad0ab --- /dev/null +++ b/tests/e2e/windows/platform.go @@ -0,0 +1,24 @@ +// 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 ( + "github.com/version-fox/vfox-java/tests/e2e/common" +) + +// maxJobs bounds parallel containers; honours $E2E_MAX_JOBS (default 2). +func maxJobs() int { + return common.MaxJobs(2) +} diff --git a/tests/e2e/windows/setup.go b/tests/e2e/windows/setup.go new file mode 100644 index 0000000..22e3d5d --- /dev/null +++ b/tests/e2e/windows/setup.go @@ -0,0 +1,244 @@ +// 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 ( + "context" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + tc "github.com/testcontainers/testcontainers-go" + + "github.com/version-fox/vfox-java/tests/e2e/common" +) + +// Java defaults live in the common package; only the Windows-specific +// toolchain pin stays here. +const ( + // goWindowsVersion pins the Go toolchain MSI used to build vfox@main + // inside the container. + goWindowsVersion = "1.27.1" + + // spacedVfoxHome is the default vfox home of a Windows account whose + // name contains a space, the layout reported in + // https://github.com/version-fox/vfox-flutter/issues/33 . + spacedVfoxHome = `C:\Users\John Doe\.vfox` +) + +// vfoxHome returns the $VFOX_HOME for a combo. The fix for Windows homes +// containing a space (https://github.com/version-fox/vfox/pull/712) is on +// vfox main only: released builds still fail on such paths, so only main +// runs against spacedVfoxHome while the others keep an unspaced slot home. +func vfoxHome(userProfile, slot, vfoxVersion string) string { + if vfoxVersion == "main" { + return spacedVfoxHome + } + return userProfile + `\vfox-e2e-runs\` + slot + `\.vfox` +} + +// pwshQuote renders a single-quoted PowerShell string literal. +func pwshQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + +// slotBox carries the per-combo isolated profile locations 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 isolates the combo: USERPROFILE/TEMP/TMP point under +// vfox-e2e-runs\, while VFOX_HOME is a sibling .vfox (spacedVfoxHome +// for main), so parallel combos on one daemon never share vfox state. +func setupSlotEnv(ctx context.Context, t *testing.T, ctr tc.Container, slot, vfoxVersion 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")) + + home := vfoxHome(userProfile, slot, vfoxVersion) + box := slotBox{slot: slot} + box.slotRoot = userProfile + `\vfox-e2e-runs\` + slot + box.tmp = box.slotRoot + `\tmp` + box.workDir = box.tmp + `\vfox-java-e2e` + box.vfoxExe = box.workDir + `\vfox.exe` + box.pluginZw = box.workDir + `\java.zip` + box.vars = []string{ + "USERPROFILE=" + box.slotRoot, + "VFOX_HOME=" + home, + "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; "+ + "New-Item -ItemType Directory -Force -Path %s | Out-Null", + pwshQuote(box.tmp), pwshQuote(box.workDir), pwshQuote(home))) + t.Logf("PASS slot %s isolated at %s (vfox home %s, arch %s)", slot, box.slotRoot, home, 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. +const curlRetry = "--retry 3 --retry-delay 5 --retry-all-errors" + +// installVfoxRelease resolves the latest vfox tag, downloads the windows +// zip, extracts it, and stages 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 installs the pinned Go MSI, clones vfox, and builds +// vfox.exe. msiexec exit codes 0/3010/1937 all mean success (ok / +// reboot-required / in-use-files). +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 +// java plugin. +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 java --source %s", pwshQuote(box.pluginZw)), box.vars...) +} + +// fetchActivation captures `vfox activate pwsh` once per container; the +// output is prepended to later scripts via activated()/activatedEnv(), since +// each exec starts a fresh process and the $env: assignments cannot persist. +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. `vfox use` needs +// the activation context ("vfox requires hook support" otherwise). +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 +} + +// checkMissingVersionRejected proves an unpublished release fails loudly +// instead of installing something else: PreInstall finds no Foojay package +// (zip, then the tar.gz fallback) 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, box slotBox, 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 "+pwshQuote("java@"+spec), box.vars...) + 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 windows/"+arch, "the lookup must target the runner platform") + t.Logf("PASS java@%s is rejected on windows/%s", spec, arch) +} diff --git a/tests/e2e/windows/verify.go b/tests/e2e/windows/verify.go new file mode 100644 index 0000000..7896378 --- /dev/null +++ b/tests/e2e/windows/verify.go @@ -0,0 +1,164 @@ +// 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 ( + "context" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + tc "github.com/testcontainers/testcontainers-go" + + "github.com/version-fox/vfox-java/tests/e2e/common" +) + +// NOTE: the markers asserted here are part of the plugin's or vfox's +// user-facing contract (see hooks/ lib/). If either side rewords them, +// update this file and tests/e2e/linux/verify.go together. + +// showSettings prints the JVM properties plus the version banner. Both go to +// stderr, which pwsh passes straight through when it is not redirected; +// `2>&1` would instead wrap every line in an ErrorRecord. +const showSettings = "-XshowSettings:properties -version" + +// verifySearch checks `vfox search java` (the plugin's Available hook) lists +// the installed version under the name PreInstall gave it, marked +// installed, with the JavaFX note for -fx builds. +// +// A JDK from the tar.gz fallback (tarball) may be missing from the list: +// lib/foojay.lua falls back per request, so Available, which lists zip +// packages, only reaches tar.gz-only releases when the distribution has no +// zip at all. JetBrains Runtime 21.0.5 is such a release; it is logged and +// skipped rather than failed. +func verifySearch(ctx context.Context, t *testing.T, ctr tc.Container, box slotBox, distribution, installed string, tarball bool) { + t.Helper() + cmd := "vfox search java" + common.SearchArgs(distribution) + out := execRetry(ctx, t, ctr, cmd, cmd, box.vars...) + entry, ok := common.SearchEntry(out, installed) + if !ok && tarball { + t.Logf("SKIP %s omits %s: installed from the tar.gz fallback, which Available does not list while zip packages exist", cmd, installed) + return + } + require.Truef(t, ok, "%s does not list %s\n--- output ---\n%s", cmd, installed, out) + require.Containsf(t, entry, "(installed)", "%s must mark %s installed", cmd, installed) + if common.JavaFX(distribution) { + require.Containsf(t, entry, "JavaFX", "%s must note JavaFX on %s", cmd, installed) + } + t.Logf("PASS %s lists %s", cmd, entry) +} + +// verifyLegacyFile proves ParseLegacyFile selects the installed JDK from a +// .sdkmanrc (PLUGIN.legacyFilenames) holding the SDKMAN-style identifier +// without the build number (e.g. java=21.0.8-tem), written by Set-Content +// with Windows line endings. It runs before any `vfox use`, so java can only +// come from the legacy file. JavaFX builds are skipped: SDKMAN spells them +// .fx-, which hooks/parse_legacy_file.lua does not +// resolve. +func verifyLegacyFile(ctx context.Context, t *testing.T, ctr tc.Container, box slotBox, distribution, version, arch string) { + t.Helper() + if common.JavaFX(distribution) { + t.Logf("SKIP .sdkmanrc probe: SDKMAN spells JavaFX builds .fx-") + return + } + const dir = `C:\e2e-legacy-sdkmanrc` + identifier := common.LegacyIdentifier(distribution, version) + execOK(ctx, t, ctr, fmt.Sprintf( + "New-Item -ItemType Directory -Force -Path %s | Out-Null; Set-Content -Path %s -Value %s", + pwshQuote(dir), pwshQuote(dir+`\.sdkmanrc`), pwshQuote("java="+identifier)), box.vars...) + // The probe activates its own PowerShell process. Activation pins + // __VFOX_PID, which scopes the `vfox env` state cache, but Windows may + // reuse a just-exited PID and legacy files are not among the cache's + // tracked inputs, so the cache file is removed before the export. + script := fmt.Sprintf( + "Invoke-Expression -Command (vfox activate pwsh | Out-String); "+ + "Set-Location -LiteralPath %s; "+ + "if ($env:__VFOX_CURTMPPATH) { Remove-Item -LiteralPath (Join-Path $env:__VFOX_CURTMPPATH 'env-state.json') -Force -ErrorAction SilentlyContinue }; "+ + "$__vfoxExport = vfox env -s pwsh | Out-String; "+ + "if ($__vfoxExport.Trim()) { Invoke-Expression -Command $__vfoxExport }; "+ + "java %s", pwshQuote(dir), showSettings) + out := execRetry(ctx, t, ctr, ".sdkmanrc java="+identifier, script, box.vars...) + common.CheckJava(t, out, distribution, version, arch, ".sdkmanrc java="+identifier) + t.Logf("PASS .sdkmanrc java=%s selects java %s", identifier, version) +} + +// verifyToolchain runs java and javac through the activate + env hooks and +// checks the JDK on PATH is the requested release, distribution, +// architecture and JavaFX flavour; that JAVA_HOME (and GRAALVM_HOME for +// GraalVM) holds that same JDK; that it compiles and runs a program; and +// that `vfox current` reports the installed version. +func verifyToolchain(ctx context.Context, t *testing.T, ctr tc.Container, box slotBox, activation, distribution, version, installed, arch string) { + t.Helper() + out := execRetry(ctx, t, ctr, "java on PATH", activatedEnv(activation, "java "+showSettings), box.vars...) + props := common.CheckJava(t, out, distribution, version, arch, "java on PATH") + require.NotEmpty(t, props["java.home"], "java on PATH: java.home") + + homes := []string{"JAVA_HOME"} + if common.SetsGraalVMHome(distribution) { + homes = append(homes, "GRAALVM_HOME") + } + for _, name := range homes { + viaHome := common.JavaProperties(execOK(ctx, t, ctr, activatedEnv(activation, fmt.Sprintf( + `& (Join-Path $env:%s 'bin\java.exe') %s`, name, showSettings)), box.vars...)) + require.Equalf(t, props["java.home"], viaHome["java.home"], "%s\\bin\\java.exe must be the java on PATH", name) + } + + javac := strings.TrimSpace(execOK(ctx, t, ctr, activatedEnv(activation, "javac -version"), box.vars...)) + require.Equal(t, "javac "+version, javac, "javac -version") + dir := box.tmp + `\e2e-hello` + execOK(ctx, t, ctr, fmt.Sprintf( + "New-Item -ItemType Directory -Force -Path %s | Out-Null; Set-Content -Path %s -Value %s", + pwshQuote(dir), pwshQuote(dir+`\Hello.java`), pwshQuote(common.HelloJava)), box.vars...) + hello := execOK(ctx, t, ctr, activatedEnv(activation, fmt.Sprintf("javac -d %s %s; java -cp %s Hello", + pwshQuote(dir+`\classes`), pwshQuote(dir+`\Hello.java`), pwshQuote(dir+`\classes`))), box.vars...) + require.Equal(t, common.HelloOutput(version, arch), strings.TrimSpace(hello), "javac + java Hello") + + modules := execOK(ctx, t, ctr, activatedEnv(activation, "java --list-modules"), box.vars...) + if common.JavaFX(distribution) { + require.Contains(t, modules, "javafx.base@", "an -fx build must bundle JavaFX") + } else { + require.NotContains(t, modules, "javafx.", "a build without -fx must not bundle JavaFX") + } + + current := execOK(ctx, t, ctr, "vfox current java", box.vars...) + require.Contains(t, current, installed, "vfox current java") + t.Logf("PASS java %s (%s, %s) runs from %s", version, props["java.vendor"], props["os.arch"], props["java.home"]) +} + +// verifyPreUse proves PreUse turns the spellings users type into the +// installed version, without another download: +// +// 1. the bare major (e.g. java@21), for which PreUse returns no result and +// vfox core fuzzy-matches the installed versions; +// 2. the asdf-style -[-fx] spelling (e.g. +// java@temurin-21.0.8+9), which PreUse rewrites to -. +// +// After each `vfox use`, java on PATH must still be the installed JDK. +func verifyPreUse(ctx context.Context, t *testing.T, ctr tc.Container, box slotBox, activation, distribution, version, installed string) { + t.Helper() + spellings := []string{common.Major(version)} + if alias, ok := common.Alias(distribution, installed); ok { + spellings = append(spellings, alias) + } + for _, spelling := range spellings { + out := execOK(ctx, t, ctr, activated(activation, "vfox use --global "+pwshQuote("java@"+spelling)), box.vars...) + require.Containsf(t, common.StripANSI(out), "Now using java@"+installed+".", + "java@%s must select the installed %s", spelling, installed) + props := common.JavaProperties(execOK(ctx, t, ctr, activatedEnv(activation, "java "+showSettings), box.vars...)) + require.Equalf(t, version, props["java.version"], "java on PATH after java@%s", spelling) + } + t.Logf("PASS java@%s select %s", strings.Join(spellings, ", java@"), installed) +} diff --git a/tests/loongnix_test.lua b/tests/loongnix_test.lua index ee1e87c..c6bb6b6 100644 --- a/tests/loongnix_test.lua +++ b/tests/loongnix_test.lua @@ -1,83 +1,131 @@ -dofile('tests/support.lua') +-- 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. + +dofile("tests/support.lua") local calls = {} local state = {} package.preload.http = function() - return {get = function(request) - table.insert(calls, request.url) - assert(request.url:match('^https://ftp%.loongnix%.cn/Java/'), 'unexpected provider: ' .. request.url) - if state.failure then return state.failure.response, state.failure.err end - local path = request.url:gsub('https://ftp%.loongnix%.cn/Java/', '') - if path == '' then path = 'index' else path = path:gsub('/$', '') end - local f = assert(io.open('tests/fixtures/loongnix/' .. path .. '.html')) - local body = f:read('*a'); f:close() - return {status_code = 200, body = body}, nil - end} + return { + get = function(request) + table.insert(calls, request.url) + assert(request.url:match("^https://ftp%.loongnix%.cn/Java/"), "unexpected provider: " .. request.url) + if state.failure then + return state.failure.response, state.failure.err + end + local path = request.url:gsub("https://ftp%.loongnix%.cn/Java/", "") + if path == "" then + path = "index" + else + path = path:gsub("/$", "") + end + local f = assert(io.open("tests/fixtures/loongnix/" .. path .. ".html")) + local body = f:read("*a") + f:close() + return { status_code = 200, body = body }, nil + end, + } end -package.preload.json = function() return {decode = function() error('Foojay must not be called') end} end -RUNTIME = {osType = 'linux', archType = 'loong64'} +package.preload.json = function() + return { + decode = function() + error("Foojay must not be called") + end, + } +end +RUNTIME = { osType = "linux", archType = "loong64" } PLUGIN = {} -dofile('hooks/available.lua') -dofile('hooks/pre_install.lua') -dofile('hooks/pre_use.lua') -local versions = PLUGIN:Available({args = {}}) -assert(#versions > 0, 'default loong64 search must return versions') +dofile("hooks/available.lua") +dofile("hooks/pre_install.lua") +dofile("hooks/pre_use.lua") +local versions = PLUGIN:Available({ args = {} }) +assert(#versions > 0, "default loong64 search must return versions") local found = {} for _, item in ipairs(versions) do - assert(item.version:match('%-lnxabi[12]'), item.version) - assert(not found[item.version], 'duplicate version ' .. item.version) + assert(item.version:match("%-lnxabi[12]"), item.version) + assert(not found[item.version], "duplicate version " .. item.version) found[item.version] = true - local result = PLUGIN:PreUse({version = item.version}) - assert(result.version == item.version, 'PreUse changed ABI or JavaFX suffix') + local result = PLUGIN:PreUse({ version = item.version }) + assert(result and result.version == item.version, "PreUse changed ABI or JavaFX suffix") end -assert(found['21.0.12+8-lnxabi1-fx']) -assert(found['21.0.12+8-lnxabi2-fx']) -assert(found['8.0.502+7-lnxabi2-fx']) +assert(found["21.0.12+8-lnxabi1-fx"]) +assert(found["21.0.12+8-lnxabi2-fx"]) +assert(found["8.0.502+7-lnxabi2-fx"]) local function check(version, expected, tail) - local result = PLUGIN:PreInstall({version = version}) + local result = PLUGIN:PreInstall({ version = version }) assert(result.version == expected, result.version) assert(result.url:sub(-#tail) == tail, result.url) end -check('21-lnxabi2-fx', '21.0.12+8-lnxabi2-fx', '-loongarch64-glibc2.34.tar.gz') -check('21.0.9+10-lnxabi1-fx', '21.0.9+10-lnxabi1-fx', '-loongarch64.tar.gz') -check('21.0.12+8-loongnix_abi2-fx', '21.0.12+8-lnxabi2-fx', '-loongarch64-glibc2.34.tar.gz') -check('25-lnxabi2-fx', '25.0.4+7-lnxabi2-fx', '-loongarch64-glibc2.34.tar.gz') -check('8-lnxabi2-fx', '8.0.502+7-lnxabi2-fx', '-loongarch64-glibc2.34.tar.gz') -check('17-lnxabi1', '17.0.3+7-lnxabi1', '-loongarch64.tar.gz') +check("21-lnxabi2-fx", "21.0.12+8-lnxabi2-fx", "-loongarch64-glibc2.34.tar.gz") +check("21.0.9+10-lnxabi1-fx", "21.0.9+10-lnxabi1-fx", "-loongarch64.tar.gz") +check("21.0.12+8-loongnix_abi2-fx", "21.0.12+8-lnxabi2-fx", "-loongarch64-glibc2.34.tar.gz") +check("25-lnxabi2-fx", "25.0.4+7-lnxabi2-fx", "-loongarch64-glibc2.34.tar.gz") +check("8-lnxabi2-fx", "8.0.502+7-lnxabi2-fx", "-loongarch64-glibc2.34.tar.gz") +check("17-lnxabi1", "17.0.3+7-lnxabi1", "-loongarch64.tar.gz") local function rejects(fn, message) local ok, err = pcall(fn) assert(not ok and tostring(err):find(message, 1, true), tostring(err)) end -rejects(function() PLUGIN:PreInstall({version='999-lnxabi2-fx'}) end, 'No Loongnix JDK') -rejects(function() PLUGIN:PreInstall({version='21'}) end, 'ABI') -rejects(function() PLUGIN:Available({args={'tem'}}) end, 'Foojay') -for _, abi in ipairs({'lnxabi1','lnxabi2'}) do - for _, item in ipairs(PLUGIN:Available({args={abi}})) do - assert(item.version:find('-' .. abi, 1, true)) +rejects(function() + PLUGIN:PreInstall({ version = "999-lnxabi2-fx" }) +end, "No Loongnix JDK") +rejects(function() + PLUGIN:PreInstall({ version = "21" }) +end, "ABI") +rejects(function() + PLUGIN:Available({ args = { "tem" } }) +end, "Foojay") +for _, abi in ipairs({ "lnxabi1", "lnxabi2" }) do + for _, item in ipairs(PLUGIN:Available({ args = { abi } })) do + assert(item.version:find("-" .. abi, 1, true)) end end -assert(#PLUGIN:Available({args={'all'}}) == #versions) -RUNTIME.archType = 'arm64' -rejects(function() PLUGIN:Available({args={'loongnix'}}) end, 'linux/loong64') -RUNTIME.archType = 'loong64'; RUNTIME.osType = 'darwin' -rejects(function() PLUGIN:PreInstall({version='21-lnxabi2-fx'}) end, 'linux/loong64') -RUNTIME.osType = 'linux' -state.failure = {err = 'network unavailable'} -rejects(function() PLUGIN:Available({args={}}) end, 'network unavailable') -state.failure = {response = {status_code=503, body='unavailable'}} -rejects(function() PLUGIN:Available({args={}}) end, '503') -state.failure = {response = {status_code=200, body='unexpected upstream page'}} -rejects(function() PLUGIN:Available({args={}}) end, 'No Loongnix JDK') +assert(#PLUGIN:Available({ args = { "all" } }) == #versions) +RUNTIME.archType = "arm64" +rejects(function() + PLUGIN:Available({ args = { "loongnix" } }) +end, "linux/loong64") +RUNTIME.archType = "loong64" +RUNTIME.osType = "darwin" +rejects(function() + PLUGIN:PreInstall({ version = "21-lnxabi2-fx" }) +end, "linux/loong64") +RUNTIME.osType = "linux" +state.failure = { err = "network unavailable" } +rejects(function() + PLUGIN:Available({ args = {} }) +end, "network unavailable") +state.failure = { response = { status_code = 503, body = "unavailable" } } +rejects(function() + PLUGIN:Available({ args = {} }) +end, "503") +state.failure = { response = { status_code = 200, body = "unexpected upstream page" } } +rejects(function() + PLUGIN:Available({ args = {} }) +end, "No Loongnix JDK") state.failure = nil -rejects(function() PLUGIN:PreInstall({version='21.0.1-lnxabi2-fx'}) end, 'No Loongnix JDK') -RUNTIME.archType = 'arm64' +rejects(function() + PLUGIN:PreInstall({ version = "21.0.1-lnxabi2-fx" }) +end, "No Loongnix JDK") +RUNTIME.archType = "arm64" local foojay_calls = {} -require('foojay').fetchtJdkList = function(distribution, version) - assert(not distribution:match('loongnix'), 'Loongnix leaked into Foojay') +require("foojay").fetchtJdkList = function(distribution, version) + assert(not distribution:match("loongnix"), "Loongnix leaked into Foojay") table.insert(foojay_calls, distribution) - return {{java_version='21.0.1', term_of_support='lts'}} + return { { java_version = "21.0.1", term_of_support = "lts" } } end -assert(PLUGIN:Available({args={}})[1].version == '21.0.1') -assert(PLUGIN:Available({args={'tem'}})[1].version == '21.0.1-tem') -assert(#PLUGIN:Available({args={'all'}}) > 0) +assert(PLUGIN:Available({ args = {} })[1].version == "21.0.1") +assert(PLUGIN:Available({ args = { "tem" } })[1].version == "21.0.1-tem") +assert(#PLUGIN:Available({ args = { "all" } }) > 0) assert(#foojay_calls > 2) -print('PASS: Loongnix hook regression tests (' .. #versions .. ' fixture versions)') +print("PASS: Loongnix hook regression tests (" .. #versions .. " fixture versions)") diff --git a/tests/parse_legacy_file_test.lua b/tests/parse_legacy_file_test.lua new file mode 100644 index 0000000..1a8ebfe --- /dev/null +++ b/tests/parse_legacy_file_test.lua @@ -0,0 +1,59 @@ +-- 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. + +dofile("tests/support.lua") +PLUGIN = {} +dofile("hooks/parse_legacy_file.lua") + +local function parse(content, installed) + local path = os.tmpname() + local file = assert(io.open(path, "wb")) + assert(file:write(content)) + assert(file:close()) + local ok, result = pcall(function() + return PLUGIN:ParseLegacyFile({ + filepath = path, + filename = ".sdkmanrc", + -- vfox 1.x binds this as a Go func() []string and rejects any + -- argument, including the ctx a ctx:getInstalledVersions() call adds. + getInstalledVersions = function(...) + assert(select("#", ...) == 0, "getInstalledVersions takes no arguments") + return installed + end, + }) + end) + os.remove(path) + assert(ok, result) + return result.version +end + +local cases = { + -- SDKMAN identifiers carry no build number. + { "java=21.0.8-tem\n", { "21.0.8+9-zulu", "21.0.8+9-tem" }, "21.0.8+9-tem" }, + { "java=25.0.2-open\n", { "25.0.2+10-tem", "25.0.2+10" }, "25.0.2+10" }, + { "java=21.0.5+79248-jb\n", { "21.0.5+79248-jb" }, "21.0.5+79248-jb" }, + -- Without the exact release, the newest installed one of the major. + { "java=21.0.1-tem\n", { "21.0.8+9-tem" }, "21.0.8+9-tem" }, + -- Windows line endings, as Set-Content writes them. + { "java=21.0.8-tem\r\nmaven=3.9.9\r\n", { "21.0.8+9-tem" }, "21.0.8+9-tem" }, + -- Identifiers the plugin does not know (SDKMAN's JetBrains is jbr) or no + -- java line at all select nothing. + { "java=21.0.5-jbr\n", { "21.0.5+79248-jb" }, nil }, + { "maven=3.9.9\n", { "21.0.8+9-tem" }, nil }, +} +for _, case in ipairs(cases) do + local version = parse(case[1], case[2]) + assert(version == case[3], string.format("%q: expected %s, got %s", case[1], tostring(case[3]), tostring(version))) +end +print("PASS: " .. #cases .. " ParseLegacyFile regression cases") diff --git a/tests/post_install_test.lua b/tests/post_install_test.lua index d48fc95..63be26e 100644 --- a/tests/post_install_test.lua +++ b/tests/post_install_test.lua @@ -1,7 +1,21 @@ -dofile('tests/support.lua') +-- 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. + +dofile("tests/support.lua") PLUGIN = {} -RUNTIME = {osType = 'darwin'} -dofile('hooks/post_install.lua') +RUNTIME = { osType = "darwin" } +dofile("hooks/post_install.lua") local function quote(value) return "'" .. value:gsub("'", "'\\''") .. "'" @@ -10,73 +24,84 @@ local function command(value) assert(os.execute(value) == 0, value) end local function write(path, content) - local file = assert(io.open(path, 'w')) + local file = assert(io.open(path, "w")) assert(file:write(content)) assert(file:close()) end local function read(path) - local file = io.open(path, 'r') - if not file then return nil end - local content = file:read('*a') + local file = io.open(path, "r") + if not file then + return nil + end + local content = file:read("*a") file:close() return content end local base = os.tmpname() os.remove(base) base = base .. " JDK's & spaces" -command('mkdir -p ' .. quote(base)) +command("mkdir -p " .. quote(base)) local count = 0 local function fixture(layout) count = count + 1 - local root = base .. '/' .. count + local root = base .. "/" .. count local home = root .. layout - command('mkdir -p ' .. quote(home .. '/bin') .. ' ' .. quote(home .. '/lib')) - write(home .. '/bin/java', '#!/bin/sh\nexit 0\n') - command('chmod +x ' .. quote(home .. '/bin/java')) - write(home .. '/lib/ct.sym', 'jdk symbols') - write(home .. '/.metadata', 'hidden metadata') - command('ln -s java ' .. quote(home .. '/bin/java-link')) + command("mkdir -p " .. quote(home .. "/bin") .. " " .. quote(home .. "/lib")) + write(home .. "/bin/java", "#!/bin/sh\nexit 0\n") + command("chmod +x " .. quote(home .. "/bin/java")) + write(home .. "/lib/ct.sym", "jdk symbols") + write(home .. "/.metadata", "hidden metadata") + command("ln -s java " .. quote(home .. "/bin/java-link")) return root, home end local function install(root) - PLUGIN:PostInstall({sdkInfo = {java = { - path = root, version = '8.0.482+10-librca', note = '8' - }}}) + PLUGIN:PostInstall({ + sdkInfo = { + java = { + path = root, + version = "8.0.482+10-librca", + note = "8", + }, + }, + }) end local ok, err = pcall(function() -- The reported Liberica layout, plus existing bundle and flat layouts. for _, layout in ipairs({ - '/jdk8u482-lite.jdk', '/jdk-8.jdk/Contents/Home', - '/jdk-21.0.2.jdk/Contents/Home', '/Contents/Home', '', - '/vendor-jdk/Contents/Home', + "/jdk8u482-lite.jdk", + "/jdk-8.jdk/Contents/Home", + "/jdk-21.0.2.jdk/Contents/Home", + "/Contents/Home", + "", + "/vendor-jdk/Contents/Home", }) do local root = fixture(layout) install(root) - assert(read(root .. '/bin/java'), 'JAVA_HOME/bin/java missing for ' .. layout) - assert(read(root .. '/lib/ct.sym') == 'jdk symbols', layout) - assert(read(root .. '/.metadata') == 'hidden metadata', 'hidden file lost') - command('test -L ' .. quote(root .. '/bin/java-link')) + assert(read(root .. "/bin/java"), "JAVA_HOME/bin/java missing for " .. layout) + assert(read(root .. "/lib/ct.sym") == "jdk symbols", layout) + assert(read(root .. "/.metadata") == "hidden metadata", "hidden file lost") + command("test -L " .. quote(root .. "/bin/java-link")) end -- Refuse ambiguous archives or collisions before moving anything. - local root, home = fixture('/jdk8u482-lite.jdk') - command('mkdir -p ' .. quote(root .. '/lib')) - write(root .. '/lib/keep', 'existing file') - assert(not pcall(install, root), 'must reject an existing destination') - assert(read(home .. '/bin/java') and read(root .. '/lib/keep') == 'existing file') - root, home = fixture('/one.jdk') - command('mkdir -p ' .. quote(root .. '/two.jdk/bin')) - write(root .. '/two.jdk/bin/java', '#!/bin/sh\nexit 0\n') - command('chmod +x ' .. quote(root .. '/two.jdk/bin/java')) - assert(not pcall(install, root), 'must reject multiple JAVA_HOME candidates') - assert(read(home .. '/bin/java') and read(root .. '/two.jdk/bin/java')) + local root, home = fixture("/jdk8u482-lite.jdk") + command("mkdir -p " .. quote(root .. "/lib")) + write(root .. "/lib/keep", "existing file") + assert(not pcall(install, root), "must reject an existing destination") + assert(read(home .. "/bin/java") and read(root .. "/lib/keep") == "existing file") + root, home = fixture("/one.jdk") + command("mkdir -p " .. quote(root .. "/two.jdk/bin")) + write(root .. "/two.jdk/bin/java", "#!/bin/sh\nexit 0\n") + command("chmod +x " .. quote(root .. "/two.jdk/bin/java")) + assert(not pcall(install, root), "must reject multiple JAVA_HOME candidates") + assert(read(home .. "/bin/java") and read(root .. "/two.jdk/bin/java")) -- Other platforms retain their layout. - root, home = fixture('/jdk8u482-lite.jdk') - RUNTIME.osType = 'linux' + root, home = fixture("/jdk8u482-lite.jdk") + RUNTIME.osType = "linux" install(root) - assert(read(home .. '/bin/java') and not read(root .. '/bin/java')) + assert(read(home .. "/bin/java") and not read(root .. "/bin/java")) end) -command('rm -rf ' .. quote(base)) +command("rm -rf " .. quote(base)) assert(ok, err) -print('PASS: macOS JAVA_HOME layouts, metadata, links and collision checks') +print("PASS: macOS JAVA_HOME layouts, metadata, links and collision checks") diff --git a/tests/pre_use_test.lua b/tests/pre_use_test.lua index 8bb33b2..12bee2b 100644 --- a/tests/pre_use_test.lua +++ b/tests/pre_use_test.lua @@ -1,19 +1,36 @@ -dofile('tests/support.lua') +-- 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. + +dofile("tests/support.lua") PLUGIN = {} -dofile('hooks/pre_use.lua') +dofile("hooks/pre_use.lua") local cases = { - {'8.0.482+8-zulu-fx', '8.0.482+8-zulu-fx'}, - {'zulu-8.0.482+8-fx', '8.0.482+8-zulu-fx'}, - {'17.0.17-liberica-fx', '17.0.17-librca-fx'}, - {'8.0.482+8-zulu', '8.0.482+8-zulu'}, - {'temurin-21.0.1', '21.0.1-tem'}, - {'21.0.1-fx', '21.0.1-fx'}, - {'21.0.1', '21.0.1'}, - {'21', ''}, + { "8.0.482+8-zulu-fx", "8.0.482+8-zulu-fx" }, + { "zulu-8.0.482+8-fx", "8.0.482+8-zulu-fx" }, + { "17.0.17-liberica-fx", "17.0.17-librca-fx" }, + { "8.0.482+8-zulu", "8.0.482+8-zulu" }, + { "temurin-21.0.1", "21.0.1-tem" }, + { "21.0.1-fx", "21.0.1-fx" }, + { "21.0.1", "21.0.1" }, + -- A bare major returns no result, leaving vfox core to fuzzy match. + { "21", nil }, } for _, case in ipairs(cases) do - local result = PLUGIN:PreUse({version = case[1]}) - local version = type(result) == 'table' and result.version or result - assert(version == case[2], case[1] .. ': expected ' .. case[2] .. ', got ' .. tostring(version)) + local result = PLUGIN:PreUse({ version = case[1] }) + -- vfox decodes the result as a table; releases after 1.0.12 reject a bare string. + assert(result == nil or type(result) == "table", case[1] .. ": expected a table or nil, got " .. type(result)) + local version = result and result.version + assert(version == case[2], case[1] .. ": expected " .. tostring(case[2]) .. ", got " .. tostring(version)) end -print('PASS: ' .. #cases .. ' PreUse regression cases') +print("PASS: " .. #cases .. " PreUse regression cases") diff --git a/tests/support.lua b/tests/support.lua index 09bd15a..830dc12 100644 --- a/tests/support.lua +++ b/tests/support.lua @@ -1,16 +1,45 @@ -package.path = './lib/?.lua;' .. package.path -package.preload['vfox.strings'] = function() - return { split = function(value, separator) - local result = {} - local start = 1 - while true do - local position = value:find(separator, start, true) - if not position then - table.insert(result, value:sub(start)) - return result +-- 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.path = "./lib/?.lua;" .. package.path +package.preload["vfox.strings"] = function() + local function has_prefix(value, prefix) + return value:sub(1, #prefix) == prefix + end + return { + split = function(value, separator) + local result = {} + local start = 1 + while true do + local position = value:find(separator, start, true) + if not position then + table.insert(result, value:sub(start)) + return result + end + table.insert(result, value:sub(start, position - 1)) + start = position + #separator end - table.insert(result, value:sub(start, position - 1)) - start = position + #separator - end - end } + end, + has_prefix = has_prefix, + has_suffix = function(value, suffix) + return suffix == "" or value:sub(-#suffix) == suffix + end, + trim_prefix = function(value, prefix) + return has_prefix(value, prefix) and value:sub(#prefix + 1) or value + end, + contains = function(value, substring) + return value:find(substring, 1, true) ~= nil + end, + } end