diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 00000000..6408c1ea --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,19 @@ +on: + push: + branches: + - main + pull_request: +concurrency: + group: integration-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + integration-test-linux: + runs-on: ubuntu-on-macos + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Build and test + run: | + docker compose run --build -e XTL_CI=1 \ + -v /usr/local/share/Xcode.app:/usr/local/share/Xcode.app xtool \ + swift test diff --git a/Tests/XToolTests/RequiresXcodeTrait.swift b/Tests/XToolTests/RequiresXcodeTrait.swift new file mode 100644 index 00000000..96a3c8af --- /dev/null +++ b/Tests/XToolTests/RequiresXcodeTrait.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +import Subprocess + +enum TestXcode { + /// A path to a copy of Xcode.app. + /// + /// Tests that use this must be annotated with `Trait.requiresXcode`. + static func current(sourceLocation: SourceLocation = #_sourceLocation) -> URL { + guard let url = TestXcode._current else { + Issue.record( + "TestXcode.current was used without Trait.requiresXcode()", + sourceLocation: sourceLocation + ) + return URL(filePath: "/dev/null") + } + return url + } + + @TaskLocal fileprivate static var _current: URL? + + fileprivate static let resolved = Task { + if let xcode = ProcessInfo.processInfo.environment["XTL_TEST_XCODE_APP"], !xcode.isEmpty { + return URL(filePath: xcode) + } + #if os(macOS) + if + let output = try await Subprocess.run( + .path("/usr/bin/xcode-select"), + arguments: ["-p"], + output: .string(limit: .max, encoding: UTF8.self), + ).standardOutput?.trimmingCharacters(in: .whitespacesAndNewlines), + output.hasSuffix(".app/Contents/Developer") { + return URL(filePath: output).deletingLastPathComponent().deletingLastPathComponent() + } + #endif + let candidate = URL(filePath: "/usr/local/share/Xcode.app") + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + return nil + } +} + +struct RequiresXcodeTrait: TestTrait, SuiteTrait, TestScoping { + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: () async throws -> Void + ) async throws { + guard let xcode = try? await TestXcode.resolved.value else { + try Test.cancel("Could not resolve Xcode.app path. Set XTL_TEST_XCODE_APP.") + } + return try await TestXcode.$_current.withValue(xcode) { + try await function() + } + } +} + +extension Trait where Self == RequiresXcodeTrait { + static var requiresXcode: Self { + Self() + } +} diff --git a/Tests/XToolTests/XcodeTests.swift b/Tests/XToolTests/XcodeTests.swift new file mode 100644 index 00000000..00796c49 --- /dev/null +++ b/Tests/XToolTests/XcodeTests.swift @@ -0,0 +1,11 @@ +import Testing +import Foundation + +@Test(.requiresXcode) func validateXcode() { + let xcode = TestXcode.current() + let swift = xcode.appending(path: "Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift") + #expect( + FileManager.default.fileExists(atPath: swift.path), + "Invalid or unrecognized layout for Xcode.app at \(xcode.path)" + ) +}