Skip to content

Commit 0447975

Browse files
committed
Add xcprojformatter end-to-end contract tests
1 parent 2963957 commit 0447975

4 files changed

Lines changed: 255 additions & 0 deletions

File tree

‎Package.swift‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ let package = Package(
4343
.define("ENABLE_PERFORMANCE_TESTS", .when(configuration: .release)),
4444
],
4545
),
46+
.testTarget(
47+
name: "XcodeProjectToolTests",
48+
dependencies: ["XcodeProjectTool", "XcodeProjectFormat"],
49+
path: "Sources/ToolTests",
50+
resources: [
51+
.copy("Fixtures"),
52+
],
53+
),
4654
],
4755
swiftLanguageModes: [.v6],
4856
)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"default-configuration": "Debug",
3+
"localizations": {
4+
"development": "en",
5+
},
6+
"files": [
7+
],
8+
"targets": [
9+
{
10+
"name": "App",
11+
"id": "0123456789ABCDEF",
12+
},
13+
],
14+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
// Deliberately non-canonical order and whitespace.
3+
"targets": [
4+
{
5+
"id": "0123456789ABCDEF",
6+
"name": "App",
7+
},
8+
],
9+
"files": [],
10+
"localizations": {
11+
"supported": [],
12+
"development": "en",
13+
},
14+
"default-configuration": "Debug",
15+
}

