Skip to content

Commit 9e87883

Browse files
p-linnaneclaude
andauthored
test(cli): cover parse/validate and end-to-end behavior (#479)
Adds Tests/macosdbTests with ArgumentParser parse tests for ScanCommand, ValidateCommand, and ListCommand (defaults, every option, the --xip-url alias, and validation failure modes), plus four subprocess smoke tests that run the built macosdb binary against --help and exercise the same validation failures end-to-end. Signed-off-by: Patrick Linnane <patrick@linnane.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c547c9d commit 9e87883

5 files changed

Lines changed: 299 additions & 0 deletions

File tree

Package.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,9 @@ let package = Package(
3232
dependencies: ["macOSdbKit"],
3333
resources: [.copy("Fixtures")]
3434
),
35+
.testTarget(
36+
name: "macosdbTests",
37+
dependencies: ["macosdb"]
38+
),
3539
]
3640
)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import Foundation
2+
import Testing
3+
4+
@testable import macosdb
5+
6+
@Suite("ListCommand parsing")
7+
struct ListCommandTests {
8+
9+
@Test("Parses with no arguments")
10+
func parsesEmpty() throws {
11+
let cmd = try ListCommand.parse([])
12+
#expect(cmd.product == nil)
13+
#expect(cmd.major == nil)
14+
#expect(cmd.json == false)
15+
#expect(cmd.dataURL == nil)
16+
}
17+
18+
@Test("Parses --product, --major, --json, --data-url")
19+
func parsesAllOptions() throws {
20+
let cmd = try ListCommand.parse([
21+
"--product", "xcode",
22+
"--major", "16",
23+
"--json",
24+
"--data-url", "https://example.com/api/v1"
25+
])
26+
#expect(cmd.product == "xcode")
27+
#expect(cmd.major == 16)
28+
#expect(cmd.json == true)
29+
#expect(cmd.dataURL == "https://example.com/api/v1")
30+
}
31+
32+
@Test("Rejects non-integer --major")
33+
func rejectsNonIntegerMajor() {
34+
#expect(throws: (any Error).self) {
35+
_ = try ListCommand.parse(["--major", "fifteen"])
36+
}
37+
}
38+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import Foundation
2+
import Testing
3+
4+
@testable import macosdb
5+
6+
@Suite("ScanCommand parsing and validation")
7+
struct ScanCommandTests {
8+
9+
// MARK: - Argument parsing
10+
11+
@Test("Parses required archive path with defaults")
12+
func parsesArchivePathWithDefaults() throws {
13+
let cmd = try ScanCommand.parse(["archive.ipsw"])
14+
#expect(cmd.archivePath == "archive.ipsw")
15+
#expect(cmd.output == nil)
16+
#expect(cmd.releaseName == nil)
17+
#expect(cmd.releaseDate == nil)
18+
#expect(cmd.beta == false)
19+
#expect(cmd.betaNumber == nil)
20+
#expect(cmd.rc == false)
21+
#expect(cmd.rcNumber == nil)
22+
#expect(cmd.downloadURL == nil)
23+
#expect(cmd.deviceSpecific == false)
24+
#expect(cmd.updateIndex == false)
25+
#expect(cmd.saveAeaKey == false)
26+
#expect(cmd.aeaKeyPath == nil)
27+
#expect(cmd.keyOnly == false)
28+
#expect(cmd.verbose == false)
29+
}
30+
31+
@Test("Parses all options together")
32+
func parsesAllOptions() throws {
33+
let cmd = try ScanCommand.parse([
34+
"archive.ipsw",
35+
"--output", "/tmp/out",
36+
"--release-name", "Sequoia",
37+
"--release-date", "2025-07-07",
38+
"--beta",
39+
"--beta-number", "3",
40+
"--ipsw-url", "https://example.com/x.ipsw",
41+
"--device-specific",
42+
"--update-index",
43+
"--save-aea-key",
44+
"--aea-key", "/path/to/key.pem",
45+
"--verbose"
46+
])
47+
#expect(cmd.archivePath == "archive.ipsw")
48+
#expect(cmd.output == "/tmp/out")
49+
#expect(cmd.releaseName == "Sequoia")
50+
#expect(cmd.releaseDate == "2025-07-07")
51+
#expect(cmd.beta == true)
52+
#expect(cmd.betaNumber == 3)
53+
#expect(cmd.downloadURL == "https://example.com/x.ipsw")
54+
#expect(cmd.deviceSpecific == true)
55+
#expect(cmd.updateIndex == true)
56+
#expect(cmd.saveAeaKey == true)
57+
#expect(cmd.aeaKeyPath == "/path/to/key.pem")
58+
#expect(cmd.verbose == true)
59+
}
60+
61+
@Test("--xip-url is an alias for the download URL option")
62+
func xipUrlAlias() throws {
63+
let cmd = try ScanCommand.parse(["archive.xip", "--xip-url", "https://example.com/x.xip"])
64+
#expect(cmd.downloadURL == "https://example.com/x.xip")
65+
}
66+
67+
@Test("Missing archive path is rejected")
68+
func missingArchivePathRejected() {
69+
#expect(throws: (any Error).self) {
70+
_ = try ScanCommand.parse([])
71+
}
72+
}
73+
74+
@Test("Unknown flag is rejected")
75+
func unknownFlagRejected() {
76+
#expect(throws: (any Error).self) {
77+
_ = try ScanCommand.parse(["archive.ipsw", "--bogus-flag"])
78+
}
79+
}
80+
81+
// MARK: - validate()
82+
//
83+
// Note: ArgumentParser's `parse(_:)` calls `validate()` automatically,
84+
// so validation failures surface as parse errors here.
85+
86+
@Test("Parse succeeds without --update-index")
87+
func validateWithoutUpdateIndex() throws {
88+
_ = try ScanCommand.parse(["archive.ipsw"])
89+
}
90+
91+
@Test("Parse succeeds when --update-index has --release-date")
92+
func validateUpdateIndexWithReleaseDate() throws {
93+
_ = try ScanCommand.parse([
94+
"archive.ipsw",
95+
"--update-index",
96+
"--release-date", "2025-07-07"
97+
])
98+
}
99+
100+
@Test("Parse rejects --update-index without --release-date")
101+
func validateRejectsUpdateIndexWithoutReleaseDate() {
102+
#expect(throws: (any Error).self) {
103+
_ = try ScanCommand.parse(["archive.ipsw", "--update-index"])
104+
}
105+
}
106+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import Foundation
2+
import Testing
3+
4+
@Suite("macosdb subprocess smoke tests")
5+
struct SubprocessSmokeTests {
6+
7+
@Test("--help exits zero and lists subcommands")
8+
func helpListsSubcommands() throws {
9+
let result = try runMacosdb(["--help"])
10+
#expect(result.exitCode == 0)
11+
#expect(result.stdout.contains("scan"))
12+
#expect(result.stdout.contains("list"))
13+
#expect(result.stdout.contains("show"))
14+
#expect(result.stdout.contains("compare"))
15+
#expect(result.stdout.contains("validate"))
16+
}
17+
18+
@Test("scan with missing archive exits non-zero")
19+
func scanMissingArchiveExitsNonZero() throws {
20+
let missing = NSTemporaryDirectory() + "definitely-not-here-\(UUID().uuidString).ipsw"
21+
let result = try runMacosdb(["scan", missing])
22+
#expect(result.exitCode != 0)
23+
#expect(result.stderr.contains("Archive not found") || result.stdout.contains("Archive not found"))
24+
}
25+
26+
@Test("scan --update-index without --release-date is rejected")
27+
func scanUpdateIndexRequiresReleaseDate() throws {
28+
let result = try runMacosdb(["scan", "archive.ipsw", "--update-index"])
29+
#expect(result.exitCode != 0)
30+
#expect(result.stderr.contains("--release-date"))
31+
}
32+
33+
@Test("validate with no arguments is rejected")
34+
func validateRequiresInput() throws {
35+
let result = try runMacosdb(["validate"])
36+
#expect(result.exitCode != 0)
37+
#expect(result.stderr.contains("Provide at least one archive path or --dir"))
38+
}
39+
40+
// MARK: - Helpers
41+
42+
private struct ProcessResult {
43+
let exitCode: Int32
44+
let stdout: String
45+
let stderr: String
46+
}
47+
48+
private func runMacosdb(_ arguments: [String], file: String = #filePath) throws -> ProcessResult {
49+
let process = Process()
50+
process.executableURL = try Self.findBinary(testSourcePath: file)
51+
process.arguments = arguments
52+
53+
let stdoutPipe = Pipe()
54+
let stderrPipe = Pipe()
55+
process.standardOutput = stdoutPipe
56+
process.standardError = stderrPipe
57+
58+
try process.run()
59+
process.waitUntilExit()
60+
61+
let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
62+
let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
63+
return ProcessResult(exitCode: process.terminationStatus, stdout: stdout, stderr: stderr)
64+
}
65+
66+
/// Locate the built `macosdb` binary by walking up from the test source file
67+
/// to the package root, then searching the build output directories for the
68+
/// most recently built copy. Handles both `swift test` (binary lives under
69+
/// `.build/`) and `xcodebuild test` (under `DerivedData/Build/Products/`).
70+
/// Swift Testing's runner loads the .xctest dynamically, so `Bundle.allBundles`
71+
/// doesn't reliably contain it the way it does under XCTest.
72+
private static func findBinary(testSourcePath: String) throws -> URL {
73+
var dir = URL(fileURLWithPath: testSourcePath).deletingLastPathComponent()
74+
while dir.path != "/" {
75+
let packageSwift = dir.appendingPathComponent("Package.swift")
76+
if FileManager.default.fileExists(atPath: packageSwift.path) {
77+
return try locateBinary(under: dir)
78+
}
79+
dir.deleteLastPathComponent()
80+
}
81+
fatalError("Could not find Package.swift walking up from \(testSourcePath)")
82+
}
83+
84+
private static func locateBinary(under packageRoot: URL) throws -> URL {
85+
let searchRoots = [".build", "DerivedData/Build/Products"]
86+
.map { packageRoot.appendingPathComponent($0) }
87+
.filter { FileManager.default.fileExists(atPath: $0.path) }
88+
89+
let candidates = searchRoots.flatMap { root -> [URL] in
90+
let subpaths = (try? FileManager.default.subpathsOfDirectory(atPath: root.path)) ?? []
91+
return subpaths
92+
.filter { $0.hasSuffix("/macosdb") || $0 == "macosdb" }
93+
.map { root.appendingPathComponent($0) }
94+
.filter { FileManager.default.isExecutableFile(atPath: $0.path) }
95+
}
96+
97+
guard let binary = candidates.max(by: { lhs, rhs in
98+
let lhsDate = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
99+
let rhsDate = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
100+
return lhsDate < rhsDate
101+
}) else {
102+
fatalError("Could not find a built macosdb binary under \(packageRoot.path) — build the package first")
103+
}
104+
return binary
105+
}
106+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import Foundation
2+
import Testing
3+
4+
@testable import macosdb
5+
6+
@Suite("ValidateCommand parsing and validation")
7+
struct ValidateCommandTests {
8+
9+
// MARK: - Argument parsing and validation
10+
//
11+
// Note: ArgumentParser's `parse(_:)` calls `validate()` automatically,
12+
// so validation failures surface as parse errors here.
13+
14+
@Test("Parses multiple archive paths")
15+
func parsesMultipleArchives() throws {
16+
let cmd = try ValidateCommand.parse(["a.ipsw", "b.ipsw", "c.xip"])
17+
#expect(cmd.archivePaths == ["a.ipsw", "b.ipsw", "c.xip"])
18+
#expect(cmd.dir == nil)
19+
#expect(cmd.rehash == false)
20+
}
21+
22+
@Test("Parses --dir and --rehash")
23+
func parsesDirAndRehash() throws {
24+
let cmd = try ValidateCommand.parse(["--dir", "/tmp/archives", "--rehash"])
25+
#expect(cmd.dir == "/tmp/archives")
26+
#expect(cmd.rehash == true)
27+
}
28+
29+
@Test("Parse succeeds with at least one archive path")
30+
func validateWithArchivePath() throws {
31+
_ = try ValidateCommand.parse(["a.ipsw"])
32+
}
33+
34+
@Test("Parse succeeds with --dir")
35+
func validateWithDir() throws {
36+
_ = try ValidateCommand.parse(["--dir", "/tmp/archives"])
37+
}
38+
39+
@Test("Parse rejects empty inputs")
40+
func validateRejectsEmpty() {
41+
#expect(throws: (any Error).self) {
42+
_ = try ValidateCommand.parse([])
43+
}
44+
}
45+
}

0 commit comments

Comments
 (0)