Skip to content

Commit 63225fa

Browse files
authored
Correctly parse connected Android test JUnit results after "ANDROID_SERIAL=... skip test" (#642)
* Handle connected Android test JUnit results * Refine connected Android test result handling * Improve connected Android JUnit result handling 1. discovery is now mode-specific rather than searching both unit-test and connected-test roots. Connected runs only look under the connected Android result folders, which avoids picking up stale output from a prior Robolectric run. also added a regression test covering that case. 2. reusing Gradle’s native JUnit XML as the source of truth, both for parsing and for staging 3. added support for variant-specific connected Android test result paths, including free/paid flavors and custom test build types, to prevent the recurring “JUnit path” error when results are not written under the default debug folder. * Patched the parser in GradleDriver.swift so connected Android <failure> elements no longer require a message attribute. If the attribute is missing, we now use the first line of the failure body
1 parent 5aae9ad commit 63225fa

3 files changed

Lines changed: 306 additions & 25 deletions

File tree

Sources/SkipDrive/GradleDriver.swift

Lines changed: 122 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ import FoundationXML
1313
/// user's `PATH` environment.
1414
@available(macOS 13, macCatalyst 16, iOS 16, tvOS 16, watchOS 8, *)
1515
public struct GradleDriver {
16+
public struct ParsedTestResults {
17+
public var testSuites: [TestSuite]
18+
public var resultFiles: [URL]
19+
20+
public init(testSuites: [TestSuite], resultFiles: [URL]) {
21+
self.testSuites = testSuites
22+
self.resultFiles = resultFiles
23+
}
24+
}
25+
1626
/// The minimum version of Kotlin we can work with
1727
public static let minimumKotlinVersion = Version(1, 8, 0)
1828

@@ -107,7 +117,7 @@ public struct GradleDriver {
107117
/// - exitHandler: the exit handler, which may want to permit a process failure in order to have time to parse the tests
108118
/// - Returns: an array of parsed test suites containing information about the test run
109119
@available(macOS 13, macCatalyst 16, iOS 16, tvOS 16, watchOS 8, *)
110-
public func launchGradleProcess(in workingDirectory: URL?, buildFolder: String = ".build", module: String?, actions: [String], arguments: [String], environment: [String: String] = ProcessInfo.processInfo.environmentWithDefaultToolPaths, daemon enableDaemon: Bool = true, info infoFlag: Bool = false, quiet quietFlag: Bool = false, plain plainFlag: Bool = true, maxMemory: UInt64? = nil, failFast failFastFlag: Bool = false, noBuildCache noBuildCacheFlag: Bool = false, continue continueFlag: Bool = false, offline offlineFlag: Bool = false, rerunTasks rerunTasksFlag: Bool = true, exitHandler: @escaping (ProcessResult) throws -> ()) async throws -> (output: AsyncLineOutput, result: () throws -> [TestSuite]) {
120+
public func launchGradleProcess(in workingDirectory: URL?, buildFolder: String = ".build", module: String?, actions: [String], arguments: [String], environment: [String: String] = ProcessInfo.processInfo.environmentWithDefaultToolPaths, daemon enableDaemon: Bool = true, info infoFlag: Bool = false, quiet quietFlag: Bool = false, plain plainFlag: Bool = true, maxMemory: UInt64? = nil, failFast failFastFlag: Bool = false, noBuildCache noBuildCacheFlag: Bool = false, continue continueFlag: Bool = false, offline offlineFlag: Bool = false, rerunTasks rerunTasksFlag: Bool = true, exitHandler: @escaping (ProcessResult) throws -> ()) async throws -> (output: AsyncLineOutput, result: () throws -> ParsedTestResults) {
111121

112122

113123
var args = actions + arguments
@@ -122,7 +132,7 @@ public struct GradleDriver {
122132
// this enables reporting on deprecated features
123133
args += ["--warning-mode", "all"]
124134

125-
var testResultFolder: URL? = nil
135+
var testResultFolders: [URL] = []
126136

127137
if let module = module {
128138
let moduleURL = URL(fileURLWithPath: module, isDirectory: true, relativeTo: workingDirectory)
@@ -133,7 +143,8 @@ public struct GradleDriver {
133143
let buildDir = "\(buildFolder)/\(module)"
134144
let testResultPath = "\(buildDir)/test-results"
135145
args += ["-PbuildDir=\(buildDir)"]
136-
testResultFolder = URL(fileURLWithPath: testResultPath, isDirectory: true, relativeTo: moduleURL)
146+
let testResultFolder = URL(fileURLWithPath: testResultPath, isDirectory: true, relativeTo: moduleURL)
147+
testResultFolders = Self.testResultFolders(for: testResultFolder, actions: actions)
137148
}
138149

139150
// this allows multiple simultaneous gradle builds to take place
@@ -218,7 +229,7 @@ public struct GradleDriver {
218229

219230
}
220231

221-
if let testResultFolder = testResultFolder {
232+
for testResultFolder in testResultFolders {
222233
#if os(macOS)
223234
try? FileManager.default.trashItem(at: testResultFolder, resultingItemURL: nil) // remove the test folder, since a build failure won't clear it and it will appear as if the tests ran successfully
224235
#else
@@ -227,7 +238,67 @@ public struct GradleDriver {
227238
}
228239

229240
let output = try await execGradle(in: workingDirectory, args: args, env: env, onExit: exitHandler)
230-
return (output, { try Self.parseTestResults(in: testResultFolder) })
241+
return (output, { try Self.parseTestResults(in: testResultFolders) })
242+
}
243+
244+
static func testResultFolders(for testResultFolder: URL, actions: [String]) -> [URL] {
245+
if actions.contains(where: { $0.hasPrefix("connected") }) {
246+
let buildFolder = testResultFolder.deletingLastPathComponent()
247+
let connectedRoot = buildFolder
248+
.appendingPathComponent("outputs", isDirectory: true)
249+
.appendingPathComponent("androidTest-results", isDirectory: true)
250+
.appendingPathComponent("connected", isDirectory: true)
251+
let variantFolders = connectedTestVariants(for: actions).map {
252+
connectedRoot.appendingPathComponent($0, isDirectory: true)
253+
}
254+
return variantFolders + [connectedRoot]
255+
}
256+
257+
return [testResultFolder]
258+
}
259+
260+
public static func connectedTestVariants(for actions: [String]) -> [String] {
261+
var variants: [String] = []
262+
for action in actions {
263+
guard let variant = connectedTestVariant(for: action), !variants.contains(variant) else {
264+
continue
265+
}
266+
variants.append(variant)
267+
}
268+
return variants
269+
}
270+
271+
public static func connectedTestVariant(for action: String) -> String? {
272+
guard action.hasPrefix("connected"), action.hasSuffix("AndroidTest") else {
273+
return nil
274+
}
275+
276+
let start = action.index(action.startIndex, offsetBy: "connected".count)
277+
let end = action.index(action.endIndex, offsetBy: -"AndroidTest".count)
278+
let variant = String(action[start..<end])
279+
guard !variant.isEmpty else {
280+
return nil
281+
}
282+
283+
return variant.prefix(1).lowercased() + variant.dropFirst()
284+
}
285+
286+
public static func connectedResultVariant(for resultFile: URL) -> String? {
287+
let components = resultFile.standardizedFileURL.pathComponents
288+
guard let connectedIndex = components.firstIndex(of: "connected"),
289+
components.indices.contains(connectedIndex + 1) else {
290+
return nil
291+
}
292+
293+
return components[connectedIndex + 1]
294+
}
295+
296+
public static func unitTestResultFolderName(forConnectedResultFiles resultFiles: [URL]) -> String {
297+
guard let variant = resultFiles.compactMap(connectedResultVariant(for:)).first else {
298+
return "testDebugUnitTest"
299+
}
300+
301+
return "test" + variant.prefix(1).uppercased() + variant.dropFirst() + "UnitTest"
231302
}
232303

233304
/// Executes `skiptool info` and returns the info dictionary.
@@ -509,13 +580,20 @@ public struct GradleDriver {
509580

510581
#if os(macOS) || os(Linux) || targetEnvironment(macCatalyst)
511582
init(from element: XMLElement, in url: URL) throws {
512-
guard let message = element.attribute(forName: "message")?.stringValue else {
513-
throw GradleDriverError.missingProperty(url: url, propertyName: "message")
514-
}
515-
516583
let type = element.attribute(forName: "type")?.stringValue
517584

518585
let contents = element.stringValue
586+
// Connected Android reports can omit the failure message attribute and
587+
// only provide the assertion text in the element body.
588+
let message = element.attribute(forName: "message")?.stringValue
589+
?? contents?
590+
.split(separator: "\n", maxSplits: 1)
591+
.first?
592+
.trimmingCharacters(in: .whitespacesAndNewlines)
593+
594+
guard let message, !message.isEmpty else {
595+
throw GradleDriverError.missingProperty(url: url, propertyName: "message")
596+
}
519597

520598
self.message = message
521599
self.type = type
@@ -524,15 +602,11 @@ public struct GradleDriver {
524602
#endif
525603
}
526604

527-
private static func parseTestResults(in testFolder: URL?) throws -> [TestSuite] {
528-
guard let testFolder = testFolder else {
529-
return []
605+
static func parseTestResults(in testFolders: [URL]) throws -> ParsedTestResults {
606+
guard !testFolders.isEmpty else {
607+
return ParsedTestResults(testSuites: [], resultFiles: [])
530608
}
531609
let fm = FileManager.default
532-
if !fm.fileExists(atPath: testFolder.path) {
533-
// missing folder
534-
throw GradleDriverError("The expected test output folder did not exist, which may indicate that the gradle process encountered a build error or other issue. Missing folder: \(testFolder.path)")
535-
}
536610

537611
func parseTestSuite(resultURL: URL) throws -> [TestSuite] {
538612
if try resultURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory != false {
@@ -547,12 +621,40 @@ public struct GradleDriver {
547621
return try TestSuite.parse(contentsOf: resultURL)
548622
}
549623

550-
let dirs = try fm.contentsOfDirectory(at: testFolder, includingPropertiesForKeys: [.isDirectoryKey])
624+
func xmlResults(in testFolder: URL) throws -> [URL] {
625+
guard fm.fileExists(atPath: testFolder.path) else {
626+
return []
627+
}
551628

552-
// check each subdir (e.g., "build/test-results/test" and "build/test-results/testDebugUnitTest/" and "build/test-results/testReleaseUnitTest/"
553-
let subdirs = try dirs.flatMap({ try fm.contentsOfDirectory(at: $0, includingPropertiesForKeys: [.isDirectoryKey]) })
629+
var results: [URL] = []
630+
if let enumerator = fm.enumerator(at: testFolder, includingPropertiesForKeys: [.isDirectoryKey]) {
631+
for case let resultURL as URL in enumerator {
632+
if resultURL.pathExtension.lowercased() == "xml" {
633+
results.append(resultURL)
634+
}
635+
}
636+
}
637+
return results.sorted { $0.path < $1.path }
638+
}
639+
640+
let existingFolders = testFolders.filter { fm.fileExists(atPath: $0.path) }
641+
guard !existingFolders.isEmpty else {
642+
let folders = testFolders.map(\.path).joined(separator: ", ")
643+
throw GradleDriverError("The expected test output folder did not exist, which may indicate that the gradle process encountered a build error or other issue. Missing folders: \(folders)")
644+
}
645+
646+
for testFolder in existingFolders {
647+
let results = try xmlResults(in: testFolder)
648+
if !results.isEmpty {
649+
return ParsedTestResults(
650+
testSuites: try Array(results.compactMap(parseTestSuite).joined()),
651+
resultFiles: results
652+
)
653+
}
654+
}
554655

555-
return try Array(subdirs.compactMap(parseTestSuite).joined())
656+
let folders = existingFolders.map(\.path).joined(separator: ", ")
657+
throw GradleDriverError("The expected test output folder did not contain any JUnit XML results. Searched folders: \(folders)")
556658
}
557659
}
558660

Sources/SkipTest/XCGradleHarness.swift

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ extension XCGradleHarness where Self : XCTestCase {
5757

5858
var actions = actions
5959
//let isTestAction = testFilter != nil
60-
let isTestAction = actions.contains(where: { $0.hasPrefix("test") })
60+
let isTestAction = actions.contains(where: { $0.hasPrefix("test") || $0.hasPrefix("connected") })
6161

6262

6363
// override test targets so we can specify "SKIP_GRADLE_TEST_TARGET=connectedDebugAndroidTest" and have the tests run against the Android emulator (e.g., using reactivecircus/android-emulator-runner@v2 with CI)
@@ -126,7 +126,13 @@ extension XCGradleHarness where Self : XCTestCase {
126126

127127
// if any of the actions are a test case, when try to parse the XML results
128128
if isTestAction {
129-
let testSuites = try parseResults()
129+
let parsedResults = try parseResults()
130+
let testSuites = parsedResults.testSuites
131+
if actions.contains(where: { $0.hasPrefix("connected") }) {
132+
// The Skip CLI still expects the canonical unit-test JUnit path.
133+
// Copy the connected run's native JUnit files there for downstream reporting.
134+
try stageConnectedJUnitResultsForSkipCLI(dir: dir, moduleName: baseModuleName, resultFiles: parsedResults.resultFiles)
135+
}
130136
// the absense of any test data probably indicates some sort of mis-configuration or else a build failure
131137
if testSuites.isEmpty {
132138
XCTFail("No tests were run; this may indicate an issue with running the tests on \(deviceID ?? "Robolectric"). See the test output and Report Navigator log for details.")
@@ -155,6 +161,30 @@ extension XCGradleHarness where Self : XCTestCase {
155161
}
156162
}
157163

164+
private func stageConnectedJUnitResultsForSkipCLI(dir: URL, moduleName: String, resultFiles: [URL]) throws {
165+
let buildRoot = dir
166+
.appendingPathComponent(moduleName, isDirectory: true)
167+
.appendingPathComponent(".build", isDirectory: true)
168+
.appendingPathComponent(moduleName, isDirectory: true)
169+
let junitTaskFolderName = GradleDriver.unitTestResultFolderName(forConnectedResultFiles: resultFiles)
170+
let junitResultRoot = buildRoot
171+
.appendingPathComponent("test-results", isDirectory: true)
172+
.appendingPathComponent(junitTaskFolderName, isDirectory: true)
173+
174+
let fm = FileManager.default
175+
if fm.fileExists(atPath: junitResultRoot.path) {
176+
try fm.removeItem(at: junitResultRoot)
177+
}
178+
try fm.createDirectory(at: junitResultRoot, withIntermediateDirectories: true)
179+
180+
for sourceURL in resultFiles {
181+
let fileName = sourceURL.lastPathComponent.replacingOccurrences(of: " ", with: "_")
182+
let destinationURL = junitResultRoot.appendingPathComponent(fileName)
183+
let data = try Data(contentsOf: sourceURL)
184+
try data.write(to: destinationURL)
185+
}
186+
}
187+
158188

159189
/// The contents typically contain a stack trace, which we need to parse in order to try to figure out the source code and line of the failure:
160190
/// ```

0 commit comments

Comments
 (0)