‎Sources/ToolTests/ToolTests.swift‎

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the xcode-project-format project authors
3+
//
4+
// Licensed under Apache License v2.0
5+
// SPDX-License-Identifier: Apache-2.0
6+
//
7+
//===----------------------------------------------------------------------===//
8+
9+
import Foundation
10+
import Testing
11+
import XcodeProjectFormat
12+
13+
struct XcodeProjectToolTests {
14+
@Test func help() throws {
15+
let result = try runTool(arguments: ["--help"])
16+
17+
#expect(result.status == 0)
18+
#expect(result.stderr.isEmpty)
19+
#expect(String(decoding: result.stdout, as: UTF8.self).contains("Usage:"))
20+
}
21+
22+
@Test func readsStandardInputAndWritesStandardOutput() throws {
23+
let result = try runTool(arguments: [], input: fixtureData(named: "input"))
24+
let expected = try fixtureData(named: "expected")
25+
26+
try expectSuccessful(result)
27+
#expect(result.stdout == expected)
28+
try expectProjectCanDecode(result.stdout)
29+
}
30+
31+
@Test func readsPositionalOuterProjectPath() throws {
32+
try withTemporaryDirectory { temporaryDirectory in
33+
let inputProject = try copyInputProject(to: temporaryDirectory)
34+
let result = try runTool(arguments: [inputProject.path])
35+
let expected = try fixtureData(named: "expected")
36+
37+
try expectSuccessful(result)
38+
#expect(result.stdout == expected)
39+
try expectProjectCanDecode(result.stdout)
40+
}
41+
}
42+
43+
@Test func readsInnerInputAndWritesInnerOutput() throws {
44+
try withTemporaryDirectory { temporaryDirectory in
45+
let input = temporaryDirectory.appendingPathComponent("input.project.xcproj")
46+
let output = temporaryDirectory.appendingPathComponent("output.project.xcproj")
47+
try FileManager.default.copyItem(at: fixtureURL(named: "input"), to: input)
48+
49+
let result = try runTool(arguments: [
50+
"--input", input.path,
51+
"--output", output.path,
52+
])
53+
54+
try expectSuccessfulFileOutput(result, at: output)
55+
}
56+
}
57+
58+
@Test func writesToOuterProjectPath() throws {
59+
try withTemporaryDirectory { temporaryDirectory in
60+
let inputProject = try copyInputProject(to: temporaryDirectory)
61+
let outputProject = temporaryDirectory.appendingPathComponent("Output.xcodeproj")
62+
63+
let result = try runTool(arguments: [
64+
"--input", inputProject.path,
65+
"--output", outputProject.path,
66+
])
67+
68+
try expectSuccessfulFileOutput(result, at: outputProject.appendingPathComponent("project.xcproj"))
69+
}
70+
}
71+
72+
@Test func updatesOuterProjectInPlace() throws {
73+
try withTemporaryDirectory { temporaryDirectory in
74+
let project = try copyInputProject(to: temporaryDirectory)
75+
let result = try runTool(arguments: ["--update", project.path])
76+
77+
try expectSuccessfulFileOutput(result, at: project.appendingPathComponent("project.xcproj"))
78+
}
79+
}
80+
81+
@Test func failedUpdateLeavesInputUnchanged() throws {
82+
try withTemporaryDirectory { temporaryDirectory in
83+
let project = temporaryDirectory.appendingPathComponent("Malformed.xcodeproj")
84+
let input = project.appendingPathComponent("project.xcproj")
85+
let original = Data("{\n \"files\": [\n".utf8)
86+
try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true)
87+
try original.write(to: input)
88+
89+
let result = try runTool(arguments: ["--update", project.path])
90+
91+
#expect(result.status != 0)
92+
#expect(result.stdout.isEmpty)
93+
#expect(!result.stderr.isEmpty)
94+
let current = try Data(contentsOf: input)
95+
#expect(current == original)
96+
}
97+
}
98+
}
99+
100+
private struct ToolResult {
101+
var status: Int32
102+
var stdout: Data
103+
var stderr: Data
104+
}
105+
106+
private enum ToolTestError: Error, CustomStringConvertible {
107+
case missingFixture(String)
108+
case unexpectedBinaryCandidates([String])
109+
110+
var description: String {
111+
switch self {
112+
case let .missingFixture(name): "Missing test fixture \(name)"
113+
case let .unexpectedBinaryCandidates(candidates):
114+
"Expected one matching xcprojformatter binary, found: \(candidates.joined(separator: ", "))"
115+
}
116+
}
117+
}
118+
119+
private func runTool(arguments: [String], input: Data? = nil, sourceFilePath: String = #filePath) throws -> ToolResult {
120+
let process = Process()
121+
process.executableURL = try formatterURL(sourceFilePath: sourceFilePath)
122+
process.arguments = arguments
123+
124+
let standardInput = Pipe()
125+
let standardOutput = Pipe()
126+
let standardError = Pipe()
127+
process.standardInput = standardInput
128+
process.standardOutput = standardOutput
129+
process.standardError = standardError
130+
131+
try process.run()
132+
if let input {
133+
try standardInput.fileHandleForWriting.write(contentsOf: input)
134+
}
135+
try standardInput.fileHandleForWriting.close()
136+
process.waitUntilExit()
137+
138+
return ToolResult(
139+
status: process.terminationStatus,
140+
stdout: standardOutput.fileHandleForReading.readDataToEndOfFile(),
141+
stderr: standardError.fileHandleForReading.readDataToEndOfFile(),
142+
)
143+
}
144+
145+
private func formatterURL(sourceFilePath: String) throws -> URL {
146+
let packageRoot = URL(fileURLWithPath: sourceFilePath)
147+
.deletingLastPathComponent()
148+
.deletingLastPathComponent()
149+
.deletingLastPathComponent()
150+
let buildDirectory = packageRoot.appendingPathComponent(".build")
151+
let fileManager = FileManager.default
152+
#if DEBUG
153+
let buildConfiguration = "Debug"
154+
#else
155+
let buildConfiguration = "Release"
156+
#endif
157+
let candidates = (fileManager.enumerator(
158+
at: buildDirectory,
159+
includingPropertiesForKeys: [.isRegularFileKey, .isExecutableKey],
160+
options: [],
161+
)?.compactMap { $0 as? URL } ?? []).filter { url in
162+
url.lastPathComponent == "xcprojformatter"
163+
&& url.pathComponents.contains(buildConfiguration)
164+
&& fileManager.isExecutableFile(atPath: url.path)
165+
}
166+
167+
guard candidates.count == 1 else {
168+
throw ToolTestError.unexpectedBinaryCandidates(candidates.map(\.path).sorted())
169+
}
170+
return candidates[0]
171+
}
172+
173+
private func fixtureURL(named name: String) throws -> URL {
174+
guard let url = Bundle.module.url(forResource: "\(name).project.xcproj", withExtension: nil, subdirectory: "Fixtures") else {
175+
throw ToolTestError.missingFixture(name)
176+
}
177+
return url
178+
}
179+
180+
private func fixtureData(named name: String) throws -> Data {
181+
try Data(contentsOf: fixtureURL(named: name))
182+
}
183+
184+
private func copyInputProject(to temporaryDirectory: URL) throws -> URL {
185+
let project = temporaryDirectory.appendingPathComponent("Input.xcodeproj")
186+
try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true)
187+
try FileManager.default.copyItem(
188+
at: fixtureURL(named: "input"),
189+
to: project.appendingPathComponent("project.xcproj"),
190+
)
191+
return project
192+
}
193+
194+
private func expectSuccessful(_ result: ToolResult) throws {
195+
#expect(result.status == 0)
196+
#expect(result.stderr.isEmpty)
197+
}
198+
199+
private func expectSuccessfulFileOutput(_ result: ToolResult, at output: URL) throws {
200+
try expectSuccessful(result)
201+
let expected = try fixtureData(named: "expected")
202+
let actual = try Data(contentsOf: output)
203+
#expect(result.stdout.isEmpty)
204+
#expect(actual == expected)
205+
try expectProjectCanDecode(actual)
206+
}
207+
208+
private func expectProjectCanDecode(_ data: Data) throws {
209+
_ = try XCSchema.Project(jsonRepresentation: data)
210+
}
211+
212+
private func withTemporaryDirectory<Result>(body: (URL) throws -> Result) throws -> Result {
213+
let directory = FileManager.default.temporaryDirectory
214+
.appendingPathComponent("XcodeProjectToolTests-\(UUID().uuidString)")
215+
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
216+
defer { try? FileManager.default.removeItem(at: directory) }
217+
return try body(directory)
218+
}

0 commit comments

Comments
 (0)