├── Sources ├── SkipDrive │ ├── Version.swift │ ├── SourceMap.swift │ ├── GradleHarness.swift │ └── GradleDriver.swift ├── skip │ └── SkipToolMain.swift └── SkipTest │ └── XCGradleHarness.swift ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── 01_setup.yml └── workflows │ ├── ci.yml │ ├── release.yml │ └── skipcheck.yml ├── CHANGELOG.md ├── README.md ├── Plugins ├── SkipLink │ └── SkipLinkPlugin.swift └── SkipPlugin │ └── SkipPlugin.swift ├── Package.swift ├── .gitignore ├── Tests ├── SkipTestTests │ └── GradleDriverTests.swift └── SkipDriveTests │ └── SkipDriveTests.swift └── LICENSE.txt /Sources/SkipDrive/Version.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2023–2025 Skip 2 | // Licensed under the Open Software License version 3.0 3 | 4 | /// The current version of SkipDrive. 5 | public let skipVersion = "1.6.31" 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: I want help writing my application 4 | url: https://github.com/orgs/skiptools/discussions/new?category=q-a 5 | about: Ask your questions about how to achieve a particular effect in Skip or get help with using a particular API. 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | - Newly created free projects will include SPDX license identifier in header 2 | - Newly created app projects will include a delegate ObservableObject that can be used to respond to lifecycle events 3 | - Resources are now embedded as Android assets in the APK rather than Java resources 4 | - Update skip init to generate both AppKit and UIKit compatible delegates 5 | -------------------------------------------------------------------------------- /Sources/skip/SkipToolMain.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2023–2025 Skip 2 | // Licensed under the Open Software License version 3.0 3 | #if !canImport(SkipBuild) 4 | #error("Should only import SkipBuild for SKIPLOCAL") 5 | #else 6 | import SkipBuild 7 | 8 | /// The plugin runner for the command-line `skip` tool when executed in the plugin environment. 9 | @main public struct SkipToolMain { 10 | static func main() async throws { 11 | await SkipRunnerExecutor.main() 12 | } 13 | } 14 | #endif 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Skip 2 | 3 | [![CI](https://github.com/skiptools/skip/workflows/skip%20ci/badge.svg)](https://github.com/skiptools/skip/actions?query=skip%3Aci) 4 | [![Slack](https://img.shields.io/badge/slack-chat-informational.svg?label=Slack&logo=slack)](https://www.skip.tools/slack) 5 | 6 | 7 | Skip is a technology for creating dual-platform apps in Swift that run on iOS and Android. 8 | Read the [documentation](https://skip.tools/docs/) to learn more about Skip. 9 | 10 | This repository hosts the Skip development toolchain, a.k.a. skipstone. It also hosts the Skip forums for general [discussions](http://community.skip.tools) as well as specific [issues and bug reports](https://source.skip.tools/skip/issues). 11 | 12 | 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: skip ci 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | schedule: 7 | - cron: '0 * * * *' 8 | workflow_dispatch: 9 | pull_request: 10 | jobs: 11 | skip-ci: 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | # fails with error redefinition of module 'ELFKitC' 17 | #- os: 'macos-15' 18 | # xcode: '16.4' 19 | 20 | - os: 'macos-14' 21 | xcode: '16.2' 22 | 23 | #- os: 'macos-15' 24 | # xcode: '26.0' 25 | 26 | - os: 'macos-26' 27 | xcode: '26.0' 28 | 29 | - os: 'ubuntu-24.04' 30 | runs-on: ${{ matrix.os }} 31 | env: 32 | DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer 33 | steps: 34 | - uses: gradle/actions/setup-gradle@v4 35 | with: 36 | gradle-version: current 37 | add-job-summary: never 38 | 39 | - name: Checkout skipstone 40 | uses: actions/checkout@v4 41 | with: 42 | repository: skiptools/skipstone 43 | path: skipstone 44 | ref: main 45 | token: ${{ secrets.REPO_ACCESS_TOKEN }} 46 | 47 | - name: Checkout skip 48 | uses: actions/checkout@v4 49 | with: 50 | repository: skiptools/skip 51 | path: skip 52 | # need to fetch tags so we know the most recent version 53 | fetch-tags: true 54 | 55 | - name: Setup Xcode 56 | if: ${{ startsWith(matrix.os, 'macos-') }} 57 | run: | 58 | # workaround for failure to find iOS 26 simulator 59 | # https://github.com/actions/runner-images/issues/12862#issuecomment-3239326872 60 | xcrun simctl list || true 61 | 62 | # alternatively https://github.com/actions/runner-images/issues/12904#issuecomment-3245474273 63 | xcodebuild -downloadPlatform iOS || true 64 | 65 | - name: Setup SKIPLOCAL 66 | run: | 67 | echo "SKIPLOCAL=$PWD/skipstone" >> $GITHUB_ENV 68 | echo "PATH=$PWD/skip/.build/plugins/tools/debug:$PWD/skip/.build/debug:$PATH" >> $GITHUB_ENV 69 | 70 | - name: Build Skip 71 | working-directory: skip 72 | run: swift build 73 | 74 | - name: Test Skip 75 | working-directory: skip 76 | run: swift test 77 | 78 | 79 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: skip release builds 2 | 3 | on: 4 | push: 5 | #branches: [ main ] 6 | workflow_dispatch: 7 | schedule: 8 | - cron: '3 * * * *' 9 | jobs: 10 | skip-release: 11 | runs-on: macos-15 12 | steps: 13 | - name: Checkout skipstone 14 | uses: actions/checkout@v4 15 | with: 16 | repository: skiptools/skipstone 17 | path: skipstone 18 | ref: main 19 | token: ${{ secrets.REPO_ACCESS_TOKEN }} 20 | 21 | - name: Checkout skip 22 | uses: actions/checkout@v4 23 | with: 24 | repository: skiptools/skip 25 | path: skip 26 | # need to fetch tags so we know the most recent version 27 | fetch-tags: true 28 | # needed or else trigger from tag will fail 29 | # https://github.com/actions/checkout/issues/1467 30 | ref: ${{ github.ref }} 31 | 32 | - name: Checkout homebrew-skip 33 | uses: actions/checkout@v4 34 | with: 35 | repository: skiptools/homebrew-skip 36 | path: homebrew-skip 37 | 38 | - name: Install Dependencies 39 | run: | 40 | brew install tree 41 | 42 | - name: Setup Static Linux SDK 43 | run: | 44 | curl -O https://download.swift.org/swiftly/darwin/swiftly.pkg 45 | installer -pkg swiftly.pkg -target CurrentUserHomeDirectory 46 | ~/.swiftly/bin/swiftly init --quiet-shell-followup 47 | . "${SWIFTLY_HOME_DIR:-$HOME/.swiftly}/env.sh" 48 | hash -r 49 | 50 | SWIFT_VERSION=6.2.1 51 | swiftly install --use ${SWIFT_VERSION} 52 | 53 | curl -fsSL -o swift-static-linux-sdk.tar.gz https://download.swift.org/swift-${SWIFT_VERSION}-release/static-sdk/swift-${SWIFT_VERSION}-RELEASE/swift-${SWIFT_VERSION}-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz 54 | swift sdk install swift-static-linux-sdk.tar.gz 55 | swift sdk list 56 | 57 | # this version will be used by the release script for 58 | # building the static Linux version 59 | echo "SWIFT_VERSION=${SWIFT_VERSION}" >> $GITHUB_ENV 60 | 61 | - name: Build Skip Release 62 | working-directory: skipstone 63 | run: ./scripts/release_skip.sh 64 | env: 65 | DRY_RUN: 1 66 | #GH_TOKEN: ${{ github.token }} 67 | 68 | -------------------------------------------------------------------------------- /Plugins/SkipLink/SkipLinkPlugin.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2024–2025 Skip 2 | // Licensed under the Open Software License version 3.0 3 | import Foundation 4 | import PackagePlugin 5 | 6 | /// Command plugin that create a local link to the transpiled output 7 | @main struct SkipPlugin: CommandPlugin { 8 | func performCommand(context: PackagePlugin.PluginContext, arguments: [String]) async throws { 9 | let packageID = context.package.id 10 | 11 | let packageFolder = URL(fileURLWithPath: context.package.directory.string) 12 | let skipLinkFolder = packageFolder.appendingPathComponent("SkipLink") 13 | 14 | // When ran from Xcode, the plugin command is invoked with `--target` arguments, 15 | // specifying the targets selected in the plugin dialog. 16 | var argumentExtractor = ArgumentExtractor(arguments) 17 | let inputTargets = argumentExtractor.extractOption(named: "target") 18 | 19 | let skipLinkOutut = URL(fileURLWithPath: context.pluginWorkDirectory.string) 20 | for targetName in inputTargets { 21 | let skipstoneTargetOutput = skipLinkOutut 22 | .deletingLastPathComponent() 23 | .appendingPathComponent(packageID + ".output") 24 | .appendingPathComponent(targetName) 25 | .appendingPathComponent("skipstone") 26 | .resolvingSymlinksInPath() 27 | 28 | if (try? skipstoneTargetOutput.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) != true { 29 | Diagnostics.warning("skipstone output folder did not exist: \(skipstoneTargetOutput.path)") 30 | } else { 31 | // create the link from the local folder to the derived data output, replacing any existing link 32 | try FileManager.default.createDirectory(at: skipLinkFolder, withIntermediateDirectories: true) 33 | let targetLinkFolder = skipLinkFolder.appendingPathComponent(targetName) 34 | Diagnostics.remark("creating link from \(skipstoneTargetOutput.path) to \(targetLinkFolder.path)") 35 | 36 | // clear any pre-existing links 37 | if (try? targetLinkFolder.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true { 38 | try FileManager.default.removeItem(at: targetLinkFolder) 39 | } 40 | try FileManager.default.createSymbolicLink(at: targetLinkFolder, withDestinationURL: skipstoneTargetOutput) 41 | } 42 | } 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "skip", 6 | defaultLocalization: "en", 7 | platforms: [ 8 | .iOS(.v16), 9 | .macOS(.v13), 10 | .tvOS(.v16), 11 | .watchOS(.v9), 12 | .macCatalyst(.v16), 13 | ], 14 | products: [ 15 | .plugin(name: "skipstone", targets: ["skipstone"]), 16 | .plugin(name: "skiplink", targets: ["Create SkipLink"]), 17 | .library(name: "SkipDrive", targets: ["SkipDrive"]), 18 | .library(name: "SkipTest", targets: ["SkipTest"]), 19 | ], 20 | targets: [ 21 | .plugin(name: "skipstone", capability: .buildTool(), dependencies: ["skip"], path: "Plugins/SkipPlugin"), 22 | .plugin(name: "Create SkipLink", capability: .command(intent: .custom(verb: "SkipLink", description: "Create local links to transpiled output"), permissions: [.writeToPackageDirectory(reason: "This command will create local links to the skipstone output for the specified package(s), enabling access to the transpiled Kotlin")]), dependencies: ["skip"], path: "Plugins/SkipLink"), 23 | .target(name: "SkipDrive", dependencies: ["skipstone", .target(name: "skip")]), 24 | .target(name: "SkipTest", dependencies: [.target(name: "SkipDrive", condition: .when(platforms: [.macOS, .linux]))]), 25 | .testTarget(name: "SkipTestTests", dependencies: ["SkipTest"]), 26 | .testTarget(name: "SkipDriveTests", dependencies: ["SkipDrive"]), 27 | ] 28 | ) 29 | 30 | let env = Context.environment 31 | if (env["SKIPLOCAL"] != nil || env["PWD"]?.hasSuffix("skipstone") == true) { 32 | package.dependencies += [.package(path: env["SKIPLOCAL"] ?? "../skipstone")] 33 | package.targets += [.executableTarget(name: "skip", dependencies: [.product(name: "SkipBuild", package: "skipstone")])] 34 | } else { 35 | #if os(macOS) 36 | package.targets += [.binaryTarget(name: "skip", url: "https://source.skip.tools/skip/releases/download/1.6.31/skip-macos.zip", checksum: "a32a2f6870a9d9b9b4e63b364c383fdea35d10f9b3d0f8b345c53fc6f0545579")] 37 | #elseif os(Linux) 38 | package.targets += [.binaryTarget(name: "skip", url: "https://source.skip.tools/skip/releases/download/1.6.31/skip-linux.zip", checksum: "40663aa12f8a4eea2e0aa2920125ae791e3b99e47a96ae09b80e5a09436f43b0")] 39 | #else 40 | package.dependencies += [.package(url: "https://source.skip.tools/skipstone.git", exact: "1.6.12")] 41 | package.targets += [.executableTarget(name: "skip", dependencies: [.product(name: "SkipBuild", package: "skipstone")])] 42 | #endif 43 | } 44 | 45 | -------------------------------------------------------------------------------- /.github/workflows/skipcheck.yml: -------------------------------------------------------------------------------- 1 | name: skip checks 2 | 3 | on: 4 | schedule: 5 | - cron: '0 8 * * *' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | skip-checks: 10 | timeout-minutes: 200 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | include: 15 | - android-sdk: '6.1' 16 | xcode: '16.4' 17 | os: 'macos-14' 18 | - android-sdk: '6.2' 19 | xcode: '26' 20 | os: 'macos-15' 21 | - android-sdk: '6.2' 22 | xcode: '26' 23 | os: 'macos-26' 24 | runs-on: ${{ matrix.os }} 25 | env: 26 | DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer 27 | steps: 28 | # larger projects sometimes yield: Unhandled exception. System.IO.IOException: No space left on device : '/Users/runner/runners/… 29 | - name: Free Disk Space 30 | run: sudo rm -rf /Applications/Xcode_14*.app /Applications/Xcode_15*.app /Applications/Xcode_16.app /Applications/Xcode_16.1.app /Applications/Xcode_16_2.app /Applications/Xcode_16.3.app 31 | - uses: gradle/actions/setup-gradle@v4 32 | with: 33 | gradle-version: current 34 | add-job-summary: never 35 | - name: Cache Homebrew packages 36 | uses: actions/cache@v4 37 | with: 38 | path: ~/Library/Caches/Homebrew 39 | key: homebrew-packages 40 | 41 | - name: Install Skip 42 | uses: skiptools/actions/setup-skip@v1 43 | 44 | - name: Skip Checkup 45 | run: skip checkup 46 | #- run: skip checkup --verbose --double-check 47 | 48 | - run: skip init --transpiled-model demo-module DemoModule 49 | - run: skip init --transpiled-app --appid=xyz.skip.Demo demo-app DemoApp DemoModule 50 | - run: skip init --transpiled-app --appfair demo-appfair DemoFairApp 51 | 52 | # native model and app init 53 | - run: skip android sdk install --version ${{ matrix.android-sdk }} --verbose 54 | 55 | - run: skip checkup --verbose --native 56 | 57 | - run: skip checkup --verbose --native-model 58 | - run: skip init --native-model demo-module-native DemoModule 59 | - run: skip init --native-model --kotlincompat demo-module-kotlincompat DemoModule 60 | - run: skip init --native-model --appid=xyz.skip.Demo demo-native-model-app DemoApp DemoModule 61 | 62 | - run: skip checkup --verbose --native-app 63 | - run: skip init --native-app --appid=xyz.skip.Demo demo-app-native DemoApp 64 | - run: skip init --native-app --native-model --appid=xyz.skip.Demo demo-app-native-model DemoApp DemoModel 65 | - run: skip export --project demo-app-native-model --arch x86_64 --arch aarch64 --arch armv7 66 | 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | skip.zip 6 | 7 | ## User settings 8 | xcuserdata/ 9 | 10 | # Generated Gradle/Kotlin/Android build 11 | kip/ 12 | .kip/ 13 | 14 | .*.swp 15 | .DS_Store 16 | 17 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 18 | *.xcscmblueprint 19 | *.xccheckout 20 | 21 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 22 | build/ 23 | DerivedData/ 24 | .android/ 25 | .kotlin/ 26 | *.moved-aside 27 | *.pbxuser 28 | !default.pbxuser 29 | *.mode1v3 30 | !default.mode1v3 31 | *.mode2v3 32 | !default.mode2v3 33 | *.perspectivev3 34 | !default.perspectivev3 35 | 36 | ## Obj-C/Swift specific 37 | *.hmap 38 | 39 | ## App packaging 40 | *.ipa 41 | *.dSYM.zip 42 | *.dSYM 43 | 44 | ## Playgrounds 45 | timeline.xctimeline 46 | playground.xcworkspace 47 | 48 | # Swift Package Manager 49 | # 50 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 51 | Packages/ 52 | Package.pins 53 | Package.resolved 54 | #*.xcodeproj 55 | #xcshareddata 56 | 57 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 58 | # hence it is not needed unless you have added a package configuration file to your project 59 | .swiftpm 60 | 61 | .build/ 62 | 63 | # CocoaPods 64 | # 65 | # We recommend against adding the Pods directory to your .gitignore. However 66 | # you should judge for yourself, the pros and cons are mentioned at: 67 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 68 | # 69 | # Pods/ 70 | # 71 | # Add this line if you want to avoid checking in source code from the Xcode workspace 72 | # *.xcworkspace 73 | 74 | # Carthage 75 | # 76 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 77 | # Carthage/Checkouts 78 | 79 | Carthage/Build/ 80 | 81 | # Accio dependency management 82 | Dependencies/ 83 | .accio/ 84 | 85 | # fastlane 86 | # 87 | # It is recommended to not store the screenshots in the git repo. 88 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 89 | # For more information about the recommended setup visit: 90 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 91 | 92 | fastlane/report.xml 93 | fastlane/Preview.html 94 | fastlane/screenshots/**/*.png 95 | fastlane/test_output 96 | 97 | # Code Injection 98 | # 99 | # After new code Injection tools there's a generated folder /iOSInjectionProject 100 | # https://github.com/johnno1962/injectionforxcode 101 | 102 | iOSInjectionProject/ 103 | 104 | 105 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/01_setup.yml: -------------------------------------------------------------------------------- 1 | name: I am having difficulty installing Skip or getting it to work 2 | description: You have run into problems while downloading or installing Skip, or the "skip" tool is failing with an error. 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Thank you for using Skip! 8 | 9 | If you are looking for support, please check out our documentation: 10 | 11 | - https://skip.tools/docs 12 | - https://skip.tools/docs/faq 13 | 14 | If you have found a bug or if our documentation doesn't have an answer 15 | to what you're looking for, then fill out the template below. 16 | - type: markdown 17 | attributes: 18 | value: | 19 | Before filling the form fields, please consider the following: 20 | - Ensure that you have searched the [existing issues](https://github.com/search?q=org%3Askiptools&type=issues) 21 | - Read the [guide to finding help](https://skip.tools/docs/help/) 22 | - type: textarea 23 | attributes: 24 | label: Steps to reproduce 25 | description: Please tell us exactly how to reproduce the problem you are running into. 26 | placeholder: | 27 | 1. ... 28 | 2. ... 29 | 3. ... 30 | validations: 31 | required: true 32 | - type: textarea 33 | attributes: 34 | label: Actual results 35 | description: Please tell us what is actually happening 36 | validations: 37 | required: true 38 | - type: textarea 39 | attributes: 40 | label: Logs 41 | description: | 42 | Include the full logs of the commands you are running between the lines 43 | with the backticks below. If you are running any `skip` commands, 44 | please include the output of running them with `--verbose`; for example, 45 | the output of running `skip create --verbose`. 46 | 47 | If the logs are too large to be uploaded to Github, you may upload 48 | them as a `txt` file or use online tools like https://pastebin.com to 49 | share it. 50 | 51 | Note: Please do not upload screenshots of text. Instead, use code blocks 52 | or the above mentioned ways to upload logs. 53 | value: | 54 |
55 | Logs 56 | 57 | ```console 58 | 59 | 60 | 61 | ``` 62 | 63 |
64 | - type: textarea 65 | attributes: 66 | label: Skip Checkup output 67 | description: | 68 | Please provide the full output of running `skip checkup` 69 | value: | 70 |
71 | Checkup output 72 | 73 | ```console 74 | 75 | 76 | 77 | ``` 78 | 79 |
80 | validations: 81 | required: true 82 | -------------------------------------------------------------------------------- /Sources/SkipDrive/SourceMap.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2023–2025 Skip 2 | // Licensed under the Open Software License version 3.0 3 | #if !SKIP 4 | import Foundation 5 | 6 | /// A line and column in a particular source location. 7 | public struct SourceLocation : Equatable { 8 | public var path: String 9 | public var position: SourceLocation.Position 10 | 11 | public init(path: String, position: SourceLocation.Position) { 12 | self.path = path 13 | self.position = position 14 | } 15 | 16 | /// A line and column-based position in the source, appropriate for Xcode reporting. 17 | /// Line and column numbers start with 1 rather than 0. 18 | public struct Position: Equatable, Comparable, Decodable { 19 | public let line: Int 20 | public let column: Int 21 | 22 | public init(line: Int, column: Int) { 23 | self.line = line 24 | self.column = column 25 | } 26 | 27 | public static func < (lhs: Position, rhs: Position) -> Bool { 28 | return lhs.line < rhs.line || (lhs.line == rhs.line && lhs.column < rhs.column) 29 | } 30 | } 31 | } 32 | 33 | public extension SourceLocation { 34 | /// Parses the `.File.sourcemap` for this `File.kt` and tries to find the matching line in the decoded source map JSON file (emitted by the skip transpiler). 35 | func findSourceMapLine() throws -> SourceLocation? { 36 | // turn SourceFile.kt into .SourceFile.sourcemap 37 | let fileURL = URL(fileURLWithPath: path, isDirectory: false) 38 | let sourceFileName = "." + fileURL.deletingPathExtension().lastPathComponent + ".sourcemap" 39 | let path = fileURL.deletingLastPathComponent().appendingPathComponent(sourceFileName, isDirectory: false) 40 | 41 | guard FileManager.default.isReadableFile(atPath: path.path) else { 42 | return nil 43 | } 44 | 45 | let sourceMap = try JSONDecoder().decode(SourceMap.self, from: Data(contentsOf: path)) 46 | var lineRanges: [ClosedRange: Self] = [:] 47 | 48 | for entry in sourceMap.entries { 49 | if let sourceRange = entry.sourceRange { 50 | let sourceLines = entry.range.start.line...entry.range.end.line 51 | if sourceLines.contains(self.position.line) { 52 | // remember all the matched ranges because we'll want to use the smallest possible match the get the best estimate of the corresponding source line number 53 | lineRanges[sourceLines] = SourceLocation(path: entry.sourceFile.path, position: SourceLocation.Position(line: sourceRange.start.line, column: sourceRange.start.column)) 54 | } 55 | } 56 | } 57 | 58 | // find the narrowest range that includes the source line 59 | let minKeyValue = lineRanges.min(by: { 60 | $0.key.count < $1.key.count 61 | }) 62 | 63 | return minKeyValue?.value 64 | } 65 | } 66 | 67 | /// A decoded source map. This is the decodable counterpart to `SkipSyntax.OutputMap` 68 | public struct SourceMap : Decodable { 69 | public let entries: [Entry] 70 | 71 | public struct Entry : Decodable { 72 | public let sourceFile: Source.FilePath 73 | public let sourceRange: Source.Range? 74 | public let range: Source.Range 75 | } 76 | 77 | public struct Source { 78 | public struct SourceLine : Decodable { 79 | public let offset: Int 80 | public let line: String 81 | } 82 | 83 | /// A Swift source file. 84 | public struct FilePath: Hashable, Decodable { 85 | public let path: String 86 | } 87 | 88 | /// A line and column-based range in the source, appropriate for Xcode reporting. 89 | public struct Range: Equatable, Decodable { 90 | public let start: SourceLocation.Position 91 | public let end: SourceLocation.Position 92 | } 93 | } 94 | } 95 | #endif 96 | -------------------------------------------------------------------------------- /Tests/SkipTestTests/GradleDriverTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2023–2025 Skip 2 | // Licensed under the Open Software License version 3.0 3 | import XCTest 4 | @testable import SkipTest 5 | import SkipDrive 6 | 7 | #if os(macOS) 8 | #if !SKIP 9 | final class GradleDriverTests: XCTestCase { 10 | func testGradleVersion() async throws { 11 | let driver = try await GradleDriver() 12 | let result = try await driver.execGradle(in: URL(fileURLWithPath: NSTemporaryDirectory()), args: ["-v"], onExit: { _ in }) 13 | guard let line = try await result.first(where: { line in 14 | line.line.hasPrefix("Gradle ") 15 | }) else { 16 | return XCTFail("No Gradle line in output") 17 | } 18 | 19 | let _ = line 20 | } 21 | 22 | /// Initialize a new Gradle project with the Kotlin DSL and run the test cases, 23 | /// parsing the output and checking for the errors and failures that are inserted into the test. 24 | @available(macOS 13, *) 25 | func testGradleInitTest() async throws { 26 | let driver = try await GradleDriver() 27 | 28 | let sampleName = "SampleGradleProject" 29 | let samplePackage = "simple.demo.project" 30 | 31 | let tmp = try FileManager.default.createTmpDir(name: sampleName) 32 | 33 | // 1. gradle init --type kotlin-library --dsl kotlin --console plain --no-daemon --offline --project-name=ExampleDemo --package=example.demo --test-framework=kotlintest --incubating 34 | print("creating sample project in:", tmp.path) 35 | for try await line in try await driver.execGradle(in: tmp, args: [ 36 | "init", 37 | "--type=kotlin-library", 38 | "--dsl=kotlin", 39 | "--project-name=\(sampleName)", 40 | "--package=\(samplePackage)", 41 | "--incubating", // use new incubating features, and avoid the prompt "Generate build using new APIs" 42 | "--offline", // do not use the network 43 | ], onExit: Process.expectZeroExitCode) { 44 | let _ = line 45 | //print("INIT >", line) 46 | } 47 | 48 | let modname = "lib" 49 | 50 | let files = try FileManager.default.contentsOfDirectory(at: tmp, includingPropertiesForKeys: [], options: [.skipsHiddenFiles]) 51 | let fileNames = Set(files.map(\.lastPathComponent)) 52 | 53 | XCTAssertTrue(fileNames.isSubset(of: [modname, "settings.gradle.kts", "gradlew", "gradlew.bat", "gradle", "gradle.properties"]), "unexpected files were created by gradle init: \(fileNames.sorted())") 54 | 55 | // work around recent change that adds "languageVersion.set(JavaLanguageVersion.of(20))" to the build.gradle.kts 56 | func fixupBuildGradle() throws { 57 | let buildPath = tmp.appendingPathComponent(modname).appendingPathComponent("build.gradle.kts") 58 | let buildGradleData = try Data(contentsOf: buildPath) 59 | var buildGradleContents = String(data: buildGradleData, encoding: String.Encoding.utf8) 60 | buildGradleContents = buildGradleContents?.replacingOccurrences(of: "languageVersion.set(JavaLanguageVersion.of(", with: "languageVersion.set(JavaLanguageVersion.of(17)) // Skip replaced: ((") // just comment it out if it exists 61 | try buildGradleContents?.write(to: buildPath, atomically: true, encoding: String.Encoding.utf8) 62 | } 63 | 64 | try fixupBuildGradle() 65 | 66 | // the module name we created 67 | let lib = URL(fileURLWithPath: modname, isDirectory: true, relativeTo: tmp) 68 | 69 | var runIndex = 0 70 | 71 | // 2. gradle test --console plain --rerun-tasks 72 | for (failure, error, failFast) in [ 73 | (false, false, false), 74 | (true, false, false), 75 | (true, true, false), 76 | //(true, true, true), 77 | ] { 78 | runIndex += 1 79 | // let canRunOffline = runIndex > 0 // after the initial run (when the dependencies should be downloaded and cached), we should be able to run the tests in offline mode 80 | 81 | // sabotage the test so it failes 82 | if failure || error { 83 | try sabotageTest(failure: failure, error: error) 84 | } 85 | 86 | let (output, parseResults) = try await driver.launchGradleProcess(in: tmp, module: modname, actions: ["test"], arguments: [], failFast: failFast, offline: false, exitHandler: { result in 87 | if !failure && !error { 88 | guard case .terminated(0) = result.exitStatus else { 89 | // we failed, but did not expect an error 90 | return XCTFail("unexpected gradle process failure when running tests with failure=\(failure) error=\(error) failFast=\(failFast)") 91 | } 92 | } 93 | }) 94 | 95 | for try await line in output { 96 | let _ = line 97 | //print("TEST >", line) 98 | } 99 | 100 | let results = try parseResults() 101 | 102 | XCTAssertEqual(1, results.count) 103 | let firstResult = try XCTUnwrap(results.first) 104 | 105 | // failFast should max the error count at 1, but it doesn't seem to work — maybe related to https://github.com/gradle/gradle/issues/4562 106 | let expectedFailCount = (failure ? 1 : 0) + (error ? 1 : 0) 107 | XCTAssertEqual(expectedFailCount + 1, firstResult.tests) 108 | XCTAssertEqual(expectedFailCount, firstResult.failures) 109 | 110 | // gather up all the failures and ensure we see the ones we expect 111 | let allFailures = firstResult.testCases.flatMap(\.failures).map(\.message).map { 112 | $0.split(separator: ":").last?.trimmingCharacters(in: .whitespaces) 113 | } 114 | 115 | if failure { 116 | XCTAssertEqual("THIS TEST CASE ALWAYS FAILS", allFailures.first) 117 | } 118 | 119 | if error { 120 | XCTAssertEqual("THIS TEST CASE ALWAYS THROWS", allFailures.last) 121 | } 122 | } 123 | 124 | /// Add some test cases we know will fail to ensure they show up in the test results folder 125 | func sabotageTest(failure: Bool, error: Bool) throws { 126 | let samplePackageFolder = samplePackage.split(separator: ".").joined(separator: "/") // turn some.package into some/package 127 | let testCaseURL = URL(fileURLWithPath: "src/test/kotlin/" + samplePackageFolder + "/LibraryTest.kt", isDirectory: false, relativeTo: lib) 128 | var testCaseContents = try String(contentsOf: testCaseURL) 129 | 130 | if failure { 131 | // tack new failing and error test cases to the end by replacing the final test 132 | if !testCaseContents.contains("@Test fun someTestCaseThatAlwaysFails()") { 133 | testCaseContents = testCaseContents.replacingOccurrences(of: "\n}\n", with: """ 134 | 135 | // added by GradleDriverTests.sabotageTest() 136 | @Test fun someTestCaseThatAlwaysFails() { 137 | assertTrue(false, "THIS TEST CASE ALWAYS FAILS") 138 | } 139 | 140 | } 141 | 142 | """) 143 | } 144 | } 145 | 146 | if error { 147 | if !testCaseContents.contains("@Test fun someTestCaseThatAlwaysThrows()") { 148 | // tack new failing and error test cases to the end by replacing the final test 149 | testCaseContents = testCaseContents.replacingOccurrences(of: "\n}\n", with: """ 150 | 151 | // added by GradleDriverTests.sabotageTest() 152 | @Test fun someTestCaseThatAlwaysThrows() { 153 | throw Exception("THIS TEST CASE ALWAYS THROWS") 154 | } 155 | 156 | } 157 | 158 | """) 159 | } 160 | } 161 | 162 | try testCaseContents.write(to: testCaseURL, atomically: true, encoding: String.Encoding.utf8) 163 | } 164 | } 165 | } 166 | #endif 167 | #endif 168 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Open Software License ("OSL") v. 3.0 2 | 3 | This Open Software License (the "License") applies to any original work of 4 | authorship (the "Original Work") whose owner (the "Licensor") has placed the 5 | following licensing notice adjacent to the copyright notice for the Original 6 | Work: 7 | 8 | Licensed under the Open Software License version 3.0 9 | 10 | 1) Grant of Copyright License. Licensor grants You a worldwide, royalty-free, 11 | non-exclusive, sublicensable license, for the duration of the copyright, to do 12 | the following: 13 | 14 | a) to reproduce the Original Work in copies, either alone or as part of a 15 | collective work; 16 | 17 | b) to translate, adapt, alter, transform, modify, or arrange the Original 18 | Work, thereby creating derivative works ("Derivative Works") based upon the 19 | Original Work; 20 | 21 | c) to distribute or communicate copies of the Original Work and Derivative 22 | Works to the public, with the proviso that copies of Original Work or 23 | Derivative Works that You distribute or communicate shall be licensed under 24 | this Open Software License; 25 | 26 | d) to perform the Original Work publicly; and 27 | 28 | e) to display the Original Work publicly. 29 | 30 | 2) Grant of Patent License. Licensor grants You a worldwide, royalty-free, 31 | non-exclusive, sublicensable license, under patent claims owned or controlled 32 | by the Licensor that are embodied in the Original Work as furnished by the 33 | Licensor, for the duration of the patents, to make, use, sell, offer for sale, 34 | have made, and import the Original Work and Derivative Works. 35 | 36 | 3) Grant of Source Code License. The term "Source Code" means the preferred 37 | form of the Original Work for making modifications to it and all available 38 | documentation describing how to modify the Original Work. Licensor agrees to 39 | provide a machine-readable copy of the Source Code of the Original Work along 40 | with each copy of the Original Work that Licensor distributes. Licensor 41 | reserves the right to satisfy this obligation by placing a machine-readable 42 | copy of the Source Code in an information repository reasonably calculated to 43 | permit inexpensive and convenient access by You for as long as Licensor 44 | continues to distribute the Original Work. 45 | 46 | 4) Exclusions From License Grant. Neither the names of Licensor, nor the names 47 | of any contributors to the Original Work, nor any of their trademarks or 48 | service marks, may be used to endorse or promote products derived from this 49 | Original Work without express prior permission of the Licensor. Except as 50 | expressly stated herein, nothing in this License grants any license to 51 | Licensor's trademarks, copyrights, patents, trade secrets or any other 52 | intellectual property. No patent license is granted to make, use, sell, offer 53 | for sale, have made, or import embodiments of any patent claims other than the 54 | licensed claims defined in Section 2. No license is granted to the trademarks 55 | of Licensor even if such marks are included in the Original Work. Nothing in 56 | this License shall be interpreted to prohibit Licensor from licensing under 57 | terms different from this License any Original Work that Licensor otherwise 58 | would have a right to license. 59 | 60 | 5) External Deployment. The term "External Deployment" means the use, 61 | distribution, or communication of the Original Work or Derivative Works in any 62 | way such that the Original Work or Derivative Works may be used by anyone 63 | other than You, whether those works are distributed or communicated to those 64 | persons or made available as an application intended for use over a network. 65 | As an express condition for the grants of license hereunder, You must treat 66 | any External Deployment by You of the Original Work or a Derivative Work as a 67 | distribution under section 1(c). 68 | 69 | 6) Attribution Rights. You must retain, in the Source Code of any Derivative 70 | Works that You create, all copyright, patent, or trademark notices from the 71 | Source Code of the Original Work, as well as any notices of licensing and any 72 | descriptive text identified therein as an "Attribution Notice." You must cause 73 | the Source Code for any Derivative Works that You create to carry a prominent 74 | Attribution Notice reasonably calculated to inform recipients that You have 75 | modified the Original Work. 76 | 77 | 7) Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that 78 | the copyright in and to the Original Work and the patent rights granted herein 79 | by Licensor are owned by the Licensor or are sublicensed to You under the 80 | terms of this License with the permission of the contributor(s) of those 81 | copyrights and patent rights. Except as expressly stated in the immediately 82 | preceding sentence, the Original Work is provided under this License on an "AS 83 | IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without 84 | limitation, the warranties of non-infringement, merchantability or fitness for 85 | a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK 86 | IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this 87 | License. No license to the Original Work is granted by this License except 88 | under this disclaimer. 89 | 90 | 8) Limitation of Liability. Under no circumstances and under no legal theory, 91 | whether in tort (including negligence), contract, or otherwise, shall the 92 | Licensor be liable to anyone for any indirect, special, incidental, or 93 | consequential damages of any character arising as a result of this License or 94 | the use of the Original Work including, without limitation, damages for loss 95 | of goodwill, work stoppage, computer failure or malfunction, or any and all 96 | other commercial damages or losses. This limitation of liability shall not 97 | apply to the extent applicable law prohibits such limitation. 98 | 99 | 9) Acceptance and Termination. If, at any time, You expressly assented to this 100 | License, that assent indicates your clear and irrevocable acceptance of this 101 | License and all of its terms and conditions. If You distribute or communicate 102 | copies of the Original Work or a Derivative Work, You must make a reasonable 103 | effort under the circumstances to obtain the express assent of recipients to 104 | the terms of this License. This License conditions your rights to undertake 105 | the activities listed in Section 1, including your right to create Derivative 106 | Works based upon the Original Work, and doing so without honoring these terms 107 | and conditions is prohibited by copyright law and international treaty. 108 | Nothing in this License is intended to affect copyright exceptions and 109 | limitations (including "fair use" or "fair dealing"). This License shall 110 | terminate immediately and You may no longer exercise any of the rights granted 111 | to You by this License upon your failure to honor the conditions in Section 112 | 1(c). 113 | 114 | 10) Termination for Patent Action. This License shall terminate automatically 115 | and You may no longer exercise any of the rights granted to You by this 116 | License as of the date You commence an action, including a cross-claim or 117 | counterclaim, against Licensor or any licensee alleging that the Original Work 118 | infringes a patent. This termination provision shall not apply for an action 119 | alleging patent infringement by combinations of the Original Work with other 120 | software or hardware. 121 | 122 | 11) Jurisdiction, Venue and Governing Law. Any action or suit relating to this 123 | License may be brought only in the courts of a jurisdiction wherein the 124 | Licensor resides or in which Licensor conducts its primary business, and under 125 | the laws of that jurisdiction excluding its conflict-of-law provisions. The 126 | application of the United Nations Convention on Contracts for the 127 | International Sale of Goods is expressly excluded. Any use of the Original 128 | Work outside the scope of this License or after its termination shall be 129 | subject to the requirements and penalties of copyright or patent law in the 130 | appropriate jurisdiction. This section shall survive the termination of this 131 | License. 132 | 133 | 12) Attorneys' Fees. In any action to enforce the terms of this License or 134 | seeking damages relating thereto, the prevailing party shall be entitled to 135 | recover its costs and expenses, including, without limitation, reasonable 136 | attorneys' fees and costs incurred in connection with such action, including 137 | any appeal of such action. This section shall survive the termination of this 138 | License. 139 | 140 | 13) Miscellaneous. If any provision of this License is held to be 141 | unenforceable, such provision shall be reformed only to the extent necessary 142 | to make it enforceable. 143 | 144 | 14) Definition of "You" in This License. "You" throughout this License, 145 | whether in upper or lower case, means an individual or a legal entity 146 | exercising rights under, and complying with all of the terms of, this License. 147 | For legal entities, "You" includes any entity that controls, is controlled by, 148 | or is under common control with you. For purposes of this definition, 149 | "control" means (i) the power, direct or indirect, to cause the direction or 150 | management of such entity, whether by contract or otherwise, or (ii) ownership 151 | of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial 152 | ownership of such entity. 153 | 154 | 15) Right to Use. You may use the Original Work in all ways not otherwise 155 | restricted or conditioned by this License or by law, and Licensor promises not 156 | to interfere with or be responsible for such uses by You. 157 | 158 | 16) Modification of This License. This License is Copyright © 2005 Lawrence 159 | Rosen. Permission is granted to copy, distribute, or communicate this License 160 | without modification. Nothing in this License permits You to modify this 161 | License as applied to the Original Work or to Derivative Works. However, You 162 | may modify the text of this License and copy, distribute or communicate your 163 | modified version (the "Modified License") and apply it to other original works 164 | of authorship subject to the following conditions: (i) You may not indicate in 165 | any way that your Modified License is the "Open Software License" or "OSL" and 166 | you may not use those names in the name of your Modified License; (ii) You 167 | must replace the notice specified in the first paragraph above with the notice 168 | "Licensed under " or with a notice of your own 169 | that is not confusingly similar to the notice in this License; and (iii) You 170 | may not claim that your original works are open source software unless your 171 | Modified License has been approved by Open Source Initiative (OSI) and You 172 | comply with its license review and certification process. 173 | -------------------------------------------------------------------------------- /Plugins/SkipPlugin/SkipPlugin.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2023–2025 Skip 2 | // Licensed under the Open Software License version 3.0 3 | import Foundation 4 | import PackagePlugin 5 | 6 | /// Build plugin that unifies the preflight linter and the transpiler in a single plugin. 7 | @main struct SkipPlugin: BuildToolPlugin { 8 | /// The suffix that is requires 9 | let testSuffix = "Tests" 10 | 11 | /// The root of target dependencies that are don't have any skipcode output 12 | let skipRootTargetNames: Set = ["SkipDrive", "SkipTest"] 13 | 14 | /// The name of the plug-in's output folder is the same as the target name for the transpiler, which matches the ".plugin(name)" in the Package.swift 15 | let pluginFolderName = "skipstone" 16 | 17 | /// The output folder in which to place Skippy files 18 | let skippyOutputFolder = ".skippy" 19 | 20 | /// The executable command forked by the plugin; this is the build artifact whose name matches the built `skip` binary 21 | let skipPluginCommandName = "skip" 22 | 23 | /// The file extension for the metadata about skipcode 24 | //let skipcodeExtension = ".skipcode.json" 25 | 26 | /// The skip transpile output containing the input source hashes to check for changes 27 | let sourcehashExtension = ".sourcehash" 28 | 29 | /// The extension to add to the skippy output; these have the `docc` extension merely because that is the only extension of generated files that is not copied as a resource when a package is built: https://github.com/apple/swift-package-manager/blob/0147f7122a2c66eef55dcf17a0e4812320d5c7e6/Sources/PackageLoading/TargetSourcesBuilder.swift#L665 30 | let skippyOuptputExtension = ".skippy" 31 | 32 | /// Whether we should run in Skippy or full-transpile mode 33 | let skippyOnly = ProcessInfo.processInfo.environment["CONFIGURATION"] == "Skippy" 34 | 35 | /// Whether to turn off the Skip plugin manually 36 | let skipDisabled = (ProcessInfo.processInfo.environment["SKIP_PLUGIN_DISABLED"] ?? "0") != "0" 37 | 38 | /// Whether we are in SkipBridge generation mode 39 | let skipBridgeMode = (ProcessInfo.processInfo.environment["SKIP_BRIDGE"] ?? "0") != "0" 40 | 41 | func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { 42 | if skipDisabled { 43 | Diagnostics.remark("Skip plugin disabled through SKIP_PLUGIN_DISABLED environment variable") 44 | return [] 45 | } 46 | 47 | if skipRootTargetNames.contains(target.name) { 48 | Diagnostics.remark("Skip eliding target name \(target.name)") 49 | return [] 50 | } 51 | guard let sourceTarget = target as? SourceModuleTarget else { 52 | Diagnostics.remark("Skip skipping non-source target name \(target.name)") 53 | return [] 54 | } 55 | 56 | var cmds: [Command] = [] 57 | if skippyOnly { 58 | cmds += try await createPreflightBuildCommands(context: context, target: sourceTarget) 59 | } else { 60 | // We only want to run the transpiler when targeting macOS and not iOS, but there doesn't appear to by any way to identify that from this phase of the plugin execution; so the transpiler will check the environment (e.g., "SUPPORTED_DEVICE_FAMILIES") and only run conditionally 61 | cmds += try await createTranspileBuildCommands(context: context, target: sourceTarget) 62 | } 63 | 64 | return cmds 65 | } 66 | 67 | func createPreflightBuildCommands(context: PluginContext, target: SourceModuleTarget) async throws -> [Command] { 68 | let runner = try context.tool(named: skipPluginCommandName).path 69 | let inputPaths = target.sourceFiles(withSuffix: ".swift").map { $0.path } 70 | let outputDir = context.pluginWorkDirectory.appending(subpath: skippyOutputFolder) 71 | return inputPaths.map { Command.buildCommand(displayName: "Skippy \(target.name): \($0.lastComponent)", executable: runner, arguments: ["skippy", "--output-suffix", skippyOuptputExtension, "-O", outputDir.string, $0.string], inputFiles: [$0], outputFiles: [$0.outputPath(in: outputDir, suffix: skippyOuptputExtension)]) } 72 | } 73 | 74 | func createTranspileBuildCommands(context: PluginContext, target: SourceModuleTarget) async throws -> [Command] { 75 | //Diagnostics.remark("Skip transpile target: \(target.name)") 76 | 77 | // we need to know the names of peer target folders in order to set up dependency links, so we need to determine the output folder structure 78 | 79 | // output named vary dependeding on whether we are running from Xcode/xcodebuild and SwiftPM, and also changed in Swift 6: 80 | // xcode: DERIVED/SourcePackages/plugins/skip-unit.output/SkipUnit/skipstone/SkipUnit.skipcode.json 81 | // SwiftPM 5: PROJECT_HOME/.build/plugins/outputs/skip-unit/SkipUnit/skipstone/SkipUnit.skipcode.json 82 | // SwiftPM 6: PROJECT_HOME/.build/plugins/outputs/skip-unit/SkipUnit/destination/skipstone/SkipUnit.skipcode.json 83 | let outputFolder = context.pluginWorkDirectory 84 | 85 | let outputExt = outputFolder.removingLastComponent().removingLastComponent().extension 86 | let pkgext = outputExt.flatMap({ "." + $0 }) ?? "" 87 | // when run from Xcode, the plugin folder ends with ".output"; when run from CLI `swift build`, there is no output extension 88 | let isXcodeBuild = !pkgext.isEmpty 89 | 90 | let skip = try context.tool(named: skipPluginCommandName) 91 | // enable overriding the path to the Skip tool for local development 92 | let skipToolPath = ProcessInfo.processInfo.environment["SKIP_COMMAND_OVERRIDE"].flatMap({ Path($0) }) ?? skip.path 93 | 94 | // look for ModuleKotlin/Sources/Skip/skip.yml 95 | let skipFolder = target.directory.appending(["Skip"]) 96 | 97 | // the peer for the current target 98 | // e.g.: SkipLibKotlin -> SkipLib 99 | // e.g.: SkipLibKtTests -> SkipLibTests 100 | let peerTarget: Target 101 | 102 | let isTest = target.name.hasSuffix(testSuffix) 103 | let kotlinModule = String(target.name.dropLast(isTest ? testSuffix.count : 0)) 104 | if isTest { 105 | if !target.name.hasSuffix(testSuffix) { 106 | throw SkipPluginError(errorDescription: "Target «\(target.name)» must have suffix «\(testSuffix)»") 107 | } 108 | 109 | // convert ModuleKotlinTests -> ModuleTests 110 | let expectedName = kotlinModule + testSuffix 111 | 112 | // Known issue with SPM in Xcode: we cannot have a dependency from one testTarget to another, or we hit the error: 113 | // Enable to resolve build file: XCBCore.BuildFile (The workspace has a reference to a missing target with GUID 'PACKAGE-TARGET:SkipLibTests') 114 | // so we cannot use `target.dependencies.first` to find the target; we just need to scan by name 115 | guard let dependencyTarget = try context.package.targets(named: [expectedName]).first else { 116 | throw SkipPluginError(errorDescription: "Target «\(target.name)» should have a peer test target named «\(expectedName)»") 117 | } 118 | 119 | peerTarget = dependencyTarget 120 | } else { 121 | let expectedName = kotlinModule 122 | 123 | guard let dependencyTarget = try context.package.targets(named: [expectedName]).first else { 124 | throw SkipPluginError(errorDescription: "Target «\(target.name)» should have a peer test target named «\(expectedName)»") 125 | } 126 | 127 | peerTarget = dependencyTarget 128 | } 129 | 130 | guard let swiftSourceTarget = peerTarget as? SourceModuleTarget else { 131 | throw SkipPluginError(errorDescription: "Peer target «\(peerTarget.name)» was not a source module") 132 | } 133 | 134 | func recursivePackageDependencies(for package: Package) -> [PackageDependency] { 135 | return package.dependencies + package.dependencies.flatMap({ recursivePackageDependencies(for: $0.package) }) 136 | } 137 | 138 | // create a lookup table from the (arbitrary but unique) product ID to the owning package 139 | // this is needed to find the package ID associated with a given product ID 140 | var productIDPackages: [Product.ID?: Package] = [:] 141 | for targetPackage in recursivePackageDependencies(for: context.package) { 142 | for product in targetPackage.package.products { 143 | productIDPackages[product.id] = targetPackage.package 144 | } 145 | } 146 | 147 | // the output files contains the .skipcode.json, and the input files contain all the dependent .skipcode.json files 148 | let outputURL = URL(fileURLWithPath: outputFolder.string, isDirectory: true) 149 | let sourceHashDot = "." 150 | let sourcehashOutputPath = Path(outputURL.appendingPathComponent(sourceHashDot + peerTarget.name + sourcehashExtension, isDirectory: false).path) 151 | //Diagnostics.warning("add sourcehash output for \(target.name): \(sourcehashOutputPath)", file: sourcehashOutputPath.string) 152 | 153 | struct Dep : Identifiable { 154 | let package: Package 155 | let target: Target 156 | 157 | var id: String { target.id } 158 | } 159 | 160 | var buildModuleArgs: [String] = [ 161 | "--module", 162 | peerTarget.name + ":" + peerTarget.directory.string, 163 | ] 164 | 165 | @discardableResult func addModuleLinkFlag(_ target: SourceModuleTarget, packageID: String?) throws -> String? { 166 | let targetName = target.name 167 | // build up a relative link path to the related module based on the plug-in output directory structure 168 | buildModuleArgs += ["--module", targetName + ":" + target.directory.string] 169 | // e.g. ../../../skiphub.output/SkipFoundationKotlin/skip/SkipFoundation 170 | // e.g. ../../SkipFoundationKotlin/skip/SkipFoundation 171 | let targetLink: String 172 | 173 | // SwiftPM 6 (included with Xcode 16b3) changes the plugin output folder behavior from running from the command line: 174 | // plugin output folders go to "plugins/outputs/package-name/TargetName/destination/skipstone" rather that "plugins/outputs/package-name/TargetName/skipstone", which affects how we set up symbolic links 175 | // See: https://forums.swift.org/t/swiftpm-included-with-xcode-16b3-changes-plugin-output-folder-to-destination/73220 176 | // So check to see if the output folder's parent directory is "destination", and if so, change our assumptions about where the plugins will be output 177 | let hasDestinationFolder = !isXcodeBuild && outputFolder.removingLastComponent().lastComponent == "destination" 178 | let destFolder = !hasDestinationFolder ? pluginFolderName : ("destination/" + pluginFolderName) 179 | let parentLink = !hasDestinationFolder ? "" : "../" // the extra folder means we need to link one more level up 180 | 181 | if let packageID = packageID { // go further up to the external package name 182 | targetLink = parentLink + "../../../" + packageID + pkgext + "/" + target.name + "/" + destFolder + "/" + targetName 183 | } else { 184 | targetLink = parentLink + "../../" + target.name + "/" + destFolder + "/" + targetName 185 | } 186 | buildModuleArgs += ["--link", targetName + ":" + targetLink] 187 | return targetLink 188 | } 189 | 190 | func dependencies(for targetDependencies: [TargetDependency], in package: Package) -> [Dep] { 191 | return targetDependencies.flatMap { dep in 192 | switch dep { 193 | case .product(let product): 194 | guard let productPackage = productIDPackages[product.id] else { 195 | // product may have been unrecognized, like a macro 196 | return [] as [Dep] 197 | } 198 | 199 | return product.targets.flatMap { target in 200 | // stop at any external targets 201 | if skipRootTargetNames.contains(target.name) { 202 | return [] as [Dep] 203 | } 204 | return [Dep(package: productPackage, target: target)] + dependencies(for: target.dependencies, in: productPackage) 205 | } 206 | case .target(let target): 207 | if skipRootTargetNames.contains(target.name) { 208 | return [] as [Dep] 209 | } 210 | return [Dep(package: package, target: target)] + dependencies(for: target.dependencies, in: package) 211 | @unknown default: 212 | return [] as [Dep] 213 | } 214 | } 215 | } 216 | 217 | var deps = dependencies(for: target.dependencies, in: context.package) 218 | deps = makeUniqueById(deps) 219 | 220 | var outputFiles: [Path] = [sourcehashOutputPath] 221 | 222 | // input files consist of the source files, as well as all the dependent module output source hash directory files, which will be modified whenever a transpiled module changes 223 | // note that using the directory as the input will cause the transpile to re-run for any sub-folder change, although this behavior is not explicitly documented 224 | var inputFiles: [Path] = [target.directory] + target.sourceFiles.map(\.path) 225 | 226 | for dep in deps { 227 | guard let depTarget = dep.target as? SourceModuleTarget else { 228 | // only consider source module targets 229 | continue 230 | } 231 | 232 | if skipRootTargetNames.contains(depTarget.name) { 233 | continue 234 | } 235 | 236 | let hasSkipConfig = FileManager.default.isReadableFile(atPath: depTarget.directory.appending("Skip", "skip.yml").string) 237 | 238 | // ignore non-Skip-enabled dependency modules, based on the existance of the SRC/MODULE/Skip/skip.yml file 239 | if !hasSkipConfig { 240 | continue 241 | } 242 | 243 | if let moduleLinkTarget = try addModuleLinkFlag(depTarget, packageID: dep.package.id) { 244 | // adds an input file dependency on all the .skipcode.json files output from the dependent targets 245 | // this should block the invocation of the transpiler plugin for this module 246 | // until the dependent modules have all been transpiled and their skipcode JSON files emitted 247 | 248 | var sourceHashFile = URL(fileURLWithPath: outputFolder.string, isDirectory: true).appendingPathComponent(moduleLinkTarget + sourcehashExtension, isDirectory: false) 249 | // turn the module name into a sourcehash file name 250 | // need to standardize the path to remove ../../ elements form the symlinks, otherwise the input and output paths don't match and Xcode will re-build everything each time 251 | sourceHashFile = sourceHashFile.standardized 252 | .deletingLastPathComponent() 253 | .appendingPathComponent(sourceHashDot + sourceHashFile.lastPathComponent, isDirectory: false) 254 | //Diagnostics.warning("sourceHashFile: outputFolder=\(outputFolder.string) moduleLinkTarget=\(moduleLinkTarget) -> \(sourceHashFile)") 255 | 256 | // output a .sourcehash file contains all the input files, so the transpile will be re-run when any of the input sources have changed 257 | let sourceHashFilePath = Path(sourceHashFile.path) 258 | inputFiles.append(sourceHashFilePath) 259 | //Diagnostics.remark("add sourcehash input for \(depTarget.name): \(sourceHashFilePath.string)", file: sourceHashFilePath.string) 260 | } 261 | } 262 | 263 | // due to FB12969712 https://github.com/apple/swift-package-manager/issues/6816 , we cannot trust the list of input files sent to the plugin because Xcode caches them onces and doesn't change them when the package source file list changes (unless first manually running: File -> Packages -> Reset Package Caches) 264 | // so instead we just pass the targets folder to the skip tool, and rely on it the tool to walk the file system and determine which files have changed or been renamed 265 | //let inputSources = target.sourceFiles // source file list will be built by walking the --project flag instead 266 | 267 | let outputBase = URL(fileURLWithPath: kotlinModule, isDirectory: true, relativeTo: outputURL) 268 | let sourceBase = URL(fileURLWithPath: isTest ? "src/test" : "src/main", isDirectory: true, relativeTo: outputBase) 269 | 270 | var buildArguments = [ 271 | "transpile", 272 | "--project", swiftSourceTarget.directory.string, 273 | "--skip-folder", skipFolder.string, 274 | "--sourcehash", sourcehashOutputPath.string, 275 | "--output-folder", sourceBase.path, 276 | "--module-root", outputBase.path, 277 | ] 278 | 279 | func appendArguments(_ args: [String], unlessAlreadySet: Bool = true) { 280 | // scan the existing arguments to see if the same arg already exists 281 | if unlessAlreadySet == true && buildArguments.indices.first(where: { 282 | buildArguments.dropFirst($0).prefix(args.count) == .init(args) 283 | }) != nil { 284 | return 285 | } 286 | 287 | buildArguments += args 288 | } 289 | 290 | let packageDeps = recursivePackageDependencies(for: context.package) 291 | 292 | // create a map from [target ID: package] for all known targets 293 | let targetsToPackage = Dictionary(packageDeps.flatMap({ packageDep in 294 | packageDep.package.targets.map({ target in 295 | (target.id, packageDep.package) 296 | }) 297 | }), uniquingKeysWith: { $1 }) 298 | 299 | // pass dependencies ids to local paths through to skipstone so that it can set up local links for native swift builds from one bridged swift package to another bridged swift package 300 | var dependencyParameters = Set() 301 | for targetDep in context.package.targets.flatMap(\.recursiveTargetDependencies) { 302 | guard let package = targetsToPackage[targetDep.id] else { 303 | continue 304 | } 305 | //Diagnostics.remark("recursiveTargetDependencies: \(target.name):\(package.id):\(package.directory)") 306 | // package.id is the lower-cased version of the name, whereas package.displayName preserves the case 307 | let dependencyParameter = [targetDep.name, package.displayName, package.directory.string].joined(separator: ":") 308 | // only add the dependency parameter if we have not already specified it (duplicates are possible because we may visit the same target multiple times) 309 | if dependencyParameters.insert(dependencyParameter).inserted { 310 | appendArguments(["--dependency", dependencyParameter]) 311 | } 312 | } 313 | 314 | for pack in packageDeps { 315 | for executableProduct in pack.package.products(ofType: ExecutableProduct.self) { 316 | // also add the Skip plugin dependency itself, so we use the local version of the plugin 317 | if executableProduct.name == "skip" { 318 | appendArguments(["--dependency", [executableProduct.name, pack.package.id, pack.package.directory.string].joined(separator: ":")]) 319 | } 320 | } 321 | } 322 | 323 | if skipBridgeMode && inputFiles.contains(where: { path in path.string.hasSuffix(".swift") }) { 324 | // when we are running with SKIP_BRIDGE, we also output NAME_Bridge.swift files for each Swift file that contains bridging information 325 | // note that we only add these if the input files have at least one .swift file, in order to exclude modules that contain only C/C++ files 326 | let skipBridgeOutputDir = outputFolder.appending(subpath: "SkipBridgeGenerated") 327 | let bridgeSuffix = "_Bridge.swift" // SkipSyntax.Source.FilePath.bridgeFileSuffix 328 | 329 | outputFiles += target.sourceFiles(withSuffix: "swift").map({ swiftFile in 330 | let swiftPath = swiftFile.path 331 | let bridgeName = swiftPath.stem + bridgeSuffix 332 | return skipBridgeOutputDir.appending(subpath: bridgeName) 333 | }) 334 | 335 | // output additional files for native support code generation 336 | let nativeSupportFileNames = [ 337 | "AnyDynamicObject_Support.swift", // SkipSyntax.KotlinDynamicObjectTransformer.supportFileName 338 | "Bundle_Support.swift", // SkipSyntax.KotlinBundleTransformer.supportFileName 339 | "FoundationBridge_Support.swift", // SkipSyntax.KotlinFoundationBridgeTransformer.supportFileName 340 | ] 341 | for fileName in nativeSupportFileNames { 342 | outputFiles.append(skipBridgeOutputDir.appending(subpath: fileName)) 343 | } 344 | 345 | appendArguments(["--skip-bridge-output", skipBridgeOutputDir.string]) 346 | } 347 | 348 | appendArguments(buildModuleArgs) 349 | 350 | //Diagnostics.remark("invoke skip \(buildArguments.joined(separator: " "))") 351 | return [ 352 | .buildCommand(displayName: "Skip \(target.name)", executable: skipToolPath, arguments: buildArguments, 353 | inputFiles: inputFiles, 354 | outputFiles: outputFiles) 355 | ] 356 | } 357 | } 358 | 359 | struct SkipPluginError : LocalizedError { 360 | let errorDescription: String? 361 | } 362 | 363 | extension Path { 364 | /// Xcode requires that we create an output file in order for incremental build tools to work. 365 | /// 366 | /// - Warning: This is duplicated in SkippyCommand. 367 | func outputPath(in outputDir: Path, suffix: String) -> Path { 368 | var outputFileName = self.lastComponent 369 | if outputFileName.hasSuffix(".swift") { 370 | outputFileName = String(lastComponent.dropLast(".swift".count)) 371 | } 372 | outputFileName += suffix 373 | return outputDir.appending(subpath: "." + outputFileName) 374 | } 375 | } 376 | 377 | func makeUniqueById(_ items: [T]) -> [T] { 378 | var uniqueItems = Set() 379 | var result = [T]() 380 | for item in items { 381 | if uniqueItems.insert(item.id).inserted { 382 | result.append(item) 383 | } 384 | } 385 | return result 386 | } 387 | -------------------------------------------------------------------------------- /Sources/SkipDrive/GradleHarness.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2023–2025 Skip 2 | // Licensed under the Open Software License version 3.0 3 | #if !SKIP 4 | import Foundation 5 | 6 | /// The `GradleHarness` is the interface to launching the `gradle` command and processing the output. 7 | /// 8 | /// It is used by the `skip` command when it needs to launch Gradle (e.g., with `skip export`), 9 | /// as well as from the `XCSkiptests` unit test runner, which will use it to launch the JUnit test runner 10 | /// on the transpied Kotlin. 11 | @available(macOS 13, macCatalyst 16, iOS 16, tvOS 16, watchOS 8, *) 12 | public protocol GradleHarness { 13 | /// Scans the output line of the Gradle command and processes it for errors or issues. 14 | func scanGradleOutput(line1: String, line2: String) 15 | } 16 | 17 | let pluginFolderName = "skipstone" 18 | 19 | @available(macOS 13, macCatalyst 16, iOS 16, tvOS 16, watchOS 8, *) 20 | extension GradleHarness { 21 | /// Returns the URL to the folder that holds the top-level `settings.gradle.kts` file for the destination module. 22 | /// - Parameters: 23 | /// - moduleTranspilerFolder: the output folder for the transpiler plug-in 24 | /// - linkFolder: when specified, the module's root folder will first be linked into the linkFolder, which enables the output of the project to be browsable from the containing project (e.g., Xcode) 25 | /// - Returns: the folder that contains the buildable gradle project, either in the DerivedData/ folder, or re-linked through the specified linkFolder 26 | public func pluginOutputFolder(moduleName: String, linkingInto linkFolder: URL?) throws -> URL { 27 | let env = ProcessInfo.processInfo.environment 28 | 29 | func isDir(_ url: URL) -> Bool { 30 | (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true 31 | } 32 | 33 | // if we are running tests from Xcode, this environment variable should be set; otherwise, assume the .build folder for an SPM build 34 | // also seems to be __XPC_DYLD_LIBRARY_PATH or __XPC_DYLD_FRAMEWORK_PATH; 35 | // this will be something like ~/Library/Developer/Xcode/DerivedData/PROJ-ABC/Build/Products/Debug 36 | // 37 | // so we build something like: 38 | // 39 | // ~/Library/Developer/Xcode/DerivedData/PROJ-ABC/Build/Products/Debug/../../../SourcePackages/plugins/skiphub.output/ 40 | // 41 | if let xcodeBuildFolder = self.xcodeBuildFolder { 42 | let buildBaseFolder = URL(fileURLWithPath: xcodeBuildFolder, isDirectory: true) 43 | .deletingLastPathComponent() 44 | .deletingLastPathComponent() 45 | .deletingLastPathComponent() 46 | var pluginsFolder = buildBaseFolder.appendingPathComponent("Build/Intermediates.noindex/BuildToolPluginIntermediates", isDirectory: true) // Xcode 16.3+ 47 | if !isDir(pluginsFolder) { 48 | pluginsFolder = buildBaseFolder.appendingPathComponent("SourcePackages/plugins", isDirectory: true) // Xcode 16.2- 49 | } 50 | return try findModuleFolder(in: pluginsFolder, extension: "output") 51 | } else { 52 | // when run from the CLI with a custom --build-path, there seems to be no way to know where the gradle folder was output, so we need to also specify it as an environment variable: 53 | // SWIFTBUILD=/tmp/swiftbuild swift test --build-path /tmp/swiftbuild 54 | let buildBaseFolder = env["SWIFTBUILD"] ?? ".build" 55 | // note that unlike Xcode, the local SPM output folder is just the package name without the ".output" suffix 56 | return try findModuleFolder(in: URL(fileURLWithPath: buildBaseFolder + "/plugins/outputs", isDirectory: true), extension: "") 57 | } 58 | 59 | /// The only known way to figure out the package name asociated with the test's module is to brute-force search through the plugin output folders. 60 | func findModuleFolder(in pluginOutputFolder: URL, extension pathExtension: String) throws -> URL { 61 | for outputFolder in try FileManager.default.contentsOfDirectory(at: pluginOutputFolder, includingPropertiesForKeys: [.isDirectoryKey]) { 62 | if !pathExtension.isEmpty && !outputFolder.lastPathComponent.hasSuffix("." + pathExtension) { 63 | continue // only check known path extensions (e.g., ".output" with running from Xcode, and no extension from SPM) 64 | } 65 | 66 | // check for new "destination" output folder that Xcode16b3's SwiftPM started adding to the plugin output hierarchy 67 | // See: https://forums.swift.org/t/swiftpm-included-with-xcode-16b3-changes-plugin-output-folder-to-destination/73220 68 | // If it is not there, then check older SwiftPM 5's destination folder without the 69 | var moduleTranspilerFolder = moduleName + "/destination/skipstone" 70 | var pluginModuleOutputFolder = URL(fileURLWithPath: moduleTranspilerFolder, isDirectory: true, relativeTo: outputFolder) 71 | if !isDir(pluginModuleOutputFolder) { 72 | moduleTranspilerFolder = moduleName + "/skipstone" 73 | pluginModuleOutputFolder = URL(fileURLWithPath: moduleTranspilerFolder, isDirectory: true, relativeTo: outputFolder) 74 | } 75 | 76 | //print("findModuleFolder: pluginModuleOutputFolder:", pluginModuleOutputFolder) 77 | if isDir(pluginModuleOutputFolder) { 78 | // found the folder; now make a link from its parent folder to the project source… 79 | if let linkFolder = linkFolder { 80 | let localModuleLink = URL(fileURLWithPath: outputFolder.lastPathComponent, isDirectory: false, relativeTo: linkFolder) 81 | // make sure the output root folder exists 82 | try FileManager.default.createDirectory(at: linkFolder, withIntermediateDirectories: true) 83 | 84 | let linkFrom = localModuleLink.path, linkTo = outputFolder.path 85 | if (try? FileManager.default.destinationOfSymbolicLink(atPath: linkFrom)) != linkTo { 86 | try? FileManager.default.removeItem(atPath: linkFrom) // if it exists 87 | try FileManager.default.createSymbolicLink(atPath: linkFrom, withDestinationPath: linkTo) 88 | } 89 | 90 | let localTranspilerOut = URL(fileURLWithPath: outputFolder.lastPathComponent, isDirectory: true, relativeTo: localModuleLink) 91 | let linkedPluginModuleOutputFolder = URL(fileURLWithPath: moduleTranspilerFolder, isDirectory: true, relativeTo: localTranspilerOut) 92 | return linkedPluginModuleOutputFolder 93 | } else { 94 | return pluginModuleOutputFolder 95 | } 96 | } 97 | } 98 | throw NoModuleFolder(errorDescription: "Unable to find module folders in \(pluginOutputFolder.path)") 99 | } 100 | } 101 | 102 | public func linkFolder(from linkFolderBase: String? = "Skip/build", forSourceFile sourcePath: StaticString?) -> URL? { 103 | // walk up from the test case swift file until we find the folder that contains "Package.swift", which we treat as the package root 104 | if let sourcePath = sourcePath, let linkFolderBase = linkFolderBase { 105 | if let packageRootURL = packageBaseFolder(forSourceFile: sourcePath) { 106 | return packageRootURL.appendingPathComponent(linkFolderBase, isDirectory: true) 107 | } 108 | } 109 | 110 | return nil 111 | } 112 | 113 | /// For any given source file, find the nearest parent folder that contains a `Package.swift` file. 114 | /// - Parameter forSourceFile: the source file for the request, typically from the `#file` directive at the call site 115 | /// - Returns: the URL containing the `Package.swift` file, or `.none` if it could not be found. 116 | public func packageBaseFolder(forSourceFile sourcePath: StaticString) -> URL? { 117 | var packageRootURL = URL(fileURLWithPath: sourcePath.description, isDirectory: false) 118 | 119 | let isPackageRoot = { 120 | (try? packageRootURL.appendingPathComponent("Package.swift", isDirectory: false).checkResourceIsReachable()) == true 121 | } 122 | 123 | while true { 124 | let parent = packageRootURL.deletingLastPathComponent() 125 | if parent.path == packageRootURL.path { 126 | return nil // top of the fs and not found 127 | } 128 | packageRootURL = parent 129 | if isPackageRoot() { 130 | return packageRootURL 131 | } 132 | } 133 | } 134 | 135 | /// Check for a swift-looking error, and if encountered, resolve any symbolic links that might have been reported, in order to accommodate Android native builds where the package exists in a derived symbloc-linked location 136 | /// Otherwise, when selecting errors in the Xcode issue navigator, it will select the correct file, but it will not highlight the line 137 | func resolveSymlinksInXcodeIssueOutput(_ line: String) -> String { 138 | if swiftBuildIssuePattern.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) != nil { 139 | if let origPath = line.split(separator: ":").first { 140 | let resolvedPath = URL(fileURLWithPath: String(origPath)).resolvingSymlinksInPath().path 141 | if resolvedPath != origPath { 142 | // substitute the symbolic path with the resolved path 143 | return resolvedPath + line.dropFirst(origPath.count) 144 | } 145 | } 146 | } 147 | return line 148 | } 149 | 150 | 151 | /// The default implementation of output scanning will match lines against the Gradle error/warning patten, 152 | /// and then output them as Xcode-formatted error/warning patterns. 153 | /// 154 | /// An attempt will be made to map the Kotlin line to original Swift line by parsing the `.sourcemap` JSON. 155 | /// 156 | /// For example, the following Gradle output: 157 | /// ``` 158 | /// e: file:///tmp/Foo.kt:12:13 Compile Error 159 | /// ``` 160 | /// 161 | /// will be converted to the following Xcode error line: 162 | /// ``` 163 | /// /tmp/Foo.swift:12:0: error: Compile Error 164 | /// ``` 165 | /// 166 | /// From https://developer.apple.com/documentation/xcode/running-custom-scripts-during-a-build#Log-errors-and-warnings-from-your-script : 167 | /// 168 | /// Log errors and warnings from your script 169 | /// 170 | /// During your script’s execution, you can report errors, warnings, and general notes to the Xcode build system. Use these messages to diagnose problems or track your script’s progress. To write messages, use the echo command and format your message as follows: 171 | /// 172 | /// ``` 173 | /// [filename]:[linenumber]: error | warning | note : [message] 174 | /// ``` 175 | /// 176 | /// If the error:, warning:, or note: string is present, Xcode adds your message to the build logs. If the issue occurs in a specific file, include the filename as an absolute path. If the issue occurs at a specific line in the file, include the line number as well. The filename and line number are optional. 177 | public func scanGradleOutput(line1: String, line2: String) { 178 | if let kotlinIssue = parseKotlinIssue(line1: line1, line2: line2) { 179 | // check for match Swift source lines; this will result in reporting two separate errors: one for the matched Swift source line, and one in the transpiled Kotlin 180 | if let swiftIssue = try? kotlinIssue.location?.findSourceMapLine() { 181 | print(GradleIssue(kind: kotlinIssue.kind, message: kotlinIssue.message, location: swiftIssue).xcodeMessageString) 182 | } 183 | print(kotlinIssue.xcodeMessageString) 184 | } 185 | } 186 | 187 | 188 | /// Parse a 2-line output buffer for the gradle command and look for error or warning pattern, optionally mapping back to the source Swift location when the location is found in a known .skipcode.json file. 189 | public func parseKotlinIssue(line1: String, line2: String) -> GradleIssue? { 190 | // check against known Kotlin error patterns 191 | // e.g.: "e: file:///PATH/build.gradle.kts:102:17: Unresolved reference: option" 192 | if let issue = parseKotlinErrorOutput(line: line1) { 193 | return issue 194 | } 195 | // check against other Gradle error output patterns 196 | if let issue = parseGradleErrorOutput(line1: line1, line2: line2) { 197 | return issue 198 | } 199 | return nil 200 | } 201 | 202 | private func parseGradleErrorOutput(line1: String, line2: String) -> GradleIssue? { 203 | // general task failure 204 | if line1 == "* What went wrong:" { 205 | return GradleIssue(kind: .error, message: line2) 206 | } 207 | 208 | if line1.hasPrefix("> Failed to apply plugin") { 209 | // e.g.: 210 | // * What went wrong: 211 | // An exception occurred applying plugin request [id: 'skip-plugin'] 212 | // > Failed to apply plugin 'skip-plugin'. 213 | // > Could not read script '~/Library/Developer/Xcode/DerivedData/Skip-Everything-aqywrhrzhkbvfseiqgxuufbdwdft/SourcePackages/plugins/skipapp-packname.output/PackName/skipstone/settings.gradle.kts' as it does not exist. 214 | return GradleIssue(kind: .error, message: line2.trimmingCharacters(in: CharacterSet(charactersIn: " >"))) 215 | } 216 | 217 | if line1.hasPrefix("Unable to install ") { 218 | // e.g.: 219 | // Unable to install /opt/src/github/skiptools/skipapp-showcase/.build/Android/app/outputs/apk/debug/app-debug.apk 220 | // com.android.ddmlib.InstallException: INSTALL_FAILED_UPDATE_INCOMPATIBLE: Existing package org.appfair.app.Showcase signatures do not match newer version; ignoring! 221 | return GradleIssue(kind: .error, message: line2.trimmingCharacters(in: .whitespacesAndNewlines)) 222 | } 223 | 224 | if line1.lowercased().hasPrefix("error:") { 225 | // match generic "Error:" output prefix, like when `skip android build` fails to find the SDK 226 | return GradleIssue(kind: .error, message: line1.trimmingCharacters(in: .whitespacesAndNewlines), location: nil) 227 | } 228 | 229 | if exceptionPattern.firstMatch(in: line1, range: NSRange(line1.startIndex..., in: line1)) != nil { 230 | // TODO: do we really want to match all exception messages? 231 | //return GradleIssue(kind: .warning, message: line2.trimmingCharacters(in: .whitespacesAndNewlines)) 232 | } 233 | 234 | guard let matchResult = gradleFailurePattern.firstMatch(in: line1, range: NSRange(line1.startIndex..., in: line1)) else { 235 | return nil 236 | } 237 | 238 | func match(at index: Int) -> String { 239 | (line1 as NSString).substring(with: matchResult.range(at: index)) 240 | } 241 | 242 | // turn "Error:" and "Warning:" into a error or warning 243 | let kind: GradleIssue.Kind 244 | switch match(at: 5) { 245 | case "Error": kind = .error 246 | case "Warning": kind = .warning 247 | default: fatalError("Should have matched either Error or Warning: \(line1)") 248 | } 249 | 250 | let path = "/" + match(at: 1) // the regex removes the leading slash 251 | 252 | // the issue description is on the line(s) following the expression pattern 253 | return GradleIssue(kind: kind, message: line2.trimmingCharacters(in: .whitespacesAndNewlines), location: SourceLocation(path: path, position: SourceLocation.Position(line: Int(match(at: 2)) ?? 0, column: Int(match(at: 3)) ?? 0))) 254 | } 255 | 256 | private func parseKotlinErrorOutput(line: String) -> GradleIssue? { 257 | guard let match = gradleIssuePattern.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) else { 258 | return nil 259 | } 260 | let l = (line as NSString) 261 | // turn "e" and "w" into a error or warning 262 | guard let kind = GradleIssue.Kind(rawValue: l.substring(with: match.range(at: 1))) else { 263 | return nil 264 | } 265 | let path = l.substring(with: match.range(at: 2)) 266 | guard let line = Int(l.substring(with: match.range(at: 3))) else { 267 | return nil 268 | } 269 | guard let col = Int(l.substring(with: match.range(at: 4))) else { 270 | return nil 271 | } 272 | let message = l.substring(with: match.range(at: 5)) 273 | 274 | return GradleIssue(kind: kind, message: message, location: SourceLocation(path: path, position: SourceLocation.Position(line: line, column: col))) 275 | } 276 | 277 | /// Whether the current build should be a release build or a debug build 278 | public var releaseBuild: Bool { 279 | #if DEBUG 280 | return false 281 | #else 282 | return true 283 | #endif 284 | } 285 | 286 | /// The build folder for Xcode 287 | var xcodeBuildFolder: String? { 288 | ProcessInfo.processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] 289 | ?? ProcessInfo.processInfo.environment["BUILT_PRODUCTS_DIR"] 290 | } 291 | 292 | public func projectRoot(forModule moduleName: String?, packageName: String?, projectFolder: String) throws -> URL? { 293 | guard let moduleName = moduleName, let packageName = packageName else { 294 | return nil 295 | } 296 | let env = ProcessInfo.processInfo.environment 297 | 298 | //for (key, value) in env.sorted(by: { $0.0 < $1.0 }) { 299 | // print("ENV: \(key)=\(value)") 300 | //} 301 | 302 | let isXcode = env["__CFBundleIdentifier"] == "com.apple.dt.Xcode" || xcodeBuildFolder != nil 303 | 304 | if isXcode || xcodeBuildFolder != nil { 305 | // Diagnostics.warning("ENVIRONMENT: \(env)") 306 | let packageFolderExtension = isXcode ? ".output" : "" 307 | 308 | guard let buildFolder = xcodeBuildFolder else { 309 | throw AppLaunchError(errorDescription: "The BUILT_PRODUCTS_DIR environment variable must be set to the output of the build process") 310 | } 311 | 312 | return URL(fileURLWithPath: "../../../SourcePackages/plugins/\(packageName)\(packageFolderExtension)/\(moduleName)/\(pluginFolderName)/", isDirectory: true, relativeTo: URL(fileURLWithPath: buildFolder, isDirectory: true)) 313 | } else { 314 | // SPM-derived project: .build/plugins/outputs/hello-skip/HelloSkip/skipstone 315 | // TODO: make it relative to project path 316 | return URL(fileURLWithPath: (projectFolder) + "/.build/plugins/outputs/\(packageName)/\(moduleName)/skipstone", isDirectory: true) 317 | } 318 | } 319 | 320 | func preprocessGradleArguments(in projectFolder: URL?, arguments: [String]) { 321 | // warn when we detect that the Gradle project needs to be upgraded 322 | var project = projectFolder ?? URL.currentDirectory() 323 | if let projectFlagIndex = arguments.firstIndex(where: { $0 == "-p" }), 324 | let projectArg = arguments.dropFirst(projectFlagIndex + 1).first { 325 | project.appendPathComponent(projectArg, isDirectory: true) 326 | } 327 | project.appendPathComponent("settings.gradle.kts", isDirectory: false) 328 | 329 | if FileManager.default.fileExists(atPath: project.path) { 330 | if let settingsContents = try? String(String(contentsOf: project, encoding: .utf8)) { 331 | if !settingsContents.contains(#"skip plugin --prebuild"#) // check for the new "skip plugin --prebuild" command we should be running 332 | || !settingsContents.contains(#"providers.exec"#) // check for deprecated "exec" in settings, which was removed in Gradle 9.0 333 | { 334 | let issue = GradleIssue(kind: .warning, message: "Android project upgrade required. Please open a Terminal and cd to the project folder, then run `skip upgrade` and `skip verify --fix` to update the project.", location: SourceLocation(path: project.path, position: SourceLocation.Position(line: 0, column: 0))) 335 | print(issue.xcodeMessageString) 336 | } 337 | } 338 | } 339 | } 340 | 341 | public func gradleExec(in projectFolder: URL?, moduleName: String?, packageName: String?, arguments: [String]) async throws { 342 | preprocessGradleArguments(in: projectFolder, arguments: arguments) 343 | 344 | let driver = try await GradleDriver() 345 | let acts: [String] = [] // releaseBuild ? ["assembleRelease"] : ["assembleDebug"] // expected in the arguments to the command 346 | 347 | var exitCode: ProcessResult.ExitStatus? = nil 348 | let (output, _) = try await driver.launchGradleProcess(in: projectFolder, module: moduleName, actions: acts, arguments: arguments, environment: ProcessInfo.processInfo.environmentWithDefaultToolPaths, info: false, rerunTasks: false, exitHandler: { result in 349 | print("note: Gradle \(result.resultDescription)") 350 | exitCode = result.exitStatus 351 | }) 352 | 353 | var lines: [String] = [] 354 | for try await pout in output { 355 | let line = resolveSymlinksInXcodeIssueOutput(pout.line) 356 | print(line) 357 | 358 | // check for Gradle errors and report them to the IDE with a 1-line buffer 359 | scanGradleOutput(line1: lines.last ?? line, line2: line) 360 | lines.append(line) 361 | } 362 | 363 | guard let exitCode = exitCode, case .terminated(0) = exitCode else { 364 | // output the gradle build to a temporary location to make the file reference clickable 365 | let logPath = (ProcessInfo.processInfo.environment["TEMP_DIR"] ?? URL.temporaryDirectory.path) + "/skip-gradle.log.txt" 366 | var logPrefix: String = "" 367 | do { 368 | let logContents = lines.joined(separator: "\n") 369 | // TODO: add debugging tips and pointers to the documentation 370 | let endContents = """ 371 | """ 372 | try (logContents + endContents).write(toFile: logPath, atomically: false, encoding: .utf8) 373 | // if we were able to write to the log file, add the xcode-compatible prefix for the error location 374 | // https://developer.apple.com/documentation/xcode/running-custom-scripts-during-a-build#Log-errors-and-warnings-from-your-script 375 | logPrefix = "\(logPath):\(lines.count):0: " 376 | } catch { 377 | // shouldn't fail 378 | } 379 | throw AppLaunchError(errorDescription: "\(logPrefix)error: The gradle command failed. Review the log for details and consult https://skip.tools/docs/faq for common solutions. Command: gradle \(arguments.joined(separator: " "))") 380 | } 381 | } 382 | 383 | } 384 | 385 | public struct MissingEnvironmentError : LocalizedError { 386 | public var errorDescription: String? 387 | } 388 | 389 | public struct AppLaunchError : LocalizedError { 390 | public var errorDescription: String? 391 | } 392 | 393 | 394 | 395 | // /DerivedData/Skip-Everything/SourcePackages/plugins/skipapp-weather.output/WeatherAppUI/skipstone/WeatherAppUI/src/main/AndroidManifest.xml:18:13-69 Error: 396 | 397 | /// Gradle-formatted lines start with "e:" or "w:", and the line:column specifer seems to sometimes trail with a colon and other times not 398 | let gradleIssuePattern = try! NSRegularExpression(pattern: #"^([we]): file://(.*):([0-9]+):([0-9]+)[:]* +(.*)$"#) 399 | let gradleFailurePattern = try! NSRegularExpression(pattern: #"^/(.*):([0-9]+):([0-9]+)-([0-9]+) (Error|Warning):$"#) 400 | 401 | // e.g.: com.xyz.SomeException: Some message 402 | let exceptionPattern = try! NSRegularExpression(pattern: #"^([a-zA-Z.]*)Exception: +(.*)$"#) 403 | 404 | // The error pattern output of swift build, matched by Xcode's issue parsing to provide line matching and error reporting 405 | // e.g.: [filepath]:[linenumber]:[columnumber]: [error | warning | note]: [message] 406 | let swiftBuildIssuePattern = try! NSRegularExpression(pattern: #"^/(.*?):([0-9]+):([0-9]+): (warning|error|note): (.*)$"#) 407 | 408 | extension NSRegularExpression { 409 | func matches(in string: String, options: MatchingOptions = []) -> [NSTextCheckingResult] { 410 | matches(in: string, options: options, range: NSRange(string.startIndex ..< string.endIndex, in: string)) 411 | } 412 | } 413 | 414 | /// A source-related issue reported during the execution of Gradle. In the form of: 415 | /// 416 | /// ``` 417 | /// e: file:///tmp/Foo.kt:12:13 Compile Error 418 | /// ``` 419 | public struct GradleIssue { 420 | public var kind: Kind 421 | public var message: String 422 | public var location: SourceLocation? = nil 423 | 424 | public enum Kind : String, CaseIterable { 425 | case error = "e" 426 | case warning = "w" 427 | 428 | // return the token for reporting the issue in xcode 429 | public var xcode: String { 430 | switch self { 431 | case .error: return "error" 432 | case .warning: return "warning" 433 | } 434 | } 435 | } 436 | 437 | /// A message string that will show up in the Xcode Issue Navigator 438 | public var xcodeMessageString: String { 439 | let msg = "\(kind.xcode): \(message)" 440 | if let location = location { 441 | return "\(location.path):\(location.position.line):\(location.position.column): " + msg 442 | } else { 443 | return msg 444 | } 445 | } 446 | } 447 | 448 | public struct NoModuleFolder : LocalizedError { 449 | public var errorDescription: String? 450 | } 451 | 452 | public struct ADBError : LocalizedError { 453 | public var errorDescription: String? 454 | } 455 | #endif 456 | -------------------------------------------------------------------------------- /Sources/SkipTest/XCGradleHarness.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2023–2025 Skip 2 | // Licensed under the Open Software License version 3.0 3 | #if !SKIP 4 | #if canImport(SkipDrive) 5 | import SkipDrive 6 | #if os(macOS) || os(Linux) 7 | @_exported import XCTest 8 | 9 | /// A `XCTestCase` that invokes the `gradle` process. 10 | /// 11 | /// When run as part of a test suite, JUnit XML test reports are parsed and converted to Xcode issues, along with any reverse-source mappings from transpiled Kotlin back into the original Swift. 12 | @available(macOS 13, macCatalyst 16, *) 13 | @available(iOS, unavailable, message: "Gradle tests can only be run on macOS") 14 | @available(watchOS, unavailable, message: "Gradle tests can only be run on macOS") 15 | @available(tvOS, unavailable, message: "Gradle tests can only be run on macOS") 16 | public protocol XCGradleHarness : GradleHarness { 17 | } 18 | 19 | extension XCGradleHarness where Self : XCTestCase { 20 | 21 | /// Invoke the Gradle tests using the Robolectric simulator, or the specified device emulator/device ID (or blank string to use the first one) 22 | /// 23 | /// - Parameters: 24 | /// - device: the device ID to test against, defaulting to the `ANDROID_SERIAL` environment property. 25 | /// 26 | /// - SeeAlso: https://developer.android.com/studio/test/command-line 27 | /// - SeeAlso: https://docs.gradle.org/current/userguide/java_testing.html#test_filtering 28 | public func runGradleTests(device: String? = ProcessInfo.processInfo.environment["ANDROID_SERIAL"], file: StaticString = #file, line: UInt = #line) async throws { 29 | do { 30 | #if DEBUG 31 | let testAction = device == nil ? "testDebug" : "connectedDebugAndroidTest" 32 | #else 33 | // there is no "connectedReleaseAndroidTest" target for some reason, so release tests against an Android emulator/simulator do not work 34 | let testAction = device == nil ? "testRelease" : "connectedAndroidTest" 35 | #endif 36 | let info = !["NO", "no", "false", "0"].contains(ProcessInfo.processInfo.environment["SKIP_GRADLE_VERBOSE"] ?? "NO") 37 | try await invokeGradle(actions: [testAction], info: info, deviceID: device) 38 | print("Completed gradle test run for \(device ?? "local")") 39 | } catch { 40 | XCTFail("\((error as? LocalizedError)?.localizedDescription ?? error.localizedDescription)", file: file, line: line) 41 | } 42 | } 43 | 44 | /// Invokes the `gradle` process with the specified arguments. 45 | /// 46 | /// This is typically used to invoke test cases, but any actions and arguments can be specified, which can be used to drive the Gradle project in custom ways from a Skip test case. 47 | /// - Parameters: 48 | /// - actions: the actions to invoke, such as `test` or `assembleDebug` 49 | /// - arguments: and additional arguments 50 | /// - deviceID: the optional device ID against which to run 51 | /// - moduleSuffix: the expected module name for automatic test determination 52 | /// - sourcePath: the full path to the test case call site, which is used to determine the package root 53 | @available(macOS 13, macCatalyst 16, iOS 16, tvOS 16, watchOS 8, *) 54 | func invokeGradle(actions: [String], arguments: [String] = [], info: Bool = false, deviceID: String? = nil, testFilter: String? = nil, moduleName: String? = nil, maxMemory: UInt64? = ProcessInfo.processInfo.physicalMemory, fromSourceFileRelativeToPackageRoot sourcePath: StaticString? = #file) async throws { 55 | 56 | // the filters should be passed through to the --tests argument, but they don't seem to work for Android unit tests, neighter for Robolectric nor connected tests 57 | precondition(testFilter == nil, "test filtering does not yet work") 58 | 59 | var actions = actions 60 | //let isTestAction = testFilter != nil 61 | let isTestAction = actions.contains(where: { $0.hasPrefix("test") }) 62 | 63 | 64 | // override test targets so we can specify "SKIP_GRADLE_TEST_TARGET=connectedDebugAndroidTest" and have the tests run against the Android emulator (e.g., using reactivecircus/android-emulator-runner@v2 with CI) 65 | let override = ProcessInfo.processInfo.environment["SKIP_GRADLE_TEST_TARGET"] 66 | if let testOverride = override { 67 | actions = actions.map { 68 | $0 == "test" || $0 == "testDebug" || $0 == "testRelease" ? testOverride : $0 69 | } 70 | } 71 | 72 | let testModuleSuffix = "Tests" 73 | let moduleSuffix = isTestAction ? testModuleSuffix : "" 74 | 75 | if #unavailable(macOS 13, macCatalyst 16) { 76 | fatalError("unsupported platform") 77 | } else { 78 | // only run in subclasses, not in the base test 79 | if self.className == "SkipUnit.XCGradleHarness" { 80 | // TODO: add a general system gradle checkup test here 81 | } else { 82 | let selfType = type(of: self) 83 | let moduleName = moduleName ?? String(reflecting: selfType).components(separatedBy: ".").first ?? "" 84 | if isTestAction && !moduleName.hasSuffix(moduleSuffix) { 85 | throw InvalidModuleNameError(errorDescription: "The module name '\(moduleName)' is invalid for running gradle tests; it must end with '\(moduleSuffix)'") 86 | } 87 | let driver = try await GradleDriver() 88 | 89 | let dir = try pluginOutputFolder(moduleName: moduleName, linkingInto: linkFolder(forSourceFile: sourcePath)) 90 | 91 | // tests are run in the merged base module (e.g., "SkipLib") that corresponds to this test module name ("SkipLibTests") 92 | let baseModuleName = moduleName.dropLast(testModuleSuffix.count).description 93 | 94 | var testProcessResult: ProcessResult? = nil 95 | 96 | var env: [String: String] = ProcessInfo.processInfo.environmentWithDefaultToolPaths 97 | if let deviceID = deviceID, !deviceID.isEmpty { 98 | env["ANDROID_SERIAL"] = deviceID 99 | } 100 | 101 | var args = arguments 102 | if let testFilter = testFilter { 103 | // NOTE: test filtering does not seem to work; no patterns are matched, 104 | args += ["--tests", testFilter] 105 | } 106 | 107 | // specify additional arguments in the GRADLE_ARGUMENT variable, such as `-P android.testInstrumentationRunnerArguments.package=skip.ui.SkipUITests` 108 | if let gradleArgument = env["GRADLE_ARGUMENT"] { 109 | args += [gradleArgument] 110 | } 111 | 112 | let (output, parseResults) = try await driver.launchGradleProcess(in: dir, module: baseModuleName, actions: actions, arguments: args, environment: env, info: info, maxMemory: maxMemory, exitHandler: { result in 113 | // do not fail on non-zero exit code because we want to be able to parse the test results first 114 | testProcessResult = result 115 | }) 116 | 117 | var previousOutput: AsyncLineOutput.Element? = nil 118 | for try await pout in output { 119 | let line = pout.line 120 | print(line) 121 | // check for errors and report them to the IDE with a 1-line buffer 122 | scanGradleOutput(line1: previousOutput?.line ?? line, line2: line) 123 | previousOutput = pout 124 | } 125 | 126 | let failedTests: [String] 127 | 128 | // if any of the actions are a test case, when try to parse the XML results 129 | if isTestAction { 130 | let testSuites = try parseResults() 131 | // the absense of any test data probably indicates some sort of mis-configuration or else a build failure 132 | if testSuites.isEmpty { 133 | XCTFail("No tests were run; this may indicate an issue with running the tests on \(deviceID ?? "Robolectric"). See the test output and Report Navigator log for details.") 134 | } 135 | failedTests = reportTestResults(testSuites, dir).map(\.fullName) 136 | } else { 137 | failedTests = [] 138 | } 139 | 140 | switch testProcessResult?.exitStatus { 141 | case .terminated(let code): 142 | // this is a general error that is reported whenever gradle fails, so that the overall test will fail even when we cannot parse any build errors or test failures 143 | // there should be additional messages in the log to provide better indication of where the test failed 144 | if code != 0 { 145 | if !failedTests.isEmpty { 146 | // TODO: output test summary and/or a log file and have the xcode error link to the file so the user can see a summary of the failed tests 147 | throw GradleDriverError("The gradle action \(actions) failed with \(failedTests.count) test \(failedTests.count == 1 ? "failure" : "failures"). Review the logs for individual test case results. Failed tests: \(failedTests.joined(separator: ", "))") 148 | } else { 149 | throw GradleDriverError("gradle \(actions.first?.description ?? "") failed, which may indicate a build error or a test failure. Examine the log tab for more details. See https://skip.tools/docs") 150 | } 151 | } 152 | default: 153 | throw GradleBuildError(errorDescription: "Gradle failed with result: \(testProcessResult?.description ?? "")") 154 | } 155 | } 156 | } 157 | } 158 | 159 | 160 | /// The contents typically contain a stack trace, which we need to parse in order to try to figure out the source code and line of the failure: 161 | /// ``` 162 | /// org.junit.ComparisonFailure: expected: but was: 163 | /// at org.junit.Assert.assertEquals(Assert.java:117) 164 | /// at org.junit.Assert.assertEquals(Assert.java:146) 165 | /// at skip.unit.XCTestCase.XCTAssertEqual(XCTest.kt:31) 166 | /// at skip.lib.SkipLibTests.testSkipLib$SkipLib(SkipLibTests.kt:16) 167 | /// at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) 168 | /// at java.base/java.lang.reflect.Method.invoke(Method.java:578) 169 | /// at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) 170 | /// ``` 171 | /// 172 | /// ``` 173 | /// java.lang.AssertionError: ABCX != ABC 174 | /// at org.junit.Assert.fail(Assert.java:89) 175 | /// at org.junit.Assert.assertTrue(Assert.java:42) 176 | /// at skip.unit.XCTestCase$DefaultImpls.XCTAssertEqual(XCTest.kt:68) 177 | /// at app.model.AppModelTests.XCTAssertEqual(AppModelTests.kt:10) 178 | /// at app.model.AppModelTests.testAppModelA$AppModel_debugUnitTest(AppModelTests.kt:14) 179 | /// ``` 180 | /// 181 | /// ``` 182 | /// java.lang.AssertionError: ABCZ != ABC 183 | /// at org.junit.Assert.fail(Assert.java:89) 184 | /// at org.junit.Assert.assertTrue(Assert.java:42) 185 | /// at skip.unit.XCTestCase$DefaultImpls.XCTAssertEqual(XCTest.kt:68) 186 | /// at app.model.AppModelTests.XCTAssertEqual(AppModelTests.kt:10) 187 | /// at app.model.AppModelTests$testAppModelB$2.invokeSuspend(AppModelTests.kt:31) 188 | /// at app.model.AppModelTests$testAppModelB$2.invoke(AppModelTests.kt) 189 | /// at app.model.AppModelTests$testAppModelB$2.invoke(AppModelTests.kt) 190 | /// at skip.lib.Async$Companion$run$2.invokeSuspend(Concurrency.kt:153) 191 | /// at _COROUTINE._BOUNDARY._(CoroutineDebugging.kt:46) 192 | /// at app.model.AppModelTests$runtestAppModelB$1$1.invokeSuspend(AppModelTests.kt:23) 193 | /// at app.model.AppModelTests$runtestAppModelB$1.invokeSuspend(AppModelTests.kt:23) 194 | /// at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTest$2$1$1.invokeSuspend(TestBuilders.kt:314) 195 | /// ``` 196 | /// 197 | private func extractSourceLocation(dir: URL, moduleName: String, failure: GradleDriver.TestFailure) -> (kotlin: SourceLocation?, swift: SourceLocation?) { 198 | let modulePath = dir.appendingPathComponent(String(moduleName), isDirectory: true) 199 | 200 | // turn: "at skip.lib.SkipLibTests.testSkipLib$SkipLib(SkipLibTests.kt:16)" 201 | // into: src/main/skip/lib/SkipLibTests.kt line: 16 202 | 203 | // take the bottom-most nested stack trace, since that should be the one with the true call stack in the case of a coroutine test 204 | let stackTrace = failure.contents?.split(separator: "\nCaused by: ").last ?? "" 205 | 206 | var skipNextLine = false 207 | 208 | for line in stackTrace.split(separator: "\n") { 209 | let trimmedLine = line.trimmingCharacters(in: .whitespaces) 210 | // make sure it matches the pattern: "at skip.lib.SkipLibTests.testSkipLib$SkipLib(SkipLibTests.kt:16)" 211 | if !trimmedLine.hasPrefix("at ") || !line.hasSuffix(")") { 212 | continue 213 | } 214 | 215 | if skipNextLine { 216 | skipNextLine = false 217 | continue 218 | } 219 | 220 | let lineParts = trimmedLine.dropFirst(3).dropLast().split(separator: "(").map(\.description) // drop the "at" and final paren 221 | 222 | // get the contents of the final parens, like: (SkipLibTests.kt:16) 223 | guard lineParts.count == 2, 224 | let stackElement = lineParts.first, 225 | let fileLine = lineParts.last else { 226 | continue 227 | } 228 | 229 | if stackElement.hasPrefix("org.junit.") { 230 | // skip over JUnit stack elements 231 | // e.g.: at org.junit.Assert.assertNotNull(Assert.java:723) 232 | continue 233 | } 234 | 235 | if stackElement.hasPrefix("skip.unit.XCTestCase") { 236 | // skip over assertion wrappers 237 | // e.g.: at skip.unit.XCTestCase$DefaultImpls.XCTAssertNotNil(XCTest.kt:55) 238 | if stackElement.hasPrefix("skip.unit.XCTestCase$DefaultImpls") { 239 | // this means that the next line will be the inlined implementation of an assertion of other XCUnit extension, which should be ignored (since the line number is not useful). E.g.: 240 | // app.model.AppModelTests.XCTAssertEqual(AppModelTests.kt:10) 241 | skipNextLine = true 242 | } 243 | continue 244 | } 245 | 246 | if !stackElement.contains("$") { 247 | // the test case itself will contain 248 | // e.g.: at skip.unit.XCTestCase$DefaultImpls.XCTAssertNotNil(XCTest.kt:55) 249 | continue 250 | } 251 | 252 | // check the format of the "SkipLibTests.kt:16" line, and only continut for Kotlin files 253 | let parts = fileLine.split(separator: ":").map(\.description) 254 | guard parts.count == 2, 255 | let fileName = parts.first, 256 | let fileLine = parts.last, 257 | let fileLineNumber = Int(fileLine), 258 | fileName.hasSuffix(".kt") else { 259 | continue 260 | } 261 | 262 | // now look at the stackElement like "skip.lib.SkipLibTests.testSkipLib$SkipLib" and turn it into "skip/lib/SkipLibTests.kt" 263 | let packageElements = stackElement.split(separator: ".").map(\.description) 264 | 265 | // we have the base file name; now construct the file path based on the package name of the failing stack 266 | // we need to check in both the base source folders of the project: "src/test/kotlin/" and "src/main/kotlin/" 267 | // also include (legacy) Java paths, which by convention can also contain Kotlin files 268 | for folder in [ 269 | modulePath.appendingPathComponent("src/test/kotlin/", isDirectory: true), 270 | modulePath.appendingPathComponent("src/main/kotlin/", isDirectory: true), 271 | modulePath.appendingPathComponent("src/test/java/", isDirectory: true), 272 | modulePath.appendingPathComponent("src/main/java/", isDirectory: true), 273 | ] { 274 | var filePath = folder 275 | 276 | for packagePart in packageElements { 277 | if packagePart.lowercased() != packagePart { 278 | // assume the convention of package names being lower-case and class names being camel-case 279 | break 280 | } 281 | filePath = filePath.appendingPathComponent(packagePart, isDirectory: true) 282 | } 283 | 284 | // finally, tack on the name of the kotlin file to the end of the path 285 | filePath = filePath.appendingPathComponent(fileName, isDirectory: false) 286 | 287 | // check whether the file exists; if not, it may be in another of the root folders 288 | if FileManager.default.fileExists(atPath: filePath.path) { 289 | let kotlinLocation = SourceLocation(path: filePath.path, position: .init(line: fileLineNumber, column: 0)) 290 | let swiftLocation = try? kotlinLocation.findSourceMapLine() 291 | return (kotlinLocation, swiftLocation) 292 | } 293 | } 294 | } 295 | 296 | return (nil, nil) 297 | } 298 | 299 | /// Parse the console output from Gradle and looks for errors of the form 300 | /// 301 | /// ``` 302 | /// e: file:///…/skiphub.output/SkipSQLTests/skipstone/SkipSQL/src/main/kotlin/skip/sql/SkipSQL.kt:94:26 Function invocation 'blob(...)' expected 303 | /// ``` 304 | public func scanGradleOutput(line1: String, line2: String) { 305 | guard var issue = parseKotlinIssue(line1: line1, line2: line2) else { 306 | return 307 | } 308 | 309 | // only turn errors into assertion failures 310 | if issue.kind != .error { 311 | return 312 | } 313 | 314 | if var location = issue.location, 315 | let linkDestination = try? FileManager.default.destinationOfSymbolicLink(atPath: location.path) { 316 | // attempt the map the error back any originally linking source projects, since it is better the be editing the canonical Xcode version of the file as Xcode is able to provide details about it 317 | location.path = linkDestination 318 | issue.location = location 319 | } 320 | 321 | record(XCTIssue(type: .assertionFailure, compactDescription: issue.message, detailedDescription: issue.message, sourceCodeContext: XCTSourceCodeContext(location: issue.location?.contextLocation), associatedError: nil, attachments: [])) 322 | 323 | // if the error maps back to a Swift source file, then also report that location 324 | if let swiftLocation = try? issue.location?.findSourceMapLine() { 325 | record(XCTIssue(type: .assertionFailure, compactDescription: issue.message, detailedDescription: issue.message, sourceCodeContext: XCTSourceCodeContext(location: swiftLocation.contextLocation), associatedError: nil, attachments: [])) 326 | } 327 | } 328 | 329 | /// Parse the test suite results and output the summary to standard out 330 | /// - Returns: an array of failed test case names 331 | private func reportTestResults(_ testSuites: [GradleDriver.TestSuite], _ dir: URL, showStreams: Bool = true) -> [GradleDriver.TestCase] { 332 | 333 | // do one intial pass to show the stdout and stderror 334 | if showStreams { 335 | for testSuite in testSuites { 336 | let testSuiteName = testSuite.name.split(separator: ".").last?.description ?? testSuite.name 337 | 338 | // all the stdout/stderr is batched together for all test tests, so output it all at the end 339 | // and line up the spaced with the "GRADLE TEST CASE" line describing the test 340 | if let systemOut = testSuite.systemOut { 341 | print("JUNIT TEST STDOUT: \(testSuiteName):") 342 | let prefix = "STDOUT> " 343 | print(prefix + systemOut.split(separator: "\n").joined(separator: "\n" + prefix)) 344 | } 345 | if let systemErr = testSuite.systemErr { 346 | print("JUNIT TEST STDERR: \(testSuiteName):") 347 | let prefix = "STDERR> " 348 | print(prefix + systemErr.split(separator: "\n").joined(separator: "\n" + prefix)) 349 | } 350 | } 351 | } 352 | 353 | var passTotal = 0, failTotal = 0, skipTotal = 0, suiteTotal = 0, testsTotal = 0 354 | var timeTotal = 0.0 355 | //var failedTests: [String] = [] 356 | 357 | // parse the test result XML files and convert test failures into XCTIssues with links to the failing source and line 358 | for testSuite in testSuites { 359 | // Turn "skip.foundation.TestDateIntervalFormatter" into "TestDateIntervalFormatter" 360 | let testSuiteName = testSuite.name.split(separator: ".").last?.description ?? testSuite.name 361 | 362 | suiteTotal += 1 363 | var pass = 0, fail = 0, skip = 0 364 | var timeSuite = 0.0 365 | defer { 366 | passTotal += pass 367 | failTotal += fail 368 | skipTotal += skip 369 | timeTotal += timeSuite 370 | } 371 | 372 | for testCase in testSuite.testCases { 373 | testsTotal += 1 374 | if testCase.skipped { 375 | skip += 1 376 | } else if testCase.failures.isEmpty { 377 | pass += 1 378 | } else { 379 | fail += 1 380 | } 381 | timeSuite += testCase.time 382 | 383 | var msg = "" 384 | 385 | // msg += className + "." // putting the class name in makes the string long 386 | 387 | let nameParts = testCase.name.split(separator: "$") 388 | 389 | // test case names are like: "testSystemRandomNumberGenerator$SkipFoundation()" or "runtestAppModelB$AppModel_debugUnitTest" 390 | let testName = nameParts.first?.description ?? testCase.name 391 | msg += testSuiteName + "." + testName 392 | 393 | if !testCase.skipped { 394 | msg += " (" + testCase.time.description + ") " // add in the time for profiling 395 | } 396 | 397 | print("JUNIT TEST", testCase.skipped ? "SKIPPED" : testCase.failures.isEmpty ? "PASSED" : "FAILED", msg) 398 | // add a failure for each reported failure 399 | for failure in testCase.failures { 400 | var failureMessage = failure.message 401 | let trimPrefixes = [ 402 | "testSkipModule(): ", 403 | //"java.lang.AssertionError: ", 404 | ] 405 | for trimPrefix in trimPrefixes { 406 | if failureMessage.hasPrefix(trimPrefix) { 407 | failureMessage.removeFirst(trimPrefix.count) 408 | } 409 | } 410 | 411 | let failureContents = failure.contents ?? "" 412 | print(failureContents) 413 | 414 | // extract the file path and report the failing file and line to Xcode via an issue 415 | var msg = msg 416 | msg += failure.type ?? "" 417 | msg += ": " 418 | msg += failureMessage 419 | msg += ": " 420 | msg += failureContents // add the stack trace 421 | 422 | // convert the failure into an XCTIssue so we can see where in the source it failed 423 | let issueType: XCTIssueReference.IssueType 424 | 425 | // check for common known assertion failure exception types 426 | if failure.type?.hasPrefix("org.junit.") == true 427 | || failure.type?.hasPrefix("org.opentest4j.") == true { 428 | issueType = .assertionFailure 429 | } else { 430 | // we might rather mark it as a `thrownError`, but Xcode seems to only report a single thrownError, whereas it will report multiple `assertionFailure` 431 | // issueType = .thrownError 432 | issueType = .assertionFailure 433 | } 434 | 435 | guard let moduleName = nameParts.dropFirst().first?.split(separator: "_").first?.description else { 436 | let desc = "Could not extract module name from test case name: \(testCase.name)" 437 | let issue = XCTIssue(type: .assertionFailure, compactDescription: desc, detailedDescription: desc, sourceCodeContext: XCTSourceCodeContext(), associatedError: nil, attachments: []) 438 | record(issue) 439 | 440 | continue 441 | } 442 | 443 | let (kotlinLocation, swiftLocation) = extractSourceLocation(dir: dir, moduleName: moduleName, failure: failure) 444 | 445 | // and report the Kotlin error so the user can jump to the right place 446 | if let kotlinLocation = kotlinLocation { 447 | let issue = XCTIssue(type: issueType, compactDescription: failure.message, detailedDescription: failure.contents, sourceCodeContext: XCTSourceCodeContext(location: kotlinLocation.contextLocation), associatedError: nil, attachments: []) 448 | record(issue) 449 | } 450 | 451 | // we managed to link up the Kotlin line with the Swift source file, so add an initial issue with the swift location 452 | if let swiftLocation = swiftLocation { 453 | let issue = XCTIssue(type: issueType, compactDescription: failure.message, detailedDescription: failure.contents, sourceCodeContext: XCTSourceCodeContext(location: swiftLocation.contextLocation), associatedError: nil, attachments: []) 454 | record(issue) 455 | } 456 | } 457 | } 458 | 459 | print("JUNIT TEST SUITE: \(testSuiteName): PASSED \(pass) FAILED \(fail) SKIPPED \(skip) TIME \(round(timeSuite * 100.0) / 100.0)") 460 | } 461 | 462 | 463 | var failedTests: [GradleDriver.TestCase] = [] 464 | // show all the failures just before the final summary for ease of browsing 465 | for testSuite in testSuites { 466 | for testCase in testSuite.testCases { 467 | if !testCase.failures.isEmpty { 468 | failedTests.append(testCase) 469 | } 470 | for failure in testCase.failures { 471 | print(testCase.name, failure.message) 472 | if let stackTrace = failure.contents { 473 | print(stackTrace) 474 | } 475 | } 476 | } 477 | } 478 | 479 | let passPercentage = Double(passTotal) / (testsTotal == 0 ? Double.nan : Double(testsTotal)) 480 | print("JUNIT SUITES \(suiteTotal) TESTS \(testsTotal) PASSED \(passTotal) (\(round(passPercentage * 100))%) FAILED \(failTotal) SKIPPED \(skipTotal) TIME \(round(timeTotal * 100.0) / 100.0)") 481 | 482 | return failedTests 483 | } 484 | 485 | } 486 | 487 | extension SourceLocation { 488 | /// Returns a `XCTSourceCodeLocation` suitable for reporting from a test case 489 | var contextLocation: XCTSourceCodeLocation { 490 | XCTSourceCodeLocation(filePath: path, lineNumber: position.line) 491 | } 492 | } 493 | 494 | struct InvalidModuleNameError : LocalizedError { 495 | var errorDescription: String? 496 | } 497 | 498 | struct GradleBuildError : LocalizedError { 499 | var errorDescription: String? 500 | } 501 | 502 | #if os(Linux) 503 | // no-op compatibility shims for Linux 504 | struct XCTSourceCodeLocation { 505 | let filePath: String 506 | let lineNumber: Int 507 | } 508 | 509 | struct XCTSourceCodeContext { 510 | var location: XCTSourceCodeLocation? 511 | } 512 | 513 | struct XCTIssue { 514 | //(type: .assertionFailure, compactDescription: issue.message, detailedDescription: issue.message, sourceCodeContext: XCTSourceCodeContext(location: swiftLocation.contextLocation), associatedError: nil, attachments: []) 515 | 516 | let type: XCTIssueReference.IssueType 517 | let compactDescription: String? 518 | let detailedDescription: String? 519 | let sourceCodeContext: XCTSourceCodeContext? 520 | let associatedError: Error? 521 | let attachments: [String] 522 | } 523 | 524 | struct XCTIssueReference { 525 | enum IssueType { 526 | case assertionFailure 527 | } 528 | } 529 | 530 | extension XCTestCase { 531 | var className: String? { 532 | nil 533 | } 534 | 535 | func record(_ issue: XCTIssue) { 536 | print("issue: \(issue)") 537 | } 538 | } 539 | 540 | 541 | #endif 542 | 543 | 544 | #endif // os(macOS) || os(Linux) 545 | #endif // canImport(SkipDrive) 546 | #endif // !SKIP 547 | -------------------------------------------------------------------------------- /Sources/SkipDrive/GradleDriver.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2023–2025 Skip 2 | // Licensed under the Open Software License version 3.0 3 | #if !SKIP 4 | import Foundation 5 | #if canImport(FoundationNetworking) 6 | import FoundationNetworking 7 | #endif 8 | #if canImport(FoundationXML) 9 | import FoundationXML 10 | #endif 11 | 12 | /// The `GradleDriver` controls the execution of the `gradle` tool, 13 | /// which is expected to already be installed on the system in the 14 | /// user's `PATH` environment. 15 | @available(macOS 13, macCatalyst 16, iOS 16, tvOS 16, watchOS 8, *) 16 | public struct GradleDriver { 17 | /// The minimum version of Kotlin we can work with 18 | public static let minimumKotlinVersion = Version(1, 8, 0) 19 | 20 | /// The minimum version of Gradle that we can work with 21 | /// https://github.com/actions/runner-images/blob/main/images/macos/macos-12-Readme.md#project-management 22 | public static let minimumGradleVersion = Version(8, 1, 1) 23 | 24 | /// The path to the `gradle` tool 25 | public let gradlePath: URL 26 | 27 | /// The output from `gradle --version`, parsed into Key/Value pairs 28 | public let gradleInfo: [String: String] 29 | 30 | /// The current version of the `gradle` tool 31 | public let gradleVersion: Version 32 | 33 | /// The current version of Kotlin as used by the `gradle` tool 34 | public let kotlinVersion: Version 35 | 36 | /// The default command args to use when executing the `gradle` tool 37 | let gradleArgs: [String] 38 | 39 | /// Creates a new `GradleDriver`. Creation will check that the Gradle and Kotlin versions are within the expected limits. 40 | @available(macOS 13, macCatalyst 16, *) 41 | public init() async throws { 42 | self.gradlePath = try Self.findGradle() 43 | self.gradleArgs = [ 44 | gradlePath.path, 45 | ] 46 | 47 | self.gradleInfo = try await Self.execGradleInfo(gradleArgs: self.gradleArgs) 48 | 49 | guard let gradleVersionString = self.gradleInfo["Gradle"], 50 | let gradleVersion = try? Version(versionString: gradleVersionString, usesLenientParsing: true) else { 51 | throw GradleDriverError.noGradleVersion(gradle: self.gradlePath, props: self.gradleInfo) 52 | } 53 | 54 | self.gradleVersion = gradleVersion 55 | if self.gradleVersion < Self.minimumGradleVersion { 56 | throw GradleDriverError.gradleVersionTooLow(gradle: self.gradlePath, version: self.gradleVersion, minimum: Self.minimumGradleVersion) 57 | } 58 | 59 | guard let kotlinVersionString = self.gradleInfo["Kotlin"], 60 | let kotlinVersion = try? Version(versionString: kotlinVersionString, usesLenientParsing: true) else { 61 | throw GradleDriverError.noKotlinVersion(gradle: self.gradlePath) 62 | } 63 | 64 | self.kotlinVersion = kotlinVersion 65 | if self.kotlinVersion < Self.minimumKotlinVersion { 66 | throw GradleDriverError.kotlinVersionTooLow(gradle: self.gradlePath, version: self.kotlinVersion, minimum: Self.minimumKotlinVersion) 67 | } 68 | } 69 | 70 | private init(gradlePath: URL, gradleInfo: [String : String], gradleVersion: Version, kotlinVersion: Version, gradleArgs: [String]) { 71 | self.gradlePath = gradlePath 72 | self.gradleInfo = gradleInfo 73 | self.gradleVersion = gradleVersion 74 | self.kotlinVersion = kotlinVersion 75 | self.gradleArgs = gradleArgs 76 | } 77 | 78 | /// Creates a clone of this driver. 79 | public func clone() -> GradleDriver { 80 | GradleDriver(gradlePath: gradlePath, gradleInfo: gradleInfo, gradleVersion: gradleVersion, kotlinVersion: kotlinVersion, gradleArgs: gradleArgs) 81 | } 82 | 83 | /// Executes `gradle` with the current default arguments and the additional args and returns an async stream of the lines from the combined standard err and standard out. 84 | public func execGradle(in workingDirectory: URL?, args: [String], env: [String: String] = ProcessInfo.processInfo.environmentWithDefaultToolPaths, onExit: @escaping (ProcessResult) throws -> ()) async throws -> AsyncLineOutput { 85 | // the resulting command will be something like: 86 | // java -Xmx64m -Xms64m -Dorg.gradle.appname=gradle -classpath /opt/homebrew/Cellar/gradle/8.0.2/libexec/lib/gradle-launcher-8.0.2.jar org.gradle.launcher.GradleMain info 87 | #if DEBUG 88 | // output the launch message in a format that makes it easy to copy and paste the result into the terminal 89 | print("execGradle:", (gradleArgs + args).joined(separator: " ")) 90 | #endif 91 | 92 | return Process.streamLines(command: gradleArgs + args, environment: env, workingDirectory: workingDirectory, includeStdErr: true, onExit: onExit) 93 | } 94 | 95 | /// Invokes the given target for a gradle project. 96 | /// 97 | /// - Parameters: 98 | /// - workingDirectory: the directory in which to fork the gradle process 99 | /// - buildFolder: the directory in which the build contents are output (`--build-path SWIFTBUILD`) 100 | /// - module: the name of the module to test 101 | /// - actions: the gradle actions to run, such as `["test"]` 102 | /// - arguments: additional arguments to specify 103 | /// - daemon: whether the enable the forking of a persistent gradle daemon that will make subsequent runs faster (e.g., 5 secs vs. 15 secs) 104 | /// - failFast: whether to pass the "--fail-fast" flag 105 | /// - continue: whether to permit failing tests to complete with the "--continue" flag 106 | /// - offline: whether to pass the "--offline" flag 107 | /// - rerunTasks: whether to pass the "--rerun-tasks" flag 108 | /// - exitHandler: the exit handler, which may want to permit a process failure in order to have time to parse the tests 109 | /// - Returns: an array of parsed test suites containing information about the test run 110 | @available(macOS 13, macCatalyst 16, iOS 16, tvOS 16, watchOS 8, *) 111 | public func launchGradleProcess(in workingDirectory: URL?, buildFolder: String = ".build", module: String?, actions: [String], arguments: [String], environment: [String: String] = ProcessInfo.processInfo.environmentWithDefaultToolPaths, daemon enableDaemon: Bool = true, info infoFlag: Bool = false, quiet quietFlag: Bool = false, plain plainFlag: Bool = true, maxMemory: UInt64? = nil, failFast failFastFlag: Bool = false, noBuildCache noBuildCacheFlag: Bool = false, continue continueFlag: Bool = false, offline offlineFlag: Bool = false, rerunTasks rerunTasksFlag: Bool = true, exitHandler: @escaping (ProcessResult) throws -> ()) async throws -> (output: AsyncLineOutput, result: () throws -> [TestSuite]) { 112 | 113 | 114 | var args = actions + arguments 115 | 116 | var env: [String: String] = environment 117 | 118 | // add in the project dir for explicitness (even though it is assumed from the current working directory as well) 119 | if let workingDirectory = workingDirectory { 120 | args += ["--project-dir", workingDirectory.path] 121 | } 122 | 123 | // this enables reporting on deprecated features 124 | args += ["--warning-mode", "all"] 125 | 126 | var testResultFolder: URL? = nil 127 | 128 | if let module = module { 129 | let moduleURL = URL(fileURLWithPath: module, isDirectory: true, relativeTo: workingDirectory) 130 | if !FileManager.default.fileExists(atPath: moduleURL.path) { 131 | throw GradleDriverError("The expected gradle folder did not exist, which may mean the Skip transpiler is not enabled or encountered errors. Try running `skip doctor` to diagnose and re-building the project. See https://skip.tools/docs/. Missing path: \(moduleURL.path)") 132 | } 133 | // rather than the top-level "build" folder, we place the module in per-module .build/ sub-folder in order to enable concurrent testing as well as placing generated files in a typically-gitignored 134 | let buildDir = "\(buildFolder)/\(module)" 135 | let testResultPath = "\(buildDir)/test-results" 136 | args += ["-PbuildDir=\(buildDir)"] 137 | testResultFolder = URL(fileURLWithPath: testResultPath, isDirectory: true, relativeTo: moduleURL) 138 | } 139 | 140 | // this allows multiple simultaneous gradle builds to take place 141 | // args += ["--parallel"] 142 | 143 | // args += ["-Dorg.gradle.configureondemand=true"] 144 | 145 | if noBuildCacheFlag { 146 | args += ["--no-build-cache"] 147 | } 148 | 149 | if rerunTasksFlag { 150 | args += ["--rerun-tasks"] 151 | } 152 | 153 | if failFastFlag { 154 | args += ["--fail-fast"] 155 | } 156 | 157 | if continueFlag { 158 | args += ["--continue"] 159 | } 160 | 161 | if offlineFlag { 162 | // // tests don't work offline until the user has a ~/.gradle/caches/ with all the base dependencies 163 | args += ["--offline"] 164 | } 165 | 166 | if infoFlag { 167 | args += ["--info"] 168 | } 169 | 170 | if quietFlag { 171 | args += ["--quiet"] 172 | } 173 | 174 | if plainFlag { 175 | args += ["--console=plain"] 176 | } 177 | 178 | // attempt to run in the same process without forking the daemon 179 | if enableDaemon == false { 180 | args += ["--no-daemon"] 181 | } 182 | 183 | if let maxMemory = maxMemory { 184 | 185 | // also need to add in JVM flags, lest we be countermanded with: “To honour the JVM settings for this build a single-use Daemon process will be forked. See https://docs.gradle.org/8.0.2/userguide/gradle_daemon.html#sec:disabling_the_daemon.” 186 | // these seem to be quite specific to the gradle version being used, so disabling the daemon in future gradle versions might require tweaking these args (which can be seen by enabling the info flag): 187 | 188 | // Checking if the launcher JVM can be re-used for build. To be re-used, the launcher JVM needs to match the parameters required for the build process: -Xms256m -Xmx512m -Dfile.encoding=UTF-8 -Duser.country=US -Duser.language=en -Duser.variant 189 | 190 | var jvmargs: [String] = [] 191 | 192 | jvmargs += ["-Dfile.encoding=UTF-8"] 193 | jvmargs += ["-Xms256m"] 194 | 195 | // large amounts of log output can cause connected Android tests to fail with an error like: 196 | // io.grpc.StatusRuntimeException: RESOURCE_EXHAUSTED: gRPC message exceeds maximum size 4194304: 9677208 197 | // TODO: add in gRPC max package size here 198 | //jvmargs += ["-Dxxx.maxSize=XXX"] 199 | 200 | // make a nice memory string if we are dividible by kb/mb/gb 201 | let memstr: String 202 | let kb = Double(maxMemory) / 1024 203 | let mb = kb / 1024 204 | let gb = mb / 1024 205 | if round(gb) == gb { 206 | memstr = "\(Int64(gb))g" 207 | } else if round(mb) == mb { 208 | memstr = "\(Int64(mb))m" 209 | } else if round(kb) == kb { 210 | memstr = "\(Int64(kb))k" 211 | } else { 212 | memstr = maxMemory.description // raw bytes description 213 | } 214 | 215 | jvmargs += ["-Xmx\(memstr)"] 216 | 217 | env["GRADLE_OPTS"] = jvmargs.joined(separator: " ") 218 | //args += ["-Dorg.gradle.jvmargs=" + jvmargs.joined(separator: " ")] 219 | 220 | } 221 | 222 | if let testResultFolder = testResultFolder { 223 | #if os(macOS) 224 | try? FileManager.default.trashItem(at: testResultFolder, resultingItemURL: nil) // remove the test folder, since a build failure won't clear it and it will appear as if the tests ran successfully 225 | #else 226 | try? FileManager.default.removeItem(atPath: testResultFolder.path) 227 | #endif 228 | } 229 | 230 | let output = try await execGradle(in: workingDirectory, args: args, env: env, onExit: exitHandler) 231 | return (output, { try Self.parseTestResults(in: testResultFolder) }) 232 | } 233 | 234 | /// Executes `skiptool info` and returns the info dictionary. 235 | @available(macOS 13, macCatalyst 16, *) 236 | private static func execGradleInfo(gradleArgs: [String]) async throws -> [String: String] { 237 | // gradle --version will output an unstructued mess like this: 238 | /* 239 | ------------------------------------------------------------ 240 | Gradle 8.1.1 241 | ------------------------------------------------------------ 242 | Build time: 2023-04-21 12:31:26 UTC 243 | Revision: 1cf537a851c635c364a4214885f8b9798051175b 244 | Kotlin: 1.8.10 245 | Groovy: 3.0.15 246 | Ant: Apache Ant(TM) version 1.10.11 compiled on July 10 2021 247 | JVM: 17.0.7 (Eclipse Adoptium 17.0.7+7) 248 | OS: Mac OS X 13.3.1 x86_64 249 | */ 250 | 251 | let lines = try await Process.streamLines(command: gradleArgs + ["--version"], environment: ProcessInfo.processInfo.environment, includeStdErr: true, onExit: Process.expectZeroExitCode).reduce([]) { $0 + [$1] } 252 | //print("gradle info", lines.joined(separator: "\n")) 253 | var lineMap: [String: String] = [:] 254 | let gradlePrefix = "Gradle" 255 | for output in lines { 256 | let line = output.line 257 | // properties are "Key: Value", except the "Gradle" version. Ugh. 258 | if line.hasPrefix(gradlePrefix + " ") { 259 | lineMap[gradlePrefix] = line.dropFirst(gradlePrefix.count).trimmingCharacters(in: .whitespaces) 260 | } else { 261 | let parts = line.split(separator: ":", maxSplits: 2).map({ $0.trimmingCharacters(in: .whitespacesAndNewlines )}) 262 | if parts.count == 2 { 263 | lineMap[parts[0]] = parts[1] 264 | } 265 | } 266 | } 267 | 268 | return lineMap 269 | } 270 | 271 | /// Finds the given tool in the current process' `PATH`. 272 | private static func findGradle() throws -> URL { 273 | // add in standard Homebrew paths, in case they aren't in the user's PATH 274 | return try URL.findCommandInPath(toolName: "gradle", withAdditionalPaths: [ProcessInfo.homebrewRoot + "/bin"]) 275 | } 276 | 277 | /* The contents of the JUnit test case XML result files look a bit like this: 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | org.opentest4j.AssertionFailedError: THIS TEST CASE ALWAYS FAILS" 287 | type="org.opentest4j.AssertionFailedError"> 288 | org.opentest4j.AssertionFailedError: THIS TEST CASE ALWAYS FAILS 289 | at app//org.junit.jupiter.api.AssertionUtils.fail(AssertionUtils.java:38) 290 | … 291 | at app//worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | */ 300 | 301 | public struct TestSuite { 302 | // e.g.: "sample.project.LibraryTest" 303 | public var name: String 304 | public var tests: Int 305 | public var skipped: Int 306 | public var failures: Int 307 | public var errors: Int 308 | //public var timestamp: Date 309 | //public var hostname: String 310 | public var time: TimeInterval 311 | public var testCases: [TestCase] 312 | // public var properties: [String: String]? // TODO 313 | public var systemOut: String? 314 | public var systemErr: String? 315 | 316 | public init(name: String, tests: Int, skipped: Int, failures: Int, errors: Int, time: TimeInterval, testCases: [TestCase], systemOut: String?, systemErr: String?) { 317 | self.name = name 318 | self.tests = tests 319 | self.skipped = skipped 320 | self.failures = failures 321 | self.errors = errors 322 | self.time = time 323 | self.testCases = testCases 324 | self.systemOut = systemOut 325 | self.systemErr = systemErr 326 | } 327 | 328 | /// Loads the test suite information from the JUnit-compatible XML format. 329 | public static func parse(contentsOf url: URL) throws -> [TestSuite] { 330 | #if os(macOS) || os(Linux) || targetEnvironment(macCatalyst) 331 | let results = try XMLDocument(contentsOf: url) 332 | //print("parsed XML results:", results) 333 | 334 | guard let root = results.rootElement() else { 335 | throw GradleDriverError.missingProperty(url: url, propertyName: "root") 336 | } 337 | 338 | let testsuites: [XMLElement] 339 | 340 | if root.name == "testsuites" { 341 | // multiple top-level testsuites (single-file XUnit output style) 342 | testsuites = root.children?.compactMap({ $0 as? XMLElement }) ?? [] 343 | } else if root.name == "testsuite" { 344 | // single top-level testsuite (multiple-file JUnit output style) 345 | testsuites = [root] 346 | } else { 347 | throw GradleDriverError.missingProperty(url: url, propertyName: "testsuite") 348 | } 349 | 350 | var suites: [TestSuite] = [] 351 | 352 | for testsuite in testsuites { 353 | guard let testSuiteName = testsuite.attribute(forName: "name")?.stringValue else { 354 | throw GradleDriverError.missingProperty(url: url, propertyName: "name") 355 | } 356 | 357 | guard let tests = testsuite.attribute(forName: "tests")?.stringValue, 358 | let testCount = Int(tests) else { 359 | throw GradleDriverError.missingProperty(url: url, propertyName: "tests") 360 | } 361 | 362 | let skipCount: Int 363 | if let skips = testsuite.attribute(forName: "skipped")?.stringValue, let skipValue = Int(skips) { 364 | // JUnit 365 | skipCount = skipValue 366 | //} else if let skips = testsuite.children?.filter({ $0.name == "skipped" }).first { 367 | // Swift test xunit output does not handle skipped tests; it just looks like it passed 368 | // fixing this would invole updating the `func run(_ tests: [UnitTest]) throws -> [TestResult]` to include skip information at: 369 | // https://github.com/apple/swift-package-manager/blob/main/Sources/Commands/SwiftTestTool.swift#L764C5-L764C57 370 | //skipCount = 1 371 | } else { 372 | skipCount = 0 373 | } 374 | 375 | guard let failures = testsuite.attribute(forName: "failures")?.stringValue, 376 | let failureCount = Int(failures) else { 377 | throw GradleDriverError.missingProperty(url: url, propertyName: "failures") 378 | } 379 | 380 | guard let errors = testsuite.attribute(forName: "errors")?.stringValue, 381 | let errorCount = Int(errors) else { 382 | throw GradleDriverError.missingProperty(url: url, propertyName: "errors") 383 | } 384 | 385 | guard let time = testsuite.attribute(forName: "time")?.stringValue, 386 | let duration = TimeInterval(time) else { 387 | throw GradleDriverError.missingProperty(url: url, propertyName: "time") 388 | } 389 | 390 | var testCases: [TestCase] = [] 391 | 392 | func addTestCase(for element: XMLElement) throws { 393 | testCases.append(try TestCase(from: element, in: url)) 394 | } 395 | 396 | var systemOut = "" 397 | var systemErr = "" 398 | 399 | for childElement in testsuite.children?.compactMap({ $0 as? XMLElement }) ?? [] { 400 | switch childElement.name { 401 | case "testcase": 402 | try addTestCase(for: childElement) 403 | case "system-out": 404 | systemOut += childElement.stringValue ?? "" 405 | case "system-err": 406 | systemErr += childElement.stringValue ?? "" 407 | case "properties": 408 | break // TODO: figure out key/value format 409 | default: 410 | break // unrecognized key 411 | } 412 | } 413 | 414 | let suite = TestSuite(name: testSuiteName, tests: testCount, skipped: skipCount, failures: failureCount, errors: errorCount, time: duration, testCases: testCases, systemOut: systemOut.isEmpty ? nil : systemOut, systemErr: systemErr.isEmpty ? nil : systemErr) 415 | suites.append(suite) 416 | } 417 | 418 | return suites 419 | #else 420 | // no XMLDocument on iOS 421 | return [] 422 | #endif 423 | } 424 | } 425 | 426 | public struct TestCase { 427 | /// e.g.: someTestCaseThatAlwaysFails() 428 | public var name: String 429 | /// e.g.: sample.project.LibraryTest 430 | public var classname: String 431 | /// The amount of time it took the test case to run 432 | public var time: TimeInterval 433 | /// Whether the test was skipped by throwing `XCTSkip` (`org.junit.AssumptionViolatedException`) 434 | public var skipped: Bool 435 | /// The failures, if any 436 | public var failures: [TestFailure] 437 | 438 | public init(name: String, classname: String, time: TimeInterval, skipped: Bool, failures: [TestFailure]) { 439 | self.name = name 440 | self.classname = classname 441 | self.time = time 442 | self.skipped = skipped 443 | self.failures = failures 444 | } 445 | 446 | /// `classname.name` with any trailing "$" cruft trimmed off 447 | public var fullName: String { 448 | classname + "." + (name.split(separator: "$").first?.description ?? name) 449 | } 450 | 451 | #if os(macOS) || os(Linux) || targetEnvironment(macCatalyst) 452 | init(from element: XMLElement, in url: URL) throws { 453 | guard let testCaseName = element.attribute(forName: "name")?.stringValue else { 454 | throw GradleDriverError.missingProperty(url: url, propertyName: "name") 455 | } 456 | 457 | guard let classname = element.attribute(forName: "classname")?.stringValue else { 458 | throw GradleDriverError.missingProperty(url: url, propertyName: "classname") 459 | } 460 | 461 | guard let time = element.attribute(forName: "time")?.stringValue, 462 | let duration = TimeInterval(time) else { 463 | throw GradleDriverError.missingProperty(url: url, propertyName: "time") 464 | } 465 | 466 | self.name = testCaseName 467 | self.classname = classname 468 | self.time = duration 469 | 470 | var skipped = false 471 | for child in element.children ?? [] { 472 | if child.name == "skipped" { 473 | skipped = true 474 | } 475 | } 476 | 477 | self.skipped = skipped 478 | 479 | var testFailures: [TestFailure] = [] 480 | func addTestFailure(for element: XMLElement) throws { 481 | testFailures.append(try TestFailure(from: element, in: url)) 482 | } 483 | 484 | 485 | for childElement in element.children?.compactMap({ $0 as? XMLElement }) ?? [] { 486 | switch childElement.name { 487 | case "failure": try addTestFailure(for: childElement) 488 | default: break // unrecognized key 489 | } 490 | } 491 | 492 | self.failures = testFailures 493 | } 494 | #endif 495 | } 496 | 497 | public struct TestFailure { 498 | /// e.g.: "org.opentest4j.AssertionFailedError: THIS TEST CASE ALWAYS FAILS" 499 | public var message: String 500 | /// e.g.: "org.opentest4j.AssertionFailedError" 501 | public var type: String? 502 | /// e.g.: "at app//org.junit.jupiter.api.AssertionUtils.fail(AssertionUtils.java:38)"… 503 | public var contents: String? 504 | 505 | public init(message: String, type: String?, contents: String?) { 506 | self.message = message 507 | self.type = type 508 | self.contents = contents 509 | } 510 | 511 | #if os(macOS) || os(Linux) || targetEnvironment(macCatalyst) 512 | init(from element: XMLElement, in url: URL) throws { 513 | guard let message = element.attribute(forName: "message")?.stringValue else { 514 | throw GradleDriverError.missingProperty(url: url, propertyName: "message") 515 | } 516 | 517 | let type = element.attribute(forName: "type")?.stringValue 518 | 519 | let contents = element.stringValue 520 | 521 | self.message = message 522 | self.type = type 523 | self.contents = contents 524 | } 525 | #endif 526 | } 527 | 528 | private static func parseTestResults(in testFolder: URL?) throws -> [TestSuite] { 529 | guard let testFolder = testFolder else { 530 | return [] 531 | } 532 | let fm = FileManager.default 533 | if !fm.fileExists(atPath: testFolder.path) { 534 | // missing folder 535 | throw GradleDriverError("The expected test output folder did not exist, which may indicate that the gradle process encountered a build error or other issue. Missing folder: \(testFolder.path)") 536 | } 537 | 538 | func parseTestSuite(resultURL: URL) throws -> [TestSuite] { 539 | if try resultURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory != false { 540 | return [] 541 | } 542 | 543 | if resultURL.pathExtension != "xml" { 544 | print("skipping non .xml test file:", resultURL.path) 545 | return [] 546 | } 547 | 548 | return try TestSuite.parse(contentsOf: resultURL) 549 | } 550 | 551 | let dirs = try fm.contentsOfDirectory(at: testFolder, includingPropertiesForKeys: [.isDirectoryKey]) 552 | 553 | // check each subdir (e.g., "build/test-results/test" and "build/test-results/testDebugUnitTest/" and "build/test-results/testReleaseUnitTest/" 554 | let subdirs = try dirs.flatMap({ try fm.contentsOfDirectory(at: $0, includingPropertiesForKeys: [.isDirectoryKey]) }) 555 | 556 | return try Array(subdirs.compactMap(parseTestSuite).joined()) 557 | } 558 | } 559 | 560 | public enum GradleDriverError : Error, LocalizedError { 561 | public init(_ custom: String) { 562 | self = .custom(custom) 563 | } 564 | 565 | case custom(String) 566 | 567 | /// The command did not return any output 568 | case commandNoResult(String) 569 | 570 | /// The Gradle version could not be parsed from the output of `gradle --version` 571 | case noGradleVersion(gradle: URL, props: [String: String]) 572 | 573 | /// The Gradle version is unsupported 574 | case gradleVersionTooLow(gradle: URL, version: Version, minimum: Version) 575 | 576 | /// The Kotlin version could not be parsed from the output of `gradle --version` 577 | case noKotlinVersion(gradle: URL) 578 | 579 | /// The Gradle version is unsupported 580 | case kotlinVersionTooLow(gradle: URL, version: Version, minimum: Version) 581 | 582 | /// A property was expected to have been found in the given URL 583 | case missingProperty(url: URL, propertyName: String) 584 | 585 | public var description: String { 586 | errorDescription ?? "" 587 | } 588 | 589 | public var errorDescription: String? { 590 | switch self { 591 | case .custom(let string): 592 | return string 593 | case .commandNoResult(let string): 594 | return "The command «\(string)» returned no result." 595 | case .noGradleVersion(let gradle, let props): 596 | return "The installed Gradle version from \(gradle.path) could not be parsed from \(props). Install with the command: brew install gradle." 597 | case .gradleVersionTooLow(let gradle, let version, let minimum): 598 | return "The Gradle version \(version) is below the minimum supported version \(minimum) at \(gradle.path). Update with the command: brew upgrade gradle." 599 | case .noKotlinVersion(let gradle): 600 | return "The instaled Kotlin version could not be parsed at \(gradle.path). Install with the command: brew install gradle." 601 | case .kotlinVersionTooLow(let gradle, let version, let minimum): 602 | return "The instaled Kotlin version \(version) is below the minimum supported version \(minimum) at \(gradle.path). Update with the command: brew upgrade gradle." 603 | case .missingProperty(let url, let propertyName): 604 | return "The property name “\(propertyName)” could not be found in \(url.path)" 605 | } 606 | } 607 | } 608 | 609 | extension ProcessInfo { 610 | /// The root path for Homebrew on this macOS 611 | public static let homebrewRoot: String = { 612 | ProcessInfo.processInfo.environment["HOMEBREW_PREFIX"] 613 | ?? (ProcessInfo.isARM ? "/opt/homebrew" : "/usr/local") 614 | }() 615 | 616 | /// The current process environment along with the default paths to various tools set 617 | public var environmentWithDefaultToolPaths: [String: String] { 618 | var env = self.environment 619 | let ANDROID_HOME = "ANDROID_HOME" 620 | if (env[ANDROID_HOME] ?? "").isEmpty { 621 | #if os(macOS) 622 | env[ANDROID_HOME] = ("~/Library/Android/sdk" as NSString).expandingTildeInPath 623 | #elseif os(Windows) 624 | env[ANDROID_HOME] = ("~/AppData/Local/Android/Sdk" as NSString).expandingTildeInPath 625 | #elseif os(Linux) 626 | env[ANDROID_HOME] = ("~/Android/Sdk" as NSString).expandingTildeInPath 627 | #endif 628 | } 629 | 630 | let JAVA_HOME = "JAVA_HOME" 631 | if (env[JAVA_HOME] ?? "").isEmpty { 632 | #if os(macOS) 633 | // default if JAVA_HOME is unset: /opt/homebrew/opt/java -> ../Cellar/openjdk/21.0.1 634 | env[JAVA_HOME] = "\(Self.homebrewRoot)/opt/java" 635 | #endif 636 | } 637 | 638 | return env 639 | } 640 | } 641 | 642 | #endif 643 | -------------------------------------------------------------------------------- /Tests/SkipDriveTests/SkipDriveTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2023–2025 Skip 2 | // Licensed under the Open Software License version 3.0 3 | import XCTest 4 | import SkipDrive 5 | 6 | @available(macOS 13, iOS 16, tvOS 16, watchOS 8, *) 7 | class SkipCommandTests : XCTestCase { 8 | func testSkipVersion() async throws { 9 | var versionOut = try await skip("version").out 10 | if versionOut.hasSuffix(" (debug)") { 11 | versionOut.removeLast(" (debug)".count) 12 | } 13 | 14 | // this fails when building against a non-released version of skip 15 | //XCTAssertEqual("Skip version \(SkipDrive.skipVersion)", versionOut) 16 | XCTAssertTrue(versionOut.hasPrefix("Skip version "), "Version output should start with 'Skip version ': \(versionOut)") 17 | } 18 | 19 | func testSkipVersionJSON() async throws { 20 | let version = try await skip("version", "--json").out.parseJSONObject()["version"] as? String 21 | // this fails when building against a non-released version of skip 22 | // try await XCTAssertEqualAsync(SkipDrive.skipVersion, version) 23 | XCTAssertNotNil(version) 24 | } 25 | 26 | func testSkipWelcome() async throws { 27 | try await skip("welcome") 28 | } 29 | 30 | func testSkipWelcomeJSON() async throws { 31 | let welcome = try await skip("welcome", "--json", "--json-array").out.parseJSONArray() 32 | XCTAssertNotEqual(0, welcome.count, "Welcome message should not be empty") 33 | } 34 | 35 | func DISABLEDtestSkipDoctor() async throws { 36 | // run `skip doctor` with JSON array output and make sure we can parse the result 37 | let doctor = try await skip("doctor", "-jA", "-v").out.parseJSONMessages() 38 | XCTAssertGreaterThan(doctor.count, 5, "doctor output should have contained some lines") 39 | XCTAssertTrue(doctor.contains(where: { $0.hasPrefix("macOS version") }), "missing macOS version") 40 | XCTAssertTrue(doctor.contains(where: { $0.hasPrefix("Swift version") }), "missing Swift version") 41 | XCTAssertTrue(doctor.contains(where: { $0.hasPrefix("Xcode version") }), "missing Xcode version") 42 | XCTAssertTrue(doctor.contains(where: { $0.hasPrefix("Xcode tools") }), "missing Xcode tools") 43 | XCTAssertTrue(doctor.contains(where: { $0.hasPrefix("Homebrew version") }), "missing Homebrew version") 44 | XCTAssertTrue(doctor.contains(where: { $0.hasPrefix("Gradle version") }), "missing Gradle version") 45 | XCTAssertTrue(doctor.contains(where: { $0.hasPrefix("Java version") }), "missing Java version") 46 | XCTAssertTrue(doctor.contains(where: { $0.hasPrefix("Android Debug Bridge version") }), "missing Android Debug Bridge version") 47 | XCTAssertTrue(doctor.contains(where: { $0.hasPrefix("Android tools SDKs:") }), "missing Android SDKs") 48 | XCTAssertTrue(doctor.contains(where: { $0.hasPrefix("Check Skip Updates") }), "missing Check Skip Updates") 49 | } 50 | 51 | func testSkipDevices() async throws { 52 | #if os(Linux) 53 | throw XCTSkip("test not yet working on Linux") 54 | #endif 55 | 56 | let devices = try await skip("devices", "-jA").out.parseJSONArray() 57 | XCTAssertGreaterThanOrEqual(devices.count, 0) 58 | } 59 | 60 | func DISABLEDtestSkipCheckup() async throws { 61 | let checkup = try await skip("checkup", "-jA").out.parseJSONMessages() 62 | XCTAssertGreaterThan(checkup.count, 5, "checkup output should have contained some lines") 63 | } 64 | 65 | func testSkipCreate() async throws { 66 | #if os(Linux) 67 | throw XCTSkip("test not yet working on Linux") 68 | #endif 69 | 70 | let tempDir = try mktmp() 71 | let projectName = "hello-skip" 72 | let dir = tempDir + "/" + projectName + "/" 73 | let appName = "HelloSkip" 74 | let out = try await skip("init", "-jA", "-v", "--show-tree", "-d", dir, "--transpiled-app", "--appid", "com.company.HelloSkip", projectName, appName) 75 | let msgs = try out.out.parseJSONMessages() 76 | 77 | XCTAssertEqual("Initializing Skip application \(projectName)", msgs.first) 78 | 79 | let xcodeproj = "Darwin/" + appName + ".xcodeproj" 80 | let xcconfig = "Darwin/" + appName + ".xcconfig" 81 | for path in ["Package.swift", xcodeproj, xcconfig, "Sources", "Tests"] { 82 | XCTAssertTrue(FileManager.default.fileExists(atPath: dir + path), "missing file at: \(path)") 83 | } 84 | 85 | XCTAssertEqual(msgs.dropLast(2).last ?? "", """ 86 | . 87 | ├─ Android 88 | │ ├─ app 89 | │ │ ├─ build.gradle.kts 90 | │ │ ├─ proguard-rules.pro 91 | │ │ └─ src 92 | │ │ └─ main 93 | │ │ ├─ AndroidManifest.xml 94 | │ │ ├─ kotlin 95 | │ │ │ └─ Main.kt 96 | │ │ └─ res 97 | │ │ ├─ mipmap-anydpi 98 | │ │ │ └─ ic_launcher.xml 99 | │ │ ├─ mipmap-hdpi 100 | │ │ │ ├─ ic_launcher.png 101 | │ │ │ ├─ ic_launcher_background.png 102 | │ │ │ ├─ ic_launcher_foreground.png 103 | │ │ │ └─ ic_launcher_monochrome.png 104 | │ │ ├─ mipmap-mdpi 105 | │ │ │ ├─ ic_launcher.png 106 | │ │ │ ├─ ic_launcher_background.png 107 | │ │ │ ├─ ic_launcher_foreground.png 108 | │ │ │ └─ ic_launcher_monochrome.png 109 | │ │ ├─ mipmap-xhdpi 110 | │ │ │ ├─ ic_launcher.png 111 | │ │ │ ├─ ic_launcher_background.png 112 | │ │ │ ├─ ic_launcher_foreground.png 113 | │ │ │ └─ ic_launcher_monochrome.png 114 | │ │ ├─ mipmap-xxhdpi 115 | │ │ │ ├─ ic_launcher.png 116 | │ │ │ ├─ ic_launcher_background.png 117 | │ │ │ ├─ ic_launcher_foreground.png 118 | │ │ │ └─ ic_launcher_monochrome.png 119 | │ │ └─ mipmap-xxxhdpi 120 | │ │ ├─ ic_launcher.png 121 | │ │ ├─ ic_launcher_background.png 122 | │ │ ├─ ic_launcher_foreground.png 123 | │ │ └─ ic_launcher_monochrome.png 124 | │ ├─ fastlane 125 | │ │ ├─ Appfile 126 | │ │ ├─ Fastfile 127 | │ │ ├─ README.md 128 | │ │ └─ metadata 129 | │ │ └─ android 130 | │ │ └─ en-US 131 | │ │ ├─ full_description.txt 132 | │ │ ├─ short_description.txt 133 | │ │ └─ title.txt 134 | │ ├─ gradle 135 | │ │ └─ wrapper 136 | │ │ └─ gradle-wrapper.properties 137 | │ ├─ gradle.properties 138 | │ └─ settings.gradle.kts 139 | ├─ Darwin 140 | │ ├─ Assets.xcassets 141 | │ │ ├─ AccentColor.colorset 142 | │ │ │ └─ Contents.json 143 | │ │ ├─ AppIcon.appiconset 144 | │ │ │ ├─ AppIcon-20@2x.png 145 | │ │ │ ├─ AppIcon-20@2x~ipad.png 146 | │ │ │ ├─ AppIcon-20@3x.png 147 | │ │ │ ├─ AppIcon-20~ipad.png 148 | │ │ │ ├─ AppIcon-29.png 149 | │ │ │ ├─ AppIcon-29@2x.png 150 | │ │ │ ├─ AppIcon-29@2x~ipad.png 151 | │ │ │ ├─ AppIcon-29@3x.png 152 | │ │ │ ├─ AppIcon-29~ipad.png 153 | │ │ │ ├─ AppIcon-40@2x.png 154 | │ │ │ ├─ AppIcon-40@2x~ipad.png 155 | │ │ │ ├─ AppIcon-40@3x.png 156 | │ │ │ ├─ AppIcon-40~ipad.png 157 | │ │ │ ├─ AppIcon-83.5@2x~ipad.png 158 | │ │ │ ├─ AppIcon@2x.png 159 | │ │ │ ├─ AppIcon@2x~ipad.png 160 | │ │ │ ├─ AppIcon@3x.png 161 | │ │ │ ├─ AppIcon~ios-marketing.png 162 | │ │ │ ├─ AppIcon~ipad.png 163 | │ │ │ └─ Contents.json 164 | │ │ └─ Contents.json 165 | │ ├─ Entitlements.plist 166 | │ ├─ HelloSkip.xcconfig 167 | │ ├─ HelloSkip.xcodeproj 168 | │ │ ├─ project.pbxproj 169 | │ │ └─ xcshareddata 170 | │ │ └─ xcschemes 171 | │ │ └─ HelloSkip App.xcscheme 172 | │ ├─ Info.plist 173 | │ ├─ Sources 174 | │ │ └─ Main.swift 175 | │ └─ fastlane 176 | │ ├─ AppStore.xcconfig 177 | │ ├─ Appfile 178 | │ ├─ Deliverfile 179 | │ ├─ Fastfile 180 | │ ├─ README.md 181 | │ └─ metadata 182 | │ ├─ app_privacy_details.json 183 | │ ├─ en-US 184 | │ │ ├─ description.txt 185 | │ │ ├─ keywords.txt 186 | │ │ ├─ privacy_url.txt 187 | │ │ ├─ release_notes.txt 188 | │ │ ├─ software_url.txt 189 | │ │ ├─ subtitle.txt 190 | │ │ ├─ support_url.txt 191 | │ │ ├─ title.txt 192 | │ │ └─ version_whats_new.txt 193 | │ └─ rating.json 194 | ├─ Package.resolved 195 | ├─ Package.swift 196 | ├─ Project.xcworkspace 197 | │ └─ contents.xcworkspacedata 198 | ├─ README.md 199 | ├─ Skip.env 200 | ├─ Sources 201 | │ └─ HelloSkip 202 | │ ├─ ContentView.swift 203 | │ ├─ HelloSkipApp.swift 204 | │ ├─ Resources 205 | │ │ ├─ Localizable.xcstrings 206 | │ │ └─ Module.xcassets 207 | │ │ └─ Contents.json 208 | │ ├─ Skip 209 | │ │ └─ skip.yml 210 | │ └─ ViewModel.swift 211 | └─ Tests 212 | └─ HelloSkipTests 213 | ├─ HelloSkipTests.swift 214 | ├─ Resources 215 | │ └─ TestData.json 216 | ├─ Skip 217 | │ └─ skip.yml 218 | └─ XCSkipTests.swift 219 | 220 | """) 221 | } 222 | 223 | func testSkipIcon() async throws { 224 | #if os(Linux) 225 | throw XCTSkip("test not yet working on Linux") 226 | #endif 227 | 228 | let tempDir = try mktmp() 229 | let name = "demo-app" 230 | let dir = tempDir + "/" + name + "/" 231 | let appName = "Demo" 232 | // generate an app scaffold without building it, just so we can see what the file tree looks like 233 | let createApp = { try await self.skip("init", "-jA", "--show-tree", "--no-icon", "--no-build", "--zero", "--free", "-v", "-d", dir, "--transpiled-app", "--appid", "demo.app.App", name, appName) } 234 | var out = try await createApp() 235 | var msgs = try out.out.parseJSONMessages() 236 | 237 | XCTAssertEqual("Initializing Skip application \(name)", msgs.first) 238 | 239 | let xcodeproj = "Darwin/" + appName + ".xcodeproj" 240 | let xcconfig = "Darwin/" + appName + ".xcconfig" 241 | for path in ["Package.swift", xcodeproj, xcconfig, "Sources", "Tests"] { 242 | XCTAssertTrue(FileManager.default.fileExists(atPath: dir + path), "missing file at: \(path)") 243 | } 244 | 245 | // we first create an app with --no-icon 246 | XCTAssertEqual(msgs.dropLast(2).last ?? "", """ 247 | . 248 | ├─ Android 249 | │ ├─ app 250 | │ │ ├─ build.gradle.kts 251 | │ │ ├─ proguard-rules.pro 252 | │ │ └─ src 253 | │ │ └─ main 254 | │ │ ├─ AndroidManifest.xml 255 | │ │ └─ kotlin 256 | │ │ └─ Main.kt 257 | │ ├─ fastlane 258 | │ │ ├─ Appfile 259 | │ │ ├─ Fastfile 260 | │ │ ├─ README.md 261 | │ │ └─ metadata 262 | │ │ └─ android 263 | │ │ └─ en-US 264 | │ │ ├─ full_description.txt 265 | │ │ ├─ short_description.txt 266 | │ │ └─ title.txt 267 | │ ├─ gradle 268 | │ │ └─ wrapper 269 | │ │ └─ gradle-wrapper.properties 270 | │ ├─ gradle.properties 271 | │ └─ settings.gradle.kts 272 | ├─ Darwin 273 | │ ├─ Assets.xcassets 274 | │ │ ├─ AccentColor.colorset 275 | │ │ │ └─ Contents.json 276 | │ │ ├─ AppIcon.appiconset 277 | │ │ │ └─ Contents.json 278 | │ │ └─ Contents.json 279 | │ ├─ Demo.xcconfig 280 | │ ├─ Demo.xcodeproj 281 | │ │ ├─ project.pbxproj 282 | │ │ └─ xcshareddata 283 | │ │ └─ xcschemes 284 | │ │ └─ Demo App.xcscheme 285 | │ ├─ Entitlements.plist 286 | │ ├─ Info.plist 287 | │ ├─ Sources 288 | │ │ └─ Main.swift 289 | │ └─ fastlane 290 | │ ├─ AppStore.xcconfig 291 | │ ├─ Appfile 292 | │ ├─ Deliverfile 293 | │ ├─ Fastfile 294 | │ ├─ README.md 295 | │ └─ metadata 296 | │ ├─ app_privacy_details.json 297 | │ ├─ en-US 298 | │ │ ├─ description.txt 299 | │ │ ├─ keywords.txt 300 | │ │ ├─ privacy_url.txt 301 | │ │ ├─ release_notes.txt 302 | │ │ ├─ software_url.txt 303 | │ │ ├─ subtitle.txt 304 | │ │ ├─ support_url.txt 305 | │ │ ├─ title.txt 306 | │ │ └─ version_whats_new.txt 307 | │ └─ rating.json 308 | ├─ LICENSE.txt 309 | ├─ Package.swift 310 | ├─ Project.xcworkspace 311 | │ └─ contents.xcworkspacedata 312 | ├─ README.md 313 | ├─ Skip.env 314 | ├─ Sources 315 | │ └─ Demo 316 | │ ├─ ContentView.swift 317 | │ ├─ DemoApp.swift 318 | │ ├─ Resources 319 | │ │ ├─ Localizable.xcstrings 320 | │ │ └─ Module.xcassets 321 | │ │ └─ Contents.json 322 | │ ├─ Skip 323 | │ │ └─ skip.yml 324 | │ └─ ViewModel.swift 325 | └─ Tests 326 | └─ DemoTests 327 | ├─ DemoTests.swift 328 | ├─ Resources 329 | │ └─ TestData.json 330 | ├─ Skip 331 | │ └─ skip.yml 332 | └─ XCSkipTests.swift 333 | 334 | """) 335 | 336 | let pngData = Data(base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==") // little red dot 337 | 338 | // generating icons from a PNG will just create a single ic_launcher.png, but not separate foreground and background images 339 | let miniPNG = "\(dir)/empty.png" 340 | XCTAssertTrue(FileManager.default.createFile(atPath: miniPNG, contents: pngData)) 341 | 342 | // first create *only* for Dawrwin 343 | let _ = try await skip("icon", "-jA", "--darwin", "-d", dir, miniPNG) 344 | 345 | out = try await createApp() 346 | msgs = try out.out.parseJSONMessages() 347 | 348 | // we re-init in the same folder simply to get the file tree again (which `skip icon` doesn't have an option for) to validate that the icons were generated 349 | XCTAssertEqual(msgs.dropLast(2).last ?? "", """ 350 | . 351 | ├─ Android 352 | │ ├─ app 353 | │ │ ├─ build.gradle.kts 354 | │ │ ├─ proguard-rules.pro 355 | │ │ └─ src 356 | │ │ └─ main 357 | │ │ ├─ AndroidManifest.xml 358 | │ │ └─ kotlin 359 | │ │ └─ Main.kt 360 | │ ├─ fastlane 361 | │ │ ├─ Appfile 362 | │ │ ├─ Fastfile 363 | │ │ ├─ README.md 364 | │ │ └─ metadata 365 | │ │ └─ android 366 | │ │ └─ en-US 367 | │ │ ├─ full_description.txt 368 | │ │ ├─ short_description.txt 369 | │ │ └─ title.txt 370 | │ ├─ gradle 371 | │ │ └─ wrapper 372 | │ │ └─ gradle-wrapper.properties 373 | │ ├─ gradle.properties 374 | │ └─ settings.gradle.kts 375 | ├─ Darwin 376 | │ ├─ Assets.xcassets 377 | │ │ ├─ AccentColor.colorset 378 | │ │ │ └─ Contents.json 379 | │ │ ├─ AppIcon.appiconset 380 | │ │ │ ├─ AppIcon-20@2x.png 381 | │ │ │ ├─ AppIcon-20@2x~ipad.png 382 | │ │ │ ├─ AppIcon-20@3x.png 383 | │ │ │ ├─ AppIcon-20~ipad.png 384 | │ │ │ ├─ AppIcon-29.png 385 | │ │ │ ├─ AppIcon-29@2x.png 386 | │ │ │ ├─ AppIcon-29@2x~ipad.png 387 | │ │ │ ├─ AppIcon-29@3x.png 388 | │ │ │ ├─ AppIcon-29~ipad.png 389 | │ │ │ ├─ AppIcon-40@2x.png 390 | │ │ │ ├─ AppIcon-40@2x~ipad.png 391 | │ │ │ ├─ AppIcon-40@3x.png 392 | │ │ │ ├─ AppIcon-40~ipad.png 393 | │ │ │ ├─ AppIcon-83.5@2x~ipad.png 394 | │ │ │ ├─ AppIcon@2x.png 395 | │ │ │ ├─ AppIcon@2x~ipad.png 396 | │ │ │ ├─ AppIcon@3x.png 397 | │ │ │ ├─ AppIcon~ios-marketing.png 398 | │ │ │ ├─ AppIcon~ipad.png 399 | │ │ │ └─ Contents.json 400 | │ │ └─ Contents.json 401 | │ ├─ Demo.xcconfig 402 | │ ├─ Demo.xcodeproj 403 | │ │ ├─ project.pbxproj 404 | │ │ └─ xcshareddata 405 | │ │ └─ xcschemes 406 | │ │ └─ Demo App.xcscheme 407 | │ ├─ Entitlements.plist 408 | │ ├─ Info.plist 409 | │ ├─ Sources 410 | │ │ └─ Main.swift 411 | │ └─ fastlane 412 | │ ├─ AppStore.xcconfig 413 | │ ├─ Appfile 414 | │ ├─ Deliverfile 415 | │ ├─ Fastfile 416 | │ ├─ README.md 417 | │ └─ metadata 418 | │ ├─ app_privacy_details.json 419 | │ ├─ en-US 420 | │ │ ├─ description.txt 421 | │ │ ├─ keywords.txt 422 | │ │ ├─ privacy_url.txt 423 | │ │ ├─ release_notes.txt 424 | │ │ ├─ software_url.txt 425 | │ │ ├─ subtitle.txt 426 | │ │ ├─ support_url.txt 427 | │ │ ├─ title.txt 428 | │ │ └─ version_whats_new.txt 429 | │ └─ rating.json 430 | ├─ LICENSE.txt 431 | ├─ Package.swift 432 | ├─ Project.xcworkspace 433 | │ └─ contents.xcworkspacedata 434 | ├─ README.md 435 | ├─ Skip.env 436 | ├─ Sources 437 | │ └─ Demo 438 | │ ├─ ContentView.swift 439 | │ ├─ DemoApp.swift 440 | │ ├─ Resources 441 | │ │ ├─ Localizable.xcstrings 442 | │ │ └─ Module.xcassets 443 | │ │ └─ Contents.json 444 | │ ├─ Skip 445 | │ │ └─ skip.yml 446 | │ └─ ViewModel.swift 447 | ├─ Tests 448 | │ └─ DemoTests 449 | │ ├─ DemoTests.swift 450 | │ ├─ Resources 451 | │ │ └─ TestData.json 452 | │ ├─ Skip 453 | │ │ └─ skip.yml 454 | │ └─ XCSkipTests.swift 455 | └─ empty.png 456 | 457 | """) 458 | 459 | // now create for Darwin + Android 460 | let _ = try await skip("icon", "-jA", "-d", dir, miniPNG) 461 | 462 | out = try await createApp() 463 | msgs = try out.out.parseJSONMessages() 464 | 465 | // we re-init in the same folder simply to get the file tree again (which `skip icon` doesn't have an option for) to validate that the icons were generated 466 | XCTAssertEqual(msgs.dropLast(2).last ?? "", """ 467 | . 468 | ├─ Android 469 | │ ├─ app 470 | │ │ ├─ build.gradle.kts 471 | │ │ ├─ proguard-rules.pro 472 | │ │ └─ src 473 | │ │ └─ main 474 | │ │ ├─ AndroidManifest.xml 475 | │ │ ├─ kotlin 476 | │ │ │ └─ Main.kt 477 | │ │ └─ res 478 | │ │ ├─ mipmap-hdpi 479 | │ │ │ └─ ic_launcher.png 480 | │ │ ├─ mipmap-mdpi 481 | │ │ │ └─ ic_launcher.png 482 | │ │ ├─ mipmap-xhdpi 483 | │ │ │ └─ ic_launcher.png 484 | │ │ ├─ mipmap-xxhdpi 485 | │ │ │ └─ ic_launcher.png 486 | │ │ └─ mipmap-xxxhdpi 487 | │ │ └─ ic_launcher.png 488 | │ ├─ fastlane 489 | │ │ ├─ Appfile 490 | │ │ ├─ Fastfile 491 | │ │ ├─ README.md 492 | │ │ └─ metadata 493 | │ │ └─ android 494 | │ │ └─ en-US 495 | │ │ ├─ full_description.txt 496 | │ │ ├─ short_description.txt 497 | │ │ └─ title.txt 498 | │ ├─ gradle 499 | │ │ └─ wrapper 500 | │ │ └─ gradle-wrapper.properties 501 | │ ├─ gradle.properties 502 | │ └─ settings.gradle.kts 503 | ├─ Darwin 504 | │ ├─ Assets.xcassets 505 | │ │ ├─ AccentColor.colorset 506 | │ │ │ └─ Contents.json 507 | │ │ ├─ AppIcon.appiconset 508 | │ │ │ ├─ AppIcon-20@2x.png 509 | │ │ │ ├─ AppIcon-20@2x~ipad.png 510 | │ │ │ ├─ AppIcon-20@3x.png 511 | │ │ │ ├─ AppIcon-20~ipad.png 512 | │ │ │ ├─ AppIcon-29.png 513 | │ │ │ ├─ AppIcon-29@2x.png 514 | │ │ │ ├─ AppIcon-29@2x~ipad.png 515 | │ │ │ ├─ AppIcon-29@3x.png 516 | │ │ │ ├─ AppIcon-29~ipad.png 517 | │ │ │ ├─ AppIcon-40@2x.png 518 | │ │ │ ├─ AppIcon-40@2x~ipad.png 519 | │ │ │ ├─ AppIcon-40@3x.png 520 | │ │ │ ├─ AppIcon-40~ipad.png 521 | │ │ │ ├─ AppIcon-83.5@2x~ipad.png 522 | │ │ │ ├─ AppIcon@2x.png 523 | │ │ │ ├─ AppIcon@2x~ipad.png 524 | │ │ │ ├─ AppIcon@3x.png 525 | │ │ │ ├─ AppIcon~ios-marketing.png 526 | │ │ │ ├─ AppIcon~ipad.png 527 | │ │ │ └─ Contents.json 528 | │ │ └─ Contents.json 529 | │ ├─ Demo.xcconfig 530 | │ ├─ Demo.xcodeproj 531 | │ │ ├─ project.pbxproj 532 | │ │ └─ xcshareddata 533 | │ │ └─ xcschemes 534 | │ │ └─ Demo App.xcscheme 535 | │ ├─ Entitlements.plist 536 | │ ├─ Info.plist 537 | │ ├─ Sources 538 | │ │ └─ Main.swift 539 | │ └─ fastlane 540 | │ ├─ AppStore.xcconfig 541 | │ ├─ Appfile 542 | │ ├─ Deliverfile 543 | │ ├─ Fastfile 544 | │ ├─ README.md 545 | │ └─ metadata 546 | │ ├─ app_privacy_details.json 547 | │ ├─ en-US 548 | │ │ ├─ description.txt 549 | │ │ ├─ keywords.txt 550 | │ │ ├─ privacy_url.txt 551 | │ │ ├─ release_notes.txt 552 | │ │ ├─ software_url.txt 553 | │ │ ├─ subtitle.txt 554 | │ │ ├─ support_url.txt 555 | │ │ ├─ title.txt 556 | │ │ └─ version_whats_new.txt 557 | │ └─ rating.json 558 | ├─ LICENSE.txt 559 | ├─ Package.swift 560 | ├─ Project.xcworkspace 561 | │ └─ contents.xcworkspacedata 562 | ├─ README.md 563 | ├─ Skip.env 564 | ├─ Sources 565 | │ └─ Demo 566 | │ ├─ ContentView.swift 567 | │ ├─ DemoApp.swift 568 | │ ├─ Resources 569 | │ │ ├─ Localizable.xcstrings 570 | │ │ └─ Module.xcassets 571 | │ │ └─ Contents.json 572 | │ ├─ Skip 573 | │ │ └─ skip.yml 574 | │ └─ ViewModel.swift 575 | ├─ Tests 576 | │ └─ DemoTests 577 | │ ├─ DemoTests.swift 578 | │ ├─ Resources 579 | │ │ └─ TestData.json 580 | │ ├─ Skip 581 | │ │ └─ skip.yml 582 | │ └─ XCSkipTests.swift 583 | └─ empty.png 584 | 585 | """) 586 | 587 | // generating random icons will create two layers in the Android folder: ic_launcher_background.png and ic_launcher_foreground.png 588 | let _ = try await skip("icon", "-jA", "-d", dir, "--random-icon") 589 | 590 | out = try await createApp() 591 | msgs = try out.out.parseJSONMessages() 592 | 593 | // we re-init in the same folder simply to get the file tree again (which `skip icon` doesn't have an option for) to validate that the icons were generated 594 | XCTAssertEqual(msgs.dropLast(2).last ?? "", """ 595 | . 596 | ├─ Android 597 | │ ├─ app 598 | │ │ ├─ build.gradle.kts 599 | │ │ ├─ proguard-rules.pro 600 | │ │ └─ src 601 | │ │ └─ main 602 | │ │ ├─ AndroidManifest.xml 603 | │ │ ├─ kotlin 604 | │ │ │ └─ Main.kt 605 | │ │ └─ res 606 | │ │ ├─ mipmap-anydpi 607 | │ │ │ └─ ic_launcher.xml 608 | │ │ ├─ mipmap-hdpi 609 | │ │ │ ├─ ic_launcher.png 610 | │ │ │ ├─ ic_launcher_background.png 611 | │ │ │ ├─ ic_launcher_foreground.png 612 | │ │ │ └─ ic_launcher_monochrome.png 613 | │ │ ├─ mipmap-mdpi 614 | │ │ │ ├─ ic_launcher.png 615 | │ │ │ ├─ ic_launcher_background.png 616 | │ │ │ ├─ ic_launcher_foreground.png 617 | │ │ │ └─ ic_launcher_monochrome.png 618 | │ │ ├─ mipmap-xhdpi 619 | │ │ │ ├─ ic_launcher.png 620 | │ │ │ ├─ ic_launcher_background.png 621 | │ │ │ ├─ ic_launcher_foreground.png 622 | │ │ │ └─ ic_launcher_monochrome.png 623 | │ │ ├─ mipmap-xxhdpi 624 | │ │ │ ├─ ic_launcher.png 625 | │ │ │ ├─ ic_launcher_background.png 626 | │ │ │ ├─ ic_launcher_foreground.png 627 | │ │ │ └─ ic_launcher_monochrome.png 628 | │ │ └─ mipmap-xxxhdpi 629 | │ │ ├─ ic_launcher.png 630 | │ │ ├─ ic_launcher_background.png 631 | │ │ ├─ ic_launcher_foreground.png 632 | │ │ └─ ic_launcher_monochrome.png 633 | │ ├─ fastlane 634 | │ │ ├─ Appfile 635 | │ │ ├─ Fastfile 636 | │ │ ├─ README.md 637 | │ │ └─ metadata 638 | │ │ └─ android 639 | │ │ └─ en-US 640 | │ │ ├─ full_description.txt 641 | │ │ ├─ short_description.txt 642 | │ │ └─ title.txt 643 | │ ├─ gradle 644 | │ │ └─ wrapper 645 | │ │ └─ gradle-wrapper.properties 646 | │ ├─ gradle.properties 647 | │ └─ settings.gradle.kts 648 | ├─ Darwin 649 | │ ├─ Assets.xcassets 650 | │ │ ├─ AccentColor.colorset 651 | │ │ │ └─ Contents.json 652 | │ │ ├─ AppIcon.appiconset 653 | │ │ │ ├─ AppIcon-20@2x.png 654 | │ │ │ ├─ AppIcon-20@2x~ipad.png 655 | │ │ │ ├─ AppIcon-20@3x.png 656 | │ │ │ ├─ AppIcon-20~ipad.png 657 | │ │ │ ├─ AppIcon-29.png 658 | │ │ │ ├─ AppIcon-29@2x.png 659 | │ │ │ ├─ AppIcon-29@2x~ipad.png 660 | │ │ │ ├─ AppIcon-29@3x.png 661 | │ │ │ ├─ AppIcon-29~ipad.png 662 | │ │ │ ├─ AppIcon-40@2x.png 663 | │ │ │ ├─ AppIcon-40@2x~ipad.png 664 | │ │ │ ├─ AppIcon-40@3x.png 665 | │ │ │ ├─ AppIcon-40~ipad.png 666 | │ │ │ ├─ AppIcon-83.5@2x~ipad.png 667 | │ │ │ ├─ AppIcon@2x.png 668 | │ │ │ ├─ AppIcon@2x~ipad.png 669 | │ │ │ ├─ AppIcon@3x.png 670 | │ │ │ ├─ AppIcon~ios-marketing.png 671 | │ │ │ ├─ AppIcon~ipad.png 672 | │ │ │ └─ Contents.json 673 | │ │ └─ Contents.json 674 | │ ├─ Demo.xcconfig 675 | │ ├─ Demo.xcodeproj 676 | │ │ ├─ project.pbxproj 677 | │ │ └─ xcshareddata 678 | │ │ └─ xcschemes 679 | │ │ └─ Demo App.xcscheme 680 | │ ├─ Entitlements.plist 681 | │ ├─ Info.plist 682 | │ ├─ Sources 683 | │ │ └─ Main.swift 684 | │ └─ fastlane 685 | │ ├─ AppStore.xcconfig 686 | │ ├─ Appfile 687 | │ ├─ Deliverfile 688 | │ ├─ Fastfile 689 | │ ├─ README.md 690 | │ └─ metadata 691 | │ ├─ app_privacy_details.json 692 | │ ├─ en-US 693 | │ │ ├─ description.txt 694 | │ │ ├─ keywords.txt 695 | │ │ ├─ privacy_url.txt 696 | │ │ ├─ release_notes.txt 697 | │ │ ├─ software_url.txt 698 | │ │ ├─ subtitle.txt 699 | │ │ ├─ support_url.txt 700 | │ │ ├─ title.txt 701 | │ │ └─ version_whats_new.txt 702 | │ └─ rating.json 703 | ├─ LICENSE.txt 704 | ├─ Package.swift 705 | ├─ Project.xcworkspace 706 | │ └─ contents.xcworkspacedata 707 | ├─ README.md 708 | ├─ Skip.env 709 | ├─ Sources 710 | │ └─ Demo 711 | │ ├─ ContentView.swift 712 | │ ├─ DemoApp.swift 713 | │ ├─ Resources 714 | │ │ ├─ Localizable.xcstrings 715 | │ │ └─ Module.xcassets 716 | │ │ └─ Contents.json 717 | │ ├─ Skip 718 | │ │ └─ skip.yml 719 | │ └─ ViewModel.swift 720 | ├─ Tests 721 | │ └─ DemoTests 722 | │ ├─ DemoTests.swift 723 | │ ├─ Resources 724 | │ │ └─ TestData.json 725 | │ ├─ Skip 726 | │ │ └─ skip.yml 727 | │ └─ XCSkipTests.swift 728 | └─ empty.png 729 | 730 | """) 731 | 732 | } 733 | 734 | func testSkipFair() async throws { 735 | #if os(Linux) 736 | throw XCTSkip("test not yet working on Linux") 737 | #endif 738 | 739 | let tempDir = try mktmp() 740 | let projectName = "hello-skip" 741 | let dir = tempDir + "/" + projectName + "/" 742 | let appName = "HelloSkip" 743 | let modelName = "HelloSkipModel" 744 | let out = try await skip("init", "-jA", "-v", "--show-tree", "-d", dir, "--transpiled-app", "--appfair", projectName, appName, modelName) 745 | let msgs = try out.out.parseJSONMessages() 746 | 747 | XCTAssertEqual("Initializing Skip application \(projectName)", msgs.first) 748 | 749 | let xcodeproj = "Darwin/" + appName + ".xcodeproj" 750 | let xcconfig = "Darwin/" + appName + ".xcconfig" 751 | for path in ["Package.swift", xcodeproj, xcconfig, "Sources", "Tests"] { 752 | XCTAssertTrue(FileManager.default.fileExists(atPath: dir + path), "missing file at: \(path)") 753 | } 754 | 755 | XCTAssertEqual(msgs.dropLast(2).last ?? "", """ 756 | . 757 | ├─ Android 758 | │ ├─ app 759 | │ │ ├─ build.gradle.kts 760 | │ │ ├─ proguard-rules.pro 761 | │ │ └─ src 762 | │ │ └─ main 763 | │ │ ├─ AndroidManifest.xml 764 | │ │ ├─ kotlin 765 | │ │ │ └─ Main.kt 766 | │ │ └─ res 767 | │ │ ├─ mipmap-anydpi 768 | │ │ │ └─ ic_launcher.xml 769 | │ │ ├─ mipmap-hdpi 770 | │ │ │ ├─ ic_launcher.png 771 | │ │ │ ├─ ic_launcher_background.png 772 | │ │ │ ├─ ic_launcher_foreground.png 773 | │ │ │ └─ ic_launcher_monochrome.png 774 | │ │ ├─ mipmap-mdpi 775 | │ │ │ ├─ ic_launcher.png 776 | │ │ │ ├─ ic_launcher_background.png 777 | │ │ │ ├─ ic_launcher_foreground.png 778 | │ │ │ └─ ic_launcher_monochrome.png 779 | │ │ ├─ mipmap-xhdpi 780 | │ │ │ ├─ ic_launcher.png 781 | │ │ │ ├─ ic_launcher_background.png 782 | │ │ │ ├─ ic_launcher_foreground.png 783 | │ │ │ └─ ic_launcher_monochrome.png 784 | │ │ ├─ mipmap-xxhdpi 785 | │ │ │ ├─ ic_launcher.png 786 | │ │ │ ├─ ic_launcher_background.png 787 | │ │ │ ├─ ic_launcher_foreground.png 788 | │ │ │ └─ ic_launcher_monochrome.png 789 | │ │ └─ mipmap-xxxhdpi 790 | │ │ ├─ ic_launcher.png 791 | │ │ ├─ ic_launcher_background.png 792 | │ │ ├─ ic_launcher_foreground.png 793 | │ │ └─ ic_launcher_monochrome.png 794 | │ ├─ fastlane 795 | │ │ ├─ Appfile 796 | │ │ ├─ Fastfile 797 | │ │ ├─ README.md 798 | │ │ └─ metadata 799 | │ │ └─ android 800 | │ │ └─ en-US 801 | │ │ ├─ full_description.txt 802 | │ │ ├─ short_description.txt 803 | │ │ └─ title.txt 804 | │ ├─ gradle 805 | │ │ └─ wrapper 806 | │ │ └─ gradle-wrapper.properties 807 | │ ├─ gradle.properties 808 | │ └─ settings.gradle.kts 809 | ├─ Darwin 810 | │ ├─ Assets.xcassets 811 | │ │ ├─ AccentColor.colorset 812 | │ │ │ └─ Contents.json 813 | │ │ ├─ AppIcon.appiconset 814 | │ │ │ ├─ AppIcon-20@2x.png 815 | │ │ │ ├─ AppIcon-20@2x~ipad.png 816 | │ │ │ ├─ AppIcon-20@3x.png 817 | │ │ │ ├─ AppIcon-20~ipad.png 818 | │ │ │ ├─ AppIcon-29.png 819 | │ │ │ ├─ AppIcon-29@2x.png 820 | │ │ │ ├─ AppIcon-29@2x~ipad.png 821 | │ │ │ ├─ AppIcon-29@3x.png 822 | │ │ │ ├─ AppIcon-29~ipad.png 823 | │ │ │ ├─ AppIcon-40@2x.png 824 | │ │ │ ├─ AppIcon-40@2x~ipad.png 825 | │ │ │ ├─ AppIcon-40@3x.png 826 | │ │ │ ├─ AppIcon-40~ipad.png 827 | │ │ │ ├─ AppIcon-83.5@2x~ipad.png 828 | │ │ │ ├─ AppIcon@2x.png 829 | │ │ │ ├─ AppIcon@2x~ipad.png 830 | │ │ │ ├─ AppIcon@3x.png 831 | │ │ │ ├─ AppIcon~ios-marketing.png 832 | │ │ │ ├─ AppIcon~ipad.png 833 | │ │ │ └─ Contents.json 834 | │ │ └─ Contents.json 835 | │ ├─ Entitlements.plist 836 | │ ├─ HelloSkip.xcconfig 837 | │ ├─ HelloSkip.xcodeproj 838 | │ │ ├─ project.pbxproj 839 | │ │ └─ xcshareddata 840 | │ │ └─ xcschemes 841 | │ │ └─ HelloSkip App.xcscheme 842 | │ ├─ Info.plist 843 | │ ├─ Sources 844 | │ │ └─ Main.swift 845 | │ └─ fastlane 846 | │ ├─ AppStore.xcconfig 847 | │ ├─ Appfile 848 | │ ├─ Deliverfile 849 | │ ├─ Fastfile 850 | │ ├─ README.md 851 | │ └─ metadata 852 | │ ├─ app_privacy_details.json 853 | │ ├─ en-US 854 | │ │ ├─ description.txt 855 | │ │ ├─ keywords.txt 856 | │ │ ├─ privacy_url.txt 857 | │ │ ├─ release_notes.txt 858 | │ │ ├─ software_url.txt 859 | │ │ ├─ subtitle.txt 860 | │ │ ├─ support_url.txt 861 | │ │ ├─ title.txt 862 | │ │ └─ version_whats_new.txt 863 | │ └─ rating.json 864 | ├─ LICENSE.txt 865 | ├─ Package.resolved 866 | ├─ Package.swift 867 | ├─ Project.xcworkspace 868 | │ └─ contents.xcworkspacedata 869 | ├─ README.md 870 | ├─ Skip.env 871 | ├─ Sources 872 | │ ├─ HelloSkip 873 | │ │ ├─ ContentView.swift 874 | │ │ ├─ HelloSkipApp.swift 875 | │ │ ├─ Resources 876 | │ │ │ ├─ Localizable.xcstrings 877 | │ │ │ └─ Module.xcassets 878 | │ │ │ └─ Contents.json 879 | │ │ └─ Skip 880 | │ │ └─ skip.yml 881 | │ └─ HelloSkipModel 882 | │ ├─ Resources 883 | │ │ └─ Localizable.xcstrings 884 | │ ├─ Skip 885 | │ │ └─ skip.yml 886 | │ └─ ViewModel.swift 887 | └─ Tests 888 | ├─ HelloSkipModelTests 889 | │ ├─ HelloSkipModelTests.swift 890 | │ ├─ Resources 891 | │ │ └─ TestData.json 892 | │ ├─ Skip 893 | │ │ └─ skip.yml 894 | │ └─ XCSkipTests.swift 895 | └─ HelloSkipTests 896 | ├─ HelloSkipTests.swift 897 | ├─ Resources 898 | │ └─ TestData.json 899 | ├─ Skip 900 | │ └─ skip.yml 901 | └─ XCSkipTests.swift 902 | 903 | """) 904 | 905 | // make sure we can export it 906 | try await checkExport(app: true, moduleName: appName, dir: dir) 907 | } 908 | 909 | func testSkipInit() async throws { 910 | #if os(Linux) 911 | throw XCTSkip("test not yet working on Linux") 912 | #endif 913 | 914 | let tempDir = try mktmp() 915 | let name = "cool-lib" 916 | let dir = tempDir + "/" + name + "/" 917 | let out = try await skip("init", "-jA", "-v", "--show-tree", "--transpiled-model", "-d", dir, name, "CoolA", "CoolB", "CoolC", "CoolD", "CoolE") 918 | let msgs = try out.out.parseJSONMessages() 919 | 920 | XCTAssertEqual("Initializing Skip library \(name)", msgs.first) 921 | 922 | for path in ["Package.swift", "Sources/CoolA", "Sources/CoolA", "Sources/CoolE", "Tests", "Tests/CoolATests/Skip/skip.yml"] { 923 | XCTAssertTrue(FileManager.default.fileExists(atPath: dir + path), "missing file at: \(path)") 924 | } 925 | 926 | let project = try await loadProjectPackage(dir) 927 | XCTAssertEqual(name, project.name) 928 | 929 | XCTAssertEqual(msgs.dropLast(2).last ?? "", """ 930 | . 931 | ├─ Package.resolved 932 | ├─ Package.swift 933 | ├─ README.md 934 | ├─ Sources 935 | │ ├─ CoolA 936 | │ │ ├─ CoolA.swift 937 | │ │ ├─ Resources 938 | │ │ │ └─ Localizable.xcstrings 939 | │ │ └─ Skip 940 | │ │ └─ skip.yml 941 | │ ├─ CoolB 942 | │ │ ├─ CoolB.swift 943 | │ │ ├─ Resources 944 | │ │ │ └─ Localizable.xcstrings 945 | │ │ └─ Skip 946 | │ │ └─ skip.yml 947 | │ ├─ CoolC 948 | │ │ ├─ CoolC.swift 949 | │ │ ├─ Resources 950 | │ │ │ └─ Localizable.xcstrings 951 | │ │ └─ Skip 952 | │ │ └─ skip.yml 953 | │ ├─ CoolD 954 | │ │ ├─ CoolD.swift 955 | │ │ ├─ Resources 956 | │ │ │ └─ Localizable.xcstrings 957 | │ │ └─ Skip 958 | │ │ └─ skip.yml 959 | │ └─ CoolE 960 | │ ├─ CoolE.swift 961 | │ ├─ Resources 962 | │ │ └─ Localizable.xcstrings 963 | │ └─ Skip 964 | │ └─ skip.yml 965 | └─ Tests 966 | ├─ CoolATests 967 | │ ├─ CoolATests.swift 968 | │ ├─ Resources 969 | │ │ └─ TestData.json 970 | │ ├─ Skip 971 | │ │ └─ skip.yml 972 | │ └─ XCSkipTests.swift 973 | ├─ CoolBTests 974 | │ ├─ CoolBTests.swift 975 | │ ├─ Resources 976 | │ │ └─ TestData.json 977 | │ ├─ Skip 978 | │ │ └─ skip.yml 979 | │ └─ XCSkipTests.swift 980 | ├─ CoolCTests 981 | │ ├─ CoolCTests.swift 982 | │ ├─ Resources 983 | │ │ └─ TestData.json 984 | │ ├─ Skip 985 | │ │ └─ skip.yml 986 | │ └─ XCSkipTests.swift 987 | ├─ CoolDTests 988 | │ ├─ CoolDTests.swift 989 | │ ├─ Resources 990 | │ │ └─ TestData.json 991 | │ ├─ Skip 992 | │ │ └─ skip.yml 993 | │ └─ XCSkipTests.swift 994 | └─ CoolETests 995 | ├─ CoolETests.swift 996 | ├─ Resources 997 | │ └─ TestData.json 998 | ├─ Skip 999 | │ └─ skip.yml 1000 | └─ XCSkipTests.swift 1001 | 1002 | """) 1003 | } 1004 | 1005 | func testSkipInitGit() async throws { 1006 | #if os(Linux) 1007 | throw XCTSkip("test not yet working on Linux") 1008 | #endif 1009 | 1010 | let tempDir = try mktmp() 1011 | let name = "neat-lib" 1012 | let dir = tempDir + "/" + name + "/" 1013 | let out = try await skip("init", "-jA", "-v", "--git-repo", "--show-tree", "--transpiled-model", "-d", dir, name, "NeatA") 1014 | let msgs = try out.out.parseJSONMessages() 1015 | 1016 | XCTAssertEqual("Initializing Skip library \(name)", msgs.first) 1017 | 1018 | for path in [ 1019 | "Package.swift", 1020 | "Sources/NeatA", 1021 | "Tests", 1022 | "Tests/NeatATests/Skip/skip.yml", 1023 | ".gitignore", 1024 | ".git/config", 1025 | ] { 1026 | XCTAssertTrue(FileManager.default.fileExists(atPath: dir + path), "missing file at: \(path)") 1027 | } 1028 | 1029 | let project = try await loadProjectPackage(dir) 1030 | XCTAssertEqual(name, project.name) 1031 | 1032 | XCTAssertEqual(msgs.dropLast(2).last ?? "", """ 1033 | . 1034 | ├─ Package.resolved 1035 | ├─ Package.swift 1036 | ├─ README.md 1037 | ├─ Sources 1038 | │ └─ NeatA 1039 | │ ├─ NeatA.swift 1040 | │ ├─ Resources 1041 | │ │ └─ Localizable.xcstrings 1042 | │ └─ Skip 1043 | │ └─ skip.yml 1044 | └─ Tests 1045 | └─ NeatATests 1046 | ├─ NeatATests.swift 1047 | ├─ Resources 1048 | │ └─ TestData.json 1049 | ├─ Skip 1050 | │ └─ skip.yml 1051 | └─ XCSkipTests.swift 1052 | 1053 | """) 1054 | } 1055 | 1056 | func testSkipExportFramework() async throws { 1057 | #if os(Linux) 1058 | throw XCTSkip("test not yet working on Linux") 1059 | #endif 1060 | 1061 | let tempDir = try mktmp() 1062 | let name = "demo-framework" 1063 | let dir = tempDir + "/" + name + "/" 1064 | let moduleName = "DemoFramework" 1065 | let out = try await skip("init", "-jA", "--show-tree", "-v", "--transpiled-model", "-d", dir, name, moduleName) 1066 | let msgs = try out.out.parseJSONMessages() 1067 | 1068 | XCTAssertEqual("Initializing Skip library \(name)", msgs.first) 1069 | 1070 | for path in ["Package.swift", "Sources/DemoFramework", "Tests", "Tests/DemoFrameworkTests/Skip/skip.yml"] { 1071 | XCTAssertTrue(FileManager.default.fileExists(atPath: dir + path), "missing file at: \(path)") 1072 | } 1073 | 1074 | try await checkExport(app: false, moduleName: moduleName, dir: dir) 1075 | } 1076 | 1077 | func testSkipExportApp() async throws { 1078 | #if os(Linux) 1079 | throw XCTSkip("test not yet working on Linux") 1080 | #endif 1081 | 1082 | let tempDir = try mktmp() 1083 | let name = "demo-app" 1084 | let dir = tempDir + "/" + name + "/" 1085 | let appName = "Demo" 1086 | let out = try await skip("init", "-jA", "--show-tree", "--zero", "--free", "-v", "-d", dir, "--transpiled-app", "--appid", "demo.app.App", name, appName) 1087 | let msgs = try out.out.parseJSONMessages() 1088 | 1089 | XCTAssertEqual("Initializing Skip application \(name)", msgs.first) 1090 | 1091 | for path in ["Package.swift", "Sources/Demo", "Tests", "Tests/DemoTests/Skip/skip.yml"] { 1092 | XCTAssertTrue(FileManager.default.fileExists(atPath: dir + path), "missing file at: \(path)") 1093 | } 1094 | 1095 | try await checkExport(app: true, moduleName: appName, dir: dir) 1096 | } 1097 | 1098 | /// This works, but it is very slow, and also required installing the Android toolchain on the Skip CI host 1099 | func DISABLEDtestSkipExportAppNative() async throws { 1100 | let tempDir = try mktmp() 1101 | let name = "demo-app-native" 1102 | let dir = tempDir + "/" + name + "/" 1103 | // create a three-module native app 1104 | let out = try await skip("init", "-jA", "--show-tree", "--free", "-v", "-d", dir, "--native-app", "--appid", "demo.app.App", name, "Demo", "DemoModel", "DemoLogic") 1105 | let msgs = try out.out.parseJSONMessages() 1106 | 1107 | XCTAssertEqual("Initializing Skip application \(name)", msgs.first) 1108 | 1109 | for path in ["Package.swift", "Sources/Demo"] { 1110 | XCTAssertTrue(FileManager.default.fileExists(atPath: dir + path), "missing file at: \(path)") 1111 | } 1112 | 1113 | let project = try await loadProjectPackage(dir) 1114 | XCTAssertEqual(name, project.name) 1115 | 1116 | let exportPath = try mktmp() 1117 | let exported = try await skip("export", "-jA", "-v", "--show-tree", "--project", dir, "-d", exportPath) 1118 | let exportedJSON = try exported.out.parseJSONMessages() 1119 | let fileTree = exportedJSON.dropLast(1).last ?? "" 1120 | 1121 | XCTAssertTrue(fileTree.contains("Demo-debug.apk"), "missing expected Demo-debug.apk in \(fileTree)") 1122 | XCTAssertTrue(fileTree.contains("Demo-release.apk"), "missing expected Demo-release.apk in \(fileTree)") 1123 | XCTAssertTrue(fileTree.contains("Demo-debug.aab"), "missing expected Demo-debug.aab in \(fileTree)") 1124 | XCTAssertTrue(fileTree.contains("Demo-release.aab"), "missing expected Demo-release.aab in \(fileTree)") 1125 | XCTAssertTrue(fileTree.contains("Demo-debug.ipa"), "missing expected Demo-debug.ipa in \(fileTree)") 1126 | XCTAssertTrue(fileTree.contains("Demo-release.ipa"), "missing expected Demo-release.ipa in \(fileTree)") 1127 | XCTAssertTrue(fileTree.contains("Demo-debug.xcarchive.zip"), "missing expected Demo-debug.xcarchive.zip in \(fileTree)") 1128 | XCTAssertTrue(fileTree.contains("Demo-release.xcarchive.zip"), "missing expected Demo-release.xcarchive.zip in \(fileTree)") 1129 | XCTAssertTrue(fileTree.contains("Demo-project.zip"), "missing expected Demo-project.zip in \(fileTree)") 1130 | } 1131 | 1132 | func DISABLEDtestSkipTestReport() async throws { 1133 | // hangs when running from the CLI 1134 | let xunit = try mktmpFile(contents: Data(xunitResults.utf8)) 1135 | let tempDir = try mktmp() 1136 | let junit = tempDir + "/" + "testDebugUnitTest" 1137 | try FileManager.default.createDirectory(atPath: junit, withIntermediateDirectories: true) 1138 | try Data(junitResults.utf8).write(to: URL(fileURLWithPath: junit + "/TEST-skip.zip.SkipZipTests.xml")) 1139 | 1140 | // .build/plugins/outputs/skip-zip/SkipZipTests/skipstone/SkipZip/.build/SkipZip/test-results/testDebugUnitTest/TEST-skip.zip.SkipZipTests.xml 1141 | let report = try await skip("test", "--configuration", "debug", "--test", "--max-column-length", "15", "--xunit", xunit, "--junit", junit) 1142 | XCTAssertEqual(report.out, """ 1143 | | Test | Case | Swift | Kotlin | 1144 | | ------------ | --------------- | ----- | ------ | 1145 | | SkipZipTests | testArchive | PASS | SKIP | 1146 | | SkipZipTests | testDeflateInfl | PASS | PASS | 1147 | | SkipZipTests | testMissingTest | PASS | ???? | 1148 | | | | 100% | 33% | 1149 | """) 1150 | } 1151 | 1152 | func checkExport(app: Bool, moduleName: String, dir: String, debug: Bool = true, release: Bool = true) async throws { 1153 | let project = try await loadProjectPackage(dir) 1154 | XCTAssertNotEqual(0, project.products.count) 1155 | //XCTAssertEqual(appName, project.name) 1156 | 1157 | let exportPath = try mktmp() 1158 | let config = debug && !release ? "--debug" : release && !debug ? "--release" : "-v" 1159 | 1160 | let exported = try await skip("export", "-jA", "-v", "--show-tree", config, "--project", dir, "-d", exportPath) 1161 | let exportedJSON = try exported.out.parseJSONMessages() 1162 | let fileTree = exportedJSON.dropLast(1).last ?? "" 1163 | 1164 | func checkArtifacts(type: String) throws { 1165 | if app { 1166 | XCTAssertTrue(fileTree.contains("\(moduleName)-\(type).apk"), "missing expected \(moduleName)-\(type).apk in \(fileTree)") 1167 | XCTAssertTrue(fileTree.contains("\(moduleName)-\(type).aab"), "missing expected \(moduleName)-\(type).aab in \(fileTree)") 1168 | XCTAssertTrue(fileTree.contains("\(moduleName)-\(type).ipa"), "missing expected \(moduleName)-\(type).ipa in \(fileTree)") 1169 | XCTAssertTrue(fileTree.contains("\(moduleName)-\(type).xcarchive.zip"), "missing expected \(moduleName)-\(type).xcarchive.zip in \(fileTree)") 1170 | } else { 1171 | XCTAssertTrue(fileTree.contains("\(moduleName)-\(type).aar"), "missing expected \(moduleName)-\(type).aar in \(fileTree)") 1172 | XCTAssertTrue(fileTree.contains("SkipFoundation-\(type).aar"), "missing expected SkipFoundation-\(type).aar in \(fileTree)") 1173 | } 1174 | } 1175 | 1176 | if debug { 1177 | try checkArtifacts(type: "debug") 1178 | } 1179 | 1180 | if release { 1181 | try checkArtifacts(type: "release") 1182 | } 1183 | 1184 | XCTAssertTrue(fileTree.contains("\(moduleName)-project.zip"), "missing expected \(moduleName)-project.zip in \(fileTree)") 1185 | } 1186 | 1187 | /// Runs the tool with the given arguments, returning the entire output string as well as a function to parse it to `JSON` 1188 | @discardableResult func skip(checkError: Bool = true, printOutput: Bool = true, _ args: String...) async throws -> (out: String, err: String) { 1189 | // turn "-[SkipCommandTests testSomeTest]" into "testSomeTest" 1190 | let testName = testRun?.test.name.split(separator: " ").last?.trimmingCharacters(in: CharacterSet(charactersIn: "[]")) ?? "TEST" 1191 | 1192 | // the default SPM location of the current skip CLI for testing 1193 | var skiptools = [ 1194 | ".build/artifacts/skip/skip/skip.artifactbundle/macos/skip", 1195 | ".build/plugins/tools/debug/skip", // the SKIPLOCAL build path macOS 14- 1196 | ".build/debug/skip", // the SKIPLOCAL build path macOS 15+ 1197 | ] 1198 | 1199 | // when running tests from Xcode, we need to use the tool download folder, which seems to be placed in one of the environment property `__XCODE_BUILT_PRODUCTS_DIR_PATHS`, so check those folders and override if skip is found 1200 | for checkFolder in (ProcessInfo.processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] ?? "").split(separator: ":") { 1201 | let xcodeSkipPath = checkFolder.description + "/skip" 1202 | if FileManager.default.isExecutableFile(atPath: xcodeSkipPath) { 1203 | skiptools.append(xcodeSkipPath) 1204 | } 1205 | } 1206 | 1207 | struct SkipLaunchError : LocalizedError { var errorDescription: String? } 1208 | 1209 | guard let skiptool = skiptools.last(where: FileManager.default.isExecutableFile(atPath:)) else { 1210 | throw SkipLaunchError(errorDescription: "Could not locate the skip executable in any of the paths: \(skiptools.joined(separator: " "))") 1211 | } 1212 | 1213 | let cmd = [skiptool] + args 1214 | if printOutput { 1215 | print("running: \(cmd.joined(separator: " "))") 1216 | } 1217 | 1218 | var outputLines: [AsyncLineOutput.Element] = [] 1219 | var result: ProcessResult? = nil 1220 | var env = ProcessInfo.processInfo.environment 1221 | env["TERM"] = "dumb" // override TERM to prevent skip from using ANSI colors or progress animations 1222 | env["SKIPLOCAL"] = nil // need to clear the sub-process SKIPLOCAL, since remote dependencies cannot use local paths (https://forums.swift.org/t/unable-to-integrate-a-remote-package-that-has-local-packages/53146/17) 1223 | 1224 | for try await outputLine in Process.streamLines(command: cmd, environment: env, includeStdErr: true, onExit: { result = $0 }) { 1225 | if printOutput { 1226 | print("\(testName) [\(outputLine.err ? "stderr" : "stdout")]> \(outputLine.line)") 1227 | } 1228 | outputLines.append(outputLine) 1229 | } 1230 | 1231 | guard let result = result else { 1232 | throw SkipLaunchError(errorDescription: "command did not exit: \(cmd)") 1233 | } 1234 | 1235 | let stdoutString = outputLines.filter({ $0.err == false }).map(\.line).joined(separator: "\n") 1236 | let stderrString = outputLines.filter({ $0.err == true }).map(\.line).joined(separator: "\n") 1237 | 1238 | // Throw if there was a non zero termination. 1239 | guard result.exitStatus == .terminated(code: 0) else { 1240 | XCTFail("error running command: \(cmd)\nenvironment:\n \(env.sorted(by: { $0.key < $1.key }).map({ $0.key + ": " + $0.value }).joined(separator: "\n "))\nSTDERR: \(stderrString)") 1241 | throw ProcessResult.Error.nonZeroExit(result) 1242 | } 1243 | 1244 | return (out: stdoutString, err: stderrString) 1245 | } 1246 | 1247 | } 1248 | 1249 | /// A JSON object 1250 | typealias JSONObject = [String: Any] 1251 | 1252 | private extension String { 1253 | /// Attempts to parse the given String as a JSON object 1254 | func parseJSONObject(file: StaticString = #file, line: UInt = #line) throws -> JSONObject { 1255 | do { 1256 | let json = try JSONSerialization.jsonObject(with: Data(utf8), options: []) 1257 | if let obj = json as? JSONObject { 1258 | return obj 1259 | } else { 1260 | struct CannotParseJSONIntoObject : LocalizedError { var errorDescription: String? } 1261 | throw CannotParseJSONIntoObject(errorDescription: "JSON object was of wrong type: \(type(of: json))") 1262 | } 1263 | } catch { 1264 | XCTFail("Error parsing JSON Object from: \(self)", file: file, line: line) 1265 | throw error 1266 | } 1267 | } 1268 | 1269 | /// Attempts to parse the given String as a JSON object 1270 | func parseJSONArray(file: StaticString = #file, line: UInt = #line) throws -> [Any] { 1271 | var str = self.trimmingCharacters(in: .whitespacesAndNewlines) 1272 | 1273 | // workround for test failures: sometimes stderr has a line line: "2023-10-27 17:04:18.587523-0400 skip[91666:2692850] [client] No error handler for XPC error: Connection invalid"; this seems to be a side effect of running `skip doctor` from within Xcode 1274 | if str.hasSuffix("No error handler for XPC error: Connection invalid") { 1275 | str = str.split(separator: "\n").dropLast().joined(separator: "\n") 1276 | } 1277 | 1278 | do { 1279 | let json = try JSONSerialization.jsonObject(with: Data(utf8), options: []) 1280 | if let arr = json as? [Any] { 1281 | return arr 1282 | } else { 1283 | struct CannotParseJSONIntoArray : LocalizedError { var errorDescription: String? } 1284 | throw CannotParseJSONIntoArray(errorDescription: "JSON object was of wrong type: \(type(of: json))") 1285 | } 1286 | } catch { 1287 | XCTFail("Error parsing JSON Array from: \(self)", file: file, line: line) 1288 | throw error 1289 | } 1290 | } 1291 | 1292 | func parseJSONMessages(file: StaticString = #file, line: UInt = #line) throws -> [String] { 1293 | try parseJSONArray(file: file, line: line).compactMap({ ($0 as? JSONObject)?["msg"] as? String }) 1294 | } 1295 | } 1296 | 1297 | /// Create a temporary directory 1298 | func mktmp(baseName: String = "SkipDriveTests") throws -> String { 1299 | let tempDir = [NSTemporaryDirectory(), baseName, UUID().uuidString].joined(separator: "/") 1300 | try FileManager.default.createDirectory(atPath: tempDir, withIntermediateDirectories: true) 1301 | return tempDir 1302 | } 1303 | 1304 | /// Create a temporary directory 1305 | func mktmpFile(baseName: String = "SkipDriveTests", named: String = "file-\(UUID().uuidString)", contents: Data) throws -> String { 1306 | let tempDir = try mktmp(baseName: baseName) 1307 | let tempFile = tempDir + "/" + named 1308 | try contents.write(to: URL(fileURLWithPath: tempFile), options: .atomic) 1309 | return tempFile 1310 | } 1311 | 1312 | func loadProjectPackage(_ path: String) async throws -> PackageManifest { 1313 | try await execJSON(["swift", "package", "dump-package", "--package-path", path]) 1314 | } 1315 | 1316 | func loadProjectConfig(_ path: String, scheme: String? = nil) async throws -> [ProjectBuildSettings] { 1317 | try await execJSON(["xcodebuild", "-showBuildSettings", "-json", "-project", path] + (scheme == nil ? [] : ["-scheme", scheme!])) 1318 | } 1319 | 1320 | func execJSON(_ arguments: [String]) async throws -> T { 1321 | let output = try await Process.checkNonZeroExit(arguments: arguments, loggingHandler: nil) 1322 | return try JSONDecoder().decode(T.self, from: Data(output.utf8)) 1323 | } 1324 | 1325 | /// Cover for `XCTAssertEqual` that permit async autoclosures. 1326 | @available(macOS 13, iOS 16, tvOS 16, watchOS 8, *) 1327 | func XCTAssertEqualAsync(_ expression1: T, _ expression2: T, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) where T : Equatable { 1328 | XCTAssertEqual(expression1, expression2, message(), file: file, line: line) 1329 | } 1330 | 1331 | 1332 | 1333 | /// An incomplete representation of package JSON, to be filled in as needed for the purposes of the tool 1334 | /// The output from `swift package dump-package`. 1335 | struct PackageManifest : Hashable, Decodable { 1336 | var name: String 1337 | //var toolsVersion: String // can be string or dict 1338 | var products: [Product] 1339 | var dependencies: [Dependency] 1340 | //var targets: [Either.Or] 1341 | var platforms: [SupportedPlatform] 1342 | var cModuleName: String? 1343 | var cLanguageStandard: String? 1344 | var cxxLanguageStandard: String? 1345 | 1346 | struct Target: Hashable, Decodable { 1347 | enum TargetType: String, Hashable, Decodable { 1348 | case regular 1349 | case test 1350 | case system 1351 | } 1352 | 1353 | var `type`: TargetType 1354 | var name: String 1355 | var path: String? 1356 | var excludedPaths: [String]? 1357 | //var dependencies: [String]? // dict 1358 | //var resources: [String]? // dict 1359 | var settings: [String]? 1360 | var cModuleName: String? 1361 | // var providers: [] // apt, brew, etc. 1362 | } 1363 | 1364 | 1365 | struct Product : Hashable, Decodable { 1366 | //var `type`: ProductType // can be string or dict 1367 | var name: String 1368 | var targets: [String] 1369 | 1370 | enum ProductType: String, Hashable, Decodable, CaseIterable { 1371 | case library 1372 | case executable 1373 | } 1374 | } 1375 | 1376 | struct Dependency : Hashable, Decodable { 1377 | var name: String? 1378 | var url: String? 1379 | //var requirement: Requirement // revision/range/branch/exact 1380 | } 1381 | 1382 | struct SupportedPlatform : Hashable, Decodable { 1383 | var platformName: String 1384 | var version: String 1385 | } 1386 | } 1387 | 1388 | 1389 | /// The output from `xcodebuild -showBuildSettings -json -project Project.xcodeproj -scheme SchemeName` 1390 | struct ProjectBuildSettings : Decodable { 1391 | let target: String 1392 | let action: String 1393 | let buildSettings: [String: String] 1394 | } 1395 | 1396 | 1397 | 1398 | 1399 | // sample test output generated with the following command in the skip-zip package: 1400 | // swift test --enable-code-coverage --parallel --xunit-output=.build/swift-xunit.xml --filter=SkipZipTests 1401 | 1402 | // .build/plugins/outputs/skip-zip/SkipZipTests/skipstone/SkipZip/.build/SkipZip/test-results/testDebugUnitTest/TEST-skip.zip.SkipZipTests.xml 1403 | let junitResults = """ 1404 | 1405 | 1406 | 1407 | 1408 | 1409 | 1410 | 1411 | 1412 | 1415 | 1416 | """ 1417 | 1418 | // .build/swift-xunit.xml 1419 | let xunitResults = """ 1420 | 1421 | 1422 | 1423 | 1424 | 1425 | 1426 | 1427 | 1428 | 1429 | 1430 | 1431 | 1432 | 1433 | """ 1434 | --------------------------------------------------------------------------------