mirror of
https://github.com/Homebrew/BrewUI.git
synced 2026-10-02 00:24:33 +08:00
Update brew before the outdated check
brew info never auto-updates, so its API data can be up to 7 days old and tap clones never refresh. Automatic loads and the reconcile after an operation run brew update-if-needed, which applies HOMEBREW_NO_AUTO_UPDATE and the update interval itself. A user's refresh runs brew update. Fixes #235
This commit is contained in:
@@ -1,47 +0,0 @@
|
||||
//
|
||||
// BrewConfigEnvironmentReader.swift
|
||||
// BrewCLI
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import Foundation
|
||||
|
||||
/// Asks `brew config`, which prints `HOMEBREW_NO_INSTALL_FROM_API: set` only when it is set.
|
||||
/// 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"
|
||||
|
||||
private let commandRunner: any BrewCommandRunning
|
||||
private let locator: any BrewExecutableLocating
|
||||
private var probed: Bool?
|
||||
|
||||
public init(commandRunner: any BrewCommandRunning, locator: any BrewExecutableLocating) {
|
||||
self.commandRunner = commandRunner
|
||||
self.locator = locator
|
||||
}
|
||||
|
||||
public init(executionContext: BrewCommandExecutionContext) {
|
||||
self.init(commandRunner: executionContext.commandRunner, locator: executionContext.locator)
|
||||
}
|
||||
|
||||
public func isInstallFromAPIDisabled() async -> Bool {
|
||||
if let probed {
|
||||
return probed
|
||||
}
|
||||
let result = await probe()
|
||||
probed = result
|
||||
return result
|
||||
}
|
||||
|
||||
/// Falls back to the API path; the caller's own brew invocation surfaces the real problem.
|
||||
private func probe() async -> Bool {
|
||||
guard let brew = try? locator.findBrewExecutable(),
|
||||
let output = try? await commandRunner.run(executableURL: brew, arguments: ["config"]),
|
||||
output.terminationStatus == 0
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return BrewConfigParser.parse(output.standardOutput).entries
|
||||
.contains { $0.key == Self.noInstallFromAPIKey }
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
//
|
||||
// HomebrewEnvironmentReading.swift
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 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
|
||||
}
|
||||
@@ -36,29 +36,17 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository
|
||||
@ObservationIgnored private let commandRunner: BrewCommandRunning
|
||||
@ObservationIgnored private let locator: any BrewExecutableLocating
|
||||
@ObservationIgnored private let cache: InstalledInventoryCache
|
||||
@ObservationIgnored private let environment: any HomebrewEnvironmentReading
|
||||
@ObservationIgnored private let now: @Sendable () -> Date
|
||||
/// Newest fetch in flight; fetches chain onto it so two refreshes cannot apply out of order.
|
||||
@ObservationIgnored private var fetchTask: Task<Void, Never>?
|
||||
|
||||
/// Every mutating operation forces a fetch, so the tap refresh runs on an interval instead.
|
||||
@ObservationIgnored private var lastTapUpdateAttempt: Date?
|
||||
|
||||
/// Homebrew's own `HOMEBREW_AUTO_UPDATE_SECS` default for the no-API path.
|
||||
private static let tapRefreshInterval: TimeInterval = 300
|
||||
|
||||
public init(
|
||||
commandRunner: BrewCommandRunning,
|
||||
locator: any BrewExecutableLocating,
|
||||
cache: InstalledInventoryCache,
|
||||
environment: any HomebrewEnvironmentReading,
|
||||
now: @escaping @Sendable () -> Date = Date.init,
|
||||
) {
|
||||
self.commandRunner = commandRunner
|
||||
self.locator = locator
|
||||
self.cache = cache
|
||||
self.environment = environment
|
||||
self.now = now
|
||||
}
|
||||
|
||||
/// Takes the context rather than building its own runner, so the composition root points every
|
||||
@@ -71,7 +59,6 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository
|
||||
commandRunner: executionContext.commandRunner,
|
||||
locator: executionContext.locator,
|
||||
cache: cache,
|
||||
environment: BrewConfigEnvironmentReader(executionContext: executionContext),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -97,7 +84,7 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository
|
||||
/// (Call `load()` — the no-arg convenience — via ``InstalledInventoryObserving``.)
|
||||
public func load(forceRefresh: Bool) async {
|
||||
guard !forceRefresh else {
|
||||
await fetchAndStore()
|
||||
await fetchAndStore(userRequested: true)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -106,9 +93,9 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository
|
||||
apply(packages)
|
||||
case let .stale(packages):
|
||||
apply(packages)
|
||||
await fetchAndStore()
|
||||
await fetchAndStore(userRequested: false)
|
||||
case .empty:
|
||||
await fetchAndStore()
|
||||
await fetchAndStore(userRequested: false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,11 +103,11 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository
|
||||
|
||||
/// Waits for any fetch already in flight, then fetches. Joining it instead would answer a reconcile
|
||||
/// with a snapshot taken before `brew` exited.
|
||||
private func fetchAndStore() async {
|
||||
private func fetchAndStore(userRequested: Bool) async {
|
||||
let previous = fetchTask
|
||||
let task = Task { @MainActor [weak self] in
|
||||
await previous?.value
|
||||
await self?.performFetch()
|
||||
await self?.performFetch(userRequested: userRequested)
|
||||
}
|
||||
fetchTask = task
|
||||
await task.value
|
||||
@@ -129,9 +116,9 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository
|
||||
}
|
||||
}
|
||||
|
||||
private func performFetch() async {
|
||||
private func performFetch(userRequested: Bool) async {
|
||||
do {
|
||||
let packages = try await fetchInstalledPackages()
|
||||
let packages = try await fetchInstalledPackages(userRequested: userRequested)
|
||||
// Only a completed fetch clears this; repainting a cached snapshot answers nothing.
|
||||
refreshFailure = nil
|
||||
apply(packages)
|
||||
@@ -156,9 +143,9 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository
|
||||
lookup = Dictionary(packages.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
|
||||
}
|
||||
|
||||
private func fetchInstalledPackages() async throws -> [InstalledBrewPackage] {
|
||||
private func fetchInstalledPackages(userRequested: Bool) async throws -> [InstalledBrewPackage] {
|
||||
let brew = try locator.findBrewExecutable()
|
||||
await updateTapsIfNeeded(executable: brew)
|
||||
await updateBrew(executable: brew, userRequested: userRequested)
|
||||
let output = try await runInstalledInfoJSON(executable: brew)
|
||||
let payload = try decodeInfoJSON(from: output)
|
||||
let packages = payload.installedPackages()
|
||||
@@ -167,20 +154,14 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository
|
||||
return packages
|
||||
}
|
||||
|
||||
/// `brew info` is not auto-updated by brew, and with the API off its data comes from tap clones —
|
||||
/// so without this the outdated check answers from the user's last manual `brew update`, forever.
|
||||
private func updateTapsIfNeeded(executable: URL) async {
|
||||
guard await environment.isInstallFromAPIDisabled() else {
|
||||
return
|
||||
}
|
||||
if let lastTapUpdateAttempt, now().timeIntervalSince(lastTapUpdateAttempt) < Self.tapRefreshInterval {
|
||||
return
|
||||
}
|
||||
lastTapUpdateAttempt = now()
|
||||
/// `brew info` never triggers brew's auto-update, so its API data can be 7 days old and tap clones
|
||||
/// never refresh. A user's refresh runs `brew update`; automatic fetches run
|
||||
/// `brew update-if-needed`, which updates only under the user's own auto-update settings.
|
||||
private func updateBrew(executable: URL, userRequested: Bool) async {
|
||||
do {
|
||||
let output = try await commandRunner.run(
|
||||
executableURL: executable,
|
||||
arguments: ["update", "--auto-update", "--quiet"],
|
||||
arguments: userRequested ? ["update", "--quiet"] : ["update-if-needed"],
|
||||
)
|
||||
guard output.terminationStatus == 0 else {
|
||||
throw BrewCommandError.failed(exitCode: output.terminationStatus, stderr: output.standardError)
|
||||
@@ -218,9 +199,10 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository
|
||||
|
||||
extension BrewInstalledPackagesRepository: BrewOperationReconciling {
|
||||
/// Refetches the inventory before the command center publishes the terminal phase. Survives
|
||||
/// cancellation of the submitting task, since ``fetchAndStore()`` works in an unstructured task.
|
||||
/// cancellation of the submitting task, since ``fetchAndStore(userRequested:)`` works in an
|
||||
/// unstructured task.
|
||||
public func reconcile() async {
|
||||
await load(forceRefresh: true)
|
||||
await fetchAndStore(userRequested: false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ public protocol InstalledInventoryObserving: Observable, Sendable {
|
||||
/// revalidation, so without this a surface cannot tell "nothing outdated" from "never found out".
|
||||
var refreshFailure: (any Error)? { get }
|
||||
|
||||
/// `forceRefresh` is the user's explicit refresh, so it also runs a full `brew update`.
|
||||
func load(forceRefresh: Bool) async
|
||||
}
|
||||
|
||||
|
||||
@@ -15,19 +15,6 @@ public struct MissingBrewExecutableLocator: BrewExecutableLocating {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed ``HomebrewEnvironmentReading`` answer, so tests need no `brew config`.
|
||||
public struct StubHomebrewEnvironment: HomebrewEnvironmentReading {
|
||||
private let installFromAPIDisabled: Bool
|
||||
|
||||
public init(installFromAPIDisabled: Bool) {
|
||||
self.installFromAPIDisabled = installFromAPIDisabled
|
||||
}
|
||||
|
||||
public func isInstallFromAPIDisabled() async -> Bool {
|
||||
installFromAPIDisabled
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-invocation result for ``MockBrewCommandRunner``.
|
||||
public enum MockBrewCommandRunnerBehavior: Sendable {
|
||||
case output(CommandOutput)
|
||||
@@ -77,9 +64,12 @@ public actor QueuedBrewInfoRunner: BrewCommandRunning {
|
||||
|
||||
public func run(
|
||||
executableURL _: URL,
|
||||
arguments _: [String],
|
||||
arguments: [String],
|
||||
options _: BrewRunOptions,
|
||||
) async throws -> CommandOutput {
|
||||
guard arguments.first == "info" else {
|
||||
return CommandOutput(standardOutput: "", standardError: "", terminationStatus: 0)
|
||||
}
|
||||
let output = outputs[min(index, outputs.count - 1)]
|
||||
if index < outputs.count - 1 {
|
||||
index += 1
|
||||
|
||||
@@ -20,8 +20,6 @@ public enum InstalledPackagesTestSupport {
|
||||
commandRunner: BrewCommandRunning,
|
||||
locator: (any BrewExecutableLocating)? = nil,
|
||||
cache: InstalledInventoryCache? = nil,
|
||||
environment: any HomebrewEnvironmentReading = StubHomebrewEnvironment(installFromAPIDisabled: false),
|
||||
now: @escaping @Sendable () -> Date = Date.init,
|
||||
) -> BrewInstalledPackagesRepository {
|
||||
let resolvedCache = cache ?? InstalledInventoryCache()
|
||||
let resolvedLocator = locator ?? BrewExecutableLocator(overrideURL: fakeBrewExecutableURL)
|
||||
@@ -29,8 +27,6 @@ public enum InstalledPackagesTestSupport {
|
||||
commandRunner: commandRunner,
|
||||
locator: resolvedLocator,
|
||||
cache: resolvedCache,
|
||||
environment: environment,
|
||||
now: now,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
//
|
||||
// BrewConfigEnvironmentReaderTests.swift
|
||||
// BrewTests
|
||||
//
|
||||
|
||||
import BrewCLI
|
||||
import BrewCore
|
||||
import BrewServicesTestSupport
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct BrewConfigEnvironmentReaderTests {
|
||||
private static let brewURL = URL(fileURLWithPath: "/fake/brew")
|
||||
|
||||
/// `brew config` prints the row only when the variable is set, so presence is the signal.
|
||||
private static func configOutput(includingNoInstallFromAPI: Bool) -> String {
|
||||
var lines = [
|
||||
"HOMEBREW_VERSION: 6.0.20",
|
||||
"HOMEBREW_PREFIX: /opt/homebrew",
|
||||
]
|
||||
if includingNoInstallFromAPI {
|
||||
lines.append("HOMEBREW_NO_INSTALL_FROM_API: set")
|
||||
}
|
||||
lines.append("macOS: 26.5-arm64")
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func reader(
|
||||
behaviors: [[String]: MockBrewCommandRunnerBehavior],
|
||||
) -> BrewConfigEnvironmentReader {
|
||||
BrewConfigEnvironmentReader(
|
||||
commandRunner: MockBrewCommandRunner(behaviors: behaviors),
|
||||
locator: BrewExecutableLocator(overrideURL: brewURL),
|
||||
)
|
||||
}
|
||||
|
||||
@Test func `reports the API disabled when brew config lists the variable`() async {
|
||||
let reader = Self.reader(behaviors: [
|
||||
["config"]: .output(CommandOutput(
|
||||
standardOutput: Self.configOutput(includingNoInstallFromAPI: true),
|
||||
standardError: "",
|
||||
terminationStatus: 0,
|
||||
)),
|
||||
])
|
||||
|
||||
#expect(await reader.isInstallFromAPIDisabled())
|
||||
}
|
||||
|
||||
@Test func `reports the API in use when brew config omits the variable`() async {
|
||||
let reader = Self.reader(behaviors: [
|
||||
["config"]: .output(CommandOutput(
|
||||
standardOutput: Self.configOutput(includingNoInstallFromAPI: false),
|
||||
standardError: "",
|
||||
terminationStatus: 0,
|
||||
)),
|
||||
])
|
||||
|
||||
#expect(await reader.isInstallFromAPIDisabled() == false)
|
||||
}
|
||||
|
||||
@Test func `falls back to the API path when brew config cannot run`() async {
|
||||
let reader = Self.reader(behaviors: [
|
||||
["config"]: .throw(BrewCommandError.launchFailed(underlying: "could not spawn brew")),
|
||||
])
|
||||
|
||||
#expect(await reader.isInstallFromAPIDisabled() == false)
|
||||
}
|
||||
|
||||
@Test func `falls back to the API path when brew config exits non zero`() async {
|
||||
let reader = Self.reader(behaviors: [
|
||||
["config"]: .output(CommandOutput(
|
||||
standardOutput: Self.configOutput(includingNoInstallFromAPI: true),
|
||||
standardError: "boom",
|
||||
terminationStatus: 1,
|
||||
)),
|
||||
])
|
||||
|
||||
// Non-zero output is not trustworthy, even when it happens to contain the row.
|
||||
#expect(await reader.isInstallFromAPIDisabled() == false)
|
||||
}
|
||||
|
||||
@Test func `falls back to the API path when brew cannot be located`() async {
|
||||
let reader = BrewConfigEnvironmentReader(
|
||||
commandRunner: MockBrewCommandRunner(behaviors: [:]),
|
||||
locator: MissingBrewExecutableLocator(),
|
||||
)
|
||||
|
||||
#expect(await reader.isInstallFromAPIDisabled() == false)
|
||||
}
|
||||
|
||||
@Test func `the answer is probed once and reused`() async {
|
||||
let runner = CountingConfigRunner(
|
||||
standardOutput: Self.configOutput(includingNoInstallFromAPI: true),
|
||||
)
|
||||
let reader = BrewConfigEnvironmentReader(
|
||||
commandRunner: runner,
|
||||
locator: BrewExecutableLocator(overrideURL: Self.brewURL),
|
||||
)
|
||||
|
||||
#expect(await reader.isInstallFromAPIDisabled())
|
||||
#expect(await reader.isInstallFromAPIDisabled())
|
||||
|
||||
#expect(await runner.callCount == 1)
|
||||
}
|
||||
}
|
||||
|
||||
private actor CountingConfigRunner: BrewCommandRunning {
|
||||
private let standardOutput: String
|
||||
private(set) var callCount = 0
|
||||
|
||||
init(standardOutput: String) {
|
||||
self.standardOutput = standardOutput
|
||||
}
|
||||
|
||||
func run(executableURL _: URL, arguments _: [String], options _: BrewRunOptions) async throws -> CommandOutput {
|
||||
callCount += 1
|
||||
return CommandOutput(standardOutput: standardOutput, standardError: "", terminationStatus: 0)
|
||||
}
|
||||
}
|
||||
@@ -469,7 +469,10 @@ private actor ConcurrencyTrackingInfoRunner: BrewCommandRunning {
|
||||
private(set) var maxConcurrent = 0
|
||||
private var current = 0
|
||||
|
||||
func run(executableURL _: URL, arguments _: [String], options _: BrewRunOptions) async throws -> CommandOutput {
|
||||
func run(executableURL _: URL, arguments: [String], options _: BrewRunOptions) async throws -> CommandOutput {
|
||||
guard arguments.first == "info" else {
|
||||
return CommandOutput(standardOutput: "", standardError: "", terminationStatus: 0)
|
||||
}
|
||||
callCount += 1
|
||||
current += 1
|
||||
maxConcurrent = max(maxConcurrent, current)
|
||||
@@ -492,7 +495,10 @@ private actor FlakyInfoRunner: BrewCommandRunning {
|
||||
self.firstError = firstError
|
||||
}
|
||||
|
||||
func run(executableURL _: URL, arguments _: [String], options _: BrewRunOptions) async throws -> CommandOutput {
|
||||
func run(executableURL _: URL, arguments: [String], options _: BrewRunOptions) async throws -> CommandOutput {
|
||||
guard arguments.first == "info" else {
|
||||
return CommandOutput(standardOutput: "", standardError: "", terminationStatus: 0)
|
||||
}
|
||||
guard didThrow else {
|
||||
didThrow = true
|
||||
throw firstError
|
||||
@@ -509,7 +515,10 @@ private actor FlakyInfoRunner: BrewCommandRunning {
|
||||
private actor CountingInfoRunner: BrewCommandRunning {
|
||||
private(set) var callCount = 0
|
||||
|
||||
func run(executableURL _: URL, arguments _: [String], options _: BrewRunOptions) async throws -> CommandOutput {
|
||||
func run(executableURL _: URL, arguments: [String], options _: BrewRunOptions) async throws -> CommandOutput {
|
||||
guard arguments.first == "info" else {
|
||||
return CommandOutput(standardOutput: "", standardError: "", terminationStatus: 0)
|
||||
}
|
||||
callCount += 1
|
||||
return CommandOutput(
|
||||
standardOutput: #"{ "formulae": [], "casks": [] }"#,
|
||||
|
||||
@@ -11,59 +11,62 @@ import BrewServicesTestSupport
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
/// With the API off, package data comes from tap clones that only `brew update` refreshes.
|
||||
/// `brew info` never auto-updates, so brew is updated first: fully when the user asks, otherwise
|
||||
/// through `brew update-if-needed` so brew's own auto-update settings apply.
|
||||
struct BrewInstalledPackagesTapRefreshTests {
|
||||
private static let emptyInfoJSON = #"{ "formulae": [], "casks": [] }"#
|
||||
private static let update = ["update", "--quiet"]
|
||||
private static let updateIfNeeded = ["update-if-needed"]
|
||||
private static let info = ["info", "--installed", "--json=v2"]
|
||||
|
||||
@Test @MainActor func `taps are updated before the outdated check when the API is disabled`() async {
|
||||
@Test @MainActor func `a user refresh runs a full brew update every time`() async {
|
||||
let runner = RecordingCommandRunner(infoJSON: Self.emptyInfoJSON)
|
||||
let repo = InstalledPackagesTestSupport.repository(
|
||||
commandRunner: runner,
|
||||
environment: StubHomebrewEnvironment(installFromAPIDisabled: true),
|
||||
)
|
||||
|
||||
await repo.load(forceRefresh: true)
|
||||
await repo.load(forceRefresh: true)
|
||||
|
||||
#expect(await runner.invocations == [
|
||||
["update", "--auto-update", "--quiet"],
|
||||
["info", "--installed", "--json=v2"],
|
||||
])
|
||||
#expect(await runner.invocations == [Self.update, Self.info, Self.update, Self.info])
|
||||
#expect(repo.state.isLoaded)
|
||||
}
|
||||
|
||||
@Test @MainActor func `taps are left alone when brew reads from the API`() async {
|
||||
@Test @MainActor func `a first load lets brew decide whether to update`() async {
|
||||
let runner = RecordingCommandRunner(infoJSON: Self.emptyInfoJSON)
|
||||
let repo = InstalledPackagesTestSupport.repository(
|
||||
commandRunner: runner,
|
||||
environment: StubHomebrewEnvironment(installFromAPIDisabled: false),
|
||||
)
|
||||
|
||||
await repo.load(forceRefresh: true)
|
||||
await repo.load()
|
||||
|
||||
// brew refreshes the API files on its own TTL, so an update here is pure cost.
|
||||
#expect(await runner.invocations == [["info", "--installed", "--json=v2"]])
|
||||
#expect(await runner.invocations == [Self.updateIfNeeded, Self.info])
|
||||
}
|
||||
|
||||
@Test @MainActor func `the tap update runs on an interval rather than before every fetch`() async {
|
||||
let clock = MutableClock(now: Date(timeIntervalSince1970: 0))
|
||||
@Test @MainActor func `the reconcile after an operation lets brew decide whether to update`() async {
|
||||
let runner = RecordingCommandRunner(infoJSON: Self.emptyInfoJSON)
|
||||
let repo = InstalledPackagesTestSupport.repository(
|
||||
commandRunner: runner,
|
||||
environment: StubHomebrewEnvironment(installFromAPIDisabled: true),
|
||||
now: clock.dateProvider,
|
||||
)
|
||||
|
||||
await repo.load(forceRefresh: true)
|
||||
clock.now = Date(timeIntervalSince1970: 120)
|
||||
await repo.load(forceRefresh: true)
|
||||
await repo.reconcile()
|
||||
|
||||
#expect(await runner.count(of: ["update", "--auto-update", "--quiet"]) == 1)
|
||||
#expect(await runner.invocations == [Self.updateIfNeeded, Self.info])
|
||||
#expect(repo.state.isLoaded)
|
||||
}
|
||||
|
||||
// Past Homebrew's 5-minute interval for this mode.
|
||||
clock.now = Date(timeIntervalSince1970: 400)
|
||||
await repo.load(forceRefresh: true)
|
||||
@Test @MainActor func `every automatic fetch leaves the update interval to brew`() async {
|
||||
let cache = InstalledInventoryCache()
|
||||
let runner = RecordingCommandRunner(infoJSON: Self.emptyInfoJSON)
|
||||
let repo = InstalledPackagesTestSupport.repository(
|
||||
commandRunner: runner,
|
||||
cache: cache,
|
||||
)
|
||||
|
||||
#expect(await runner.count(of: ["update", "--auto-update", "--quiet"]) == 2)
|
||||
await Self.automaticFetch(repo, cache: cache)
|
||||
await Self.automaticFetch(repo, cache: cache)
|
||||
|
||||
#expect(await runner.count(of: Self.updateIfNeeded) == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor func `a failed tap update still lets the outdated check answer`() async {
|
||||
@@ -74,7 +77,6 @@ struct BrewInstalledPackagesTapRefreshTests {
|
||||
)
|
||||
let repo = InstalledPackagesTestSupport.repository(
|
||||
commandRunner: runner,
|
||||
environment: StubHomebrewEnvironment(installFromAPIDisabled: true),
|
||||
)
|
||||
|
||||
await repo.load(forceRefresh: true)
|
||||
@@ -84,26 +86,6 @@ struct BrewInstalledPackagesTapRefreshTests {
|
||||
#expect(await runner.invocations.contains(["info", "--installed", "--json=v2"]))
|
||||
}
|
||||
|
||||
@Test @MainActor func `a persistently failing tap update does not stall every fetch`() async {
|
||||
let clock = MutableClock(now: Date(timeIntervalSince1970: 0))
|
||||
let runner = RecordingCommandRunner(
|
||||
infoJSON: Self.emptyInfoJSON,
|
||||
updateBehavior: .throwing,
|
||||
)
|
||||
let repo = InstalledPackagesTestSupport.repository(
|
||||
commandRunner: runner,
|
||||
environment: StubHomebrewEnvironment(installFromAPIDisabled: true),
|
||||
now: clock.dateProvider,
|
||||
)
|
||||
|
||||
await repo.load(forceRefresh: true)
|
||||
clock.now = Date(timeIntervalSince1970: 60)
|
||||
await repo.load(forceRefresh: true)
|
||||
|
||||
// The attempt is timestamped even when it fails, so the interval still applies.
|
||||
#expect(await runner.count(of: ["update", "--auto-update", "--quiet"]) == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func `a cache-first load that skips the fetch also skips the tap update`() async {
|
||||
let cache = InstalledInventoryCache()
|
||||
await cache.replace(
|
||||
@@ -113,39 +95,28 @@ struct BrewInstalledPackagesTapRefreshTests {
|
||||
let repo = InstalledPackagesTestSupport.repository(
|
||||
commandRunner: runner,
|
||||
cache: cache,
|
||||
environment: StubHomebrewEnvironment(installFromAPIDisabled: true),
|
||||
)
|
||||
|
||||
await repo.load()
|
||||
|
||||
#expect(await runner.invocations.isEmpty)
|
||||
}
|
||||
|
||||
/// A stale cache is what launch and tab loads find, so `load()` refetches without the user asking.
|
||||
@MainActor
|
||||
private static func automaticFetch(_ repo: BrewInstalledPackagesRepository, cache: InstalledInventoryCache) async {
|
||||
await cache.replace(InstalledInventorySnapshot(fetchedAt: .distantPast, packages: []))
|
||||
await repo.load()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Doubles
|
||||
|
||||
/// Mutable time source, so interval behaviour is asserted without waiting.
|
||||
@MainActor
|
||||
private final class MutableClock {
|
||||
var now: Date
|
||||
|
||||
init(now: Date) {
|
||||
self.now = now
|
||||
}
|
||||
|
||||
nonisolated var dateProvider: @Sendable () -> Date {
|
||||
// The repository is @MainActor; the synchronous @Sendable closure type can't say so.
|
||||
// swiftlint:disable:next assume_isolated
|
||||
{ MainActor.assumeIsolated { self.now } }
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the `brew` argument lists it was asked to run.
|
||||
private actor RecordingCommandRunner: BrewCommandRunning {
|
||||
enum UpdateBehavior {
|
||||
case success
|
||||
case failure(exitCode: Int32, stderr: String)
|
||||
case throwing
|
||||
}
|
||||
|
||||
private let infoJSON: String
|
||||
@@ -163,7 +134,7 @@ private actor RecordingCommandRunner: BrewCommandRunning {
|
||||
|
||||
func run(executableURL _: URL, arguments: [String], options _: BrewRunOptions) async throws -> CommandOutput {
|
||||
invocations.append(arguments)
|
||||
guard arguments.first == "update" else {
|
||||
guard arguments.first == "update-if-needed" || arguments.first == "update" else {
|
||||
return CommandOutput(standardOutput: infoJSON, standardError: "", terminationStatus: 0)
|
||||
}
|
||||
switch updateBehavior {
|
||||
@@ -171,8 +142,6 @@ private actor RecordingCommandRunner: BrewCommandRunning {
|
||||
return CommandOutput(standardOutput: "", standardError: "", terminationStatus: 0)
|
||||
case let .failure(exitCode, stderr):
|
||||
return CommandOutput(standardOutput: "", standardError: stderr, terminationStatus: exitCode)
|
||||
case .throwing:
|
||||
throw BrewCommandError.launchFailed(underlying: "could not spawn brew")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user