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,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])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user