diff --git a/.ai/memory.md b/.ai/memory.md index 589bee6d..e640931e 100644 --- a/.ai/memory.md +++ b/.ai/memory.md @@ -584,3 +584,11 @@ - **The renderer and the transcript must agree character-for-character.** Offsets address the rendered text, so `ConsoleTranscript.text(of:)` and `ANSIConsoleText.attributed(for:)` both emit spans-joined + `"\n"`. A unit test pins the invariant (`rendered length == transcript.length`) because nothing else would catch drift. - **Follow-the-tail is owned by a scroll observer, not by the update.** `ConsoleOutputTextView` gives up following when the user scrolls away and takes it back when they return to the end — growing the document doesn't move the clip view, so the bounds-change notification only ever fires for a real scroll. The pin also runs from `layout()`: SwiftUI hands over the first batch of output *before* the scroll view has any size, and a scroll issued then goes nowhere (symptom: opening the console on a finished job showed the top of the log). - **Verified by driving the real app** (DYLD-injected driver, see the 2026-08-25 note): drag across lines selects four lines; `selectAll:` validates on the first responder and selects the whole document; hit-testing the bottom edge of the output area lands on the text view (no arrow-cursor dead zone); a 200-character selection survives ~1100 characters of streamed output; scrolling away leaves the reader in place. Menu-routed ⌘A could **not** be exercised — a bare exec never becomes the key app — so that path rests on the responder validating `selectAll:`. + +## 2026-08-31 — The outdated check answers from `brew info`, which brew never auto-updates + +- **`brew info` is not an auto-update command.** `Library/Homebrew/utils/auto-update.sh` lists `install`, `outdated`, `upgrade`, `bundle`, `release` (plus `tap` with args). `brew info --installed --json=v2` — the app's only source of outdated state — is not among them. With the JSON API in play that is harmless: brew re-fetches `api/formula.jws.json` on its own TTL whenever a command reads it. Under **`HOMEBREW_NO_INSTALL_FROM_API` there is no such refresh** — formula and cask data come from tap git clones, so the app reported whatever the taps held the last time the user ran `brew update` by hand, forever. `BrewInstalledPackagesRepository.updateTapsIfNeeded` now runs `brew update --auto-update --quiet` (the same command `brew upgrade` runs) ahead of the info fetch, on Homebrew's own 300s interval for this mode. +- **The app cannot read `HOMEBREW_*` from its own process.** It is launched by Finder, not from a shell, so a profile-exported variable is invisible to `ProcessInfo` while being fully in effect for every brew invocation the app makes through `LoginShellBrewCommandRunner` (see 2026-06-23). `BrewConfigEnvironmentReader` asks brew instead: `brew config` prints `HOMEBREW_NO_INSTALL_FROM_API: set` **only when it is set**, so presence of the row is the signal. Probed once per process — changing it means editing a shell profile, which does not take effect for a running app anyway — and it falls back to the API path whenever `brew config` cannot be run or exits non-zero. +- **A failed tap update is deliberately not fatal.** The taps keep their previous contents, and the `brew info` fetch is what decides whether the check produced an answer at all. The *attempt* is timestamped whether or not it succeeded, so a persistently failing `brew update` cannot stall every fetch behind it (each mutating operation reconciles with a forced fetch). +- **`state` alone cannot express "the check failed".** The repository deliberately keeps cached packages `.loaded` when a refresh fails, so a surface reading only `state` presents a stale zero as fact — the app claiming "everything is up to date" when it never found out. `InstalledInventoryObserving.refreshFailure` carries the last failure, **cleared only by a fetch that completes**: clearing it in `apply(_:)` would have let a cache-first repaint (which fetches nothing) silently turn "couldn't check" back into "nothing to upgrade". +- **A revision bump moves `revision`, not `versions.stable`.** Reading `versions.stable` alone made the Upgrades tab advertise a target identical to the installed keg (ffmpeg `9.0.1` revision 1 rendered "v9.0.1 → v9.0.1"). `HomebrewPkgVersion` renders Homebrew's own `PkgVersion` (`version_revision` when the revision is non-zero) and both the `brew info` mapping and the formula catalogue go through it. diff --git a/Homebrew/BrewApp.swift b/Homebrew/BrewApp.swift index 5573797b..23ce2711 100644 --- a/Homebrew/BrewApp.swift +++ b/Homebrew/BrewApp.swift @@ -211,6 +211,7 @@ struct BrewApp: App { .commands { SearchCommands() SidebarCommands() + RefreshCommands() ConsoleCommands() // Replace the default "Homebrew Help" item (which points at a diff --git a/Homebrew/Features/MainWindow/Views/MainWindowView.swift b/Homebrew/Features/MainWindow/Views/MainWindowView.swift index 793aefd0..39aa54c8 100644 --- a/Homebrew/Features/MainWindow/Views/MainWindowView.swift +++ b/Homebrew/Features/MainWindow/Views/MainWindowView.swift @@ -6,10 +6,15 @@ import BrewFeatureConsole import BrewFeatureDiscover import BrewFeatureDoctor import BrewFeatureInstalled +import BrewRepositoryInterfaces import BrewUIComponents import SwiftUI struct MainWindowView: View { + @Environment(\.installedPackagesRepository) private var installedPackagesRepository + @Environment(\.discoverPackagesRepository) private var discoverPackagesRepository + @Environment(\.configRepository) private var configRepository + @State var selectedSidebarItem: SidebarItem = .installed @State private var pendingInstalledSelection: InstalledBrewPackage.ID? @SceneStorage("consoleExpanded") private var consoleExpanded: Bool = false @@ -36,12 +41,24 @@ struct MainWindowView: View { .navigationSplitViewStyle(.automatic) .focusedSceneValue(\.consoleExpanded, $consoleExpanded) .focusedSceneValue(\.sidebarSelection, $selectedSidebarItem) + .focusedSceneValue(\.refreshAll, RefreshAllAction { refreshAll() }) .environment(\.navigateToInstalledPackage) { id in pendingInstalledSelection = id selectedSidebarItem = .installed } } + /// ⌘R refetches every cached surface at once, whichever tab is showing, since the sidebar counts and + /// the other tabs go stale just as readily as the visible one. Doctor is deliberately left out: it + /// shells out to a slow `brew doctor` run and keeps its own explicit "Run Again". + private func refreshAll() { + Task { + await installedPackagesRepository.load(forceRefresh: true) + await discoverPackagesRepository.load(forceRefresh: true) + await configRepository.load(forceRefresh: true) + } + } + /// Approximate catalogue size for the Discover subtitle. Hardcoded for now; should eventually be /// sourced from the catalogue once a package-count property is exposed. private static let approximateCatalogueSize = "9,000+" @@ -88,8 +105,6 @@ struct MainWindowView: View { } #if DEBUG - import BrewRepositoryInterfaces - #Preview { MainWindowView() .environment(\.brewCommandCenter, PreviewSupport.commandCenter) diff --git a/Sources/BrewAccessibilityID/AXID.swift b/Sources/BrewAccessibilityID/AXID.swift index 6b704c0b..d201003e 100644 --- a/Sources/BrewAccessibilityID/AXID.swift +++ b/Sources/BrewAccessibilityID/AXID.swift @@ -24,6 +24,7 @@ public enum AXID: Hashable, Sendable { case upgradesScreen case upgradesList case upgradesRow(token: String) + case upgradesRefreshButton // Discover case discoverScreen @@ -68,6 +69,7 @@ public enum AXID: Hashable, Sendable { case .upgradesScreen: "upgrades.screen" case .upgradesList: "upgrades.list" case let .upgradesRow(token): "upgrades.row.\(token)" + case .upgradesRefreshButton: "upgrades.refresh" case .discoverScreen: "discover.screen" case .discoverSearchField: "discover.search" case .discoverList: "discover.list" diff --git a/Sources/BrewAppEnvironment/UnimplementedRepositories.swift b/Sources/BrewAppEnvironment/UnimplementedRepositories.swift index fccd6fd7..4dc9c8fd 100644 --- a/Sources/BrewAppEnvironment/UnimplementedRepositories.swift +++ b/Sources/BrewAppEnvironment/UnimplementedRepositories.swift @@ -19,6 +19,10 @@ final class UnimplementedInstalledPackagesRepository: InstalledPackagesRepositor unimplemented() } + var refreshFailure: (any Error)? { + unimplemented() + } + func load(forceRefresh _: Bool) async { unimplemented() } diff --git a/Sources/BrewCLI/Config/BrewConfigEnvironmentReader.swift b/Sources/BrewCLI/Config/BrewConfigEnvironmentReader.swift new file mode 100644 index 00000000..27f7c827 --- /dev/null +++ b/Sources/BrewCLI/Config/BrewConfigEnvironmentReader.swift @@ -0,0 +1,47 @@ +// +// 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: changing it means editing a shell profile, which needs a relaunch anyway. +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 } + } +} diff --git a/Sources/BrewCLI/JSON/BrewInfoJSON+Mapping.swift b/Sources/BrewCLI/JSON/BrewInfoJSON+Mapping.swift index 005325f2..c6170aba 100644 --- a/Sources/BrewCLI/JSON/BrewInfoJSON+Mapping.swift +++ b/Sources/BrewCLI/JSON/BrewInfoJSON+Mapping.swift @@ -32,7 +32,7 @@ private extension BrewInfoFormula { kind: .formula, description: BrewInfoJSON.trimmedOrEmpty(desc), homepage: BrewInfoJSON.trimmedOrEmpty(homepage), - latestVersion: BrewInfoJSON.trimmedOrEmpty(versions.stable), + latestVersion: HomebrewPkgVersion.string(version: versions.stable, revision: revision) ?? "", dependencies: HomebrewPackageID.formulaDependencies(from: dependencies), ), installedVersions: installedVersions, diff --git a/Sources/BrewCLI/JSON/BrewInfoJSON.swift b/Sources/BrewCLI/JSON/BrewInfoJSON.swift index a30af33c..39a9528d 100644 --- a/Sources/BrewCLI/JSON/BrewInfoJSON.swift +++ b/Sources/BrewCLI/JSON/BrewInfoJSON.swift @@ -33,6 +33,7 @@ struct BrewInfoFormula: Decodable { var dependencies: [String] var rubySourcePath: String? var versions: BrewInfoFormulaVersions + var revision: Int var installed: [BrewInfoFormulaInstalled] var linkedKeg: String? var pinned: Bool @@ -52,6 +53,7 @@ struct BrewInfoFormula: Decodable { rubySourcePath = try? container.decode(String.self, forKey: .rubySourcePath) versions = (try? container.decode(BrewInfoFormulaVersions.self, forKey: .versions)) ?? BrewInfoFormulaVersions(stable: nil) + revision = (try? container.decode(Int.self, forKey: .revision)) ?? 0 installed = (try? container.decode([BrewInfoFormulaInstalled].self, forKey: .installed)) ?? [] linkedKeg = try? container.decode(String.self, forKey: .linkedKeg) @@ -71,6 +73,7 @@ struct BrewInfoFormula: Decodable { case dependencies case rubySourcePath = "ruby_source_path" case versions + case revision case installed case linkedKeg = "linked_keg" case pinned diff --git a/Sources/BrewCore/Operations/HomebrewEnvironmentReading.swift b/Sources/BrewCore/Operations/HomebrewEnvironmentReading.swift new file mode 100644 index 00000000..e2ed762f --- /dev/null +++ b/Sources/BrewCore/Operations/HomebrewEnvironmentReading.swift @@ -0,0 +1,13 @@ +// +// HomebrewEnvironmentReading.swift +// BrewCore +// + +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. +public protocol HomebrewEnvironmentReading: Sendable { + /// True when brew resolves packages from local tap clones rather than the JSON API. + func isInstallFromAPIDisabled() async -> Bool +} diff --git a/Sources/BrewCore/Support/HomebrewPkgVersion.swift b/Sources/BrewCore/Support/HomebrewPkgVersion.swift new file mode 100644 index 00000000..da56066e --- /dev/null +++ b/Sources/BrewCore/Support/HomebrewPkgVersion.swift @@ -0,0 +1,20 @@ +// +// HomebrewPkgVersion.swift +// BrewCore +// + +import Foundation + +/// Homebrew's `PkgVersion`: `version_revision` when the revision is non-zero, as the keg is named. +/// A revision bump leaves `versions.stable` untouched, so the bare version reads as no upgrade at all. +public enum HomebrewPkgVersion { + public static func string(version: String?, revision: Int?) -> String? { + guard let trimmed = version?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + guard let revision, revision > 0 else { + return trimmed + } + return "\(trimmed)_\(revision)" + } +} diff --git a/Sources/BrewFeatureDiscover/Views/DiscoverPackageDetailView.swift b/Sources/BrewFeatureDiscover/Views/DiscoverPackageDetailView.swift index 07ae0c36..18aeb2c3 100644 --- a/Sources/BrewFeatureDiscover/Views/DiscoverPackageDetailView.swift +++ b/Sources/BrewFeatureDiscover/Views/DiscoverPackageDetailView.swift @@ -147,7 +147,7 @@ private struct DiscoverPackageDetailMetadataSection: View { valueFontWeight: .heavy, ) } - detailRow(label: "Latest stable", value: viewModel.stableVersionLabel) + detailRow(label: "Latest version", value: viewModel.stableVersionLabel) if viewModel.showsInstallMetrics { detailRow(label: "30-day installs", value: viewModel.installs30DayLabel) } diff --git a/Sources/BrewFeatureInstalled/ViewModels/UpgradesUpToDateCopy.swift b/Sources/BrewFeatureInstalled/ViewModels/UpgradesUpToDateCopy.swift new file mode 100644 index 00000000..f13937d5 --- /dev/null +++ b/Sources/BrewFeatureInstalled/ViewModels/UpgradesUpToDateCopy.swift @@ -0,0 +1,36 @@ +// +// UpgradesUpToDateCopy.swift +// BrewFeatureInstalled +// + +import Foundation + +/// One phrase for "nothing to upgrade", shared by the four places the tab makes that claim at once. +enum UpgradesUpToDateCopy { + static var headline: String { + String( + localized: "Everything is up to date", + comment: "Upgrades tab: canonical phrase for having no upgrades available", + ) + } + + static func installedDetail(count: Int) -> String { + switch count { + case 0: + String( + localized: "No installed packages to check.", + comment: "Upgrades empty state when nothing is installed", + ) + case 1: + String( + localized: "Your installed package is up to date.", + comment: "Upgrades empty state for a single installed package", + ) + default: + String( + localized: "All \(count) installed packages are up to date.", + comment: "Upgrades empty state with total installed count", + ) + } + } +} diff --git a/Sources/BrewFeatureInstalled/ViewModels/UpgradesViewModel.swift b/Sources/BrewFeatureInstalled/ViewModels/UpgradesViewModel.swift index 79501fa2..e506adc9 100644 --- a/Sources/BrewFeatureInstalled/ViewModels/UpgradesViewModel.swift +++ b/Sources/BrewFeatureInstalled/ViewModels/UpgradesViewModel.swift @@ -40,6 +40,9 @@ final class UpgradesViewModel { private var runningIDs: Set = [] + /// The repository stays `.loaded` through a revalidation, so Refresh needs its own signal. + private(set) var isRefreshing = false + var state: LoadState { switch repository.state { case .loading: @@ -82,6 +85,15 @@ final class UpgradesViewModel { repository.outdatedCount } + var upgradeCheckFailureMessage: String? { + repository.refreshFailure.map(Self.userMessage(for:)) + } + + /// No upgrades to show, and a failed check means the app cannot vouch for that. + var showsUpgradeCheckFailure: Bool { + totalOutdatedCount == 0 && state.isLoaded && upgradeCheckFailureMessage != nil + } + /// Initial fetch with no rows yet — show blocking spinner. private var shouldShowInitialLoadingIndicator: Bool { if case .loading = state { @@ -98,19 +110,29 @@ final class UpgradesViewModel { if shouldShowInitialLoadingIndicator { return String(localized: "Loading packages…", comment: "Upgrades tab subtitle while fetching") } - if isFiltering { - return filteredSubtitle + if showsUpgradeCheckFailure { + return Self.upgradeCheckFailedTitle } - return inventorySubtitle + let subtitle = isFiltering ? filteredSubtitle : inventorySubtitle + guard upgradeCheckFailureMessage != nil else { + return subtitle + } + // The count came from the last check that succeeded, so it must not read as current. + return String( + localized: "\(subtitle) — last check failed", + comment: "Upgrades tab subtitle when cached upgrades are shown after a failed re-check", + ) } + static let upgradeCheckFailedTitle = String( + localized: "Couldn't check for upgrades", + comment: "Upgrades tab: the outdated check failed, so the tab cannot report an answer", + ) + private var inventorySubtitle: String { switch totalOutdatedCount { case 0: - String( - localized: "All packages are up to date", - comment: "Upgrades tab subtitle when nothing is outdated", - ) + UpgradesUpToDateCopy.headline case 1: String( localized: "1 package can be upgraded", @@ -180,6 +202,8 @@ final class UpgradesViewModel { } func refresh() async { + isRefreshing = true + defer { isRefreshing = false } await repository.load(forceRefresh: true) } @@ -359,15 +383,33 @@ extension UpgradesViewModel { } var emptyUpgradeActionTitle: String { + if showsUpgradeCheckFailure { + return Self.upgradeCheckFailedTitle + } if isFilteringOutEveryUpgrade { return String( localized: "Nothing to upgrade here", comment: "Upgrades header stand-in when filters hide every available upgrade", ) } + return UpgradesUpToDateCopy.headline + } + + var upToDateTitle: String { + UpgradesUpToDateCopy.headline + } + + var upToDateDetail: String { + UpgradesUpToDateCopy.installedDetail(count: totalInstalledCount) + } + + var upgradeCheckFailureDetail: String { + guard let message = upgradeCheckFailureMessage else { + return "" + } return String( - localized: "Nothing to upgrade", - comment: "Upgrades header stand-in when no package is outdated", + localized: "\(message)\n\nUntil this succeeds the app can't tell whether anything needs upgrading.", + comment: "Upgrades empty state under a failed check: brew's error, then why the list is empty", ) } diff --git a/Sources/BrewFeatureInstalled/Views/InstalledPackageDetailSubviewSections.swift b/Sources/BrewFeatureInstalled/Views/InstalledPackageDetailSubviewSections.swift index de51f72f..7c738162 100644 --- a/Sources/BrewFeatureInstalled/Views/InstalledPackageDetailSubviewSections.swift +++ b/Sources/BrewFeatureInstalled/Views/InstalledPackageDetailSubviewSections.swift @@ -96,7 +96,7 @@ struct InstalledPackageDetailMetadataSection: View { valueColor: metadata.isOutdated ? .brewStatusWarning : .brewTextPrimary, valueFontWeight: .heavy, ) - detailRow(label: "Latest stable", value: metadata.latestVersionValue) + detailRow(label: "Latest version", value: metadata.latestVersionValue) if let dateValue = metadata.installDateValue { detailRow(label: "Installed on", value: dateValue) } diff --git a/Sources/BrewFeatureInstalled/Views/UpgradesHeaderView.swift b/Sources/BrewFeatureInstalled/Views/UpgradesHeaderView.swift new file mode 100644 index 00000000..5b37a882 --- /dev/null +++ b/Sources/BrewFeatureInstalled/Views/UpgradesHeaderView.swift @@ -0,0 +1,109 @@ +// +// UpgradesHeaderView.swift +// BrewFeatureInstalled +// + +import BrewAccessibilityID +import BrewCore +import BrewUIComponents +import SwiftUI + +/// Top of the Upgrades tab: title, subtitle, the bulk `brew upgrade` command, and the action row. +struct UpgradesHeaderView: View { + let viewModel: UpgradesViewModel + + var body: some View { + VStack(alignment: .leading, spacing: BrewSpacing.md) { + VStack(alignment: .leading, spacing: BrewSpacing.xs) { + Text("Available upgrades") + .font(.brewTitle2) + .foregroundStyle(Color.brewTextPrimary) + Text(viewModel.outdatedSubtitle) + .font(.brewSubheadline) + .foregroundStyle(Color.brewTextSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityHeading(.h1) + + if viewModel.state.isLoaded { + CommandBlockView( + command: viewModel.bulkUpgradeDisplayCommand, + summaryText: viewModel.bulkUpgradeSummary, + ) + + actionRow + } + } + .padding(BrewSpacing.lg) + } + + private var actionRow: some View { + HStack(spacing: BrewSpacing.sm) { + if viewModel.outdatedCount > 0 { + upgradeAllButton + } else { + nothingToUpgradeIndicator + } + Spacer(minLength: 0) + refreshButton + } + .frame(height: BrewLayout.headerActionHeight) + } + + private var upgradeAllButton: some View { + Button { + viewModel.upgradeAll() + } label: { + Text("Upgrade All (\(viewModel.outdatedCount))") + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .keyboardShortcut("u", modifiers: [.command, .shift]) + .disabled(viewModel.isUpgradingAny) + .accessibilityLabel("Upgrade all \(viewModel.outdatedCount) packages") + } + + /// ⌘R is the window-wide refresh (``RefreshCommands``); this button is the upgrades-only one. + private var refreshButton: some View { + Button { + Task { await viewModel.refresh() } + } label: { + // The label stays laid out while hidden, so swapping in the spinner cannot resize the button. + Label("Refresh", systemImage: "arrow.clockwise") + .opacity(viewModel.isRefreshing ? 0 : 1) + .overlay { + if viewModel.isRefreshing { + ProgressView() + .controlSize(.small) + } + } + } + .controlSize(.regular) + .disabled(viewModel.isRefreshing) + .accessibilityLabel("Check for upgrades again") + .axid(.upgradesRefreshButton) + } + + /// A green tick claims nothing needs upgrading; a failed check has no such claim to make. + private var nothingToUpgradeIndicator: some View { + HStack { + statusGlyph + .accessibilityHidden(true) + Text(viewModel.emptyUpgradeActionTitle) + .foregroundStyle(Color.brewTextSecondary) + } + .font(.brewBody) + } + + @ViewBuilder + private var statusGlyph: some View { + if viewModel.showsUpgradeCheckFailure { + Image(systemName: "exclamationmark.triangle.fill") + .brewWarningGlyphStyle() + } else { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.brewStatusSuccess) + } + } +} diff --git a/Sources/BrewFeatureInstalled/Views/UpgradesPackagesView.swift b/Sources/BrewFeatureInstalled/Views/UpgradesPackagesView.swift index 592d350a..afc66779 100644 --- a/Sources/BrewFeatureInstalled/Views/UpgradesPackagesView.swift +++ b/Sources/BrewFeatureInstalled/Views/UpgradesPackagesView.swift @@ -16,7 +16,7 @@ struct UpgradesPackagesView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - header + UpgradesHeaderView(viewModel: viewModel) if viewModel.totalOutdatedCount > 0 { scopePicker @@ -30,6 +30,8 @@ struct UpgradesPackagesView: View { if content.packages.isEmpty { if viewModel.totalOutdatedCount > 0 { noSearchMatchesState + } else if viewModel.showsUpgradeCheckFailure { + upgradeCheckFailedState } else { allCaughtUpState } @@ -47,63 +49,6 @@ struct UpgradesPackagesView: View { } } - private var header: some View { - VStack(alignment: .leading, spacing: BrewSpacing.md) { - VStack(alignment: .leading, spacing: BrewSpacing.xs) { - Text("Available upgrades") - .font(.brewTitle2) - .foregroundStyle(Color.brewTextPrimary) - Text(viewModel.outdatedSubtitle) - .font(.brewSubheadline) - .foregroundStyle(Color.brewTextSecondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) - .accessibilityHeading(.h1) - - if viewModel.state.isLoaded { - CommandBlockView( - command: viewModel.bulkUpgradeDisplayCommand, - summaryText: viewModel.bulkUpgradeSummary, - ) - - upgradeAction - } - } - .padding(BrewSpacing.lg) - } - - private var upgradeAction: some View { - Group { - if viewModel.outdatedCount > 0 { - Button { - viewModel.upgradeAll() - } label: { - Text("Upgrade All (\(viewModel.outdatedCount))") - } - .buttonStyle(.borderedProminent) - .controlSize(.regular) - .keyboardShortcut("u", modifiers: [.command, .shift]) - .disabled(viewModel.isUpgradingAny) - .accessibilityLabel("Upgrade all \(viewModel.outdatedCount) packages") - } else { - nothingToUpgradeIndicator - } - } - .frame(height: BrewLayout.headerActionHeight) - } - - private var nothingToUpgradeIndicator: some View { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(Color.brewStatusSuccess) - .accessibilityHidden(true) - Text(viewModel.emptyUpgradeActionTitle) - .foregroundStyle(Color.brewTextSecondary) - } - .font(.brewBody) - } - /// Kind filter shown whenever there is outdated inventory to narrow. Filters client-side; never refetches. private var scopePicker: some View { Picker("Scope", selection: $viewModel.scope) { @@ -187,37 +132,56 @@ struct UpgradesPackagesView: View { private var allCaughtUpState: some View { centeredEmptyState( - title: "✅ You're all caught up", - subtitle: allCaughtUpSubtitle, - actionTitle: "Refresh", - accessibilityLabel: "All packages are up to date", - ) { - Task { await viewModel.refresh() } - } + title: viewModel.upToDateTitle, + subtitle: viewModel.upToDateDetail, + accessibilityLabel: viewModel.upToDateTitle, + ) + } + + /// An empty list after a failed check means "unknown", not "up to date". + private var upgradeCheckFailedState: some View { + centeredEmptyState( + title: UpgradesViewModel.upgradeCheckFailedTitle, + subtitle: viewModel.upgradeCheckFailureDetail, + actionTitle: "Try Again", + accessibilityLabel: UpgradesViewModel.upgradeCheckFailedTitle, + icon: { + Image(systemName: "exclamationmark.triangle.fill") + .font(.brewTitle2) + .brewWarningGlyphStyle() + }, + action: { Task { await viewModel.refresh() } }, + ) + .axid(.errorState) } /// Shown when the active filters (scope and/or search) hide every outdated package but upgrades /// still exist in the inventory — distinct from the "all caught up" state. private var noSearchMatchesState: some View { centeredEmptyState( - title: "No matching upgrades", + title: String( + localized: "No matching upgrades", + comment: "Upgrades filter-empty state title", + ), subtitle: noSearchMatchesSubtitle, actionTitle: "Show all upgrades", accessibilityLabel: noSearchMatchesSubtitle, - ) { - viewModel.resetFilters() - } + action: { viewModel.resetFilters() }, + ) } private func centeredEmptyState( - title: LocalizedStringKey, + title: String, subtitle: String, - actionTitle: LocalizedStringKey, + actionTitle: LocalizedStringKey? = nil, accessibilityLabel: String, - action: @escaping () -> Void, + @ViewBuilder icon: () -> some View = { EmptyView() }, + action: (() -> Void)? = nil, ) -> some View { VStack(spacing: BrewSpacing.md) { Spacer(minLength: 0) + icon() + .accessibilityHidden(true) Text(title) .font(.brewTitle2) .foregroundStyle(Color.brewTextPrimary) @@ -225,9 +189,11 @@ struct UpgradesPackagesView: View { .font(.brewCallout) .foregroundStyle(Color.brewTextSecondary) .multilineTextAlignment(.center) - Button(action: action) { Text(actionTitle) } - .controlSize(.regular) - .padding(.top, BrewSpacing.sm) + if let actionTitle, let action { + Button(action: action) { Text(actionTitle) } + .controlSize(.regular) + .padding(.top, BrewSpacing.sm) + } Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -236,27 +202,6 @@ struct UpgradesPackagesView: View { .accessibilityLabel(accessibilityLabel) } - private var allCaughtUpSubtitle: String { - let total = viewModel.totalInstalledCount - switch total { - case 0: - return String( - localized: "No installed packages to check.", - comment: "Upgrades empty state when nothing is installed", - ) - case 1: - return String( - localized: "Your installed package is at its latest version.", - comment: "Upgrades empty state for a single installed package", - ) - default: - return String( - localized: "All \(total) packages are at their latest versions.", - comment: "Upgrades empty state with total installed count", - ) - } - } - private var noSearchMatchesSubtitle: String { let hidden = viewModel.totalOutdatedCount if hidden == 1 { @@ -308,6 +253,28 @@ struct UpgradesPackagesView: View { .frame(minWidth: 360, minHeight: 500) } + #Preview("Upgrades list - check failed") { + let viewModel = UpgradesViewModel( + repository: StubInstalledPackagesRepository( + packages: [PreviewSupport.currentCask], + refreshFailure: BrewCommandError.failed( + exitCode: 1, + stderr: "fatal: not a git repository (or any of the parent directories): .git", + ), + ), + brewCommandCenter: PreviewSupport.commandCenter, + commandFactory: PreviewSupport.mutatingCommandFactory, + ) + SearchFocusPreviewHost { focus in + UpgradesPackagesView(viewModel: viewModel, focus: focus) + } + .environment(\.brewCommandCenter, PreviewSupport.commandCenter) + .task { + await viewModel.load() + } + .frame(minWidth: 360, minHeight: 500) + } + #Preview("Upgrades list - empty") { let viewModel = UpgradesViewModel( repository: StubInstalledPackagesRepository(packages: [ diff --git a/Sources/BrewNetworking/CatalogueJSON.swift b/Sources/BrewNetworking/CatalogueJSON.swift index 98b0e6f2..92f641a8 100644 --- a/Sources/BrewNetworking/CatalogueJSON.swift +++ b/Sources/BrewNetworking/CatalogueJSON.swift @@ -34,6 +34,7 @@ public struct FormulaCatalogueItemJSON: Codable, Sendable { public let desc: String public let homepage: String public let versions: Versions + public let revision: Int? public let dependencies: [String] public struct Versions: Codable, Sendable { @@ -81,7 +82,7 @@ public extension FormulaCatalogueItemJSON { } var stableVersion: String { - versions.stable + HomebrewPkgVersion.string(version: versions.stable, revision: revision) ?? versions.stable } var dependencyReferences: [HomebrewPackageID] { diff --git a/Sources/BrewRepositories/BrewInstalledPackagesRepository.swift b/Sources/BrewRepositories/BrewInstalledPackagesRepository.swift index 9d319a7b..2fb55582 100644 --- a/Sources/BrewRepositories/BrewInstalledPackagesRepository.swift +++ b/Sources/BrewRepositories/BrewInstalledPackagesRepository.swift @@ -27,6 +27,8 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository /// layers (view models) map it to user-facing copy. `failed` only when there is no data to show. public private(set) var state: LoadState<[InstalledBrewPackage], any Error> = .loading + public private(set) var refreshFailure: (any Error)? + /// O(1) membership/info lookups, kept in lock-step with ``state``. Tracked by observation so /// row views re-render when an install/uninstall changes a package's presence. private var lookup: [HomebrewPackageID: InstalledBrewPackage] = [:] @@ -35,18 +37,30 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository @ObservationIgnored private let locator: any BrewExecutableLocating @ObservationIgnored private let cache: InstalledInventoryCache @ObservationIgnored private let commandCenter: any BrewCommandCenter + @ObservationIgnored private let environment: any HomebrewEnvironmentReading + @ObservationIgnored private let now: @Sendable () -> Date @ObservationIgnored private var completionObserverTask: Task? + /// 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, commandCenter: any BrewCommandCenter, + environment: any HomebrewEnvironmentReading, + now: @escaping @Sendable () -> Date = Date.init, ) { self.commandRunner = commandRunner self.locator = locator self.cache = cache self.commandCenter = commandCenter + self.environment = environment + self.now = now completionObserverTask = Task { @MainActor [weak self] in await self?.observeOperationCompletions() } @@ -64,6 +78,7 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository locator: executionContext.locator, cache: cache, commandCenter: commandCenter, + environment: BrewConfigEnvironmentReader(executionContext: executionContext), ) } @@ -138,11 +153,14 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository private func fetchAndStore() async { do { let packages = try await fetchInstalledPackages() + // Only a completed fetch clears this; repainting a cached snapshot answers nothing. + refreshFailure = nil apply(packages) } catch is CancellationError { return } catch { // Keep showing cached data if we have any; only surface an error with nothing to show. + refreshFailure = error if case .loaded = state { installedRepositoryLogger.error( "Installed inventory revalidation failed: \(error.localizedDescription, privacy: .public)", @@ -161,6 +179,7 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository private func fetchInstalledPackages() async throws -> [InstalledBrewPackage] { let brew = try locator.findBrewExecutable() + await updateTapsIfNeeded(executable: brew) let output = try await runInstalledInfoJSON(executable: brew) let payload = try decodeInfoJSON(from: output) let packages = payload.installedPackages() @@ -169,6 +188,32 @@ 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() + do { + let output = try await commandRunner.run( + executableURL: executable, + arguments: ["update", "--auto-update", "--quiet"], + ) + guard output.terminationStatus == 0 else { + throw BrewCommandError.failed(exitCode: output.terminationStatus, stderr: output.standardError) + } + } catch { + // Not fatal: the info fetch below decides whether the check produced an answer. + installedRepositoryLogger.error( + "Tap refresh before the outdated check failed: \(error.localizedDescription, privacy: .public)", + ) + } + } + private func runInstalledInfoJSON(executable: URL) async throws -> String { let arguments = ["info", "--installed", "--json=v2"] let output = try await commandRunner.run(executableURL: executable, arguments: arguments) diff --git a/Sources/BrewRepositoryInterfaces/Fakes/Stubs.swift b/Sources/BrewRepositoryInterfaces/Fakes/Stubs.swift index bb177f38..5c01be0c 100644 --- a/Sources/BrewRepositoryInterfaces/Fakes/Stubs.swift +++ b/Sources/BrewRepositoryInterfaces/Fakes/Stubs.swift @@ -12,10 +12,12 @@ import Observation @MainActor public final class StubInstalledPackagesRepository: InstalledPackagesRepository { public private(set) var state: LoadState<[InstalledBrewPackage], any Error> + public private(set) var refreshFailure: (any Error)? private var lookup: [HomebrewPackageID: InstalledBrewPackage] - public init(packages: [InstalledBrewPackage]) { + public init(packages: [InstalledBrewPackage], refreshFailure: (any Error)? = nil) { state = .loaded(packages) + self.refreshFailure = refreshFailure lookup = Dictionary(packages.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) } diff --git a/Sources/BrewRepositoryInterfaces/Protocols/InstalledInventoryObserving.swift b/Sources/BrewRepositoryInterfaces/Protocols/InstalledInventoryObserving.swift index 3616b35a..dac7a01e 100644 --- a/Sources/BrewRepositoryInterfaces/Protocols/InstalledInventoryObserving.swift +++ b/Sources/BrewRepositoryInterfaces/Protocols/InstalledInventoryObserving.swift @@ -12,6 +12,11 @@ import Observation @MainActor public protocol InstalledInventoryObserving: Observable, Sendable { var state: LoadState<[InstalledBrewPackage], any Error> { get } + + /// Last failed refresh; nil once a fetch succeeds. `state` stays `.loaded` through a failed + /// revalidation, so without this a surface cannot tell "nothing outdated" from "never found out". + var refreshFailure: (any Error)? { get } + func load(forceRefresh: Bool) async } diff --git a/Sources/BrewServicesTestSupport/CommandRunnerDoubles.swift b/Sources/BrewServicesTestSupport/CommandRunnerDoubles.swift index f0188951..ceea0f83 100644 --- a/Sources/BrewServicesTestSupport/CommandRunnerDoubles.swift +++ b/Sources/BrewServicesTestSupport/CommandRunnerDoubles.swift @@ -15,6 +15,19 @@ 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) diff --git a/Sources/BrewServicesTestSupport/InstalledPackagesTestSupport.swift b/Sources/BrewServicesTestSupport/InstalledPackagesTestSupport.swift index ac99a400..98cd7545 100644 --- a/Sources/BrewServicesTestSupport/InstalledPackagesTestSupport.swift +++ b/Sources/BrewServicesTestSupport/InstalledPackagesTestSupport.swift @@ -14,13 +14,15 @@ public enum InstalledPackagesTestSupport { /// Stable fake path passed to `commandRunner` when using `BrewExecutableLocator(overrideURL:)`. public static let fakeBrewExecutableURL = URL(fileURLWithPath: "/fake/brew") - /// Wired like production slice tests: default locator is ``fakeBrewExecutableURL``. + /// Wired like production slice tests; the default environment is the API path, so no tap refresh runs. @MainActor public static func repository( commandRunner: BrewCommandRunning, locator: (any BrewExecutableLocating)? = nil, cache: InstalledInventoryCache? = nil, commandCenter: any BrewCommandCenter = NoopBrewCommandCenter.forTesting(), + environment: any HomebrewEnvironmentReading = StubHomebrewEnvironment(installFromAPIDisabled: false), + now: @escaping @Sendable () -> Date = Date.init, ) -> BrewInstalledPackagesRepository { let resolvedCache = cache ?? InstalledInventoryCache() let resolvedLocator = locator ?? BrewExecutableLocator(overrideURL: fakeBrewExecutableURL) @@ -29,6 +31,8 @@ public enum InstalledPackagesTestSupport { locator: resolvedLocator, cache: resolvedCache, commandCenter: commandCenter, + environment: environment, + now: now, ) } diff --git a/Sources/BrewUIComponents/Commands/RefreshCommands.swift b/Sources/BrewUIComponents/Commands/RefreshCommands.swift new file mode 100644 index 00000000..8d702cef --- /dev/null +++ b/Sources/BrewUIComponents/Commands/RefreshCommands.swift @@ -0,0 +1,39 @@ +// +// RefreshCommands.swift +// BrewUIComponents +// + +import SwiftUI + +/// ⌘R for the whole window. The window publishes what refreshing means via ``FocusedValues/refreshAll``. +public struct RefreshCommands: Commands { + @FocusedValue(\.refreshAll) private var refreshAll + + public init() {} + + public var body: some Commands { + CommandGroup(after: .sidebar) { + Button("Refresh") { refreshAll?() } + .keyboardShortcut("r", modifiers: .command) + .disabled(refreshAll == nil) + } + } +} + +/// An action, not a `Binding`: re-setting an already-`true` binding moves nothing. +public struct RefreshAllAction { + private let handler: @MainActor () -> Void + + public init(_ handler: @escaping @MainActor () -> Void) { + self.handler = handler + } + + @MainActor + public func callAsFunction() { + handler() + } +} + +public extension FocusedValues { + @Entry var refreshAll: RefreshAllAction? +} diff --git a/Tests/BrewAccessibilityIDTests/AXIDTests.swift b/Tests/BrewAccessibilityIDTests/AXIDTests.swift index fa6ec0d7..51577daf 100644 --- a/Tests/BrewAccessibilityIDTests/AXIDTests.swift +++ b/Tests/BrewAccessibilityIDTests/AXIDTests.swift @@ -18,6 +18,7 @@ struct AXIDTests { .installedSearchField, .upgradesScreen, .upgradesList, + .upgradesRefreshButton, .discoverScreen, .discoverSearchField, .discoverList, @@ -43,6 +44,7 @@ struct AXIDTests { "installed.search", "upgrades.screen", "upgrades.list", + "upgrades.refresh", "discover.screen", "discover.search", "discover.list", diff --git a/Tests/BrewCLITests/BrewConfigEnvironmentReaderTests.swift b/Tests/BrewCLITests/BrewConfigEnvironmentReaderTests.swift new file mode 100644 index 00000000..7a4c3ef5 --- /dev/null +++ b/Tests/BrewCLITests/BrewConfigEnvironmentReaderTests.swift @@ -0,0 +1,119 @@ +// +// 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) + } +} diff --git a/Tests/BrewCLITests/BrewInfoJSONMappingTests.swift b/Tests/BrewCLITests/BrewInfoJSONMappingTests.swift index 4d88cfe1..4d88ee15 100644 --- a/Tests/BrewCLITests/BrewInfoJSONMappingTests.swift +++ b/Tests/BrewCLITests/BrewInfoJSONMappingTests.swift @@ -33,6 +33,65 @@ struct BrewInfoJSONMappingTests { #expect(packages.map(\.kind) == [.cask, .formula, .formula, .cask]) } + @Test func `formula upgrade target keeps the packaging revision suffix`() throws { + // A revision bump leaves versions.stable alone, so the bare version reads as no upgrade. + let json = """ + { + "formulae": [ + { + "name": "ffmpeg", + "versions": { "stable": "9.0.1" }, + "revision": 1, + "installed": [{ "version": "9.0.1" }], + "outdated": true + }, + { + "name": "wget", + "versions": { "stable": "1.25.0" }, + "revision": 0, + "installed": [{ "version": "1.25.0" }] + }, + { + "name": "aria2", + "versions": { "stable": "1.37.0" }, + "installed": [{ "version": "1.37.0" }] + } + ], + "casks": [] + } + """ + + let payload = try JSONDecoder().decode(BrewInfoJSON.self, from: Data(json.utf8)) + let packages = payload.installedPackages() + + let ffmpeg = try #require(packages.first { $0.name == "ffmpeg" }) + #expect(ffmpeg.latestVersion == "9.0.1_1") + #expect(ffmpeg.installedVersions == ["9.0.1"]) + #expect(packages.first { $0.name == "wget" }?.latestVersion == "1.25.0") + #expect(packages.first { $0.name == "aria2" }?.latestVersion == "1.37.0") + } + + @Test func `unusable revision values fall back to the plain stable version`() throws { + let json = """ + { + "formulae": [ + { "name": "alpha", "versions": { "stable": "2.0.0" }, "revision": "1" }, + { "name": "beta", "versions": { "stable": "3.0.0" }, "revision": null }, + { "name": "gamma", "revision": 4 } + ], + "casks": [] + } + """ + + let payload = try JSONDecoder().decode(BrewInfoJSON.self, from: Data(json.utf8)) + let packages = payload.installedPackages() + + #expect(packages.first { $0.name == "alpha" }?.latestVersion == "2.0.0") + #expect(packages.first { $0.name == "beta" }?.latestVersion == "3.0.0") + // No stable version to hang the revision off. + #expect(packages.first { $0.name == "gamma" }?.latestVersion == "") + } + @Test func `installedPackages sorts case-insensitively`() throws { let json = """ { diff --git a/Tests/BrewCoreTests/HomebrewPkgVersionTests.swift b/Tests/BrewCoreTests/HomebrewPkgVersionTests.swift new file mode 100644 index 00000000..810dc872 --- /dev/null +++ b/Tests/BrewCoreTests/HomebrewPkgVersionTests.swift @@ -0,0 +1,37 @@ +// +// HomebrewPkgVersionTests.swift +// BrewTests +// + +import BrewCore +import Foundation +import Testing + +struct HomebrewPkgVersionTests { + @Test func `revision zero renders the bare version`() { + #expect(HomebrewPkgVersion.string(version: "9.0.1", revision: 0) == "9.0.1") + } + + @Test func `missing revision renders the bare version`() { + #expect(HomebrewPkgVersion.string(version: "9.0.1", revision: nil) == "9.0.1") + } + + @Test func `non zero revision is appended with an underscore`() { + #expect(HomebrewPkgVersion.string(version: "9.0.1", revision: 1) == "9.0.1_1") + #expect(HomebrewPkgVersion.string(version: "1.11.1", revision: 4) == "1.11.1_4") + } + + @Test func `version is trimmed before the revision is appended`() { + #expect(HomebrewPkgVersion.string(version: " 3.8.13\n", revision: 2) == "3.8.13_2") + } + + @Test func `missing or blank versions yield nil regardless of revision`() { + #expect(HomebrewPkgVersion.string(version: nil, revision: 1) == nil) + #expect(HomebrewPkgVersion.string(version: "", revision: 1) == nil) + #expect(HomebrewPkgVersion.string(version: " ", revision: 0) == nil) + } + + @Test func `negative revisions are ignored rather than rendered`() { + #expect(HomebrewPkgVersion.string(version: "9.0.1", revision: -1) == "9.0.1") + } +} diff --git a/Tests/BrewFeatureInstalledTests/UpgradesUpToDateCopyTests.swift b/Tests/BrewFeatureInstalledTests/UpgradesUpToDateCopyTests.swift new file mode 100644 index 00000000..1aa5dddd --- /dev/null +++ b/Tests/BrewFeatureInstalledTests/UpgradesUpToDateCopyTests.swift @@ -0,0 +1,71 @@ +// +// UpgradesUpToDateCopyTests.swift +// BrewTests +// + +import BrewCore +import BrewCoreTestSupport +@testable import BrewFeatureInstalled +import BrewRepositoryInterfaces +import Foundation +import Testing + +struct UpgradesUpToDateCopyTests { + @Test @MainActor func `every up-to-date surface renders the same phrase`() { + let viewModel = Self.makeViewModel(packages: [ + .fixture(name: "git", kind: .formula, outdated: false), + .fixture(name: "wget", kind: .formula, outdated: false), + ]) + + let phrase = UpgradesUpToDateCopy.headline + #expect(viewModel.outdatedSubtitle == phrase) + #expect(viewModel.emptyUpgradeActionTitle == phrase) + #expect(viewModel.upToDateTitle == phrase) + } + + @Test @MainActor func `detail line reports the installed count it covers`() { + let none = Self.makeViewModel(packages: []) + #expect(none.upToDateDetail == "No installed packages to check.") + + let one = Self.makeViewModel(packages: [.fixture(name: "git", kind: .formula, outdated: false)]) + #expect(one.upToDateDetail == "Your installed package is up to date.") + + let many = Self.makeViewModel(packages: [ + .fixture(name: "git", kind: .formula, outdated: false), + .fixture(name: "wget", kind: .formula, outdated: false), + .fixture(name: "slack", kind: .cask, outdated: false), + ]) + #expect(many.upToDateDetail == "All 3 installed packages are up to date.") + } + + @Test @MainActor func `detail counts every installed package, not just the up-to-date ones`() { + // The claim covers the whole inventory the check looked at. + let viewModel = Self.makeViewModel(packages: [ + .fixture(name: "git", kind: .formula, outdated: true), + .fixture(name: "wget", kind: .formula, outdated: false), + ]) + + #expect(viewModel.upToDateDetail == "All 2 installed packages are up to date.") + } + + @Test @MainActor func `filters hiding every upgrade keep their own distinct phrase`() { + // A different claim from "there is nothing to upgrade"; it must not collapse into the phrase. + let viewModel = Self.makeViewModel(packages: [ + .fixture(name: "git", kind: .formula, outdated: true), + ]) + + viewModel.searchQuery = "no-such-package" + + #expect(viewModel.emptyUpgradeActionTitle != UpgradesUpToDateCopy.headline) + #expect(viewModel.emptyUpgradeActionTitle == "Nothing to upgrade here") + } + + @MainActor + private static func makeViewModel(packages: [InstalledBrewPackage]) -> UpgradesViewModel { + UpgradesViewModel( + repository: StubInstalledPackagesRepository(packages: packages), + brewCommandCenter: StubBrewCommandCenter(), + commandFactory: StubMutatingCommandFactory(), + ) + } +} diff --git a/Tests/BrewFeatureInstalledTests/UpgradesViewModelCheckFailureTests.swift b/Tests/BrewFeatureInstalledTests/UpgradesViewModelCheckFailureTests.swift new file mode 100644 index 00000000..7b2a3b1e --- /dev/null +++ b/Tests/BrewFeatureInstalledTests/UpgradesViewModelCheckFailureTests.swift @@ -0,0 +1,106 @@ +// +// UpgradesViewModelCheckFailureTests.swift +// BrewTests +// + +import BrewCore +import BrewCoreTestSupport +@testable import BrewFeatureInstalled +import BrewRepositoryInterfaces +import Foundation +import Testing + +/// "Nothing to upgrade" and "I couldn't find out" look identical from a package list alone. +struct UpgradesViewModelCheckFailureTests { + private static let brewError = BrewCommandError.failed(exitCode: 1, stderr: "Not a git repository") + + @Test @MainActor func `an empty inventory after a failed check is reported as a failure`() { + let viewModel = Self.makeViewModel(packages: [ + .fixture(name: "git", kind: .formula, outdated: false), + ], refreshFailure: Self.brewError) + + #expect(viewModel.showsUpgradeCheckFailure) + #expect(viewModel.emptyUpgradeActionTitle == "Couldn't check for upgrades") + #expect(viewModel.outdatedSubtitle == "Couldn't check for upgrades") + #expect(viewModel.upgradeCheckFailureMessage == "Not a git repository") + #expect(viewModel.upgradeCheckFailureDetail.hasPrefix("Not a git repository")) + } + + @Test @MainActor func `an empty inventory after a successful check still claims up to date`() { + let viewModel = Self.makeViewModel(packages: [ + .fixture(name: "git", kind: .formula, outdated: false), + ], refreshFailure: nil) + + #expect(!viewModel.showsUpgradeCheckFailure) + #expect(viewModel.emptyUpgradeActionTitle == UpgradesUpToDateCopy.headline) + #expect(viewModel.outdatedSubtitle == UpgradesUpToDateCopy.headline) + #expect(viewModel.upgradeCheckFailureMessage == nil) + } + + @Test @MainActor func `cached upgrades survive a failed check but stop reading as current`() { + // The list is still worth showing; the count must not pass for fresh. + let viewModel = Self.makeViewModel(packages: [ + .fixture(name: "git", kind: .formula, outdated: true), + .fixture(name: "slack", kind: .cask, outdated: true), + ], refreshFailure: Self.brewError) + + #expect(!viewModel.showsUpgradeCheckFailure) + #expect(viewModel.outdatedCount == 2) + #expect(viewModel.outdatedSubtitle == "2 packages can be upgraded — last check failed") + } + + @Test @MainActor func `a failed check does not masquerade as a filtered-out list`() { + let viewModel = Self.makeViewModel(packages: [ + .fixture(name: "git", kind: .formula, outdated: false), + ], refreshFailure: Self.brewError) + + #expect(!viewModel.isFilteringOutEveryUpgrade) + #expect(viewModel.emptyUpgradeActionTitle != "Nothing to upgrade here") + } + + @Test @MainActor func `filters hiding every upgrade win over a failed check`() { + // With known outdated inventory, the filter is the honest explanation for the empty list. + let viewModel = Self.makeViewModel(packages: [ + .fixture(name: "git", kind: .formula, outdated: true), + ], refreshFailure: Self.brewError) + + viewModel.searchQuery = "no-such-package" + + #expect(!viewModel.showsUpgradeCheckFailure) + #expect(viewModel.emptyUpgradeActionTitle == "Nothing to upgrade here") + } + + @Test @MainActor func `the failure detail spells out that empty means unknown`() { + let viewModel = Self.makeViewModel(packages: [], refreshFailure: Self.brewError) + + #expect( + viewModel.upgradeCheckFailureDetail + .contains("can't tell whether anything needs upgrading"), + ) + } + + @Test @MainActor func `a missing brew executable is reported in the tab's own words`() { + let viewModel = Self.makeViewModel( + packages: [], + refreshFailure: BrewLookupError.executableNotFound, + ) + + #expect(viewModel.showsUpgradeCheckFailure) + #expect( + viewModel.upgradeCheckFailureMessage + == "Could not find Homebrew. Install it or ensure brew is in the default location.", + ) + } + + @MainActor + private static func makeViewModel( + packages: [InstalledBrewPackage], + refreshFailure: (any Error)?, + ) -> UpgradesViewModel { + UpgradesViewModel( + repository: StubInstalledPackagesRepository(packages: packages, refreshFailure: refreshFailure), + brewCommandCenter: StubBrewCommandCenter(), + commandFactory: StubMutatingCommandFactory(), + ) + } +} diff --git a/Tests/BrewFeatureInstalledTests/UpgradesViewModelRefreshTests.swift b/Tests/BrewFeatureInstalledTests/UpgradesViewModelRefreshTests.swift new file mode 100644 index 00000000..fb08e18b --- /dev/null +++ b/Tests/BrewFeatureInstalledTests/UpgradesViewModelRefreshTests.swift @@ -0,0 +1,125 @@ +// +// UpgradesViewModelRefreshTests.swift +// BrewTests +// + +import BrewCore +import BrewCoreTestSupport +@testable import BrewFeatureInstalled +import BrewRepositoryInterfaces +import Foundation +import Observation +import Testing + +struct UpgradesViewModelRefreshTests { + @Test @MainActor func `refresh forces a fetch and reports progress while it runs`() async { + let repository = GatedInstalledPackagesRepository(packages: [ + .fixture(name: "git", kind: .formula, outdated: true), + ]) + let viewModel = Self.makeViewModel(repository: repository) + + #expect(!viewModel.isRefreshing) + + let refresh = Task { await viewModel.refresh() } + await waitUntil { repository.didStartLoad } + #expect(viewModel.isRefreshing) + + repository.resumeLoad() + await refresh.value + + #expect(!viewModel.isRefreshing) + #expect(repository.forceRefreshCalls == [true]) + } + + @Test @MainActor func `refresh is available whether or not upgrades exist`() async { + for packages in [[], [InstalledBrewPackage.fixture(name: "git", kind: .formula, outdated: true)]] { + let repository = GatedInstalledPackagesRepository(packages: packages) + repository.resumeLoad() + let viewModel = Self.makeViewModel(repository: repository) + + await viewModel.refresh() + + #expect(repository.forceRefreshCalls == [true]) + #expect(!viewModel.isRefreshing) + } + } + + @Test @MainActor func `load leaves the refresh indicator alone`() async { + let repository = GatedInstalledPackagesRepository(packages: []) + repository.resumeLoad() + let viewModel = Self.makeViewModel(repository: repository) + + await viewModel.load() + + #expect(!viewModel.isRefreshing) + #expect(repository.forceRefreshCalls == [false]) + } + + // MARK: - Helpers + + @MainActor + private static func makeViewModel( + repository: GatedInstalledPackagesRepository, + ) -> UpgradesViewModel { + UpgradesViewModel( + repository: repository, + brewCommandCenter: StubBrewCommandCenter(), + commandFactory: StubMutatingCommandFactory(), + ) + } +} + +/// Loaded-state inventory whose `load` parks until released, so a test can observe the view model +/// mid-refresh rather than only before and after. +@Observable +@MainActor +private final class GatedInstalledPackagesRepository: InstalledPackagesRepository { + private(set) var state: LoadState<[InstalledBrewPackage], any Error> + private(set) var refreshFailure: (any Error)? + private(set) var forceRefreshCalls: [Bool] = [] + private(set) var didStartLoad = false + private var isGated = true + + private var lookup: [HomebrewPackageID: InstalledBrewPackage] + + init(packages: [InstalledBrewPackage]) { + state = .loaded(packages) + lookup = Dictionary(packages.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + } + + func resumeLoad() { + isGated = false + } + + func load(forceRefresh: Bool) async { + forceRefreshCalls.append(forceRefresh) + didStartLoad = true + while isGated { + await Task.yield() + } + } + + func isInstalled(_ id: HomebrewPackageID) -> Bool { + lookup[id] != nil + } + + func info(for id: HomebrewPackageID) -> InstalledBrewPackage? { + lookup[id] + } + + func installedPackageIDs() async -> Set { + Set(lookup.keys) + } +} + +/// Yields cooperatively until `condition` holds, with a bounded budget so a regression fails an +/// assertion instead of hanging the suite. +@MainActor +private func waitUntil(_ condition: () -> Bool) async { + for _ in 0 ..< 500 { + if condition() { + return + } + await Task.yield() + } +} diff --git a/Tests/BrewFeatureInstalledTests/UpgradesViewModelScopeTests.swift b/Tests/BrewFeatureInstalledTests/UpgradesViewModelScopeTests.swift index 869023ff..096343df 100644 --- a/Tests/BrewFeatureInstalledTests/UpgradesViewModelScopeTests.swift +++ b/Tests/BrewFeatureInstalledTests/UpgradesViewModelScopeTests.swift @@ -243,11 +243,11 @@ struct UpgradesViewModelScopeTests { .fixture(name: "git", kind: .formula, outdated: false), ]) #expect(!upToDate.isFilteringOutEveryUpgrade) - #expect(upToDate.emptyUpgradeActionTitle == "Nothing to upgrade") + #expect(upToDate.emptyUpgradeActionTitle == "Everything is up to date") upToDate.scope = .casks #expect(!upToDate.isFilteringOutEveryUpgrade) - #expect(upToDate.emptyUpgradeActionTitle == "Nothing to upgrade") + #expect(upToDate.emptyUpgradeActionTitle == "Everything is up to date") } @Test @MainActor func `scope that hides every upgrade reports the filtered title`() { diff --git a/Tests/BrewFeatureInstalledTests/UpgradesViewModelTests.swift b/Tests/BrewFeatureInstalledTests/UpgradesViewModelTests.swift index 294efa62..940fba7d 100644 --- a/Tests/BrewFeatureInstalledTests/UpgradesViewModelTests.swift +++ b/Tests/BrewFeatureInstalledTests/UpgradesViewModelTests.swift @@ -96,7 +96,7 @@ struct UpgradesViewModelTests { let none = Self.makeViewModel(packages: [ .fixture(name: "wget", kind: .formula, outdated: false), ]) - #expect(none.outdatedSubtitle == "All packages are up to date") + #expect(none.outdatedSubtitle == "Everything is up to date") let one = Self.makeViewModel(packages: [ .fixture(name: "git", kind: .formula, outdated: true), diff --git a/Tests/BrewNetworkingTests/CatalogueJSONTests.swift b/Tests/BrewNetworkingTests/CatalogueJSONTests.swift index fbae2a2a..4debf578 100644 --- a/Tests/BrewNetworkingTests/CatalogueJSONTests.swift +++ b/Tests/BrewNetworkingTests/CatalogueJSONTests.swift @@ -34,6 +34,58 @@ struct CatalogueJSONTests { #expect(decoded.items.first?.dependencyReferences == [.formula(name: "bdw-gc"), .formula(name: "libpaper")]) } + @Test func `formula stable version carries the packaging revision`() throws { + let data = Data( + """ + [ + { + "name": "ffmpeg", + "desc": "Play, record, convert, and stream audio and video", + "homepage": "https://ffmpeg.org/", + "versions": { "stable": "9.0.1" }, + "revision": 1, + "dependencies": [] + }, + { + "name": "wget", + "desc": "Network downloader", + "homepage": "https://www.gnu.org/software/wget/", + "versions": { "stable": "1.25.0" }, + "revision": 0, + "dependencies": [] + } + ] + """.utf8, + ) + + let decoded = try JSONDecoder().decode(FormulaCatalogueJSON.self, from: data) + + #expect(decoded.decodeFailures.isEmpty) + #expect(decoded.items.first(where: { $0.name == "ffmpeg" })?.stableVersion == "9.0.1_1") + #expect(decoded.items.first(where: { $0.name == "wget" })?.stableVersion == "1.25.0") + } + + @Test func `formula item without a revision field still decodes`() throws { + let data = Data( + """ + [ + { + "name": "a2ps", + "desc": "Any-to-PostScript filter", + "homepage": "https://www.gnu.org/software/a2ps/", + "versions": { "stable": "4.15.8" }, + "dependencies": [] + } + ] + """.utf8, + ) + + let decoded = try JSONDecoder().decode(FormulaCatalogueJSON.self, from: data) + + #expect(decoded.decodeFailures.isEmpty) + #expect(decoded.items.first?.stableVersion == "4.15.8") + } + @Test func `decodes homebrew wire cask bulk shape`() throws { let data = Data( """ diff --git a/Tests/BrewRepositoriesTests/BrewInstalledPackagesRepositoryTests.swift b/Tests/BrewRepositoriesTests/BrewInstalledPackagesRepositoryTests.swift index e73ba48b..0be2f346 100644 --- a/Tests/BrewRepositoriesTests/BrewInstalledPackagesRepositoryTests.swift +++ b/Tests/BrewRepositoriesTests/BrewInstalledPackagesRepositoryTests.swift @@ -54,6 +54,33 @@ struct BrewInstalledPackagesRepositoryTests { #expect(zed.installedVersions == ["1.2.4"]) } + @Test @MainActor func `load reports a revision bump as a distinct upgrade target`() async throws { + let json = """ + { + "formulae": [ + { + "name": "ffmpeg", + "versions": { "stable": "9.0.1" }, + "revision": 1, + "installed": [{ "version": "9.0.1" }], + "outdated": true + } + ], + "casks": [] + } + """ + let runner = MockBrewCommandRunner( + responses: InstalledPackagesTestSupport.installedInfoJSONResponse(standardOutput: json), + ) + let repo = InstalledPackagesTestSupport.repository(commandRunner: runner) + let packages = await InstalledPackagesTestSupport.loadedPackages(from: repo) + + let ffmpeg = try #require(package(named: "ffmpeg", in: packages)) + #expect(ffmpeg.outdated) + #expect(ffmpeg.latestVersion == "9.0.1_1") + #expect(ffmpeg.latestVersion != ffmpeg.installedVersions.first) + } + @Test @MainActor func `load handles mixed payload version fallback rules`() async throws { let json = """ { @@ -300,6 +327,82 @@ struct BrewInstalledPackagesRepositoryTests { } } + @Test @MainActor func `a failed refresh is recorded even though the cached list stays on screen`() async { + let cache = InstalledInventoryCache() + await cache.replace( + InstalledInventorySnapshot(fetchedAt: .now, packages: [.fixture(name: "git", kind: .formula)]), + ) + let runner = MockBrewCommandRunner( + behaviors: [ + ["info", "--installed", "--json=v2"]: .throw(BrewCommandError.failed(exitCode: 1, stderr: "boom")), + ], + ) + let repo = InstalledPackagesTestSupport.repository(commandRunner: runner, cache: cache) + + await repo.load() + #expect(repo.refreshFailure == nil) + + await repo.load(forceRefresh: true) + + #expect(repo.state.isLoaded) + guard let failure = repo.refreshFailure, + case let BrewCommandError.failed(_, stderr) = failure + else { + Issue.record("expected the refresh failure to be recorded") + return + } + #expect(stderr == "boom") + } + + @Test @MainActor func `an initial failure is recorded alongside the failed state`() async { + let runner = MockBrewCommandRunner( + responses: InstalledPackagesTestSupport.responsesInstalledInfoFailure( + standardError: "boom", + terminationStatus: 1, + ), + ) + let repo = InstalledPackagesTestSupport.repository(commandRunner: runner) + + await repo.load(forceRefresh: true) + + #expect(repo.refreshFailure != nil) + } + + @Test @MainActor func `a successful fetch clears a recorded failure`() async { + let runner = FlakyInfoRunner( + firstError: BrewCommandError.failed(exitCode: 1, stderr: "boom"), + ) + let repo = InstalledPackagesTestSupport.repository(commandRunner: runner) + + await repo.load(forceRefresh: true) + #expect(repo.refreshFailure != nil) + + await repo.load(forceRefresh: true) + #expect(repo.refreshFailure == nil) + #expect(repo.state.isLoaded) + } + + @Test @MainActor func `repainting a fresh cache does not clear a recorded failure`() async { + // Painting the cache answers nothing, so it must not clear the recorded failure. + let cache = InstalledInventoryCache() + await cache.replace( + InstalledInventorySnapshot(fetchedAt: .now, packages: [.fixture(name: "git", kind: .formula)]), + ) + let runner = MockBrewCommandRunner( + behaviors: [ + ["info", "--installed", "--json=v2"]: .throw(BrewCommandError.failed(exitCode: 1, stderr: "boom")), + ], + ) + let repo = InstalledPackagesTestSupport.repository(commandRunner: runner, cache: cache) + + await repo.load(forceRefresh: true) + #expect(repo.refreshFailure != nil) + + await repo.load() // cache is fresh, so this paints without fetching + + #expect(repo.refreshFailure != nil) + } + @Test @MainActor func `command center running to idle triggers a reconcile fetch`() async { let commandCenter = ControllableAllPhasesCommandCenter() let runner = CountingInfoRunner() @@ -398,6 +501,28 @@ private func expectCallCount(atLeast target: Int, runner: CountingInfoRunner) as #expect(await runner.callCount >= target) } +/// Throws once, then succeeds — for asserting that recovery clears recorded failure state. +private actor FlakyInfoRunner: BrewCommandRunning { + private let firstError: any Error + private var didThrow = false + + init(firstError: any Error) { + self.firstError = firstError + } + + func run(executableURL _: URL, arguments _: [String], options _: BrewRunOptions) async throws -> CommandOutput { + guard didThrow else { + didThrow = true + throw firstError + } + return CommandOutput( + standardOutput: #"{ "formulae": [], "casks": [] }"#, + standardError: "", + terminationStatus: 0, + ) + } +} + /// Counts `brew info` invocations so reconcile tests can assert a fresh fetch happened. private actor CountingInfoRunner: BrewCommandRunning { private(set) var callCount = 0 diff --git a/Tests/BrewRepositoriesTests/BrewInstalledPackagesTapRefreshTests.swift b/Tests/BrewRepositoriesTests/BrewInstalledPackagesTapRefreshTests.swift new file mode 100644 index 00000000..26046eab --- /dev/null +++ b/Tests/BrewRepositoriesTests/BrewInstalledPackagesTapRefreshTests.swift @@ -0,0 +1,178 @@ +// +// BrewInstalledPackagesTapRefreshTests.swift +// BrewTests +// + +import BrewCLI +import BrewCore +import BrewCoreTestSupport +@testable import BrewRepositories +import BrewServicesTestSupport +import Foundation +import Testing + +/// With the API off, package data comes from tap clones that only `brew update` refreshes. +struct BrewInstalledPackagesTapRefreshTests { + private static let emptyInfoJSON = #"{ "formulae": [], "casks": [] }"# + + @Test @MainActor func `taps are updated before the outdated check when the API is disabled`() async { + let runner = RecordingCommandRunner(infoJSON: Self.emptyInfoJSON) + let repo = InstalledPackagesTestSupport.repository( + commandRunner: runner, + environment: StubHomebrewEnvironment(installFromAPIDisabled: true), + ) + + await repo.load(forceRefresh: true) + + #expect(await runner.invocations == [ + ["update", "--auto-update", "--quiet"], + ["info", "--installed", "--json=v2"], + ]) + #expect(repo.state.isLoaded) + } + + @Test @MainActor func `taps are left alone when brew reads from the API`() async { + let runner = RecordingCommandRunner(infoJSON: Self.emptyInfoJSON) + let repo = InstalledPackagesTestSupport.repository( + commandRunner: runner, + environment: StubHomebrewEnvironment(installFromAPIDisabled: false), + ) + + await repo.load(forceRefresh: true) + + // brew refreshes the API files on its own TTL, so an update here is pure cost. + #expect(await runner.invocations == [["info", "--installed", "--json=v2"]]) + } + + @Test @MainActor func `the tap update runs on an interval rather than before every fetch`() async { + let clock = MutableClock(now: Date(timeIntervalSince1970: 0)) + 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) + + #expect(await runner.count(of: ["update", "--auto-update", "--quiet"]) == 1) + + // Past Homebrew's 5-minute interval for this mode. + clock.now = Date(timeIntervalSince1970: 400) + await repo.load(forceRefresh: true) + + #expect(await runner.count(of: ["update", "--auto-update", "--quiet"]) == 2) + } + + @Test @MainActor func `a failed tap update still lets the outdated check answer`() async { + // The taps keep their previous contents, so a stale answer beats no answer. + let runner = RecordingCommandRunner( + infoJSON: Self.emptyInfoJSON, + updateBehavior: .failure(exitCode: 1, stderr: "fatal: not a git repository"), + ) + let repo = InstalledPackagesTestSupport.repository( + commandRunner: runner, + environment: StubHomebrewEnvironment(installFromAPIDisabled: true), + ) + + await repo.load(forceRefresh: true) + + #expect(repo.state.isLoaded) + #expect(repo.refreshFailure == nil) + #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( + InstalledInventorySnapshot(fetchedAt: .now, packages: [.fixture(name: "git", kind: .formula)]), + ) + let runner = RecordingCommandRunner(infoJSON: Self.emptyInfoJSON) + let repo = InstalledPackagesTestSupport.repository( + commandRunner: runner, + cache: cache, + environment: StubHomebrewEnvironment(installFromAPIDisabled: true), + ) + + await repo.load() + + #expect(await runner.invocations.isEmpty) + } +} + +// 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 + private let updateBehavior: UpdateBehavior + private(set) var invocations: [[String]] = [] + + init(infoJSON: String, updateBehavior: UpdateBehavior = .success) { + self.infoJSON = infoJSON + self.updateBehavior = updateBehavior + } + + func count(of arguments: [String]) -> Int { + invocations.count(where: { $0 == arguments }) + } + + func run(executableURL _: URL, arguments: [String], options _: BrewRunOptions) async throws -> CommandOutput { + invocations.append(arguments) + guard arguments.first == "update" else { + return CommandOutput(standardOutput: infoJSON, standardError: "", terminationStatus: 0) + } + switch updateBehavior { + case .success: + 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") + } + } +} diff --git a/Tests/BrewUIComponentsTests/RefreshAllActionTests.swift b/Tests/BrewUIComponentsTests/RefreshAllActionTests.swift new file mode 100644 index 00000000..c3321dee --- /dev/null +++ b/Tests/BrewUIComponentsTests/RefreshAllActionTests.swift @@ -0,0 +1,45 @@ +// +// RefreshAllActionTests.swift +// BrewTests +// + +@testable import BrewUIComponents +import Foundation +import Testing + +@MainActor +struct RefreshAllActionTests { + @Test func `invoking the action runs its handler`() { + var runs = 0 + let action = RefreshAllAction { runs += 1 } + + action() + + #expect(runs == 1) + } + + @Test func `every ⌘R runs the handler again`() { + var runs = 0 + let action = RefreshAllAction { runs += 1 } + + action() + action() + action() + + #expect(runs == 3) + } + + @Test func `the handler re-reads the state it refreshes on every invocation`() { + var refreshed: [String] = [] + var surfaces = ["installed", "discover"] + let action = RefreshAllAction { refreshed.append(contentsOf: surfaces) } + + action() + #expect(refreshed == ["installed", "discover"]) + + surfaces = ["config"] + action() + + #expect(refreshed == ["installed", "discover", "config"]) + } +}