diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index 8912a1a..99efcb1 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -30,7 +30,13 @@ jobs:
- name: Build spmgraph for Xcode 16.4
run: swift build
- timeout-minutes: 4
+ timeout-minutes: 3
+
+ - name: Run tests on Xcode 16.4
+ env:
+ IS_RUNNING_TESTS: 1
+ run: swift test
+ timeout-minutes: 3
- name: Select Xcode 26.0 toolchain
uses: ./.github/actions/xcode-version
@@ -40,4 +46,4 @@ jobs:
- name: Build spmgraph for Xcode 26.0
run: swift build
- timeout-minutes: 4
+ timeout-minutes: 3
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/spmgraph.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/spmgraph.xcscheme
index 25bacdf..29cc732 100644
--- a/.swiftpm/xcode/xcshareddata/xcschemes/spmgraph.xcscheme
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/spmgraph.xcscheme
@@ -26,13 +26,19 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
- shouldUseLaunchSchemeArgsEnv = "YES"
- shouldAutocreateTestPlan = "YES">
+ shouldUseLaunchSchemeArgsEnv = "YES">
+
+
+
+
With it, you can visualize your dependency graph, run selective testing, and enforce architectural rules for optimal modular setups.
@@ -146,12 +149,11 @@ mint install getyourguide/spmgraph
## Acknowledgments
- Inspired by the work that the [Tuist](https://tuist.dev/) team does for the Apple developers community and their focus on leveraging the dependency graph to provide amazing features for engineers. Also, a source of inspiration for our shell abstraction layer.
-## Open roadmap
+## Open roadmap
- [ ] Cover the core logic of Lint, Map, and Visualize libs with tests
- [ ] Improve the `unusedDependencies` lint rule to cover products with multiple targets
- [ ] Support macros (to become a GitHub issue)
-- [ ] Create Danger plugin for the linter functionality
Ideas
-- [ ] Lint - see if it can be improved to cover auto-exported dependencies. For example, usages of `import Dependencies` justify linking `DependenciesExtras` as a dependency.
- [ ] Add fix-it suggestion to lint errors
+- [ ] Create Danger plugin for the linter functionality
diff --git a/Sources/Core/Extensions/ProcessInfo+Core.swift b/Sources/Core/Extensions/ProcessInfo+Core.swift
new file mode 100644
index 0000000..3887c47
--- /dev/null
+++ b/Sources/Core/Extensions/ProcessInfo+Core.swift
@@ -0,0 +1,25 @@
+//
+//
+// Copyright (c) 2025 GetYourGuide GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+//
+
+import Foundation
+
+public extension ProcessInfo {
+ static var isRunningTests: Bool {
+ processInfo.environment["IS_RUNNING_TESTS"] != nil
+ }
+}
diff --git a/Sources/SPMGraphConfigSetup/Resources/Package.txt b/Sources/SPMGraphConfigSetup/Resources/Package.txt
index 6a8b628..be1efd2 100644
--- a/Sources/SPMGraphConfigSetup/Resources/Package.txt
+++ b/Sources/SPMGraphConfigSetup/Resources/Package.txt
@@ -20,7 +20,7 @@ let package = Package(
// TODO: Change it to HTTP when made public
.package(
url: "git@github.com:getyourguide/spmgraph.git",
- branch: "main"
+ revision: "bda3a77facf780f921bab267628ce1c36afaadb1"
),
// TODO: Review which tag / Swift release to use
diff --git a/Sources/SPMGraphConfigSetup/Resources/DoNotEdit_DynamicLoading.txt b/Sources/SPMGraphConfigSetup/Resources/_DoNotEdit_DynamicLoading.txt
similarity index 100%
rename from Sources/SPMGraphConfigSetup/Resources/DoNotEdit_DynamicLoading.txt
rename to Sources/SPMGraphConfigSetup/Resources/_DoNotEdit_DynamicLoading.txt
diff --git a/Sources/SPMGraphConfigSetup/SPMGraphEdit.swift b/Sources/SPMGraphConfigSetup/SPMGraphEdit.swift
index 80a34bf..f5f070b 100644
--- a/Sources/SPMGraphConfigSetup/SPMGraphEdit.swift
+++ b/Sources/SPMGraphConfigSetup/SPMGraphEdit.swift
@@ -125,6 +125,11 @@ public final class SPMGraphEdit: SPMGraphEditProtocol {
private extension SPMGraphEdit {
func openEditPackage() throws(SPMGraphEditError) {
+ guard !ProcessInfo.isRunningTests else {
+ print("Skipped opening the edit package in tests...")
+ return
+ }
+
if verbose {
print("Opening the edit package...")
}
diff --git a/Sources/SPMGraphConfigSetup/SPMGraphLoad.swift b/Sources/SPMGraphConfigSetup/SPMGraphLoad.swift
index 467c771..67ea2ad 100644
--- a/Sources/SPMGraphConfigSetup/SPMGraphLoad.swift
+++ b/Sources/SPMGraphConfigSetup/SPMGraphLoad.swift
@@ -70,7 +70,7 @@ public final class SPMGraphLoad: SPMGraphLoadProtocol {
editPackageDirectory
.appending(component: "Sources")
.appending(component: "SPMGraphConfig")
- .appending(component: "DoNotEdit_DynamicLoading")
+ .appending(component: "_DoNotEdit_DynamicLoading")
.appending(extension: "swift")
public init(input: SPMGraphLoadInput) throws(SPMGraphLoadError) {
@@ -90,6 +90,8 @@ private extension SPMGraphLoad {
func load(userConfigFile: AbsolutePath) throws(SPMGraphLoadError) {
print("Loading your SPMGraphConfig.swift into spmgraph... please await")
+ defer { try? removeDynamicLoadingFile() }
+
try includeDynamicLoadingFile()
do {
@@ -115,8 +117,6 @@ private extension SPMGraphLoad {
)
}
- defer { try? removeDynamicLoadingFile() }
-
print("Finished loading")
}
@@ -124,7 +124,7 @@ private extension SPMGraphLoad {
do {
guard
let dynamicLoadingTemplateURL = Bundle.module.url(
- forResource: "Resources/DoNotEdit_DynamicLoading",
+ forResource: "Resources/_DoNotEdit_DynamicLoading",
withExtension: "txt"
)
else {
diff --git a/Sources/SPMGraphExecutable/Subcommands/Tests.swift b/Sources/SPMGraphExecutable/Subcommands/Tests.swift
index 8b24049..1c52736 100644
--- a/Sources/SPMGraphExecutable/Subcommands/Tests.swift
+++ b/Sources/SPMGraphExecutable/Subcommands/Tests.swift
@@ -24,7 +24,7 @@ struct TestsArguments: ParsableArguments {
@Option(
name: [.customLong("files"), .customLong("changedFiles")],
- help: "Optional list of changed files. Otherwise git versioning is used"
+ help: "Optional list of changed files. Otherwise git versioning is used. It supports both absolute and relative paths"
)
var changedFiles: [String] = [] // TODO: Change to AbsolutePath
diff --git a/Sources/SPMGraphExecutable/Subcommands/Visualize.swift b/Sources/SPMGraphExecutable/Subcommands/Visualize.swift
index b03b8de..709c477 100644
--- a/Sources/SPMGraphExecutable/Subcommands/Visualize.swift
+++ b/Sources/SPMGraphExecutable/Subcommands/Visualize.swift
@@ -37,7 +37,7 @@ struct VisualizeArguments: ParsableArguments {
@Option(
name: [.customShort("o"), .customLong("output", withSingleDash: false)],
help:
- "Custom output file path for the generated PNG file. Default will generate a 'graph.png' file in the current directory"
+ "Custom output file path for the generated PNG file. By default a 'graph.png' file is generated in the current directory"
)
var outputFilePath: String?
diff --git a/Sources/SPMGraphTests/SPMGraphTests.swift b/Sources/SPMGraphTests/SPMGraphTests.swift
index 68574b6..b433f56 100644
--- a/Sources/SPMGraphTests/SPMGraphTests.swift
+++ b/Sources/SPMGraphTests/SPMGraphTests.swift
@@ -137,8 +137,11 @@ public final class SPMGraphTests: SSPMGraphTestsProtocol {
} else {
let changedFilesString = input.changedFiles
changedFiles = try changedFilesString.map {
- let relativePath = try RelativePath(validating: $0)
- return AbsolutePath.currentDir.appending(relativePath)
+ // Uses the AbsolutePath initializer that accepts either a relative or an absolute path
+ try AbsolutePath(
+ validating: $0,
+ relativeTo: .currentDir
+ )
}
}
diff --git a/Sources/SPMGraphVisualize/SPMGraphVisualize.swift b/Sources/SPMGraphVisualize/SPMGraphVisualize.swift
index efec4d1..9b3df6d 100644
--- a/Sources/SPMGraphVisualize/SPMGraphVisualize.swift
+++ b/Sources/SPMGraphVisualize/SPMGraphVisualize.swift
@@ -34,7 +34,7 @@ public struct SPMGraphVisualizeInput {
/// Flag to exclude third-party dependencies from the graph declared in the `Package.swift`
let excludeThirdPartyDependencies: Bool
/// Custom output file path for the generated PNG file. Default will generate a 'graph.png' file in the current directory
- let outputFilePath: String?
+ let outputFilePath: AbsolutePath
/// Minimum vertical spacing between the ranks (levels) of the graph. Default is set to 4. Is a double value in inches.
let rankSpacing: Double
/// Show extra logging for troubleshooting purposes
@@ -54,9 +54,20 @@ public struct SPMGraphVisualizeInput {
self.excludedSuffixes = excludedSuffixes
self.focusedModule = focusedModule
self.excludeThirdPartyDependencies = excludeThirdPartyDependencies
- self.outputFilePath = outputFilePath
self.rankSpacing = rankSpacing
self.verbose = verbose
+
+ if let outputFilePath {
+ self.outputFilePath = try AbsolutePath(
+ validating: outputFilePath,
+ relativeTo: .currentDir
+ )
+ } else {
+ // the default output path
+ self.outputFilePath = AbsolutePath.currentDir
+ .appending(component: "graph")
+ .appending(extension: "png")
+ }
}
}
@@ -123,32 +134,25 @@ private extension SPMGraphVisualize {
graph.render(using: .dot, to: .png) { [weak system] result in
switch result {
case let .success(data):
- let fileURL = URL(
- fileURLWithPath: input.outputFilePath
- ?? FileManager.default.currentDirectoryPath.appending("/graph.png")
- )
-
do {
- try data.write(to: fileURL)
-
- try system?
- .run(
- "open",
- fileURL.absoluteString,
- verbose: true
- )
+ try data.write(to: input.outputFilePath.asURL)
+
+ // Opens the generated graph unless running tests
+ if !ProcessInfo.isRunningTests {
+ try system?
+ .run(
+ "open",
+ input.outputFilePath.pathString,
+ verbose: true
+ )
+ }
+ continuation.resume(returning: ())
} catch {
- try? system?
- .echo(
- "Failed save and open graph visualization file with error: \(error.localizedDescription)"
- )
+ continuation.resume(throwing: error)
}
case let .failure(error):
- try? system?
- .echo("Failed to render dependency graph with error: \(error.localizedDescription)")
+ continuation.resume(throwing: error)
}
-
- continuation.resume(returning: ())
}
}
}
diff --git a/Tests/Fixtures/Package/Package.swift b/Tests/Fixtures/Package/Package.swift
new file mode 100644
index 0000000..530eb3a
--- /dev/null
+++ b/Tests/Fixtures/Package/Package.swift
@@ -0,0 +1,40 @@
+// swift-tools-version: 6.0
+
+import PackageDescription
+
+let package = Package(
+ name: "FixturePackage",
+ products: [
+ .library(
+ name: "Library",
+ targets: [
+ "TargetA",
+ "TargetB",
+ ]
+ ),
+ ],
+ targets: [
+ .target(
+ name: "TargetA",
+ dependencies: [
+ "TargetB"
+ ]
+ ),
+ .target(
+ name: "TargetB"
+ ),
+
+ .testTarget(
+ name: "TargetATests",
+ dependencies: [
+ "TargetA"
+ ]
+ ),
+ .testTarget(
+ name: "TargetBTests",
+ dependencies: [
+ "TargetB"
+ ]
+ ),
+ ]
+)
diff --git a/Tests/Fixtures/Package/Sources/TargetA/Example.swift b/Tests/Fixtures/Package/Sources/TargetA/Example.swift
new file mode 100644
index 0000000..88a4223
--- /dev/null
+++ b/Tests/Fixtures/Package/Sources/TargetA/Example.swift
@@ -0,0 +1,8 @@
+import TargetB
+
+struct Example {
+ static func main() {
+ print(Foo())
+ print(Bar())
+ }
+}
diff --git a/Tests/Fixtures/Package/Sources/TargetB/Example.swift b/Tests/Fixtures/Package/Sources/TargetB/Example.swift
new file mode 100644
index 0000000..8591c49
--- /dev/null
+++ b/Tests/Fixtures/Package/Sources/TargetB/Example.swift
@@ -0,0 +1,11 @@
+package struct Foo {
+ package let a = "dummy"
+
+ package init() {}
+}
+
+package struct Bar {
+ package let b = "baz"
+
+ package init() {}
+}
diff --git a/Tests/Fixtures/Package/Tests/TargetATests/ExampleTests.swift b/Tests/Fixtures/Package/Tests/TargetATests/ExampleTests.swift
new file mode 100644
index 0000000..527240b
--- /dev/null
+++ b/Tests/Fixtures/Package/Tests/TargetATests/ExampleTests.swift
@@ -0,0 +1,7 @@
+import TargetA
+import Testing
+
+@Suite
+struct FooTests {
+ @Test bar() {}
+}
diff --git a/Tests/Fixtures/Package/Tests/TargetBTests/ExampleTests.swift b/Tests/Fixtures/Package/Tests/TargetBTests/ExampleTests.swift
new file mode 100644
index 0000000..9f46dc0
--- /dev/null
+++ b/Tests/Fixtures/Package/Tests/TargetBTests/ExampleTests.swift
@@ -0,0 +1,7 @@
+import TargetB
+import Testing
+
+@Suite
+struct FooTests {
+ @Test bar() {}
+}
diff --git a/Tests/SPMGraphExecutableTests/SPMGraphExecutableE2ETests.swift b/Tests/SPMGraphExecutableTests/SPMGraphExecutableE2ETests.swift
new file mode 100644
index 0000000..3510a45
--- /dev/null
+++ b/Tests/SPMGraphExecutableTests/SPMGraphExecutableE2ETests.swift
@@ -0,0 +1,399 @@
+//
+//
+// Copyright (c) 2025 GetYourGuide GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+//
+
+import Basics
+import Foundation
+import Testing
+
+@Suite(.serialized)
+struct SPMGraphExecutableE2ETests {
+ // auto reset to its initial value before it test runs
+ private let process = Process()
+ private let outputPipe = Pipe()
+ private let errorPipe = Pipe()
+
+ @Test(
+ arguments: [
+ "",
+ "visualize ",
+ "tests ",
+ "config ",
+ "load ",
+ "lint ",
+ ]
+ )
+ func help(command: String) throws {
+ try runToolProcess(command: "\(command)--help")
+ assertProcess()
+ }
+
+ /// Tests the visualize feature
+ ///
+ /// - warning: For this to work it has to be run via the spmgraph testplan, where the Address Sanitizer is enabled,
+ /// which works around potential memory crashes with graphviz in debug
+ @Test(.enabled(if: ProcessInfo.isSpmgraphTestPlan))
+ func visualize() async throws {
+ // GIVEN
+ let outputPath = try localFileSystem.tempDirectory
+ .appending(component: "graph")
+ .appending(extension: "png")
+
+ // WHEN
+ try runToolProcess(command: "visualize \(AbsolutePath.fixturePackagePath) -o \(outputPath)")
+
+ // THEN
+ assertProcess()
+
+ #expect(localFileSystem.exists(outputPath))
+
+ // Cleanup
+ try localFileSystem.removeFileTree(outputPath)
+ }
+
+ @Test(arguments: ["textDump", "textFile"])
+ func tests(outputMode: String) throws {
+ // GIVEN
+ let changedFilePath = AbsolutePath.fixturePackagePath
+ .appending(component: "Sources")
+ .appending(component: "TargetB")
+ .appending(component: "Example")
+ .appending(extension: "swift")
+
+ // WHEN
+ try runToolProcess(
+ command: "tests \(AbsolutePath.fixturePackagePath) --files \(changedFilePath) --output \(outputMode)"
+ )
+
+ // THEN
+ assertProcess(
+ outputContains: outputMode == "textDump"
+ ? "TargetBTests,TargetATests"
+ : "saved the formatted list of test modules to"
+ )
+
+ if outputMode == "textFile" {
+ let outputPath = try #require(localFileSystem.currentWorkingDirectory)
+ .appending(component: "output")
+ .appending(extension: "txt")
+ #expect(localFileSystem.exists(outputPath))
+ try localFileSystem.removeFileTree(outputPath)
+ }
+ }
+
+ @Test func initialConfig() async throws {
+ // WHEN
+ try runToolProcess(
+ command: "config \(AbsolutePath.fixturePackagePath) -d \(AbsolutePath.buildDir)",
+ waitForExit: false
+ )
+
+ // THEN
+
+ // Await for the package to be created and loaded
+ try await Task.sleep(for: .seconds(4))
+
+ #expect(
+ localFileSystem.exists(.configPackagePath),
+ "It creates the config package in the buildDir"
+ )
+ #expect(
+ try localFileSystem.getDirectoryContents(.configPackagePath) ==
+ [
+ "Package.swift",
+ "Sources"
+ ]
+ )
+ #expect(
+ localFileSystem.exists(.userConfigFilePath),
+ "It creates a spmgraph config file in the same dir as the Package"
+ )
+
+ process.terminate()
+ assertProcess()
+
+ // The config package outlives the process
+ #expect(localFileSystem.exists(.configPackagePath))
+
+ // Cleanup
+ try localFileSystem.removeFileTree(.userConfigFilePath)
+ try localFileSystem.removeFileTree(.dirtyConfigFilePath)
+ try localFileSystem.removeFileTree(.configPackagePath)
+ }
+
+ @Test func configWhenEditing() async throws {
+ // GIVEN
+ createUserConfigFile()
+ try stubUserConfigFile()
+
+ // WHEN
+ let buildDir = try localFileSystem.tempDirectory
+ .appending(component: "buildDir")
+ try runToolProcess(
+ command: "config \(AbsolutePath.fixturePackagePath) -d \(buildDir)",
+ waitForExit: false
+ )
+
+ // THEN
+
+ // Await for the package to be created and loaded
+ try await Task.sleep(for: .seconds(2))
+
+ // It creates the config package in the buildDir
+ #expect(localFileSystem.exists(.configPackagePath))
+ #expect(
+ try localFileSystem.getDirectoryContents(.configPackagePath) ==
+ [
+ "Package.swift",
+ "Sources"
+ ]
+ )
+ #expect(
+ try localFileSystem.getDirectoryContents(.configPackageSources) ==
+ [
+ "SPMGraphConfig.swift"
+ ]
+ )
+ #expect(
+ try localFileSystem.readFileContents(.configPackageConfigFile) ==
+ .userConfigStub,
+ "The config package config file has the same content as the user config file"
+ )
+
+ // WHEN - the user config file is updated
+
+ let updatedConfigContent = """
+ import Foundation
+ import PackageModel
+ import SPMGraphDescriptionInterface
+
+ let spmGraphConfig = SPMGraphConfig(
+ lint: SPMGraphConfig.Lint(isStrict: true)
+ )
+ """
+ try stubUserConfigFile(with: updatedConfigContent)
+
+ // Ensure the file is updated
+ try await Task.sleep(for: .seconds(1))
+
+ // THEN
+ #expect(
+ try localFileSystem.readFileContents(.userConfigFilePath) ==
+ updatedConfigContent,
+ "The user spmgraph config file should be updated reflecting the changes done"
+ )
+
+ process.terminate()
+ assertProcess()
+
+ // The config package outlives the process
+ #expect(localFileSystem.exists(.configPackagePath))
+
+ // Cleanup
+ try localFileSystem.removeFileTree(.userConfigFilePath)
+ try localFileSystem.removeFileTree(.dirtyConfigFilePath)
+ }
+
+ /// Tests the `load functionality`, which feeds the user configuration into spmgraph, by building it and dynamically loading it's dylib.
+ ///
+ /// - warning: It depends on the serial execution of the tests and on the config package being loaded into memory beforehand.
+ ///
+ /// - note: This could be improved to rely on two separate `Process`s to run both `config` and `load` in sequence,
+ /// instead of relying on the serial order of tests.
+ @Test(.disabled("Disabled while spmgraph is not open source and can't be easily resolved as a package"))
+ func testLoad() async throws {
+ createUserConfigFile()
+ try stubUserConfigFile()
+
+ // Ensure the file is updated
+ try await Task.sleep(for: .seconds(1))
+
+ let buildDir = try localFileSystem.tempDirectory
+ .appending(component: "buildDir")
+
+ try runToolProcess(
+ command: "load \(AbsolutePath.fixturePackagePath) -d \(buildDir)",
+ waitForExit: true
+ )
+ assertProcess()
+ }
+
+ /// Tests the `lint functionality`, which depends on the the user config being loaded into spmgraph.
+ ///
+ /// - warning: It depends on the serial execution of the tests and on the user config dylib being generated.
+ ///
+ /// - note: This could be improved to rely on two separate `Process`s to run `config`, `load` and `lint` in sequence,
+ /// instead of relying on the serial order of tests.
+ @Test(.disabled("Disabled due to issue with duplicate symbols"))
+ func testLint() async throws {
+ let outputPath = "lint_output"
+
+ try runToolProcess(
+ command: "lint \(AbsolutePath.fixturePackagePath) --strict -o \(outputPath) -d \(AbsolutePath.buildDir) --verbose",
+ waitForExit: true
+ )
+
+ let outputAbsolutePath = try #require(localFileSystem.currentWorkingDirectory)
+ .appending(component: "lint_output")
+ .appending(extension: "txt")
+ #expect(localFileSystem.exists(outputAbsolutePath))
+ try localFileSystem.removeFileTree(outputAbsolutePath)
+ }
+}
+
+// MARK: - Helpers
+
+private extension SPMGraphExecutableE2ETests {
+ func runToolProcess(
+ command: String,
+ waitForExit: Bool = true
+ ) throws {
+ let commands = command.split(whereSeparator: \.isWhitespace)
+ let arguments = commands.map(String.init)
+
+ let executableURL = Bundle.productsDirectory.appendingPathComponent("spmgraph")
+
+ process.executableURL = executableURL
+ process.arguments = arguments
+ process.standardOutput = outputPipe
+ process.standardError = errorPipe
+
+ try process.run()
+ if waitForExit {
+ process.waitUntilExit()
+ }
+ }
+
+ func assertProcess(
+ expectsError: Bool = false,
+ outputContains: String? = nil,
+ _ sourceLocation: SourceLocation = #_sourceLocation
+ ) {
+ let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
+ let outputContent = String(data: outputData, encoding: .utf8) ?? ""
+ let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
+ let errorContent = String(data: errorData, encoding: .utf8) ?? ""
+
+ #expect(
+ errorContent.isEmpty != expectsError,
+ expectsError
+ ? "Expected error, but none was found."
+ : "Unexpected error found: \(errorContent)",
+ sourceLocation: sourceLocation
+ )
+
+ if let outputContains {
+ #expect(
+ outputContent.contains(outputContains),
+ sourceLocation: sourceLocation
+ )
+ }
+ }
+
+ func createUserConfigFile() {
+ localFileSystem.createEmptyFiles(
+ at: .fixturePackagePath,
+ files: "SPMGraphConfig.swift"
+ )
+ }
+
+ func stubUserConfigFile(with content: String = .userConfigStub) throws {
+ try localFileSystem.writeFileContents(
+ .userConfigFilePath,
+ string: content
+ )
+
+ if !localFileSystem.exists(.userConfigFilePath) {
+ Issue.record("Missing SPMGraphConfig.swift fixture file")
+ }
+ }
+}
+
+private extension ProcessInfo {
+ static var isSpmgraphTestPlan: Bool {
+ processInfo.environment["TESTPLAN"] == "spmgraph"
+ }
+}
+
+private extension Bundle {
+ /// Returns path to the built products directory.
+ static var productsDirectory: URL {
+ #if os(macOS)
+ if ProcessInfo.isSpmgraphTestPlan {
+ for bundle in allBundles where bundle.bundlePath.hasSuffix(".xctest") {
+ return bundle.bundleURL.deletingLastPathComponent()
+ }
+ fatalError("couldn't find the products directory")
+ } else {
+ return localFileSystem.currentWorkingDirectory!
+ .appending(".build")
+ .appending(component: "debug")
+ .asURL
+ }
+ #else
+ return main.bundleURL
+ #endif
+ }
+}
+
+private extension AbsolutePath {
+ static var fixturePackagePath: AbsolutePath {
+ do {
+ return try AbsolutePath(
+ validating: "../../Fixtures/Package",
+ relativeTo: .init(validating: #filePath)
+ )
+ } catch {
+ Issue.record("Unable to resolve fixture package path")
+ preconditionFailure("Unable to resolve fixture package path")
+ }
+ }
+
+ static var buildDir: AbsolutePath {
+ do {
+ return try localFileSystem.tempDirectory
+ .appending(component: "buildDir")
+ } catch {
+ Issue.record("Unable to resolve custom build directory path")
+ preconditionFailure("Unable to resolve custom build directory path")
+ }
+ }
+
+ static let configPackagePath: AbsolutePath = buildDir.appending(component: "spmgraph-config")
+ static let configPackageSources = configPackagePath
+ .appending("Sources")
+ .appending("SPMGraphConfig")
+ static let configPackageConfigFile = configPackagePath
+ .appending("Sources")
+ .appending("SPMGraphConfig")
+ .appending("SPMGraphConfig.swift")
+
+ static let userConfigFilePath: AbsolutePath = fixturePackagePath
+ .appending(component: "SPMGraphConfig.swift")
+ static let dirtyConfigFilePath: AbsolutePath = fixturePackagePath
+ .appending(component: "PMGraphConfig.swift")
+}
+
+private extension String {
+ static let userConfigStub: String = """
+ import Foundation
+ import PackageModel
+ import SPMGraphDescriptionInterface
+
+ let spmGraphConfig = SPMGraphConfig.default
+ """
+}
diff --git a/spmgraph.xctestplan b/spmgraph.xctestplan
new file mode 100644
index 0000000..c6ea084
--- /dev/null
+++ b/spmgraph.xctestplan
@@ -0,0 +1,65 @@
+{
+ "configurations" : [
+ {
+ "id" : "5D7B73B1-8F29-45BF-A398-A3A25640665A",
+ "name" : "Test Scheme Action",
+ "options" : {
+
+ }
+ }
+ ],
+ "defaultOptions" : {
+ "addressSanitizer" : {
+ "enabled" : true
+ },
+ "codeCoverage" : false,
+ "commandLineArgumentEntries" : [
+ {
+ "argument" : "load",
+ "enabled" : false
+ },
+ {
+ "argument" : ""
+ },
+ {
+ "argument" : "edit",
+ "enabled" : false
+ },
+ {
+ "argument" : "visualize"
+ },
+ {
+ "argument" : "lint",
+ "enabled" : false
+ },
+ {
+ "argument" : "$path_to_your_package"
+ }
+ ],
+ "environmentVariableEntries" : [
+ {
+ "key" : "TESTPLAN",
+ "value" : "spmgraph"
+ },
+ {
+ "key" : "IS_RUNNING_TESTS",
+ "value" : "1"
+ }
+ ],
+ "targetForVariableExpansion" : {
+ "containerPath" : "container:",
+ "identifier" : "spmgraph",
+ "name" : "spmgraph"
+ }
+ },
+ "testTargets" : [
+ {
+ "target" : {
+ "containerPath" : "container:",
+ "identifier" : "SPMGraphExecutableTests",
+ "name" : "SPMGraphExecutableTests"
+ }
+ }
+ ],
+ "version" : 1
+}