|
| 1 | +// Copyright 2026 Han Li and contributors |
| 2 | +// |
| 3 | +// Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +// you may not use this file except in compliance with the License. |
| 5 | +// You may obtain a copy of the License at |
| 6 | +// |
| 7 | +// http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +// |
| 9 | +// Unless required by applicable law or agreed to in writing, software |
| 10 | +// distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +// See the License for the specific language governing permissions and |
| 13 | +// limitations under the License. |
| 14 | + |
| 15 | +package linux |
| 16 | + |
| 17 | +import ( |
| 18 | + "context" |
| 19 | + "fmt" |
| 20 | + "os" |
| 21 | + "path/filepath" |
| 22 | + "strings" |
| 23 | + "testing" |
| 24 | + "time" |
| 25 | + |
| 26 | + "github.com/stretchr/testify/require" |
| 27 | + tc "github.com/testcontainers/testcontainers-go" |
| 28 | + |
| 29 | + "github.com/version-fox/vfox-flutter/tests/e2e/common" |
| 30 | +) |
| 31 | + |
| 32 | +// This file covers https://github.com/version-fox/vfox/issues/523 |
| 33 | +// ("macOS M1 Homebrew vfox new tab global version is N/A"). |
| 34 | +// |
| 35 | +// The matrix suite in e2e_test.go installs vfox via install.sh and drives it |
| 36 | +// from bash with an explicit `eval "$(vfox activate bash)"` per exec. Issue |
| 37 | +// 523 follows a different distribution path: zsh + Linuxbrew/Homebrew + |
| 38 | +// `eval "$(vfox activate zsh)"` persisted in ~/.zshrc. A regression there |
| 39 | +// (activation not restoring the global version in a fresh shell) would be |
| 40 | +// invisible to the matrix suite, so this standalone test reproduces the exact |
| 41 | +// user flow in an independent container: |
| 42 | +// |
| 43 | +// zsh -> Homebrew (via zsh) -> `brew install vfox` (via zsh) -> |
| 44 | +// pack local plugin -> `vfox add` -> `vfox install/use flutter@<version>` |
| 45 | +// -> assert `flutter --version --no-version-check` and `dart --version` |
| 46 | +// in the current zsh and in a brand-new zsh (login + interactive, |
| 47 | +// simulating a new terminal tab). |
| 48 | +// |
| 49 | +// The test name starts with TestE2E so the existing CI command |
| 50 | +// (`go test -run TestE2E ./linux`, see tests/e2e/linux/compose.yaml) picks |
| 51 | +// it up without extra flags. It is skipped in `-short` mode like TestE2E. |
| 52 | + |
| 53 | +const ( |
| 54 | + zshBrewTester = "tester" |
| 55 | + zshBrewPrefix = "/home/linuxbrew/.linuxbrew" |
| 56 | + zshBrewBin = zshBrewPrefix + "/bin/brew" |
| 57 | + zshBrewHome = "/home/tester" |
| 58 | + zshBrewZshrc = zshBrewHome + "/.zshrc" |
| 59 | + zshBrewPlugin = "/tmp/flutter-523.zip" |
| 60 | + zshBrewTimeout = 50 * time.Minute |
| 61 | +) |
| 62 | + |
| 63 | +// shQuote renders a single-quoted POSIX shell string literal. |
| 64 | +func shQuote(s string) string { |
| 65 | + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" |
| 66 | +} |
| 67 | + |
| 68 | +// execZshRun runs script in a fresh zsh-as-tester and returns exit code plus |
| 69 | +// combined output. extraEnv entries ("K=V") are prepended as `env` assignments |
| 70 | +// inside the tester context so sudo does not strip them. |
| 71 | +func execZshRun(ctx context.Context, t *testing.T, ctr tc.Container, script string, login bool, extraEnv ...string) (int, string) { |
| 72 | + t.Helper() |
| 73 | + flag := "-i -c" |
| 74 | + if login { |
| 75 | + flag = "-l -i -c" |
| 76 | + } |
| 77 | + wrapped := "sudo -u " + zshBrewTester |
| 78 | + if len(extraEnv) > 0 { |
| 79 | + quoted := make([]string, 0, len(extraEnv)) |
| 80 | + for _, kv := range extraEnv { |
| 81 | + parts := strings.SplitN(kv, "=", 2) |
| 82 | + if len(parts) == 2 { |
| 83 | + quoted = append(quoted, parts[0]+"="+shQuote(parts[1])) |
| 84 | + } else { |
| 85 | + quoted = append(quoted, shQuote(kv)) |
| 86 | + } |
| 87 | + } |
| 88 | + wrapped += " env " + strings.Join(quoted, " ") |
| 89 | + } |
| 90 | + wrapped += " zsh " + flag + " " + shQuote(script) |
| 91 | + return execRun(ctx, t, ctr, wrapped) |
| 92 | +} |
| 93 | + |
| 94 | +// execZshOK runs script in a fresh zsh and fatals unless it exits 0. |
| 95 | +func execZshOK(ctx context.Context, t *testing.T, ctr tc.Container, script string, login bool, extraEnv ...string) string { |
| 96 | + t.Helper() |
| 97 | + code, out := execZshRun(ctx, t, ctr, script, login, extraEnv...) |
| 98 | + if code != 0 { |
| 99 | + t.Fatalf("zsh exec exited with code %d\n--- script ---\n%s\n--- output ---\n%s", code, script, out) |
| 100 | + } |
| 101 | + return out |
| 102 | +} |
| 103 | + |
| 104 | +// execZshRetry runs script in a fresh zsh through the shared retry helper. |
| 105 | +func execZshRetry(ctx context.Context, t *testing.T, ctr tc.Container, label, script string, login bool, extraEnv ...string) string { |
| 106 | + t.Helper() |
| 107 | + return common.Retry(ctx, t, label, func() (int, string) { |
| 108 | + return execZshRun(ctx, t, ctr, script, login, extraEnv...) |
| 109 | + }) |
| 110 | +} |
| 111 | + |
| 112 | +// TestE2EZshBrew reproduces issue 523 via zsh + Homebrew + `brew install vfox`. |
| 113 | +func TestE2EZshBrew(t *testing.T) { |
| 114 | + if testing.Short() { |
| 115 | + t.Skip("skip container E2E in short mode") |
| 116 | + } |
| 117 | + t.Parallel() |
| 118 | + |
| 119 | + arch, err := common.DetectArch() |
| 120 | + if err != nil { |
| 121 | + t.Fatal(err) |
| 122 | + } |
| 123 | + platform := "linux/" + arch |
| 124 | + image := "vfox-flutter-e2e:linux-" + arch |
| 125 | + |
| 126 | + here, err := filepath.Abs(".") |
| 127 | + if err != nil { |
| 128 | + t.Fatal(err) |
| 129 | + } |
| 130 | + repoRoot := filepath.Join(here, "..", "..", "..") |
| 131 | + |
| 132 | + ctx := context.Background() |
| 133 | + buildImage(t, ctx, repoRoot, image, platform) |
| 134 | + |
| 135 | + ctr, err := tc.GenericContainer(ctx, tc.GenericContainerRequest{ |
| 136 | + ContainerRequest: tc.ContainerRequest{ |
| 137 | + Image: image, |
| 138 | + ImagePlatform: platform, |
| 139 | + Name: common.ContainerName("zsh-brew-523"), |
| 140 | + }, |
| 141 | + Started: true, |
| 142 | + }) |
| 143 | + if err != nil { |
| 144 | + t.Fatalf("start container: %v", err) |
| 145 | + } |
| 146 | + t.Cleanup(func() { |
| 147 | + _ = ctr.Terminate(context.Background()) |
| 148 | + }) |
| 149 | + |
| 150 | + ctx, cancel := context.WithTimeout(ctx, zshBrewTimeout) |
| 151 | + defer cancel() |
| 152 | + |
| 153 | + version := common.ResolveFlutterVersion(t, "official") |
| 154 | + if v := os.Getenv("FLUTTER_VERSION"); v != "" { |
| 155 | + version = v |
| 156 | + } |
| 157 | + machine := strings.TrimSpace(execOK(ctx, t, ctr, "uname --machine")) |
| 158 | + t.Logf("=== issue 523: zsh + brew + vfox + flutter %s on %s (%s) ===", version, platform, machine) |
| 159 | + |
| 160 | + setupZshBrewUser(ctx, t, ctr) |
| 161 | + installBrewViaZsh(ctx, t, ctr) |
| 162 | + brewInstallVfoxViaZsh(ctx, t, ctr) |
| 163 | + addLocalFlutterPluginViaZsh(ctx, t, ctr) |
| 164 | + installFlutterViaZsh(ctx, t, ctr, version) |
| 165 | + |
| 166 | + // Current terminal: first fresh zsh after `vfox use --global`. |
| 167 | + verifyZshToolchain(ctx, t, ctr, version, "current", false) |
| 168 | + // New terminal tab: brand-new login+interactive zsh, same assertions. |
| 169 | + // macOS Terminal opens login shells; covering `-l -i` proves ~/.zshrc |
| 170 | + // alone (the documented setup) restores the global version without |
| 171 | + // needing ~/.zprofile workarounds from the issue thread. |
| 172 | + verifyZshToolchain(ctx, t, ctr, version, "new-tab", true) |
| 173 | + |
| 174 | + t.Log("PASS issue 523: flutter/dart survive a fresh zsh via Homebrew vfox") |
| 175 | +} |
| 176 | + |
| 177 | +func setupZshBrewUser(ctx context.Context, t *testing.T, ctr tc.Container) { |
| 178 | + t.Helper() |
| 179 | + execOK(ctx, t, ctr, |
| 180 | + "apt-get update && apt-get install --assume-yes "+ |
| 181 | + "zsh curl git sudo build-essential procps file zip unzip xz-utils ca-certificates") |
| 182 | + execOK(ctx, t, ctr, "zsh --version") |
| 183 | + execOK(ctx, t, ctr, |
| 184 | + "id tester 2>/dev/null || useradd -m -s /usr/bin/zsh tester; "+ |
| 185 | + "echo 'tester ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/tester; "+ |
| 186 | + "chmod 440 /etc/sudoers.d/tester; id tester") |
| 187 | + t.Log("PASS zsh + brew build deps ready with non-root tester user") |
| 188 | +} |
| 189 | + |
| 190 | +func installBrewViaZsh(ctx context.Context, t *testing.T, ctr tc.Container) { |
| 191 | + t.Helper() |
| 192 | + // Driven from zsh on purpose: the issue flow installs Homebrew from a zsh |
| 193 | + // terminal, so the parent shell here is `zsh -c` (the payload itself |
| 194 | + // re-execs bash, exactly like the official installer snippet pasted into |
| 195 | + // a zsh prompt). |
| 196 | + execZshRetry(ctx, t, ctr, "install homebrew via zsh", |
| 197 | + `NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`, |
| 198 | + false) |
| 199 | + execOK(ctx, t, ctr, "test -x "+shQuote(zshBrewBin)+" && "+shQuote(zshBrewBin)+" --version") |
| 200 | + // Brew shellenv must come before the vfox hook so fresh shells find brew |
| 201 | + // (and later vfox) on PATH. Single-quote the payload so the outer bash |
| 202 | + // writes it literally instead of expanding $(). |
| 203 | + brewLine := `eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"` |
| 204 | + execOK(ctx, t, ctr, "touch "+shQuote(zshBrewZshrc)+ |
| 205 | + " && grep -qF 'brew shellenv' "+shQuote(zshBrewZshrc)+ |
| 206 | + " || printf '%s\\n' "+shQuote(brewLine)+" >> "+shQuote(zshBrewZshrc)+ |
| 207 | + "; chown tester:tester "+shQuote(zshBrewZshrc)+"; cat "+shQuote(zshBrewZshrc)) |
| 208 | + out := execZshOK(ctx, t, ctr, "brew --version", false) |
| 209 | + t.Logf("PASS Homebrew installed via zsh:\n%s", strings.TrimSpace(out)) |
| 210 | +} |
| 211 | + |
| 212 | +func brewInstallVfoxViaZsh(ctx context.Context, t *testing.T, ctr tc.Container) { |
| 213 | + t.Helper() |
| 214 | + execZshRetry(ctx, t, ctr, "brew install vfox via zsh", "brew install vfox", false) |
| 215 | + vfoxVersion := strings.TrimSpace(execZshOK(ctx, t, ctr, "vfox --version", false)) |
| 216 | + t.Logf("PASS `brew install vfox` via zsh: %s", vfoxVersion) |
| 217 | + // Persist the documented zsh hook. A fresh `zsh -i -c` sources ~/.zshrc, |
| 218 | + // so every later execZsh* call exercises the same activation path as a |
| 219 | + // new terminal tab in the issue. |
| 220 | + vfoxLine := `eval "$(vfox activate zsh)"` |
| 221 | + execOK(ctx, t, ctr, "grep -qF 'vfox activate zsh' "+shQuote(zshBrewZshrc)+ |
| 222 | + " || printf '%s\\n' "+shQuote(vfoxLine)+" >> "+shQuote(zshBrewZshrc)+ |
| 223 | + "; chown tester:tester "+shQuote(zshBrewZshrc)+"; cat "+shQuote(zshBrewZshrc)) |
| 224 | + activated := strings.TrimSpace(execZshOK(ctx, t, ctr, "vfox --version", false)) |
| 225 | + require.Equal(t, vfoxVersion, activated, "fresh zsh must see the Homebrew vfox") |
| 226 | + // Also prove a login shell (macOS new-tab semantics) sees it. |
| 227 | + loginVfox := strings.TrimSpace(execZshOK(ctx, t, ctr, "vfox --version", true)) |
| 228 | + require.Equal(t, vfoxVersion, loginVfox, "fresh login zsh must see the Homebrew vfox") |
| 229 | +} |
| 230 | + |
| 231 | +func addLocalFlutterPluginViaZsh(ctx context.Context, t *testing.T, ctr tc.Container) { |
| 232 | + t.Helper() |
| 233 | + execOK(ctx, t, ctr, fmt.Sprintf( |
| 234 | + "(cd /e2e && zip -qr %q metadata.lua hooks lib) && chown tester:tester %q && ls -la %q", |
| 235 | + zshBrewPlugin, zshBrewPlugin, zshBrewPlugin)) |
| 236 | + execZshOK(ctx, t, ctr, "vfox add flutter --source "+shQuote(zshBrewPlugin), false) |
| 237 | + t.Log("PASS local vfox-flutter plugin added via zsh + Homebrew vfox") |
| 238 | +} |
| 239 | + |
| 240 | +func installFlutterViaZsh(ctx context.Context, t *testing.T, ctr tc.Container, version string) { |
| 241 | + t.Helper() |
| 242 | + execZshRetry(ctx, t, ctr, fmt.Sprintf("vfox install flutter@%s via zsh", version), |
| 243 | + "vfox install flutter@"+version, false) |
| 244 | + execZshOK(ctx, t, ctr, "vfox use --global flutter@"+version, false) |
| 245 | + current := execZshOK(ctx, t, ctr, "vfox current", false) |
| 246 | + t.Logf("--- vfox current after use --global ---\n%s", current) |
| 247 | + require.Contains(t, current, "flutter", "vfox current must list flutter after --global") |
| 248 | + require.NotContains(t, current, "N/A", "vfox current must not be N/A (issue 523)") |
| 249 | +} |
| 250 | + |
| 251 | +func verifyZshToolchain(ctx context.Context, t *testing.T, ctr tc.Container, version, label string, login bool) { |
| 252 | + t.Helper() |
| 253 | + shellKind := "zsh -i" |
| 254 | + if login { |
| 255 | + shellKind = "zsh -l -i" |
| 256 | + } |
| 257 | + execZshRetry(ctx, t, ctr, "pub.dev reachable", |
| 258 | + "curl --fail --silent --show-error --location --max-time 20 --output /dev/null https://pub.dev/api/packages/args", |
| 259 | + login) |
| 260 | + |
| 261 | + current := execZshOK(ctx, t, ctr, "vfox current", login) |
| 262 | + require.Contains(t, current, "flutter", label+" "+shellKind+" vfox current") |
| 263 | + require.NotContains(t, current, "N/A", label+" "+shellKind+" vfox current N/A (issue 523)") |
| 264 | + |
| 265 | + flutter := execZshRetry(ctx, t, ctr, label+" "+shellKind+" flutter --version", |
| 266 | + "flutter --version --no-version-check", login) |
| 267 | + // NOTE: linux/arm64 official installs are source checkouts (no prebuilt |
| 268 | + // flutter_tools.snapshot), so `flutter --version` reports |
| 269 | + // `Flutter • channel [user-branch] • unknown source` instead of |
| 270 | + // `Flutter 3.47.4 • channel stable` (see docs/arm64.md and the SKIP in |
| 271 | + // verifyFirstRunMatchesOfficial). Assert the stable markers that hold on |
| 272 | + // both archive (amd64) and source (arm64) installs. |
| 273 | + require.Contains(t, flutter, "Framework • revision", label+" flutter framework") |
| 274 | + require.Contains(t, flutter, "Tools • Dart", label+" flutter tools") |
| 275 | + |
| 276 | + dart := execZshRetry(ctx, t, ctr, label+" "+shellKind+" dart --version", |
| 277 | + "dart --version", login) |
| 278 | + dartWant := common.ToolsDart(flutter) |
| 279 | + if dartWant == "" { |
| 280 | + t.Fatalf("%s %s: flutter --version has no Tools line\n--- actual ---\n%s", label, shellKind, flutter) |
| 281 | + } |
| 282 | + require.Contains(t, dart, dartWant, label+" "+shellKind+" dart --version") |
| 283 | + |
| 284 | + t.Logf("PASS [%s via %s] flutter %s + dart %s", label, shellKind, version, dartWant) |
| 285 | +} |
0 commit comments