├── 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 | [](https://github.com/skiptools/skip/actions?query=skip%3Aci)
4 | [](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 |
--------------------------------------------------------------------------------