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,66 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
@MainActor
|
||||
struct BackendChainTests {
|
||||
@Test func runsJobsOneAtATimeInOrder() async {
|
||||
let chain = BackendChain()
|
||||
var log: [String] = []
|
||||
for name in ["a", "b", "c"] {
|
||||
chain.enqueue {
|
||||
log.append("\(name) begins")
|
||||
await Task.yield()
|
||||
log.append("\(name) ends")
|
||||
}
|
||||
}
|
||||
await chain.waitUntilIdle()
|
||||
#expect(log == ["a begins", "a ends", "b begins", "b ends", "c begins", "c ends"])
|
||||
}
|
||||
|
||||
@Test func waitsForJobsEnqueuedByJobs() async {
|
||||
let chain = BackendChain()
|
||||
var log: [String] = []
|
||||
chain.enqueue {
|
||||
log.append("outer")
|
||||
chain.enqueue {
|
||||
await Task.yield() // Still running when the outer job's Task finishes.
|
||||
log.append("inner")
|
||||
}
|
||||
}
|
||||
await chain.waitUntilIdle()
|
||||
#expect(log == ["outer", "inner"])
|
||||
}
|
||||
|
||||
@Test func closingSkipsJobsThatHaveNotStarted() async {
|
||||
let chain = BackendChain()
|
||||
var log: [String] = []
|
||||
chain.enqueue {
|
||||
log.append("running")
|
||||
chain.close()
|
||||
}
|
||||
chain.enqueue { log.append("queued") }
|
||||
await chain.waitUntilIdle()
|
||||
chain.enqueue { log.append("after close") }
|
||||
await chain.waitUntilIdle()
|
||||
#expect(log == ["running"])
|
||||
}
|
||||
|
||||
@Test func liveIDsAreASnapshot() {
|
||||
let ids = LiveIDs()
|
||||
ids.insert("knotchy-00000001")
|
||||
ids.insert("knotchy-00000002")
|
||||
ids.remove("knotchy-00000001")
|
||||
#expect(ids.snapshot == ["knotchy-00000002"])
|
||||
}
|
||||
|
||||
@Test nonisolated func liveIDsCanBeUsedFromManyThreadsAtOnce() {
|
||||
let ids = LiveIDs()
|
||||
DispatchQueue.concurrentPerform(iterations: 1000) { i in
|
||||
ids.insert("knotchy-\(i)")
|
||||
_ = ids.snapshot
|
||||
if i.isMultiple(of: 2) { ids.remove("knotchy-\(i)") }
|
||||
}
|
||||
#expect(ids.snapshot.count == 500)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
struct CLIRunnerTests {
|
||||
@Test func findsARuntimeInstalledAfterLaunch() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("cli-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let tool = directory.appendingPathComponent("container")
|
||||
let runner = CLIRunner(searchPaths: [tool.path])
|
||||
#expect(runner.executable == nil)
|
||||
|
||||
try "#!/bin/sh\necho container CLI version 1.4.1\n".write(to: tool, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tool.path)
|
||||
#expect(runner.executable == tool.path)
|
||||
#expect(try runner.runBlocking(["--version"], timeout: 5).text.contains("1.4.1"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
@Suite(.timeLimit(.minutes(1)))
|
||||
struct TestClockTests {
|
||||
@Test func sleepReturnsOnlyOnceTimeIsAdvancedPastTheDeadline() async throws {
|
||||
let clock = TestClock()
|
||||
let deadline = clock.now + .seconds(10)
|
||||
let sleeper = Task { try await clock.sleep(until: deadline) }
|
||||
await waitUntil("the task sleeps") { clock.sleeperCount == 1 }
|
||||
|
||||
clock.advance(by: .seconds(9))
|
||||
#expect(clock.sleeperCount == 1)
|
||||
|
||||
clock.advance(by: .seconds(1))
|
||||
try await sleeper.value
|
||||
#expect(clock.sleeperCount == 0)
|
||||
}
|
||||
|
||||
@Test func sleepingUntilNowOrThePastReturnsImmediately() async throws {
|
||||
let clock = TestClock()
|
||||
try await clock.sleep(until: clock.now)
|
||||
try await clock.sleep(until: clock.now - .seconds(1))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func aSleepInAnAlreadyCancelledTaskThrows() async {
|
||||
@MainActor final class Outcome {
|
||||
var error: (any Error)?
|
||||
var finished = false
|
||||
}
|
||||
let clock = TestClock()
|
||||
let outcome = Outcome()
|
||||
Task { @MainActor in
|
||||
withUnsafeCurrentTask { $0?.cancel() }
|
||||
do { try await clock.sleep(until: clock.now + .seconds(10)) } catch { outcome.error = error }
|
||||
outcome.finished = true
|
||||
}
|
||||
// Bounded: a sleep that wrongly registers would never return.
|
||||
await waitUntil("the sleep ends") { outcome.finished }
|
||||
#expect(outcome.error is CancellationError)
|
||||
#expect(clock.sleeperCount == 0)
|
||||
}
|
||||
|
||||
@Test func cancellingASleeperThrowsAndForgetsIt() async {
|
||||
let clock = TestClock()
|
||||
let sleeper = Task { try await clock.sleep(until: clock.now + .seconds(10)) }
|
||||
await waitUntil("the task sleeps") { clock.sleeperCount == 1 }
|
||||
|
||||
sleeper.cancel()
|
||||
await #expect(throws: CancellationError.self) { try await sleeper.value }
|
||||
#expect(clock.sleeperCount == 0)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct DeadlineTimersTests {
|
||||
@Test func firesDueDeadlinesInOrder() {
|
||||
let clock = TestClock()
|
||||
let timers = DeadlineTimers<String>(clock: clock)
|
||||
var fired: [String] = []
|
||||
timers.onFire = { fired.append($0) }
|
||||
|
||||
// Seven keys share a deadline, so a lost tie-break shows up nearly every run.
|
||||
let ties = (1...6).map { "tie\($0)" }
|
||||
timers.set("late", after: .seconds(2))
|
||||
timers.set("early", after: .seconds(1))
|
||||
for key in ties { timers.set(key, after: .seconds(2)) }
|
||||
clock.advance(by: .milliseconds(999))
|
||||
timers.fireDue()
|
||||
#expect(fired == [])
|
||||
|
||||
clock.advance(by: .seconds(2))
|
||||
timers.fireDue()
|
||||
#expect(fired == ["early", "late"] + ties)
|
||||
|
||||
timers.fireDue()
|
||||
#expect(fired == ["early", "late"] + ties)
|
||||
}
|
||||
|
||||
@Test func settingAKeyAgainReplacesItsDeadline() {
|
||||
let clock = TestClock()
|
||||
let timers = DeadlineTimers<String>(clock: clock)
|
||||
var fired: [String] = []
|
||||
timers.onFire = { fired.append($0) }
|
||||
|
||||
timers.set("a", after: .seconds(1))
|
||||
timers.set("a", after: .seconds(5))
|
||||
#expect(timers.deadline(of: "a") == clock.now + .seconds(5))
|
||||
clock.advance(by: .seconds(4))
|
||||
timers.fireDue()
|
||||
#expect(fired == [])
|
||||
}
|
||||
|
||||
@Test func cancelledDeadlinesNeverFire() {
|
||||
let clock = TestClock()
|
||||
let timers = DeadlineTimers<String>(clock: clock)
|
||||
var fired: [String] = []
|
||||
timers.onFire = { fired.append($0) }
|
||||
|
||||
timers.set("a", after: .seconds(1))
|
||||
timers.cancel("a")
|
||||
clock.advance(by: .seconds(2))
|
||||
timers.fireDue()
|
||||
#expect(fired == [])
|
||||
#expect(timers.deadline(of: "a") == nil)
|
||||
}
|
||||
|
||||
@Test func onFireMaySetAndCancelTimers() {
|
||||
let clock = TestClock()
|
||||
let timers = DeadlineTimers<String>(clock: clock)
|
||||
var fired: [String] = []
|
||||
timers.onFire = { [unowned timers] key in
|
||||
fired.append(key)
|
||||
if key == "a" {
|
||||
timers.cancel("b")
|
||||
timers.set("c", after: .zero)
|
||||
timers.set("d", after: .seconds(1))
|
||||
}
|
||||
}
|
||||
timers.set("a", after: .seconds(1))
|
||||
timers.set("b", after: .seconds(1))
|
||||
clock.advance(by: .seconds(1))
|
||||
timers.fireDue()
|
||||
#expect(fired == ["a", "c"])
|
||||
#expect(timers.deadline(of: "d") == clock.now + .seconds(1))
|
||||
}
|
||||
|
||||
@Test func theDriverFiresEveryTimerInDeadlineOrder() async {
|
||||
let clock = TestClock()
|
||||
let timers = DeadlineTimers<String>(clock: clock)
|
||||
var fired: [String] = []
|
||||
timers.onFire = { fired.append($0) }
|
||||
|
||||
timers.set("b", after: .seconds(2))
|
||||
timers.set("a", after: .seconds(1))
|
||||
await waitUntil("the driver sleeps") { clock.sleeperCount == 1 }
|
||||
clock.advance(by: .seconds(1))
|
||||
await waitUntil("the driver fires a") { fired == ["a"] }
|
||||
await waitUntil("the driver sleeps again") { clock.sleeperCount == 1 }
|
||||
clock.advance(by: .seconds(1))
|
||||
await waitUntil("the driver fires b") { fired == ["a", "b"] }
|
||||
}
|
||||
|
||||
@Test func theDriverFiresWithoutHelp() async {
|
||||
let clock = TestClock()
|
||||
let timers = DeadlineTimers<String>(clock: clock)
|
||||
var fired: [String] = []
|
||||
timers.onFire = { fired.append($0) }
|
||||
|
||||
timers.set("a", after: .seconds(1))
|
||||
await waitUntil("the driver sleeps") { clock.sleeperCount == 1 }
|
||||
clock.advance(by: .seconds(1))
|
||||
await waitUntil("the driver fires") { fired == ["a"] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
struct ContainerCLIBackendRuntimeTests {
|
||||
/// A fake `container` that answers only `--version`.
|
||||
private func fakeCLI(printing line: String) throws -> (CLIRunner, URL) {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("cli-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let tool = directory.appendingPathComponent("container")
|
||||
let script = "#!/bin/sh\nif [ \"$1\" = --version ]; then echo '\(line)'; exit 0; fi\nexit 1\n"
|
||||
try script.write(to: tool, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tool.path)
|
||||
return (CLIRunner(searchPaths: [tool.path]), directory)
|
||||
}
|
||||
|
||||
@Test func anOldRuntimeIsFlaggedBeforeAnythingElse() async throws {
|
||||
let (cli, directory) = try fakeCLI(printing: "container CLI version 1.3.0 (build: release)")
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let backend = ContainerCLIBackend(vmSources: nil, cli: cli)
|
||||
#expect(await backend.runtimeStatus() == .outdated("1.3.0"))
|
||||
#expect(await backend.preflight() == .issue(.cliOutdated(found: "1.3.0")))
|
||||
}
|
||||
|
||||
@Test func aMissingRuntimeIsReported() async {
|
||||
let backend = ContainerCLIBackend(vmSources: nil, cli: CLIRunner(searchPaths: ["/nonexistent/container"]))
|
||||
#expect(await backend.runtimeStatus() == .missing)
|
||||
#expect(await backend.preflight() == .issue(.cliMissing))
|
||||
}
|
||||
|
||||
@Test func anOldRuntimeHasNoAutomaticFix() async throws {
|
||||
let backend = ContainerCLIBackend(vmSources: nil, cli: CLIRunner(searchPaths: []))
|
||||
await #expect(throws: VMBackendError.self) {
|
||||
try await backend.fix(.cliOutdated(found: "1.3.0")) { _ in }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
struct ContainerRuntimeTests {
|
||||
@Test func readsTheVersionFromTheCLIOutput() {
|
||||
#expect(ContainerRuntime.version(in: "container CLI version 1.4.1 (build: release, commit: 9a8917c)\n") == "1.4.1")
|
||||
#expect(ContainerRuntime.version(in: "warning: x\ncontainer CLI version 2.0 (build: debug)") == "2.0")
|
||||
#expect(ContainerRuntime.version(in: "container version 1.4.1") == nil)
|
||||
}
|
||||
|
||||
@Test func comparesVersionsNumerically() {
|
||||
#expect(ContainerRuntime.isOlder("1.3.9", than: "1.4.0"))
|
||||
#expect(ContainerRuntime.isOlder("1.4", than: "1.4.1"))
|
||||
#expect(!ContainerRuntime.isOlder("1.10.0", than: "1.4.0"))
|
||||
#expect(!ContainerRuntime.isOlder("1.4.0", than: "1.4"))
|
||||
}
|
||||
|
||||
@Test func judgesAnInstalledRuntime() {
|
||||
#expect(ContainerRuntime.status(versionOutput: nil) == .missing)
|
||||
#expect(ContainerRuntime.status(versionOutput: "container CLI version 1.3.1 (build: release)") == .outdated("1.3.1"))
|
||||
#expect(ContainerRuntime.status(versionOutput: "container CLI version 1.4.1 (build: release)") == .current("1.4.1"))
|
||||
#expect(ContainerRuntime.status(versionOutput: "container CLI version 9.0.0") == .current("9.0.0"))
|
||||
}
|
||||
|
||||
@Test func unreadableOutputNeverTriggersAnUpdate() {
|
||||
#expect(ContainerRuntime.status(versionOutput: "container 3 (a new format)") == .current(nil))
|
||||
#expect(ContainerRuntime.status(versionOutput: "") == .current(nil))
|
||||
}
|
||||
|
||||
@Test func thePinnedReleaseIsNewEnough() {
|
||||
#expect(!ContainerRuntime.isOlder(ContainerRuntime.pinned.version, than: ContainerRuntime.minimumVersion))
|
||||
#expect(ContainerRuntime.pinned.sha256.count == 64)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
struct ContentReadinessTests {
|
||||
private let t0 = ContinuousClock.now
|
||||
|
||||
@Test func ignoresBootstrapFramesAndProgress() {
|
||||
// A bootstrap 100 after NAVIGATED never gets here: the agent's progress gate drops it.
|
||||
var r = ContentReadiness()
|
||||
r.hello(at: t0)
|
||||
r.frame(isUniform: false)
|
||||
r.progress(100)
|
||||
#expect(!r.isReady)
|
||||
}
|
||||
|
||||
@Test func readyOnTheFirstNonUniformFrameAfterNavigated() {
|
||||
var r = ContentReadiness()
|
||||
r.hello(at: t0)
|
||||
r.navigated(at: t0)
|
||||
r.frame(isUniform: true)
|
||||
#expect(!r.isReady)
|
||||
r.frame(isUniform: false)
|
||||
#expect(r.isReady)
|
||||
}
|
||||
|
||||
@Test func readyOnProgress100AfterNavigated() {
|
||||
var r = ContentReadiness()
|
||||
r.hello(at: t0)
|
||||
r.navigated(at: t0)
|
||||
r.progress(40)
|
||||
#expect(!r.isReady)
|
||||
r.progress(100)
|
||||
#expect(r.isReady)
|
||||
}
|
||||
|
||||
@Test func readyFifteenSecondsAfterNavigated() {
|
||||
var r = ContentReadiness()
|
||||
r.hello(at: t0)
|
||||
r.navigated(at: t0 + .seconds(1))
|
||||
#expect(r.nextDeadline == t0 + .seconds(16))
|
||||
r.tick(now: t0 + .seconds(16) - .milliseconds(1))
|
||||
#expect(!r.isReady)
|
||||
r.tick(now: t0 + .seconds(16))
|
||||
#expect(r.isReady)
|
||||
#expect(r.nextDeadline == nil)
|
||||
}
|
||||
|
||||
@Test func readyTwentyFiveSecondsAfterHelloIfNavigatedNeverComes() {
|
||||
var r = ContentReadiness()
|
||||
#expect(r.nextDeadline == nil)
|
||||
r.hello(at: t0)
|
||||
#expect(r.nextDeadline == t0 + .seconds(25))
|
||||
r.tick(now: t0 + .seconds(24))
|
||||
#expect(!r.isReady)
|
||||
r.tick(now: t0 + .seconds(25))
|
||||
#expect(r.isReady)
|
||||
}
|
||||
|
||||
@Test func theHelloBackstopStillAppliesAfterALateNavigated() {
|
||||
var r = ContentReadiness()
|
||||
r.hello(at: t0)
|
||||
r.navigated(at: t0 + .seconds(20))
|
||||
#expect(r.nextDeadline == t0 + .seconds(25))
|
||||
r.tick(now: t0 + .seconds(25))
|
||||
#expect(r.isReady)
|
||||
}
|
||||
|
||||
@Test func aLateViewerIsCaughtUpByTheReplay() {
|
||||
var loaded = ContentReadiness()
|
||||
loaded.hello(at: t0)
|
||||
loaded.navigated(at: t0)
|
||||
loaded.progress(100)
|
||||
#expect(loaded.isReady)
|
||||
|
||||
var loading = ContentReadiness()
|
||||
loading.hello(at: t0)
|
||||
loading.navigated(at: t0)
|
||||
loading.progress(40)
|
||||
#expect(!loading.isReady)
|
||||
loading.frame(isUniform: false)
|
||||
#expect(loading.isReady)
|
||||
}
|
||||
|
||||
@Test func keepsTheFirstHelloAndNavigated() {
|
||||
// A repeated message can't push the timeouts back.
|
||||
var r = ContentReadiness()
|
||||
r.hello(at: t0)
|
||||
r.hello(at: t0 + .seconds(10))
|
||||
#expect(r.nextDeadline == t0 + .seconds(25))
|
||||
r.navigated(at: t0 + .seconds(5))
|
||||
r.navigated(at: t0 + .seconds(9))
|
||||
#expect(r.nextDeadline == t0 + .seconds(20))
|
||||
}
|
||||
|
||||
@Test func staysReadyOnceReady() {
|
||||
var r = ContentReadiness()
|
||||
r.hello(at: t0)
|
||||
r.navigated(at: t0)
|
||||
r.frame(isUniform: false)
|
||||
r.frame(isUniform: true)
|
||||
r.progress(10)
|
||||
#expect(r.isReady)
|
||||
}
|
||||
}
|
||||
|
||||
struct FrameSamplerTests {
|
||||
private func frame(width: Int, height: Int, bytesPerRow: Int) -> [UInt8] {
|
||||
var bytes = [UInt8](repeating: 0, count: height * bytesPerRow)
|
||||
for y in 0..<height {
|
||||
for x in 0..<width * 4 { bytes[y * bytesPerRow + x] = 0xFF }
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
/// Bytes that differ almost everywhere, so an out-of-bounds read can't look uniform by chance.
|
||||
private func ramp(_ count: Int) -> [UInt8] {
|
||||
(0..<count).map { UInt8(truncatingIfNeeded: $0 &* 37 &+ 1) }
|
||||
}
|
||||
|
||||
@Test func aFlatFrameIsUniform() {
|
||||
let bytes = frame(width: 96, height: 60, bytesPerRow: 96 * 4 + 16)
|
||||
#expect(bytes.withUnsafeBytes { FrameSampler.isUniform($0, width: 96, height: 60, bytesPerRow: 96 * 4 + 16) })
|
||||
}
|
||||
|
||||
@Test func aSampledPixelThatDiffersMakesItContent() {
|
||||
var bytes = frame(width: 96, height: 60, bytesPerRow: 96 * 4)
|
||||
// Grid point (gx 24, gy 24) samples pixel (48, 30).
|
||||
bytes[30 * 96 * 4 + 48 * 4] = 0
|
||||
#expect(!bytes.withUnsafeBytes { FrameSampler.isUniform($0, width: 96, height: 60, bytesPerRow: 96 * 4) })
|
||||
}
|
||||
|
||||
@Test func aTightBufferIsSampledAndOneByteShortIsRejected() {
|
||||
let (width, height, bytesPerRow) = (8, 8, 40)
|
||||
let bytes = ramp((height - 1) * bytesPerRow + width * 4)
|
||||
#expect(!bytes.withUnsafeBytes { FrameSampler.isUniform($0, width: width, height: height, bytesPerRow: bytesPerRow) })
|
||||
#expect(Array(bytes.dropLast()).withUnsafeBytes {
|
||||
FrameSampler.isUniform($0, width: width, height: height, bytesPerRow: bytesPerRow)
|
||||
})
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
(8, 8, -32), (8, 8, 31), (8, 0, 32), (0, 8, 32), (8, -1, 32),
|
||||
(Int.max / 2, 8, Int.max), (8, Int.max / 2, 32), (8, 8, Int.max), (1 << 16, 1, 1 << 18),
|
||||
])
|
||||
func malformedGeometryCountsAsUniform(_ geometry: (width: Int, height: Int, bytesPerRow: Int)) {
|
||||
#expect(ramp(4096).withUnsafeBytes {
|
||||
FrameSampler.isUniform($0, width: geometry.width, height: geometry.height, bytesPerRow: geometry.bytesPerRow)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
struct H264Tests {
|
||||
private let aud: [UInt8] = [0x09, 0xF0]
|
||||
private let sei: [UInt8] = [0x06, 0x05, 0x01, 0xAA, 0x80]
|
||||
private let sps: [UInt8] = [0x67, 0x42, 0xC0, 0x1F, 0x8C]
|
||||
private let sps2: [UInt8] = [0x67, 0x42, 0xC0, 0x28, 0x8C]
|
||||
private let pps: [UInt8] = [0x68, 0xCE, 0x3C, 0x80]
|
||||
private let pps2: [UInt8] = [0x68, 0xCE, 0x3C, 0x81]
|
||||
private let idr: [UInt8] = [0x65, 0x88, 0x84, 0x21]
|
||||
private let slice: [UInt8] = [0x41, 0x9A, 0x02]
|
||||
|
||||
private let four: [UInt8] = [0, 0, 0, 1]
|
||||
private let three: [UInt8] = [0, 0, 1]
|
||||
|
||||
private func avcc(_ nals: [UInt8]...) -> Data {
|
||||
nals.reduce(into: Data()) { data, nal in
|
||||
data.appendBE32(UInt32(nal.count))
|
||||
data.append(contentsOf: nal)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func splitsOnThreeAndFourByteStartCodes() {
|
||||
let stream = Data(four + aud + three + sps + four + pps + three + idr)
|
||||
#expect(AnnexB.nalUnits(in: stream) == [aud, sps, pps, idr].map { Data($0) })
|
||||
}
|
||||
|
||||
@Test func ignoresLeadingBytesAndTrailingZeros() {
|
||||
let stream = Data([0xFF, 0x00] + three + slice + [0, 0] + four + slice + [0])
|
||||
#expect(AnnexB.nalUnits(in: stream) == [Data(slice), Data(slice)])
|
||||
}
|
||||
|
||||
@Test func handlesStreamsWithoutStartCodes() {
|
||||
#expect(AnnexB.nalUnits(in: Data()) == [])
|
||||
#expect(AnnexB.nalUnits(in: Data([0x65, 0x88])) == [])
|
||||
#expect(AnnexB.nalUnits(in: Data(four)) == [])
|
||||
#expect(AnnexB.nalUnits(in: Data(three + idr + three)) == [Data(idr)])
|
||||
}
|
||||
|
||||
@Test func turnsAnIDRAccessUnitIntoAnAVCCSampleWithNewParameterSets() {
|
||||
var parser = H264Parser()
|
||||
let unit = parser.parse(Data(four + aud + four + sps + four + pps + four + sei + four + idr + four + idr))
|
||||
#expect(unit.sample == avcc(idr, idr))
|
||||
#expect(unit.isIDR)
|
||||
#expect(unit.newParameterSets == ParameterSets(sps: Data(sps), pps: Data(pps)))
|
||||
#expect(parser.parameterSets == unit.newParameterSets)
|
||||
}
|
||||
|
||||
@Test func repeatedParameterSetsAreNotAChange() {
|
||||
var parser = H264Parser()
|
||||
_ = parser.parse(Data(four + sps + four + pps + four + idr))
|
||||
let again = parser.parse(Data(four + aud + four + sps + four + pps + four + idr))
|
||||
#expect(again.newParameterSets == nil)
|
||||
let delta = parser.parse(Data(four + aud + four + slice))
|
||||
#expect(delta.sample == avcc(slice))
|
||||
#expect(!delta.isIDR)
|
||||
#expect(delta.newParameterSets == nil)
|
||||
// Repeated with every IDR: not a change, so the decoder isn't rebuilt.
|
||||
let nextIDR = parser.parse(Data(four + aud + four + sps + four + pps + four + idr))
|
||||
#expect(nextIDR.newParameterSets == nil)
|
||||
}
|
||||
|
||||
@Test func aChangedSPSOrPPSIsANewFormat() {
|
||||
var parser = H264Parser()
|
||||
_ = parser.parse(Data(four + sps + four + pps + four + idr))
|
||||
let changedSPS = parser.parse(Data(four + sps2 + four + pps + four + idr))
|
||||
#expect(changedSPS.newParameterSets == ParameterSets(sps: Data(sps2), pps: Data(pps)))
|
||||
let changedPPS = parser.parse(Data(four + sps2 + four + pps2 + four + idr))
|
||||
#expect(changedPPS.newParameterSets == ParameterSets(sps: Data(sps2), pps: Data(pps2)))
|
||||
}
|
||||
|
||||
@Test func aHalfPairIsNeverAFormat() {
|
||||
var parser = H264Parser()
|
||||
let spsOnly = parser.parse(Data(four + sps + four + idr))
|
||||
#expect(spsOnly.newParameterSets == nil)
|
||||
#expect(parser.parameterSets == nil)
|
||||
|
||||
_ = parser.parse(Data(four + sps + four + pps + four + idr))
|
||||
let lone = parser.parse(Data(four + sps2 + four + slice))
|
||||
#expect(lone.newParameterSets == nil)
|
||||
#expect(parser.parameterSets == ParameterSets(sps: Data(sps), pps: Data(pps)))
|
||||
}
|
||||
|
||||
@Test func onlyValidSliceUnitsAreDecoded() {
|
||||
var parser = H264Parser()
|
||||
// Type 21, a 3D extension slice.
|
||||
let extensionSlice = parser.parse(Data(four + [0x75, 0x88]))
|
||||
#expect(extensionSlice.sample.isEmpty)
|
||||
#expect(!extensionSlice.isIDR)
|
||||
// Forbidden bit set.
|
||||
let forbidden = parser.parse(Data(four + [0xE5, 0x88] + four + slice))
|
||||
#expect(forbidden.sample == avcc(slice))
|
||||
#expect(!forbidden.isIDR)
|
||||
#expect(parser.parse(Data(four + idr + four + slice)).isIDR)
|
||||
let long = [UInt8(0x41)] + [UInt8](repeating: 0x9A, count: 299)
|
||||
#expect(parser.parse(Data(four + long)).sample.prefix(4) == Data([0, 0, 0x01, 0x2C]))
|
||||
}
|
||||
|
||||
@Test func slicesBeforeAnyParameterSetsHaveNoFormat() {
|
||||
var parser = H264Parser()
|
||||
let unit = parser.parse(Data(four + slice))
|
||||
#expect(unit.sample == avcc(slice))
|
||||
#expect(unit.newParameterSets == nil)
|
||||
#expect(parser.parameterSets == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
struct HostChecksTests {
|
||||
@Test func unsupportedMacsAlwaysSeeTheWindow() {
|
||||
#expect(SetupLaunchRule.shouldOpen(hostSupported: false, setupCompleted: true, runtime: .current("1.4.1")))
|
||||
}
|
||||
|
||||
@Test func theFirstLaunchAlwaysSeesTheWindow() {
|
||||
#expect(SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: false, runtime: .current("1.4.1")))
|
||||
}
|
||||
|
||||
@Test func anUnsupportedMacOrAFirstLaunchOpensItWhateverTheRuntime() {
|
||||
#expect(SetupLaunchRule.shouldOpen(hostSupported: false, setupCompleted: false, runtime: .missing))
|
||||
#expect(SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: false, runtime: .missing))
|
||||
#expect(SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: false, runtime: .outdated("1.3.0")))
|
||||
}
|
||||
|
||||
@Test func laterOnlyAMissingOrOldRuntimeOpensIt() {
|
||||
#expect(SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: true, runtime: .missing))
|
||||
#expect(SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: true, runtime: .outdated("1.3.0")))
|
||||
#expect(!SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: true, runtime: .current("1.4.1")))
|
||||
#expect(!SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: true, runtime: .current(nil)))
|
||||
}
|
||||
|
||||
@MainActor @Test func thisMacCanRunVirtualMachines() {
|
||||
#expect(SystemHost.unsupported() == nil)
|
||||
}
|
||||
|
||||
@Test func factsIncludeTheSystemAndFreeSpace() async {
|
||||
let facts = await SystemHost().facts()
|
||||
#expect(facts.osVersion.hasPrefix("macOS "))
|
||||
#expect((facts.bytesFree ?? 0) > 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
struct LinkIntakeTests {
|
||||
private func extract(_ text: String) -> String? {
|
||||
LinkIntake.extractURL(from: text)?.absoluteString
|
||||
}
|
||||
|
||||
@Test func findsAURLInsideProse() {
|
||||
#expect(extract("Your parcel is waiting at https://example.com/track?id=42 today") == "https://example.com/track?id=42")
|
||||
}
|
||||
|
||||
@Test func acceptsABareURL() {
|
||||
#expect(extract("http://example.com") == "http://example.com")
|
||||
}
|
||||
|
||||
@Test func stripsTrailingPunctuation() {
|
||||
#expect(extract("Go to https://example.com/page.") == "https://example.com/page")
|
||||
#expect(extract("**https://example.com/page**") == "https://example.com/page")
|
||||
#expect(extract("https://example.com/search?q=*") == "https://example.com/search?q=*")
|
||||
#expect(extract("Really? https://example.com/page!?") == "https://example.com/page")
|
||||
#expect(extract("'https://example.com/a'") == "https://example.com/a")
|
||||
#expect(extract("\"https://example.com/a\",") == "https://example.com/a")
|
||||
#expect(extract("see https://example.com/a;") == "https://example.com/a")
|
||||
}
|
||||
|
||||
@Test func keepsBalancedBracketsAndStripsUnbalancedOnes() {
|
||||
#expect(extract("https://en.wikipedia.org/wiki/Foo_(bar)") == "https://en.wikipedia.org/wiki/Foo_(bar)")
|
||||
#expect(extract("(see https://en.wikipedia.org/wiki/Foo_(bar)).") == "https://en.wikipedia.org/wiki/Foo_(bar)")
|
||||
#expect(extract("(see https://example.com/a)") == "https://example.com/a")
|
||||
#expect(extract("(https://example.com/a)and more") == "https://example.com/a")
|
||||
#expect(extract("(https://en.wikipedia.org/wiki/Foo_(bar))") == "https://en.wikipedia.org/wiki/Foo_(bar)")
|
||||
#expect(extract("[link](https://example.com/a)") == "https://example.com/a")
|
||||
#expect(extract("https://example.com/a]") == "https://example.com/a")
|
||||
#expect(extract("{https://example.com/a}") == "https://example.com/a")
|
||||
}
|
||||
|
||||
@Test func handlesMarkdownLinks() {
|
||||
#expect(extract("[https://example.com/a](https://example.com/a)") == "https://example.com/a")
|
||||
#expect(extract("[https://example.com/a](https://example.org/b)") == "https://example.com/a")
|
||||
#expect(extract("[httpbin](https://httpbin.org)") == "https://httpbin.org")
|
||||
#expect(extract("See [XMLHttpRequest](https://developer.mozilla.org/docs/XMLHttpRequest).")
|
||||
== "https://developer.mozilla.org/docs/XMLHttpRequest")
|
||||
#expect(extract("**[Example](https://example.com)**") == "https://example.com")
|
||||
#expect(extract("*[Example](https://example.com/a)*") == "https://example.com/a")
|
||||
#expect(extract("Get it: **[httpbin](https://httpbin.org)**.") == "https://httpbin.org")
|
||||
#expect(extract("[x](https://example.com)and more") == "https://example.com")
|
||||
#expect(extract("[w](https://en.wikipedia.org/wiki/Foo_(bar))**") == "https://en.wikipedia.org/wiki/Foo_(bar)")
|
||||
}
|
||||
|
||||
@Test func smartQuotesAndCJKPunctuationEndALink() {
|
||||
#expect(extract("\u{201C}https://example.com\u{201D}") == "https://example.com")
|
||||
#expect(extract("\u{201C}https://example.com/page\u{201D}") == "https://example.com/page")
|
||||
#expect(extract("\u{00AB}https://example.com/page\u{00BB}") == "https://example.com/page")
|
||||
#expect(extract("请访问https://example.com。谢谢") == "https://example.com")
|
||||
#expect(extract("请访问https://example.com.谢谢") == "https://example.com")
|
||||
#expect(extract("https://example.com。テスト") == "https://example.com")
|
||||
#expect(extract("见(https://example.com/a)") == "https://example.com/a")
|
||||
#expect(extract("「https://example.com/a」") == "https://example.com/a")
|
||||
}
|
||||
|
||||
@Test func unwrapsAngleBrackets() {
|
||||
#expect(extract("Open <https://example.com/a> now") == "https://example.com/a")
|
||||
}
|
||||
|
||||
@Test func usesTheFirstWebURL() {
|
||||
#expect(extract("ftp://files.example.com then https://b.example.com and https://c.example.com") == "https://b.example.com")
|
||||
}
|
||||
|
||||
@Test func textWithoutAWebLinkHasNoLink() {
|
||||
#expect(extract("javascript:alert(1)") == nil)
|
||||
#expect(extract("file:///etc/passwd") == nil)
|
||||
#expect(extract("mailto:someone@example.com") == nil)
|
||||
#expect(extract("ftp://files.example.com") == nil)
|
||||
#expect(extract("httpx://example.com") == nil)
|
||||
}
|
||||
|
||||
@Test func rejectsEmptyAndHostlessInput() {
|
||||
#expect(extract("") == nil)
|
||||
#expect(extract(" \n") == nil)
|
||||
#expect(extract("no links here, just httpbin and https://") == nil)
|
||||
}
|
||||
|
||||
@Test func acceptsUppercaseSchemes() {
|
||||
#expect(extract("HTTPS://EXAMPLE.COM/") == "HTTPS://EXAMPLE.COM/")
|
||||
}
|
||||
|
||||
@Test func acceptsLinksUpToTheLengthLimit() {
|
||||
let base = "https://example.com/"
|
||||
let longest = base + String(repeating: "a", count: LinkIntake.maxURLBytes - base.utf8.count)
|
||||
#expect(extract(longest) == longest)
|
||||
#expect(extract("Go to " + longest + ".") == longest) // Measured after trimming.
|
||||
#expect(extract(longest + "a") == nil)
|
||||
// Measured as encoded: each `{` becomes `%7B`.
|
||||
#expect(extract(base + String(repeating: "{", count: 6000)) == nil)
|
||||
}
|
||||
|
||||
@Test func skipsAnOverlongTokenWholeIncludingLinksNestedInIt() {
|
||||
let overlong = "https://example.com/" + String(repeating: "a", count: LinkIntake.maxURLBytes)
|
||||
#expect(extract(overlong + "https://nested.example.com/") == nil)
|
||||
#expect(extract(overlong + " https://next.example.com/") == "https://next.example.com/")
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
String(repeating: "http", count: 256 * 1024),
|
||||
String(repeating: "http ", count: 100_000),
|
||||
String(repeating: "http" + String(repeating: ")", count: 16_000) + " ", count: 16),
|
||||
String(repeating: "http](", count: 200_000),
|
||||
])
|
||||
func hostileTextIsScannedInLinearTime(_ hostile: String) {
|
||||
// Quadratic versions took 10 s to minutes on these; linear takes well under a second.
|
||||
let start = ContinuousClock.now
|
||||
#expect(extract(hostile) == nil)
|
||||
#expect(ContinuousClock.now - start < .seconds(5))
|
||||
}
|
||||
|
||||
@Test func onlyLinksStartingNearTheStartOfHugeTextCount() {
|
||||
let filler = String(repeating: "x", count: LinkIntake.maxScannedCharacters)
|
||||
#expect(extract(filler + " https://example.com/") == nil)
|
||||
#expect(extract("https://example.com/ " + filler) == "https://example.com/")
|
||||
}
|
||||
|
||||
@Test func aLinkThatStartsBeforeTheScanLimitIsReadWhole() {
|
||||
let link = "https://example.com/very/long/path?token=abcdef"
|
||||
let filler = String(repeating: "x", count: LinkIntake.maxScannedCharacters - 30)
|
||||
#expect(extract(filler + " " + link) == link)
|
||||
}
|
||||
|
||||
@Test func findsTheLinkBehindSelectedHyperlinkText() {
|
||||
func extract(html: String) -> String? { LinkIntake.extractURL(fromHTML: html)?.absoluteString }
|
||||
#expect(extract(html: #"<a href="https://www.theverge.com/transportation/909060/amflows-latest-e-bikes">Amflow's latest e-bikes</a>"#)
|
||||
== "https://www.theverge.com/transportation/909060/amflows-latest-e-bikes")
|
||||
#expect(extract(html: #"<a href='https://www.google.com/url?q=https://example.com/&sa=D'>Verify</a>"#)
|
||||
== "https://www.google.com/url?q=https://example.com/&sa=D")
|
||||
#expect(extract(html: "<A HREF=https://example.com/a>x</A>") == "https://example.com/a")
|
||||
#expect(extract(html: #"<a href="mailto:a@example.com">mail</a> <a href="https://example.org/">web</a>"#) == "https://example.org/")
|
||||
#expect(extract(html: "<p>see https://example.com/page.</p>") == "https://example.com/page")
|
||||
#expect(extract(html: "<p>no links</p>") == nil)
|
||||
}
|
||||
|
||||
@Test func validatesURLObjects() throws {
|
||||
let web = try #require(URL(string: "https://example.com/x"))
|
||||
#expect(LinkIntake.validate(web) == web)
|
||||
#expect(LinkIntake.validate(try #require(URL(string: "HTTP://example.com/"))) != nil)
|
||||
#expect(LinkIntake.validate(URL(filePath: "/etc/passwd")) == nil)
|
||||
#expect(LinkIntake.validate(try #require(URL(string: "ftp://files.example.com/"))) == nil)
|
||||
#expect(LinkIntake.validate(try #require(URL(string: "mailto:a@example.com"))) == nil)
|
||||
#expect(LinkIntake.validate(try #require(URL(string: "https:///no-host"))) == nil)
|
||||
#expect(LinkIntake.validate(try #require(URL(string: "http://:80/"))) == nil)
|
||||
let long = "https://example.com/" + String(repeating: "a", count: LinkIntake.maxURLBytes)
|
||||
#expect(LinkIntake.validate(try #require(URL(string: long))) == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
@Suite struct NetworkRouteTests {
|
||||
static let key = Data(repeating: 7, count: 32).base64EncodedString()
|
||||
static let otherKey = Data(repeating: 9, count: 32).base64EncodedString()
|
||||
|
||||
static let mullvadStyle = """
|
||||
[Interface]
|
||||
# Device: Quick Fox
|
||||
PrivateKey = \(key)
|
||||
Address = 10.64.12.34/32,fc00:bbbb:bbbb:bb01::1:c21/128
|
||||
DNS = 10.64.0.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = \(otherKey)
|
||||
AllowedIPs = 0.0.0.0/0,::0/0
|
||||
Endpoint = se-got-wg-001.relays.mullvad.net:51820
|
||||
"""
|
||||
|
||||
@Test func parsesAProviderConfig() throws {
|
||||
let config = try WireGuardConfig.parse(Self.mullvadStyle)
|
||||
#expect(config.privateKey == Self.key)
|
||||
#expect(config.addresses == ["10.64.12.34/32"])
|
||||
#expect(config.dns == ["10.64.0.1"])
|
||||
#expect(config.peerPublicKey == Self.otherKey)
|
||||
#expect(config.endpointHost == "se-got-wg-001.relays.mullvad.net")
|
||||
#expect(config.endpointPort == 51820)
|
||||
#expect(config.mtu == nil)
|
||||
#expect(config.persistentKeepalive == nil)
|
||||
}
|
||||
|
||||
@Test func keysAreCaseInsensitiveAndRepeatsJoin() throws {
|
||||
let text = """
|
||||
[interface]
|
||||
privatekey=\(Self.key)
|
||||
Address = 10.2.0.2
|
||||
Address = 10.2.0.3/32
|
||||
DNS = 10.2.0.1, 1.1.1.1, example.search.domain
|
||||
MTU = 1380
|
||||
[PEER]
|
||||
publickey = \(Self.otherKey)
|
||||
PresharedKey = \(Self.key)
|
||||
endpoint = 203.0.113.9:443
|
||||
PersistentKeepalive = 15
|
||||
"""
|
||||
let config = try WireGuardConfig.parse(text)
|
||||
#expect(config.addresses == ["10.2.0.2/32", "10.2.0.3/32"])
|
||||
#expect(config.dns == ["10.2.0.1", "1.1.1.1"])
|
||||
#expect(config.mtu == 1380)
|
||||
#expect(config.presharedKey == Self.key)
|
||||
#expect(config.persistentKeepalive == 15)
|
||||
}
|
||||
|
||||
@Test func rejectsWhatTheVMCannotUse() {
|
||||
func parse(_ text: String) -> WireGuardConfig.ParseError? {
|
||||
do { _ = try WireGuardConfig.parse(text); return nil } catch { return error }
|
||||
}
|
||||
let peer = "[Peer]\nPublicKey = \(Self.otherKey)\nEndpoint = 1.2.3.4:51820\n"
|
||||
#expect(parse("[Interface]\nAddress = 10.0.0.2/32\n" + peer) == .missing("PrivateKey"))
|
||||
#expect(parse("[Interface]\nPrivateKey = nope\nAddress = 10.0.0.2/32\n" + peer) == .invalid("PrivateKey"))
|
||||
#expect(parse("[Interface]\nPrivateKey = \(Self.key)\nAddress = fd00::2/128\n" + peer) == .missing("an IPv4 Address"))
|
||||
#expect(parse("[Interface]\nPrivateKey = \(Self.key)\nAddress = 10.0.0.2/32\n") == .missing("[Peer]"))
|
||||
#expect(parse("[Interface]\nPrivateKey = \(Self.key)\nAddress = 10.0.0.2/32\n" + peer + peer) == .multiplePeers)
|
||||
let v6Endpoint = "[Peer]\nPublicKey = \(Self.otherKey)\nEndpoint = [2001:db8::1]:51820\n"
|
||||
#expect(parse("[Interface]\nPrivateKey = \(Self.key)\nAddress = 10.0.0.2/32\n" + v6Endpoint) != nil)
|
||||
let shellEndpoint = "[Peer]\nPublicKey = \(Self.otherKey)\nEndpoint = $(reboot):51820\n"
|
||||
#expect(parse("[Interface]\nPrivateKey = \(Self.key)\nAddress = 10.0.0.2/32\n" + shellEndpoint) == .invalid("Endpoint"))
|
||||
#expect(parse("[Interface]\nPrivateKey = \(Self.key)\nAddress = 10.0.0.2/32\nPostUp = rm -rf /\n" + peer) == nil)
|
||||
}
|
||||
|
||||
@Test func setconfForcesAFullTunnelAndAKeepalive() throws {
|
||||
let config = try WireGuardConfig.parse(Self.mullvadStyle)
|
||||
let setconf = config.setconf(endpointAddress: "185.213.154.66")
|
||||
#expect(setconf == """
|
||||
[Interface]
|
||||
PrivateKey = \(Self.key)
|
||||
|
||||
[Peer]
|
||||
PublicKey = \(Self.otherKey)
|
||||
Endpoint = 185.213.154.66:51820
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
PersistentKeepalive = 25
|
||||
|
||||
""")
|
||||
}
|
||||
|
||||
@Test func proxyURLsEncodeCredentialsAndUseTheAddress() {
|
||||
let proxy = ProxySettings(kind: .socks5, host: "proxy.example", port: 1080, username: "me@home", password: "p:w/d@!")
|
||||
#expect(proxy.url(address: "203.0.113.7") == "socks5://me%40home:p%3Aw%2Fd%40%21@203.0.113.7:1080")
|
||||
#expect(ProxySettings(kind: .http, host: "h", port: 8080).url(address: "10.0.0.5") == "http://10.0.0.5:8080")
|
||||
#expect(ProxySettings(kind: .http, host: "h", port: 8080, username: "u").url(address: "10.0.0.5") == "http://u@10.0.0.5:8080")
|
||||
}
|
||||
|
||||
@Test func launchKeepsSecretsOutOfTheEnvironment() throws {
|
||||
var asked: [String] = []
|
||||
let resolve = { (host: String) -> String in asked.append(host); return "198.51.100.4" }
|
||||
#expect(try NetworkRoute.direct.launch(resolve: resolve) == RouteLaunch(environment: ["KNOTCHY_ROUTE=direct"], secrets: []))
|
||||
|
||||
let proxy = NetworkRoute.proxy(ProxySettings(kind: .http, host: "proxy.corp", port: 3128, username: "u", password: "p"))
|
||||
#expect(try proxy.launch(resolve: resolve) == RouteLaunch(
|
||||
environment: ["KNOTCHY_ROUTE=proxy", "KNOTCHY_PROXY_ADDR=198.51.100.4:3128"],
|
||||
secrets: ["KNOTCHY_PROXY_URL=http://u:p@198.51.100.4:3128"]
|
||||
))
|
||||
|
||||
let wireGuard = try NetworkRoute.wireGuard(WireGuardConfig.parse(Self.mullvadStyle)).launch(resolve: resolve)
|
||||
#expect(asked == ["proxy.corp", "se-got-wg-001.relays.mullvad.net"])
|
||||
#expect(wireGuard.environment == [
|
||||
"KNOTCHY_ROUTE=wireguard", "KNOTCHY_WG_ENDPOINT=198.51.100.4:51820", "KNOTCHY_WG_ADDRESS=10.64.12.34/32",
|
||||
"KNOTCHY_WG_DNS=10.64.0.1", "KNOTCHY_WG_MTU=1420",
|
||||
])
|
||||
#expect(wireGuard.environment.allSatisfy { !$0.contains(Self.key) })
|
||||
#expect(wireGuard.secrets.count == 1 && wireGuard.secrets[0].hasPrefix("KNOTCHY_WG_SETCONF="))
|
||||
#expect((wireGuard.environment + wireGuard.secrets).allSatisfy { !$0.contains("\n") })
|
||||
|
||||
#expect(throws: VMBackendError.failed("no")) { try NetworkRoute.unusable(reason: "no").launch(resolve: resolve) }
|
||||
}
|
||||
|
||||
@Test func routeLabels() throws {
|
||||
#expect(NetworkRoute.direct.label == nil)
|
||||
#expect(NetworkRoute.proxy(ProxySettings(kind: .socks5, host: "h", port: 1)).label == "SOCKS5 proxy")
|
||||
var config = try WireGuardConfig.parse(Self.mullvadStyle)
|
||||
#expect(NetworkRoute.wireGuard(config).label == "VPN")
|
||||
config.name = "sg-sin-wg-001"
|
||||
#expect(NetworkRoute.wireGuard(config).label == "sg-sin-wg-001")
|
||||
}
|
||||
|
||||
@Test func proxiesOnTheMacOrItsNetworkAreRefused() throws {
|
||||
for local in ["10.1.2.3", "127.0.0.1", "169.254.169.254", "172.20.0.1", "192.168.64.1", "100.100.1.1", "0.0.0.0"] {
|
||||
#expect(ProxySettings.isLocal(local, hostNetworks: []), "\(local)")
|
||||
}
|
||||
#expect(ProxySettings.isLocal("203.0.113.9", hostNetworks: ["203.0.113.0/24"]))
|
||||
#expect(!ProxySettings.isLocal("203.0.113.9", hostNetworks: ["198.51.100.0/24"]))
|
||||
#expect(!ProxySettings.isLocal("8.8.8.8", hostNetworks: []))
|
||||
#expect(!ProxySettings.isLocal("172.32.0.1", hostNetworks: []))
|
||||
|
||||
let proxy = NetworkRoute.proxy(ProxySettings(kind: .http, host: "proxy.lan", port: 3128))
|
||||
#expect(throws: VMBackendError.self) { try proxy.launch(resolve: { _ in "192.168.1.10" }) }
|
||||
#expect(try proxy.launch(resolve: { _ in "192.168.1.10" }, allowLocalProxy: true).environment.count == 2)
|
||||
// A local WireGuard server is fine: only wireguard-go can reach it.
|
||||
let wireGuard = NetworkRoute.wireGuard(try WireGuardConfig.parse(Self.mullvadStyle))
|
||||
#expect(try wireGuard.launch(resolve: { _ in "192.168.1.1" }).environment.contains("KNOTCHY_WG_ENDPOINT=192.168.1.1:51820"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import CoreGraphics
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
struct NotchGeometryTests {
|
||||
private let macBookPro = ScreenInfo(
|
||||
frame: CGRect(x: 0, y: 0, width: 1512, height: 982),
|
||||
visibleFrame: CGRect(x: 0, y: 80, width: 1512, height: 870),
|
||||
safeAreaTop: 32,
|
||||
auxLeft: CGRect(x: 0, y: 950, width: 662, height: 32),
|
||||
auxRight: CGRect(x: 850, y: 950, width: 662, height: 32),
|
||||
menuBarThickness: 24
|
||||
)
|
||||
|
||||
private let external = ScreenInfo(
|
||||
frame: CGRect(x: 1512, y: -200, width: 1920, height: 1080),
|
||||
visibleFrame: CGRect(x: 1512, y: -200, width: 1920, height: 1055),
|
||||
safeAreaTop: 0, auxLeft: .zero, auxRight: .zero, menuBarThickness: 24
|
||||
)
|
||||
|
||||
@Test func peekUnderAPhysicalNotch() {
|
||||
let layout = NotchGeometry.layout(for: macBookPro, mode: .peek)
|
||||
#expect(layout.notch == CGRect(x: 662, y: 950, width: 188, height: 32))
|
||||
#expect(layout.pill == CGRect(x: 590, y: 950, width: 332, height: 32))
|
||||
#expect(layout.panel == CGRect(x: 430, y: 510, width: 652, height: 472))
|
||||
#expect(layout.toolbar == CGRect(x: 436, y: 916, width: 640, height: 34))
|
||||
#expect(layout.content == CGRect(x: 436, y: 516, width: 640, height: 400))
|
||||
#expect(layout.contentScale == 0.625)
|
||||
}
|
||||
|
||||
@Test func expandedUnderAPhysicalNotch() {
|
||||
let layout = NotchGeometry.layout(for: macBookPro, mode: .expanded)
|
||||
#expect(layout.panel == CGRect(x: 238, y: 270, width: 1036, height: 712))
|
||||
#expect(layout.content == CGRect(x: 244, y: 276, width: 1024, height: 640))
|
||||
#expect(layout.contentScale == 1)
|
||||
}
|
||||
|
||||
@Test func theNotchHeightIsTheSafeArea() {
|
||||
var screen = macBookPro
|
||||
screen.safeAreaTop = 37
|
||||
screen.auxLeft = CGRect(x: 0, y: 945, width: 662, height: 37)
|
||||
screen.auxRight = CGRect(x: 850, y: 945, width: 662, height: 37)
|
||||
let layout = NotchGeometry.layout(for: screen, mode: .peek)
|
||||
#expect(layout.notch == CGRect(x: 662, y: 945, width: 188, height: 37))
|
||||
#expect(layout.panel.height == CGFloat(37 + 34 + 400 + 6))
|
||||
}
|
||||
|
||||
@Test func aNotchNeedsASafeAreaAndBothMenuBarAreas() {
|
||||
let virtualNotch = CGRect(x: 656, y: 950, width: 200, height: 32)
|
||||
var noRightArea = macBookPro
|
||||
noRightArea.auxRight = .zero
|
||||
#expect(!noRightArea.hasNotch)
|
||||
#expect(NotchGeometry.layout(for: noRightArea, mode: .peek).notch == virtualNotch)
|
||||
|
||||
var noSafeArea = macBookPro
|
||||
noSafeArea.safeAreaTop = 0
|
||||
#expect(!noSafeArea.hasNotch)
|
||||
#expect(NotchGeometry.layout(for: noSafeArea, mode: .peek).notch == virtualNotch)
|
||||
}
|
||||
|
||||
@Test func asymmetricEarsAndNotchWidthClamping() {
|
||||
var screen = macBookPro
|
||||
screen.auxLeft = CGRect(x: 0, y: 950, width: 600, height: 32)
|
||||
screen.auxRight = CGRect(x: 850, y: 950, width: 662, height: 32)
|
||||
#expect(NotchGeometry.layout(for: screen, mode: .peek).notch == CGRect(x: 600, y: 950, width: 250, height: 32))
|
||||
|
||||
screen.auxLeft = CGRect(x: 0, y: 950, width: 500, height: 32)
|
||||
screen.auxRight = CGRect(x: 1000, y: 950, width: 512, height: 32)
|
||||
#expect(NotchGeometry.layout(for: screen, mode: .peek).notch.width == 300)
|
||||
|
||||
screen.auxLeft = CGRect(x: 0, y: 950, width: 700, height: 32)
|
||||
screen.auxRight = CGRect(x: 760, y: 950, width: 752, height: 32)
|
||||
#expect(NotchGeometry.layout(for: screen, mode: .peek).notch == CGRect(x: 670, y: 950, width: 120, height: 32))
|
||||
}
|
||||
|
||||
@Test func virtualNotchOnAScreenWithoutOne() {
|
||||
let layout = NotchGeometry.layout(for: external, mode: .peek)
|
||||
#expect(layout.notch == CGRect(x: 2372, y: 855, width: 200, height: 25))
|
||||
#expect(layout.panel == CGRect(x: 2146, y: 415, width: 652, height: 465))
|
||||
#expect(layout.content == CGRect(x: 2152, y: 421, width: 640, height: 400))
|
||||
}
|
||||
|
||||
@Test func aScreenLeftOfTheMainOne() {
|
||||
var screen = external
|
||||
screen.frame.origin = CGPoint(x: -1920, y: 0)
|
||||
screen.visibleFrame.origin = CGPoint(x: -1920, y: 0)
|
||||
let layout = NotchGeometry.layout(for: screen, mode: .peek)
|
||||
#expect(layout.notch == CGRect(x: -1060, y: 1055, width: 200, height: 25))
|
||||
#expect(layout.panel == CGRect(x: -1286, y: 615, width: 652, height: 465))
|
||||
}
|
||||
|
||||
@Test func autoHiddenMenuBarKeepsAVisibleNotch() {
|
||||
var screen = external
|
||||
screen.visibleFrame = screen.frame
|
||||
screen.menuBarThickness = 0
|
||||
#expect(NotchGeometry.layout(for: screen, mode: .peek).notch.height == 24)
|
||||
|
||||
screen.menuBarThickness = 30
|
||||
#expect(NotchGeometry.layout(for: screen, mode: .peek).notch.height == 30)
|
||||
}
|
||||
|
||||
@Test func expandedScalesDownOnAShortScreenKeeping16by10() {
|
||||
let short = ScreenInfo(
|
||||
frame: CGRect(x: 0, y: 0, width: 1280, height: 720),
|
||||
visibleFrame: CGRect(x: 0, y: 60, width: 1280, height: 636),
|
||||
safeAreaTop: 0, auxLeft: .zero, auxRight: .zero, menuBarThickness: 24
|
||||
)
|
||||
let layout = NotchGeometry.layout(for: short, mode: .expanded)
|
||||
// Usable height: 720 - 60 (Dock) - 24 (notch) - 34 - 6 - 8 = 588, rounded down to 585.
|
||||
#expect(layout.content.size == CGSize(width: 936, height: 585))
|
||||
#expect(layout.content.width / layout.content.height == 1.6)
|
||||
#expect(layout.panel.minY >= short.visibleFrame.minY)
|
||||
#expect(layout.contentScale == CGFloat(936) / 1024)
|
||||
#expect(NotchGeometry.layout(for: short, mode: .peek).content.size == CGSize(width: 640, height: 400))
|
||||
}
|
||||
|
||||
@Test func expandedScalesDownOnANarrowScreen() {
|
||||
let portrait = ScreenInfo(
|
||||
frame: CGRect(x: 0, y: 0, width: 900, height: 1600),
|
||||
visibleFrame: CGRect(x: 0, y: 0, width: 900, height: 1575),
|
||||
safeAreaTop: 0, auxLeft: .zero, auxRight: .zero, menuBarThickness: 25
|
||||
)
|
||||
let layout = NotchGeometry.layout(for: portrait, mode: .expanded)
|
||||
// Usable width: 900 - 16 - 12 = 872, which is exactly 16:10 at 545 high.
|
||||
#expect(layout.panel == CGRect(x: 8, y: 990, width: 884, height: 610))
|
||||
#expect(layout.content == CGRect(x: 14, y: 996, width: 872, height: 545))
|
||||
}
|
||||
|
||||
@Test func aVirtualNotchIsOnWholePoints() {
|
||||
var odd = external
|
||||
odd.frame.size.width = 1365
|
||||
odd.visibleFrame.size.width = 1365
|
||||
let layout = NotchGeometry.layout(for: odd, mode: .peek)
|
||||
#expect(layout.notch.minX == layout.notch.minX.rounded())
|
||||
#expect(layout.panel.minX == layout.panel.minX.rounded())
|
||||
}
|
||||
|
||||
@Test func mapsSurfacePointsToRenderPixels() {
|
||||
let render = PixelSize(width: 1024, height: 640)
|
||||
let peek = CGSize(width: 640, height: 400)
|
||||
func point(_ p: CGPoint, _ surface: CGSize) -> [UInt16]? {
|
||||
NotchGeometry.videoPoint(p, surfaceSize: surface, renderSize: render).map { [$0.x, $0.y] }
|
||||
}
|
||||
#expect(point(CGPoint(x: 320, y: 200), peek) == [512, 320])
|
||||
#expect(point(.zero, peek) == [0, 0])
|
||||
#expect(point(CGPoint(x: 0.5, y: 0.5), peek) == [0, 0])
|
||||
#expect(point(CGPoint(x: 640, y: 400), peek) == [1023, 639])
|
||||
#expect(point(CGPoint(x: -5, y: 900), peek) == [0, 639])
|
||||
// Letterboxing: a wider surface gets 30 pt bars at the sides, a taller one 50 pt above and below.
|
||||
#expect(point(CGPoint(x: 30, y: 0), CGSize(width: 700, height: 400)) == [0, 0])
|
||||
#expect(point(CGPoint(x: 0, y: 450), CGSize(width: 640, height: 500)) == [0, 639])
|
||||
#expect(point(CGPoint(x: 0, y: 50), CGSize(width: 640, height: 500)) == [0, 0])
|
||||
}
|
||||
|
||||
@Test func nothingToMapOntoGivesNoPoint() {
|
||||
let render = PixelSize(width: 1024, height: 640)
|
||||
let peek = CGSize(width: 640, height: 400)
|
||||
#expect(NotchGeometry.videoPoint(.zero, surfaceSize: .zero, renderSize: render) == nil)
|
||||
#expect(NotchGeometry.videoPoint(CGPoint(x: CGFloat.nan, y: 5), surfaceSize: peek, renderSize: render) == nil)
|
||||
#expect(NotchGeometry.videoPoint(.zero, surfaceSize: CGSize(width: CGFloat.infinity, height: CGFloat.infinity), renderSize: render) == nil)
|
||||
#expect(NotchGeometry.videoPoint(.zero, surfaceSize: CGSize(width: CGFloat.leastNonzeroMagnitude, height: 1), renderSize: render) == nil)
|
||||
#expect(NotchGeometry.videoPoint(.zero, surfaceSize: peek, renderSize: PixelSize(width: 70_000, height: 640)) == nil)
|
||||
#expect(NotchGeometry.videoPoint(.zero, surfaceSize: peek, renderSize: PixelSize(width: 0, height: 640)) == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
// Serialized: each download clears leftover download folders, which other tests would still be using.
|
||||
@Suite(.serialized)
|
||||
struct RuntimeInstallerTests {
|
||||
private func release(for data: Data, url: URL) -> ContainerRuntime.Release {
|
||||
let hash = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
||||
return ContainerRuntime.Release(version: "1.4.1", url: url, sha256: hash, size: Int64(data.count))
|
||||
}
|
||||
|
||||
private func source(_ data: Data) throws -> URL {
|
||||
let file = FileManager.default.temporaryDirectory.appendingPathComponent("source-\(UUID().uuidString).pkg")
|
||||
try data.write(to: file)
|
||||
return file
|
||||
}
|
||||
|
||||
private static func downloadDirectories() -> Set<String> {
|
||||
let names = (try? FileManager.default.contentsOfDirectory(atPath: NSTemporaryDirectory())) ?? []
|
||||
return Set(names.filter { $0.hasPrefix("knotchy-runtime-") })
|
||||
}
|
||||
|
||||
@Test func aMatchingDownloadIsKeptUntilDiscarded() async throws {
|
||||
let data = Data("package".utf8)
|
||||
let url = try source(data)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
let installer = SystemRuntimeInstaller()
|
||||
let package = try await installer.download(release(for: data, url: url)) { _ in }
|
||||
#expect(try Data(contentsOf: package.file) == data)
|
||||
#expect(package.file.lastPathComponent == url.lastPathComponent)
|
||||
installer.discard(package)
|
||||
#expect(!FileManager.default.fileExists(atPath: package.directory.path))
|
||||
}
|
||||
|
||||
@Test func aMismatchedDownloadIsDeleted() async throws {
|
||||
let url = try source(Data("tampered".utf8))
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
let expected = release(for: Data("package".utf8), url: url)
|
||||
await #expect(throws: DownloadError.mismatch) {
|
||||
_ = try await SystemRuntimeInstaller().download(expected) { _ in }
|
||||
}
|
||||
#expect(Self.downloadDirectories().isEmpty)
|
||||
}
|
||||
|
||||
@Test func aNewDownloadClearsLeftovers() async throws {
|
||||
let leftover = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("knotchy-runtime-LEFTOVER")
|
||||
try FileManager.default.createDirectory(at: leftover, withIntermediateDirectories: true)
|
||||
let data = Data("package".utf8)
|
||||
let url = try source(data)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
let installer = SystemRuntimeInstaller()
|
||||
let package = try await installer.download(release(for: data, url: url)) { _ in }
|
||||
#expect(!FileManager.default.fileExists(atPath: leftover.path))
|
||||
installer.discard(package)
|
||||
}
|
||||
|
||||
@Test func aFailedDownloadSaysSo() async {
|
||||
let missing = URL(fileURLWithPath: "/nonexistent/container.pkg")
|
||||
await #expect(throws: DownloadError.self) {
|
||||
_ = try await SystemRuntimeInstaller().download(release(for: Data(), url: missing)) { _ in }
|
||||
}
|
||||
}
|
||||
|
||||
// Only this path of the real `openInInstaller` may run in tests: any other would open Installer.
|
||||
@MainActor
|
||||
@Test func aChangedPackageIsNotOpened() async throws {
|
||||
let data = Data("package".utf8)
|
||||
let url = try source(data)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
let installer = SystemRuntimeInstaller()
|
||||
let expected = release(for: data, url: url)
|
||||
let package = try await installer.download(expected) { _ in }
|
||||
try Data("pAckage".utf8).write(to: package.file) // Same size, so the hash has to catch it.
|
||||
guard case .changed = await installer.openInInstaller(package, release: expected) else {
|
||||
Issue.record("Expected the changed package not to be opened")
|
||||
installer.discard(package)
|
||||
return
|
||||
}
|
||||
#expect(!FileManager.default.fileExists(atPath: package.directory.path))
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
@MainActor
|
||||
@Suite(.timeLimit(.minutes(1)))
|
||||
struct SessionNewLinkTests {
|
||||
private let first = "knotchy-00000001"
|
||||
private let second = "knotchy-00000002"
|
||||
|
||||
@Test func fromLiveTheOldVMIsDestroyedAndTheNewOneStartsAtOnce() async {
|
||||
let h = Harness()
|
||||
let old = await h.goLive()
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
#expect(h.controller.openedURL == Harness.otherURL)
|
||||
#expect(old.isClosed)
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .start(first), .destroy(first), .preflight, .start(second)])
|
||||
}
|
||||
|
||||
@Test func aStartThatHasNotBegunNeverRuns() async {
|
||||
let h = Harness()
|
||||
h.controller.open(Harness.url)
|
||||
h.controller.open(Harness.otherURL)
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .start(second)])
|
||||
#expect(h.controller.liveIDs.snapshot == [second])
|
||||
}
|
||||
|
||||
@Test func aSupersededStartIsDestroyedRightAfterItReturns() async {
|
||||
let h = Harness()
|
||||
h.backend.hold(.start)
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("start runs") { h.backend.parkedCount(.start) == 1 }
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.controller.liveIDs.snapshot == [first, second])
|
||||
|
||||
h.backend.release(.start)
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .start(first), .destroy(first), .preflight, .start(second)])
|
||||
await waitUntil("the new session connects") { h.connector.socketPaths == ["/fake/\(second).sock"] }
|
||||
#expect(h.controller.liveIDs.snapshot == [second])
|
||||
}
|
||||
|
||||
@Test func aConnectThatReturnsAfterANewLinkIsClosed() async {
|
||||
let h = Harness()
|
||||
h.connector.hold()
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("connect runs") { h.connector.parkedCount == 1 }
|
||||
h.backend.hold(.start)
|
||||
h.controller.open(Harness.otherURL)
|
||||
await waitUntil("the new start runs") { h.backend.parkedCount(.start) == 1 }
|
||||
|
||||
h.connector.release()
|
||||
await waitUntil("the late stream is closed") { h.connector.connections.first?.isClosed == true }
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
#expect(h.controller.connection == nil)
|
||||
|
||||
h.backend.release(.start)
|
||||
await waitUntil("the new session connects") { h.state == .starting(.loading, folded: false) }
|
||||
#expect((h.controller.connection as? FakeStreamConnection)?.socketPath == "/fake/\(second).sock")
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .start(first), .destroy(first), .preflight, .start(second)])
|
||||
}
|
||||
|
||||
@Test func aSupersededStartThatFailsLeavesTheNewSessionAlone() async {
|
||||
let h = Harness()
|
||||
h.backend.hold(.start)
|
||||
h.backend.hold(.destroy)
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("start runs") { h.backend.parkedCount(.start) == 1 }
|
||||
h.controller.open(Harness.otherURL)
|
||||
h.backend.failStarts(with: VMBackendError.failed("boom"))
|
||||
h.backend.release(.start)
|
||||
await waitUntil("the old VM's destroy runs") { h.backend.parkedCount(.destroy) == 1 }
|
||||
#expect(h.state == .starting(.booting, folded: false)) // Not the old start's error card.
|
||||
h.backend.failStarts(with: nil)
|
||||
h.backend.release(.destroy)
|
||||
await waitUntil("the new session connects") { h.state == .starting(.loading, folded: false) }
|
||||
}
|
||||
|
||||
@Test func anOldSessionsStuckDestroyDoesNotDriveTheCurrentPill() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.backend.hold(.destroy)
|
||||
h.backend.failDestroys(with: VMBackendError.destroyStuck(id: first))
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroy runs") { h.backend.parkedCount(.destroy) == 1 }
|
||||
h.controller.open(Harness.otherURL)
|
||||
h.controller.destroy() // The new session never started a VM, so this one succeeds.
|
||||
h.backend.release(.destroy)
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.state == .destroyed)
|
||||
}
|
||||
|
||||
@Test func liveIDsCoverASupersededSessionUntilItsDestroyIsConfirmed() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.backend.hold(.destroy)
|
||||
h.controller.open(Harness.otherURL)
|
||||
await waitUntil("destroy runs") { h.backend.parkedCount(.destroy) == 1 }
|
||||
#expect(h.controller.liveIDs.snapshot == [first, second])
|
||||
h.backend.release(.destroy)
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.controller.liveIDs.snapshot == [second])
|
||||
}
|
||||
|
||||
@Test func fromDestroyingTheNewSessionStartsAfterTheDestroy() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.backend.hold(.destroy)
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroy runs") { h.backend.parkedCount(.destroy) == 1 }
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
|
||||
h.backend.release(.destroy)
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls.suffix(3) == [.destroy(first), .preflight, .start(second)])
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
}
|
||||
|
||||
@Test func fromTheCollapsedPillTheNewLinkOpensThePanel() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.controller.clickedOutside()
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
#expect(h.controller.countdown == nil)
|
||||
}
|
||||
|
||||
@Test func fromDestroyedTheCheckmarkIsCutShort() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
h.advance(by: .seconds(2))
|
||||
#expect(h.state != .idle)
|
||||
}
|
||||
|
||||
@Test func fromSessionEndedThePillIsCutShort() async {
|
||||
let h = Harness()
|
||||
let stream = await h.goLive()
|
||||
stream.emit(.ended(.closed))
|
||||
await waitUntil("ended") { h.state == .failed(.ended(.closed)) }
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
h.advance(by: .seconds(3))
|
||||
#expect(h.state != .idle)
|
||||
}
|
||||
|
||||
@Test func fromTheErrorCardTheNewLinkStarts() 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.backend.failStarts(with: nil)
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
await waitUntil("the new start runs") { h.backend.calls.contains(.start(second)) }
|
||||
}
|
||||
|
||||
@Test func fromAStuckDestroyTheStuckVMStaysOnPendingCleanup() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.backend.failDestroys(with: VMBackendError.destroyStuck(id: first))
|
||||
h.backend.failForceDestroys(with: VMBackendError.destroyStuck(id: first))
|
||||
h.controller.destroy()
|
||||
await waitUntil("stuck") { h.state == .failed(.destroyStuck) }
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.controller.liveIDs.snapshot == [first, second])
|
||||
}
|
||||
|
||||
@Test func fromSetupRequiredPreflightRunsAgain() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageMissing))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .preflight, .start(second)])
|
||||
}
|
||||
|
||||
@Test func backendCallsNeverOverlap() async {
|
||||
let h = Harness()
|
||||
h.backend.hold(.start)
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("start runs") { h.backend.parkedCount(.start) == 1 }
|
||||
h.controller.open(Harness.otherURL)
|
||||
h.controller.open(Harness.url)
|
||||
h.controller.destroy()
|
||||
h.controller.open(Harness.otherURL)
|
||||
h.backend.release(.start)
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.maxConcurrentCalls == 1)
|
||||
#expect(h.controller.liveIDs.snapshot == ["knotchy-00000004"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
@MainActor
|
||||
@Suite(.timeLimit(.minutes(1)))
|
||||
struct SessionPanelTests {
|
||||
@Test func aFreshPanelStaysOpenUntilEnteredOrFor30s() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.advance(by: .milliseconds(29_999))
|
||||
#expect(h.state == .live(.peek))
|
||||
h.advance(by: .milliseconds(1))
|
||||
#expect(h.state == .live(.collapsed))
|
||||
}
|
||||
|
||||
@Test func theFirstClickOutsideFoldsAnUnengagedPanel() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.controller.clickedOutside()
|
||||
#expect(h.state == .live(.collapsed))
|
||||
}
|
||||
|
||||
@Test func anEnteredPeekFolds400msAfterThePointerLeaves() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.controller.pointerEntered()
|
||||
h.advance(by: .seconds(60))
|
||||
#expect(h.state == .live(.peek)) // Entered: the 30 s fold no longer applies.
|
||||
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: .milliseconds(399))
|
||||
#expect(h.state == .live(.peek))
|
||||
h.advance(by: .milliseconds(1))
|
||||
#expect(h.state == .live(.collapsed))
|
||||
}
|
||||
|
||||
@Test func comingBackWithinTheGraceKeepsItOpen() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.controller.pointerEntered()
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: .milliseconds(200))
|
||||
h.controller.pointerEntered()
|
||||
h.advance(by: .seconds(1))
|
||||
#expect(h.state == .live(.peek))
|
||||
}
|
||||
|
||||
@Test func afterAClickInsideOnlyAClickOutsideFolds() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.controller.pointerEntered()
|
||||
h.controller.panelClicked()
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: .seconds(60))
|
||||
#expect(h.state == .live(.peek))
|
||||
h.controller.clickedOutside()
|
||||
#expect(h.state == .live(.collapsed))
|
||||
}
|
||||
|
||||
@Test func expandedFoldsOnlyOnAClickOutside() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.controller.pointerEntered()
|
||||
h.controller.expand()
|
||||
#expect(h.state == .live(.expanded))
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: .seconds(60))
|
||||
#expect(h.state == .live(.expanded))
|
||||
|
||||
h.controller.shrink()
|
||||
#expect(h.state == .live(.peek))
|
||||
h.controller.pointerEntered()
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: .seconds(1))
|
||||
#expect(h.state == .live(.peek)) // Still key after Expanded: pointer-away doesn't fold.
|
||||
|
||||
h.controller.expand()
|
||||
h.controller.clickedOutside()
|
||||
#expect(h.state == .live(.collapsed))
|
||||
}
|
||||
|
||||
@Test func expandAndShrinkOnlyApplyToTheirModes() async {
|
||||
let h = Harness()
|
||||
h.controller.expand()
|
||||
#expect(h.state == .idle)
|
||||
await h.goLive()
|
||||
h.controller.shrink()
|
||||
#expect(h.state == .live(.peek))
|
||||
h.controller.clickedOutside()
|
||||
h.controller.expand()
|
||||
#expect(h.state == .live(.collapsed))
|
||||
}
|
||||
|
||||
@Test func hoveringThePillReopensPeek() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.controller.clickedOutside()
|
||||
h.controller.pointerEntered()
|
||||
#expect(h.state == .live(.peek))
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: .milliseconds(400))
|
||||
#expect(h.state == .live(.collapsed))
|
||||
}
|
||||
|
||||
@Test func theIdleCountdownRunsOnlyWhileCollapsedAndResetsOnReopen() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
#expect(h.controller.countdown == nil)
|
||||
h.controller.clickedOutside()
|
||||
#expect(h.controller.countdown == .seconds(300))
|
||||
|
||||
h.advance(by: .seconds(240))
|
||||
#expect(h.controller.countdown == .seconds(60))
|
||||
h.controller.pointerEntered()
|
||||
#expect(h.controller.countdown == nil)
|
||||
h.advance(by: .seconds(120))
|
||||
#expect(h.state == .live(.peek))
|
||||
|
||||
h.controller.clickedOutside()
|
||||
#expect(h.controller.countdown == .seconds(300))
|
||||
h.advance(by: .seconds(300) - .milliseconds(1))
|
||||
#expect(h.state == .live(.collapsed))
|
||||
h.advance(by: .milliseconds(1))
|
||||
#expect(h.state == .destroying)
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
}
|
||||
|
||||
@Test func decodingPausesWhileCollapsed() async {
|
||||
let h = Harness()
|
||||
let stream = await h.goLive()
|
||||
h.controller.clickedOutside()
|
||||
#expect(stream.decodeChanges == [false])
|
||||
h.controller.pointerEntered()
|
||||
#expect(stream.decodeChanges == [false, true])
|
||||
}
|
||||
|
||||
@Test func foldingWhileStartingShowsThePillAndStaysFoldedWhenReady() async {
|
||||
let h = Harness()
|
||||
h.connector.hold()
|
||||
h.controller.open(Harness.url)
|
||||
h.controller.clickedOutside()
|
||||
#expect(h.state == .starting(.booting, folded: true))
|
||||
|
||||
await waitUntil("connecting") { h.state == .starting(.connecting, folded: true) }
|
||||
h.connector.release()
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: true) }
|
||||
|
||||
let stream = h.connector.connections[0]
|
||||
stream.emit(.contentReady)
|
||||
await waitUntil("collapsed") { h.state == .live(.collapsed) }
|
||||
#expect(h.controller.countdown == .seconds(300))
|
||||
#expect(stream.decodeChanges == [false])
|
||||
}
|
||||
|
||||
@Test func hoveringTheStartingPillUnfoldsIt() async {
|
||||
let h = Harness()
|
||||
h.backend.hold(.start)
|
||||
h.controller.open(Harness.url)
|
||||
h.controller.clickedOutside()
|
||||
h.controller.pointerEntered()
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: .milliseconds(400))
|
||||
#expect(h.state == .starting(.booting, folded: true))
|
||||
h.backend.release(.start)
|
||||
}
|
||||
|
||||
@Test func aGraceFoldStartedWhileStartingCarriesIntoPeek() async {
|
||||
let h = Harness()
|
||||
let stream = await h.openAndConnect()
|
||||
h.controller.pointerEntered()
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: .milliseconds(200))
|
||||
stream.emit(.contentReady)
|
||||
await waitUntil("live") { h.state == .live(.peek) }
|
||||
h.advance(by: .milliseconds(200))
|
||||
#expect(h.state == .live(.collapsed))
|
||||
}
|
||||
|
||||
@Test func retryKeepsThePanelEnteredWhenThePointerIsOnIt() 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.backend.failStarts(with: nil)
|
||||
h.controller.pointerEntered()
|
||||
h.controller.retry()
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
h.connector.connections[0].emit(.contentReady)
|
||||
await waitUntil("live") { h.state == .live(.peek) }
|
||||
|
||||
h.advance(by: .seconds(30))
|
||||
#expect(h.state == .live(.peek))
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: .milliseconds(400))
|
||||
#expect(h.state == .live(.collapsed))
|
||||
}
|
||||
|
||||
@Test func pointerInputDoesNothingWithoutAnOpenPanel() async {
|
||||
let h = Harness()
|
||||
h.controller.clickedOutside()
|
||||
h.controller.pointerEntered()
|
||||
h.controller.pointerExited()
|
||||
h.controller.panelClicked()
|
||||
#expect(h.state == .idle)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite(.timeLimit(.minutes(1)))
|
||||
struct SessionProgressTests {
|
||||
@Test func showsTheLatestProgressWhileStarting() async {
|
||||
let h = Harness()
|
||||
let stream = await h.openAndConnect()
|
||||
stream.emit(.progress(10))
|
||||
await waitUntil("10 %") { h.controller.progress == 10 }
|
||||
stream.emit(.progress(60))
|
||||
await waitUntil("60 %") { h.controller.progress == 60 }
|
||||
h.advance(by: .seconds(10))
|
||||
#expect(h.controller.progress == 60) // No hiding before content is ready.
|
||||
}
|
||||
|
||||
@Test func hidesAtProgress100() async {
|
||||
let h = Harness()
|
||||
let stream = await h.openAndConnect()
|
||||
stream.emit(.progress(30))
|
||||
await waitUntil("30 %") { h.controller.progress == 30 }
|
||||
stream.emit(.progress(100))
|
||||
await waitUntil("hidden") { h.controller.progress == nil }
|
||||
}
|
||||
|
||||
@Test func onceReadyItHides2sAfterTheLastProgress() async {
|
||||
let h = Harness()
|
||||
let stream = await h.openAndConnect()
|
||||
stream.emit(.progress(30))
|
||||
stream.emit(.contentReady)
|
||||
await waitUntil("live") { h.state == .live(.peek) }
|
||||
#expect(h.controller.progress == 30)
|
||||
h.advance(by: .milliseconds(1999))
|
||||
#expect(h.controller.progress == 30)
|
||||
h.advance(by: .milliseconds(1))
|
||||
#expect(h.controller.progress == nil)
|
||||
}
|
||||
|
||||
@Test func aLoadThatNeverReports100StillHidesWhenReady() async {
|
||||
let h = Harness()
|
||||
let stream = await h.openAndConnect()
|
||||
stream.emit(.progress(30))
|
||||
await waitUntil("30 %") { h.controller.progress == 30 }
|
||||
h.advance(by: .seconds(5))
|
||||
stream.emit(.contentReady)
|
||||
await waitUntil("live") { h.state == .live(.peek) }
|
||||
#expect(h.controller.progress == nil)
|
||||
}
|
||||
|
||||
@Test func endingASessionHidesTheBar() async {
|
||||
let h = Harness()
|
||||
let stream = await h.openAndConnect()
|
||||
stream.emit(.progress(40))
|
||||
await waitUntil("40 %") { h.controller.progress == 40 }
|
||||
stream.emit(.ended(.closed))
|
||||
await waitUntil("failed") { h.state == .failed(.start("The VM closed the connection.")) }
|
||||
#expect(h.controller.progress == nil)
|
||||
}
|
||||
|
||||
@Test func progressAfterReadyShowsAgainThenHides() async {
|
||||
let h = Harness()
|
||||
let stream = await h.goLive()
|
||||
stream.emit(.progress(20))
|
||||
await waitUntil("20 %") { h.controller.progress == 20 }
|
||||
h.advance(by: .seconds(2))
|
||||
#expect(h.controller.progress == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite(.timeLimit(.minutes(1)))
|
||||
struct SessionToolbarTests {
|
||||
@Test func navigateSendsTheURLAndUpdatesTheAddress() async throws {
|
||||
let h = Harness()
|
||||
let stream = await h.goLive()
|
||||
let url = try #require(URL(string: "https://example.net/page"))
|
||||
h.controller.navigate(url)
|
||||
#expect(stream.sent == [.navigate("https://example.net/page")])
|
||||
#expect(h.controller.openedURL == url)
|
||||
}
|
||||
|
||||
@Test func backForwardAndReloadRunScripts() async {
|
||||
let h = Harness()
|
||||
let stream = await h.goLive()
|
||||
h.controller.goBack()
|
||||
h.controller.goForward()
|
||||
h.controller.reload()
|
||||
#expect(stream.sent == [.script("history.back()"), .script("history.forward()"), .script("location.reload()")])
|
||||
}
|
||||
|
||||
@Test func toolbarActionsNeedALiveSession() async {
|
||||
let h = Harness()
|
||||
let stream = await h.openAndConnect()
|
||||
h.controller.reload()
|
||||
h.controller.navigate(Harness.otherURL)
|
||||
#expect(stream.sent.isEmpty)
|
||||
#expect(h.controller.openedURL == Harness.url)
|
||||
}
|
||||
|
||||
@Test func aNonWebLinkShowsANoticeFor2s() async throws {
|
||||
let h = Harness()
|
||||
h.controller.open(try #require(URL(string: "ftp://files.example.com")))
|
||||
#expect(h.controller.notice == .noWebLink)
|
||||
#expect(h.state == .idle)
|
||||
h.advance(by: .milliseconds(1999))
|
||||
#expect(h.controller.notice == .noWebLink)
|
||||
h.advance(by: .milliseconds(1))
|
||||
#expect(h.controller.notice == nil)
|
||||
}
|
||||
|
||||
@Test func navigateRejectsNonWebLinks() async throws {
|
||||
let h = Harness()
|
||||
let stream = await h.goLive()
|
||||
h.controller.navigate(try #require(URL(string: "file:///etc/passwd")))
|
||||
#expect(stream.sent.isEmpty)
|
||||
#expect(h.controller.notice == .noWebLink)
|
||||
#expect(h.controller.openedURL == Harness.url)
|
||||
}
|
||||
|
||||
@Test func textWithoutALinkShowsANotice() {
|
||||
let h = Harness()
|
||||
h.controller.open(text: "call me maybe")
|
||||
#expect(h.controller.notice == .noWebLink)
|
||||
#expect(h.state == .idle)
|
||||
}
|
||||
|
||||
@Test func textWithALinkOpensIt() {
|
||||
let h = Harness()
|
||||
h.controller.open(text: "Your parcel: https://example.com/ (tracking)")
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
#expect(h.controller.openedURL == Harness.url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
@MainActor
|
||||
@Suite(.timeLimit(.minutes(1)))
|
||||
struct SessionRuntimeInstallTests {
|
||||
@Test func aFreshInstallHoldsLinksThenStartsTheServiceAndBuilds() async {
|
||||
let h = Harness()
|
||||
let hold = await h.controller.beginRuntimeInstall(replacing: nil)
|
||||
#expect((try? hold.get()) != nil)
|
||||
#expect(h.state == .settingUp(.cliMissing))
|
||||
#expect(h.controller.setupFolded)
|
||||
h.controller.open(Harness.url)
|
||||
#expect(h.state == .settingUp(.cliMissing))
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls == [.sweep(keeping: [])]) // The link waits: no preflight, no start.
|
||||
|
||||
h.backend.queuePreflight(.issue(.imageMissing))
|
||||
#expect(await h.controller.finishRuntimeInstall(.installed) == .ready)
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
#expect(Array(h.backend.calls.prefix(5))
|
||||
== [.sweep(keeping: []), .fix(.serviceStopped), .preflight, .fix(.imageMissing), .preflight])
|
||||
}
|
||||
|
||||
@Test func anUpdateStopsTheServiceFirst() async {
|
||||
let h = Harness()
|
||||
let hold = await h.controller.beginRuntimeInstall(replacing: "1.3.0")
|
||||
#expect((try? hold.get()) != nil)
|
||||
#expect(h.state == .settingUp(.cliOutdated(found: "1.3.0")))
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .stopService])
|
||||
}
|
||||
|
||||
@Test func aServiceThatWontStopEndsTheHold() async {
|
||||
let h = Harness()
|
||||
h.backend.failStopService(with: VMBackendError.failed("still running"))
|
||||
guard case .failure(let refusal) = await h.controller.beginRuntimeInstall(replacing: "1.3.0") else {
|
||||
Issue.record("The hold should have been refused")
|
||||
return
|
||||
}
|
||||
#expect(refusal == .stopFailed("still running"))
|
||||
#expect(h.state == .idle)
|
||||
}
|
||||
|
||||
@Test func theHoldWaitsForTheBrowserToClose() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
guard case .failure(let refusal) = await h.controller.beginRuntimeInstall(replacing: "1.3.0") else {
|
||||
Issue.record("The hold should have been refused")
|
||||
return
|
||||
}
|
||||
#expect(refusal == .sessionOpen)
|
||||
#expect(h.state == .live(.peek))
|
||||
#expect(!h.backend.calls.contains(.stopService))
|
||||
}
|
||||
|
||||
@Test func theHoldIsAcceptedAfterAFailedStart() async {
|
||||
let h = Harness()
|
||||
h.backend.failStarts(with: VMBackendError.failed("boot failed"))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("the start fails") { if case .failed(.start) = h.state { true } else { false } }
|
||||
let hold = await h.controller.beginRuntimeInstall(replacing: nil)
|
||||
#expect((try? hold.get()) != nil)
|
||||
#expect(h.state == .settingUp(.cliMissing))
|
||||
}
|
||||
|
||||
@Test func aCancelledInstallGoesBackToIdle() async {
|
||||
let h = Harness()
|
||||
_ = await h.controller.beginRuntimeInstall(replacing: nil)
|
||||
#expect(await h.controller.finishRuntimeInstall(.cancelled) == .needs(.cliMissing))
|
||||
#expect(h.state == .idle)
|
||||
}
|
||||
|
||||
@Test func aFailedInstallShowsItsCardToAWaitingLink() async {
|
||||
let h = Harness()
|
||||
_ = await h.controller.beginRuntimeInstall(replacing: nil)
|
||||
h.controller.open(Harness.url)
|
||||
#expect(await h.controller.finishRuntimeInstall(.failed("Couldn't install the runtime."))
|
||||
== .failed(.cliMissing, "Couldn't install the runtime."))
|
||||
#expect(h.state == .setupRequired(.cliMissing, error: "Couldn't install the runtime."))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
@MainActor
|
||||
@Suite(.timeLimit(.minutes(1)))
|
||||
struct SessionSetupRunTests {
|
||||
@Test func aWindowRunFixesEachIssueInTurnAndEndsIdle() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageMissing))
|
||||
#expect(await h.controller.runSetup(.serviceStopped, continuing: true) == .ready)
|
||||
#expect(h.state == .idle)
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .fix(.serviceStopped), .preflight, .fix(.imageMissing), .preflight])
|
||||
}
|
||||
|
||||
@Test func aWindowRunStartsFolded() async {
|
||||
let h = Harness()
|
||||
h.backend.hold(.fix)
|
||||
let run = Task { await h.controller.runSetup(.imageMissing, continuing: false) }
|
||||
await waitUntil("building") { h.backend.parkedCount(.fix) == 1 }
|
||||
#expect(h.state == .settingUp(.imageMissing))
|
||||
#expect(h.controller.setupFolded)
|
||||
h.backend.release(.fix)
|
||||
#expect(await run.value == .ready)
|
||||
}
|
||||
|
||||
@Test func aWindowRunNeverFixesTheSameIssueTwice() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.serviceStopped))
|
||||
#expect(await h.controller.runSetup(.serviceStopped, continuing: true)
|
||||
== .failed(.serviceStopped, SessionController.stillBrokenMessage))
|
||||
#expect(h.state == .idle)
|
||||
#expect(h.backend.calls.filter { $0 == .fix(.serviceStopped) }.count == 1)
|
||||
}
|
||||
|
||||
@Test func anotherImageIssueAfterABuildIsTheSameFix() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageOutdated(found: 2, need: 3)))
|
||||
#expect(await h.controller.runSetup(.imageMissing, continuing: true)
|
||||
== .failed(.imageOutdated(found: 2, need: 3), SessionController.stillBrokenMessage))
|
||||
#expect(h.backend.calls.filter { if case .fix = $0 { true } else { false } }.count == 1)
|
||||
}
|
||||
|
||||
@Test func aFailedWindowRunLeavesNoCardWithoutALink() async {
|
||||
let h = Harness()
|
||||
h.backend.scriptFix(output: [], error: VMBackendError.failed("container build failed"))
|
||||
#expect(await h.controller.runSetup(.imageMissing, continuing: true) == .failed(.imageMissing, "container build failed"))
|
||||
#expect(h.state == .idle)
|
||||
}
|
||||
|
||||
@Test func aLinkDuringAWindowRunWaitsForTheWholeChain() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageMissing))
|
||||
h.backend.hold(.fix)
|
||||
let run = Task { await h.controller.runSetup(.serviceStopped, continuing: true) }
|
||||
await waitUntil("starting the service") { h.backend.parkedCount(.fix) == 1 }
|
||||
h.controller.open(Harness.url)
|
||||
#expect(h.state == .settingUp(.serviceStopped))
|
||||
#expect(!h.controller.setupFolded) // A waiting link shows its card.
|
||||
h.backend.release(.fix)
|
||||
#expect(await run.value == .ready)
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
#expect(Array(h.backend.calls.prefix(5))
|
||||
== [.sweep(keeping: []), .fix(.serviceStopped), .preflight, .fix(.imageMissing), .preflight])
|
||||
}
|
||||
|
||||
@Test func aBusyControllerRefusesTheWindow() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
#expect(await h.controller.runSetup(.imageMissing, continuing: true) == nil)
|
||||
#expect(await h.controller.checkSetup() == nil)
|
||||
}
|
||||
|
||||
@Test func checkingStartsAStoppedServiceButNeverBuilds() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.serviceStopped), .issue(.imageMissing))
|
||||
#expect(await h.controller.checkSetup() == .needs(.imageMissing))
|
||||
#expect(h.state == .idle)
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .fix(.serviceStopped), .preflight])
|
||||
}
|
||||
|
||||
@Test func checkingOpensALinkACardWasHolding() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageMissing))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
|
||||
#expect(await h.controller.checkSetup() == .ready)
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
#expect(h.controller.openedURL == Harness.url)
|
||||
}
|
||||
|
||||
@Test func checkingAReadyMacFixesNothing() async {
|
||||
let h = Harness()
|
||||
#expect(await h.controller.checkSetup() == .ready)
|
||||
#expect(!h.backend.calls.contains { if case .fix = $0 { true } else { false } })
|
||||
}
|
||||
|
||||
@Test func setupOutputKeepsTheLast200Lines() async {
|
||||
let h = Harness()
|
||||
h.backend.scriptFix(output: (1...250).map { "line \($0)" })
|
||||
_ = await h.controller.runSetup(.imageMissing, continuing: false)
|
||||
#expect(h.controller.setupOutput.count == 200)
|
||||
#expect(h.controller.setupOutput.first == "line 51")
|
||||
#expect(h.controller.setupOutput.last == "line 250")
|
||||
}
|
||||
|
||||
@Test func waitingForSetupReturnsOnlyWhenItEnds() async {
|
||||
let h = Harness()
|
||||
h.backend.hold(.fix)
|
||||
let run = Task { await h.controller.runSetup(.imageMissing, continuing: false) }
|
||||
await waitUntil("building") { h.backend.parkedCount(.fix) == 1 }
|
||||
let waiter = Task {
|
||||
await h.controller.waitWhileSettingUp()
|
||||
return h.state
|
||||
}
|
||||
await Task.yield()
|
||||
h.backend.release(.fix)
|
||||
#expect(await waiter.value == .idle)
|
||||
_ = await run.value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
@MainActor
|
||||
@Suite(.timeLimit(.minutes(1)))
|
||||
struct SessionSetupTests {
|
||||
@Test func aPreflightIssueNeedsSetupWithoutStartingAVM() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageMissing))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight])
|
||||
#expect(h.controller.liveIDs.snapshot.isEmpty)
|
||||
}
|
||||
|
||||
@Test func theSetupActionShowsItsOutputThenTheLinkOpens() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageMissing))
|
||||
h.backend.scriptFix(output: ["Step 1/9", "Step 9/9"])
|
||||
h.backend.hold(.fix)
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
|
||||
|
||||
h.controller.performSetupAction()
|
||||
#expect(h.state == .settingUp(.imageMissing))
|
||||
await waitUntil("the output shows") { h.controller.setupLog == "Step 9/9" }
|
||||
|
||||
h.backend.release(.fix)
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
#expect(h.controller.openedURL == Harness.url)
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .fix(.imageMissing), .preflight, .start("knotchy-00000002")])
|
||||
}
|
||||
|
||||
@Test func aSetupActionFoldsAwayAndItsLinkStartsFolded() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageMissing))
|
||||
h.backend.hold(.fix)
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
|
||||
h.controller.clickedOutside()
|
||||
#expect(!h.controller.setupFolded) // Only a running action folds.
|
||||
|
||||
h.controller.performSetupAction()
|
||||
h.controller.clickedOutside()
|
||||
#expect(h.controller.setupFolded)
|
||||
h.controller.pointerEntered()
|
||||
#expect(!h.controller.setupFolded)
|
||||
h.controller.foldSetup()
|
||||
#expect(h.controller.setupFolded)
|
||||
|
||||
h.backend.release(.fix)
|
||||
await waitUntil("loading, folded") { h.state == .starting(.loading, folded: true) }
|
||||
#expect(!h.controller.setupFolded)
|
||||
}
|
||||
|
||||
@Test func aFoldedSetupOpenedByHoveringFoldsWhenThePointerLeaves() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageMissing))
|
||||
h.backend.hold(.fix)
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
|
||||
h.controller.performSetupAction()
|
||||
h.controller.foldSetup()
|
||||
|
||||
h.controller.pointerEntered()
|
||||
#expect(!h.controller.setupFolded)
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: SessionController.foldGrace)
|
||||
#expect(h.controller.setupFolded)
|
||||
|
||||
h.controller.showPanel()
|
||||
h.controller.pointerEntered()
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: SessionController.foldGrace)
|
||||
#expect(!h.controller.setupFolded)
|
||||
}
|
||||
|
||||
@Test func aFailedSetupShowsTheErrorOnTheCard() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageMissing))
|
||||
h.backend.scriptFix(output: [], error: VMBackendError.failed("container build failed"))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
|
||||
h.controller.performSetupAction()
|
||||
await waitUntil("the error shows") {
|
||||
h.state == .setupRequired(.imageMissing, error: "container build failed")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func aSecondIssueAfterSetupShowsItsOwnCard() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageMissing), .issue(.imageOutdated(found: 2, need: 3)))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
|
||||
h.controller.performSetupAction()
|
||||
await waitUntil("the next issue") { h.state == .setupRequired(.imageOutdated(found: 2, need: 3), error: nil) }
|
||||
}
|
||||
|
||||
@Test func aMissingOrOldRuntimeOpensSetup() async {
|
||||
for issue in [SetupIssue.cliMissing, .cliOutdated(found: "1.3.0")] {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(issue))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(issue, error: nil) }
|
||||
h.controller.performSetupAction()
|
||||
#expect(h.recorder.setupRequests == 1)
|
||||
#expect(h.state == .setupRequired(issue, error: nil))
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(!h.backend.calls.contains(.fix(issue)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func lowDiskNeedsSetupAndHasNoAction() async {
|
||||
let h = Harness()
|
||||
h.disk.available = 1_000_000_000
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.lowDisk(bytesFree: 1_000_000_000), error: nil) }
|
||||
h.controller.performSetupAction()
|
||||
#expect(h.state == .setupRequired(.lowDisk(bytesFree: 1_000_000_000), error: nil))
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight])
|
||||
}
|
||||
|
||||
@Test func dismissDropsThePendingLink() async {
|
||||
// Only visible when setup runs again afterwards: here, a requested rebuild.
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)),
|
||||
.issue(.imageMissing))
|
||||
await h.goLive()
|
||||
h.controller.requestImageUpdate()
|
||||
h.controller.open(Harness.otherURL)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
|
||||
|
||||
h.controller.dismiss()
|
||||
#expect(h.state == .settingUp(.imageStale(builtDaysAgo: 20)))
|
||||
await waitUntil("idle after the rebuild") { h.state == .idle }
|
||||
#expect(!h.backend.calls.contains(.start("knotchy-00000003")))
|
||||
}
|
||||
|
||||
@Test func aNewLinkDuringAnAutomaticStartOpensAfterIt() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.serviceStopped))
|
||||
h.backend.hold(.fix)
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("starting the service") { h.backend.parkedCount(.fix) == 1 }
|
||||
#expect(h.state == .settingUp(.serviceStopped))
|
||||
#expect(!h.controller.setupFolded)
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.state == .settingUp(.serviceStopped))
|
||||
h.backend.release(.fix)
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
#expect(h.controller.openedURL == Harness.otherURL)
|
||||
}
|
||||
|
||||
@Test func aStaleImageWarnsButNeverBlocks() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)))
|
||||
await h.goLive()
|
||||
#expect(h.controller.staleImage == StaleImage(builtDaysAgo: 20))
|
||||
}
|
||||
|
||||
@Test func updateNowRebuildsOnceTheSessionEnds() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)))
|
||||
await h.goLive()
|
||||
h.controller.requestImageUpdate()
|
||||
#expect(h.controller.staleImage == nil)
|
||||
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
h.advance(by: .milliseconds(1500))
|
||||
#expect(h.state == .settingUp(.imageStale(builtDaysAgo: 20)))
|
||||
await waitUntil("idle after the rebuild") { h.state == .idle }
|
||||
#expect(h.backend.calls.suffix(2) == [.fix(.imageStale(builtDaysAgo: 20)), .preflight])
|
||||
}
|
||||
|
||||
@Test func aRebuildThatEndsInAnotherIssueShowsItsCard() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)),
|
||||
.issue(.serviceStopped))
|
||||
await h.goLive()
|
||||
h.controller.requestImageUpdate()
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
h.advance(by: .milliseconds(1500))
|
||||
await waitUntil("the next issue") { h.state == .setupRequired(.serviceStopped, error: nil) }
|
||||
}
|
||||
|
||||
@Test func aFailedRebuildStillOpensAPendingLink() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)))
|
||||
h.backend.scriptFix(output: [], error: VMBackendError.failed("container build failed"))
|
||||
h.backend.hold(.fix)
|
||||
await h.goLive()
|
||||
h.controller.requestImageUpdate()
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
h.advance(by: .milliseconds(1500))
|
||||
await waitUntil("rebuilding") { h.backend.parkedCount(.fix) == 1 }
|
||||
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.state == .settingUp(.imageStale(builtDaysAgo: 20)))
|
||||
h.backend.release(.fix)
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
#expect(h.controller.openedURL == Harness.otherURL)
|
||||
}
|
||||
|
||||
@Test func aLinkOpenedAfterARebuildStartsUnengaged() async {
|
||||
// The pointer was on the panel before it hid; nothing says it is there now.
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)))
|
||||
h.backend.hold(.fix)
|
||||
await h.goLive()
|
||||
h.controller.pointerEntered()
|
||||
h.controller.requestImageUpdate()
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
h.advance(by: .milliseconds(1500))
|
||||
await waitUntil("rebuilding") { h.backend.parkedCount(.fix) == 1 }
|
||||
h.controller.open(Harness.otherURL)
|
||||
h.backend.release(.fix)
|
||||
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
h.connector.connections.last!.emit(.contentReady)
|
||||
await waitUntil("live") { h.state == .live(.peek) }
|
||||
h.advance(by: .seconds(30))
|
||||
#expect(h.state == .live(.collapsed))
|
||||
}
|
||||
|
||||
@Test func aFreshImageBuildSatisfiesARequestedRebuild() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)),
|
||||
.issue(.imageMissing))
|
||||
await h.goLive()
|
||||
h.controller.requestImageUpdate()
|
||||
h.controller.open(Harness.otherURL)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
|
||||
h.controller.performSetupAction()
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
h.advance(by: .milliseconds(1500))
|
||||
#expect(h.state == .idle)
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls.filter { if case .fix = $0 { true } else { false } } == [.fix(.imageMissing)])
|
||||
}
|
||||
|
||||
@Test func laterHidesTheWarningForThisSession() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: nil)))
|
||||
await h.goLive()
|
||||
h.controller.dismissStaleImageWarning()
|
||||
#expect(h.controller.staleImage == nil)
|
||||
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
h.advance(by: .milliseconds(1500))
|
||||
#expect(h.state == .idle)
|
||||
}
|
||||
|
||||
@Test func aStoppedServiceStartsByItselfAndTheLinkOpens() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.serviceStopped))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .fix(.serviceStopped), .preflight,
|
||||
.start("knotchy-00000002")])
|
||||
}
|
||||
|
||||
@Test func aServiceThatWontStayUpShowsItsCardOnce() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.serviceStopped), .issue(.serviceStopped))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") {
|
||||
h.state == .setupRequired(.serviceStopped, error: SessionController.stillBrokenMessage)
|
||||
}
|
||||
#expect(h.backend.calls.filter { $0 == .fix(.serviceStopped) }.count == 1)
|
||||
|
||||
h.backend.queuePreflight(.issue(.serviceStopped))
|
||||
h.controller.open(Harness.url) // A new request gets its own automatic start.
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
#expect(h.backend.calls.filter { $0 == .fix(.serviceStopped) }.count == 2)
|
||||
}
|
||||
|
||||
@Test func retryStartsTheServiceAgain() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.serviceStopped))
|
||||
h.backend.failStarts(with: VMBackendError.failed("boot failed"))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("the start fails") { if case .failed(.start) = h.state { true } else { false } }
|
||||
|
||||
h.backend.failStarts(with: nil)
|
||||
h.backend.queuePreflight(.issue(.serviceStopped))
|
||||
h.controller.retry()
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
#expect(h.backend.calls.filter { $0 == .fix(.serviceStopped) }.count == 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
@MainActor
|
||||
@Suite(.timeLimit(.minutes(1)))
|
||||
struct SetupFlowTests {
|
||||
let host = FakeHost()
|
||||
let installer = FakeInstaller()
|
||||
let driver = FakeSetupDriver()
|
||||
|
||||
func makeFlow() -> SetupFlow {
|
||||
SetupFlow(host: host, installer: installer, driver: driver, retryDelay: .zero)
|
||||
}
|
||||
|
||||
@Test func aReadyMacShowsEveryRowDone() async {
|
||||
let flow = makeFlow()
|
||||
await flow.refresh()
|
||||
#expect(flow.mac == .done("macOS 27.0 · 212 GB free"))
|
||||
#expect(flow.runtime == .done("container 1.4.1 installed"))
|
||||
#expect(flow.vm == .done("Ready"))
|
||||
#expect(flow.allRequiredDone)
|
||||
#expect(!flow.canSetUp)
|
||||
}
|
||||
|
||||
@Test func aMissingRuntimeOffersAnInstallWithoutCheckingTheVM() async {
|
||||
driver.runtimeStatuses = [.missing]
|
||||
let flow = makeFlow()
|
||||
await flow.refresh()
|
||||
#expect(flow.runtimeAction == .install)
|
||||
#expect(flow.runtime == .pending("Apple's container 1.4.1 · 118 MB · installs with Apple's Installer"))
|
||||
#expect(flow.vm == .pending("About 3 minutes · downloads about 225 MB"))
|
||||
#expect(flow.canSetUp)
|
||||
#expect(driver.calls == ["runtimeStatus"])
|
||||
}
|
||||
|
||||
@Test func anOldRuntimeOffersAnUpdate() async {
|
||||
driver.runtimeStatuses = [.outdated("1.3.0")]
|
||||
let flow = makeFlow()
|
||||
await flow.refresh()
|
||||
#expect(flow.runtimeAction == .update)
|
||||
#expect(flow.runtime == .pending("container 1.3.0 is too old · updates to 1.4.1 with Apple's Installer"))
|
||||
}
|
||||
|
||||
@Test func aMacThatCantRunVirtualMachinesShowsOnlyThat() async {
|
||||
host.current.unsupported = .noVirtualization
|
||||
let flow = makeFlow()
|
||||
await flow.refresh()
|
||||
#expect(flow.unsupported == .noVirtualization)
|
||||
#expect(driver.calls.isEmpty)
|
||||
#expect(!flow.canSetUp)
|
||||
}
|
||||
|
||||
@Test func lowDiskBlocksOnlyWhileSomethingNeedsSpace() async {
|
||||
host.current.bytesFree = 2_500_000_000
|
||||
driver.runtimeStatuses = [.missing]
|
||||
let flow = makeFlow()
|
||||
await flow.refresh()
|
||||
#expect(flow.mac == .failed("Setting up needs about 3 GB free. 2.5 GB is free.", details: []))
|
||||
#expect(!flow.canSetUp)
|
||||
|
||||
driver.runtimeStatuses = [.current("1.4.1")]
|
||||
await flow.refresh()
|
||||
#expect(flow.mac == .done("macOS 27.0 · 2.5 GB free"))
|
||||
#expect(flow.allRequiredDone)
|
||||
}
|
||||
|
||||
@Test func installingWithInstallerFinishesTheChain() async throws {
|
||||
driver.runtimeStatuses = [.missing, .current("1.4.1")]
|
||||
installer.session.hasQuit = false
|
||||
let flow = makeFlow()
|
||||
await flow.setUp()
|
||||
#expect(driver.calls == ["runtimeStatus", "begin(nil)", "runtimeStatus", "finish(installed)"])
|
||||
#expect(flow.mac == .done("macOS 27.0 · 212 GB free"))
|
||||
#expect(flow.runtime == .done("container 1.4.1 installed"))
|
||||
#expect(flow.vm == .done("Ready"))
|
||||
#expect(flow.allRequiredDone)
|
||||
|
||||
// Installer still reads the package while its summary is open.
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
#expect(installer.calls == ["download", "open"])
|
||||
installer.session.hasQuit = true
|
||||
await waitUntil("the package is discarded") { installer.calls.contains("discard") }
|
||||
#expect(installer.calls == ["download", "open", "discard"])
|
||||
}
|
||||
|
||||
@Test func anUpdatePassesTheOldVersionOn() async {
|
||||
driver.runtimeStatuses = [.outdated("1.3.0"), .current("1.4.1")]
|
||||
let flow = makeFlow()
|
||||
await flow.setUp()
|
||||
#expect(driver.calls.contains("begin(1.3.0)"))
|
||||
#expect(flow.allRequiredDone)
|
||||
}
|
||||
|
||||
@Test func aMismatchedDownloadIsNeverInstalled() async {
|
||||
driver.runtimeStatuses = [.missing]
|
||||
installer.failDownload(with: .mismatch)
|
||||
let flow = makeFlow()
|
||||
await flow.setUp()
|
||||
#expect(flow.runtime == .failed("The download didn't match the expected package, so it wasn't installed.", details: []))
|
||||
#expect(installer.calls == ["download"])
|
||||
#expect(!driver.calls.contains { $0.hasPrefix("begin") })
|
||||
}
|
||||
|
||||
@Test func anOfflineDownloadSaysSo() async {
|
||||
driver.runtimeStatuses = [.missing]
|
||||
installer.failDownload(with: .failed("The Internet connection appears to be offline."))
|
||||
let flow = makeFlow()
|
||||
await flow.setUp()
|
||||
guard case .failed(let message, _) = flow.runtime else {
|
||||
Issue.record("Expected a failure")
|
||||
return
|
||||
}
|
||||
#expect(message == "Couldn't download the runtime. Check your connection and try again.")
|
||||
}
|
||||
|
||||
@Test func quittingInstallerWithoutInstallingIsCancelled() async {
|
||||
driver.runtimeStatuses = [.missing]
|
||||
let flow = makeFlow()
|
||||
await flow.setUp()
|
||||
#expect(flow.runtime == .pending("Installing was cancelled."))
|
||||
#expect(flow.runtimeAction == .install)
|
||||
#expect(flow.canSetUp)
|
||||
#expect(driver.calls == ["runtimeStatus", "begin(nil)", "runtimeStatus", "runtimeStatus", "finish(cancelled)"])
|
||||
await waitUntil("the package is discarded") { installer.calls.contains("discard") }
|
||||
#expect(installer.calls == ["download", "open", "discard"])
|
||||
}
|
||||
|
||||
@Test func aChangedDownloadIsNotOpened() async {
|
||||
driver.runtimeStatuses = [.missing]
|
||||
installer.open(with: .changed)
|
||||
let flow = makeFlow()
|
||||
await flow.setUp()
|
||||
#expect(flow.runtime == .failed("The download changed after it was checked, so it wasn't opened.", details: []))
|
||||
#expect(installer.calls == ["download", "open"])
|
||||
#expect(driver.calls.last?.hasPrefix("finish(failed") == true)
|
||||
}
|
||||
|
||||
@Test func installerThatWontOpenSaysSo() async {
|
||||
driver.runtimeStatuses = [.missing]
|
||||
installer.open(with: .failed("x"))
|
||||
let flow = makeFlow()
|
||||
await flow.setUp()
|
||||
#expect(flow.runtime == .failed("Couldn't open Installer.", details: ["x"]))
|
||||
#expect(installer.calls == ["download", "open", "discard"])
|
||||
#expect(flow.canSetUp)
|
||||
#expect(driver.calls.last?.hasPrefix("finish(failed") == true)
|
||||
}
|
||||
|
||||
@Test func anUpdateWaitsForTheBrowserToClose() async {
|
||||
driver.runtimeStatuses = [.outdated("1.3.0")]
|
||||
driver.holdResult = .failure(.sessionOpen)
|
||||
let flow = makeFlow()
|
||||
await flow.setUp()
|
||||
#expect(flow.runtime == .failed("Close the Knotchy browser first.", details: []))
|
||||
#expect(installer.calls == ["download", "discard"])
|
||||
}
|
||||
|
||||
@Test func quittingWhileInstallerIsOpenLeavesItToFinish() async {
|
||||
driver.runtimeStatuses = [.missing]
|
||||
installer.session.hasQuit = false
|
||||
let flow = makeFlow()
|
||||
flow.start()
|
||||
await waitUntil("Installer is open") { flow.runtime == .running("Finish the install in Installer…", fraction: nil) }
|
||||
flow.cancel()
|
||||
await waitUntil("the flow stops") { !flow.isRunning }
|
||||
#expect(!driver.calls.contains { $0.hasPrefix("finish(") })
|
||||
#expect(!installer.calls.contains("discard"))
|
||||
installer.session.hasQuit = true // Ends the cleanup task.
|
||||
}
|
||||
|
||||
@Test func aClosingBrowserIsWaitedFor() async {
|
||||
driver.runtimeStatuses = [.outdated("1.3.0")]
|
||||
for state in [SessionState.destroying, .failed(.destroyStuck)] {
|
||||
driver.state = state
|
||||
let flow = makeFlow()
|
||||
await flow.setUp()
|
||||
#expect(flow.runtime == .failed("Wait for the Knotchy browser to finish closing, then try again.", details: []))
|
||||
}
|
||||
#expect(installer.calls.isEmpty)
|
||||
}
|
||||
|
||||
@Test func aFailedServiceStartStopsSetUp() async {
|
||||
driver.checkResults = [.failed(.serviceStopped, "Couldn't start the service: x")]
|
||||
driver.setupOutput = ["x"]
|
||||
let flow = makeFlow()
|
||||
await flow.setUp()
|
||||
#expect(flow.runtime == .failed("Couldn't start the service: x", details: ["x"]))
|
||||
#expect(driver.calls.filter { $0 == "checkSetup" }.count == 1)
|
||||
#expect(!driver.calls.contains { $0.hasPrefix("runSetup") })
|
||||
}
|
||||
|
||||
@Test func aBuildFailureShowsItsOutput() async {
|
||||
driver.checkResults = [.needs(.imageMissing), .needs(.imageMissing)]
|
||||
driver.runResults = [.failed(.imageMissing, "The image build failed: out of space")]
|
||||
driver.setupOutput = ["Step 7/9", "error: out of space"]
|
||||
let flow = makeFlow()
|
||||
await flow.setUp()
|
||||
#expect(flow.vm == .failed("The image build failed: out of space", details: ["Step 7/9", "error: out of space"]))
|
||||
#expect(driver.calls.contains("runSetup(imageMissing, continuing: true)"))
|
||||
}
|
||||
|
||||
@Test func aSetupTheNotchStartedIsFollowed() async {
|
||||
driver.state = .settingUp(.imageMissing)
|
||||
driver.checkResults = [nil, nil, .ready]
|
||||
let flow = makeFlow()
|
||||
await flow.setUp()
|
||||
#expect(driver.calls.contains("wait"))
|
||||
#expect(flow.vm == .done("Ready"))
|
||||
}
|
||||
|
||||
@Test func theRowsShowTheControllersProgress() {
|
||||
let flow = makeFlow()
|
||||
driver.state = .settingUp(.imageMissing)
|
||||
driver.setupLog = "Step 3/9"
|
||||
#expect(flow.vm == .running("Step 3/9", fraction: nil))
|
||||
driver.state = .settingUp(.serviceStopped)
|
||||
#expect(flow.runtime == .running("Starting the runtime…", fraction: nil))
|
||||
}
|
||||
|
||||
@Test func anOpenBrowserCountsAsAReadyVM() async {
|
||||
driver.state = .live(.peek)
|
||||
driver.checkResults = [nil]
|
||||
let flow = makeFlow()
|
||||
await flow.refresh()
|
||||
#expect(flow.vm == .done("Ready"))
|
||||
#expect(flow.allRequiredDone)
|
||||
}
|
||||
|
||||
@Test func anOpenBrowserIsReportedBeforeDownloadingAnUpdate() async {
|
||||
driver.state = .live(.peek)
|
||||
driver.runtimeStatuses = [.outdated("1.3.0")]
|
||||
let flow = makeFlow()
|
||||
await flow.setUp()
|
||||
#expect(flow.runtime == .failed("Close the Knotchy browser first.", details: []))
|
||||
#expect(installer.calls.isEmpty)
|
||||
}
|
||||
|
||||
@Test func downloadProgressReadsInMegabytes() {
|
||||
#expect(SetupFlow.downloadText(58_886_932, of: ContainerRuntime.pinned.size) == "Downloading… 59 of 118 MB")
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
struct WireProtocolTests {
|
||||
private let size = PixelSize(width: 1024, height: 640)
|
||||
|
||||
private func decodeAll(_ bytes: Data, into decoder: inout AgentMessageDecoder) throws -> [AgentMessage] {
|
||||
decoder.append(bytes)
|
||||
var messages: [AgentMessage] = []
|
||||
while let message = try decoder.next() { messages.append(message) }
|
||||
return messages
|
||||
}
|
||||
|
||||
private func decodeAll(_ bytes: Data) throws -> [AgentMessage] {
|
||||
var decoder = AgentMessageDecoder()
|
||||
return try decodeAll(bytes, into: &decoder)
|
||||
}
|
||||
|
||||
private func violation(_ bytes: Data) -> AgentMessageDecoder.ProtocolViolation? {
|
||||
do {
|
||||
_ = try decodeAll(bytes)
|
||||
return nil
|
||||
} catch {
|
||||
return error as? AgentMessageDecoder.ProtocolViolation
|
||||
}
|
||||
}
|
||||
|
||||
private func raw(_ type: UInt8, _ payload: [UInt8]) -> Data {
|
||||
framed(type, Data(payload))
|
||||
}
|
||||
|
||||
@Test func matchesTheImageProtocolVersion() throws {
|
||||
let root = URL(filePath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
|
||||
let containerfile = try String(contentsOf: root.appending(path: "vm/Containerfile"), encoding: .utf8)
|
||||
let arg = containerfile.split(separator: "\n").first { $0.hasPrefix("ARG KNOTCHY_PROTOCOL=") }
|
||||
#expect(arg == "ARG KNOTCHY_PROTOCOL=\(WireProtocol.version)")
|
||||
}
|
||||
|
||||
@Test func pageURLMustBeShortUTF8() throws {
|
||||
let hello = raw(MessageType.hello, [1, 0x04, 0x00, 0x02, 0x80])
|
||||
#expect(try decodeAll(hello + raw(MessageType.pageURL, Array("https://a.test/ü".utf8)))
|
||||
== [.hello(version: 1, size: size), .pageURL("https://a.test/ü")])
|
||||
#expect(violation(hello + raw(MessageType.pageURL, [0x68, 0xC3])) != nil) // cut-off UTF-8
|
||||
#expect(violation(hello + raw(MessageType.pageURL, [])) != nil)
|
||||
let long = [UInt8](repeating: 0x61, count: WireProtocol.maxPageURL + 1)
|
||||
#expect(violation(hello + raw(MessageType.pageURL, long)) != nil)
|
||||
}
|
||||
|
||||
@Test func helloAndVideoBytesAreAsSpecified() throws {
|
||||
#expect(AgentMessage.hello(version: 1, size: size).encoded() == Data([0x01, 0, 0, 0, 5, 1, 0x04, 0x00, 0x02, 0x80]))
|
||||
let hello = raw(MessageType.hello, [1, 0x04, 0x00, 0x02, 0x80])
|
||||
let video = raw(MessageType.video, [0x04, 0x00, 0x02, 0x80, 0xAA])
|
||||
#expect(try decodeAll(hello + video) == [.hello(version: 1, size: size), .video(size: size, accessUnit: Data([0xAA]))])
|
||||
}
|
||||
|
||||
@Test func roundTripsEveryAgentMessage() throws {
|
||||
let messages: [AgentMessage] = [
|
||||
.hello(version: 1, size: size),
|
||||
.navigated,
|
||||
.progress(0),
|
||||
.progress(100),
|
||||
.video(size: size, accessUnit: Data([0, 0, 0, 1, 0x09, 0xF0])),
|
||||
.pageURL("https://example.com/a?b=c#d"),
|
||||
.unknown(type: 0x7F),
|
||||
]
|
||||
let stream = messages.reduce(into: Data()) { $0.append($1.encoded()) }
|
||||
#expect(try decodeAll(stream) == messages)
|
||||
}
|
||||
|
||||
@Test func decodesAcrossArbitraryChunkBoundaries() throws {
|
||||
let messages: [AgentMessage] = [
|
||||
.hello(version: 1, size: size),
|
||||
.video(size: size, accessUnit: Data(repeating: 0xAB, count: 3000)),
|
||||
.progress(42),
|
||||
]
|
||||
let stream = messages.reduce(into: Data()) { $0.append($1.encoded()) }
|
||||
var decoder = AgentMessageDecoder()
|
||||
var decoded: [AgentMessage] = []
|
||||
for chunkStart in stride(from: 0, to: stream.count, by: 7) {
|
||||
decoded += try decodeAll(stream.subdata(in: chunkStart..<min(chunkStart + 7, stream.count)), into: &decoder)
|
||||
}
|
||||
#expect(decoded == messages)
|
||||
#expect(decoder.bufferedByteCount == 0)
|
||||
}
|
||||
|
||||
@Test func readsTheHelloVersionAndAPreV1HelloIsVersionZero() throws {
|
||||
#expect(try decodeAll(raw(MessageType.hello, [2, 0x04, 0x00, 0x02, 0x80])) == [.hello(version: 2, size: size)])
|
||||
let legacy = raw(MessageType.hello, [0x04, 0x00, 0x02, 0x80])
|
||||
#expect(try decodeAll(legacy) == [.hello(version: 0, size: size)])
|
||||
#expect(AgentMessage.hello(version: 0, size: size).encoded() == legacy)
|
||||
}
|
||||
|
||||
@Test func waitsForTheRestOfATruncatedMessage() throws {
|
||||
var decoder = AgentMessageDecoder()
|
||||
let hello = AgentMessage.hello(version: 1, size: size).encoded()
|
||||
#expect(try decodeAll(hello.prefix(7), into: &decoder) == [])
|
||||
#expect(decoder.bufferedByteCount == 7)
|
||||
#expect(try decodeAll(hello.dropFirst(7), into: &decoder) == [.hello(version: 1, size: size)])
|
||||
}
|
||||
|
||||
@Test func rejectsWrongFixedSizes() {
|
||||
let hello = AgentMessage.hello(version: 1, size: size).encoded()
|
||||
#expect(violation(raw(MessageType.hello, [1, 2, 3])) != nil)
|
||||
#expect(violation(raw(MessageType.hello, [1, 0x04, 0x00, 0x02, 0x80, 0])) != nil)
|
||||
#expect(violation(hello + raw(MessageType.progress, [])) != nil)
|
||||
#expect(violation(hello + raw(MessageType.progress, [1, 2])) != nil)
|
||||
#expect(violation(hello + raw(MessageType.navigated, [0])) != nil)
|
||||
#expect(violation(hello + raw(MessageType.video, [0x04, 0x00, 0x02])) != nil)
|
||||
}
|
||||
|
||||
@Test func rejectsProgressOver100() {
|
||||
let hello = AgentMessage.hello(version: 1, size: size).encoded()
|
||||
#expect(violation(hello + raw(MessageType.progress, [101])) != nil)
|
||||
}
|
||||
|
||||
@Test func rejectsOversizedMessagesAsSoonAsTheHeaderArrives() {
|
||||
let hello = AgentMessage.hello(version: 1, size: size).encoded()
|
||||
var video = Data([MessageType.video])
|
||||
video.appendBE32(UInt32(WireProtocol.maxVideoPayload + 1))
|
||||
#expect(violation(hello + video) != nil)
|
||||
|
||||
var unknown = Data([0x7F])
|
||||
unknown.appendBE32(UInt32(WireProtocol.maxOtherPayload + 1))
|
||||
#expect(violation(hello + unknown) != nil)
|
||||
}
|
||||
|
||||
@Test func acceptsMaximumSizeMessages() throws {
|
||||
let hello = AgentMessage.hello(version: 1, size: size)
|
||||
let video = AgentMessage.video(size: size, accessUnit: Data(count: WireProtocol.maxVideoPayload - 4))
|
||||
let unknown = raw(0x7F, [UInt8](repeating: 0, count: WireProtocol.maxOtherPayload))
|
||||
#expect(try decodeAll(hello.encoded() + video.encoded() + unknown) == [hello, video, .unknown(type: 0x7F)])
|
||||
}
|
||||
|
||||
@Test func rejectsVideoWhoseSizeDiffersFromHello() {
|
||||
let hello = AgentMessage.hello(version: 1, size: size).encoded()
|
||||
for other in [PixelSize(width: 800, height: 600), PixelSize(width: 1024, height: 600), PixelSize(width: 800, height: 640)] {
|
||||
#expect(violation(hello + AgentMessage.video(size: other, accessUnit: Data([1])).encoded()) != nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func aViolationIsFinal() throws {
|
||||
var decoder = AgentMessageDecoder()
|
||||
decoder.append(AgentMessage.hello(version: 1, size: size).encoded()
|
||||
+ raw(MessageType.progress, [101]) + AgentMessage.progress(50).encoded())
|
||||
#expect(try decoder.next() == .hello(version: 1, size: size))
|
||||
#expect(throws: AgentMessageDecoder.ProtocolViolation.self) { try decoder.next() }
|
||||
#expect(throws: AgentMessageDecoder.ProtocolViolation.self) { try decoder.next() }
|
||||
decoder.append(Data(count: 1000))
|
||||
#expect(decoder.storedByteCount == 0)
|
||||
}
|
||||
|
||||
@Test func staysBoundedWhenReadsNeverEndOnAMessageBoundary() throws {
|
||||
let readSize = 4099 // Not a multiple of the 5-byte NAVIGATED, so reads end mid-message.
|
||||
var decoder = AgentMessageDecoder()
|
||||
var stream = AgentMessage.hello(version: 1, size: size).encoded()
|
||||
for _ in 0..<100_000 { stream.append(AgentMessage.navigated.encoded()) }
|
||||
var decoded = 0
|
||||
var largest = 0
|
||||
for chunkStart in stride(from: 0, to: stream.count, by: readSize) {
|
||||
decoded += try decodeAll(stream.subdata(in: chunkStart..<min(chunkStart + readSize, stream.count)), into: &decoder).count
|
||||
largest = max(largest, decoder.storedByteCount)
|
||||
}
|
||||
#expect(decoded == 100_001)
|
||||
#expect(largest <= readSize + WireProtocol.headerSize)
|
||||
}
|
||||
|
||||
@Test func rejectsAnythingBeforeHelloAndASecondHello() {
|
||||
let hello = AgentMessage.hello(version: 1, size: size).encoded()
|
||||
#expect(violation(AgentMessage.navigated.encoded()) != nil)
|
||||
#expect(violation(AgentMessage.progress(10).encoded()) != nil)
|
||||
#expect(violation(raw(MessageType.video, [0x04, 0x00, 0x02, 0x80, 0xAA])) != nil)
|
||||
#expect(violation(AgentMessage.unknown(type: 0x7F).encoded()) != nil)
|
||||
#expect(violation(hello + hello) != nil)
|
||||
}
|
||||
}
|
||||
|
||||
struct InputEventTests {
|
||||
@Test func encodesPointerMessages() {
|
||||
#expect(InputEvent.pointerMove(x: 0x0102, y: 0x0304, mods: [.shift, .leftButton]).encoded()
|
||||
== Data([0x10, 0, 0, 0, 5, 0x01, 0x02, 0x03, 0x04, 0x11]))
|
||||
#expect(InputEvent.pointerButton(.right, pressed: true, x: 10, y: 20, mods: .control).encoded()
|
||||
== Data([0x11, 0, 0, 0, 7, 2, 1, 0, 10, 0, 20, 0x02]))
|
||||
#expect(InputEvent.pointerButton(.left, pressed: false, x: 0, y: 0, mods: []).encoded()
|
||||
== Data([0x11, 0, 0, 0, 7, 1, 0, 0, 0, 0, 0, 0]))
|
||||
}
|
||||
|
||||
@Test func encodesScrollTicksAsSignedBigEndian() {
|
||||
#expect(InputEvent.scroll(ticksX: 0, ticksY: -3, x: 1, y: 2).encoded()
|
||||
== Data([0x12, 0, 0, 0, 8, 0, 0, 0xFF, 0xFD, 0, 1, 0, 2]))
|
||||
}
|
||||
|
||||
@Test func encodesKeysAndStrings() {
|
||||
#expect(InputEvent.key(pressed: true, mods: .meta, name: "Return").encoded()
|
||||
== Data([0x13, 0, 0, 0, 8, 1, 0x08]) + Data("Return".utf8))
|
||||
#expect(InputEvent.navigate("https://a.example").encoded()
|
||||
== Data([0x14, 0, 0, 0, 17]) + Data("https://a.example".utf8))
|
||||
#expect(InputEvent.script("history.back()").encoded()
|
||||
== Data([0x15, 0, 0, 0, 14]) + Data("history.back()".utf8))
|
||||
#expect(InputEvent.keyframe.encoded() == Data([0x16, 0, 0, 0, 0]))
|
||||
}
|
||||
|
||||
@Test func modifierBitsMatchTheAgent() {
|
||||
let bits: [Modifiers] = [.shift, .control, .alt, .meta, .leftButton, .rightButton, .middleButton]
|
||||
#expect(bits.map(\.rawValue) == [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40])
|
||||
}
|
||||
|
||||
@Test func refusesMessagesTheAgentCouldNotUse() {
|
||||
#expect(InputEvent.script(String(repeating: "a", count: WireProtocol.maxOtherPayload)).encoded() != nil)
|
||||
#expect(InputEvent.script(String(repeating: "a", count: WireProtocol.maxOtherPayload + 1)).encoded() == nil)
|
||||
#expect(InputEvent.key(pressed: true, mods: [], name: "").encoded() == nil)
|
||||
#expect(InputEvent.key(pressed: true, mods: [], name: "\u{0}x").encoded() == nil)
|
||||
#expect(InputEvent.navigate("https://a.example/\u{0}").encoded() == nil)
|
||||
#expect(InputEvent.script("history.back()\u{0}").encoded() == nil)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user