diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 00000000..b55c44c1 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,18 @@ +on: + push: + branches: + - main + pull_request: +concurrency: + group: integration-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + integration-test-linux: + # https://github.com/xtool-org/mac-actions-runner + runs-on: ubuntu-on-macos + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Run integration tests + run: | + ./integration-tests/run.sh diff --git a/Dockerfile b/Dockerfile index c451ce80..0433f7a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -84,6 +84,14 @@ ENV USBMUXD_SOCKET_ADDRESS=host.docker.internal:27015 CMD [ "/bin/bash" ] +FROM dev AS dev-test + +ENV XTL_TEST_ENV=1 + +COPY --from=bats/bats:latest /opt/bats /opt/bats +COPY --from=bats/bats:latest /usr/lib/bats /usr/lib/bats +RUN ln -s /opt/bats/bin/bats /usr/local/bin/bats + FROM build-xtool-base AS build-xtool ARG XTL_CI 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)" + ) +} diff --git a/docker-compose.yml b/docker-compose.yml index c1d48c0d..c04ee451 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,3 +16,8 @@ services: extra_hosts: # host.docker.internal doesn't exist by default on Linux hosts - "host.docker.internal:host-gateway" + xtool-test: + extends: + service: xtool + build: + target: dev-test diff --git a/integration-tests/run.sh b/integration-tests/run.sh new file mode 100755 index 00000000..eb70d5d7 --- /dev/null +++ b/integration-tests/run.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +set -euo pipefail + +xcode_path="${XTL_TEST_XCODE_APP:-}" + +if [[ -z "$xcode_path" && $(uname -s) == "Darwin" ]]; then + xcode_path="$(dirname $(dirname $(/usr/bin/xcode-select -p)))" +fi + +if [[ -z "$xcode_path" && -d /usr/local/share/Xcode.app ]]; then + xcode_path="/usr/local/share/Xcode.app" +fi + +if [[ -z "$xcode_path" ]]; then + echo "Xcode.app not found. Please set XTL_TEST_XCODE_APP." + exit 1 +fi + +export XTL_TEST_XCODE_APP="$xcode_path" + +if [[ -z "${XTL_TEST_ENV:-}" ]]; then + [[ -t 1 ]] && tty_flag="-it" || tty_flag="" + + echo "Running" + + exec docker compose run --build --rm -e XTL_CI=1 -v "$xcode_path":/usr/local/share/Xcode.app:ro \ + xtool-test bash -c \ + "./integration-tests/run.sh" +fi + +cd "$(dirname "$0")/.." + +project_dir="$PWD" + +swift build --product xtool +swift test +ln -fs $PWD/.build/debug/xtool /usr/local/bin/xtool +hash -r + +mkdir /testroot +cd /testroot +export XDG_CONFIG_HOME="/testroot/config" + +echo "Installing SDK" +xtool sdk install --slim "$XTL_TEST_XCODE_APP" + +echo "Running tests..." +bats "$project_dir/integration-tests/suite" diff --git a/integration-tests/suite/smoke.bats b/integration-tests/suite/smoke.bats new file mode 100644 index 00000000..b0d959ae --- /dev/null +++ b/integration-tests/suite/smoke.bats @@ -0,0 +1,21 @@ +setup() { + bats_require_minimum_version 1.5.0 + bats_load_library bats-support + bats_load_library bats-assert +} + +@test "can run xtool" { + xtool --version +} + +@test "can build a fresh app" { + xtool new --skip-setup MyApp + cd MyApp + xtool dev build + llvm-objdump -p ./xtool/MyApp.app/MyApp | grep "\bcmd LC_BUILD_VERSION\b" -a2 | grep "\bplatform ios\b" +} + +@test "slim sdk cannot be updated" { + run ! xtool sdk update + assert_output --partial "cannot be updated in place" +}