Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ let targets: [PackageDescription.Target] = [
"Git",
"PathKit",
"Rainbow",
"Yams"]),
"Yams",
.product(name: "ArgumentParser", package: "swift-argument-parser")]),
.target(name: "DependencyCalculator",
dependencies: ["Workspace", "PathKit", "SelectiveTestLogger", "Git"]),
.target(name: "TestConfigurator",
Expand Down
23 changes: 16 additions & 7 deletions Plugins/SelectiveTestingPlugin/SelectiveTestingPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,22 @@ struct SelectiveTestingPlugin: CommandPlugin {
toolArguments.remove(at: indexOfTarget)
}

if !toolArguments.contains(where: { $0 == "--test-plan" }),
let testPlan = context.xcodeProject.filePaths.first(where: {
$0.extension == "xctestplan"
})
{
print("Using \(testPlan.string) test plan")
toolArguments.append(contentsOf: ["--test-plan", testPlan.string])
if !toolArguments.contains(where: { $0 == "--test-plan" }) {
let testPlans = context.xcodeProject.filePaths.filter {
$0.extension == "xctestplan"
}

if !testPlans.isEmpty {
if testPlans.count == 1 {
print("Using \(testPlans[0].string) test plan")
} else {
print("Using \(testPlans.count) test plans")
}

for testPlan in testPlans {
toolArguments.append(contentsOf: ["--test-plan", testPlan.string])
}
}
}

try run(tool.path.string, arguments: toolArguments)
Expand Down
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,14 @@ NB: This command assumes you have [jq](https://jqlang.github.io/jq/) tool instal
Alternatively, you can use CLI to achieve the same result:

1. Run `mint run mikeger/XcodeSelectiveTesting@0.12.7 YourWorkspace.xcworkspace --test-plan YourTestPlan.xctestplan`
2. Run tests normally, XcodeSelectiveTesting would modify your test plan according to the local changes
2. Run tests normally, XcodeSelectiveTesting would modify your test plan according to the local changes

To process multiple test plans, specify the `--test-plan` option multiple times:
```bash
mint run mikeger/XcodeSelectiveTesting@0.12.7 YourWorkspace.xcworkspace \
--test-plan TestPlan1.xctestplan \
--test-plan TestPlan2.xctestplan
```

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

Expand All @@ -99,6 +106,14 @@ Alternatively, you can use CLI to achieve the same result:
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`
3. Execute your tests

To process multiple test plans on CI:
```bash
mint run mikeger/XcodeSelectiveTesting@0.12.7 YourWorkspace.xcworkspace \
--test-plan TestPlan1.xctestplan \
--test-plan TestPlan2.xctestplan \
--base-branch $PR_BASE_BRANCH
```

### Use case: GitHub Actions, other cases when the git repo is not in the shape to provide the changeset out of the box

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

- `--help`: Display all command line options
- `--base-branch`: Branch to compare against to find the relevant changes. If emitted, a local changeset is used (development mode).
- `--test-plan`: Path to the test plan. If not given, tool would try to infer the path.
- `--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.
- `--json`: Provide output in JSON format (STDOUT).
- `--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).
- `--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`
Expand All @@ -160,7 +175,8 @@ It is possible to define the configuration in a separate file. The tool would lo
Options available are (see `selective-testing-config-example.yml` for an example):

- `basePath`: Relative or absolute path to the project. If set, the command line option can be emitted.
- `testPlan`: Relative or absolute path to the test plan to configure.
- `testPlan`: Relative or absolute path to the test plan to configure. For backwards compatibility.
- `testPlans`: Array of relative or absolute paths to test plans to configure. Use this to process multiple test plans.
- `exclude`: List of relative paths to exclude when looking for Swift packages.
- `extra/dependencies`: Options allowing to hint tool about dependencies between targets or packages.
- `extra/targetsFiles`: Options allowing to hint tool about the files affecting targets or packages.
Expand Down
17 changes: 9 additions & 8 deletions Sources/DependencyCalculator/DependencyGraph.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ extension WorkspaceInfo {
var resultDependencies = packageWorkspaceInfo.dependencyStructure
var files = packageWorkspaceInfo.files
var folders = packageWorkspaceInfo.folders
var candidateTestPlan = packageWorkspaceInfo.candidateTestPlan
var candidateTestPlans = packageWorkspaceInfo.candidateTestPlans

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

files = files.merging(with: newFiles)
folders = folders.merging(with: newDependencies.folders)
if candidateTestPlan == nil {
candidateTestPlan = newDependencies.candidateTestPlan
}
candidateTestPlans.append(contentsOf: newDependencies.candidateTestPlans)
}

let workspaceInfo = WorkspaceInfo(files: files,
folders: folders,
dependencyStructure: resultDependencies,
candidateTestPlan: candidateTestPlan)
candidateTestPlans: candidateTestPlans)
if let config {
// Process additional config
return processAdditional(config: config, workspaceInfo: workspaceInfo)
Expand Down Expand Up @@ -295,7 +293,7 @@ extension WorkspaceInfo {
var dependsOn: [TargetIdentity: Set<TargetIdentity>] = [:]
var files: [TargetIdentity: Set<Path>] = [:]
var folders: [Path: TargetIdentity] = [:]
var candidateTestPlan: Path? = nil
var candidateTestPlans: [Path] = []

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

return WorkspaceInfo(files: files,
folders: folders,
dependencyStructure: DependencyGraph(dependsOn: dependsOn),
candidateTestPlan: candidateTestPlan?.string)
candidateTestPlans: candidateTestPlans.map { $0.string })
}

private static func isSwiftVersion6Plus() throws -> Bool {
Expand Down
13 changes: 13 additions & 0 deletions Sources/SelectiveTestingCore/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,24 @@ import Yams
struct Config: Codable {
let basePath: String?
let testPlan: String?
let testPlans: [String]?
let exclude: [String]?

let extra: WorkspaceInfo.AdditionalConfig?

static let defaultConfigName = ".xcode-selective-testing.yml"

/// Returns all test plans, merging singular `testPlan` and plural `testPlans`
var allTestPlans: [String] {
var plans: [String] = []
if let testPlan = testPlan {
plans.append(testPlan)
}
if let testPlans = testPlans {
plans.append(contentsOf: testPlans)
}
return plans
}
}

extension Config {
Expand Down
35 changes: 26 additions & 9 deletions Sources/SelectiveTestingCore/SelectiveTestingTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ public final class SelectiveTestingTool {
private let dryRun: Bool
private let dot: Bool
private let verbose: Bool
private let testPlan: String?
private let testPlans: [String]
private let config: Config?

public init(baseBranch: String?,
basePath: String?,
testPlan: String?,
testPlans: [String],
changedFiles: [String],
printJSON: Bool = false,
renderDependencyGraph: Bool = false,
Expand Down Expand Up @@ -57,7 +57,11 @@ public final class SelectiveTestingTool {
self.dot = dot
self.dryRun = dryRun
self.verbose = verbose
self.testPlan = testPlan ?? config?.testPlan

// Merge CLI test plans with config test plans
var allTestPlans = config?.allTestPlans ?? []
allTestPlans.append(contentsOf: testPlans)
self.testPlans = allTestPlans
}

public func run() async throws -> Set<TargetIdentity> {
Expand Down Expand Up @@ -130,13 +134,26 @@ public final class SelectiveTestingTool {
}
}

if !dryRun, let testPlan {
if !dryRun {
// 4. Configure workspace to test given targets
try enableTests(at: Path(testPlan),
targetsToTest: affectedTargets)
} else if !dryRun, let testPlan = workspaceInfo.candidateTestPlan {
try enableTests(at: Path(testPlan),
targetsToTest: affectedTargets)
let plansToUpdate = testPlans.isEmpty ? workspaceInfo.candidateTestPlans : testPlans

if !plansToUpdate.isEmpty {
for testPlan in plansToUpdate {
try enableTests(at: Path(testPlan),
targetsToTest: affectedTargets)
}
} else if !printJSON {
if affectedTargets.isEmpty {
if verbose { Logger.message("No targets affected") }
} else {
if verbose { Logger.message("Targets to test:") }

for target in affectedTargets {
Logger.message(target.description)
}
}
}
} else if !printJSON {
if affectedTargets.isEmpty {
if verbose { Logger.message("No targets affected") }
Expand Down
24 changes: 21 additions & 3 deletions Sources/Workspace/WorkspaceInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ public struct WorkspaceInfo {
public let targetsForFiles: [Path: Set<TargetIdentity>]
public let folders: [Path: TargetIdentity]
public let dependencyStructure: DependencyGraph
public var candidateTestPlan: String?
public var candidateTestPlans: [String]

/// Backwards compatibility: returns the first candidate test plan
public var candidateTestPlan: String? {
candidateTestPlans.first
}

public init(files: [TargetIdentity: Set<Path>],
folders: [Path: TargetIdentity],
Expand All @@ -39,18 +44,31 @@ public struct WorkspaceInfo {
targetsForFiles = WorkspaceInfo.targets(for: files)
self.folders = folders
self.dependencyStructure = dependencyStructure
self.candidateTestPlan = candidateTestPlan
self.candidateTestPlans = candidateTestPlan.map { [$0] } ?? []
}

public init(files: [TargetIdentity: Set<Path>],
folders: [Path: TargetIdentity],
dependencyStructure: DependencyGraph,
candidateTestPlans: [String])
{
self.files = files
targetsForFiles = WorkspaceInfo.targets(for: files)
self.folders = folders
self.dependencyStructure = dependencyStructure
self.candidateTestPlans = candidateTestPlans
}

public func merging(with other: WorkspaceInfo) -> WorkspaceInfo {
let newFiles = files.merging(with: other.files)
let newFolders = folders.merging(with: other.folders)
let dependencyStructure = dependencyStructure.merging(with: other.dependencyStructure)
let mergedTestPlans = candidateTestPlans + other.candidateTestPlans

return WorkspaceInfo(files: newFiles,
folders: newFolders,
dependencyStructure: dependencyStructure,
candidateTestPlan: candidateTestPlan ?? other.candidateTestPlan)
candidateTestPlans: mergedTestPlans)
}

static func targets(for targetsToFiles: [TargetIdentity: Set<Path>]) -> [Path: Set<TargetIdentity>] {
Expand Down
6 changes: 3 additions & 3 deletions Sources/xcode-selective-test/SelectiveTesting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ struct SelectiveTesting: AsyncParsableCommand {
@Option(name: .long, help: "Name of the base branch")
var baseBranch: String?

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

@Flag(name: .long, help: "Output in JSON format")
var JSON: Bool = false
Expand All @@ -43,7 +43,7 @@ struct SelectiveTesting: AsyncParsableCommand {
mutating func run() async throws {
let tool = try SelectiveTestingTool(baseBranch: baseBranch,
basePath: basePath,
testPlan: testPlan,
testPlans: testPlan,
changedFiles: changedFiles,
printJSON: JSON,
renderDependencyGraph: dependencyGraph,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"configurations" : [
{
"id" : "B9E0CAB3-55AE-4CC1-82BC-0598F7105368",
"name" : "Test Scheme Action",
"options" : {

}
}
],
"defaultOptions" : {
"codeCoverage" : false,
"targetForVariableExpansion" : {
"containerPath" : "container:ExampleProject.xcodeproj",
"identifier" : "276DB5BA29B144C900E5C615",
"name" : "ExampleProject"
}
},
"testTargets" : [
{
"target" : {
"containerPath" : "container:ExampleLibrary\/ExampleLibrary.xcodeproj",
"identifier" : "27F467CF29B1453600A93E94",
"name" : "ExampleLibraryTests"
}
},
{
"target" : {
"containerPath" : "container:ExamplePackage",
"identifier" : "ExamplePackageTests",
"name" : "ExamplePackageTests"
}
},
{
"target" : {
"containerPath" : "container:ExampleProject.xcodeproj",
"identifier" : "276DB5CA29B144CA00E5C615",
"name" : "ExampleProjectTests"
}
},
{
"target" : {
"containerPath" : "container:ExampleProject.xcodeproj",
"identifier" : "276DB5D429B144CA00E5C615",
"name" : "ExampleProjectUITests"
}
},
{
"target" : {
"containerPath" : "container:ExampleProject.xcodeproj",
"identifier" : "27F467ED29B1457600A93E94",
"name" : "ExmapleTargetLibraryTests"
}
},
{
"target" : {
"containerPath" : "container:ExamplePackage",
"identifier" : "Subtests",
"name" : "Subtests"
}
}
],
"version" : 1
}
12 changes: 10 additions & 2 deletions Tests/SelectiveTestingTests/IntegrationTestTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,17 @@ final class IntegrationTestTool {
try configText.write(toFile: path.string, atomically: true, encoding: .utf8)
}

let testPlans: [String]
if let testPlan {
testPlans = [testPlan]
}
else {
testPlans = []
}

return try SelectiveTestingTool(baseBranch: "main",
basePath: basePath?.string,
testPlan: testPlan,
testPlans: testPlans,
changedFiles: changedFiles,
renderDependencyGraph: false,
turbo: turbo,
Expand All @@ -95,7 +103,7 @@ final class IntegrationTestTool {
func createSUT() throws -> SelectiveTestingTool {
return try SelectiveTestingTool(baseBranch: "main",
basePath: (projectPath + "ExampleWorkspace.xcworkspace").string,
testPlan: "ExampleProject.xctestplan",
testPlans: ["ExampleProject.xctestplan"],
changedFiles: [],
renderDependencyGraph: false,
verbose: true)
Expand Down
Loading