Skip to content

Commit 74d9f29

Browse files
authored
Support multiple test plans (#67)
1 parent dcac8c5 commit 74d9f29

File tree

12 files changed

+291
-37
lines changed

12 files changed

+291
-37
lines changed

Package.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ let targets: [PackageDescription.Target] = [
2929
"Git",
3030
"PathKit",
3131
"Rainbow",
32-
"Yams"]),
32+
"Yams",
33+
.product(name: "ArgumentParser", package: "swift-argument-parser")]),
3334
.target(name: "DependencyCalculator",
3435
dependencies: ["Workspace", "PathKit", "SelectiveTestLogger", "Git"]),
3536
.target(name: "TestConfigurator",

Plugins/SelectiveTestingPlugin/SelectiveTestingPlugin.swift

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,22 @@ struct SelectiveTestingPlugin: CommandPlugin {
4848
toolArguments.remove(at: indexOfTarget)
4949
}
5050

51-
if !toolArguments.contains(where: { $0 == "--test-plan" }),
52-
let testPlan = context.xcodeProject.filePaths.first(where: {
53-
$0.extension == "xctestplan"
54-
})
55-
{
56-
print("Using \(testPlan.string) test plan")
57-
toolArguments.append(contentsOf: ["--test-plan", testPlan.string])
51+
if !toolArguments.contains(where: { $0 == "--test-plan" }) {
52+
let testPlans = context.xcodeProject.filePaths.filter {
53+
$0.extension == "xctestplan"
54+
}
55+
56+
if !testPlans.isEmpty {
57+
if testPlans.count == 1 {
58+
print("Using \(testPlans[0].string) test plan")
59+
} else {
60+
print("Using \(testPlans.count) test plans")
61+
}
62+
63+
for testPlan in testPlans {
64+
toolArguments.append(contentsOf: ["--test-plan", testPlan.string])
65+
}
66+
}
5867
}
5968

6069
try run(tool.path.string, arguments: toolArguments)

README.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,14 @@ NB: This command assumes you have [jq](https://jqlang.github.io/jq/) tool instal
8484
Alternatively, you can use CLI to achieve the same result:
8585

8686
1. Run `mint run mikeger/XcodeSelectiveTesting@0.12.7 YourWorkspace.xcworkspace --test-plan YourTestPlan.xctestplan`
87-
2. Run tests normally, XcodeSelectiveTesting would modify your test plan according to the local changes
87+
2. Run tests normally, XcodeSelectiveTesting would modify your test plan according to the local changes
88+
89+
To process multiple test plans, specify the `--test-plan` option multiple times:
90+
```bash
91+
mint run mikeger/XcodeSelectiveTesting@0.12.7 YourWorkspace.xcworkspace \
92+
--test-plan TestPlan1.xctestplan \
93+
--test-plan TestPlan2.xctestplan
94+
```
8895

8996
### Use case: Xcode-based project, execute tests on the CI, no test plan
9097

@@ -99,6 +106,14 @@ Alternatively, you can use CLI to achieve the same result:
99106
2. Add a CI step before you execute your tests: `mint run mikeger/XcodeSelectiveTesting@0.12.7 YourWorkspace.xcworkspace --test-plan YourTestPlan.xctestplan --base-branch $PR_BASE_BRANCH`
100107
3. Execute your tests
101108

109+
To process multiple test plans on CI:
110+
```bash
111+
mint run mikeger/XcodeSelectiveTesting@0.12.7 YourWorkspace.xcworkspace \
112+
--test-plan TestPlan1.xctestplan \
113+
--test-plan TestPlan2.xctestplan \
114+
--base-branch $PR_BASE_BRANCH
115+
```
116+
102117
### Use case: GitHub Actions, other cases when the git repo is not in the shape to provide the changeset out of the box
103118

104119
1. Add code to install the tool
@@ -145,7 +160,7 @@ This is the hardest part: dealing with obscure Xcode formats. But if we get that
145160

146161
- `--help`: Display all command line options
147162
- `--base-branch`: Branch to compare against to find the relevant changes. If emitted, a local changeset is used (development mode).
148-
- `--test-plan`: Path to the test plan. If not given, tool would try to infer the path.
163+
- `--test-plan`: Path to the test plan. If not given, tool would try to infer the path. Can be specified multiple times to process multiple test plans.
149164
- `--json`: Provide output in JSON format (STDOUT).
150165
- `--dependency-graph`: Opens Safari with a dependency graph visualization. Attention: if you don't trust Javascript ecosystem prefer using `--dot` option. More info [here](https://github.com/mikeger/XcodeSelectiveTesting/wiki/How-to-visualize-your-dependency-structure).
151166
- `--dot`: Output dependency graph in Dot (Graphviz) format. To be used with Graphviz: `brew install graphviz`, then `xcode-selective-test --dot | dot -Tsvg > output.svg && open output.svg`
@@ -160,7 +175,8 @@ It is possible to define the configuration in a separate file. The tool would lo
160175
Options available are (see `selective-testing-config-example.yml` for an example):
161176

162177
- `basePath`: Relative or absolute path to the project. If set, the command line option can be emitted.
163-
- `testPlan`: Relative or absolute path to the test plan to configure.
178+
- `testPlan`: Relative or absolute path to the test plan to configure. For backwards compatibility.
179+
- `testPlans`: Array of relative or absolute paths to test plans to configure. Use this to process multiple test plans.
164180
- `exclude`: List of relative paths to exclude when looking for Swift packages.
165181
- `extra/dependencies`: Options allowing to hint tool about dependencies between targets or packages.
166182
- `extra/targetsFiles`: Options allowing to hint tool about the files affecting targets or packages.

Sources/DependencyCalculator/DependencyGraph.swift

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ extension WorkspaceInfo {
106106
var resultDependencies = packageWorkspaceInfo.dependencyStructure
107107
var files = packageWorkspaceInfo.files
108108
var folders = packageWorkspaceInfo.folders
109-
var candidateTestPlan = packageWorkspaceInfo.candidateTestPlan
109+
var candidateTestPlans = packageWorkspaceInfo.candidateTestPlans
110110

111111
let allProjects: [(XcodeProj, Path)]
112112
var workspaceDefinitionPath: Path? = nil
@@ -144,15 +144,13 @@ extension WorkspaceInfo {
144144

145145
files = files.merging(with: newFiles)
146146
folders = folders.merging(with: newDependencies.folders)
147-
if candidateTestPlan == nil {
148-
candidateTestPlan = newDependencies.candidateTestPlan
149-
}
147+
candidateTestPlans.append(contentsOf: newDependencies.candidateTestPlans)
150148
}
151149

152150
let workspaceInfo = WorkspaceInfo(files: files,
153151
folders: folders,
154152
dependencyStructure: resultDependencies,
155-
candidateTestPlan: candidateTestPlan)
153+
candidateTestPlans: candidateTestPlans)
156154
if let config {
157155
// Process additional config
158156
return processAdditional(config: config, workspaceInfo: workspaceInfo)
@@ -295,7 +293,7 @@ extension WorkspaceInfo {
295293
var dependsOn: [TargetIdentity: Set<TargetIdentity>] = [:]
296294
var files: [TargetIdentity: Set<Path>] = [:]
297295
var folders: [Path: TargetIdentity] = [:]
298-
var candidateTestPlan: Path? = nil
296+
var candidateTestPlans: [Path] = []
299297

300298
var packagesByName: [String: PackageTargetMetadata] = packages.toDictionary(path: \.name)
301299
let targetsByName = project.pbxproj.nativeTargets.toDictionary(path: \.name)
@@ -385,14 +383,17 @@ extension WorkspaceInfo {
385383
// Find existing test plans
386384
project.sharedData?.schemes.forEach { scheme in
387385
scheme.testAction?.testPlans?.forEach { plan in
388-
candidateTestPlan = path.parent() + plan.reference.replacingOccurrences(of: "container:", with: "")
386+
let testPlanPath = path.parent() + plan.reference.replacingOccurrences(of: "container:", with: "")
387+
if !candidateTestPlans.contains(testPlanPath) {
388+
candidateTestPlans.append(testPlanPath)
389+
}
389390
}
390391
}
391392

392393
return WorkspaceInfo(files: files,
393394
folders: folders,
394395
dependencyStructure: DependencyGraph(dependsOn: dependsOn),
395-
candidateTestPlan: candidateTestPlan?.string)
396+
candidateTestPlans: candidateTestPlans.map { $0.string })
396397
}
397398

398399
private static func isSwiftVersion6Plus() throws -> Bool {

Sources/SelectiveTestingCore/Config.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,24 @@ import Yams
99
struct Config: Codable {
1010
let basePath: String?
1111
let testPlan: String?
12+
let testPlans: [String]?
1213
let exclude: [String]?
1314

1415
let extra: WorkspaceInfo.AdditionalConfig?
1516

1617
static let defaultConfigName = ".xcode-selective-testing.yml"
18+
19+
/// Returns all test plans, merging singular `testPlan` and plural `testPlans`
20+
var allTestPlans: [String] {
21+
var plans: [String] = []
22+
if let testPlan = testPlan {
23+
plans.append(testPlan)
24+
}
25+
if let testPlans = testPlans {
26+
plans.append(contentsOf: testPlans)
27+
}
28+
return plans
29+
}
1730
}
1831

1932
extension Config {

Sources/SelectiveTestingCore/SelectiveTestingTool.swift

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ public final class SelectiveTestingTool {
2121
private let dryRun: Bool
2222
private let dot: Bool
2323
private let verbose: Bool
24-
private let testPlan: String?
24+
private let testPlans: [String]
2525
private let config: Config?
2626

2727
public init(baseBranch: String?,
2828
basePath: String?,
29-
testPlan: String?,
29+
testPlans: [String],
3030
changedFiles: [String],
3131
printJSON: Bool = false,
3232
renderDependencyGraph: Bool = false,
@@ -57,7 +57,11 @@ public final class SelectiveTestingTool {
5757
self.dot = dot
5858
self.dryRun = dryRun
5959
self.verbose = verbose
60-
self.testPlan = testPlan ?? config?.testPlan
60+
61+
// Merge CLI test plans with config test plans
62+
var allTestPlans = config?.allTestPlans ?? []
63+
allTestPlans.append(contentsOf: testPlans)
64+
self.testPlans = allTestPlans
6165
}
6266

6367
public func run() async throws -> Set<TargetIdentity> {
@@ -130,13 +134,26 @@ public final class SelectiveTestingTool {
130134
}
131135
}
132136

133-
if !dryRun, let testPlan {
137+
if !dryRun {
134138
// 4. Configure workspace to test given targets
135-
try enableTests(at: Path(testPlan),
136-
targetsToTest: affectedTargets)
137-
} else if !dryRun, let testPlan = workspaceInfo.candidateTestPlan {
138-
try enableTests(at: Path(testPlan),
139-
targetsToTest: affectedTargets)
139+
let plansToUpdate = testPlans.isEmpty ? workspaceInfo.candidateTestPlans : testPlans
140+
141+
if !plansToUpdate.isEmpty {
142+
for testPlan in plansToUpdate {
143+
try enableTests(at: Path(testPlan),
144+
targetsToTest: affectedTargets)
145+
}
146+
} else if !printJSON {
147+
if affectedTargets.isEmpty {
148+
if verbose { Logger.message("No targets affected") }
149+
} else {
150+
if verbose { Logger.message("Targets to test:") }
151+
152+
for target in affectedTargets {
153+
Logger.message(target.description)
154+
}
155+
}
156+
}
140157
} else if !printJSON {
141158
if affectedTargets.isEmpty {
142159
if verbose { Logger.message("No targets affected") }

Sources/Workspace/WorkspaceInfo.swift

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ public struct WorkspaceInfo {
2828
public let targetsForFiles: [Path: Set<TargetIdentity>]
2929
public let folders: [Path: TargetIdentity]
3030
public let dependencyStructure: DependencyGraph
31-
public var candidateTestPlan: String?
31+
public var candidateTestPlans: [String]
32+
33+
/// Backwards compatibility: returns the first candidate test plan
34+
public var candidateTestPlan: String? {
35+
candidateTestPlans.first
36+
}
3237

3338
public init(files: [TargetIdentity: Set<Path>],
3439
folders: [Path: TargetIdentity],
@@ -39,18 +44,31 @@ public struct WorkspaceInfo {
3944
targetsForFiles = WorkspaceInfo.targets(for: files)
4045
self.folders = folders
4146
self.dependencyStructure = dependencyStructure
42-
self.candidateTestPlan = candidateTestPlan
47+
self.candidateTestPlans = candidateTestPlan.map { [$0] } ?? []
48+
}
49+
50+
public init(files: [TargetIdentity: Set<Path>],
51+
folders: [Path: TargetIdentity],
52+
dependencyStructure: DependencyGraph,
53+
candidateTestPlans: [String])
54+
{
55+
self.files = files
56+
targetsForFiles = WorkspaceInfo.targets(for: files)
57+
self.folders = folders
58+
self.dependencyStructure = dependencyStructure
59+
self.candidateTestPlans = candidateTestPlans
4360
}
4461

4562
public func merging(with other: WorkspaceInfo) -> WorkspaceInfo {
4663
let newFiles = files.merging(with: other.files)
4764
let newFolders = folders.merging(with: other.folders)
4865
let dependencyStructure = dependencyStructure.merging(with: other.dependencyStructure)
66+
let mergedTestPlans = candidateTestPlans + other.candidateTestPlans
4967

5068
return WorkspaceInfo(files: newFiles,
5169
folders: newFolders,
5270
dependencyStructure: dependencyStructure,
53-
candidateTestPlan: candidateTestPlan ?? other.candidateTestPlan)
71+
candidateTestPlans: mergedTestPlans)
5472
}
5573

5674
static func targets(for targetsToFiles: [TargetIdentity: Set<Path>]) -> [Path: Set<TargetIdentity>] {

Sources/xcode-selective-test/SelectiveTesting.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ struct SelectiveTesting: AsyncParsableCommand {
1919
@Option(name: .long, help: "Name of the base branch")
2020
var baseBranch: String?
2121

22-
@Option(name: .long, help: "Test plan to modify")
23-
var testPlan: String?
22+
@Option(name: .long, parsing: .upToNextOption, help: "Test plan(s) to modify. Can be specified multiple times.")
23+
var testPlan: [String] = []
2424

2525
@Flag(name: .long, help: "Output in JSON format")
2626
var JSON: Bool = false
@@ -43,7 +43,7 @@ struct SelectiveTesting: AsyncParsableCommand {
4343
mutating func run() async throws {
4444
let tool = try SelectiveTestingTool(baseBranch: baseBranch,
4545
basePath: basePath,
46-
testPlan: testPlan,
46+
testPlans: testPlan,
4747
changedFiles: changedFiles,
4848
printJSON: JSON,
4949
renderDependencyGraph: dependencyGraph,
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
{
2+
"configurations" : [
3+
{
4+
"id" : "B9E0CAB3-55AE-4CC1-82BC-0598F7105368",
5+
"name" : "Test Scheme Action",
6+
"options" : {
7+
8+
}
9+
}
10+
],
11+
"defaultOptions" : {
12+
"codeCoverage" : false,
13+
"targetForVariableExpansion" : {
14+
"containerPath" : "container:ExampleProject.xcodeproj",
15+
"identifier" : "276DB5BA29B144C900E5C615",
16+
"name" : "ExampleProject"
17+
}
18+
},
19+
"testTargets" : [
20+
{
21+
"target" : {
22+
"containerPath" : "container:ExampleLibrary\/ExampleLibrary.xcodeproj",
23+
"identifier" : "27F467CF29B1453600A93E94",
24+
"name" : "ExampleLibraryTests"
25+
}
26+
},
27+
{
28+
"target" : {
29+
"containerPath" : "container:ExamplePackage",
30+
"identifier" : "ExamplePackageTests",
31+
"name" : "ExamplePackageTests"
32+
}
33+
},
34+
{
35+
"target" : {
36+
"containerPath" : "container:ExampleProject.xcodeproj",
37+
"identifier" : "276DB5CA29B144CA00E5C615",
38+
"name" : "ExampleProjectTests"
39+
}
40+
},
41+
{
42+
"target" : {
43+
"containerPath" : "container:ExampleProject.xcodeproj",
44+
"identifier" : "276DB5D429B144CA00E5C615",
45+
"name" : "ExampleProjectUITests"
46+
}
47+
},
48+
{
49+
"target" : {
50+
"containerPath" : "container:ExampleProject.xcodeproj",
51+
"identifier" : "27F467ED29B1457600A93E94",
52+
"name" : "ExmapleTargetLibraryTests"
53+
}
54+
},
55+
{
56+
"target" : {
57+
"containerPath" : "container:ExamplePackage",
58+
"identifier" : "Subtests",
59+
"name" : "Subtests"
60+
}
61+
}
62+
],
63+
"version" : 1
64+
}

Tests/SelectiveTestingTests/IntegrationTestTool.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,17 @@ final class IntegrationTestTool {
8383
try configText.write(toFile: path.string, atomically: true, encoding: .utf8)
8484
}
8585

86+
let testPlans: [String]
87+
if let testPlan {
88+
testPlans = [testPlan]
89+
}
90+
else {
91+
testPlans = []
92+
}
93+
8694
return try SelectiveTestingTool(baseBranch: "main",
8795
basePath: basePath?.string,
88-
testPlan: testPlan,
96+
testPlans: testPlans,
8997
changedFiles: changedFiles,
9098
renderDependencyGraph: false,
9199
turbo: turbo,
@@ -95,7 +103,7 @@ final class IntegrationTestTool {
95103
func createSUT() throws -> SelectiveTestingTool {
96104
return try SelectiveTestingTool(baseBranch: "main",
97105
basePath: (projectPath + "ExampleWorkspace.xcworkspace").string,
98-
testPlan: "ExampleProject.xctestplan",
106+
testPlans: ["ExampleProject.xctestplan"],
99107
changedFiles: [],
100108
renderDependencyGraph: false,
101109
verbose: true)

0 commit comments

Comments
 (0)