From af0369d443e316a853d38f989187993d66223cff Mon Sep 17 00:00:00 2001 From: Andrew Moore Date: Fri, 7 Aug 2026 04:14:34 -0700 Subject: [PATCH] Merge nucleic/mellow-dewy-falcon-rjhr into main --- Sources/RunnerCore/XcodeInstall.swift | 130 +++++++++++ Sources/RunnerHost/GuestProvisioner.swift | 216 +++++++++++++++--- Sources/RunnerHost/ImageBuilder.swift | 6 +- Sources/gitea-macos-runner/CommandImage.swift | 14 +- Tests/RunnerCoreTests/XcodeInstallTests.swift | 116 ++++++++++ 5 files changed, 450 insertions(+), 32 deletions(-) create mode 100644 Sources/RunnerCore/XcodeInstall.swift create mode 100644 Tests/RunnerCoreTests/XcodeInstallTests.swift diff --git a/Sources/RunnerCore/XcodeInstall.swift b/Sources/RunnerCore/XcodeInstall.swift new file mode 100644 index 0000000..dfd251d --- /dev/null +++ b/Sources/RunnerCore/XcodeInstall.swift @@ -0,0 +1,130 @@ +import Foundation + +/// The decisions in an Xcode install that are pure string and arithmetic work. +/// +/// Installing Xcode into a guest is a long chain of SSH commands, and the parts +/// of it that are easy to get wrong — which `.app` came out of the archive, how +/// much disk the expansion is going to want, what `df` actually said — are all +/// decidable from text. They live here so they can be tested without a VM, +/// which is the only way they ever get tested: the surrounding code takes forty +/// minutes and a 12 GB file to run once. +public enum XcodeInstall { + + // MARK: - Which app came out of the archive + + /// Picks the expanded application bundle out of an `ls -d …/*.app` listing. + /// + /// The archive's payload is *not* reliably named `Xcode.app`. Beta releases + /// expand to `Xcode-beta.app`, and Apple has shipped version-qualified names + /// before, so the name has to be discovered rather than assumed — hardcoding + /// it is what made a successful 12 GB upload and a 30-minute expansion fail + /// on the very last `mv`. + /// + /// - Parameters: + /// - listing: Standard output of `ls -d /*.app`. + /// - staging: The directory that was listed, named in error messages. + /// - Returns: The full path of the single matching bundle. + /// - Throws: ``CoreError/provisioningFailed(_:)`` for zero or several + /// matches. Both are ambiguous rather than recoverable: picking one of two + /// candidates would install something the operator did not ask for. + public static func expandedAppPath(fromListing listing: String, staging: String) throws -> String { + let candidates = listing + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + // An unmatched glob is echoed back verbatim by /bin/sh, so the + // no-match case arrives looking like a path that ends in `*.app`. + .filter { !$0.contains("*") } + + switch candidates.count { + case 1: + return candidates[0] + case 0: + throw CoreError.provisioningFailed( + "the Xcode archive expanded but produced no .app in \(staging). " + + "The download may be truncated — check the .xip and try again." + ) + default: + let names = candidates.map { ($0 as NSString).lastPathComponent } + throw CoreError.provisioningFailed( + "the Xcode archive expanded to \(candidates.count) applications in \(staging) " + + "(\(names.joined(separator: ", "))), so it is not clear which to install. " + + "Expand the .xip by hand and pass a single-application archive." + ) + } + } + + // MARK: - Disk + + /// How much room the expansion of an archive is expected to need, excluding + /// the archive itself. + /// + /// A `.xip` is an LZMA-compressed cpio of the whole application, and Xcode + /// compresses well: recent releases land near 3.5× on expansion. This is an + /// estimate used for a pre-flight, so it is deliberately the ratio at the + /// pessimistic end of what has been observed rather than an average — the + /// cost of overestimating is a clear error message, and the cost of + /// underestimating is a guest that runs out of disk 35 minutes in. + public static func expansionEstimateBytes(xipBytes: Int) -> Int { + (xipBytes * 7) / 2 + } + + /// Total free space the guest needs before the upload starts: the uploaded + /// archive plus everything it expands into. + /// + /// Both have to coexist — `xip --expand` reads the archive while it writes — + /// and the archive is deleted as soon as the expansion succeeds, before the + /// move, which is a same-volume rename that needs no headroom of its own. + public static func requiredFreeBytes(xipBytes: Int) -> Int { + xipBytes + expansionEstimateBytes(xipBytes: xipBytes) + } + + /// Reads the available-bytes column out of `df -Pk` output. + /// + /// `-P` matters: without it `df` wraps a long device name onto its own line + /// and the columns stop lining up. `-k` fixes the block size at 1024, so the + /// value does not depend on the guest's `BLOCKSIZE`. + /// + /// - Returns: Free bytes, or `nil` if the output was not in the expected + /// shape — the caller treats that as "could not check" rather than as a + /// failure, since refusing to install because `df` was unparseable would + /// be worse than the risk it guards against. + public static func availableBytes(dfOutput: String) -> Int? { + for line in dfOutput.split(separator: "\n") { + let fields = line.split(whereSeparator: \.isWhitespace) + guard fields.count >= 4, fields[0] != "Filesystem", + let kilobytes = Int(fields[3]) + else { continue } + return kilobytes * 1024 + } + return nil + } + + /// Renders a byte count the way the progress lines do, e.g. `12.4 GB`. + /// + /// Decimal gigabytes, matching how the archives are advertised and how + /// Finder reports them, so the number in an error message is the number the + /// operator can see on their own disk. + public static func formatGB(_ bytes: Int) -> String { + String(format: "%.1f GB", Double(bytes) / 1_000_000_000) + } + + /// The message shown when the guest cannot fit the install. + /// + /// Built here, with the numbers spelled out, because "no space left on + /// device" 35 minutes into an expansion tells the operator nothing about how + /// much bigger the image needed to be. + public static func insufficientDiskMessage(xipBytes: Int, availableBytes: Int) -> String { + let needed = requiredFreeBytes(xipBytes: xipBytes) + return """ + not enough free disk in the guest to install Xcode: \ + \(formatGB(availableBytes)) available, about \(formatGB(needed)) needed \ + (\(formatGB(xipBytes)) for the archive plus roughly \ + \(formatGB(expansionEstimateBytes(xipBytes: xipBytes))) once expanded). + + Rebuild the base image with a larger disk, e.g.: + gitea-macos-runner image delete + gitea-macos-runner image build --ipsw --disk-gb 200 + """ + } +} diff --git a/Sources/RunnerHost/GuestProvisioner.swift b/Sources/RunnerHost/GuestProvisioner.swift index 03daf94..683a471 100644 --- a/Sources/RunnerHost/GuestProvisioner.swift +++ b/Sources/RunnerHost/GuestProvisioner.swift @@ -304,53 +304,134 @@ public struct GuestProvisioner: Sendable { /// running `xcodebuild -runFirstLaunch` so the first job does not pay for /// component installation. /// + /// The application's name is **discovered, not assumed**. A release archive + /// expands to `Xcode.app`, a beta to `Xcode-beta.app`, and Apple has shipped + /// version-qualified names too; whatever comes out keeps its name under + /// `/Applications`, because `xcode-select -s` makes the name irrelevant to + /// anything that builds. + /// /// - Parameters: /// - executor: A connected guest executor. /// - xipPath: Path to the `.xip` **on the host**; it is uploaded. - public func installXcode(executor: any GuestExecutor, xipPath: String) async throws { + /// - progress: Optional stage callback. Every phase here runs for tens of + /// minutes, so silence is indistinguishable from a hang — this is the + /// only thing that says otherwise. + public func installXcode( + executor: any GuestExecutor, + xipPath: String, + progress: (@Sendable (String) -> Void)? = nil + ) async throws { let localURL = URL(fileURLWithPath: (xipPath as NSString).expandingTildeInPath) guard FileManager.default.fileExists(atPath: localURL.path) else { throw CoreError.notFound("Xcode .xip not found at \(localURL.path)") } + let localBytes = + (try? FileManager.default.attributesOfItem(atPath: localURL.path))?[.size] as? Int ?? 0 let remoteXIP = "/tmp/Xcode.xip" - // Uploads go over an SSH exec channel with the payload as stdin, and - // `GuestExecutor.upload` reads the whole local file into memory first — - // fine for a 90 MB pkg, ruinous for a 12 GB xip. So this streams the file - // in bounded chunks and appends them guest-side instead. It is still slow - // (an exec channel is not SCP), but it is functional and its host memory - // use is capped at one chunk. - try await executor.runChecked("rm -f \(Self.shellQuote(remoteXIP))", timeout: .seconds(120)) - try await Self.uploadLargeFile(executor: executor, localURL: localURL, remotePath: remoteXIP) - - // Free the disk the old copy occupies before expanding into ~40 GB more. - _ = try? await executor.run("sudo -n rm -rf /Applications/Xcode.app", timeout: .seconds(600)) - let staging = "/tmp/xcode-expand" - // `xip --expand` writes into the current directory and needs no sudo, but - // /tmp is small on some layouts; staging under /tmp keeps it beside the - // archive so the later move is a rename within one volume where possible. - try await executor.runChecked( - "rm -rf \(Self.shellQuote(staging)) && mkdir -p \(Self.shellQuote(staging))", - timeout: .seconds(300) - ) + let quotedXIP = Self.shellQuote(remoteXIP) + let quotedStaging = Self.shellQuote(staging) - // Expansion of a full Xcode takes 20–45 minutes on VM-backed storage. - try await executor.runChecked( - "cd \(Self.shellQuote(staging)) && sudo -n /usr/bin/xip --expand \(Self.shellQuote(remoteXIP))", - timeout: .seconds(5400) - ) + // Is a previous run's expansion still sitting there, complete? Then the + // upload and the expansion — between them the entire cost of this + // function — are already paid for. Opportunistic only: macOS clears /tmp + // on boot, so after the guest has been power-cycled this finds nothing, + // which is fine. + var expandedApp = try await Self.reusableExpandedApp(executor: executor, staging: staging) + + if let expandedApp { + progress?("reusing expanded \((expandedApp as NSString).lastPathComponent)") + } else { + try await Self.checkGuestDisk(executor: executor, xipBytes: localBytes, progress: progress) + + if try await Self.hasMatchingUpload( + executor: executor, remotePath: remoteXIP, expectedBytes: localBytes) + { + progress?("reusing uploaded xip (\(XcodeInstall.formatGB(localBytes)))") + } else { + // Uploads go over an SSH exec channel with the payload as stdin, + // and `GuestExecutor.upload` reads the whole local file into + // memory first — fine for a 90 MB pkg, ruinous for a 12 GB xip. + // So this streams the file in bounded chunks and appends them + // guest-side instead. It is still slow (an exec channel is not + // SCP), but it is functional and its host memory use is capped at + // one chunk. + let headline = "uploading Xcode (\(XcodeInstall.formatGB(localBytes)))" + progress?(headline + " 0%") + try await executor.runChecked("rm -f \(quotedXIP)", timeout: .seconds(120)) + try await Self.uploadLargeFile( + executor: executor, + localURL: localURL, + remotePath: remoteXIP, + progress: { fraction in + progress?(headline + " \(Int(fraction * 100))%") + } + ) + progress?(headline + " 100%") + } + + // A half-finished expansion from an earlier attempt would leave + // `ls *.app` ambiguous, or leave a truncated bundle to be installed. + // Clear it before, not after. + try await executor.runChecked( + "rm -rf \(quotedStaging) && mkdir -p \(quotedStaging)", timeout: .seconds(600)) + + // `xip --expand` writes into the current directory and needs no sudo, + // but staging beside the archive keeps the later move a rename within + // one volume. Expansion of a full Xcode takes 20–45 minutes on + // VM-backed storage. + progress?("expanding xip (takes 15-40 min)…") + try await executor.runChecked( + "cd \(quotedStaging) && sudo -n /usr/bin/xip --expand \(quotedXIP)", + timeout: .seconds(5400) + ) + + // Immediately, and unconditionally on success: the archive is dead + // weight from here on, and the guest is at its tightest right now + // holding both copies. Deleting it as part of a success-only `&&` + // chain at the very end — which is what this used to do — means a + // failure anywhere later strands 12 GB in /tmp. + _ = try? await executor.run("rm -f \(quotedXIP)", timeout: .seconds(300)) + + let listing = try await executor.run( + "ls -d \(quotedStaging)/*.app 2>/dev/null", timeout: .seconds(300)) + expandedApp = try XcodeInstall.expandedAppPath( + fromListing: listing.stdout, staging: staging) + } + + guard let sourceApp = expandedApp else { + throw CoreError.provisioningFailed("could not locate the expanded Xcode in \(staging)") + } + let appName = (sourceApp as NSString).lastPathComponent + let destination = "/Applications/" + appName + let quotedDestination = Self.shellQuote(destination) + + // Whatever is already there loses. This is a golden image being built to + // a specification, not a user's Mac, and leaving the old copy would both + // fail the move and waste tens of gigabytes in every clone. + let existing = try await executor.run( + "test -e \(quotedDestination) && echo present", timeout: .seconds(120)) + if existing.stdout.contains("present") { + progress?("replacing existing \(appName) in the guest") + try await executor.runChecked( + "sudo -n rm -rf \(quotedDestination)", timeout: .seconds(1800)) + } // Writing into /Applications needs root. + progress?("installing \(appName)…") try await executor.runChecked( - "sudo -n mv \(Self.shellQuote(staging + "/Xcode.app")) /Applications/Xcode.app " - + "&& sudo -n rm -rf \(Self.shellQuote(staging)) \(Self.shellQuote(remoteXIP))", + "sudo -n mv \(Self.shellQuote(sourceApp)) \(quotedDestination)", timeout: .seconds(1800) ) + _ = try? await executor.run("rm -rf \(quotedStaging)", timeout: .seconds(600)) - // xcode-select writes /var/db/xcode_select_link — root only. + // xcode-select writes /var/db/xcode_select_link — root only. Pointing it + // at the discovered path is what makes the bundle's name a non-issue: + // `xcodebuild`, `swift`, and every `xcrun` shim resolve through this. try await executor.runChecked( - "sudo -n /usr/bin/xcode-select -s /Applications/Xcode.app/Contents/Developer", + "sudo -n /usr/bin/xcode-select -s " + + Self.shellQuote(destination + "/Contents/Developer"), timeout: .seconds(300) ) @@ -361,6 +442,7 @@ public struct GuestProvisioner: Sendable { "sudo -n /usr/bin/xcodebuild -license accept", timeout: .seconds(600) ) + progress?("running xcodebuild -runFirstLaunch (installs simulators; 10-30 min)…") try await executor.runChecked( "sudo -n /usr/bin/xcodebuild -runFirstLaunch", timeout: .seconds(3600) @@ -373,6 +455,82 @@ public struct GuestProvisioner: Sendable { + Self.tail(check.stderr.isEmpty ? check.stdout : check.stderr) ) } + // Proof, in the operator's log, that the thing they waited an hour for + // is actually there and selected — on one line, since `-version` prints + // two. + let version = check.stdout + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .joined(separator: " — ") + progress?("Xcode ready: \(version) at \(destination)") + } + + /// An already-expanded application left by an earlier attempt, if one is + /// there and looks complete. + /// + /// "Complete" is `Contents/MacOS` existing: an expansion killed part-way + /// leaves a directory tree that `ls` is perfectly happy to list, and + /// installing that would produce an Xcode that fails at first use rather + /// than at install time. Never throws — a guest with nothing staged is the + /// normal case, and an ambiguous listing here just means "do it properly". + static func reusableExpandedApp( + executor: any GuestExecutor, + staging: String + ) async throws -> String? { + let listing = try await executor.run( + "ls -d \(shellQuote(staging))/*.app 2>/dev/null", timeout: .seconds(120)) + guard let app = try? XcodeInstall.expandedAppPath(fromListing: listing.stdout, staging: staging) + else { return nil } + + let complete = try await executor.run( + "test -d \(shellQuote(app + "/Contents/MacOS")) && echo ok", timeout: .seconds(120)) + return complete.stdout.contains("ok") ? app : nil + } + + /// Whether the guest already holds a byte-for-byte-sized copy of the upload. + /// + /// Size only — hashing 12 GB over an exec channel would cost more than the + /// upload it is trying to avoid. The archive is written by this code alone, + /// to a fixed path, so a size match is strong enough evidence; a partial + /// upload from an interrupted run is shorter and fails the check. + static func hasMatchingUpload( + executor: any GuestExecutor, + remotePath: String, + expectedBytes: Int + ) async throws -> Bool { + guard expectedBytes > 0 else { return false } + let result = try await executor.run( + "stat -f %z \(shellQuote(remotePath)) 2>/dev/null", timeout: .seconds(120)) + let reported = Int(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)) + return reported == expectedBytes + } + + /// Refuses the install before the upload when the guest cannot hold it. + /// + /// The failure this replaces is the worst kind: `xip --expand` fills the + /// disk half an hour in, and the error names neither how much was needed nor + /// what to do about it. Unparseable `df` output is treated as "cannot check" + /// and allowed through — a pre-flight that blocks the install because it did + /// not recognise the output is worse than the problem. + static func checkGuestDisk( + executor: any GuestExecutor, + xipBytes: Int, + progress: (@Sendable (String) -> Void)? + ) async throws { + guard xipBytes > 0 else { return } + let result = try await executor.run("df -Pk /", timeout: .seconds(120)) + guard let available = XcodeInstall.availableBytes(dfOutput: result.stdout) else { return } + + let needed = XcodeInstall.requiredFreeBytes(xipBytes: xipBytes) + guard available >= needed else { + throw CoreError.provisioningFailed( + XcodeInstall.insufficientDiskMessage(xipBytes: xipBytes, availableBytes: available) + ) + } + progress?( + "guest disk: \(XcodeInstall.formatGB(available)) free, " + + "\(XcodeInstall.formatGB(needed)) needed") } /// The Node.js version installed when none is specified. diff --git a/Sources/RunnerHost/ImageBuilder.swift b/Sources/RunnerHost/ImageBuilder.swift index 3b09e1d..1819bed 100644 --- a/Sources/RunnerHost/ImageBuilder.swift +++ b/Sources/RunnerHost/ImageBuilder.swift @@ -672,10 +672,14 @@ public struct ImageBuilder: Sendable { if let xcodeXIPPath { progress?(.provisioning(step: "Xcode")) + // Xcode is the one step measured in hours, so it reports its own + // sub-stages rather than going quiet behind a single headline. try await provisioner.installXcode( executor: executor, xipPath: (xcodeXIPPath as NSString).expandingTildeInPath - ) + ) { step in + progress?(.provisioning(step: step)) + } } } catch { await executor.close() diff --git a/Sources/gitea-macos-runner/CommandImage.swift b/Sources/gitea-macos-runner/CommandImage.swift index 96811d0..9c02ae9 100644 --- a/Sources/gitea-macos-runner/CommandImage.swift +++ b/Sources/gitea-macos-runner/CommandImage.swift @@ -309,13 +309,23 @@ struct ImageCommand: AsyncParsableCommand { case .installing: return "install" case .firstBoot: return "firstBoot" // Each provisioning step is its own headline — "installing Node.js" - // should not erase "downloading gitea-runner". - case .provisioning(let step): return "provisioning:\(step)" + // should not erase "downloading gitea-runner" — but a step that carries + // a live percentage keeps rewriting one line rather than scrolling a + // hundred of them, so only the part before the payload identifies it. + case .provisioning(let step): return "provisioning:\(Self.stableHead(of: step))" case .finalizing: return "finalizing" case .done: return "done" } } + /// The fixed part of a status line: everything before the two-space run that + /// separates a headline from its payload, following the same convention as + /// `installing macOS [====]`. A step with no payload is its own head. + static func stableHead(of step: String) -> String { + guard let separator = step.range(of: " ") else { return step } + return String(step[step.startIndex.. String { switch stage { diff --git a/Tests/RunnerCoreTests/XcodeInstallTests.swift b/Tests/RunnerCoreTests/XcodeInstallTests.swift new file mode 100644 index 0000000..c7a14e4 --- /dev/null +++ b/Tests/RunnerCoreTests/XcodeInstallTests.swift @@ -0,0 +1,116 @@ +import Foundation +import Testing + +@testable import RunnerCore + +/// Tests for ``XcodeInstall`` — the decidable parts of installing Xcode. +/// +/// The bug these exist for: the installer assumed the archive expanded to +/// `Xcode.app`. A beta expands to `Xcode-beta.app`, so a 12 GB upload and a +/// half-hour expansion both succeeded and then the final `mv` failed with +/// "No such file or directory". Nothing about that is worth discovering from a +/// forty-minute live run twice. +@Suite("XcodeInstall") +struct XcodeInstallTests { + + // MARK: - Discovering the expanded app + + @Test("a release archive's Xcode.app is found") + func findsReleaseApp() throws { + let path = try XcodeInstall.expandedAppPath( + fromListing: "/tmp/xcode-expand/Xcode.app\n", staging: "/tmp/xcode-expand") + #expect(path == "/tmp/xcode-expand/Xcode.app") + } + + /// The reported failure, in one line. + @Test("a beta archive's Xcode-beta.app is found") + func findsBetaApp() throws { + let path = try XcodeInstall.expandedAppPath( + fromListing: "/tmp/xcode-expand/Xcode-beta.app", staging: "/tmp/xcode-expand") + #expect(path == "/tmp/xcode-expand/Xcode-beta.app") + } + + @Test("a version-qualified name is found") + func findsVersionQualifiedApp() throws { + let path = try XcodeInstall.expandedAppPath( + fromListing: " /tmp/xcode-expand/Xcode_16.2.app \n\n", staging: "/tmp/xcode-expand") + #expect(path == "/tmp/xcode-expand/Xcode_16.2.app") + } + + /// `/bin/sh` echoes an unmatched glob back verbatim, so "no match" arrives + /// as a plausible-looking path rather than as empty output. + @Test("an unmatched glob reads as no match, not as a path") + func unmatchedGlobIsNotAPath() { + #expect(throws: CoreError.self) { + try XcodeInstall.expandedAppPath( + fromListing: "/tmp/xcode-expand/*.app\n", staging: "/tmp/xcode-expand") + } + } + + @Test("empty output is an error naming the staging directory") + func emptyListingThrows() { + do { + _ = try XcodeInstall.expandedAppPath(fromListing: "\n \n", staging: "/tmp/xcode-expand") + Issue.record("expected a failure") + } catch { + #expect("\(error)".contains("/tmp/xcode-expand")) + } + } + + /// Guessing between two candidates would install something the operator did + /// not ask for, so this stops instead. + @Test("several apps is an error listing them") + func ambiguousListingThrows() { + do { + _ = try XcodeInstall.expandedAppPath( + fromListing: "/tmp/x/Xcode.app\n/tmp/x/Xcode-beta.app\n", staging: "/tmp/x") + Issue.record("expected a failure") + } catch { + let text = "\(error)" + #expect(text.contains("Xcode.app")) + #expect(text.contains("Xcode-beta.app")) + } + } + + // MARK: - Disk arithmetic + + @Test("the requirement covers the archive and its expansion") + func requiredFreeSpaceCoversBoth() { + let xip = 12_000_000_000 + #expect(XcodeInstall.expansionEstimateBytes(xipBytes: xip) == 42_000_000_000) + #expect(XcodeInstall.requiredFreeBytes(xipBytes: xip) == 54_000_000_000) + } + + @Test("df -Pk output yields available bytes") + func parsesDF() { + let output = """ + Filesystem 1024-blocks Used Available Capacity Mounted on + /dev/disk3s5 488245288 120000000 62914560 66% / + """ + #expect(XcodeInstall.availableBytes(dfOutput: output) == 62_914_560 * 1024) + } + + /// Unparseable output means "could not check", not "no space" — the caller + /// must not refuse to install because `df` printed something unexpected. + @Test("unparseable df output yields nil rather than zero") + func unparseableDFIsNil() { + #expect(XcodeInstall.availableBytes(dfOutput: "df: /nope: No such file or directory") == nil) + #expect(XcodeInstall.availableBytes(dfOutput: "") == nil) + } + + @Test("the shortfall message names every number and a remedy") + func messageNamesTheNumbers() { + let text = XcodeInstall.insufficientDiskMessage( + xipBytes: 12_000_000_000, availableBytes: 20_000_000_000) + #expect(text.contains("20.0 GB")) // available + #expect(text.contains("54.0 GB")) // needed + #expect(text.contains("12.0 GB")) // the archive + #expect(text.contains("--disk-gb")) + } + + @Test("byte counts render as decimal gigabytes") + func formatsGigabytes() { + #expect(XcodeInstall.formatGB(12_400_000_000) == "12.4 GB") + #expect(XcodeInstall.formatGB(0) == "0.0 GB") + } +}