├── .gitignore ├── .swiftpm └── xcode │ └── xcshareddata │ └── xcschemes │ └── xctestplanner.xcscheme ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md └── Sources └── xctestplanner ├── CLI └── Commands │ ├── Arguments │ ├── Argument.swift │ └── EnvironmentVariable.swift │ ├── Localization │ ├── Language.swift │ └── Region.swift │ ├── Test Execution │ └── Rerun.swift │ ├── Test Plan │ ├── DefaultOptions.swift │ └── DefaultTestPlan.swift │ ├── Test Selection │ ├── Remove.swift │ ├── Select.swift │ ├── SelectTarget.swift │ ├── SelectiveTesting.swift │ └── Skip.swift │ └── main.swift └── Core ├── Entity └── TestPlanModel.swift ├── Extensions └── Array+Extensions.swift └── Helper ├── Logger.swift └── TestPlanHelper.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/xctestplanner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "swift-argument-parser", 6 | "repositoryURL": "https://github.com/apple/swift-argument-parser", 7 | "state": { 8 | "branch": null, 9 | "revision": "83b23d940471b313427da226196661856f6ba3e0", 10 | "version": "0.4.4" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let argumentParserDependency: Target.Dependency = .product(name: "ArgumentParser", package: "swift-argument-parser") 7 | 8 | let package = Package( 9 | name: "xctestplanner", 10 | products: [ 11 | .executable(name: "xctestplanner", targets: ["xctestplanner"]), 12 | ], 13 | dependencies: [ 14 | .package( 15 | name: "swift-argument-parser", 16 | url: "https://github.com/apple/swift-argument-parser", 17 | .upToNextMinor(from: "0.4.3") 18 | ) 19 | ], 20 | targets: [ 21 | .executableTarget( 22 | name: "xctestplanner", 23 | dependencies: [argumentParserDependency]), 24 | ] 25 | ) 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # XCTestPlanner 2 | A tool for managing [Xcode Test Plans](https://medium.com/trendyol-tech/get-the-most-out-of-ui-tests-with-xcode-test-plans-d089a2252ba2) from command line. 3 | 4 | ## Why? 5 | Test plans are a valuable tool for organizing and managing your tests in Xcode projects. \ 6 | However, manually editing large number of test plans can be time-consuming and tedious (sometimes crashy). 7 | 8 | And also, It's not possible to selectively run or skip specific test classes with a test plan. 9 | 10 | ## Features 11 | XCTestPlanner simplifies editing test plans by providing a command-line interface for adding or removing tests, setting language and region options, and adjusting the rerun numbers. 12 | 13 | - Handle your test plans from the command line interface instead of Xcode. 14 | - Simplify your CI setups by creating a single test plan that can be customized for various configurations. 15 | - Easily adjust the number of test repetitions for different environments. 16 | - Control the Localizations by passing parameters. 17 | - Conveniently set environment variables/ arguments for your CI pipelines. 18 | 19 | ## Selective Testing 20 | 21 | Selective testing is a new feature of xctestplanner, especially for projects with lots of tests. By using this command only the affected modules tests will be selected, significantly improving efficiency by skipping unchanged modules' tests. This can reduce your test execution times by up to 80%. 22 | 23 | xctestplanner uses git diff to spot the affected modules, enabling only their targets and skipping the rest. By default, it checks against origin/develop, but you can customize it by passing a different target branch in your CI pipelines. Just run this command, then execute your tests, and watch the magic happen! 24 | 25 | ### Requirements: 26 | - Your test target names should be in the format ModuleNameTests. 27 | - Ensure all test targets are added to your test plan. 28 | 29 | ``` 30 | xctestplanner selective-testing -f {testPlanPath} -p {projectPath} -t {targetBranch} 31 | ``` 32 | 33 | ## Installation 34 | ### [Mint](https://github.com/yonaskolb/mint) 35 | ``` 36 | brew install mint 37 | mint install atakankarsli/xctestplanner 38 | cp -f /Users/user.name/.mint/bin/xctestplanner /usr/local/bin/xctestplanner 39 | ``` 40 | ### Clone 41 | 42 | Clone the repo and run `swift build --configuration release` command. You will find xctestplanner executable in `.build/release` directory 43 | You need to copy it copy it to the /usr/local/bin directory, allowing you to run without the need to specify swift run." 44 | ``` 45 | cp -f .build/release/xctestplanner /usr/local/bin/xctestplanner 46 | ``` 47 | 48 | ## Usage 49 | To use xctestplanner, you will need to follow these steps: 50 | 51 | 1. Create a test plan in your Xcode project. 52 | 2. Add at least one test target to the test plan. 53 | 3. Run the xctestplanner command and pass the path to the test plan file and any necessary options. 54 | 4. The tool will modify the test plan according to the provided options and save the changes to the file. 55 | 56 | After these step you'll have example.xctestplan look like this: 57 | 58 | ### Example Test Plan JSON: 59 | ```json 60 | { 61 | "configurations": [ 62 | { 63 | "name": "Configuration 1", 64 | "options": { 65 | "targetForVariableExpansion": { 66 | "name": "TestTarget" 67 | } 68 | } 69 | } 70 | ], 71 | "defaultOptions": { 72 | "commandLineArgumentEntries": [ 73 | { 74 | "argument": "ARGUMENT_NAME" 75 | } 76 | ], 77 | "environmentVariableEntries": [ 78 | { 79 | "key": "VAR_KEY_1", 80 | "value": "$(VAR_VALUE_1)" 81 | } 82 | ], 83 | "language": "en", 84 | "maximumTestRepetitions": 2, 85 | "region": "US", 86 | "testRepetitionMode": "retryOnFailure", 87 | "testTimeoutsEnabled": true 88 | }, 89 | "testTargets": [ 90 | { 91 | "parallelizable": true, 92 | "skippedTests": [ 93 | "TestClass", 94 | "TestClass\/TestName()" 95 | ], 96 | "target": { 97 | "name": "TestTarget" 98 | } 99 | } 100 | ], 101 | "version": 1 102 | } 103 | ``` 104 | 105 | There are two main commands for selecting tests in a test plan: `select` and `skip`. \ 106 | To add tests to the list of selected tests, use the `select` command. If the `Automatically include new tests` option is enabled in your test plan, you should use the `skip` command to exclude more tests from being run. 107 | 108 | SCR-20221223-wuy-2 109 | 110 | For all commands you need to provide the path to the JSON file containing the test plan using the -f or --filePath option 111 | 112 | ### Select / Skip 113 | To update the list of selected/skipped tests in a test plan, use the select command and pass the path to the test plan file and a list of test names: 114 | 115 | ``` 116 | xctestplanner select TestClass1 TestClass2 -f path/to/testplan.xctestplan 117 | ``` 118 | Or 119 | ``` 120 | xctestplanner skip TestClass1 TestClass2 -f path/to/testplan.xctestplan 121 | ``` 122 | 123 | By default, the select/skip commands will add the specified tests to the existing list of selected/skipped tests. \ 124 | If you want to selectively run or skip only specified test classes use `--override` or the `-o` flag: 125 | ``` 126 | xctestplanner select TestClass1 TestClass2 -o -f filePath 127 | ``` 128 | 129 | And you can set the language, region and adjust rerun count with using the `--language` and `--region`, `--rerun` flags: 130 | 131 | ``` 132 | xctestplanner select TestClass1 TestClass2 -f filePath -o --language en --region US --rerun 3 133 | ``` 134 | 135 | ### Rerun 136 | You can adjust these without adding/removing tests with the these commands, you will need to provide --rerun option. 137 | 138 | ``` 139 | xctestplanner rerun 3 -f filePath 140 | ``` 141 | 142 | ### Language 143 | ``` 144 | xctestplanner language en -f filePath 145 | ``` 146 | 147 | ### Region 148 | ``` 149 | xctestplanner region EN -f filePath 150 | ``` 151 | 152 | ### Argument 153 | The key of the command line argument to set using the --key option. If you want to disable the specified command line argument, use the `-d` or `--disabled` flag. 154 | 155 | ``` 156 | xctestplanner argument -f filePath --key DEV_CONFIG --disabled 157 | ``` 158 | 159 | ### Environment Variable 160 | The key and value of the environment variable using the `--key` and `--value` options, respectively. 161 | 162 | ``` 163 | xctestplanner environment -f filePath --key MY_VAR --value 123 164 | ``` 165 | 166 | ### Remove 167 | ``` 168 | xctestplanner remove -f filePath TestClass1\/testName1 TestClass2\/testName2 169 | ``` 170 | 171 | ### Select Target 172 | ``` 173 | xctestplanner select-target -f filePath XModuleTests YModuleTests 174 | ``` 175 | 176 | ### Default Options 177 | This command allows to configure various default options. 178 | 179 | ``` 180 | xctestplanner defaultOptions -f "/path/to/your/testplan.xctestplan" --enable-localization-screenshots true --enable-code-coverage false --diagnostic-policy "Never" --enable-timeouts true --screenshot-lifetime "keepAlways" --screen-capture-format "screenshot" 181 | ``` 182 | 183 | ## Contribution 184 | 185 | Please create an issue whenever you find an issue or think a feature could be a good addition to XCTestPlanner. 186 | 187 | ## License 188 | 189 | XCTestPlanner is [available under the Apache License 2.0](https://github.com/atakankarsli/xctestplanner/blob/main/LICENSE). 190 | -------------------------------------------------------------------------------- /Sources/xctestplanner/CLI/Commands/Arguments/Argument.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Argument.swift 3 | // 4 | // 5 | // Created by Atakan Karslı on 24/12/2022. 6 | // 7 | 8 | import ArgumentParser 9 | import Foundation 10 | 11 | extension Command { 12 | struct Arg: ParsableCommand { 13 | static var configuration = CommandConfiguration( 14 | commandName: "argument", 15 | abstract: "Sets the specified command line argument. Use '-d' to disable" 16 | ) 17 | 18 | @Option(name: .shortAndLong, help: "The path to the JSON file to parse.") 19 | var filePath: String 20 | 21 | @Option(name: .shortAndLong, help: "The key of the command line argument to set.") 22 | var key: String 23 | 24 | @Flag(name: .short, help: "Disables the specified command line argument.") 25 | var disabled = false 26 | 27 | func run() throws { 28 | var testPlan = try TestPlanHelper.readTestPlan(filePath: filePath) 29 | 30 | TestPlanHelper.setArgument(testPlan: &testPlan, key: key, disabled: disabled) 31 | 32 | try TestPlanHelper.writeTestPlan(testPlan, filePath: filePath) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/xctestplanner/CLI/Commands/Arguments/EnvironmentVariable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentVariable.swift 3 | // 4 | // 5 | // Created by Atakan Karslı on 24/12/2022. 6 | // 7 | 8 | import ArgumentParser 9 | import Foundation 10 | 11 | extension Command { 12 | struct EnvironmentVariable: ParsableCommand { 13 | static var configuration = CommandConfiguration( 14 | commandName: "environment", 15 | abstract: "Sets the environment variable." 16 | ) 17 | 18 | @Option(name: .shortAndLong, help: "The path to the JSON file to parse.") 19 | var filePath: String 20 | 21 | @Option(name: .shortAndLong, help: "The key of the environment variable.") 22 | var key: String 23 | 24 | @Option(name: .shortAndLong, help: "The value of the environment variable.") 25 | var value: String 26 | 27 | func run() throws { 28 | var testPlan = try TestPlanHelper.readTestPlan(filePath: filePath) 29 | 30 | TestPlanHelper.setEnvironmentVariable(testPlan: &testPlan, key: key, value: value) 31 | 32 | try TestPlanHelper.writeTestPlan(testPlan, filePath: filePath) 33 | } 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /Sources/xctestplanner/CLI/Commands/Localization/Language.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Language.swift 3 | // 4 | // 5 | // Created by Atakan Karslı on 23/12/2022. 6 | // 7 | 8 | import ArgumentParser 9 | import Foundation 10 | 11 | extension Command { 12 | struct Language: ParsableCommand { 13 | static var configuration = CommandConfiguration( 14 | commandName: "language", 15 | abstract: "Updates the language." 16 | ) 17 | 18 | @Argument(help: "The language to update. (examples: 'en', 'tr')") 19 | var language: String 20 | 21 | @Option(name: .shortAndLong, help: "The path to the JSON file to parse.") 22 | var filePath: String 23 | 24 | func run() throws { 25 | var testPlan = try TestPlanHelper.readTestPlan(filePath: filePath) 26 | 27 | TestPlanHelper.updateLanguage(testPlan: &testPlan, to: language) 28 | 29 | try TestPlanHelper.writeTestPlan(testPlan, filePath: filePath) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/xctestplanner/CLI/Commands/Localization/Region.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Region.swift 3 | // 4 | // 5 | // Created by Atakan Karslı on 23/12/2022. 6 | // 7 | 8 | import ArgumentParser 9 | import Foundation 10 | 11 | extension Command { 12 | struct Region: ParsableCommand { 13 | static var configuration = CommandConfiguration( 14 | commandName: "region", 15 | abstract: "Updates the region." 16 | ) 17 | 18 | @Argument(help: "The region to update. (examples: 'EN', 'TR')") 19 | var region: String 20 | 21 | @Option(name: .shortAndLong, help: "The path to the JSON file to parse.") 22 | var filePath: String 23 | 24 | func run() throws { 25 | var testPlan = try TestPlanHelper.readTestPlan(filePath: filePath) 26 | 27 | TestPlanHelper.updateRegion(testPlan: &testPlan, to: region) 28 | 29 | try TestPlanHelper.writeTestPlan(testPlan, filePath: filePath) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/xctestplanner/CLI/Commands/Test Execution/Rerun.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Rerun.swift 3 | // 4 | // 5 | // Created by Atakan Karslı on 23/12/2022. 6 | // 7 | 8 | import ArgumentParser 9 | import Foundation 10 | 11 | extension Command { 12 | struct Rerun: ParsableCommand { 13 | static var configuration = CommandConfiguration( 14 | commandName: "rerun", 15 | abstract: "Updates the maximum number of test repetitions." 16 | ) 17 | 18 | @Argument(help: "The maximum number of test repetitions.") 19 | var rerun: Int 20 | 21 | @Option(name: .shortAndLong, help: "The path to the JSON file to parse.") 22 | var filePath: String 23 | 24 | func run() throws { 25 | var testPlan = try TestPlanHelper.readTestPlan(filePath: filePath) 26 | 27 | TestPlanHelper.updateRerunCount(testPlan: &testPlan, to: rerun) 28 | 29 | try TestPlanHelper.writeTestPlan(testPlan, filePath: filePath) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/xctestplanner/CLI/Commands/Test Plan/DefaultOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultOptions.swift 3 | // 4 | // 5 | // Created by Atakan Karslı on 12/08/2024. 6 | // 7 | 8 | import ArgumentParser 9 | import Foundation 10 | 11 | extension Command { 12 | struct DefaultOptions: ParsableCommand { 13 | static var configuration = CommandConfiguration( 14 | commandName: "defaultOptions", 15 | abstract: "Configures various default options in the test plan." 16 | ) 17 | 18 | @Option(name: .shortAndLong, help: "The path to the JSON file to parse.") 19 | var filePath: String 20 | 21 | @Option(name: .long, help: "Enable or disable localization screenshots.") 22 | var enableLocalizationScreenshots: Bool = false 23 | 24 | @Option(name: .long, help: "Enable or disable code coverage.") 25 | var enableCodeCoverage: Bool? 26 | 27 | @Option(name: .long, help: "Set the diagnostic collection policy. (options: 'Never', 'Always', etc.)") 28 | var diagnosticPolicy: String? 29 | 30 | @Option(name: .long, help: "Enable or disable test timeouts.") 31 | var enableTimeouts: Bool = false 32 | 33 | @Option(name: .long, help: "Set the screenshot lifetime for UI testing. (options: 'keepAlways', 'deleteAfterSuccess', etc.)") 34 | var screenshotLifetime: String? 35 | 36 | @Option(name: .long, help: "Set the preferred screen capture format. (example: 'screenshot')") 37 | var screenCaptureFormat: String? 38 | 39 | func run() throws { 40 | var testPlan = try TestPlanHelper.readTestPlan(filePath: filePath) 41 | 42 | TestPlanHelper.configureDefaultOptions( 43 | testPlan: &testPlan, 44 | enableLocalizationScreenshots: enableLocalizationScreenshots, 45 | enableCodeCoverage: enableCodeCoverage, 46 | diagnosticPolicy: diagnosticPolicy, 47 | enableTimeouts: enableTimeouts, 48 | screenshotLifetime: screenshotLifetime, 49 | screenCaptureFormat: screenCaptureFormat 50 | ) 51 | 52 | try TestPlanHelper.writeTestPlan(testPlan, filePath: filePath) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/xctestplanner/CLI/Commands/Test Plan/DefaultTestPlan.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultTestPlan.swift 3 | // 4 | // 5 | // Created by Atakan Karslı on 27/03/2023. 6 | // 7 | 8 | import Foundation 9 | import ArgumentParser 10 | 11 | extension Command { 12 | struct DefaultTestPlan: ParsableCommand { 13 | static var configuration = CommandConfiguration( 14 | commandName: "default-testplan", 15 | abstract: "Updates the default selected test plan in the specified Xcode scheme." 16 | ) 17 | 18 | @Option(name: .shortAndLong, help: "The path to the Xcode scheme file to edit.") 19 | var schemePath: String 20 | 21 | @Argument(help: "The path to the Test plan JSON file to parse.") 22 | var testPlanPath: String 23 | 24 | func run() throws { 25 | 26 | let schemeUrl = URL(fileURLWithPath: schemePath) 27 | var schemeContent = try String(contentsOf: schemeUrl) 28 | 29 | let defaultReference = """ 30 | default = "YES" 31 | """ 32 | 33 | // Remove default parameter from existing reference 34 | schemeContent = schemeContent.replacingOccurrences(of: defaultReference, with: "") 35 | 36 | 37 | let testPlanReference = """ 38 | reference = "container:\(testPlanPath)" 39 | """ 40 | 41 | let newTestPlanReference = """ 42 | reference = "container:\(testPlanPath)" 43 | default = "YES" 44 | """ 45 | 46 | // Add default parameter to given testPlan reference 47 | schemeContent = schemeContent.replacingOccurrences(of: testPlanReference, with: newTestPlanReference) 48 | 49 | // Write the updated scheme file 50 | try schemeContent.write(to: schemeUrl, atomically: true, encoding: .utf8) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/xctestplanner/CLI/Commands/Test Selection/Remove.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Remove.swift 3 | // 4 | // 5 | // Created by Atakan Karslı on 27/02/2023. 6 | // 7 | 8 | import ArgumentParser 9 | import Foundation 10 | 11 | extension Command { 12 | struct Remove: ParsableCommand { 13 | static var configuration = CommandConfiguration( 14 | commandName: "remove", 15 | abstract: "Removes the specified tests from the selected tests. " 16 | ) 17 | 18 | @Argument(help: "A list of tests to remove from selected tests.") 19 | var tests: [String] 20 | 21 | @Option(name: .shortAndLong, help: "The path to the JSON file to parse.") 22 | var filePath: String 23 | 24 | func run() throws { 25 | var testPlan = try TestPlanHelper.readTestPlan(filePath: filePath) 26 | 27 | TestPlanHelper.removeTests(testPlan: &testPlan, with: tests) 28 | 29 | try TestPlanHelper.writeTestPlan(testPlan, filePath: filePath) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/xctestplanner/CLI/Commands/Test Selection/Select.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Select.swift 3 | // 4 | // 5 | // Created by Atakan Karslı on 22/12/2022. 6 | // 7 | 8 | import ArgumentParser 9 | import Foundation 10 | 11 | extension Command { 12 | struct Select: ParsableCommand { 13 | static var configuration = CommandConfiguration( 14 | commandName: "select", 15 | abstract: "Adds the specified tests to the list of selected tests. Use '-o' to override." 16 | ) 17 | 18 | @Argument(help: "A list of tests to add to the skipped tests.") 19 | var tests: [String] 20 | 21 | @Option(name: .shortAndLong, help: "The path to the JSON file to parse.") 22 | var filePath: String 23 | 24 | @Flag(name: .shortAndLong, help: "If provided, the selected tests will be replaced with the specified tests.") 25 | var override = false 26 | 27 | @Option(name: .long, help: "The language to update. (examples: 'en', 'tr')") 28 | var language: String? 29 | 30 | @Option(name: .long, help: "The region to update. (examples: 'EN', 'TR')") 31 | var region: String? 32 | 33 | @Option(name: .long, help: "The maximum number of test repetitions.") 34 | var rerun: Int? 35 | 36 | func run() throws { 37 | var testPlan = try TestPlanHelper.readTestPlan(filePath: filePath) 38 | 39 | TestPlanHelper.updateSelectedTests(testPlan: &testPlan, with: tests, override: override) 40 | 41 | if let rerun = rerun { 42 | TestPlanHelper.updateRerunCount(testPlan: &testPlan, to: rerun) 43 | } 44 | 45 | if let language = language { 46 | TestPlanHelper.updateLanguage(testPlan: &testPlan, to: language) 47 | } 48 | 49 | if let region = region { 50 | TestPlanHelper.updateRegion(testPlan: &testPlan, to: region) 51 | } 52 | 53 | try TestPlanHelper.writeTestPlan(testPlan, filePath: filePath) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/xctestplanner/CLI/Commands/Test Selection/SelectTarget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Atakan Karslı on 08/12/2023. 6 | // 7 | 8 | import ArgumentParser 9 | import Foundation 10 | 11 | // Assuming TestPlanModel, TestTarget, and other required types are already defined 12 | 13 | extension Command { 14 | struct SelectTarget: ParsableCommand { 15 | static var configuration = CommandConfiguration( 16 | commandName: "select-target", 17 | abstract: "Enables the specified test targets in the test plan." 18 | ) 19 | 20 | @Argument(help: "A list of test targets to enable.") 21 | var targets: [String] 22 | 23 | @Option(name: .shortAndLong, help: "The path to the JSON file to parse.") 24 | var filePath: String 25 | 26 | func run() throws { 27 | var testPlan = try TestPlanHelper.readTestPlan(filePath: filePath) 28 | 29 | // Convert target names to a Set 30 | let affectedTargets = Set(targets) 31 | 32 | // Update the test plan with the affected targets 33 | TestPlanHelper.updateTestPlanTargets(testPlan: &testPlan, affectedTargets: affectedTargets) 34 | 35 | // Write the updated test plan back to the file 36 | try TestPlanHelper.writeTestPlan(testPlan, filePath: filePath) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/xctestplanner/CLI/Commands/Test Selection/SelectiveTesting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SelectiveTesting.swift 3 | // 4 | // 5 | // Created by Atakan Karslı on 26/05/2024. 6 | // 7 | 8 | import ArgumentParser 9 | import Foundation 10 | 11 | extension Command { 12 | struct SelectiveTesting: ParsableCommand { 13 | static var configuration = CommandConfiguration( 14 | commandName: "selective-testing", 15 | abstract: "Selectively enables tests in the specified test plan based on code changes." 16 | ) 17 | 18 | @Option(name: .shortAndLong, help: "The path to the test plan file.") 19 | var filePath: String 20 | 21 | @Option(name: .shortAndLong, help: "The target branch for git diff. Defaults to 'origin/develop'.") 22 | var targetBranch: String = "origin/develop" 23 | 24 | @Option(name: .shortAndLong, parsing: .upToNextOption, help: "List of modules to ignore. Defaults to an empty list.") 25 | var ignoreList: [String] = [] 26 | 27 | @Option(name: .shortAndLong, help: "The path to the project directory.") 28 | var projectPath: String 29 | 30 | func run() throws { 31 | Logger.log("Starting selective testing process...", level: .info) 32 | 33 | let selectedTargets = TestAnalyzer.handleTests(targetBranch: targetBranch, ignoreList: ignoreList, projectPath: projectPath) 34 | 35 | if selectedTargets.isEmpty { 36 | Logger.log("No test targets were selected based on git diff.", level: .warning) 37 | Command.SelectiveTesting.exit() 38 | } 39 | 40 | Logger.log("Selected test targets: \(selectedTargets)", level: .info) 41 | 42 | var testPlan = try TestPlanHelper.readTestPlan(filePath: filePath) 43 | let affectedTargets = Set(selectedTargets.split(separator: " ").map { String($0) }) 44 | 45 | TestPlanHelper.updateTestPlanTargets(testPlan: &testPlan, affectedTargets: affectedTargets) 46 | try TestPlanHelper.writeTestPlan(testPlan, filePath: filePath) 47 | 48 | Logger.log("Test plan successfully updated and written to file: \(filePath)", level: .info) 49 | } 50 | } 51 | } 52 | 53 | struct TestAnalyzer { 54 | static func handleTests(targetBranch: String, ignoreList: [String], projectPath: String) -> String { 55 | do { 56 | let gitDiffOutput = try executeShellCommand("cd \(projectPath) && git diff --name-only \(targetBranch)") 57 | let diffPaths = gitDiffOutput.components(separatedBy: "\n").filter { !$0.isEmpty } 58 | let moduleNames = findModuleNames(in: diffPaths, ignoreList: ignoreList, projectPath: projectPath) 59 | let dependentModules = Set() 60 | let combinedModules = Set(moduleNames + dependentModules).sorted() 61 | return selectedTestTargets(combinedModules) 62 | } catch { 63 | Logger.log("Error: \(error)", level: .error) 64 | return "" 65 | } 66 | } 67 | 68 | static func executeShellCommand(_ command: String) throws -> String { 69 | let task = Process() 70 | let pipe = Pipe() 71 | 72 | task.standardOutput = pipe 73 | task.standardError = pipe 74 | task.arguments = ["-c", command] 75 | task.executableURL = URL(fileURLWithPath: "/bin/zsh") 76 | 77 | try task.run() 78 | 79 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 80 | return String(decoding: data, as: UTF8.self) 81 | } 82 | 83 | static func findModuleNames(in paths: [String], ignoreList: [String], projectPath: String) -> [String] { 84 | var moduleNames = Set() 85 | let corePath = URL(fileURLWithPath: projectPath) 86 | 87 | for path in paths { 88 | let processedNames = processPath(path, startingAt: corePath) 89 | let filteredNames = processedNames.filter { !ignoreList.contains($0) } 90 | moduleNames.formUnion(filteredNames) 91 | } 92 | 93 | return Array(moduleNames) 94 | } 95 | 96 | static func processPath(_ path: String, startingAt basePath: URL) -> Set { 97 | var folderPath = basePath 98 | var moduleNames = Set() 99 | 100 | path.components(separatedBy: "/").forEach { component in 101 | if !component.hasSuffix(".swift") { 102 | folderPath.appendPathComponent(component) 103 | if let moduleName = extractModuleName(from: folderPath) { 104 | moduleNames.insert(moduleName) 105 | return 106 | } 107 | } 108 | } 109 | 110 | return moduleNames 111 | } 112 | 113 | static func extractModuleName(from folderPath: URL) -> String? { 114 | do { 115 | let folderContents = try FileManager.default.contentsOfDirectory(atPath: folderPath.relativePath) 116 | return folderContents.first(where: { $0.contains(".xcodeproj") })?.replacingOccurrences(of: ".xcodeproj", with: "") 117 | } catch { 118 | return nil 119 | } 120 | } 121 | 122 | static func selectedTestTargets(_ modules: [String]) -> String { 123 | let targets = modules.map { $0 + "Tests" } 124 | return targets.joined(separator: " ") 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Sources/xctestplanner/CLI/Commands/Test Selection/Skip.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Skip.swift 3 | // 4 | // 5 | // Created by Atakan Karslı on 22/12/2022. 6 | // 7 | 8 | import ArgumentParser 9 | import Foundation 10 | 11 | extension Command { 12 | struct Skip: ParsableCommand { 13 | static var configuration = CommandConfiguration( 14 | commandName: "skip", 15 | abstract: "Adds the specified tests to the list of skipped tests. Use '-o' to override." 16 | ) 17 | 18 | @Argument(help: "A list of tests to add to the skipped tests.") 19 | var tests: [String] 20 | 21 | @Option(name: .shortAndLong, help: "The path to the JSON file to parse.") 22 | var filePath: String 23 | 24 | @Flag(name: .shortAndLong, help: "If provided, the skipped tests will be replaced with the specified tests.") 25 | var override = false 26 | 27 | @Option(name: .long, help: "The language to update. (examples: 'en', 'tr')") 28 | var language: String? 29 | 30 | @Option(name: .long, help: "The region to update. (examples: 'EN', 'TR')") 31 | var region: String? 32 | 33 | @Option(name: .long, help: "The maximum number of test repetitions.") 34 | var rerun: Int? 35 | 36 | func run() throws { 37 | var testPlan = try TestPlanHelper.readTestPlan(filePath: filePath) 38 | 39 | TestPlanHelper.updateSkippedTests(testPlan: &testPlan, tests: tests, override: override) 40 | 41 | if let rerun = rerun { 42 | TestPlanHelper.updateRerunCount(testPlan: &testPlan, to: rerun) 43 | } 44 | 45 | if let language = language { 46 | TestPlanHelper.updateLanguage(testPlan: &testPlan, to: language) 47 | } 48 | 49 | if let region = region { 50 | TestPlanHelper.updateRegion(testPlan: &testPlan, to: region) 51 | } 52 | 53 | try TestPlanHelper.writeTestPlan(testPlan, filePath: filePath) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/xctestplanner/CLI/Commands/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Main.swift 3 | // 4 | // 5 | // Created by Atakan Karslı on 22/12/2022. 6 | // 7 | 8 | import ArgumentParser 9 | 10 | enum Command {} 11 | 12 | extension Command { 13 | struct Main: ParsableCommand { 14 | static var configuration: CommandConfiguration { 15 | .init( 16 | commandName: "xctestplanner", 17 | abstract: "Manage your Xcode Test Plans from the command line", 18 | version: "0.0.1", 19 | subcommands: [ 20 | Command.Select.self, 21 | Command.Skip.self, 22 | Command.Rerun.self, 23 | Command.Language.self, 24 | Command.Region.self, 25 | Command.EnvironmentVariable.self, 26 | Command.Arg.self, 27 | Command.Remove.self, 28 | Command.DefaultTestPlan.self, 29 | Command.SelectTarget.self, 30 | Command.SelectiveTesting.self, 31 | Command.DefaultOptions.self 32 | ] 33 | ) 34 | } 35 | } 36 | } 37 | 38 | Command.Main.main() 39 | -------------------------------------------------------------------------------- /Sources/xctestplanner/Core/Entity/TestPlanModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - TestPlanModel 4 | struct TestPlanModel: Codable { 5 | var configurations: [Configuration] 6 | var defaultOptions: DefaultOptions 7 | var testTargets: [TestTarget] 8 | var version: Int 9 | } 10 | 11 | // MARK: - Configuration 12 | struct Configuration: Codable { 13 | var id, name: String 14 | var options: Options 15 | } 16 | 17 | // MARK: - Options 18 | struct Options: Codable { 19 | var areLocalizationScreenshotsEnabled: Bool? 20 | var codeCoverage: Bool? 21 | var diagnosticCollectionPolicy: String? 22 | var environmentVariableEntries: [EnvironmentVariableEntry]? 23 | var language: String? 24 | var locationScenario: LocationScenario? 25 | var preferredScreenCaptureFormat: String? 26 | var region: String? 27 | var testTimeoutsEnabled: Bool? 28 | var uiTestingScreenshotsLifetime: String? 29 | var commandLineArgumentEntries: [CommandLineArgumentEntry]? 30 | var testRepetitionMode: String? 31 | var maximumTestRepetitions: Int? 32 | var defaultTestExecutionTimeAllowance: Int? 33 | var maximumTestExecutionTimeAllowance: Int? 34 | var targetForVariableExpansion: Target? 35 | } 36 | 37 | // MARK: - Target 38 | struct Target: Codable { 39 | var containerPath, identifier, name: String 40 | } 41 | 42 | // MARK: - DefaultOptions 43 | struct DefaultOptions: Codable { 44 | var areLocalizationScreenshotsEnabled: Bool? 45 | var codeCoverage: Bool? 46 | var diagnosticCollectionPolicy: String? 47 | var environmentVariableEntries: [EnvironmentVariableEntry]? 48 | var language: String? 49 | var locationScenario: LocationScenario? 50 | var preferredScreenCaptureFormat: String? 51 | var region: String? 52 | var testTimeoutsEnabled: Bool? 53 | var uiTestingScreenshotsLifetime: String? 54 | var commandLineArgumentEntries: [CommandLineArgumentEntry]? 55 | var testRepetitionMode: String? 56 | var maximumTestRepetitions: Int? 57 | var defaultTestExecutionTimeAllowance: Int? 58 | var maximumTestExecutionTimeAllowance: Int? 59 | var targetForVariableExpansion: Target? 60 | } 61 | 62 | // MARK: - CommandLineArgumentEntry 63 | struct CommandLineArgumentEntry: Codable { 64 | let argument: String 65 | let enabled: Bool? 66 | } 67 | 68 | // MARK: - EnvironmentVariableEntry 69 | struct EnvironmentVariableEntry: Codable { 70 | var key, value: String 71 | let enabled: Bool? 72 | } 73 | 74 | // MARK: - LocationScenario 75 | struct LocationScenario: Codable { 76 | var identifier: String 77 | } 78 | 79 | // MARK: - TestTarget 80 | struct TestTarget: Codable { 81 | var parallelizable: Bool? 82 | var skippedTests: [String]? 83 | var selectedTests: [String]? 84 | var target: Target 85 | var enabled: Bool? 86 | } 87 | -------------------------------------------------------------------------------- /Sources/xctestplanner/Core/Extensions/Array+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array+Extensions.swift 3 | // 4 | // 5 | // Created by Atakan Karslı on 27/02/2023. 6 | // 7 | 8 | extension Array where Element: Equatable { 9 | mutating func remove(object: Element) { 10 | guard let index = firstIndex(of: object) else {return} 11 | remove(at: index) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/xctestplanner/Core/Helper/Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger.swift 3 | // 4 | // 5 | // Created by Atakan Karslı on 22/12/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | class Logger { 11 | enum Level: String { 12 | case info 13 | case warning 14 | case error 15 | } 16 | 17 | static func log(_ message: String, level: Level, withColor: Bool = true) { 18 | switch level { 19 | case .info: 20 | if withColor { 21 | print("\u{001B}[0;36m[INFO]\u{001B}[0;0m \(message)") 22 | } else { 23 | print("[INFO] \(message)") 24 | } 25 | case .warning: 26 | if withColor { 27 | print("\u{001B}[0;33m[WARNING]\u{001B}[0;0m \(message)") 28 | } else { 29 | print("[WARNING] \(message)") 30 | } 31 | case .error: 32 | if withColor { 33 | print("\u{001B}[0;31m[ERROR]\u{001B}[0;0m \(message)") 34 | } else { 35 | print("[ERROR] \(message)") 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/xctestplanner/Core/Helper/TestPlanHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestPlanHelper.swift 3 | // 4 | // 5 | // Created by Atakan Karslı on 20/12/2022. 6 | // 7 | 8 | import ArgumentParser 9 | import Foundation 10 | 11 | class TestPlanHelper { 12 | static func readTestPlan(filePath: String) throws -> TestPlanModel { 13 | Logger.log("Reading test plan from file: \(filePath)", level: .info) 14 | let url = URL(fileURLWithPath: filePath) 15 | let data = try Data(contentsOf: url) 16 | 17 | let decoder = JSONDecoder() 18 | return try decoder.decode(TestPlanModel.self, from: data) 19 | } 20 | 21 | static func writeTestPlan(_ testPlan: TestPlanModel, filePath: String) throws { 22 | Logger.log("Writing updated test plan to file: \(filePath)", level: .info) 23 | let encoder = JSONEncoder() 24 | encoder.outputFormatting = .prettyPrinted 25 | let updatedData = try encoder.encode(testPlan) 26 | 27 | let url = URL(fileURLWithPath: filePath) 28 | try updatedData.write(to: url) 29 | } 30 | 31 | static func updateSkippedTests(testPlan: inout TestPlanModel, tests: [String], override: Bool) { 32 | checkForTestTargets(testPlan: testPlan) 33 | for (index, _) in testPlan.testTargets.enumerated() { 34 | if testPlan.testTargets[index].selectedTests != nil { 35 | testPlan.testTargets[index].selectedTests = nil 36 | } 37 | if override { 38 | Logger.log("Overriding skipped tests in test plan", level: .warning) 39 | testPlan.testTargets[index].skippedTests = tests 40 | } else { 41 | if testPlan.testTargets[index].skippedTests == nil { 42 | testPlan.testTargets[index].skippedTests = [] 43 | } 44 | Logger.log("Append given tests to skipped tests in test plan", level: .info) 45 | testPlan.testTargets[index].skippedTests?.append(contentsOf: tests) 46 | } 47 | } 48 | } 49 | 50 | static func updateSelectedTests(testPlan: inout TestPlanModel, with tests: [String], override: Bool) { 51 | checkForTestTargets(testPlan: testPlan) 52 | for (index, _) in testPlan.testTargets.enumerated() { 53 | if testPlan.testTargets[index].skippedTests != nil { 54 | testPlan.testTargets[index].skippedTests = nil 55 | } 56 | if override { 57 | Logger.log("Overriding selected tests in test plan", level: .warning) 58 | testPlan.testTargets[index].selectedTests = tests 59 | } else { 60 | if testPlan.testTargets[index].selectedTests == nil { 61 | testPlan.testTargets[index].selectedTests = [] 62 | } 63 | Logger.log("Append given tests to selected tests in test plan", level: .info) 64 | testPlan.testTargets[index].selectedTests?.append(contentsOf: tests) 65 | } 66 | } 67 | } 68 | 69 | static func removeTests(testPlan: inout TestPlanModel, with tests: [String]) { 70 | checkForTestTargets(testPlan: testPlan) 71 | for (index, _) in testPlan.testTargets.enumerated() { 72 | Logger.log("Remove given tests from selected tests in test plan", level: .info) 73 | for test in tests { 74 | testPlan.testTargets[index].selectedTests?.remove(object: test) 75 | } 76 | } 77 | } 78 | 79 | static func updateRerunCount(testPlan: inout TestPlanModel, to count: Int) { 80 | Logger.log("Updating rerun count in test plan to: \(count)", level: .info) 81 | if testPlan.defaultOptions.testRepetitionMode == nil { 82 | testPlan.defaultOptions.testRepetitionMode = TestPlanValue.retryOnFailure.rawValue 83 | } 84 | testPlan.defaultOptions.maximumTestRepetitions = count 85 | } 86 | 87 | static func updateLanguage(testPlan: inout TestPlanModel, to language: String) { 88 | Logger.log("Updating language in test plan to: \(language)", level: .info) 89 | testPlan.defaultOptions.language = language.lowercased() 90 | } 91 | 92 | static func updateRegion(testPlan: inout TestPlanModel, to region: String) { 93 | Logger.log("Updating region in test plan to: \(region)", level: .info) 94 | testPlan.defaultOptions.region = region.uppercased() 95 | } 96 | 97 | static func setEnvironmentVariable(testPlan: inout TestPlanModel, key: String, value: String, enabled: Bool? = true) { 98 | Logger.log("Setting environment variable with key '\(key)' and value '\(value)' in test plan", level: .info) 99 | 100 | // Add environment variable to defaultOptions 101 | if testPlan.defaultOptions.environmentVariableEntries == nil { 102 | testPlan.defaultOptions.environmentVariableEntries = [] 103 | } 104 | testPlan.defaultOptions.environmentVariableEntries?.append(EnvironmentVariableEntry(key: key, value: value, enabled: enabled)) 105 | 106 | for index in testPlan.configurations.indices { 107 | var configuration = testPlan.configurations[index] 108 | if configuration.options.environmentVariableEntries == nil { 109 | configuration.options.environmentVariableEntries = [] 110 | } 111 | configuration.options.environmentVariableEntries?.append(EnvironmentVariableEntry(key: key, value: value, enabled: enabled)) 112 | testPlan.configurations[index] = configuration 113 | } 114 | } 115 | 116 | static func setArgument(testPlan: inout TestPlanModel, key: String, disabled: Bool) { 117 | if testPlan.defaultOptions.commandLineArgumentEntries == nil { 118 | testPlan.defaultOptions.commandLineArgumentEntries = [] 119 | } 120 | if disabled { 121 | Logger.log("Setting command line argument with key '\(key)' in test plan as disabled", level: .info) 122 | testPlan.defaultOptions.commandLineArgumentEntries?.append(CommandLineArgumentEntry(argument: key, enabled: !disabled)) 123 | } else { 124 | Logger.log("Setting command line argument with key '\(key)', enabled by default", level: .info) 125 | testPlan.defaultOptions.commandLineArgumentEntries?.append(CommandLineArgumentEntry(argument: key, enabled: nil)) 126 | } 127 | } 128 | 129 | static func checkForTestTargets(testPlan: TestPlanModel) { 130 | if testPlan.testTargets.isEmpty { 131 | Logger.log("Error: Test plan does not have any test targets. Add a test target before attempting to update the selected or skipped tests.", level: .error) 132 | exit(1) 133 | } 134 | } 135 | 136 | static func updateTestPlanTargets(testPlan: inout TestPlanModel, affectedTargets: Set) { 137 | checkForTestTargets(testPlan: testPlan) 138 | testPlan.testTargets = testPlan.testTargets.map { testTarget in 139 | let isEnabled = affectedTargets.contains(testTarget.target.name) 140 | return TestTarget(parallelizable: testTarget.parallelizable, 141 | skippedTests: testTarget.skippedTests, 142 | selectedTests: testTarget.selectedTests, 143 | target: testTarget.target, 144 | enabled: isEnabled) 145 | } 146 | } 147 | 148 | static func runGitDiffAndGetSelectedTargets() -> String { 149 | printWithColor("Code diff analysis in progress. This may take a moment (up to 30 seconds)...", color: .yellow) 150 | let output = shell(launchPath: "/bin/bash", arguments: ["-c", "git diff --name-only HEAD~1"]) 151 | 152 | let testFiles = output 153 | .split(separator: "\n") 154 | .filter { $0.contains("Tests/") } 155 | .map { String($0) } 156 | 157 | guard !testFiles.isEmpty else { 158 | printWithColor("No changes detected affecting tests. No tests have been selected", color: .red) 159 | return "" 160 | } 161 | 162 | let selectedTests = testFiles.joined(separator: " ") 163 | printWithColor("Selected test files: \(selectedTests)", color: .green) 164 | return selectedTests 165 | } 166 | 167 | static func selectTests(withTargets targets: String, testPlanPath: String) { 168 | printWithColor("Selecting tests with xctestplanner...", color: .yellow) 169 | 170 | let targetsArray = targets.components(separatedBy: " ") 171 | let quotedTargets = targetsArray.map { "'\($0)'" }.joined(separator: " ") 172 | 173 | let command = "xctestplanner select-target \(quotedTargets) -f \(testPlanPath)" 174 | printWithColor("Executing command: \(command)", color: .yellow) 175 | 176 | let result = shell(launchPath: "/bin/bash", arguments: ["-c", command]) 177 | 178 | printWithColor("\(result)", color: .green) 179 | } 180 | 181 | static func shell(launchPath: String, arguments: [String]) -> String { 182 | let process = Process() 183 | let outputPipe = Pipe() 184 | let errorPipe = Pipe() 185 | 186 | process.executableURL = URL(fileURLWithPath: launchPath) 187 | process.arguments = arguments 188 | process.standardOutput = outputPipe 189 | process.standardError = errorPipe 190 | 191 | do { 192 | try process.run() 193 | } catch { 194 | print("Error executing process: \(error)") 195 | return "" 196 | } 197 | 198 | let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() 199 | let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() 200 | 201 | if let output = String(data: outputData, encoding: .utf8), !output.isEmpty { 202 | return output 203 | } else if let error = String(data: errorData, encoding: .utf8), !error.isEmpty { 204 | return error 205 | } 206 | 207 | process.waitUntilExit() 208 | return "" 209 | } 210 | 211 | static func getFirstTestTarget(filePath: String) throws -> String { 212 | let testPlan = try readTestPlan(filePath: filePath) 213 | guard let firstTarget = testPlan.testTargets.first else { 214 | printWithColor("No test targets found in the test plan.", color: .red) 215 | throw NSError(domain: "TestPlanHelper", code: 1, userInfo: [NSLocalizedDescriptionKey: "No test targets found in the test plan."]) 216 | } 217 | return firstTarget.target.name 218 | } 219 | 220 | static func configureDefaultOptions( 221 | testPlan: inout TestPlanModel, 222 | enableLocalizationScreenshots: Bool, 223 | enableCodeCoverage: Bool?, 224 | diagnosticPolicy: String?, 225 | enableTimeouts: Bool, 226 | screenshotLifetime: String?, 227 | screenCaptureFormat: String? 228 | ) { 229 | Logger.log("Configuring default options and updating all configurations in the test plan...", level: .info) 230 | 231 | // Configure default options directly 232 | if enableLocalizationScreenshots { 233 | testPlan.defaultOptions.areLocalizationScreenshotsEnabled = true 234 | } else { 235 | testPlan.defaultOptions.areLocalizationScreenshotsEnabled = nil 236 | } 237 | 238 | if let codeCoverage = enableCodeCoverage { 239 | testPlan.defaultOptions.codeCoverage = codeCoverage 240 | } else { 241 | testPlan.defaultOptions.codeCoverage = nil 242 | } 243 | 244 | if let diagnosticPolicy = diagnosticPolicy { 245 | testPlan.defaultOptions.diagnosticCollectionPolicy = diagnosticPolicy 246 | } else { 247 | testPlan.defaultOptions.diagnosticCollectionPolicy = nil 248 | } 249 | 250 | if enableTimeouts { 251 | testPlan.defaultOptions.testTimeoutsEnabled = true 252 | } else { 253 | testPlan.defaultOptions.testTimeoutsEnabled = nil 254 | } 255 | 256 | if let screenshotLifetime = screenshotLifetime, screenshotLifetime == "keepAlways" || screenshotLifetime == "deleteAfterSuccess" { 257 | testPlan.defaultOptions.uiTestingScreenshotsLifetime = screenshotLifetime 258 | } else { 259 | testPlan.defaultOptions.uiTestingScreenshotsLifetime = nil 260 | } 261 | 262 | if let screenCaptureFormat = screenCaptureFormat, screenCaptureFormat == "screenshot" { 263 | testPlan.defaultOptions.preferredScreenCaptureFormat = "screenshot" 264 | } else { 265 | testPlan.defaultOptions.preferredScreenCaptureFormat = nil 266 | } 267 | 268 | // Update each configuration's options 269 | for index in testPlan.configurations.indices { 270 | var configurationOptions = testPlan.configurations[index].options 271 | 272 | if enableLocalizationScreenshots { 273 | configurationOptions.areLocalizationScreenshotsEnabled = true 274 | } else { 275 | configurationOptions.areLocalizationScreenshotsEnabled = nil 276 | } 277 | 278 | if let codeCoverage = enableCodeCoverage { 279 | configurationOptions.codeCoverage = codeCoverage 280 | } else { 281 | configurationOptions.codeCoverage = nil 282 | } 283 | 284 | if let diagnosticPolicy = diagnosticPolicy { 285 | configurationOptions.diagnosticCollectionPolicy = diagnosticPolicy 286 | } else { 287 | configurationOptions.diagnosticCollectionPolicy = nil 288 | } 289 | 290 | if enableTimeouts { 291 | configurationOptions.testTimeoutsEnabled = true 292 | } else { 293 | configurationOptions.testTimeoutsEnabled = nil 294 | } 295 | 296 | if let screenshotLifetime = screenshotLifetime, screenshotLifetime == "keepAlways" || screenshotLifetime == "deleteAfterSuccess" { 297 | configurationOptions.uiTestingScreenshotsLifetime = screenshotLifetime 298 | } else { 299 | configurationOptions.uiTestingScreenshotsLifetime = nil 300 | } 301 | 302 | if let screenCaptureFormat = screenCaptureFormat, screenCaptureFormat == "screenshot" { 303 | configurationOptions.preferredScreenCaptureFormat = "screenshot" 304 | } else { 305 | configurationOptions.preferredScreenCaptureFormat = nil 306 | } 307 | testPlan.configurations[index].options = configurationOptions 308 | } 309 | } 310 | } 311 | 312 | enum TestPlanValue: String { 313 | case retryOnFailure 314 | } 315 | 316 | 317 | enum Colors: String { 318 | case red = "\u{001B}[0;31m" 319 | case green = "\u{001B}[0;34m" 320 | case yellow = "\u{001B}[0;33m" 321 | case `default` = "\u{001B}[0;0m" 322 | } 323 | 324 | func printWithColor(_ text: String, color: Colors = .default) { 325 | print(color.rawValue + text + Colors.default.rawValue) 326 | } 327 | --------------------------------------------------------------------------------