Initial release

This commit is contained in:
aculix committed 2026-09-28 18:28:32 +05:30
commit 4b67728cce
108 files changed
+13310

No files matched your search

@@ -0,0 +1,372 @@
import Foundation
import Testing
@testable import KnotchyCore
@MainActor
@Suite(.timeLimit(.minutes(1)))
struct SessionLifecycleTests {
private let first = "knotchy-00000001"
private let second = "knotchy-00000002"
@Test func launchSweepsOrphansFirst() async {
let h = Harness()
await h.controller.waitForBackendIdle()
#expect(h.backend.calls == [.sweep(keeping: [])])
#expect(h.state == .idle)
}
@Test func aLinkBootsConnectsAndLoadsThenPeeks() async {
let h = Harness()
h.backend.hold(.start)
h.connector.hold()
h.controller.open(Harness.url)
#expect(h.state == .starting(.booting, folded: false))
#expect(h.controller.openedURL == Harness.url)
#expect(h.controller.liveIDs.snapshot == [first])
await waitUntil("start runs") { h.backend.parkedCount(.start) == 1 }
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .start(first)])
#expect(h.backend.startArguments == [.init(id: first, url: Harness.url,
renderSize: PixelSize(width: 1024, height: 640),
blockCIDRs: ["192.168.1.0/24"])])
h.backend.release(.start)
await waitUntil("connecting") { h.state == .starting(.connecting, folded: false) }
await waitUntil("connect runs") { h.connector.parkedCount == 1 }
#expect(h.connector.socketPaths == ["/fake/\(first).sock"])
h.connector.release()
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
let stream = h.connector.connections[0]
#expect(h.controller.connection === stream)
stream.emit(.hello(version: 1, size: SessionController.renderSize))
stream.emit(.contentReady)
await waitUntil("live") { h.state == .live(.peek) }
}
@Test func thePageURLFollowsTheVMButNeverReplacesTheOpenedLink() async {
let h = Harness()
let stream = await h.goLive()
#expect(h.controller.pageURL == nil)
let next = URL(string: "https://example.org/next")!
stream.emit(.pageURL(next))
await waitUntil("the page URL shows") { h.controller.pageURL == next }
#expect(h.controller.openedURL == Harness.url)
h.controller.open(URL(string: "https://example.net/")!)
#expect(h.controller.pageURL == nil)
}
@Test func destroyIsConfirmedBeforeDestroyedShowsThenGoesIdle() async {
let h = Harness()
let stream = await h.goLive()
h.backend.hold(.destroy)
h.controller.destroy()
#expect(h.state == .destroying)
#expect(stream.isClosed)
#expect(h.controller.connection == nil)
await waitUntil("destroy runs") { h.backend.parkedCount(.destroy) == 1 }
#expect(h.state == .destroying)
#expect(h.controller.liveIDs.snapshot == [first])
h.backend.release(.destroy)
await waitUntil("destroyed") { h.state == .destroyed }
#expect(h.controller.liveIDs.snapshot.isEmpty)
h.advance(by: .milliseconds(1499))
#expect(h.state == .destroyed)
h.advance(by: .milliseconds(1))
#expect(h.state == .idle)
#expect(h.controller.openedURL == nil)
}
@Test(arguments: [LiveMode.peek, .expanded, .collapsed])
func destroyWorksFromEveryLiveMode(_ mode: LiveMode) async {
let h = Harness()
await h.goLive()
h.enter(mode)
#expect(h.state == .live(mode))
h.controller.destroy()
#expect(h.state == .destroying)
#expect(h.controller.countdown == nil)
await waitUntil("destroyed") { h.state == .destroyed }
}
@Test func destroyDuringPreflightNeverCallsTheBackendsDestroy() async {
let h = Harness()
h.backend.hold(.preflight)
h.controller.open(Harness.url)
await waitUntil("preflight runs") { h.backend.parkedCount(.preflight) == 1 }
h.controller.destroy()
h.backend.release(.preflight)
await waitUntil("destroyed") { h.state == .destroyed }
await h.controller.waitForBackendIdle()
#expect(h.backend.calls == [.sweep(keeping: []), .preflight])
#expect(h.controller.liveIDs.snapshot.isEmpty)
}
@Test func destroyWhileConnectingClosesTheLateStream() async {
let h = Harness()
h.connector.hold()
h.controller.open(Harness.url)
await waitUntil("connect runs") { h.connector.parkedCount == 1 }
h.controller.destroy()
await waitUntil("destroyed") { h.state == .destroyed }
h.connector.release()
await waitUntil("the late stream is closed") { h.connector.connections.first?.isClosed == true }
#expect(h.state == .destroyed)
#expect(h.controller.connection == nil)
h.advance(by: .milliseconds(1500))
#expect(h.state == .idle)
}
@Test func aConnectThatFailsAfterDestroyIsIgnored() async {
let h = Harness()
h.connector.hold()
h.connector.failNextConnect(with: .timedOut)
h.backend.hold(.destroy)
h.controller.open(Harness.url)
await waitUntil("connect runs") { h.connector.parkedCount == 1 }
h.controller.destroy()
await waitUntil("destroy runs") { h.backend.parkedCount(.destroy) == 1 }
h.connector.release()
await h.controller.waitForSessionTasks()
#expect(h.state == .destroying) // Not the failed connect's error card.
h.backend.release(.destroy)
await waitUntil("destroyed") { h.state == .destroyed }
}
@Test func destroyWhileBootingDestroysTheVMOnceStartReturns() async {
let h = Harness()
h.backend.hold(.start)
h.controller.open(Harness.url)
await waitUntil("start runs") { h.backend.parkedCount(.start) == 1 }
h.controller.destroy()
#expect(h.state == .destroying)
h.backend.release(.start)
await waitUntil("destroyed") { h.state == .destroyed }
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .start(first), .destroy(first)])
#expect(h.connector.socketPaths.isEmpty)
}
@Test func aStuckDestroyShowsTheAmberPillAndCanBeRetried() async {
let h = Harness()
await h.goLive()
h.backend.failDestroys(with: VMBackendError.destroyStuck(id: first))
h.controller.destroy()
await waitUntil("stuck") { h.state == .failed(.destroyStuck) }
#expect(h.controller.liveIDs.snapshot == [first])
h.backend.failDestroys(with: nil)
h.controller.retry()
#expect(h.state == .destroying)
await waitUntil("destroyed") { h.state == .destroyed }
#expect(h.backend.calls.filter { $0 == .destroy(first) }.count == 2)
#expect(h.controller.liveIDs.snapshot.isEmpty)
}
@Test func aRetryNeverRetriesItself() async {
let h = Harness()
await h.goLive()
h.backend.failDestroys(with: VMBackendError.destroyStuck(id: first))
h.controller.destroy()
await waitUntil("stuck") { h.state == .failed(.destroyStuck) }
h.controller.retry() // Fails again: its own job must not also force-destroy the VM.
await h.controller.waitForBackendIdle()
#expect(h.state == .failed(.destroyStuck))
h.backend.failDestroys(with: nil)
h.controller.retry()
await waitUntil("destroyed") { h.state == .destroyed }
await h.controller.waitForBackendIdle()
#expect(!h.backend.calls.contains(.forceDestroy(first)))
}
@Test func theStuckPillGoesIdleAfter30sAndTheVMStaysListed() async {
let h = Harness()
await h.goLive()
h.backend.failDestroys(with: VMBackendError.destroyStuck(id: first))
h.controller.destroy()
await waitUntil("stuck") { h.state == .failed(.destroyStuck) }
h.advance(by: .seconds(29))
#expect(h.state == .failed(.destroyStuck))
h.advance(by: .seconds(1))
#expect(h.state == .idle)
#expect(h.controller.liveIDs.snapshot == [first])
}
@Test func eachLaterJobRetriesAStuckVMOnce() async {
let h = Harness()
await h.goLive()
h.backend.failDestroys(with: VMBackendError.destroyStuck(id: first))
h.controller.destroy()
await waitUntil("stuck") { h.state == .failed(.destroyStuck) }
await h.controller.waitForBackendIdle()
#expect(!h.backend.calls.contains(.forceDestroy(first)))
h.backend.failDestroys(with: nil)
h.backend.failForceDestroys(with: VMBackendError.destroyStuck(id: first))
await h.goLive(Harness.otherURL)
await h.controller.waitForBackendIdle()
#expect(h.backend.calls.filter { $0 == .forceDestroy(first) }.count == 1)
#expect(h.controller.liveIDs.snapshot == [first, second])
h.backend.failForceDestroys(with: nil)
h.controller.destroy() // Destroying `second` is a later job: `first` gets another retry.
await waitUntil("destroyed") { h.state == .destroyed }
await h.controller.waitForBackendIdle()
#expect(h.backend.calls.filter { $0 == .forceDestroy(first) }.count == 2)
#expect(h.controller.liveIDs.snapshot.isEmpty)
}
@Test func stuckVMsTakeTurnsForRetries() async {
let h = Harness()
h.backend.failDestroys(with: VMBackendError.destroyStuck(id: "any"))
h.backend.failForceDestroys(with: VMBackendError.destroyStuck(id: "any"))
await h.goLive()
h.controller.destroy()
await waitUntil("the first VM is stuck") { h.state == .failed(.destroyStuck) }
await h.goLive(Harness.otherURL)
h.controller.destroy()
await waitUntil("the second VM is stuck") { h.state == .failed(.destroyStuck) }
await h.goLive()
await h.controller.waitForBackendIdle()
let retries = h.backend.calls.filter { if case .forceDestroy = $0 { true } else { false } }
#expect(retries == [.forceDestroy(first), .forceDestroy(first), .forceDestroy(second)])
}
@Test func aStartFailureShowsTheErrorCardAndQueuesDestroy() async {
let h = Harness()
h.backend.failStarts(with: VMBackendError.failed("container run timed out"))
h.controller.open(Harness.url)
await waitUntil("failed") { h.state == .failed(.start("container run timed out")) }
await waitUntil("the VM is destroyed") { h.backend.calls.contains(.destroy(first)) }
await waitUntil("gone") { h.controller.liveIDs.snapshot.isEmpty }
h.backend.failStarts(with: nil)
h.controller.retry()
#expect(h.state == .starting(.booting, folded: false))
#expect(h.controller.openedURL == Harness.url)
await waitUntil("the retry starts") { h.backend.calls.contains(.start(second)) }
}
@Test func dismissingTheErrorCardGoesIdle() async {
let h = Harness()
h.backend.failStarts(with: VMBackendError.failed("boom"))
h.controller.open(Harness.url)
await waitUntil("failed") { h.state == .failed(.start("boom")) }
h.controller.dismiss()
#expect(h.state == .idle)
#expect(h.controller.openedURL == nil)
}
@Test func aConnectFailureFailsTheStart() async {
let h = Harness()
h.connector.failNextConnect(with: .timedOut)
h.controller.open(Harness.url)
await waitUntil("failed") { h.state == .failed(.start("The VM didn't answer in time.")) }
await waitUntil("the VM is destroyed") { h.backend.calls.contains(.destroy(first)) }
}
@Test func aVersionMismatchAsksForARebuildAndDestroysTheVM() async {
let h = Harness()
h.connector.failNextConnect(with: .versionMismatch(found: 0, need: 1))
h.backend.hold(.destroy)
h.controller.open(Harness.url)
await waitUntil("setup required") {
h.state == .setupRequired(.imageOutdated(found: 0, need: 1), error: nil)
}
await waitUntil("destroy runs") { h.backend.parkedCount(.destroy) == 1 }
#expect(h.controller.liveIDs.snapshot == [first]) // Kept until the destroy is confirmed.
h.backend.release(.destroy)
await waitUntil("gone") { h.controller.liveIDs.snapshot.isEmpty }
}
@Test func theStreamEndingWhileStartingFailsTheStart() async {
let h = Harness()
let stream = await h.openAndConnect()
stream.emit(.ended(.noFirstFrame))
await waitUntil("failed") { h.state == .failed(.start("The VM didn't send a first frame in time.")) }
await waitUntil("the VM is destroyed") { h.backend.calls.contains(.destroy(first)) }
}
@Test func theStreamEndingWhileLiveShowsSessionEndedFor3s() async {
let h = Harness()
let stream = await h.goLive()
stream.emit(.ended(.closed))
await waitUntil("ended") { h.state == .failed(.ended(.closed)) }
await waitUntil("the VM is destroyed") { h.backend.calls.contains(.destroy(first)) }
h.advance(by: .milliseconds(2999))
#expect(h.state == .failed(.ended(.closed)))
h.advance(by: .milliseconds(1))
#expect(h.state == .idle)
}
@Test(arguments: [LiveMode.peek, .expanded, .collapsed])
func theStreamEndingInEveryLiveModeShowsSessionEnded(_ mode: LiveMode) async {
let h = Harness()
let stream = await h.goLive()
h.enter(mode)
stream.emit(.ended(.decodeErrors))
await waitUntil("ended") { h.state == .failed(.ended(.decodeErrors)) }
#expect(h.controller.countdown == nil)
#expect(h.controller.connection == nil)
}
@Test func aStreamThatFinishesWithoutEndedStillEnds() async {
let h = Harness()
let stream = await h.goLive()
stream.finishEvents()
await waitUntil("ended") { h.state == .failed(.ended(.closed)) }
await waitUntil("the VM is destroyed") { h.backend.calls.contains(.destroy(first)) }
}
@Test func theAppQuitsItselfAfter60sIdle() {
let h = Harness()
h.advance(by: .seconds(59))
#expect(h.recorder.quitRequests == 0)
h.advance(by: .seconds(1))
#expect(h.recorder.quitRequests == 1)
}
@Test func theIdleQuitTimerRestartsWheneverIdleStarts() async {
let h = Harness()
h.advance(by: .seconds(30))
await h.goLive()
h.advance(by: .seconds(60))
#expect(h.recorder.quitRequests == 0)
h.controller.destroy()
await waitUntil("destroyed") { h.state == .destroyed }
h.advance(by: .milliseconds(1500))
#expect(h.state == .idle)
h.advance(by: .seconds(59))
#expect(h.recorder.quitRequests == 0)
h.advance(by: .seconds(1))
#expect(h.recorder.quitRequests == 1)
}
@Test func noLinkOpensOnceTheChainIsClosedForQuitting() async {
let h = Harness()
h.controller.closeBackendChain()
h.controller.open(Harness.url)
#expect(h.state == .idle)
}
@Test func theQuitSnapshotIncludesAStartInFlight() async {
let h = Harness()
h.backend.hold(.start)
h.controller.open(Harness.url)
await waitUntil("start runs") { h.backend.parkedCount(.start) == 1 }
h.controller.closeBackendChain()
#expect(h.controller.liveIDs.snapshot == [first])
// Nothing may destroy the VM behind the quit path's back.
h.backend.release(.start)
await h.controller.waitForBackendIdle()
#expect(!h.backend.calls.contains(.destroy(first)))
#expect(h.controller.liveIDs.snapshot == [first])
}
}