Skip to content

Commit 92fa615

Browse files
authored
test(e2e): cover zsh + Homebrew vfox global version via flutter@3.47.4 (#44)
1 parent be63a79 commit 92fa615

1 file changed

Lines changed: 285 additions & 0 deletions

File tree

‎tests/e2e/linux/zsh_brew_test.go‎

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
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

Comments
 (0)