├── .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 |
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 |
--------------------------------------------------------------------------------