From 6f36c0591bb3220d2051c40b5f66e4cd2ea0810e Mon Sep 17 00:00:00 2001 From: Muka Schultze Date: Thu, 14 May 2026 19:09:13 -0300 Subject: [PATCH] fix: exit TestRunner upon a crash instead of waiting a timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testRuntime() previously called wait(for:timeout:) with a single 300s budget. If the runtime crashed (EXC_BAD_ACCESS or similar) the HTTP POST that fulfills runtimeUnitTestsExpectation never arrived, so the test would sit out the full 5 minutes before reporting a generic timeout — masking crashes as slow tests in CI. Add a 0.5s polling Timer that watches XCUIApplication.state and fulfills the expectation when the app reaches .notRunning, recording the exit via a didCrash flag. The Timer is registered on RunLoop.main in .common modes so it keeps firing while XCTWaiter spins the run loop. After the wait resolves, didCrash drives a specific XCTFail pointing reviewers at ~/Library/Logs/DiagnosticReports/TestRunner-*.ips, while a true timeout still surfaces as the original "exceeded N seconds" failure. Switching the expectation from self.expectation(...) to a standalone XCTestExpectation is required because XCTestCase tracks the former and requires waitForExpectations; driving it through XCTWaiter directly avoids that constraint. --- TestRunnerTests/TestRunnerTests.swift | 43 +++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/TestRunnerTests/TestRunnerTests.swift b/TestRunnerTests/TestRunnerTests.swift index aab37bfd..312ff05e 100644 --- a/TestRunnerTests/TestRunnerTests.swift +++ b/TestRunnerTests/TestRunnerTests.swift @@ -9,7 +9,10 @@ class TestRunnerTests: XCTestCase { override func setUp() { continueAfterFailure = false - runtimeUnitTestsExpectation = self.expectation(description: "Jasmine tests") + // Standalone (not via self.expectation(...)) so we can drive it through + // XCTWaiter alongside the crash watchdog without tripping the + // XCTestCase "must waitForExpectations" rule. + runtimeUnitTestsExpectation = XCTestExpectation(description: "Jasmine tests") loop = try! SelectorEventLoop(selector: try! KqueueSelector()) self.server = DefaultHTTPServer(eventLoop: loop!, port: port) { @@ -58,11 +61,45 @@ class TestRunnerTests: XCTestCase { loop.stop() } - func testRuntime() { + func testRuntime() { + let jasmineTestsTimeout: TimeInterval = 300 + let app = XCUIApplication() app.launchEnvironment["REPORT_BASEURL"] = "http://[::1]:\(port)/junit_report" app.launch() - wait(for: [runtimeUnitTestsExpectation], timeout: 300.0, enforceOrder: true) + // Watchdog: if the runtime crashes (e.g. EXC_BAD_ACCESS) it never + // POSTs results, and a plain `wait(for:)` would sit out the full + // timeout. Fulfill the same expectation from the watchdog when the + // app process leaves the running state, and track the crash via a + // flag so we can still distinguish the two outcomes after the wait. + var didCrash = false + let crashWatchdog = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in + if app.state == .notRunning { + didCrash = true + self.runtimeUnitTestsExpectation.fulfill() + } + } + // The XCUITest run loop spins in default mode during wait(for:); add + // the timer to common modes too in case anything switches it. + RunLoop.main.add(crashWatchdog, forMode: .common) + + let result = XCTWaiter().wait( + for: [runtimeUnitTestsExpectation], + timeout: jasmineTestsTimeout + ) + crashWatchdog.invalidate() + + switch result { + case .completed: + if didCrash { + XCTFail("TestRunner exited before reporting Jasmine results (likely crashed). Check ~/Library/Logs/DiagnosticReports/TestRunner-*.ips for the stack.") + } + return + case .timedOut: + XCTFail("Asynchronous wait failed: exceeded \(Int(jasmineTestsTimeout)) seconds with unfulfilled \"Jasmine tests\" expectation") + default: + XCTFail("Unexpected XCTWaiter result: \(result.rawValue)") + } } }