Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions apps/macos/Sources/Kit/Models/ScratchpadState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@ final class ScratchpadState {
@ObservationIgnored private var debounce: Task<Void, Never>?
@ObservationIgnored private var savingContent: String?
@ObservationIgnored private var loading = false
/// Waits out `autosaveDelay`.
@ObservationIgnored private let sleeper: Sleeper
/// How long an edit waits for more before it is saved.
static let autosaveDelay: Duration = .seconds(5)

init(demo: Bool) {
init(demo: Bool, sleep: @escaping Sleeper = realTimeSleeper) {
self.sleeper = sleep
draft = demo ? "# Scratchpad\n\n" : ""
status = demo ? .saved : .loading
}
Expand Down Expand Up @@ -65,8 +70,8 @@ final class ScratchpadState {

private func schedule() {
debounce?.cancel()
debounce = Task { [weak self] in
try? await Task.sleep(for: .seconds(5))
debounce = Task { [weak self, sleeper] in
try? await sleeper(Self.autosaveDelay)
guard !Task.isCancelled else { return }
await self?.autosave()
}
Expand Down
9 changes: 6 additions & 3 deletions apps/macos/Sources/Kit/Models/SessionWindowModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,16 @@ final class SessionWindowModel: Identifiable {
/// divider drag step.
@ObservationIgnored private var layoutCapture: Task<Void, Never>?
static let layoutCaptureDelay: Duration = .milliseconds(500)
/// Waits out `layoutCaptureDelay`.
@ObservationIgnored private let sleeper: Sleeper

static let defaultServer = ServerClient.localID

init(id: UUID, servers: ServerDirectory = .inert(), sessions: [SessionIdentity] = [], selected: SessionIdentity? = nil,
layouts: [SessionIdentity: TileArrangement] = [:], collapsed: Set<String> = [],
titles: [SessionIdentity: String] = [:]) {
titles: [SessionIdentity: String] = [:], sleep: @escaping Sleeper = realTimeSleeper) {
self.id = id
self.sleeper = sleep
self.servers = servers
self.collapsed = collapsed
let tabs = sessions.isEmpty
Expand Down Expand Up @@ -543,8 +546,8 @@ final class SessionWindowModel: Identifiable {

private func scheduleLayoutCapture() {
layoutCapture?.cancel()
layoutCapture = Task { @MainActor [weak self] in
try? await Task.sleep(for: Self.layoutCaptureDelay)
layoutCapture = Task { @MainActor [weak self, sleeper] in
try? await sleeper(Self.layoutCaptureDelay)
guard !Task.isCancelled, let self else { return }
self.layoutCapture = nil
self.onChange(self)
Expand Down
6 changes: 6 additions & 0 deletions apps/macos/Sources/Kit/Support/Sleeper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// Waits out a delay. Models that debounce take one, so tests can decide
/// when the delay has passed instead of waiting for the clock.
typealias Sleeper = @Sendable (Duration) async throws -> Void

/// Waits out a delay in real time, throwing if the task is cancelled first.
let realTimeSleeper: Sleeper = { try await Task.sleep(for: $0) }
20 changes: 4 additions & 16 deletions apps/macos/Tests/KitTests/AnnotationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,28 +141,19 @@ extension AnnotationTests {
let probe = AnnotationStreamProbe()
let watch = Task { try await observer.watch(id) { value in await probe.receive(value) } }
defer { watch.cancel() }
for _ in 0..<100 {
if await probe.ready { break }
try await Task.sleep(for: .milliseconds(20))
}
try await eventually { await probe.ready }
#expect(await probe.ready)
do {
let fileRead = try await client.readWorkspaceFile(id, path: "sample.swift")
let anchor = WireAnnotationAnchor(kind: .value0, workspaceFile: .init(workspaceId: fileRead.workspace.workspaceId,
path: fileRead.path, fileRevision: fileRead.revision, startLine: 1, endLine: 2), workingTreeDiff: nil)
let created = try await client.createAnnotation(id, input: .init(anchor: anchor, body: "Explain this greeting."))
#expect(created.source == "let greeting = \"🌿\"\nprint(greeting)")
for _ in 0..<100 {
if await probe.bodies.contains("Explain this greeting.") { break }
try await Task.sleep(for: .milliseconds(20))
}
try await eventually { await probe.bodies.contains("Explain this greeting.") }
#expect(await probe.bodies.contains("Explain this greeting."))
let edited = try await client.updateAnnotation(id, input: .init(annotationId: created.id, body: "Keep the Unicode greeting."))
#expect(edited.body == "Keep the Unicode greeting.")
for _ in 0..<100 {
if await probe.bodies.contains(edited.body) { break }
try await Task.sleep(for: .milliseconds(20))
}
try await eventually { await probe.bodies.contains(edited.body) }
#expect(await probe.bodies.contains(edited.body))
#expect(try await client.annotations(id).map(\.id) == [created.id])
try "let greeting = \"changed\"\n".write(to: file, atomically: true, encoding: .utf8)
Expand All @@ -171,10 +162,7 @@ extension AnnotationTests {
#expect(stale.source == created.source)
try await client.deleteAnnotation(id, id: created.id)
#expect(try await client.annotations(id).isEmpty)
for _ in 0..<100 {
if await probe.liveIDs.isEmpty { break }
try await Task.sleep(for: .milliseconds(20))
}
try await eventually { await probe.liveIDs.isEmpty }
#expect(await probe.liveIDs.isEmpty)
guard ProcessInfo.processInfo.environment["KIT_ANNOTATION_SEND_LIVE_TEST"] == "1" else {
try await client.disposeSession(id)
Expand Down
13 changes: 4 additions & 9 deletions apps/macos/Tests/KitTests/AuthorizationStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,11 @@ private actor AuthorizingClient: ComposerClient {
let engines = FakeBrowserEngines()
store.ui.workspace.makeBrowserEngine = engines.make
store.attach()
try await until { await client.ready("a") }
try await eventually { await client.ready("a") }
await client.emit(session("a", authorizing: true))
return (store, client, engines)
}

private func until(_ condition: () async -> Bool) async throws {
for _ in 0..<100 where await !condition() { try await Task.sleep(for: .milliseconds(10)) }
#expect(await condition())
}

@Test func anAuthorizationRequestOpensItsPageOnce() async throws {
let (store, _, engines) = try await authorizing()
defer { store.detach() }
Expand All @@ -88,10 +83,10 @@ private actor AuthorizingClient: ComposerClient {
store.openAuthorizationOnce(try #require(store.dockedInteraction))

#expect(!engines.made[0].navigate(callback))
try await until { await client.responses.count == 1 }
try await eventually { await client.responses.count == 1 }
#expect(await client.responses.map(\.session) == ["a"])
#expect(await client.responses.map(\.value) == [callback.absoluteString])
try await until { workspace.root.tiles.count == 1 }
try await eventually { workspace.root.tiles.count == 1 }
#expect(workspace.panes == [.conversation])
}

Expand All @@ -103,7 +98,7 @@ private actor AuthorizingClient: ComposerClient {
#expect(store.selectedID == "b")

#expect(!engines.made[0].navigate(callback))
try await until { await client.responses.count == 1 }
try await eventually { await client.responses.count == 1 }
#expect(await client.responses.map(\.session) == ["a"])
#expect(await client.responses.map(\.value) == [callback.absoluteString])
}
Expand Down
14 changes: 3 additions & 11 deletions apps/macos/Tests/KitTests/BashTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,7 @@ private actor ShellResults {
var running = idle
running.messages = [try execution("bash_row").message]
await projection.stream(running)
for _ in 0..<100 where await sink.latest?.messages.first?.bash?.running != false {
try await Task.sleep(for: .milliseconds(10))
}
try await eventually { await sink.latest?.messages.first?.bash?.running == false }
#expect(await sink.latest?.messages.map(\.id) == ["bash_row"])
#expect(await sink.latest?.messages.first?.bash?.output == "done")
try await Task.sleep(for: .milliseconds(50))
Expand Down Expand Up @@ -194,21 +192,15 @@ private actor ShellResults {
let observed = ShellResults()
let watch = Task { try await client.watch(session) { await observed.accept($0) } }
defer { watch.cancel() }
for _ in 0..<60 {
if await observed.latest?.messages.contains(where: { $0.bash?.id == id && $0.bash?.running == true }) == true { break }
try await Task.sleep(for: .milliseconds(100))
}
try await eventually { await observed.latest?.messages.contains(where: { $0.bash?.id == id && $0.bash?.running == true }) == true }
#expect(await observed.latest?.messages.contains { $0.bash?.id == id && $0.bash?.running == true } == true)
try await client.abortBash(session, id: id)
var result = try await client.bash(session, id: id)
for _ in 0..<40 where result.running {
try await Task.sleep(for: .milliseconds(100)); result = try await client.bash(session, id: id)
}
#expect(result.status == "aborted")
for _ in 0..<60 {
if await observed.latest?.messages.contains(where: { $0.bash?.id == id && $0.bash?.status == "aborted" }) == true { break }
try await Task.sleep(for: .milliseconds(100))
}
try await eventually { await observed.latest?.messages.contains(where: { $0.bash?.id == id && $0.bash?.status == "aborted" }) == true }
#expect(await observed.latest?.messages.contains { $0.bash?.id == id && $0.bash?.status == "aborted" } == true)
watch.cancel()
try await client.disposeSession(session)
Expand Down
2 changes: 1 addition & 1 deletion apps/macos/Tests/KitTests/ComposerClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ private actor ComposerMock: ComposerClient {

@MainActor struct ComposerClientTests {
private func wait(_ condition: @MainActor () -> Bool) async throws {
for _ in 0..<100 where !condition() { try await Task.sleep(for: .milliseconds(10)) }
try await eventually { condition() }
#expect(condition())
}
@Test func attachmentsRetainIdentityAndBytesAcrossFailureRetryAndSessionSwitch() async throws {
Expand Down
4 changes: 2 additions & 2 deletions apps/macos/Tests/KitTests/ComposerGrowthTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,9 @@ private struct QuoteComposerHost: View {
return view.subviews.lazy.compactMap { editor(in: $0) }.first
}
func settle(until done: () -> Bool) async throws {
for _ in 0..<20 where !done() {
try await eventually {
host.layoutSubtreeIfNeeded()
try await Task.sleep(for: .milliseconds(20))
return done()
}
}
try await settle { editor(in: host)?.string == state.draft }
Expand Down
6 changes: 1 addition & 5 deletions apps/macos/Tests/KitTests/ComposerHistoryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,7 @@ private struct HistoryTestClient: ComposerHistoryClient {
@MainActor
struct ComposerHistoryTests {
private func settle(_ condition: @escaping @MainActor () -> Bool) async throws {
for _ in 0..<100 {
if condition() { return }
try await Task.sleep(for: .milliseconds(10))
}
Issue.record("history operation did not settle")
try await eventually { condition() }
}

@Test func historyPanelFitsVisibleRowsAndKeepsItsBottomAnchoredToComposer() {
Expand Down
2 changes: 1 addition & 1 deletion apps/macos/Tests/KitTests/ComposerMentionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import Testing
let state = ComposerMentionState()
state.observe(previous: "see later", next: "see @ later", pasted: false)
state.load { _ in FileIndex(paths: ["docs/", "src/main.swift"]) }
for _ in 0..<100 where state.loading { try await Task.sleep(for: .milliseconds(5)) }
try await eventually { !state.loading }
#expect(state.selected == "docs/")
state.move(-1)
#expect(state.selected == "src/main.swift")
Expand Down
Loading
Loading