Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[5/y] Improve support for configuring SPM Xcode projects #253

Merged
merged 1 commit into from
Jan 6, 2022
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
6 changes: 5 additions & 1 deletion Mockingbird.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
/* End PBXAggregateTarget section */

/* Begin PBXBuildFile section */
2803D96927781A4D00651C60 /* String+Regex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2803D96827781A4D00651C60 /* String+Regex.swift */; };
281B6246251DC7220084EBED /* MockingbirdShadowedTestsHost.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 281B6226251DC5AD0084EBED /* MockingbirdShadowedTestsHost.framework */; };
281B6286251DC7FC0084EBED /* ModuleNameShadowing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 281B61F1251DBBDE0084EBED /* ModuleNameShadowing.swift */; };
281B62FF251DCCFC0084EBED /* MockingbirdShadowedTestsHostMocks.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 281B62FE251DCCFC0084EBED /* MockingbirdShadowedTestsHostMocks.generated.swift */; };
Expand Down Expand Up @@ -494,6 +495,7 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
2803D96827781A4D00651C60 /* String+Regex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Regex.swift"; sourceTree = "<group>"; };
281B61F1251DBBDE0084EBED /* ModuleNameShadowing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleNameShadowing.swift; sourceTree = "<group>"; };
281B6226251DC5AD0084EBED /* MockingbirdShadowedTestsHost.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = MockingbirdShadowedTestsHost.framework; sourceTree = BUILT_PRODUCTS_DIR; };
281B62CF251DCAB90084EBED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1166,6 +1168,7 @@
D36A3DCA24922D6B007964DC /* Encodable+SHA1.swift */,
2838FD2027756314007A1CB4 /* Path+Abbreviate.swift */,
28C28E7826C0EE4D00729617 /* Path+Symlink.swift */,
2803D96827781A4D00651C60 /* String+Regex.swift */,
28C28E7A26C0FB2700729617 /* TimeUnit.swift */,
);
path = Utils;
Expand Down Expand Up @@ -2109,7 +2112,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -e\n\n${BUILT_PRODUCTS_DIR}/MockingbirdCli generate \\\n --targets 'MockingbirdTestsHost' 'MockingbirdShadowedTestsHost' \\\n --outputs \"${SRCROOT}/Tests/MockingbirdTests/Mocks/MockingbirdTestsHostMocks.generated.swift\" \"${SRCROOT}/Tests/MockingbirdTests/Mocks/MockingbirdShadowedTestsHostMocks.generated.swift\" \\\n --support \"${SRCROOT}/Sources/MockingbirdSupport\" \\\n --diagnostics all \\\n --disable-cache \\\n --prune stub \\\n --verbose\n";
shellScript = "set -eu\n\n# Prevent Xcode 13 from running this script while indexing.\n[[ \"${ACTION}\" == \"indexbuild\" ]] && exit 0\n\n\"${BUILT_PRODUCTS_DIR}/MockingbirdCli\" generate \\\n --targets 'MockingbirdTestsHost' 'MockingbirdShadowedTestsHost' \\\n --outputs \"${SRCROOT}/Tests/MockingbirdTests/Mocks/MockingbirdTestsHostMocks.generated.swift\" \"${SRCROOT}/Tests/MockingbirdTests/Mocks/MockingbirdShadowedTestsHostMocks.generated.swift\" \\\n --support \"${SRCROOT}/Sources/MockingbirdSupport\" \\\n --diagnostics all \\\n --disable-cache \\\n --prune stub \\\n --verbose\n";
};
D372A59C2455878B0000E80A /* Generate Embedded Dylibs */ = {
isa = PBXShellScriptBuildPhase;
Expand Down Expand Up @@ -2316,6 +2319,7 @@
D3DC37952492140D001E02A5 /* Generator+PruningPipeline.swift in Sources */,
2852643F2775882B000298B3 /* SwiftFilePath.swift in Sources */,
28C28E7B26C0FB2700729617 /* TimeUnit.swift in Sources */,
2803D96927781A4D00651C60 /* String+Regex.swift in Sources */,
28C2EE4A2771AA41003CD0D5 /* ExtendedGeneratorTypes.swift in Sources */,
OBJ_814 /* Generator.swift in Sources */,
OBJ_815 /* Installer.swift in Sources */,
Expand Down
29 changes: 17 additions & 12 deletions README-0.19.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ Add the framework to your project:
In your project directory, resolve the derived data path. This can take a few moments.

```console
$ DERIVED_DATA="$(xcodebuild -showBuildSettings | pcregrep -o1 'OBJROOT = (/.*)/Build')"
$ DERIVED_DATA="$(xcodebuild -showBuildSettings | sed -n 's|.*BUILD_ROOT = \(.*\)/Build/.*|\1|p'
```

Finally, configure the test target to generate mocks for specific modules or libraries.
Expand Down Expand Up @@ -198,30 +198,30 @@ let package = Package(
)
```

In your project directory, initialize the package.
In your package directory, initialize the dependency.

```console
$ xcodebuild -resolvePackageDependencies
$ swift package update Mockingbird
```

Next, save Bash script below in the same directory as your package manifest. Change the lines marked with `FIXME`.
Next, create a Bash script named `gen-mocks.sh` in the same directory as your package manifest. Copy the example below, making sure to change the lines marked with `FIXME`.

```bash
#!/bin/bash
set -eu
cd "$(dirname "$0")"
readonly derivedData="$(xcodebuild -showBuildSettings | pcregrep -o1 'OBJROOT = (/.*)/Build')"
swift package describe --type json > project.json
"${derivedData}/SourcePackages/checkouts/mockingbird/mockingbird" generate --project project.json \
--testbundle MyPackageTests \ # FIXME: The name of your test target.
--targets MyPackage MyLibrary1 MyLibrary2 # FIXME: Specific modules or libraries that should be mocked.
.build/checkouts/mockingbird/mockingbird generate --project project.json \
--output-dir Sources/MyPackageTests/MockingbirdMocks \ # FIXME: Where mocks should be generated.
--testbundle MyPackageTests \ # FIXME: Name of your test target.
--targets MyPackage MyLibrary1 MyLibrary2 # FIXME: Specific modules or libraries that should be mocked.
```

Ensure that the script runs and generates mock files.

```console
$ chmod u+x generate-mocks.sh
$ ./generate-mocks.sh
$ chmod u+x gen-mocks.sh
$ ./gen-mocks.sh
Generated file to MockingbirdMocks/MyPackageTests-MyPackage.generated.swift
Generated file to MockingbirdMocks/MyPackageTests-MyLibrary1.generated.swift
Generated file to MockingbirdMocks/MyPackageTests-MyLibrary2.generated.swift
Expand Down Expand Up @@ -728,8 +728,9 @@ Generate mocks for a set of targets in a project.
| Option | Default Value | Description |
| --- | --- | --- |
| `-t, --targets` | *(required)* | List of target names to generate mocks for. |
| `-o, --outputs` | [`(inferred)`](#--outputs) | List of mock output file paths for each target. |
| `-o, --outputs` | [`(inferred)`](#--outputs) | List of output file paths corresponding to each target. |
| `-p, --project` | [`(inferred)`](#--project) | Path to an Xcode project or a [JSON project description](https://github.com/birdrides/mockingbird/wiki/Manual-Setup#generating-mocks-for-non-xcode-projects). |
| `--output-dir` | [`(inferred)`](#--outputs) | The directory where generated files should be output. |
| `--srcroot` | [`(inferred)`](#--srcroot) | The directory containing your project’s source files. |
| `--support` | [`(inferred)`](#--support) | The directory containing [supporting source files](https://github.com/birdrides/mockingbird/wiki/Supporting-Source-Files). |
| `--testbundle` | [`(inferred)`](#--testbundle) | The name of the test bundle using the mocks. |
Expand Down Expand Up @@ -788,7 +789,7 @@ Mockingbird checks the environment variables `SRCROOT` and `SOURCE_ROOT` set by

#### `--outputs`

Mockingbird generates mocks into the directory `$(SRCROOT)/MockingbirdMocks` with the file name `$(PRODUCT_MODULE_NAME)Mocks.generated.swift`.
Mockingbird generates mocks into the directory `$(SRCROOT)/MockingbirdMocks` with the file name `$(PRODUCT_MODULE_NAME)Mocks-$(TEST_TARGET_NAME).generated.swift`.

#### `--support`

Expand All @@ -798,6 +799,10 @@ Mockingbird recursively looks for [supporting source files](https://github.com/b

Mockingbird checks the environment variables `TARGET_NAME` and `TARGETNAME` set by the Xcode build context and verifies that it refers to a valid Swift unit test target. The test bundle option must be set when using [JSON project descriptions](https://github.com/birdrides/mockingbird/wiki/Manual-Setup#generating-mocks-for-non-xcode-projects) in order to enable thunk stubs.

### `--generator`

Mockingbird uses the current executable path and attempts to make it relative to the project’s `SRCROOT` or derived data. To improve portability across development environments, avoid linking executables outside of project-specific directories.

### `--url`

Mockingbird uses the GitHub release artifacts located at `https://github.com/birdrides/mockingbird/releases/download`. Note that asset bundles are versioned by release.
Expand Down
10 changes: 9 additions & 1 deletion Sources/MockingbirdCli/Interface/Commands/Generate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,12 @@ extension Mockingbird {
.customLong("output"),
.customShort("o")],
parsing: .upToNextOption,
help: "List of mock output file paths for each target.")
help: "List of output file paths corresponding to each target.")
var outputs: [SwiftFilePath] = [] // TODO: This will be optional in generator v2

@Option(help: "The directory where generated files should be output.")
var outputDir: DirectoryPath?

@Option(help: "The directory containing supporting source files.")
var support: DirectoryPath?

Expand Down Expand Up @@ -80,6 +83,7 @@ extension Mockingbird {
let project: Path
let srcroot: Path
let outputs: [Path]
let outputDir: Path?
let support: Path
let testbundle: String?
let header: [String]
Expand Down Expand Up @@ -134,6 +138,7 @@ extension Mockingbird {
project: validProject.path,
srcroot: validSrcroot.path,
outputs: outputs.map({ $0.path }),
outputDir: outputDir?.path, // Managed by the generator.
support: validSupportPath,
testbundle: validTestBundle?.name,
header: header,
Expand Down Expand Up @@ -162,6 +167,7 @@ extension Mockingbird {
environmentSourceRoot: arguments.environmentSourceRoot,
environmentTargetName: arguments.environmentTargetName,
outputPaths: arguments.outputs,
outputDir: arguments.outputDir,
supportPath: arguments.support,
header: arguments.header,
compilationCondition: arguments.condition,
Expand All @@ -185,6 +191,7 @@ extension Mockingbird.Generate: EncodableArguments {
case project
case srcroot
case outputs
case outputDir
case support
case testbundle
case header
Expand All @@ -211,6 +218,7 @@ extension Mockingbird.Generate: EncodableArguments {
try container.encode(project, forKey: .project)
try container.encode(srcroot, forKey: .srcroot)
try container.encode(outputs, forKey: .outputs)
try container.encode(outputDir, forKey: .outputDir)
try container.encode(support, forKey: .support)
try container.encode(testbundle, forKey: .testbundle)
try container.encode(header, forKey: .header)
Expand Down
29 changes: 18 additions & 11 deletions Sources/MockingbirdCli/Interface/Handlers/Generator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ class Generator {
let environmentProjectFilePath: Path?
let environmentSourceRoot: Path?
let environmentTargetName: String?
let outputPaths: [Path]?
let outputPaths: [Path]
let outputDir: Path?
let supportPath: Path?
let header: [String]
let compilationCondition: String?
Expand Down Expand Up @@ -149,9 +150,9 @@ class Generator {
}

func generate() throws {
guard config.outputPaths == nil || config.inputTargetNames.count == config.outputPaths?.count else {
if !config.outputPaths.isEmpty && config.inputTargetNames.count != config.outputPaths.count {
throw Error.mismatchedInputsAndOutputs(inputCount: config.inputTargetNames.count,
outputCount: config.outputPaths?.count ?? 0)
outputCount: config.outputPaths.count)
}

if config.supportPath == nil {
Expand Down Expand Up @@ -185,12 +186,18 @@ class Generator {
})

// Resolve unspecified output paths to the default mock file output destination.
let outputPaths = try config.outputPaths ?? targets.map({ target throws -> Path in
try config.sourceRoot.mocksDirectory.mkpath()
return Generator.defaultOutputPath(for: target,
sourceRoot: config.sourceRoot,
environment: getBuildEnvironment)
})
let outputPaths: [Path] = try {
if !config.outputPaths.isEmpty {
return config.outputPaths
}
return try targets.map({ target throws -> Path in
let outputDir = config.outputDir ?? config.sourceRoot.mocksDirectory
try outputDir.mkpath()
return Generator.defaultOutputPath(for: target,
outputDir: outputDir,
environment: getBuildEnvironment)
})
}()

let queue = OperationQueue.createForActiveProcessors()

Expand Down Expand Up @@ -263,7 +270,7 @@ class Generator {

static func defaultOutputPath(for sourceTarget: TargetType,
testTarget: TargetType? = nil,
sourceRoot: Path,
outputDir: Path,
environment: () -> [String: Any]) -> Path {
let moduleName = sourceTarget.resolveProductModuleName(environment: environment)

Expand All @@ -275,7 +282,7 @@ class Generator {
prefix = "" // Probably installed on a source target instead of a test target.
}

return sourceRoot.mocksDirectory + "\(prefix)\(moduleName)\(Constants.generatedFileNameSuffix)"
return outputDir + "\(prefix)\(moduleName)\(Constants.generatedFileNameSuffix)"
}

static func resolveTarget(targetName: String,
Expand Down
95 changes: 81 additions & 14 deletions Sources/MockingbirdCli/Interface/Handlers/Installer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ struct Installer {
sourceTargets.map({ sourceTarget in
Generator.defaultOutputPath(for: .pbxTarget(sourceTarget),
testTarget: .pbxTarget(testTarget),
sourceRoot: config.sourceRoot,
outputDir: config.sourceRoot.mocksDirectory,
environment: { testProject.implicitBuildEnvironment })
})
guard outputPaths.count == sourceTargets.count else {
Expand All @@ -103,7 +103,7 @@ struct Installer {
}

// Add build phase to target and project.
let buildPhase = createBuildPhase(outputPaths: outputPaths)
let buildPhase = try createBuildPhase(outputPaths: outputPaths)
testTarget.buildPhases.insert(buildPhase, at: buildPhaseIndex)
testProject.pbxproj.add(object: buildPhase)

Expand All @@ -118,6 +118,61 @@ struct Installer {
try testProject.writePBXProj(path: config.projectPath, outputSettings: PBXOutputSettings())
}

/// Checks if a path is located in the same derived data directory as the test project. If so,
/// rewrites that path to use Bash substitution in the form `${DERIVED_DATA}/<subpath>`
private func rewriteDerivedDataPath(_ path: Path) throws -> String? {
// It's possible to use a custom derived data path (in addition to a custom build products
// location), but this is very uncommon so we won't try to handle it.
guard path.starts(with: Path("~/Library/Developer/Xcode/DerivedData/")) else {
return nil
}
log("Attempting to rewrite the derived data path \(path.abbreviate())")

guard let derivedDataPath = try resolveDerivedDataPath()?.abbreviated() else {
return nil
}

let pathString = path.abbreviated()
guard pathString.starts(with: derivedDataPath) else {
// Falls back to the abbreviated (home-relative) path, which is only portable between users on
// the same machine.
logWarning("The path \(pathString) is outside of the project’s derived data location ")
return nil
}

return SubstitutionStyle.bash.wrap("DERIVED_DATA") + pathString.dropFirst(derivedDataPath.count)
}

private func resolveDerivedDataPath() throws -> Path? {
let xcodebuild = Process()
xcodebuild.launchPath = "/usr/bin/env"
xcodebuild.arguments = ["xcodebuild", "-showBuildSettings"]
xcodebuild.currentDirectoryURL = config.projectPath.parent().url
xcodebuild.qualityOfService = .userInitiated

let stdout = Pipe()
xcodebuild.standardOutput = stdout

try xcodebuild.run()
xcodebuild.waitUntilExit()

let stdoutData = stdout.fileHandleForReading.readDataToEndOfFile()
guard let buildSettings = String(data: stdoutData, encoding: .utf8) else {
logWarning("Unable to read the build settings from xcodebuild")
return nil
}

let components = buildSettings.components(matching: #"BUILD_ROOT = (.*)/Build"#)
guard let component = components.first, component.count == 2 else {
logWarning("Unable to parse the build settings from xcodebuild")
return nil
}

let derivedData = Path(String(component[1]))
log("Resolved the derived data directory to \(derivedData.abbreviate())")
return derivedData
}

private func findTarget(name: String,
project: XcodeProj,
testTarget: Bool) throws -> PBXTarget {
Expand Down Expand Up @@ -208,30 +263,42 @@ struct Installer {
xcodeproj.pbxproj.add(object: fileReference)
}

private func createBuildPhase(outputPaths: [Path]) -> PBXShellScriptBuildPhase {
let cliPath = config.cliPath.abbreviated(root: config.sourceRoot, variable: "SRCROOT")
private func createBuildPhase(outputPaths: [Path]) throws -> PBXShellScriptBuildPhase {
var scriptSections: [String] = [
"set -eu",

"""
# Prevent Xcode 13 from running this script while indexing.
[[ "${ACTION}" == "indexbuild" ]] && exit 0
""",
]

let cliPath: String
if let derivedDataCliPath = try rewriteDerivedDataPath(config.cliPath) {
cliPath = derivedDataCliPath
scriptSections.append(#"""
# Infer the derived data location from the build environment.
[[ -z "${DERIVED_DATA+x}" ]] && DERIVED_DATA="$(echo "${BUILD_ROOT}" | sed -n 's|\(.*\)/Build/.*|\1|p')"
"""#)
} else {
cliPath = config.cliPath.abbreviated(root: config.sourceRoot, variable: "SRCROOT")
}

var options = config.generatorOptions
// TODO: Remove this for generator v2. Only needed for backwards compatibility.
if config.outputPaths.isEmpty {
options += ["--outputs"] + outputPaths.map({ path in
path.abbreviated(root: config.sourceRoot, variable: "SRCROOT").doubleQuoted
})
}
scriptSections.append("\(doubleQuoted: cliPath) generate \(options.joined(separator: " "))")

return PBXShellScriptBuildPhase(
name: Constants.buildPhaseName,
outputPaths: outputPaths.map({ path in
path.abbreviated(root: config.sourceRoot, variable: "SRCROOT", style: .make)
}),
shellScript: """
set -eu

# Prevent Xcode 13 from running this script while indexing.
if [[ "${ACTION}" == "indexbuild" ]]; then
exit 0
fi

\(cliPath) generate \(options.joined(separator: " "))
""",
shellScript: scriptSections.joined(separator: "\n\n"),
alwaysOutOfDate: true)
}

Expand Down
24 changes: 24 additions & 0 deletions Sources/MockingbirdCli/Utils/String+Regex.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// String+Regex.swift
// MockingbirdCli
//
// Created by typealias on 12/25/21.
//

import Foundation

extension String {
/// Returns all matches and capture groups given a regex pattern. First element is the full match.
func components(matching pattern: String) -> [[Substring]] {
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
return regex
.matches(in: self, range: NSMakeRange(0, count))
.map({ result -> [Substring] in
return (0..<result.numberOfRanges)
.map({ index -> NSRange in result.range(at: index) })
.filter({ range -> Bool in range.location != NSNotFound })
.compactMap({ range -> Range<Index>? in Range(range, in: self) })
.map({ range -> Substring in self[range] })
})
}
}