mirror of
https://github.com/Homebrew/BrewUI.git
synced 2026-10-02 00:24:33 +08:00
Run Homebrew through isolated zsh
- Read user configuration from `brew.env`, independent of shell setup. - Filter system startup banners while preserving startup diagnostics. - Drain terminal output before closing its descriptors to prevent loss. - Use the same restricted environment for app self-upgrades. - Document the policy and configure live CI through `brew.env`.
This commit is contained in:
@@ -675,3 +675,21 @@
|
||||
## 2026-09-15 — Before and after PR screenshots
|
||||
|
||||
- The PR template requires before and after screenshots for visible changes, with a comparison table. Changes with no visual impact must explain why screenshots do not apply.
|
||||
|
||||
## 2026-09-15 — Homebrew uses isolated system zsh
|
||||
|
||||
- Supersedes the login-shell policy from 2026-06-23 and 2026-09-09. `ZshBrewCommandRunner` always launches `/bin/zsh --no-rcs --no-global-rcs` with an explicit environment. Login-shell discovery and startup-output filtering are removed.
|
||||
- `PATH` contains the located brew executable's directory followed by `/usr/bin:/bin`. Identity, home and temporary directories come from Foundation; shell exports, including `HOMEBREW_*` and `XDG_CONFIG_HOME`, are not inherited. Homebrew loads user settings from `brew.env` itself. The README and Configuration tab explain migration and relaunching after edits because the API-mode probe is cached.
|
||||
- Stock zsh always executes `/etc/zshenv`. A second `env -i` after startup clears its exports before brew. Both environment assignments and brew arguments travel as literal argv, never interpolated shell code. App-owned output controls and explicit fixture variables are retained.
|
||||
- Self-upgrades use the same runner by default; the handoff no longer carries a shell-selection flag. Deterministic UI tests still invoke the fake executable directly to inherit their fixture environment.
|
||||
- Live E2E launch variables no longer configure the app. CI writes the deterministic settings into `~/.homebrew/brew.env` on its ephemeral runner; manual-run guidance is in `BrewUITests/E2E/README.md`. Never run the live suite unasked.
|
||||
|
||||
## 2026-09-15 — Filter unavoidable zsh startup output
|
||||
|
||||
- Restores startup-output filtering for `/etc/zshenv`: clearing its exports cannot prevent banners from corrupting JSON or appearing in the console. A per-run marker gates each live stream and trims buffered output, with a leading newline to separate unterminated banners.
|
||||
- Detect the actual terminal in zsh when emitting markers so allocation fallback marks both pipes. Retain startup diagnostics if the shell exits before emitting a marker. Tests simulate startup at the runner boundary without editing system files.
|
||||
|
||||
## 2026-09-15 — Drain terminal output before closing the replica
|
||||
|
||||
- Darwin discards unread terminal output when the last replica descriptor closes. `BrewCommandService` must keep its replica open until draining finishes, including cancellation and launch failure. Close both descriptors in one `defer`; finish the drain only after a quiet poll that began after child exit.
|
||||
- The regression test holds the output observer until the child is reaped, then checks buffered and streamed stdout/stderr. This reproduces the output loss without depending on CI scheduling or adding a production test hook.
|
||||
|
||||
@@ -43,6 +43,13 @@ jobs:
|
||||
TEST_FILTER: ${{ inputs.test_filter }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
mkdir -p "$HOME/.homebrew"
|
||||
cat > "$HOME/.homebrew/brew.env" <<'EOF'
|
||||
HOMEBREW_NO_AUTO_UPDATE=1
|
||||
HOMEBREW_NO_ANALYTICS=1
|
||||
HOMEBREW_NO_INSTALL_CLEANUP=1
|
||||
HOMEBREW_NO_ENV_HINTS=1
|
||||
EOF
|
||||
if [[ -n "${TEST_FILTER}" ]]; then
|
||||
scripts/test-e2e -only-testing:"${TEST_FILTER}" | tee xcodebuild-e2e.log
|
||||
else
|
||||
|
||||
@@ -54,6 +54,10 @@ Guiding patterns:
|
||||
|
||||
Run Homebrew commands **asynchronously** via subprocess; support **cancellation**; **stream or preserve** stdout/stderr for transparency and logs. Always make the **exact command** visible to the user; treat **CLI text output as unstable** (tolerant parsing, fallbacks).
|
||||
|
||||
Production commands, including self-upgrades, use system zsh with optional startup files disabled
|
||||
and an explicit environment. `PATH` is the located brew directory followed by `/usr/bin:/bin`.
|
||||
Homebrew loads user configuration from `brew.env`; see [configuration and the `/etc/zshenv` exception](README.md#homebrew-configuration).
|
||||
|
||||
## JSON API
|
||||
|
||||
Use the [Homebrew JSON API](https://formulae.brew.sh/docs/api/) where it helps. Prefer **optional / resilient decoding** — schema can change; **never crash** on unknown fields. Combine with CLI only as needed when the app grows.
|
||||
|
||||
@@ -8,8 +8,7 @@ import Foundation
|
||||
/// Arranges and cleans up live-suite state through the machine's real `brew`, never as the code under
|
||||
/// test: the tests themselves act through the app's own path to brew.
|
||||
nonisolated enum Brew {
|
||||
/// Applied to every brew this suite runs, the app's included. Without `HOMEBREW_NO_AUTO_UPDATE` an
|
||||
/// install can spend minutes updating the tap first, which reads as a hung test.
|
||||
/// Applied to fixture setup and cleanup. CI puts the same settings in `brew.env` for the app.
|
||||
static let determinismEnvironment = [
|
||||
"HOMEBREW_NO_AUTO_UPDATE": "1",
|
||||
"HOMEBREW_NO_ANALYTICS": "1",
|
||||
|
||||
@@ -5,15 +5,12 @@
|
||||
|
||||
import XCTest
|
||||
|
||||
/// Launches with **no** `-uiTesting` argument, so `BrewApp.init()` takes `.live()`: real login shell,
|
||||
/// Launches with **no** `-uiTesting` argument, so `BrewApp.init()` takes `.live()`: isolated system zsh,
|
||||
/// real `brew`, real network.
|
||||
@MainActor
|
||||
enum BrewE2EApp {
|
||||
static func launch() -> XCUIApplication {
|
||||
let app = XCUIApplication()
|
||||
for (key, value) in Brew.determinismEnvironment {
|
||||
app.launchEnvironment[key] = value
|
||||
}
|
||||
app.launch()
|
||||
BrewApp.activate(app)
|
||||
return app
|
||||
|
||||
@@ -56,6 +56,10 @@ Responsibilities are split: **arrange and clean up by shelling out to real brew*
|
||||
**act through the app's UI** using the same page objects the stubbed suite uses. A broken arrange then
|
||||
reads as a fixture failure rather than as a red assertion inside the flow under test.
|
||||
|
||||
The app ignores inherited Homebrew variables. [CI](../../.github/workflows/e2e.yml) writes the
|
||||
fixture settings to `~/.homebrew/brew.env`. For equivalent manual runs, merge those settings into
|
||||
your configuration and restore it afterwards. The test harness does not edit your configuration.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Homebrew installed (`/opt/homebrew/bin/brew` or `/usr/local/bin/brew`) — the suite fails by name in
|
||||
|
||||
@@ -342,12 +342,11 @@ extension BrewApp {
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The same locator and login-shell decision every other brew invocation goes through.
|
||||
/// The same brew locator every other invocation uses.
|
||||
private static func makeHelperHandoff(_ context: SelfUpgradeLaunchContext) -> HelperSelfUpgradeHandoff {
|
||||
HelperSelfUpgradeHandoff(
|
||||
brewExecutableURL: { try context.executionContext.brewExecutableURL() },
|
||||
commandCenter: context.commandCenter,
|
||||
usesLoginShell: context.uiTesting == nil,
|
||||
defaultsKeyPrefix: context.selfUpgradeKeyPrefix,
|
||||
relaunchArguments: relaunchArguments(uiTesting: context.uiTesting),
|
||||
relaunchEnvironment: relaunchEnvironment(uiTesting: context.uiTesting),
|
||||
|
||||
@@ -15,7 +15,6 @@ struct HelperSelfUpgradeHandoff: SelfUpgradeHandoff {
|
||||
let brewExecutableURL: @MainActor () throws -> URL
|
||||
/// Asked before quitting: terminating would kill the `brew` an install is streaming through.
|
||||
let commandCenter: any BrewCommandCenter
|
||||
let usesLoginShell: Bool
|
||||
let defaultsKeyPrefix: String
|
||||
/// Carried across the relaunch so a UI-test run comes back still pointed at its fixtures.
|
||||
let relaunchArguments: [String]
|
||||
@@ -64,7 +63,6 @@ struct HelperSelfUpgradeHandoff: SelfUpgradeHandoff {
|
||||
relaunchEnvironment: relaunchEnvironment,
|
||||
brewExecutablePath: brewExecutableURL.path,
|
||||
upgradeArguments: BrewCommands.selfUpgrade().arguments,
|
||||
usesLoginShell: usesLoginShell,
|
||||
upgradeEnvironment: upgradeEnvironment,
|
||||
logFilePath: logFileURL.path,
|
||||
defaultsSuiteName: Bundle.main.bundleIdentifier ?? SelfUpgradeIdentity.bundleIdentifier,
|
||||
|
||||
@@ -45,7 +45,6 @@ struct UpgradeHelper {
|
||||
private func performUpgrade() async -> Bool {
|
||||
log.write("running \(spec.brewExecutablePath) \(spec.upgradeArguments.joined(separator: " "))")
|
||||
let runner = SelfUpgradeRunner(
|
||||
usesLoginShell: spec.usesLoginShell,
|
||||
transcriptSink: { line in log.write(line) },
|
||||
)
|
||||
let outcome = await runner.run(
|
||||
|
||||
@@ -20,6 +20,42 @@ Enable CLI-averse users to safely discover, install, update, and manage Homebrew
|
||||
brew install --cask homebrew-app
|
||||
```
|
||||
|
||||
## Homebrew configuration
|
||||
|
||||
BrewUI always launches Homebrew through `/bin/zsh`, including app self-upgrades. It disables
|
||||
optional user and system shell startup files with `--no-rcs --no-global-rcs` and supplies a clean environment.
|
||||
`PATH` contains only the directory of the located `brew` executable followed by `/usr/bin:/bin`.
|
||||
Your login shell, shell aliases, exported variables and custom `PATH` do not configure Homebrew in BrewUI.
|
||||
|
||||
**Put your Homebrew configuration variables in `brew.env` files.** Homebrew reads these itself:
|
||||
|
||||
| Scope | File |
|
||||
| --- | --- |
|
||||
| User | `~/.homebrew/brew.env` |
|
||||
| Installation | `<Homebrew prefix>/etc/homebrew/brew.env` |
|
||||
| System | `/etc/homebrew/brew.env` |
|
||||
|
||||
For example, add this line to `~/.homebrew/brew.env`:
|
||||
|
||||
```text
|
||||
HOMEBREW_NO_ENV_HINTS=1
|
||||
```
|
||||
|
||||
Use literal `NAME=value` lines without `export`, shell expansion or command substitution.
|
||||
User settings normally override installation settings, which override system settings.
|
||||
`HOMEBREW_SYSTEM_ENV_TAKES_PRIORITY=1` in the system file makes that file take precedence.
|
||||
See [Homebrew's environment documentation](https://docs.brew.sh/Manpage#environment).
|
||||
An `XDG_CONFIG_HOME` exported by your shell is also ignored; use the user file above.
|
||||
|
||||
Relaunch BrewUI after changing configuration, then check the Configuration tab. Its report and
|
||||
Doctor describe Homebrew's environment in the app and may differ from Terminal. BrewUI still
|
||||
sets output controls for its console and self-upgrade log.
|
||||
|
||||
System zsh always reads `/etc/zshenv`, if present; its execution cannot be disabled.
|
||||
BrewUI clears the environment again afterwards and discards startup output so banners do not
|
||||
reach Homebrew's reports or the console. If startup fails before Homebrew runs, its diagnostics are retained.
|
||||
See [zsh's startup-file documentation](https://zsh.sourceforge.io/Doc/Release/Files.html).
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
After cloning:
|
||||
|
||||
@@ -7,12 +7,10 @@ import BrewCore
|
||||
import Foundation
|
||||
|
||||
public extension BrewCommandExecutionContext {
|
||||
/// Production wiring: brew spawned through the user's login + interactive shell so the
|
||||
/// subprocess inherits the same environment the user sees in Terminal (see
|
||||
/// ``LoginShellBrewCommandRunner``).
|
||||
/// Production wiring: system zsh, a restricted PATH and settings loaded by Homebrew from `brew.env`.
|
||||
static func live() -> BrewCommandExecutionContext {
|
||||
BrewCommandExecutionContext(
|
||||
commandRunner: LoginShellBrewCommandRunner(),
|
||||
commandRunner: ZshBrewCommandRunner(),
|
||||
locator: BrewExecutableLocator(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ import Foundation
|
||||
|
||||
public extension BrewCommandExecutionContext {
|
||||
/// The real ``BrewCommandService`` pointed at a fake `brew`, so spawning, pipes and streaming stay
|
||||
/// under test. Deliberately *not* ``LoginShellBrewCommandRunner``: wrapping the fake in the
|
||||
/// developer's login shell would source their dotfiles and make runs machine-dependent.
|
||||
/// under test. The fake inherits the fixture environment published by the UI-test installer.
|
||||
///
|
||||
/// `nil` resolves nothing, which drives the brew-not-found surfaces and stops a launch that named
|
||||
/// no fake from falling through to a real Homebrew install.
|
||||
|
||||
@@ -79,6 +79,10 @@ private extension BrewCommandService {
|
||||
arguments: [String],
|
||||
options: BrewRunOptions,
|
||||
) async throws -> CommandOutput {
|
||||
defer {
|
||||
terminal.closeReplica()
|
||||
terminal.closePrimary()
|
||||
}
|
||||
let childHasExited = TerminalDrainGate()
|
||||
let sink = options.lineObserver
|
||||
|
||||
@@ -95,15 +99,10 @@ private extension BrewCommandService {
|
||||
input: .none,
|
||||
output: .fileDescriptor(terminal.replicaDescriptor, closeAfterSpawningProcess: false),
|
||||
error: .fileDescriptor(terminal.replicaDescriptor, closeAfterSpawningProcess: false),
|
||||
body: { _ in
|
||||
// Runs once the child is spawned, the only safe moment to drop our replica copy.
|
||||
terminal.closeReplica()
|
||||
},
|
||||
)
|
||||
|
||||
childHasExited.open()
|
||||
let transcript = await drained
|
||||
terminal.closePrimary()
|
||||
try Task.checkCancellation()
|
||||
|
||||
return CommandOutput(
|
||||
@@ -113,10 +112,8 @@ private extension BrewCommandService {
|
||||
)
|
||||
} catch {
|
||||
// Reap the drain before rethrowing, so no thread is left parked on the primary.
|
||||
terminal.closeReplica()
|
||||
childHasExited.open()
|
||||
_ = await drained
|
||||
terminal.closePrimary()
|
||||
|
||||
if error is CancellationError {
|
||||
throw error
|
||||
@@ -144,6 +141,8 @@ private extension BrewCommandService {
|
||||
var readFailure: Int32?
|
||||
|
||||
loop: while true {
|
||||
// Only a quiet interval after exit can prove the final output has been drained.
|
||||
let childHadExited = gate.isOpen
|
||||
switch terminal.read() {
|
||||
case let .data(chunk):
|
||||
undecoded.append(chunk)
|
||||
@@ -152,7 +151,7 @@ private extension BrewCommandService {
|
||||
emit(event, sink: sink)
|
||||
}
|
||||
case .timedOut:
|
||||
if gate.isOpen {
|
||||
if childHadExited {
|
||||
break loop
|
||||
}
|
||||
case .endOfInput:
|
||||
|
||||
@@ -7,7 +7,7 @@ import BrewCore
|
||||
import Foundation
|
||||
|
||||
/// Asks `brew config`, which prints `HOMEBREW_NO_INSTALL_FROM_API: set` only when it is set.
|
||||
/// Probed once: changing it means editing a shell profile, which needs a relaunch anyway.
|
||||
/// Probed once per app launch. Relaunch after changing this setting in `brew.env`.
|
||||
public actor BrewConfigEnvironmentReader: HomebrewEnvironmentReading {
|
||||
private static let noInstallFromAPIKey = "HOMEBREW_NO_INSTALL_FROM_API"
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
//
|
||||
// LoginShellBrewCommandRunner.swift
|
||||
// BrewCLI
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import Foundation
|
||||
|
||||
/// Decorates a ``BrewCommandRunning`` so every brew invocation is executed inside the user's
|
||||
/// login + interactive shell. Produces parity with what the user sees in Terminal — `brew config`
|
||||
/// and `brew doctor` are the strict acceptance bar; every other invocation inherits the same
|
||||
/// environment for free.
|
||||
///
|
||||
/// The wrapper rewrites `run(executableURL: brew, arguments: [...])` into
|
||||
/// `<login-shell> -l -i -c <exec script> <brew> <args...>`. The `-l` flag forces the shell's profile files
|
||||
/// (`.zprofile`, `.bash_profile`) to load — that is where Homebrew installs `brew shellenv`. The
|
||||
/// `-i` flag additionally sources interactive rc files (`.zshrc`, `.bashrc`) so users who put
|
||||
/// their brew setup in those files also get parity; the tradeoff is that interactive rc files may
|
||||
/// print banners or expect a TTY, which we accept as the cost of exact-Terminal parity.
|
||||
public struct LoginShellBrewCommandRunner: BrewCommandRunning {
|
||||
private let underlying: any BrewCommandRunning
|
||||
private let shellResolver: LoginShellResolver
|
||||
private let makeMarker: @Sendable () -> String
|
||||
|
||||
public init(
|
||||
underlying: any BrewCommandRunning = BrewCommandService(),
|
||||
shellResolver: LoginShellResolver = LoginShellResolver(),
|
||||
makeMarker: @escaping @Sendable () -> String = { UUID().uuidString },
|
||||
) {
|
||||
self.underlying = underlying
|
||||
self.shellResolver = shellResolver
|
||||
self.makeMarker = makeMarker
|
||||
}
|
||||
|
||||
public func run(
|
||||
executableURL: URL,
|
||||
arguments: [String],
|
||||
options: BrewRunOptions,
|
||||
) async throws -> CommandOutput {
|
||||
var options = options
|
||||
let shell = shellResolver.resolve()
|
||||
let marker = makeMarker()
|
||||
let shellCommand = Self.shellCommand(for: shell, marker: marker, output: options.output)
|
||||
let shellArguments = Self.shellArguments(executableURL: executableURL, arguments: arguments)
|
||||
|
||||
if let lineObserver = options.lineObserver {
|
||||
let gate = LoginShellStartupGate(marker: marker)
|
||||
options.lineObserver = { line in
|
||||
if let admitted = gate.admit(line) {
|
||||
lineObserver(admitted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Forward `options` so the wrapped subprocess still streams + colours — the default protocol
|
||||
// implementation would drop it, silently disabling colour on the production login-shell path.
|
||||
let output = try await underlying.run(
|
||||
executableURL: shell,
|
||||
arguments: ["-l", "-i", "-c", shellCommand] + shellArguments,
|
||||
options: options,
|
||||
)
|
||||
return CommandOutput(
|
||||
standardOutput: Self.removingStartupNoise(from: output.standardOutput, upTo: marker),
|
||||
standardError: Self.removingStartupNoise(from: output.standardError, upTo: marker),
|
||||
terminationStatus: output.terminationStatus,
|
||||
)
|
||||
}
|
||||
|
||||
/// Printed to the streams the child will actually use, right after `-l -i` startup and before
|
||||
/// `exec`, so ``removingStartupNoise(from:upTo:)`` has an exact line to cut rc-file noise at.
|
||||
static func shellCommand(for shell: URL, marker: String, output: BrewRunOptions.OutputChannel) -> String {
|
||||
let announce = switch output {
|
||||
case .pipes:
|
||||
"printf '%s\\n' '\(marker)' 1>&2; printf '%s\\n' '\(marker)'; "
|
||||
case .pseudoTerminal:
|
||||
"printf '%s\\n' '\(marker)'; "
|
||||
}
|
||||
let exec = shell.lastPathComponent == "fish" ? "exec $argv" : "exec \"$0\" \"$@\""
|
||||
return announce + exec
|
||||
}
|
||||
|
||||
static func shellArguments(executableURL: URL, arguments: [String]) -> [String] {
|
||||
[executableURL.path] + arguments
|
||||
}
|
||||
|
||||
static func removingStartupNoise(from text: String, upTo marker: String) -> String {
|
||||
guard let markerRange = text.range(of: marker) else {
|
||||
return text
|
||||
}
|
||||
let remainder = text[markerRange.upperBound...]
|
||||
guard let newline = remainder.firstIndex(of: "\n") else {
|
||||
return String(remainder)
|
||||
}
|
||||
return String(remainder[remainder.index(after: newline)...])
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops every live console line up to and including the login-shell startup marker, so streamed
|
||||
/// output skips the same rc-file noise ``LoginShellBrewCommandRunner`` strips from the buffered result.
|
||||
// swiftlint:disable:next unchecked_sendable
|
||||
private final class LoginShellStartupGate: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let marker: String
|
||||
private var admittingStdout = false
|
||||
private var admittingStderr = false
|
||||
|
||||
init(marker: String) {
|
||||
self.marker = marker
|
||||
}
|
||||
|
||||
func admit(_ line: BrewCommandOutputLine) -> BrewCommandOutputLine? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let admitting = switch line.stream {
|
||||
case .stdout:
|
||||
admittingStdout
|
||||
case .stderr:
|
||||
admittingStderr
|
||||
}
|
||||
if admitting {
|
||||
return line
|
||||
}
|
||||
if line.text == marker {
|
||||
switch line.stream {
|
||||
case .stdout:
|
||||
admittingStdout = true
|
||||
case .stderr:
|
||||
admittingStderr = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
//
|
||||
// LoginShellResolver.swift
|
||||
// BrewCLI
|
||||
//
|
||||
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
/// Resolves the current user's login shell from Directory Services (`getpwuid_r` reads the same
|
||||
/// backing store as `dscl . -read /Users/<user> UserShell`, without a subprocess).
|
||||
///
|
||||
/// Why this exists: a GUI process launched from Finder/Dock/launchd inherits a stripped environment
|
||||
/// — `$SHELL` may be stale or absent and must not be consulted. The login shell is the input to
|
||||
/// ``LoginShellBrewCommandRunner``, which then spawns brew under `<shell> -l -i -c` so the user's
|
||||
/// profile files load and the subprocess sees the same world as Terminal.
|
||||
public struct LoginShellResolver: Sendable {
|
||||
/// Default fallback when Directory Services is unreadable — modern macOS ships with zsh as the
|
||||
/// default user shell.
|
||||
public static let defaultFallback = URL(fileURLWithPath: "/bin/zsh")
|
||||
|
||||
private let lookup: @Sendable () -> URL?
|
||||
private let fallback: URL
|
||||
|
||||
public init(
|
||||
lookup: @escaping @Sendable () -> URL? = LoginShellResolver.directoryServicesLookup,
|
||||
fallback: URL = LoginShellResolver.defaultFallback,
|
||||
) {
|
||||
self.lookup = lookup
|
||||
self.fallback = fallback
|
||||
}
|
||||
|
||||
/// Returns the resolved login shell, falling back to ``defaultFallback`` if the lookup yields nil.
|
||||
public func resolve() -> URL {
|
||||
lookup() ?? fallback
|
||||
}
|
||||
|
||||
/// Native equivalent of `dscl . -read /Users/<user> UserShell`, using the thread-safe
|
||||
/// `getpwuid_r`. Returns nil if the lookup fails or the path isn't a trustworthy absolute path.
|
||||
public static let directoryServicesLookup: @Sendable () -> URL? = {
|
||||
guard let path = resolveLoginShellPath() else { return nil }
|
||||
return URL(fileURLWithPath: path)
|
||||
}
|
||||
|
||||
/// Does the actual `getpwuid_r` lookup.
|
||||
private static func resolveLoginShellPath() -> String? {
|
||||
var pwd = passwd()
|
||||
var result: UnsafeMutablePointer<passwd>?
|
||||
|
||||
let suggestedSize = sysconf(_SC_GETPW_R_SIZE_MAX)
|
||||
let bufferSize = suggestedSize > 0 ? Int(suggestedSize) : 16384
|
||||
|
||||
var buffer = [CChar](repeating: 0, count: bufferSize)
|
||||
|
||||
let rc: Int32 = buffer.withUnsafeMutableBufferPointer { buf in
|
||||
guard let baseAddress = buf.baseAddress else { return EINVAL }
|
||||
return getpwuid_r(getuid(), &pwd, baseAddress, buf.count, &result)
|
||||
}
|
||||
|
||||
guard rc == 0, let entry = result else { return nil }
|
||||
guard let cString = entry.pointee.pw_shell else { return nil }
|
||||
|
||||
let path = String(cString: cString)
|
||||
|
||||
guard path.hasPrefix("/") else { return nil }
|
||||
|
||||
return path
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import System
|
||||
/// A pty pair: the primary (POSIX: master) stays here, the replica (POSIX: slave) becomes the child's
|
||||
/// stdout/stderr and is a real terminal device, so `isatty` holds in the child.
|
||||
///
|
||||
/// The replica must be closed in this process once the child is spawned. While any replica descriptor
|
||||
/// stays open here the kernel sees a potential writer, so reads on the primary never report EOF.
|
||||
/// Keep the local replica open until output is drained: Darwin discards queued bytes when the last
|
||||
/// replica closes. The runner uses child exit and a quiet poll interval to finish without waiting for EOF.
|
||||
///
|
||||
/// The descriptors never change after `init`, so ``read(timeout:)`` touches `primaryFD` unlocked; the
|
||||
/// lock guards only the closed flags, and `read` could not hold it anyway while parked on `poll`. What
|
||||
@@ -60,7 +60,7 @@ final class PseudoTerminal: @unchecked Sendable {
|
||||
return FileDescriptor(rawValue: replicaFD)
|
||||
}
|
||||
|
||||
/// Call immediately after the child is spawned; see the ownership note on the type. Idempotent.
|
||||
/// Call after draining output; see the ownership note on the type. Idempotent.
|
||||
func closeReplica() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//
|
||||
// ZshBrewCommandRunner.swift
|
||||
// BrewCLI
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import Foundation
|
||||
import Synchronization
|
||||
|
||||
/// Runs brew through system zsh with an explicit environment. Homebrew loads user settings from `brew.env`.
|
||||
public struct ZshBrewCommandRunner: BrewCommandRunning {
|
||||
private let underlying: any BrewCommandRunning
|
||||
|
||||
public init(underlying: any BrewCommandRunning = BrewCommandService()) {
|
||||
self.underlying = underlying
|
||||
}
|
||||
|
||||
public func run(
|
||||
executableURL: URL,
|
||||
arguments: [String],
|
||||
options: BrewRunOptions,
|
||||
) async throws -> CommandOutput {
|
||||
var environment = [
|
||||
"HOME": FileManager.default.homeDirectoryForCurrentUser.path,
|
||||
"USER": NSUserName(),
|
||||
"LOGNAME": NSUserName(),
|
||||
"TMPDIR": FileManager.default.temporaryDirectory.path,
|
||||
"LANG": "en_US.UTF-8",
|
||||
]
|
||||
switch options.output {
|
||||
case .pseudoTerminal:
|
||||
environment["TERM"] = "xterm-256color"
|
||||
fallthrough
|
||||
case .pipes(forceColor: true):
|
||||
// Also covers a pseudo-terminal falling back to pipes.
|
||||
environment["HOMEBREW_COLOR"] = "1"
|
||||
environment["CLICOLOR_FORCE"] = "1"
|
||||
case .pipes(forceColor: false):
|
||||
break
|
||||
}
|
||||
environment.merge(options.environment) { _, override in override }
|
||||
environment["SHELL"] = "/bin/zsh"
|
||||
environment["PATH"] = executableURL.deletingLastPathComponent().path + ":/usr/bin:/bin"
|
||||
let assignments = environment.map { "\($0.key)=\($0.value)" }
|
||||
let startup = ZshStartupOutput()
|
||||
var wrappedOptions = options
|
||||
if let observer = options.lineObserver {
|
||||
wrappedOptions.lineObserver = { line in
|
||||
if startup.admit(line) { observer(line) }
|
||||
}
|
||||
}
|
||||
defer { startup.pendingLines.forEach { options.lineObserver?($0) } }
|
||||
|
||||
// /etc/zshenv always runs. Mark its output and clear its exports, keeping argv literal.
|
||||
// Check the actual terminal so a fallback to pipes gets a marker on each stream.
|
||||
var output = try await underlying.run(
|
||||
executableURL: URL(fileURLWithPath: "/usr/bin/env"),
|
||||
arguments: ["-i"] + assignments + [
|
||||
"/bin/zsh", "--no-rcs", "--no-global-rcs", "-c",
|
||||
"[[ -t 1 ]] || printf '\\n%s\\n' \"$0\" >&2; printf '\\n%s\\n' \"$0\"; exec /usr/bin/env -i \"$@\"",
|
||||
startup.marker,
|
||||
] + assignments + [executableURL.path] + arguments,
|
||||
options: wrappedOptions,
|
||||
)
|
||||
output.standardOutput = startup.removingBanner(from: output.standardOutput)
|
||||
output.standardError = startup.removingBanner(from: output.standardError)
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
private final class ZshStartupOutput: Sendable {
|
||||
let marker = UUID().uuidString
|
||||
private let state = Mutex<(started: [BrewCommandOutputLine.Stream], pending: [BrewCommandOutputLine])>(([], []))
|
||||
|
||||
/// Retain diagnostics if startup exits before the marker, including on cancellation.
|
||||
var pendingLines: [BrewCommandOutputLine] {
|
||||
state.withLock { $0.pending }
|
||||
}
|
||||
|
||||
func admit(_ line: BrewCommandOutputLine) -> Bool {
|
||||
state.withLock { state in
|
||||
if state.started.contains(line.stream) {
|
||||
return true
|
||||
}
|
||||
if line.isComplete, line.text == marker {
|
||||
state.started.append(line.stream)
|
||||
state.pending.removeAll { $0.stream == line.stream }
|
||||
} else {
|
||||
state.pending.append(line)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func removingBanner(from text: String) -> String {
|
||||
guard let range = text.range(of: "\n\(marker)\n") else {
|
||||
return text
|
||||
}
|
||||
return String(text[range.upperBound...])
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The Homebrew environment as `brew` resolves it. Not `ProcessInfo`: the app is Finder-launched, so
|
||||
/// a profile-exported `HOMEBREW_*` is invisible to it yet in effect for every brew invocation.
|
||||
/// The environment Homebrew resolves from its configuration files, independent of the app's environment.
|
||||
public protocol HomebrewEnvironmentReading: Sendable {
|
||||
/// True when brew resolves packages from local tap clones rather than the JSON API.
|
||||
func isInstallFromAPIDisabled() async -> Bool
|
||||
|
||||
@@ -65,6 +65,10 @@ struct ConfigView: View {
|
||||
private func loadedCards(snapshot: BrewConfigSnapshot) -> some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: BrewSpacing.lg) {
|
||||
NoteCallout("""
|
||||
Homebrew settings are read from brew.env files. Shell profiles and exported variables are ignored. \
|
||||
Put user settings in ~/.homebrew/brew.env, then relaunch BrewUI.
|
||||
""", tone: .info)
|
||||
ForEach(viewModel.sections(for: snapshot)) { section in
|
||||
ConfigSectionCard(section: section)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ public final class BrewConfigRepository: ConfigRepository {
|
||||
}
|
||||
|
||||
/// Takes the context rather than building its own runner, so the composition root points every
|
||||
/// brew invocation at one place: production's login shell, or a UI test's fake executable.
|
||||
/// brew invocation at one place: production's isolated zsh runner, or a UI test's fake executable.
|
||||
public convenience init(executionContext: BrewCommandExecutionContext) {
|
||||
self.init(
|
||||
commandRunner: executionContext.commandRunner,
|
||||
@@ -49,8 +49,7 @@ public final class BrewConfigRepository: ConfigRepository {
|
||||
)
|
||||
}
|
||||
|
||||
/// Production wiring: brew is spawned through the user's login + interactive shell
|
||||
/// (``LoginShellBrewCommandRunner``) so `brew config` reflects the same environment as Terminal.
|
||||
/// Production wiring: `brew config` reports the environment used by every app command.
|
||||
public static func live() -> BrewConfigRepository {
|
||||
BrewConfigRepository(executionContext: .live())
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository
|
||||
}
|
||||
|
||||
/// Takes the context rather than building its own runner, so the composition root points every
|
||||
/// brew invocation at one place: production's login shell, or a UI test's fake executable.
|
||||
/// brew invocation at one place: production's isolated zsh runner, or a UI test's fake executable.
|
||||
public convenience init(
|
||||
executionContext: BrewCommandExecutionContext,
|
||||
cache: InstalledInventoryCache,
|
||||
@@ -86,9 +86,7 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository
|
||||
completionObserverTask?.cancel()
|
||||
}
|
||||
|
||||
/// Production wiring: brew spawned through the user's login + interactive shell
|
||||
/// (``LoginShellBrewCommandRunner``) so `brew info --installed --json=v2` reads the same world
|
||||
/// as Terminal. Reconciles off `commandCenter`.
|
||||
/// Production wiring: the shared zsh execution policy, reconciled off `commandCenter`.
|
||||
public static func live(
|
||||
cache: InstalledInventoryCache,
|
||||
commandCenter: any BrewCommandCenter,
|
||||
|
||||
@@ -17,9 +17,6 @@ public struct SelfUpgradeHandoffSpec: Codable, Sendable, Equatable {
|
||||
public let brewExecutablePath: String
|
||||
/// Built by `BrewCommands.selfUpgrade()`, so the argv the helper runs is what the app displays.
|
||||
public let upgradeArguments: [String]
|
||||
/// The helper inherits the app's stripped environment, so without the login shell the one command that
|
||||
/// replaces the app sees none of the `HOMEBREW_*` the user's profile exports.
|
||||
public let usesLoginShell: Bool
|
||||
/// Empty in production. Under `-uiTesting` this points the fake `brew` at the fixture tree, which the
|
||||
/// helper cannot inherit: it is spawned by the app, but outlives it.
|
||||
public let upgradeEnvironment: [String: String]
|
||||
@@ -40,7 +37,6 @@ public struct SelfUpgradeHandoffSpec: Codable, Sendable, Equatable {
|
||||
relaunchEnvironment: [String: String],
|
||||
brewExecutablePath: String,
|
||||
upgradeArguments: [String],
|
||||
usesLoginShell: Bool,
|
||||
upgradeEnvironment: [String: String],
|
||||
logFilePath: String,
|
||||
defaultsSuiteName: String,
|
||||
@@ -56,7 +52,6 @@ public struct SelfUpgradeHandoffSpec: Codable, Sendable, Equatable {
|
||||
self.relaunchEnvironment = relaunchEnvironment
|
||||
self.brewExecutablePath = brewExecutablePath
|
||||
self.upgradeArguments = upgradeArguments
|
||||
self.usesLoginShell = usesLoginShell
|
||||
self.upgradeEnvironment = upgradeEnvironment
|
||||
self.logFilePath = logFilePath
|
||||
self.defaultsSuiteName = defaultsSuiteName
|
||||
|
||||
@@ -25,7 +25,7 @@ public struct SelfUpgradeRunner: Sendable {
|
||||
private let sleep: @Sendable (TimeInterval) async throws -> Void
|
||||
|
||||
public init(
|
||||
commandRunner: any BrewCommandRunning,
|
||||
commandRunner: any BrewCommandRunning = ZshBrewCommandRunner(),
|
||||
transcriptSink: @escaping @Sendable (String) -> Void = { _ in },
|
||||
sleep: @escaping @Sendable (TimeInterval) async throws -> Void = { try await Task.sleep(for: .seconds($0)) },
|
||||
) {
|
||||
@@ -34,17 +34,6 @@ public struct SelfUpgradeRunner: Sendable {
|
||||
self.sleep = sleep
|
||||
}
|
||||
|
||||
/// See ``SelfUpgradeHandoffSpec/usesLoginShell``.
|
||||
public init(
|
||||
usesLoginShell: Bool,
|
||||
transcriptSink: @escaping @Sendable (String) -> Void = { _ in },
|
||||
) {
|
||||
self.init(
|
||||
commandRunner: usesLoginShell ? LoginShellBrewCommandRunner() : BrewCommandService(),
|
||||
transcriptSink: transcriptSink,
|
||||
)
|
||||
}
|
||||
|
||||
public func run(
|
||||
executablePath: String,
|
||||
arguments: [String],
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@testable import BrewCLI
|
||||
import BrewCore
|
||||
import Foundation
|
||||
import Synchronization
|
||||
import Testing
|
||||
|
||||
@Suite(.serialized)
|
||||
@@ -41,6 +42,38 @@ struct BrewCommandServicePseudoTerminalTests {
|
||||
#expect(collector.allLines().filter(\.isComplete).map(\.text) == ["one", "two", "three"])
|
||||
}
|
||||
|
||||
@Test func `terminal output survives the child exiting while the observer is busy`() async throws {
|
||||
let release = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: release) }
|
||||
let collector = OutputCollector()
|
||||
let exitedBeforeReading = Mutex(false)
|
||||
|
||||
let output = try await BrewCommandService().run(
|
||||
executableURL: URL(fileURLWithPath: "/bin/zsh"),
|
||||
arguments: [
|
||||
"-c",
|
||||
"printf '%s\\n' $$; while [[ ! -e \"$1\" ]]; do sleep 0.01; done; printf 'out\\n'; printf 'err\\n' >&2; exit 12",
|
||||
"brewui", release.path,
|
||||
],
|
||||
options: BrewRunOptions(lineObserver: { line in
|
||||
collector.append(line)
|
||||
guard line.isComplete, let pid = Int32(line.text) else { return }
|
||||
_ = FileManager.default.createFile(atPath: release.path, contents: nil)
|
||||
// Hold the reader until the child is reaped, leaving its final output queued.
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||
while kill(pid, 0) == 0, ContinuousClock.now < deadline {
|
||||
usleep(1000)
|
||||
}
|
||||
exitedBeforeReading.withLock { $0 = kill(pid, 0) == -1 && errno == ESRCH }
|
||||
}, output: .pseudoTerminal),
|
||||
)
|
||||
|
||||
#expect(exitedBeforeReading.withLock { $0 })
|
||||
#expect(output.standardOutput.hasSuffix("\nout\nerr\n"))
|
||||
#expect(output.terminationStatus == 12)
|
||||
#expect(collector.allLines().filter(\.isComplete).dropFirst().map(\.text) == ["out", "err"])
|
||||
}
|
||||
|
||||
@Test func `pseudo-terminal run merges stderr into the stdout stream`() async throws {
|
||||
// One device carries both streams, so stderr cannot be told apart.
|
||||
let output = try await run(
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
//
|
||||
// LoginShellBrewCommandRunnerTests.swift
|
||||
// BrewCLITests
|
||||
//
|
||||
|
||||
@testable import BrewCLI
|
||||
import BrewCore
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct LoginShellBrewCommandRunnerTests {
|
||||
// MARK: - Command construction
|
||||
|
||||
@Test func `shellCommand for posix shells uses positional parameters`() {
|
||||
let command = LoginShellBrewCommandRunner.shellCommand(
|
||||
for: URL(fileURLWithPath: "/bin/zsh"),
|
||||
marker: "MARK",
|
||||
output: .pipes(forceColor: false),
|
||||
)
|
||||
#expect(command.hasSuffix("exec \"$0\" \"$@\""))
|
||||
}
|
||||
|
||||
@Test func `shellCommand for fish uses argv`() {
|
||||
let command = LoginShellBrewCommandRunner.shellCommand(
|
||||
for: URL(fileURLWithPath: "/opt/homebrew/bin/fish"),
|
||||
marker: "MARK",
|
||||
output: .pipes(forceColor: false),
|
||||
)
|
||||
#expect(command.hasSuffix("exec $argv"))
|
||||
}
|
||||
|
||||
@Test func `shellCommand announces the marker on both streams for pipes`() {
|
||||
let command = LoginShellBrewCommandRunner.shellCommand(
|
||||
for: URL(fileURLWithPath: "/bin/zsh"),
|
||||
marker: "MARK",
|
||||
output: .pipes(forceColor: false),
|
||||
)
|
||||
#expect(command == "printf '%s\\n' 'MARK' 1>&2; printf '%s\\n' 'MARK'; exec \"$0\" \"$@\"")
|
||||
}
|
||||
|
||||
@Test func `shellCommand announces the marker once for a pseudo-terminal`() {
|
||||
let command = LoginShellBrewCommandRunner.shellCommand(
|
||||
for: URL(fileURLWithPath: "/bin/zsh"),
|
||||
marker: "MARK",
|
||||
output: .pseudoTerminal,
|
||||
)
|
||||
#expect(command == "printf '%s\\n' 'MARK'; exec \"$0\" \"$@\"")
|
||||
}
|
||||
|
||||
@Test func `removingStartupNoise cuts everything through the marker's line`() {
|
||||
let text = "hello from zshrc\nMARK\n{\"foo\":1}\n"
|
||||
#expect(LoginShellBrewCommandRunner.removingStartupNoise(from: text, upTo: "MARK") == "{\"foo\":1}\n")
|
||||
}
|
||||
|
||||
@Test func `removingStartupNoise leaves text unchanged when the marker is absent`() {
|
||||
let text = "{\"foo\":1}\n"
|
||||
#expect(LoginShellBrewCommandRunner.removingStartupNoise(from: text, upTo: "MARK") == text)
|
||||
}
|
||||
|
||||
@Test func `removingStartupNoise returns empty when the marker is the last line`() {
|
||||
let text = "startup noise\nMARK\n"
|
||||
#expect(LoginShellBrewCommandRunner.removingStartupNoise(from: text, upTo: "MARK") == "")
|
||||
}
|
||||
|
||||
@Test func `shellArguments prefixes the brew path and appends arguments`() {
|
||||
let arguments = LoginShellBrewCommandRunner.shellArguments(
|
||||
executableURL: URL(fileURLWithPath: "/opt/homebrew/bin/brew"),
|
||||
arguments: ["info", "--installed", "--json=v2"],
|
||||
)
|
||||
#expect(arguments == ["/opt/homebrew/bin/brew", "info", "--installed", "--json=v2"])
|
||||
}
|
||||
|
||||
// MARK: - Wrapping behavior
|
||||
|
||||
@Test func `run invokes the resolved login shell with -l -i -c`() async throws {
|
||||
let recorder = InvocationRecorder()
|
||||
let wrapped = LoginShellBrewCommandRunner(
|
||||
underlying: recorder,
|
||||
shellResolver: LoginShellResolver(
|
||||
lookup: { URL(fileURLWithPath: "/bin/bash") },
|
||||
),
|
||||
makeMarker: { "MARK" },
|
||||
)
|
||||
|
||||
_ = try await wrapped.run(
|
||||
executableURL: URL(fileURLWithPath: "/opt/homebrew/bin/brew"),
|
||||
arguments: ["doctor"],
|
||||
)
|
||||
|
||||
let invocation = try #require(await recorder.first)
|
||||
#expect(invocation.executableURL.path == "/bin/bash")
|
||||
#expect(invocation.arguments.count == 6)
|
||||
#expect(invocation.arguments[0] == "-l")
|
||||
#expect(invocation.arguments[1] == "-i")
|
||||
#expect(invocation.arguments[2] == "-c")
|
||||
#expect(invocation.arguments[3] == "printf '%s\\n' 'MARK' 1>&2; printf '%s\\n' 'MARK'; exec \"$0\" \"$@\"")
|
||||
#expect(invocation.arguments[4] == "/opt/homebrew/bin/brew")
|
||||
#expect(invocation.arguments[5] == "doctor")
|
||||
}
|
||||
|
||||
@Test func `run uses fish-compatible exec script when login shell is fish`() async throws {
|
||||
let recorder = InvocationRecorder()
|
||||
let wrapped = LoginShellBrewCommandRunner(
|
||||
underlying: recorder,
|
||||
shellResolver: LoginShellResolver(
|
||||
lookup: { URL(fileURLWithPath: "/opt/homebrew/bin/fish") },
|
||||
),
|
||||
makeMarker: { "MARK" },
|
||||
)
|
||||
|
||||
_ = try await wrapped.run(
|
||||
executableURL: URL(fileURLWithPath: "/opt/homebrew/bin/brew"),
|
||||
arguments: ["config", "--foo=it's"],
|
||||
)
|
||||
|
||||
let invocation = try #require(await recorder.first)
|
||||
#expect(invocation.executableURL.path == "/opt/homebrew/bin/fish")
|
||||
#expect(invocation.arguments[3] == "printf '%s\\n' 'MARK' 1>&2; printf '%s\\n' 'MARK'; exec $argv")
|
||||
#expect(invocation.arguments[4] == "/opt/homebrew/bin/brew")
|
||||
#expect(invocation.arguments[5] == "config")
|
||||
#expect(invocation.arguments[6] == "--foo=it's")
|
||||
}
|
||||
|
||||
@Test func `run falls back to default shell when Directory Services lookup yields nil`() async throws {
|
||||
let recorder = InvocationRecorder()
|
||||
let wrapped = LoginShellBrewCommandRunner(
|
||||
underlying: recorder,
|
||||
shellResolver: LoginShellResolver(lookup: { nil }),
|
||||
)
|
||||
|
||||
_ = try await wrapped.run(
|
||||
executableURL: URL(fileURLWithPath: "/opt/homebrew/bin/brew"),
|
||||
arguments: ["config"],
|
||||
)
|
||||
|
||||
let invocation = try #require(await recorder.first)
|
||||
#expect(invocation.executableURL.path == LoginShellResolver.defaultFallback.path)
|
||||
}
|
||||
|
||||
/// The shell exports what it inherits, so a variable pinned on it reaches brew.
|
||||
@Test func `run forwards the pinned environment to the shell`() async throws {
|
||||
let recorder = InvocationRecorder()
|
||||
let wrapped = LoginShellBrewCommandRunner(
|
||||
underlying: recorder,
|
||||
shellResolver: LoginShellResolver(lookup: { URL(fileURLWithPath: "/bin/zsh") }),
|
||||
)
|
||||
|
||||
_ = try await wrapped.run(
|
||||
executableURL: URL(fileURLWithPath: "/opt/homebrew/bin/brew"),
|
||||
arguments: ["upgrade"],
|
||||
options: BrewRunOptions(environment: ["HOMEBREW_NO_COLOR": "1"]),
|
||||
)
|
||||
|
||||
let invocation = try #require(await recorder.first)
|
||||
#expect(invocation.options.environment["HOMEBREW_NO_COLOR"] == "1")
|
||||
}
|
||||
|
||||
/// Reproduces the iTerm2 shell-integration report: startup noise lands on both streams ahead of
|
||||
/// the real command's output, and only the marker line tells us where it ends.
|
||||
@Test func `run strips shell startup noise from both streams`() async throws {
|
||||
let recorder = InvocationRecorder(
|
||||
stubbedOutput: CommandOutput(
|
||||
standardOutput: "hello from zshrc\nMARK\n{\"foo\":1}\n",
|
||||
standardError: "OSC 1337 junk\nMARK\nreal warning\n",
|
||||
terminationStatus: 0,
|
||||
),
|
||||
)
|
||||
let wrapped = LoginShellBrewCommandRunner(
|
||||
underlying: recorder,
|
||||
shellResolver: LoginShellResolver(lookup: { URL(fileURLWithPath: "/bin/zsh") }),
|
||||
makeMarker: { "MARK" },
|
||||
)
|
||||
|
||||
let output = try await wrapped.run(
|
||||
executableURL: URL(fileURLWithPath: "/opt/homebrew/bin/brew"),
|
||||
arguments: ["info", "--installed", "--json=v2"],
|
||||
)
|
||||
|
||||
#expect(output.standardOutput == "{\"foo\":1}\n")
|
||||
#expect(output.standardError == "real warning\n")
|
||||
}
|
||||
|
||||
@Test func `run filters the startup marker out of streamed lines`() async throws {
|
||||
let collector = LineCollector()
|
||||
let recorder = InvocationRecorder(
|
||||
linesToEmit: [
|
||||
BrewCommandOutputLine(stream: .stdout, text: "hello from zshrc"),
|
||||
BrewCommandOutputLine(stream: .stdout, text: "MARK"),
|
||||
BrewCommandOutputLine(stream: .stdout, text: "==> Installing wget"),
|
||||
],
|
||||
)
|
||||
let wrapped = LoginShellBrewCommandRunner(
|
||||
underlying: recorder,
|
||||
shellResolver: LoginShellResolver(lookup: { URL(fileURLWithPath: "/bin/zsh") }),
|
||||
makeMarker: { "MARK" },
|
||||
)
|
||||
|
||||
_ = try await wrapped.run(
|
||||
executableURL: URL(fileURLWithPath: "/opt/homebrew/bin/brew"),
|
||||
arguments: ["install", "wget"],
|
||||
options: BrewRunOptions(lineObserver: { collector.append($0.text) }, output: .pseudoTerminal),
|
||||
)
|
||||
|
||||
#expect(collector.all == ["==> Installing wget"])
|
||||
}
|
||||
|
||||
@Test func `run filters startup markers independently from stdout and stderr`() async throws {
|
||||
let collector = LineCollector()
|
||||
let recorder = InvocationRecorder(
|
||||
linesToEmit: [
|
||||
BrewCommandOutputLine(stream: .stderr, text: "stderr startup"),
|
||||
BrewCommandOutputLine(stream: .stdout, text: "stdout startup"),
|
||||
BrewCommandOutputLine(stream: .stderr, text: "MARK"),
|
||||
BrewCommandOutputLine(stream: .stdout, text: "MARK"),
|
||||
BrewCommandOutputLine(stream: .stdout, text: "Your system is ready to brew."),
|
||||
BrewCommandOutputLine(stream: .stderr, text: "real warning"),
|
||||
],
|
||||
)
|
||||
let wrapped = LoginShellBrewCommandRunner(
|
||||
underlying: recorder,
|
||||
shellResolver: LoginShellResolver(lookup: { URL(fileURLWithPath: "/bin/zsh") }),
|
||||
makeMarker: { "MARK" },
|
||||
)
|
||||
|
||||
_ = try await wrapped.run(
|
||||
executableURL: URL(fileURLWithPath: "/opt/homebrew/bin/brew"),
|
||||
arguments: ["doctor"],
|
||||
options: BrewRunOptions(lineObserver: { collector.append($0.text) }, output: .pipes(forceColor: true)),
|
||||
)
|
||||
|
||||
#expect(collector.all == ["Your system is ready to brew.", "real warning"])
|
||||
}
|
||||
|
||||
@Test func `run returns the underlying CommandOutput verbatim`() async throws {
|
||||
let expected = CommandOutput(
|
||||
standardOutput: "ok",
|
||||
standardError: "warn",
|
||||
terminationStatus: 0,
|
||||
)
|
||||
let recorder = InvocationRecorder(stubbedOutput: expected)
|
||||
let wrapped = LoginShellBrewCommandRunner(
|
||||
underlying: recorder,
|
||||
shellResolver: LoginShellResolver(lookup: { URL(fileURLWithPath: "/bin/zsh") }),
|
||||
)
|
||||
|
||||
let output = try await wrapped.run(
|
||||
executableURL: URL(fileURLWithPath: "/opt/homebrew/bin/brew"),
|
||||
arguments: ["config"],
|
||||
)
|
||||
|
||||
#expect(output.standardOutput == "ok")
|
||||
#expect(output.standardError == "warn")
|
||||
#expect(output.terminationStatus == 0)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RecordedInvocation {
|
||||
let executableURL: URL
|
||||
let arguments: [String]
|
||||
let options: BrewRunOptions
|
||||
}
|
||||
|
||||
// swiftlint:disable:next unchecked_sendable
|
||||
private final class LineCollector: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var lines: [String] = []
|
||||
|
||||
var all: [String] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return lines
|
||||
}
|
||||
|
||||
func append(_ line: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
lines.append(line)
|
||||
}
|
||||
}
|
||||
|
||||
private actor InvocationRecorder: BrewCommandRunning {
|
||||
private(set) var invocations: [RecordedInvocation] = []
|
||||
private let stubbedOutput: CommandOutput
|
||||
private let linesToEmit: [BrewCommandOutputLine]
|
||||
|
||||
init(
|
||||
stubbedOutput: CommandOutput = CommandOutput(standardOutput: "", standardError: "", terminationStatus: 0),
|
||||
linesToEmit: [BrewCommandOutputLine] = [],
|
||||
) {
|
||||
self.stubbedOutput = stubbedOutput
|
||||
self.linesToEmit = linesToEmit
|
||||
}
|
||||
|
||||
var first: RecordedInvocation? {
|
||||
invocations.first
|
||||
}
|
||||
|
||||
func run(executableURL: URL, arguments: [String], options: BrewRunOptions) async throws -> CommandOutput {
|
||||
invocations.append(RecordedInvocation(executableURL: executableURL, arguments: arguments, options: options))
|
||||
linesToEmit.forEach { options.lineObserver?($0) }
|
||||
return stubbedOutput
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
//
|
||||
// LoginShellResolverTests.swift
|
||||
// BrewCLITests
|
||||
//
|
||||
|
||||
@testable import BrewCLI
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct LoginShellResolverTests {
|
||||
@Test func `resolve returns lookup value when Directory Services yields a shell`() {
|
||||
let resolver = LoginShellResolver(
|
||||
lookup: { URL(fileURLWithPath: "/bin/bash") },
|
||||
fallback: URL(fileURLWithPath: "/bin/zsh"),
|
||||
)
|
||||
|
||||
#expect(resolver.resolve().path == "/bin/bash")
|
||||
}
|
||||
|
||||
@Test func `resolve falls back when lookup yields nil`() {
|
||||
let resolver = LoginShellResolver(
|
||||
lookup: { nil },
|
||||
fallback: URL(fileURLWithPath: "/bin/zsh"),
|
||||
)
|
||||
|
||||
#expect(resolver.resolve().path == "/bin/zsh")
|
||||
}
|
||||
|
||||
@Test func `default fallback is bin zsh`() {
|
||||
#expect(LoginShellResolver.defaultFallback.path == "/bin/zsh")
|
||||
}
|
||||
|
||||
@Test func `directoryServicesLookup returns the running user's login shell when present`() {
|
||||
// Live probe — on macOS CI and dev machines the running user always has a pw_shell entry.
|
||||
// The value itself varies (zsh, bash, fish, …), so we only assert non-nil and absolute path.
|
||||
let resolved = LoginShellResolver.directoryServicesLookup()
|
||||
try? #require(resolved != nil)
|
||||
#expect(resolved?.path.hasPrefix("/") == true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//
|
||||
// ZshBrewCommandRunnerTests.swift
|
||||
// BrewCLITests
|
||||
//
|
||||
|
||||
@testable import BrewCLI
|
||||
import BrewCore
|
||||
import Foundation
|
||||
import Synchronization
|
||||
import Testing
|
||||
|
||||
struct ZshBrewCommandRunnerTests {
|
||||
@Test func `brew receives only the explicit environment`() async throws {
|
||||
let output = try await ZshBrewCommandRunner(underlying: PollutedEnvironmentRunner()).run(
|
||||
executableURL: URL(fileURLWithPath: "/usr/bin/env"),
|
||||
arguments: [],
|
||||
)
|
||||
|
||||
let keys = output.standardOutput.split(separator: "\n").compactMap { $0.split(separator: "=", maxSplits: 1).first }
|
||||
#expect(Set(keys) == ["HOME", "USER", "LOGNAME", "TMPDIR", "LANG", "SHELL", "PATH"])
|
||||
}
|
||||
|
||||
@Test func `the executable path and arguments survive shell metacharacters`() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("brew ' $; \(UUID())")
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let executable = directory.appendingPathComponent("brew")
|
||||
try FileManager.default.createSymbolicLink(at: executable, withDestinationURL: URL(fileURLWithPath: "/usr/bin/printf"))
|
||||
let arguments = ["", "two words", "it's", "$(echo injected)", "a;b", "*", "a\nb"]
|
||||
|
||||
let output = try await ZshBrewCommandRunner().run(
|
||||
executableURL: executable,
|
||||
arguments: ["<%s>"] + arguments,
|
||||
)
|
||||
|
||||
#expect(output.standardOutput == arguments.map { "<\($0)>" }.joined())
|
||||
}
|
||||
|
||||
@Test func `user startup files are skipped`() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
for name in [".zshenv", ".zprofile", ".zshrc", ".zlogin"] {
|
||||
try "print sourced > \"$ZDOTDIR/sourced\"\n".write(to: directory.appendingPathComponent(name), atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
_ = try await ZshBrewCommandRunner().run(
|
||||
executableURL: URL(fileURLWithPath: "/usr/bin/printf"),
|
||||
arguments: ["brew output"],
|
||||
options: BrewRunOptions(environment: ["HOME": directory.path, "ZDOTDIR": directory.path]),
|
||||
)
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent("sourced").path))
|
||||
}
|
||||
|
||||
@Test func `exports from system startup are cleared before Homebrew`() async throws {
|
||||
let output = try await ZshBrewCommandRunner(
|
||||
underlying: PollutedEnvironmentRunner(startupScript: "export HOMEBREW_STARTUP_LEAK=yes PATH=/unexpected; "),
|
||||
).run(executableURL: URL(fileURLWithPath: "/usr/bin/env"), arguments: [])
|
||||
|
||||
#expect(!output.standardOutput.contains("/unexpected") && !output.standardOutput.contains("HOMEBREW_STARTUP_LEAK"))
|
||||
}
|
||||
|
||||
@Test(arguments: [BrewRunOptions.OutputChannel.pipes(forceColor: true), .pseudoTerminal])
|
||||
func `colour controls reach Homebrew`(channel: BrewRunOptions.OutputChannel) async throws {
|
||||
let output = try await ZshBrewCommandRunner().run(
|
||||
executableURL: URL(fileURLWithPath: "/bin/sh"),
|
||||
arguments: ["-c", "printf '%s|%s' \"$HOMEBREW_COLOR\" \"$CLICOLOR_FORCE\""],
|
||||
options: BrewRunOptions(output: channel),
|
||||
)
|
||||
|
||||
#expect(output.standardOutput == "1|1")
|
||||
}
|
||||
|
||||
@Test func `explicit output controls survive a terminal allocation failure`() async throws {
|
||||
let lines = Mutex<[String]>([])
|
||||
let output = try await ZshBrewCommandRunner(
|
||||
underlying: PollutedEnvironmentRunner(
|
||||
startupScript: "printf 'startup banner'; printf 'startup warning' >&2; ",
|
||||
service: BrewCommandService(makeTerminal: { throw CocoaError(.fileWriteUnknown) }),
|
||||
),
|
||||
).run(
|
||||
executableURL: URL(fileURLWithPath: "/bin/sh"),
|
||||
arguments: ["-c", "printf '%s|%s' \"$HOMEBREW_COLOR\" \"$HOMEBREW_NO_COLOR\""],
|
||||
options: BrewRunOptions(
|
||||
lineObserver: { line in lines.withLock { $0.append(line.text) } },
|
||||
output: .pseudoTerminal,
|
||||
environment: ["HOMEBREW_NO_COLOR": "1"],
|
||||
),
|
||||
)
|
||||
|
||||
#expect(output.standardOutput == "1|1")
|
||||
#expect(output.standardError.isEmpty)
|
||||
#expect(lines.withLock { $0 } == ["1|1"])
|
||||
}
|
||||
|
||||
@Test func `PATH and SHELL cannot be overridden`() async throws {
|
||||
let output = try await ZshBrewCommandRunner().run(
|
||||
executableURL: URL(fileURLWithPath: "/bin/sh"),
|
||||
arguments: ["-c", "printf '%s|%s' \"$PATH\" \"$SHELL\""],
|
||||
options: BrewRunOptions(environment: ["PATH": "/unexpected", "SHELL": "/bin/bash"]),
|
||||
)
|
||||
|
||||
#expect(output.standardOutput == "/bin:/usr/bin:/bin|/bin/zsh")
|
||||
}
|
||||
|
||||
@Test func `buffered output and failure status are preserved`() async throws {
|
||||
let output = try await ZshBrewCommandRunner().run(
|
||||
executableURL: URL(fileURLWithPath: "/bin/sh"),
|
||||
arguments: ["-c", "printf 'output'; printf 'warning' >&2; exit 12"],
|
||||
)
|
||||
|
||||
#expect(output == CommandOutput(standardOutput: "output", standardError: "warning", terminationStatus: 12))
|
||||
}
|
||||
|
||||
@Test func `streaming preserves the first output on both streams`() async throws {
|
||||
let lines = Mutex<[String]>([])
|
||||
_ = try await ZshBrewCommandRunner().run(
|
||||
executableURL: URL(fileURLWithPath: "/bin/sh"),
|
||||
arguments: ["-c", "printf 'output\\n'; printf 'warning\\n' >&2"],
|
||||
options: BrewRunOptions(lineObserver: { line in lines.withLock { $0.append(line.text) } }),
|
||||
)
|
||||
|
||||
#expect(lines.withLock { $0.sorted() } == ["output", "warning"])
|
||||
}
|
||||
|
||||
@Test(arguments: [BrewRunOptions.OutputChannel.pipes(forceColor: false), .pseudoTerminal])
|
||||
func `system startup banners are removed from buffered and streamed output`(channel: BrewRunOptions.OutputChannel) async throws {
|
||||
let lines = Mutex<[BrewCommandOutputLine]>([])
|
||||
let output = try await ZshBrewCommandRunner(
|
||||
underlying: PollutedEnvironmentRunner(
|
||||
startupScript: "printf 'startup\\nbanner'; printf 'startup\\nwarning' >&2; sleep 0.05; ",
|
||||
),
|
||||
).run(
|
||||
executableURL: URL(fileURLWithPath: "/bin/sh"),
|
||||
arguments: ["-c", "printf '{\"formulae\":[]}\\n'; printf 'brew warning\\n' >&2; exit 12"],
|
||||
options: BrewRunOptions(lineObserver: { line in lines.withLock { $0.append(line) } }, output: channel),
|
||||
)
|
||||
|
||||
#expect(output.standardOutput == (channel == .pseudoTerminal ? "{\"formulae\":[]}\nbrew warning\n" : "{\"formulae\":[]}\n"))
|
||||
#expect(output.standardError == (channel == .pseudoTerminal ? "" : "brew warning\n"))
|
||||
#expect(output.terminationStatus == 12)
|
||||
#expect(lines.withLock { $0.filter(\.isComplete).map(\.text).sorted() } == ["brew warning", "{\"formulae\":[]}"])
|
||||
#expect(lines.withLock { $0.allSatisfy { "{\"formulae\":[]}".hasPrefix($0.text) || "brew warning".hasPrefix($0.text) } })
|
||||
}
|
||||
|
||||
@Test(arguments: [BrewRunOptions.OutputChannel.pipes(forceColor: false), .pseudoTerminal])
|
||||
func `startup failures retain their diagnostics`(channel: BrewRunOptions.OutputChannel) async throws {
|
||||
let lines = Mutex<[BrewCommandOutputLine]>([])
|
||||
let output = try await ZshBrewCommandRunner(
|
||||
underlying: PollutedEnvironmentRunner(startupScript: "printf 'startup failed\\n' >&2; exit 17; "),
|
||||
).run(
|
||||
executableURL: URL(fileURLWithPath: "/usr/bin/printf"),
|
||||
arguments: ["unreachable"],
|
||||
options: BrewRunOptions(lineObserver: { line in lines.withLock { $0.append(line) } }, output: channel),
|
||||
)
|
||||
|
||||
#expect(output.standardOutput == (channel == .pseudoTerminal ? "startup failed\n" : ""))
|
||||
#expect(output.standardError == (channel == .pseudoTerminal ? "" : "startup failed\n"))
|
||||
#expect(output.terminationStatus == 17)
|
||||
#expect(lines.withLock { $0.filter(\.isComplete).map(\.text) } == ["startup failed"])
|
||||
}
|
||||
}
|
||||
|
||||
private struct PollutedEnvironmentRunner: BrewCommandRunning {
|
||||
var environment = [
|
||||
"HOMEBREW_NO_INSTALL_FROM_API": "1",
|
||||
"HOMEBREW_NO_ENV_FILE": "1",
|
||||
"HOMEBREW_CASK_OPTS": "--no-quarantine",
|
||||
"PATH": "/bin:/usr/bin:/unexpected",
|
||||
"SHELL": "/nonexistent/shell",
|
||||
"XDG_CONFIG_HOME": "/unexpected/config",
|
||||
"BASH_ENV": "/unexpected/bashenv",
|
||||
]
|
||||
var startupScript = ""
|
||||
var service = BrewCommandService()
|
||||
|
||||
func run(executableURL: URL, arguments: [String], options: BrewRunOptions) async throws -> CommandOutput {
|
||||
var arguments = arguments
|
||||
// Simulate /etc/zshenv without modifying the machine's startup files.
|
||||
if let index = arguments.firstIndex(of: "-c") {
|
||||
arguments[index + 1] = startupScript + arguments[index + 1]
|
||||
}
|
||||
var options = options
|
||||
options.environment.merge(environment) { _, polluted in polluted }
|
||||
return try await service.run(executableURL: executableURL, arguments: arguments, options: options)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ struct SelfUpgradeHandoffSpecTests {
|
||||
relaunchEnvironment: ["BREW_UITEST_SCENARIO": "selfUpgradeAvailable"],
|
||||
brewExecutablePath: "/opt/homebrew/bin/brew",
|
||||
upgradeArguments: ["upgrade", "--cask", "homebrew-app"],
|
||||
usesLoginShell: true,
|
||||
upgradeEnvironment: ["BREW_UITEST_FIXTURES": "/tmp/fixtures"],
|
||||
logFilePath: "/tmp/self-upgrade.log",
|
||||
defaultsSuiteName: "sh.brew.app",
|
||||
@@ -49,7 +48,6 @@ struct SelfUpgradeHandoffSpecTests {
|
||||
#expect(decoded.brewExecutablePath == "/opt/homebrew/bin/brew")
|
||||
#expect(decoded.upgradeArguments == ["upgrade", "--cask", "homebrew-app"])
|
||||
#expect(decoded.upgradeEnvironment["BREW_UITEST_FIXTURES"] == "/tmp/fixtures")
|
||||
#expect(decoded.usesLoginShell)
|
||||
#expect(decoded.logFilePath == "/tmp/self-upgrade.log")
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ struct SelfUpgradeRunnerTests {
|
||||
let brew = try FakeExecutable(script: "exit 0")
|
||||
defer { brew.remove() }
|
||||
|
||||
let outcome = await FakeExecutable.runner().run(
|
||||
let outcome = await SelfUpgradeRunner().run(
|
||||
executablePath: brew.path,
|
||||
arguments: ["upgrade", "--cask", "homebrew-app"],
|
||||
environment: [:],
|
||||
@@ -32,7 +32,7 @@ struct SelfUpgradeRunnerTests {
|
||||
let brew = try FakeExecutable(script: "exit 12")
|
||||
defer { brew.remove() }
|
||||
|
||||
let outcome = await FakeExecutable.runner().run(
|
||||
let outcome = await SelfUpgradeRunner().run(
|
||||
executablePath: brew.path,
|
||||
arguments: ["upgrade"],
|
||||
environment: [:],
|
||||
@@ -47,7 +47,7 @@ struct SelfUpgradeRunnerTests {
|
||||
let brew = try FakeExecutable(script: "kill -TERM $$; sleep 5")
|
||||
defer { brew.remove() }
|
||||
|
||||
let outcome = await FakeExecutable.runner().run(
|
||||
let outcome = await SelfUpgradeRunner().run(
|
||||
executablePath: brew.path,
|
||||
arguments: [],
|
||||
environment: [:],
|
||||
@@ -61,7 +61,7 @@ struct SelfUpgradeRunnerTests {
|
||||
|
||||
/// The app resolves `brew` before it quits, so the path can be stale by the time the helper runs it.
|
||||
@Test func `a path with no executable fails without launching anything`() async {
|
||||
let outcome = await FakeExecutable.runner().run(
|
||||
let outcome = await SelfUpgradeRunner().run(
|
||||
executablePath: "/nonexistent/brew",
|
||||
arguments: ["upgrade"],
|
||||
environment: [:],
|
||||
@@ -73,7 +73,7 @@ struct SelfUpgradeRunnerTests {
|
||||
}
|
||||
|
||||
@Test func `a directory is not an executable`() async {
|
||||
let outcome = await FakeExecutable.runner().run(
|
||||
let outcome = await SelfUpgradeRunner().run(
|
||||
executablePath: NSTemporaryDirectory(),
|
||||
arguments: ["upgrade"],
|
||||
environment: [:],
|
||||
@@ -91,7 +91,7 @@ struct SelfUpgradeRunnerTests {
|
||||
defer { brew.remove() }
|
||||
|
||||
let started = Date()
|
||||
let outcome = await FakeExecutable.runner().run(
|
||||
let outcome = await SelfUpgradeRunner().run(
|
||||
executablePath: brew.path,
|
||||
arguments: [],
|
||||
environment: [:],
|
||||
@@ -202,7 +202,7 @@ struct SelfUpgradeRunnerTests {
|
||||
defer { brew.remove() }
|
||||
|
||||
let transcript = TranscriptRecorder()
|
||||
let outcome = await FakeExecutable.runner(transcriptSink: { transcript.append($0) }).run(
|
||||
let outcome = await SelfUpgradeRunner(transcriptSink: { transcript.append($0) }).run(
|
||||
executablePath: brew.path,
|
||||
arguments: [],
|
||||
environment: [:],
|
||||
@@ -219,7 +219,7 @@ struct SelfUpgradeRunnerTests {
|
||||
defer { brew.remove() }
|
||||
|
||||
let transcript = TranscriptRecorder()
|
||||
_ = await FakeExecutable.runner(transcriptSink: { transcript.append($0) }).run(
|
||||
_ = await SelfUpgradeRunner(transcriptSink: { transcript.append($0) }).run(
|
||||
executablePath: brew.path,
|
||||
arguments: [],
|
||||
environment: [:],
|
||||
@@ -235,7 +235,7 @@ struct SelfUpgradeRunnerTests {
|
||||
defer { brew.remove() }
|
||||
|
||||
let transcript = TranscriptRecorder()
|
||||
let outcome = await FakeExecutable.runner(transcriptSink: { transcript.append($0) }).run(
|
||||
let outcome = await SelfUpgradeRunner(transcriptSink: { transcript.append($0) }).run(
|
||||
executablePath: brew.path,
|
||||
arguments: [],
|
||||
environment: [:],
|
||||
@@ -253,7 +253,7 @@ struct SelfUpgradeRunnerTests {
|
||||
defer { brew.remove() }
|
||||
|
||||
let transcript = TranscriptRecorder()
|
||||
_ = await FakeExecutable.runner(transcriptSink: { transcript.append($0) }).run(
|
||||
_ = await SelfUpgradeRunner(transcriptSink: { transcript.append($0) }).run(
|
||||
executablePath: brew.path,
|
||||
arguments: ["upgrade", "--cask", "homebrew-app"],
|
||||
environment: [:],
|
||||
@@ -269,7 +269,7 @@ struct SelfUpgradeRunnerTests {
|
||||
defer { brew.remove() }
|
||||
|
||||
let transcript = TranscriptRecorder()
|
||||
_ = await FakeExecutable.runner(transcriptSink: { transcript.append($0) }).run(
|
||||
_ = await SelfUpgradeRunner(transcriptSink: { transcript.append($0) }).run(
|
||||
executablePath: brew.path,
|
||||
arguments: [],
|
||||
environment: ["BREW_UITEST_SCENARIO": "selfUpgradeAvailable"],
|
||||
@@ -279,19 +279,19 @@ struct SelfUpgradeRunnerTests {
|
||||
#expect(transcript.lines == ["selfUpgradeAvailable"])
|
||||
}
|
||||
|
||||
@Test func `the inherited environment survives alongside the pinned one`() async throws {
|
||||
@Test func `self upgrade uses the same restricted PATH as the app`() async throws {
|
||||
let brew = try FakeExecutable(script: #"echo "${PATH}""#)
|
||||
defer { brew.remove() }
|
||||
|
||||
let transcript = TranscriptRecorder()
|
||||
_ = await FakeExecutable.runner(transcriptSink: { transcript.append($0) }).run(
|
||||
_ = await SelfUpgradeRunner(transcriptSink: { transcript.append($0) }).run(
|
||||
executablePath: brew.path,
|
||||
arguments: [],
|
||||
environment: ["PINNED": "yes"],
|
||||
timeout: 30,
|
||||
)
|
||||
|
||||
#expect(transcript.lines == [ProcessInfo.processInfo.environment["PATH"]])
|
||||
#expect(transcript.lines == [brew.url.deletingLastPathComponent().path + ":/usr/bin:/bin"])
|
||||
}
|
||||
|
||||
/// The transcript is a file, so escape codes in it are noise rather than colour.
|
||||
@@ -350,12 +350,6 @@ private struct FakeExecutable {
|
||||
func remove() {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
/// Invoked directly, for the reason `BrewCommandExecutionContext.uiTesting(brewURL:)` does the same:
|
||||
/// the login shell would source the developer's dotfiles.
|
||||
static func runner(transcriptSink: @escaping @Sendable (String) -> Void = { _ in }) -> SelfUpgradeRunner {
|
||||
SelfUpgradeRunner(usesLoginShell: false, transcriptSink: transcriptSink)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fires the timeout once the fake `brew` has started, rather than on a wall-clock guess.
|
||||
|
||||
Reference in New Issue
Block a user