Skip to content

Commit 905f4be

Browse files
authored
infra(ci): Add run command to integ test script (#15433)
1 parent 1a9c17d commit 905f4be

File tree

3 files changed

+277
-17
lines changed

3 files changed

+277
-17
lines changed

.github/workflows/firebaseai.yml

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,12 @@ jobs:
3939
if: (github.repository == 'Firebase/firebase-ios-sdk' && github.event_name == 'schedule') || github.event_name == 'pull_request'
4040
strategy:
4141
matrix:
42-
target: [iOS]
43-
os: [macos-15]
4442
include:
4543
- os: macos-15
44+
target: iOS
4645
xcode: Xcode_16.4
4746
- os: macos-26
47+
target: iOS
4848
xcode: Xcode_26.0
4949
runs-on: ${{ matrix.os }}
5050
needs: spm
@@ -59,19 +59,8 @@ jobs:
5959
with:
6060
path: .build
6161
key: ${{ needs.spm.outputs.cache_key }}
62-
- name: Install Secret GoogleService-Info.plist
63-
run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg \
64-
FirebaseAI/Tests/TestApp/Resources/GoogleService-Info.plist "$secrets_passphrase"
65-
- name: Install Secret GoogleService-Info-Spark.plist
66-
run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info-Spark.plist.gpg \
67-
FirebaseAI/Tests/TestApp/Resources/GoogleService-Info-Spark.plist "$secrets_passphrase"
68-
- name: Install Secret Credentials.swift
69-
run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/FirebaseAI/TestApp-Credentials.swift.gpg \
70-
FirebaseAI/Tests/TestApp/Tests/Integration/Credentials.swift "$secrets_passphrase"
71-
- name: Xcode
72-
run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer
73-
- name: Run IntegrationTests
74-
run: scripts/build.sh FirebaseAIIntegration ${{ matrix.target }}
62+
- name: Run integration tests
63+
run: scripts/repo.sh tests run --secrets ./scripts/secrets/AI.json --platforms ${{ matrix.target }} --xcode ${{ matrix.xcode }} AI
7564
- name: Upload xcodebuild logs
7665
if: failure()
7766
uses: actions/upload-artifact@v4
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import ArgumentParser
18+
import Foundation
19+
import Logging
20+
import Util
21+
22+
extension Tests {
23+
/// Command for running the integration tests of a given SDK.
24+
struct Run: ParsableCommand {
25+
nonisolated(unsafe) static var configuration = CommandConfiguration(
26+
abstract: "Run the integration tests for a given SDK.",
27+
usage: """
28+
tests run [--overwrite] [--secrets <file_path>] [--xcode <version_or_path>] [--platforms <platforms> ...] [<sdk>]
29+
30+
tests run --xcode Xcode_16.4.0 --platforms iOS --platforms macOS AI
31+
tests run --xcode "/Applications/Xcode_15.0.0.app" --platforms tvOS Storage
32+
tests run --overwrite --secrets ./scripts/secrets/AI.json AI
33+
""",
34+
discussion: """
35+
If multiple Xcode versions are installed, you must specify an Xcode version manually via the
36+
'xcode' option. If you run the script without doing so, the script will log an error message
37+
that contains all the Xcode versions installed, telling you to manually specify the 'xcode' option.
38+
39+
Note that Xcode versions can be specified as either the application name, or a full path. For
40+
example, the following are both valid:
41+
"Xcode_16.4.0" and "/Applications/Xcode_16.4.0.app".
42+
43+
If your tests have encrypted secret files, you can pass a json file to the script via the
44+
'secrets' option. The script will automatically decrypt them before running the tests, and
45+
delete them after running the tests. You'll also need to provide the password that the secret
46+
files were encrypted with via the 'secrets_passphrase' environment variable. The json file
47+
should be an array of json elements in the format of:
48+
{ encrypted: <path-to-encrypted-file>, destination: <where-to-output-decrypted-file> }
49+
50+
If you pass a secret file, but decrypted files already exist at the destination, the script
51+
will NOT overwrite them. The script will also not delete these files either. If you want
52+
the script to overwrite and delete secret files, regardless if they existed before the script
53+
ran, you can pass the 'overwrite' flag.
54+
""",
55+
)
56+
57+
@Option(
58+
help:
59+
"""
60+
Xcode version to run tests against. \
61+
Can be either the application name, or a full path (eg; "Xcode_16.4.0" or "/Applications/Xcode_16.4.0.app").
62+
By default, the script will look for your local Xcode installation.
63+
"""
64+
)
65+
var xcode: String = ""
66+
67+
@Option(help: "Platforms to run rests on.")
68+
var platforms: [Platform] = [.iOS]
69+
70+
@Option(help: "Path to a json file containing an array of secret files to use, if any.")
71+
var secrets: String? = nil
72+
73+
@Flag(help: "Overwrite existing decrypted secret files.")
74+
var overwrite: Bool = false
75+
76+
@Argument(
77+
help: """
78+
The SDK to run integration tests for.
79+
There should be a build target for the SDK that follows the format "Firebase{SDK}Integration"
80+
"""
81+
)
82+
var sdk: String
83+
84+
static let log: Logger = .init(label: "Tests::Run")
85+
private var log: Logger { Self.log }
86+
87+
/// A path to the Xcode to use.
88+
///
89+
/// Only populated after `validate()` runs.
90+
private var xcodePath: String = ""
91+
92+
mutating func validate() throws {
93+
if xcode.isEmpty {
94+
try findAndValidateXcodeOnDisk()
95+
} else {
96+
try validateProvidedXcode()
97+
}
98+
}
99+
100+
/// When the `xcode` option isn't provided, try to find an installation on disk.
101+
private mutating func findAndValidateXcodeOnDisk() throws {
102+
let xcodes = try findXcodeVersions()
103+
guard xcodes.count == 1 else {
104+
let formattedXcodes = xcodes.map { $0.path(percentEncoded: false) }
105+
log.error(
106+
"Multiple Xcode versions found.",
107+
metadata: ["versions": "\(formattedXcodes)"]
108+
)
109+
110+
throw ValidationError(
111+
"Multiple Xcode installations found. Explicitly pass the 'xcode' option to specify which to use."
112+
)
113+
}
114+
xcodePath = xcodes[0].path()
115+
log.debug("Found Xcode installation", metadata: ["path": "\(xcodePath)"])
116+
}
117+
118+
/// When the `xcode` option is provided, ensure it exists.
119+
///
120+
/// The `xcode` argument can be either a full path to the application, or just the application
121+
/// name.
122+
private mutating func validateProvidedXcode() throws {
123+
if xcode.hasSuffix(".app") {
124+
// it's a full path to the Xcode, just ensure it exists
125+
guard FileManager.default.fileExists(atPath: xcode) else {
126+
throw ValidationError("Xcode application not found at path: \(xcode)")
127+
}
128+
xcodePath = URL(filePath: xcode).path()
129+
} else {
130+
// it's the application name, find an Xcode installation that matches
131+
let xcodes = try findXcodeVersions()
132+
guard
133+
let match = xcodes.first(where: {
134+
$0.path(percentEncoded: false).contains("\(xcode).app")
135+
})
136+
else {
137+
let formattedXcodes = xcodes.map { $0.path(percentEncoded: false) }
138+
log.error("Invalid Xcode specified.",
139+
metadata: ["versions": "\(formattedXcodes)"])
140+
throw ValidationError(
141+
"Failed to find an Xcode installation that matches: \(xcode)"
142+
)
143+
}
144+
xcodePath = match.path()
145+
log.debug("Found matching Xcode", metadata: ["path": "\(xcodePath)"])
146+
}
147+
}
148+
149+
private func findXcodeVersions() throws -> [URL] {
150+
let applicationDirs = FileManager.default.urls(
151+
for: .applicationDirectory, in: .allDomainsMask
152+
).filter { url in
153+
// file manager lists application dirs that CAN exist, so we should check if they actually
154+
// do exist before trying to get their contents
155+
let exists = FileManager.default.fileExists(atPath: url.path())
156+
if !exists {
157+
log.debug(
158+
"Application directory doesn't exists, so we're skipping it.",
159+
metadata: ["directory": "\(url.path())"]
160+
)
161+
}
162+
return exists
163+
}
164+
165+
log.debug(
166+
"Searching application directories for Xcode installations.",
167+
metadata: ["directories": "\(applicationDirs)"]
168+
)
169+
170+
let allApplications = try applicationDirs.flatMap { URL in
171+
try FileManager.default.contentsOfDirectory(
172+
at: URL, includingPropertiesForKeys: nil
173+
)
174+
}
175+
176+
let xcodes = allApplications.filter { file in
177+
let isXcode = file.lastPathComponent.contains(/Xcode.*\.app/)
178+
if !isXcode {
179+
log.debug(
180+
"Application isn't an Xcode installation, so we're skipping it.",
181+
metadata: ["application": "\(file.lastPathComponent)"]
182+
)
183+
}
184+
return isXcode
185+
}
186+
guard !xcodes.isEmpty else {
187+
throw ValidationError(
188+
"Failed to find any Xcode versions installed. Please install Xcode."
189+
)
190+
}
191+
192+
log.debug("Found Xcode installations.", metadata: ["installations": "\(xcodes)"])
193+
return xcodes
194+
}
195+
196+
mutating func run() throws {
197+
var secretFiles: [SecretFile] = []
198+
199+
defer {
200+
// ensure secret files are deleted, regardless of test result
201+
for file in secretFiles {
202+
do {
203+
log.debug("Deleting secret file", metadata: ["file": "\(file.destination)"])
204+
try FileManager.default.removeItem(atPath: file.destination)
205+
} catch {
206+
log.error(
207+
"Failed to delete secret file.",
208+
metadata: [
209+
"file": "\(file.destination)",
210+
"error": "\(error.localizedDescription)",
211+
]
212+
)
213+
}
214+
}
215+
}
216+
217+
// decrypt secrets if we need to
218+
if let secrets {
219+
var args = ["--json"]
220+
if overwrite {
221+
args.append("--overwrite")
222+
}
223+
args.append(secrets)
224+
var decrypt = try Decrypt.parse(args)
225+
try decrypt.validate()
226+
227+
// save the secret files to delete later
228+
secretFiles = decrypt.files
229+
230+
try decrypt.run()
231+
}
232+
233+
let buildScript = URL(filePath: "scripts/build.sh", relativeTo: URL.currentDirectory())
234+
for platform in platforms {
235+
log.info(
236+
"Running integration tests",
237+
metadata: ["sdk": "\(sdk)", "platform": "\(platform)"]
238+
)
239+
240+
// instead of using xcode-select (which requires sudo), we can use the env variable
241+
// `DEVELOPER_DIR` to point to our target xcode
242+
let build = Process(
243+
buildScript.path(percentEncoded: false),
244+
env: ["DEVELOPER_DIR": "\(xcodePath)/Contents/Developer"],
245+
inheritEnvironment: true
246+
)
247+
248+
let exitCode = try build.runWithSignals([
249+
"Firebase\(sdk)Integration", "\(platform)",
250+
])
251+
guard exitCode == 0 else {
252+
log.error(
253+
"Failed to run integration tests.",
254+
metadata: ["sdk": "\(sdk)", "platform": "\(platform)"]
255+
)
256+
throw ExitCode(exitCode)
257+
}
258+
}
259+
}
260+
}
261+
}
262+
263+
/// Apple platforms that tests can be ran under.
264+
enum Platform: String, Codable, ExpressibleByArgument, CaseIterable {
265+
case iOS
266+
case iPad
267+
case macOS
268+
case tvOS
269+
case watchOS
270+
case visionOS
271+
}

scripts/repo/Sources/Tests/main.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ struct Tests: ParsableCommand {
2626
debugging, you can set the "LOG_LEVEL" environment variable to a different minimum level \
2727
(eg; "debug").
2828
""",
29-
subcommands: [Decrypt.self]
30-
// defaultSubcommand: Run.self
29+
subcommands: [Decrypt.self, Run.self],
30+
defaultSubcommand: Run.self
3131
)
3232
}
3333

0 commit comments

Comments
 (0)