Constrained tests tool implementation + skill (#5873)

* constrained tests tool implementation + skill

* pr suggestions
This commit is contained in:
Mauro
2026-07-15 12:17:51 +02:00
committed by GitHub
parent 1062e2c6dd
commit 0be5693779
4 changed files with 94 additions and 2 deletions
+34
View File
@@ -0,0 +1,34 @@
---
name: constrained-tests
description: Slow-CI simulation — run unit tests on saturated CPU to reproduce CI-style flakiness locally. Suggest when new/reworked suite lean heavy on awaits (deferFulfillment, waitForConfirmation, TestClock, timeouts) or test fail only on CI. Not for every change.
---
# Constrained test runs
CI runners shared + slow. Background tasks starve, awaits stretch, scheduler races flake there, pass locally. `--constrained` saturate CPU with hog tasks — same conditions, local.
## When
- PR add/rework suite heavy on timing-sensitive async: `deferFulfillment`, `waitForConfirmation`, `TestClock`, `transitionValues`, timeouts.
- Test fail on CI, pass locally.
- Not routine changes, not sync suites — slow, no gain.
## How
Full unit test workflow (what CI run):
```bash
swift run tools ci unit-tests --constrained
```
One suite, iterate on new tests (prefer while developing):
```bash
swift run tools ci run-tests --scheme UnitTests --test-name MyNewTests --constrained
```
Hog count automatic (2× cores). Suite survive ~10 constrained iterations → good shape.
## Failures mean
Failure under constraint = real bug in test (sometimes code under test): assertion race async work, transient state observation missed, timeout too tight. Fix synchronisation, no blind timeout bump. Helpers in `UnitTests/Sources/TestUtilities/` (`deferFulfillment`, `deferFailure`, `waitForConfirmation`). Wait on durable state, not transient.
@@ -0,0 +1,40 @@
import Foundation
/// Saturates the CPU with busy processes for the duration of a test run, approximating
/// a loaded CI runner where timing-sensitive tests are starved of processor time.
final class CPUConstraint {
private var processes = [Process]()
/// 2× the machine's cores is enough to keep the scheduler permanently oversubscribed.
private let taskCount = ProcessInfo.processInfo.processorCount * 2
func start() {
logger.info("\n🐌 Constraining the CPU with \(taskCount) tasks…\n")
for _ in 0..<taskCount {
let process = Process()
process.executableURL = URL(filePath: "/usr/bin/yes")
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
do {
try process.run()
processes.append(process)
} catch {
logger.error("Failed to start a CPU constraining task: \(error)")
}
}
}
func stop() {
guard !processes.isEmpty else { return }
logger.info("\n🐌 Releasing the CPU…\n")
processes.forEach { $0.terminate() }
processes.removeAll()
}
deinit {
stop()
}
}
+9
View File
@@ -39,6 +39,9 @@ struct RunTests: AsyncParsableCommand {
@Option(help: "Only run a specific test (format: 'ClassName/testName').")
var testName: String?
@Flag(help: "Run the tests on a saturated CPU to reproduce the flakiness of a busy CI runner.")
var constrained = false
private var isCI: Bool {
ProcessInfo.processInfo.environment["CI"] != nil
}
@@ -73,6 +76,12 @@ struct RunTests: AsyncParsableCommand {
let outputDirectory = resultBundleURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
let cpuConstraint = CPUConstraint()
if constrained {
cpuConstraint.start()
}
defer { cpuConstraint.stop() }
try await executeXcodeBuild()
try await shutdownSimulator()
+11 -2
View File
@@ -8,6 +8,9 @@ struct UnitTests: AsyncParsableCommand {
@Flag(help: "Skip preview tests")
var skipPreviews = false
@Flag(help: "Run the unit tests on a saturated CPU to reproduce the flakiness of a busy CI runner.")
var constrained = false
private static let osVersion = CI.defaultOSVersion
private static let device = "iPhone 17"
@@ -19,12 +22,18 @@ struct UnitTests: AsyncParsableCommand {
// Run unit tests
do {
logger.info("\n🧪 Running unit tests…\n")
try await RunTests.parse([
var arguments = [
"--scheme", "UnitTests",
"--device", Self.device,
"--os-version", Self.osVersion,
"--retries", "3"
]).run()
]
if constrained {
arguments.append("--constrained")
}
try await RunTests.parse(arguments).run()
} catch {
failures.append("UnitTests")
logger.error("\n❌ Unit tests failed. \(error)\n")