mirror of
https://github.com/aculix/knotchy.git
synced 2026-10-27 07:15:53 +00:00
Initial release
This commit is contained in:
commit
4b67728cce
108 files changed
+13310
No files matched your search
@@ -0,0 +1,336 @@
|
||||
import Foundation
|
||||
import Synchronization
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
/// A forgotten `release` fails the test after this instead of hanging the run.
|
||||
private let heldCallLimit: Duration = .seconds(10)
|
||||
|
||||
final class FakeVMBackend: VMBackend {
|
||||
enum Call: Equatable, Sendable {
|
||||
case sweep(keeping: Set<String>)
|
||||
case preflight
|
||||
case start(String)
|
||||
case destroy(String)
|
||||
case forceDestroy(String)
|
||||
case fix(SetupIssue)
|
||||
case destroyBlocking([String])
|
||||
case stopService
|
||||
|
||||
var kind: Kind {
|
||||
switch self {
|
||||
case .sweep: .sweep
|
||||
case .preflight: .preflight
|
||||
case .start: .start
|
||||
case .destroy: .destroy
|
||||
case .forceDestroy: .forceDestroy
|
||||
case .fix: .fix
|
||||
case .destroyBlocking: .destroyBlocking
|
||||
case .stopService: .stopService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Kind: Hashable, Sendable {
|
||||
case sweep, preflight, start, destroy, forceDestroy, fix, destroyBlocking, stopService
|
||||
}
|
||||
|
||||
struct StartArguments: Equatable, Sendable {
|
||||
var id: String
|
||||
var url: URL
|
||||
var renderSize: PixelSize
|
||||
var blockCIDRs: [String]
|
||||
}
|
||||
|
||||
private struct State {
|
||||
var calls: [Call] = []
|
||||
var startArguments: [StartArguments] = []
|
||||
var active = 0
|
||||
var maxActive = 0
|
||||
var held: Set<Kind> = []
|
||||
var nextParkID = 0
|
||||
var parked: [Kind: [(id: Int, continuation: CheckedContinuation<Void, Never>)]] = [:]
|
||||
var preflightResults: [PreflightResult] = []
|
||||
var startError: (any Error)?
|
||||
var destroyError: (any Error)?
|
||||
var forceDestroyError: (any Error)?
|
||||
var fixOutput: [String] = []
|
||||
var fixError: (any Error)?
|
||||
var runtimeStatus: RuntimeStatus = .current("1.4.1")
|
||||
var stopError: (any Error)?
|
||||
}
|
||||
|
||||
private let state = Mutex(State())
|
||||
|
||||
var calls: [Call] { state.withLock { $0.calls } }
|
||||
var startArguments: [StartArguments] { state.withLock { $0.startArguments } }
|
||||
var maxConcurrentCalls: Int { state.withLock { $0.maxActive } }
|
||||
|
||||
/// Used in order; after them, preflight is ready.
|
||||
func queuePreflight(_ results: PreflightResult...) {
|
||||
state.withLock { $0.preflightResults += results }
|
||||
}
|
||||
|
||||
func failStarts(with error: (any Error)?) { state.withLock { $0.startError = error } }
|
||||
func failDestroys(with error: (any Error)?) { state.withLock { $0.destroyError = error } }
|
||||
func failForceDestroys(with error: (any Error)?) { state.withLock { $0.forceDestroyError = error } }
|
||||
|
||||
func scriptFix(output: [String], error: (any Error)? = nil) {
|
||||
state.withLock {
|
||||
$0.fixOutput = output
|
||||
$0.fixError = error
|
||||
}
|
||||
}
|
||||
|
||||
func hold(_ kind: Kind) {
|
||||
precondition(kind != .destroyBlocking, "destroyBlocking is synchronous and can't be held")
|
||||
_ = state.withLock { $0.held.insert(kind) }
|
||||
}
|
||||
|
||||
func release(_ kind: Kind) {
|
||||
let parked = state.withLock { s in
|
||||
s.held.remove(kind)
|
||||
return s.parked.removeValue(forKey: kind) ?? []
|
||||
}
|
||||
for call in parked { call.continuation.resume() }
|
||||
}
|
||||
|
||||
func parkedCount(_ kind: Kind) -> Int {
|
||||
state.withLock { $0.parked[kind]?.count ?? 0 }
|
||||
}
|
||||
|
||||
func sweepOrphans(keeping: Set<String>) async {
|
||||
await begin(.sweep(keeping: keeping))
|
||||
end()
|
||||
}
|
||||
|
||||
func preflight() async -> PreflightResult {
|
||||
await begin(.preflight)
|
||||
defer { end() }
|
||||
return state.withLock { s in
|
||||
s.preflightResults.isEmpty ? .ready(appRoot: "/fake/appRoot", staleImage: nil) : s.preflightResults.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
func start(id: String, url: URL, renderSize: PixelSize, blockCIDRs: [String]) async throws -> VMSession {
|
||||
state.withLock {
|
||||
$0.startArguments.append(StartArguments(id: id, url: url, renderSize: renderSize, blockCIDRs: blockCIDRs))
|
||||
}
|
||||
await begin(.start(id))
|
||||
defer { end() }
|
||||
if let error = state.withLock({ $0.startError }) { throw error }
|
||||
return VMSession(id: id, socketPath: "/fake/\(id).sock")
|
||||
}
|
||||
|
||||
func destroy(id: String) async throws {
|
||||
await begin(.destroy(id))
|
||||
defer { end() }
|
||||
if let error = state.withLock({ $0.destroyError }) { throw error }
|
||||
}
|
||||
|
||||
func forceDestroy(id: String) async throws {
|
||||
await begin(.forceDestroy(id))
|
||||
defer { end() }
|
||||
if let error = state.withLock({ $0.forceDestroyError }) { throw error }
|
||||
}
|
||||
|
||||
func fix(_ issue: SetupIssue, output: @escaping @Sendable (String) -> Void) async throws {
|
||||
record(.fix(issue))
|
||||
state.withLock { $0.fixOutput }.forEach(output)
|
||||
await parkIfHeld(.fix)
|
||||
defer { end() }
|
||||
if let error = state.withLock({ $0.fixError }) { throw error }
|
||||
}
|
||||
|
||||
func destroyBlocking(ids: [String], deadline: Duration) {
|
||||
state.withLock { $0.calls.append(.destroyBlocking(ids)) }
|
||||
}
|
||||
|
||||
func setRuntimeStatus(_ status: RuntimeStatus) { state.withLock { $0.runtimeStatus = status } }
|
||||
func failStopService(with error: (any Error)?) { state.withLock { $0.stopError = error } }
|
||||
|
||||
func runtimeStatus() async -> RuntimeStatus {
|
||||
state.withLock { $0.runtimeStatus }
|
||||
}
|
||||
|
||||
func stopService() async throws {
|
||||
await begin(.stopService)
|
||||
defer { end() }
|
||||
if let error = state.withLock({ $0.stopError }) { throw error }
|
||||
}
|
||||
|
||||
private func begin(_ call: Call) async {
|
||||
record(call)
|
||||
await parkIfHeld(call.kind)
|
||||
}
|
||||
|
||||
private func record(_ call: Call) {
|
||||
state.withLock { s in
|
||||
s.calls.append(call)
|
||||
s.active += 1
|
||||
s.maxActive = max(s.maxActive, s.active)
|
||||
}
|
||||
}
|
||||
|
||||
private func end() {
|
||||
state.withLock { $0.active -= 1 }
|
||||
}
|
||||
|
||||
private func parkIfHeld(_ kind: Kind) async {
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
let id: Int? = state.withLock { s in
|
||||
guard s.held.contains(kind) else { return nil }
|
||||
s.nextParkID += 1
|
||||
s.parked[kind, default: []].append((s.nextParkID, continuation))
|
||||
return s.nextParkID
|
||||
}
|
||||
guard let id else { return continuation.resume() }
|
||||
Task {
|
||||
try? await Task.sleep(for: heldCallLimit)
|
||||
let forgotten = self.state.withLock { s -> CheckedContinuation<Void, Never>? in
|
||||
guard let index = s.parked[kind]?.firstIndex(where: { $0.id == id }) else { return nil }
|
||||
return s.parked[kind]?.remove(at: index).continuation
|
||||
}
|
||||
if let forgotten {
|
||||
Issue.record("A held \(kind) call was never released")
|
||||
forgotten.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queued errors go to connects in call order; `connections` is in completion order.
|
||||
final class FakeStreamConnector: StreamConnector {
|
||||
private struct State {
|
||||
var socketPaths: [String] = []
|
||||
var connections: [FakeStreamConnection] = []
|
||||
var errors: [StreamError] = []
|
||||
var held = false
|
||||
var nextParkID = 0
|
||||
var parked: [(id: Int, continuation: CheckedContinuation<Void, Never>)] = []
|
||||
}
|
||||
|
||||
private let state = Mutex(State())
|
||||
|
||||
var socketPaths: [String] { state.withLock { $0.socketPaths } }
|
||||
var connections: [FakeStreamConnection] { state.withLock { $0.connections } }
|
||||
var parkedCount: Int { state.withLock { $0.parked.count } }
|
||||
|
||||
func failNextConnect(with error: StreamError) {
|
||||
state.withLock { $0.errors.append(error) }
|
||||
}
|
||||
|
||||
func hold() {
|
||||
state.withLock { $0.held = true }
|
||||
}
|
||||
|
||||
func release() {
|
||||
let parked = state.withLock { s in
|
||||
s.held = false
|
||||
defer { s.parked = [] }
|
||||
return s.parked
|
||||
}
|
||||
for call in parked { call.continuation.resume() }
|
||||
}
|
||||
|
||||
func connect(socketPath: String) async throws -> any StreamConnection {
|
||||
let scriptedError = state.withLock { s -> StreamError? in
|
||||
s.socketPaths.append(socketPath)
|
||||
return s.errors.isEmpty ? nil : s.errors.removeFirst()
|
||||
}
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
let id: Int? = state.withLock { s in
|
||||
guard s.held else { return nil }
|
||||
s.nextParkID += 1
|
||||
s.parked.append((s.nextParkID, continuation))
|
||||
return s.nextParkID
|
||||
}
|
||||
guard let id else { return continuation.resume() }
|
||||
Task {
|
||||
try? await Task.sleep(for: heldCallLimit)
|
||||
let forgotten = self.state.withLock { s -> CheckedContinuation<Void, Never>? in
|
||||
guard let index = s.parked.firstIndex(where: { $0.id == id }) else { return nil }
|
||||
return s.parked.remove(at: index).continuation
|
||||
}
|
||||
if let forgotten {
|
||||
Issue.record("A held connect was never released")
|
||||
forgotten.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
if let scriptedError { throw scriptedError }
|
||||
let connection = FakeStreamConnection(socketPath: socketPath)
|
||||
state.withLock { $0.connections.append(connection) }
|
||||
return connection
|
||||
}
|
||||
}
|
||||
|
||||
final class FakeStreamConnection: StreamConnection {
|
||||
let socketPath: String
|
||||
let events: AsyncStream<StreamEvent>
|
||||
private let continuation: AsyncStream<StreamEvent>.Continuation
|
||||
|
||||
private struct State {
|
||||
var decodeEnabled = true
|
||||
var decodeChanges: [Bool] = []
|
||||
var sent: [InputEvent] = []
|
||||
var closed = false
|
||||
}
|
||||
|
||||
private let state = Mutex(State())
|
||||
|
||||
init(socketPath: String) {
|
||||
self.socketPath = socketPath
|
||||
let (stream, continuation) = AsyncStream.makeStream(of: StreamEvent.self)
|
||||
events = stream
|
||||
self.continuation = continuation
|
||||
}
|
||||
|
||||
var decodeEnabled: Bool {
|
||||
get { state.withLock { $0.decodeEnabled } }
|
||||
set {
|
||||
state.withLock {
|
||||
$0.decodeEnabled = newValue
|
||||
$0.decodeChanges.append(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var decodeChanges: [Bool] { state.withLock { $0.decodeChanges } }
|
||||
var sent: [InputEvent] { state.withLock { $0.sent } }
|
||||
var isClosed: Bool { state.withLock { $0.closed } }
|
||||
|
||||
func setFrameHandler(_ handler: @escaping @Sendable (DecodedFrame) -> Void) {}
|
||||
|
||||
func send(_ input: InputEvent) {
|
||||
state.withLock { $0.sent.append(input) }
|
||||
}
|
||||
|
||||
func close() {
|
||||
state.withLock { $0.closed = true }
|
||||
continuation.finish()
|
||||
}
|
||||
|
||||
func emit(_ event: StreamEvent) {
|
||||
continuation.yield(event)
|
||||
}
|
||||
|
||||
/// Without an `.ended` event, as a buggy client might.
|
||||
func finishEvents() {
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
final class FakeDiskSpace: DiskSpaceChecking {
|
||||
private let free = Mutex<Int64?>(100_000_000_000)
|
||||
|
||||
var available: Int64? {
|
||||
get { free.withLock { $0 } }
|
||||
set { free.withLock { $0 = newValue } }
|
||||
}
|
||||
|
||||
func bytesFree(atPath path: String) -> Int64? {
|
||||
free.withLock { $0 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
@MainActor
|
||||
final class Recorder {
|
||||
var quitRequests = 0
|
||||
var setupRequests = 0
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class Harness {
|
||||
static let url = URL(string: "https://example.com/")!
|
||||
static let otherURL = URL(string: "https://example.org/")!
|
||||
|
||||
let backend = FakeVMBackend()
|
||||
let connector = FakeStreamConnector()
|
||||
let disk = FakeDiskSpace()
|
||||
let clock = TestClock()
|
||||
let recorder: Recorder
|
||||
let controller: SessionController
|
||||
|
||||
init(hostNetworks: [String] = ["192.168.1.0/24"]) {
|
||||
let recorder = Recorder()
|
||||
self.recorder = recorder
|
||||
var sessions = 0
|
||||
controller = SessionController(
|
||||
backend: backend,
|
||||
connector: connector,
|
||||
diskSpace: disk,
|
||||
clock: clock,
|
||||
hostNetworks: { hostNetworks },
|
||||
requestQuit: { recorder.quitRequests += 1 },
|
||||
makeSessionID: {
|
||||
sessions += 1
|
||||
return String(format: "knotchy-%08x", sessions)
|
||||
}
|
||||
)
|
||||
controller.showSetup = { recorder.setupRequests += 1 }
|
||||
}
|
||||
|
||||
/// Lets go of held calls, so the fakes' 10 s check can't fire later against another test.
|
||||
deinit {
|
||||
for kind in [FakeVMBackend.Kind.sweep, .preflight, .start, .destroy, .forceDestroy, .fix, .stopService] {
|
||||
backend.release(kind)
|
||||
}
|
||||
connector.release()
|
||||
for stream in connector.connections { stream.close() }
|
||||
}
|
||||
|
||||
var state: SessionState { controller.state }
|
||||
|
||||
func advance(by duration: Duration) {
|
||||
clock.advance(by: duration)
|
||||
controller.fireDueTimers()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func openAndConnect(_ url: URL = Harness.url) async -> FakeStreamConnection {
|
||||
let before = connector.connections.count
|
||||
controller.open(url)
|
||||
await waitUntil("the stream connects") {
|
||||
self.connector.connections.count > before && self.state == .starting(.loading, folded: false)
|
||||
}
|
||||
return connector.connections.last!
|
||||
}
|
||||
|
||||
func enter(_ mode: LiveMode) {
|
||||
switch mode {
|
||||
case .peek:
|
||||
break
|
||||
case .expanded:
|
||||
controller.pointerEntered()
|
||||
controller.expand()
|
||||
case .collapsed:
|
||||
controller.clickedOutside()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func goLive(_ url: URL = Harness.url) async -> FakeStreamConnection {
|
||||
let stream = await openAndConnect(url)
|
||||
stream.emit(.contentReady)
|
||||
await waitUntil("the session is live") { self.state == .live(.peek) }
|
||||
return stream
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import Foundation
|
||||
import Synchronization
|
||||
@testable import KnotchyCore
|
||||
|
||||
final class FakeHost: HostChecking {
|
||||
private let value = Mutex(HostFacts(unsupported: nil, osVersion: "macOS 27.0", bytesFree: 212_000_000_000))
|
||||
|
||||
var current: HostFacts {
|
||||
get { value.withLock { $0 } }
|
||||
set { value.withLock { $0 = newValue } }
|
||||
}
|
||||
|
||||
func facts() async -> HostFacts { current }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class FakeInstallerSession: InstallerSession {
|
||||
var hasQuit: Bool
|
||||
|
||||
nonisolated init(hasQuit: Bool = true) {
|
||||
self.hasQuit = hasQuit
|
||||
}
|
||||
}
|
||||
|
||||
final class FakeInstaller: RuntimeInstalling {
|
||||
enum Opening: Sendable {
|
||||
case opened
|
||||
case changed
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
private struct State {
|
||||
var downloadError: DownloadError?
|
||||
var opening: Opening = .opened
|
||||
var calls: [String] = []
|
||||
}
|
||||
|
||||
private let state = Mutex(State())
|
||||
/// What `.opened` hands out; it has already quit unless a test says otherwise.
|
||||
let session = FakeInstallerSession()
|
||||
|
||||
var calls: [String] { state.withLock { $0.calls } }
|
||||
func failDownload(with error: DownloadError) { state.withLock { $0.downloadError = error } }
|
||||
func open(with opening: Opening) { state.withLock { $0.opening = opening } }
|
||||
|
||||
func download(_ release: ContainerRuntime.Release,
|
||||
progress: @escaping @Sendable (Int64) -> Void) async throws -> DownloadedPackage {
|
||||
let error = state.withLock { s -> DownloadError? in
|
||||
s.calls.append("download")
|
||||
return s.downloadError
|
||||
}
|
||||
if let error { throw error }
|
||||
return DownloadedPackage(directory: URL(fileURLWithPath: "/fake/knotchy-runtime"), name: release.url.lastPathComponent)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func openInInstaller(_ package: DownloadedPackage, release: ContainerRuntime.Release) async -> InstallerOpening {
|
||||
let opening = state.withLock { s -> Opening in
|
||||
s.calls.append("open")
|
||||
return s.opening
|
||||
}
|
||||
switch opening {
|
||||
case .opened: return .opened(session)
|
||||
case .changed: return .changed
|
||||
case .failed(let message): return .failed(message)
|
||||
}
|
||||
}
|
||||
|
||||
func discard(_ package: DownloadedPackage) {
|
||||
state.withLock { $0.calls.append("discard") }
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class FakeSetupDriver: SetupDriving {
|
||||
var state: SessionState = .idle
|
||||
var setupLog: String?
|
||||
var setupOutput: [String] = []
|
||||
/// Used in order; the last one repeats.
|
||||
var runtimeStatuses: [RuntimeStatus] = [.current("1.4.1")]
|
||||
/// Used in order; then `.ready`.
|
||||
var checkResults: [SetupRunOutcome?] = []
|
||||
var runResults: [SetupRunOutcome?] = []
|
||||
var holdResult: Result<Void, RuntimeHoldRefusal> = .success(())
|
||||
var finishResult: SetupRunOutcome = .ready
|
||||
private(set) var calls: [String] = []
|
||||
|
||||
func runtimeStatus() async -> RuntimeStatus {
|
||||
calls.append("runtimeStatus")
|
||||
return runtimeStatuses.count > 1 ? runtimeStatuses.removeFirst() : runtimeStatuses[0]
|
||||
}
|
||||
|
||||
func checkSetup() async -> SetupRunOutcome? {
|
||||
calls.append("checkSetup")
|
||||
return checkResults.isEmpty ? .ready : checkResults.removeFirst()
|
||||
}
|
||||
|
||||
func runSetup(_ issue: SetupIssue, continuing: Bool) async -> SetupRunOutcome? {
|
||||
calls.append("runSetup(\(issue), continuing: \(continuing))")
|
||||
return runResults.isEmpty ? .ready : runResults.removeFirst()
|
||||
}
|
||||
|
||||
func beginRuntimeInstall(replacing found: String?) async -> Result<Void, RuntimeHoldRefusal> {
|
||||
calls.append("begin(\(found ?? "nil"))")
|
||||
return holdResult
|
||||
}
|
||||
|
||||
func finishRuntimeInstall(_ result: RuntimeInstallResult) async -> SetupRunOutcome {
|
||||
calls.append("finish(\(result))")
|
||||
return finishResult
|
||||
}
|
||||
|
||||
func waitWhileSettingUp() async {
|
||||
calls.append("wait")
|
||||
state = .idle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Synchronization
|
||||
@testable import KnotchyCore
|
||||
|
||||
final class TestClock: KnotchyClock {
|
||||
private struct Sleeper {
|
||||
let id: Int
|
||||
let deadline: ContinuousClock.Instant
|
||||
let continuation: CheckedContinuation<Void, any Error>
|
||||
}
|
||||
|
||||
private struct State {
|
||||
var now: ContinuousClock.Instant
|
||||
var nextID = 0
|
||||
var sleepers: [Sleeper] = []
|
||||
}
|
||||
|
||||
private let state: Mutex<State>
|
||||
|
||||
init(now: ContinuousClock.Instant = .now) {
|
||||
state = Mutex(State(now: now))
|
||||
}
|
||||
|
||||
var now: ContinuousClock.Instant {
|
||||
state.withLock { $0.now }
|
||||
}
|
||||
|
||||
var sleeperCount: Int {
|
||||
state.withLock { $0.sleepers.count }
|
||||
}
|
||||
|
||||
func sleep(until deadline: ContinuousClock.Instant) async throws {
|
||||
let id = state.withLock { s in
|
||||
s.nextID += 1
|
||||
return s.nextID
|
||||
}
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
|
||||
let outcome: Result<Void, any Error>? = state.withLock { s in
|
||||
if Task.isCancelled { return .failure(CancellationError()) }
|
||||
if deadline <= s.now { return .success(()) }
|
||||
s.sleepers.append(Sleeper(id: id, deadline: deadline, continuation: continuation))
|
||||
return nil
|
||||
}
|
||||
if let outcome { continuation.resume(with: outcome) }
|
||||
}
|
||||
} onCancel: {
|
||||
let sleeper = state.withLock { s -> Sleeper? in
|
||||
guard let index = s.sleepers.firstIndex(where: { $0.id == id }) else { return nil }
|
||||
return s.sleepers.remove(at: index)
|
||||
}
|
||||
sleeper?.continuation.resume(throwing: CancellationError())
|
||||
}
|
||||
}
|
||||
|
||||
/// The woken tasks run concurrently, in no particular order.
|
||||
func advance(by duration: Duration) {
|
||||
let due = state.withLock { s -> [Sleeper] in
|
||||
s.now += duration
|
||||
let now = s.now
|
||||
let due = s.sleepers.filter { $0.deadline <= now }.sorted { $0.deadline < $1.deadline }
|
||||
s.sleepers.removeAll { $0.deadline <= now }
|
||||
return due
|
||||
}
|
||||
for sleeper in due { sleeper.continuation.resume() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Testing
|
||||
|
||||
/// For work on other executors; controller timers run on the `TestClock` and never need it.
|
||||
@MainActor
|
||||
func waitUntil(
|
||||
_ description: String,
|
||||
timeout: Duration = .seconds(5),
|
||||
sourceLocation: SourceLocation = #_sourceLocation,
|
||||
_ condition: @MainActor () -> Bool
|
||||
) async {
|
||||
let deadline = ContinuousClock.now + timeout
|
||||
while !condition() {
|
||||
if ContinuousClock.now >= deadline {
|
||||
Issue.record("Timed out waiting until \(description)", sourceLocation: sourceLocation)
|
||||
return
|
||||
}
|
||||
do { try await Task.sleep(for: .milliseconds(1)) } catch { return }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user