diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c32b2e917..f1f3fb7e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,54 +24,129 @@ jobs: args: --strict env: DIFF_BASE: ${{ github.base_ref }} - Latest: - name: Test Latest (iOS, macOS, tvOS, watchOS) - runs-on: macOS-12 + macOS: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} env: - DEVELOPER_DIR: "/Applications/Xcode_14.1.app/Contents/Developer" + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" timeout-minutes: 10 strategy: fail-fast: false matrix: include: - - destination: "OS=16.1,name=iPhone 14 Pro" - name: "iOS" - scheme: "Flare" - sdk: iphonesimulator - - destination: "OS=16.1,name=Apple TV" - name: "tvOS" - scheme: "Flare" - sdk: appletvsimulator - # - destination: "OS=9.1,name=Apple Watch Series 8 (45mm)" - # name: "watchOS" - # scheme: "Flare" - # sdk: watchsimulator - - destination: "platform=macOS" - name: "macOS" - scheme: "Flare" - sdk: macosx + - xcode: "Xcode_15.0" + runsOn: macos-13 + name: "macOS 13, Xcode 15.0, Swift 5.9.0" + - xcode: "Xcode_14.3.1" + runsOn: macos-13 + name: "macOS 13, Xcode 14.3.1, Swift 5.8.0" + - xcode: "Xcode_14.2" + runsOn: macOS-12 + name: "macOS 12, Xcode 14.2, Swift 5.7.2" + - xcode: "Xcode_14.1" + runsOn: macOS-12 + name: "macOS 12, Xcode 14.1, Swift 5.7.1" + steps: + - uses: actions/checkout@v3 + - name: Install Dependencies + run: make setup_build_tools + - name: Generate project + run: make generate + - name: ${{ matrix.name }} + run: xcodebuild test CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -scheme "Flare" -destination "platform=macOS" clean -enableCodeCoverage YES -resultBundlePath "./macos.xcresult" || exit 1 + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3.1.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + xcode: true + xcode_archive_path: "./macos.xcresult" + + iOS: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} + env: + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - destination: "OS=17.0,name=iPhone 14 Pro" + name: "iOS 17.0" + xcode: "Xcode_15.0" + runsOn: macos-13 + - destination: "OS=16.4,name=iPhone 14 Pro" + name: "iOS 16.4" + xcode: "Xcode_14.3.1" + runsOn: macos-13 + - destination: "OS=15.5,name=iPhone 13 Pro" + name: "iOS 15.5" + xcode: "Xcode_14.3.1" + runsOn: macOS-13 steps: - uses: actions/checkout@v3 + - name: Install Dependencies + run: make setup_build_tools + - name: Generate project + run: make generate - name: ${{ matrix.name }} - run: xcodebuild test -scheme "${{ matrix.scheme }}" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "./${{ matrix.sdk }}.xcresult" || exit 1 + run: xcodebuild test CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -scheme "Flare" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "./iphonesimulator.xcresult" || exit 1 - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v3.1.0 with: token: ${{ secrets.CODECOV_TOKEN }} xcode: true - xcode_archive_path: "./${{ matrix.sdk }}.xcresult" + xcode_archive_path: "./iphonesimulator.xcresult" + + tvOS: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} + env: + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - destination: "OS=17.0,name=Apple TV" + name: "tvOS 17.0" + xcode: "Xcode_15.0" + runsOn: macos-13 + - destination: "OS=16.4,name=Apple TV" + name: "tvOS 16.4" + xcode: "Xcode_14.3.1" + runsOn: macos-13 + - destination: "OS=15.4,name=Apple TV" + name: "tvOS 15.4" + xcode: "Xcode_14.3.1" + runsOn: macos-13 + steps: + - uses: actions/checkout@v3 + - name: Install Dependencies + run: make setup_build_tools + - name: Generate project + run: make generate + - name: ${{ matrix.name }} + run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "./appletvsimulator.xcresult" || exit 1 + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3.1.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + xcode: true + xcode_archive_path: "./appletvsimulator.xcresult" + Beta: - name: "Test Betas" + name: ${{ matrix.name }} runs-on: macos-13 env: - DEVELOPER_DIR: "/Applications/Xcode_15.0.app/Contents/Developer" + DEVELOPER_DIR: "/Applications/Xcode_15.1.app/Contents/Developer" timeout-minutes: 10 strategy: fail-fast: false matrix: include: - destination: "OS=1.0,name=Apple Vision Pro" - name: "visionOS" + name: "visionOS 1.0" scheme: "Flare" steps: - uses: actions/checkout@v3 diff --git a/Makefile b/Makefile index 55d969d56..f11937816 100644 --- a/Makefile +++ b/Makefile @@ -19,4 +19,7 @@ fmt: generate: xcodegen generate -.PHONY: all bootstrap hook mint lint fmt generate \ No newline at end of file +setup_build_tools: + sh scripts/setup_build_tools.sh + +.PHONY: all bootstrap hook mint lint fmt generate setup_build_tools \ No newline at end of file diff --git a/Tests/FlareTests/UnitTestHostApp/AppDelegate.swift b/Tests/FlareTests/UnitTestHostApp/AppDelegate.swift index eb5f98aae..aea816e94 100644 --- a/Tests/FlareTests/UnitTestHostApp/AppDelegate.swift +++ b/Tests/FlareTests/UnitTestHostApp/AppDelegate.swift @@ -5,20 +5,14 @@ import SwiftUI -#if os(watchOS) || os(tvOS) || os(macOS) +#if os(macOS) + + import Cocoa @main - struct TestApp: App { - var body: some Scene { - WindowGroup { - Text("Hello World") - } - } - } + class AppDelegate: NSObject, NSApplicationDelegate {} #else - // Scene isn't available until iOS 14.0, so this is for backwards compatibility. - @main class AppDelegate: UIResponder, UIApplicationDelegate {} diff --git a/Tests/FlareTests/UnitTests/FlareStoreKit2Tests.swift b/Tests/FlareTests/UnitTests/FlareStoreKit2Tests.swift new file mode 100644 index 000000000..24922b25b --- /dev/null +++ b/Tests/FlareTests/UnitTests/FlareStoreKit2Tests.swift @@ -0,0 +1,156 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +import XCTest + +// MARK: - FlareStoreKit2Tests + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +final class FlareStoreKit2Tests: StoreSessionTestCase { + // MARK: - Properties + + private var iapProviderMock: IAPProviderMock! + + private var sut: Flare! + + // MARK: - XCTestCase + + override func setUp() { + super.setUp() + iapProviderMock = IAPProviderMock() + sut = Flare(iapProvider: iapProviderMock) + } + + override func tearDown() { + iapProviderMock = nil + sut = nil + super.tearDown() + } + + #if os(iOS) || VISION_OS + func test_thatFlareRefundsPurchase() async throws { + // given + iapProviderMock.stubbedBeginRefundRequest = .success + + // when + let state = try await sut.beginRefundRequest(productID: .productID) + + // then + if case .success = state {} + else { XCTFail("state must be `success`") } + } + + func test_thatFlareRefundRequestThrowsAnError_whenBeginRefundRequestFailed() async throws { + // given + iapProviderMock.stubbedBeginRefundRequest = .failed(error: IAPError.unknown) + + // when + let state = try await sut.beginRefundRequest(productID: .productID) + + // then + if case let .failed(error) = state { XCTAssertEqual(error as NSError, IAPError.unknown as NSError) } + else { XCTFail("state must be `failed`") } + } + #endif + + func test_thatFlarePurchasesAProductWithOptions_whenPurchaseCompleted() async throws { + let transaction = StoreTransactionStub() + try await test_purchaseWithOptionsAndCompletion( + transaction: transaction, + canMakePayments: true, + expectedResult: .success(StoreTransaction(storeTransaction: transaction)) + ) + } + + func test_thatFlarePurchaseThrowsAnError_whenPaymentNotAllowed() async throws { + try await test_purchaseWithOptionsAndCompletion( + canMakePayments: false, + expectedResult: .failure(IAPError.paymentNotAllowed) + ) + } + + func test_thatFlarePurchasesAsyncAProductWithOptionsAndCompletionHandler_whenPurchaseCompleted() async throws { + let transaction = StoreTransactionStub() + try await test_purchaseWithOptions( + canMakePayments: true, + expectedResult: .success(StoreTransaction(storeTransaction: transaction)) + ) + } + + func test_thatFlarePurchaseAsyncThrowsAnError_whenPaymentNotAllowed() async throws { + try await test_purchaseWithOptions( + canMakePayments: false, + expectedResult: .failure(IAPError.paymentNotAllowed) + ) + } + + // MARK: Private + + private func test_purchaseWithOptionsAndCompletion( + transaction: StoreTransactionStub? = nil, + canMakePayments: Bool, + expectedResult: Result + ) async throws { + // given + let product = try await ProductProviderHelper.purchases.randomElement() + let storeTransactionStub = transaction ?? StoreTransactionStub() + storeTransactionStub.stubbedProductIdentifier = product?.id + + iapProviderMock.stubbedCanMakePayments = canMakePayments + iapProviderMock.stubbedAsyncPurchaseWithOptions = StoreTransaction( + storeTransaction: storeTransactionStub + ) + + // when + let result: Result = await result(for: { + try await sut.purchase( + product: StoreProduct(product: product!), + options: [.simulatesAskToBuyInSandbox(false)] + ) + }) + + // then + XCTAssertEqual(result, expectedResult) + } + + private func test_purchaseWithOptions( + transaction: StoreTransactionStub? = nil, + canMakePayments: Bool, + expectedResult: Result + ) async throws { + // given + let expectation = XCTestExpectation(description: "Purchase a product") + + let product = try await ProductProviderHelper.purchases.randomElement() + let storeTransactionStub = transaction ?? StoreTransactionStub() + storeTransactionStub.stubbedProductIdentifier = product?.id + + iapProviderMock.stubbedCanMakePayments = canMakePayments + iapProviderMock.stubbedPurchaseWithOptionsResult = .success(StoreTransaction(storeTransaction: storeTransactionStub)) + + // when + sut.purchase( + product: StoreProduct(product: product!), + options: [.simulatesAskToBuyInSandbox(false)] + ) { result in + XCTAssertEqual(result, expectedResult) + expectation.fulfill() + } + + // then + wait(for: [expectation], timeout: .second) + } +} + +// MARK: - Constants + +private extension TimeInterval { + static let second: CGFloat = 1.0 +} + +private extension String { + static let productID = "com.flare.test_purchase_2" +} diff --git a/Tests/FlareTests/UnitTests/FlareTests.swift b/Tests/FlareTests/UnitTests/FlareTests.swift index d2c186fcb..0192a970d 100644 --- a/Tests/FlareTests/UnitTests/FlareTests.swift +++ b/Tests/FlareTests/UnitTests/FlareTests.swift @@ -9,7 +9,7 @@ import XCTest // MARK: - FlareTests -class FlareTests: StoreSessionTestCase { +class FlareTests: XCTestCase { // MARK: - Properties private var iapProviderMock: IAPProviderMock! @@ -213,128 +213,6 @@ class FlareTests: StoreSessionTestCase { // then XCTAssertTrue(iapProviderMock.invokedAddTransactionObserver) } - - #if os(iOS) || VISION_OS - @available(iOS 15.0, *) - func test_thatFlareRefundsPurchase() async throws { - // given - iapProviderMock.stubbedBeginRefundRequest = .success - - // when - let state = try await sut.beginRefundRequest(productID: .productID) - - // then - if case .success = state {} - else { XCTFail("state must be `success`") } - } - - @available(iOS 15.0, *) - func test_thatFlareRefundRequestThrowsAnError_whenBeginRefundRequestFailed() async throws { - // given - iapProviderMock.stubbedBeginRefundRequest = .failed(error: IAPError.unknown) - - // when - let state = try await sut.beginRefundRequest(productID: .productID) - - // then - if case let .failed(error) = state { XCTAssertEqual(error as NSError, IAPError.unknown as NSError) } - else { XCTFail("state must be `failed`") } - } - #endif - - @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) - func test_thatFlarePurchasesAProductWithOptions_whenPurchaseCompleted() async throws { - let transaction = StoreTransactionStub() - try await test_purchaseWithOptionsAndCompletion( - transaction: transaction, - canMakePayments: true, - expectedResult: .success(StoreTransaction(storeTransaction: transaction)) - ) - } - - @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) - func test_thatFlarePurchaseThrowsAnError_whenPaymentNotAllowed() async throws { - try await test_purchaseWithOptionsAndCompletion( - canMakePayments: false, - expectedResult: .failure(IAPError.paymentNotAllowed) - ) - } - - @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) - func test_thatFlarePurchasesAsyncAProductWithOptionsAndCompletionHandler_whenPurchaseCompleted() async throws { - let transaction = StoreTransactionStub() - try await test_purchaseWithOptions( - canMakePayments: true, - expectedResult: .success(StoreTransaction(storeTransaction: transaction)) - ) - } - - @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) - func test_thatFlarePurchaseAsyncThrowsAnError_whenPaymentNotAllowed() async throws { - try await test_purchaseWithOptions( - canMakePayments: false, - expectedResult: .failure(IAPError.paymentNotAllowed) - ) - } - - // MARK: Private - - @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) - private func test_purchaseWithOptionsAndCompletion( - transaction: StoreTransactionStub? = nil, - canMakePayments: Bool, - expectedResult: Result - ) async throws { - // given - let product = try await ProductProviderHelper.purchases.randomElement() - let storeTransactionStub = transaction ?? StoreTransactionStub() - storeTransactionStub.stubbedProductIdentifier = product?.id - - iapProviderMock.stubbedCanMakePayments = canMakePayments - iapProviderMock.stubbedAsyncPurchaseWithOptions = StoreTransaction( - storeTransaction: storeTransactionStub - ) - - // when - let result: Result = await result(for: { - try await sut.purchase( - product: StoreProduct(product: product!), - options: [.simulatesAskToBuyInSandbox(false)] - ) - }) - - // then - XCTAssertEqual(result, expectedResult) - } - - @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) - private func test_purchaseWithOptions( - transaction: StoreTransactionStub? = nil, - canMakePayments: Bool, - expectedResult: Result - ) async throws { - // given - let expectation = XCTestExpectation(description: "Purchase a product") - - let product = try await ProductProviderHelper.purchases.randomElement() - let storeTransactionStub = transaction ?? StoreTransactionStub() - storeTransactionStub.stubbedProductIdentifier = product?.id - - iapProviderMock.stubbedCanMakePayments = canMakePayments - iapProviderMock.stubbedPurchaseWithOptionsResult = .success(StoreTransaction(storeTransaction: storeTransactionStub)) - - // when - sut.purchase( - product: StoreProduct(product: product!), - options: [.simulatesAskToBuyInSandbox(false)] - ) { result in - XCTAssertEqual(result, expectedResult) - expectation.fulfill() - } - - // then - wait(for: [expectation], timeout: .second) - } } // MARK: - Constants @@ -347,7 +225,3 @@ private extension String { static let productID = "product_ID" static let receipt = "receipt" } - -private extension TimeInterval { - static let second: CGFloat = 1.0 -} diff --git a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderStoreKit2Tests.swift b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderStoreKit2Tests.swift new file mode 100644 index 000000000..312c2f32e --- /dev/null +++ b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderStoreKit2Tests.swift @@ -0,0 +1,77 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +import XCTest + +// MARK: - PurchaseProviderStoreKit2Tests + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +final class PurchaseProviderStoreKit2Tests: StoreSessionTestCase { + // MARK: Properties + + private var paymentProviderMock: PaymentProviderMock! + + private var sut: PurchaseProvider! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + paymentProviderMock = PaymentProviderMock() + sut = PurchaseProvider( + paymentProvider: paymentProviderMock + ) + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatPurchaseProviderReturnsPaymentTransaction_whenPurchasesAProductWithOptions() async throws { + let expectation = XCTestExpectation(description: "Purchase a product") + let productMock = try StoreProduct(product: await ProductProviderHelper.purchases.randomElement()!) + + // when + sut.purchase(product: productMock, options: [.simulatesAskToBuyInSandbox(false)]) { result in + switch result { + case let .success(transaction): + XCTAssertEqual(transaction.productIdentifier, productMock.productIdentifier) + expectation.fulfill() + case let .failure(error): + XCTFail(error.localizedDescription) + } + } + + wait(for: [expectation], timeout: .second) + } + + func test_thatPurchaseProviderReturnsPaymentTransaction_whenSK2ProductExist() async throws { + let expectation = XCTestExpectation(description: "Purchase a product") + let productMock = try StoreProduct(product: await ProductProviderHelper.purchases.randomElement()!) + + // when + sut.purchase(product: productMock) { result in + switch result { + case let .success(transaction): + XCTAssertEqual(transaction.productIdentifier, productMock.productIdentifier) + expectation.fulfill() + case let .failure(error): + XCTFail(error.localizedDescription) + } + } + + wait(for: [expectation], timeout: .second) + } +} + +// MARK: - Constants + +private extension TimeInterval { + static let second: TimeInterval = 1.0 +} diff --git a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift index 11638516f..2e377f714 100644 --- a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift @@ -10,7 +10,7 @@ import XCTest // MARK: - PurchaseProviderTests -final class PurchaseProviderTests: StoreSessionTestCase { +final class PurchaseProviderTests: XCTestCase { // MARK: Properties private var paymentQueueMock: PaymentQueueMock! @@ -54,44 +54,6 @@ final class PurchaseProviderTests: StoreSessionTestCase { } } - @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - func test_thatPurchaseProviderReturnsPaymentTransaction_whenSK2ProductExist() async throws { - let expectation = XCTestExpectation(description: "Purchase a product") - let productMock = try StoreProduct(product: await ProductProviderHelper.purchases[0]) - - // when - sut.purchase(product: productMock) { result in - switch result { - case let .success(transaction): - XCTAssertEqual(transaction.productIdentifier, productMock.productIdentifier) - expectation.fulfill() - case let .failure(error): - XCTFail(error.localizedDescription) - } - } - - wait(for: [expectation], timeout: .second) - } - - @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - func test_thatPurchaseProviderReturnsPaymentTransaction_whenPurchasesAProductWithOptions() async throws { - let expectation = XCTestExpectation(description: "Purchase a product") - let productMock = try StoreProduct(product: await ProductProviderHelper.purchases[0]) - - // when - sut.purchase(product: productMock, options: [.simulatesAskToBuyInSandbox(false)]) { result in - switch result { - case let .success(transaction): - XCTAssertEqual(transaction.productIdentifier, productMock.productIdentifier) - expectation.fulfill() - case let .failure(error): - XCTFail(error.localizedDescription) - } - } - - wait(for: [expectation], timeout: .second) - } - func test_thatPurchaseProviderFinishesTransaction() { // given let transaction = PurchaseManagerTestHelper.makePaymentTransaction(state: .purchased) @@ -140,9 +102,3 @@ final class PurchaseProviderTests: StoreSessionTestCase { XCTAssertTrue(paymentProviderMock.invokedRemoveTransactionObserver) } } - -// MARK: - Constants - -private extension TimeInterval { - static let second: TimeInterval = 1.0 -} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/StoreSessionTestCase.swift b/Tests/FlareTests/UnitTests/TestHelpers/StoreSessionTestCase.swift index 4a542cb2f..4e33a5234 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/StoreSessionTestCase.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/StoreSessionTestCase.swift @@ -6,6 +6,7 @@ import StoreKitTest import XCTest +@available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) class StoreSessionTestCase: XCTestCase { // MARK: Properties @@ -16,16 +17,10 @@ class StoreSessionTestCase: XCTestCase { override func setUp() { super.setUp() - if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { - do { - session = try SKTestSession(configurationFileNamed: "Flare") - session?.resetToDefaultState() - session?.askToBuyEnabled = false - session?.disableDialogs = true - } catch { - debugPrint("[StoreSessionTestCase] An error occurred while initializing a session: \(error.localizedDescription)") - } - } + session = try? SKTestSession(configurationFileNamed: "Flare") + session?.resetToDefaultState() + session?.askToBuyEnabled = false + session?.disableDialogs = true } override func tearDown() { diff --git a/project.yml b/project.yml index 25419d99a..558a79566 100644 --- a/project.yml +++ b/project.yml @@ -22,13 +22,12 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.spacecode.flare - DEVELOPMENT_TEAM: A8WE5LL2GU scheme: testTargets: - FlareTests Flare: type: framework - supportedDestinations: [iOS, tvOS, macOS, watchOS] + supportedDestinations: [iOS, tvOS, macOS] dependencies: - package: Concurrency product: Concurrency @@ -44,7 +43,7 @@ targets: - Flare FlareTests: type: bundle.unit-test - supportedDestinations: [iOS, tvOS, macOS, watchOS] + supportedDestinations: [iOS, tvOS, macOS] dependencies: - package: Concurrency product: TestConcurrency diff --git a/scripts/setup_build_tools.sh b/scripts/setup_build_tools.sh new file mode 100644 index 000000000..a0b9e5235 --- /dev/null +++ b/scripts/setup_build_tools.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +which -s xcodegen +if [[ $? != 0 ]] ; then + # Install xcodegen + echo "Installing xcodegen." + brew install xcodegen +fi \ No newline at end of file