A gear on the feed opens a status overview that leads with what is wrong: offline hosts, hosts on a fallback route, tokens expiring within 7 days, and push problems, with healthy hosts folded into one row. Each host opens its route ladder with Reconnect. The notifications page shows registration, the last push, Send test and Unpair. Demo mode gains a Laptop on its VPN fallback, and snapshot tests render the three screens.
420 lines
20 KiB
Swift
420 lines
20 KiB
Swift
// Decodes the docs/api.md examples into the Codable models, and checks the small bits of logic
|
|
// the screens rely on.
|
|
|
|
import Foundation
|
|
import Security
|
|
import Testing
|
|
@testable import WristCode
|
|
|
|
private func decode<T: Decodable>(_ type: T.Type, _ json: String) throws -> T {
|
|
try JSON.decoder().decode(type, from: Data(json.utf8))
|
|
}
|
|
|
|
private func encodeObject(_ value: some Encodable) throws -> [String: Any] {
|
|
let data = try JSON.encoder().encode(value)
|
|
return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
|
}
|
|
|
|
private let feedItemJSON = """
|
|
{
|
|
"hostId": "...", "hostLabel": "Mac mini",
|
|
"threadId": "...", "projectId": "...", "projectTitle": "bill-tracker",
|
|
"title": "Receipt OCR",
|
|
"phase": "waiting_for_approval",
|
|
"updatedAt": "2026-11-01T09:41:00.000Z",
|
|
"lastMessage": "Parser and tests are written. I want to run the suite.",
|
|
"request": {
|
|
"id": "...",
|
|
"kind": "command",
|
|
"prompt": "bun test --filter ocr",
|
|
"reason": null,
|
|
"options": [{ "decision": "accept", "label": "Allow" }, { "decision": "decline", "label": "Deny" }],
|
|
"questions": []
|
|
}
|
|
}
|
|
"""
|
|
|
|
@Suite struct DecodingTests {
|
|
@Test func feedFromDocs() throws {
|
|
let feed = try decode(Feed.self, """
|
|
{
|
|
"needsYou": [\(feedItemJSON)],
|
|
"working": [],
|
|
"recent": [],
|
|
"hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null }]
|
|
}
|
|
""")
|
|
#expect(feed.needsYou.count == 1)
|
|
#expect(feed.hosts.first?.status == .online)
|
|
#expect(feed.hosts.first?.error == nil)
|
|
|
|
let item = try #require(feed.needsYou.first)
|
|
#expect(item.phase == .waitingForApproval)
|
|
#expect(item.phase.needsYou)
|
|
#expect(item.updatedAt == Date(timeIntervalSince1970: 1_793_526_060))
|
|
let request = try #require(item.request)
|
|
#expect(request.prompt == "bun test --filter ocr")
|
|
#expect(request.reason == nil)
|
|
#expect(request.quickOptions?.allow.label == "Allow")
|
|
#expect(request.quickOptions?.deny.decision == .decline)
|
|
#expect(request.summary == "bun test --filter ocr")
|
|
}
|
|
|
|
@Test func questionRequest() throws {
|
|
let request = try decode(PendingRequest.self, """
|
|
{ "id": "r1", "kind": "user_input", "prompt": null, "reason": null, "options": [],
|
|
"questions": [{ "id": "q1", "header": "Storage", "question": "Hash the codes?",
|
|
"options": [{ "label": "Yes", "value": "yes" }], "allowCustomAnswer": true }] }
|
|
""")
|
|
#expect(request.isQuestion)
|
|
#expect(request.quickOptions == nil)
|
|
#expect(request.summary == "Hash the codes?")
|
|
#expect(request.questions.first?.allowCustomAnswer == true)
|
|
// A gateway older than multi-select leaves the flag out, which means single-select.
|
|
#expect(request.questions.first?.multiSelect == false)
|
|
}
|
|
|
|
@Test func multiSelectQuestion() throws {
|
|
let question = try decode(PendingRequest.Question.self, """
|
|
{ "id": "q1", "header": "Checks", "question": "Which checks?",
|
|
"options": [{ "label": "Unit", "value": "unit" }, { "label": "Lint", "value": "lint" }],
|
|
"allowCustomAnswer": false, "multiSelect": true }
|
|
""")
|
|
#expect(question.multiSelect)
|
|
#expect(question.options.map(\.value) == ["unit", "lint"])
|
|
}
|
|
|
|
@Test func summaryFromDocs() throws {
|
|
let summary = try decode(Summary.self, """
|
|
{ "needsYou": 1, "working": 3, "hostsOffline": 0,
|
|
"next": { "hostId": "h", "threadId": "t", "title": "Receipt OCR", "hostLabel": "Mac mini" } }
|
|
""")
|
|
#expect(summary.needsYou == 1)
|
|
#expect(summary.working == 3)
|
|
#expect(summary.next?.title == "Receipt OCR")
|
|
#expect(summary.next?.route == .thread(hostId: "h", threadId: "t"))
|
|
|
|
let idle = try decode(Summary.self, #"{ "needsYou": 0, "working": 0, "hostsOffline": 2, "next": null }"#)
|
|
#expect(idle.next == nil)
|
|
#expect(idle.hostsOffline == 2)
|
|
}
|
|
|
|
@Test func unknownEnumValuesStillDecode() throws {
|
|
let item = try decode(FeedItem.self, feedItemJSON
|
|
.replacingOccurrences(of: "waiting_for_approval", with: "hibernating")
|
|
.replacingOccurrences(of: "\"decline\"", with: "\"rejectForever\""))
|
|
#expect(item.phase.rawValue == "hibernating")
|
|
#expect(!item.phase.needsYou && !item.phase.isWorking)
|
|
#expect(item.request?.options.last?.decision.rawValue == "rejectForever")
|
|
|
|
let host = try decode(HostSummary.self, #"{ "id": "h", "label": "L", "status": "sleeping", "error": "zzz" }"#)
|
|
#expect(host.status.problem == "offline")
|
|
}
|
|
|
|
@Test func threadFromDocs() throws {
|
|
let detail = try decode(ThreadDetail.self, """
|
|
{ "item": \(feedItemJSON),
|
|
"messages": [{ "id": "...", "role": "assistant", "text": "...", "createdAt": "2026-11-01T09:40:00Z" }],
|
|
"canStop": true }
|
|
""")
|
|
#expect(detail.canStop)
|
|
#expect(detail.messages.first?.role == .assistant)
|
|
#expect(detail.messages.first?.createdAt == Date(timeIntervalSince1970: 1_793_526_000))
|
|
}
|
|
|
|
@Test func projectsFromDocs() throws {
|
|
let response = try decode(ProjectsResponse.self, """
|
|
{ "hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null,
|
|
"projects": [{ "id": "a", "title": "wristcode", "model": "claude-opus-5-5" },
|
|
{ "id": "b", "title": "scratch", "model": null }] }] }
|
|
""")
|
|
let host = try #require(response.hosts.first)
|
|
#expect(host.projects.count == 2)
|
|
#expect(host.launchable.map(\.id) == ["a"])
|
|
}
|
|
|
|
@Test func smallResponses() throws {
|
|
#expect(try decode(PairResponse.self, #"{ "deviceId": "9b0c", "deviceToken": "wc_x" }"#).deviceToken == "wc_x")
|
|
#expect(try decode(NewThreadResponse.self, #"{ "hostId": "h", "threadId": "t" }"#).threadId == "t")
|
|
#expect(try decode(ErrorResponse.self, #"{ "error": "That host is offline" }"#).error == "That host is offline")
|
|
}
|
|
|
|
@Test func statusFromDocs() throws {
|
|
let status = try decode(StatusResponse.self, """
|
|
{
|
|
"hosts": [{
|
|
"id": "laptop", "label": "Laptop", "status": "online", "error": null,
|
|
"activeUrl": "http://laptopvpn.gib:3773",
|
|
"routes": [
|
|
{ "url": "http://laptop.gib:3773", "kind": "direct", "result": "failed", "error": "no answer in 5 s", "checkedAt": "2026-11-01T09:12:00.000Z" },
|
|
{ "url": "http://laptopvpn.gib:3773", "kind": "vpn", "result": "ok", "error": null, "checkedAt": "2026-11-01T09:12:00.000Z" },
|
|
{ "url": "https://t3laptop.gbrown.org", "kind": "public", "result": "untried", "error": null, "checkedAt": null }
|
|
],
|
|
"onlineSince": "2026-11-01T09:12:00.000Z", "lastOnlineAt": null, "nextRetryAt": null,
|
|
"tokenExpiresAt": "2026-12-01T00:00:00.000Z", "serverVersion": "0.1.5"
|
|
}],
|
|
"gateway": { "startedAt": "2026-11-01T08:00:00.000Z", "pushConfigured": true,
|
|
"lastPush": { "ok": false, "at": "2026-11-01T09:40:00.000Z", "status": 400, "reason": "BadDeviceToken" } },
|
|
"device": { "name": "Apple Watch", "pairedAt": "2026-10-30T08:00:00.000Z", "pushRegistered": true, "apnsEnvironment": null }
|
|
}
|
|
""")
|
|
let host = try #require(status.hosts.first)
|
|
#expect(host.onFallback)
|
|
#expect(host.activeRoute?.kind == .vpn)
|
|
#expect(host.routes.map(\.hostPort) == ["laptop.gib:3773", "laptopvpn.gib:3773", "t3laptop.gbrown.org"])
|
|
#expect(status.device.apnsEnvironment == nil)
|
|
#expect(status.gateway.lastPush?.hint?.contains("sandbox/production") == true)
|
|
}
|
|
|
|
@Test func badDateFails() {
|
|
#expect(throws: DecodingError.self) {
|
|
try decode(Message.self, #"{ "id": "m", "role": "user", "text": "x", "createdAt": "yesterday" }"#)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Suite struct EncodingTests {
|
|
@Test func respondWithDecision() throws {
|
|
let object = try encodeObject(RespondRequest.decision(requestId: "r1", decision: .accept))
|
|
#expect(object["requestId"] as? String == "r1")
|
|
#expect(object["decision"] as? String == "accept")
|
|
#expect(object["answers"] == nil)
|
|
}
|
|
|
|
@Test func respondWithAnswers() throws {
|
|
let object = try encodeObject(RespondRequest.answers(requestId: "r1", answers: ["q1": .text("yes")]))
|
|
#expect(object["answers"] as? [String: String] == ["q1": "yes"])
|
|
#expect(object["decision"] == nil)
|
|
}
|
|
|
|
@Test func respondWithMultiSelectAnswers() throws {
|
|
let object = try encodeObject(RespondRequest.answers(
|
|
requestId: "r1", answers: ["checks": .values(["unit", "lint"]), "note": .text("after lunch")]))
|
|
let answers = try #require(object["answers"] as? [String: Any])
|
|
#expect(answers["checks"] as? [String] == ["unit", "lint"])
|
|
#expect(answers["note"] as? String == "after lunch")
|
|
}
|
|
|
|
@Test func pushRegistration() throws {
|
|
let object = try encodeObject(PushRegistration(apnsToken: "ab", apnsEnvironment: .sandbox))
|
|
#expect(object["apnsEnvironment"] as? String == "sandbox")
|
|
}
|
|
}
|
|
|
|
@Suite struct LogicTests {
|
|
@Test func gatewayURLNormalizing() {
|
|
#expect(DeviceStore.normalizeGatewayURL(" wristcode.gbrown.org/ ")?.absoluteString == "https://wristcode.gbrown.org")
|
|
#expect(DeviceStore.normalizeGatewayURL("http://10.0.0.5:8080")?.absoluteString == "http://10.0.0.5:8080")
|
|
#expect(DeviceStore.normalizeGatewayURL("") == nil)
|
|
#expect(DeviceStore.normalizeGatewayURL("ftp://x.org") == nil)
|
|
}
|
|
|
|
@Test func modelNames() {
|
|
#expect(Format.modelName("claude-opus-5-5") == "Claude Opus 5.5")
|
|
#expect(Format.modelName("claude-haiku-5-5") == "Claude Haiku 5.5")
|
|
#expect(Format.modelName("gpt-5-codex") == "GPT 5 Codex")
|
|
}
|
|
|
|
@Test func ages() {
|
|
let now = Date(timeIntervalSince1970: 100_000)
|
|
#expect(Format.age(of: now, now: now) == "now")
|
|
#expect(Format.age(of: now.addingTimeInterval(-4 * 60), now: now) == "4 min")
|
|
#expect(Format.age(of: now.addingTimeInterval(-62 * 60), now: now) == "1 h")
|
|
#expect(Format.age(of: now.addingTimeInterval(-50 * 3600), now: now) == "2 d")
|
|
}
|
|
|
|
@Test func messageSplitShowsLatestAgentMessage() {
|
|
func m(_ id: String, _ role: Message.Role) -> Message { Message(id: id, role: role, text: id, createdAt: .now) }
|
|
let split = MessageSplit([m("1", .user), m("2", .assistant), m("3", .assistant), m("4", .user)])
|
|
#expect(split.earlier.map(\.id) == ["1", "2"])
|
|
#expect(split.latest?.id == "3")
|
|
#expect(split.after.map(\.id) == ["4"])
|
|
#expect(MessageSplit([m("1", .user)]).latest == nil)
|
|
}
|
|
|
|
@Test func defaultProjectPrefersLastUsed() {
|
|
let projects = ProjectsResponse(hosts: [
|
|
.init(id: "off", label: "Off", status: .offline, error: nil, projects: [.init(id: "x", title: "x", model: "m")]),
|
|
.init(id: "on", label: "On", status: .online, error: nil, projects: [
|
|
.init(id: "none", title: "none", model: nil),
|
|
.init(id: "a", title: "a", model: "m"),
|
|
.init(id: "b", title: "b", model: "m"),
|
|
]),
|
|
])
|
|
let last = ProjectRef(hostId: "on", projectId: "b")
|
|
#expect(NewThreadView.defaultSelection(in: projects, last: last) == last)
|
|
// A forgotten or model-less project falls back to the first online host's first usable project.
|
|
#expect(NewThreadView.defaultSelection(in: projects, last: ProjectRef(hostId: "on", projectId: "none"))
|
|
== ProjectRef(hostId: "on", projectId: "a"))
|
|
}
|
|
|
|
@Test func summaryFromFeed() throws {
|
|
let feed = DemoFixtures.previewFeed()
|
|
let summary = Summary(feed)
|
|
#expect(summary.needsYou == feed.needsYou.count)
|
|
#expect(summary.working == feed.working.count)
|
|
#expect(summary.hostsOffline == 1)
|
|
#expect(summary.next?.threadId == feed.needsYou.first?.threadId)
|
|
#expect(Summary(Feed(needsYou: [], working: [], recent: [], hosts: [])).next == nil)
|
|
}
|
|
|
|
@Test func routeURLs() {
|
|
let thread = Route.thread(hostId: "mac mini", threadId: "t/1")
|
|
#expect(thread.url.absoluteString == "wristcode://thread/mac%20mini/t%2F1")
|
|
#expect(Route(url: thread.url) == thread)
|
|
#expect(Route(url: Route.newThread.url) == .newThread)
|
|
for route in [Route.status, .host(id: "mac mini"), .notifications] {
|
|
#expect(Route(url: route.url) == route)
|
|
}
|
|
#expect(Route(url: URL(string: "wristcode://thread/only-host")!) == nil)
|
|
#expect(Route(url: URL(string: "https://thread/h/t")!) == nil)
|
|
}
|
|
|
|
@Test func notificationCommands() {
|
|
let info: [AnyHashable: Any] = ["hostId": "h", "threadId": "t", "requestId": "r"]
|
|
#expect(NotificationCommand(actionIdentifier: "allow", userInfo: info, text: nil)
|
|
== .respond(hostId: "h", threadId: "t", requestId: "r", decision: .accept))
|
|
#expect(NotificationCommand(actionIdentifier: "deny", userInfo: info, text: nil)
|
|
== .respond(hostId: "h", threadId: "t", requestId: "r", decision: .decline))
|
|
#expect(NotificationCommand(actionIdentifier: "reply", userInfo: info, text: " Commit it ")
|
|
== .reply(hostId: "h", threadId: "t", text: "Commit it"))
|
|
#expect(NotificationCommand(actionIdentifier: "com.apple.UNNotificationDefaultActionIdentifier", userInfo: info, text: nil)
|
|
== .open(.thread(hostId: "h", threadId: "t")))
|
|
// A host notification has no thread, so tapping it opens the feed.
|
|
#expect(NotificationCommand(actionIdentifier: "com.apple.UNNotificationDefaultActionIdentifier", userInfo: [:], text: nil)
|
|
== .open(nil))
|
|
#expect(NotificationCommand(actionIdentifier: "com.apple.UNNotificationDefaultActionIdentifier", userInfo: ["hostId": "h", "threadId": ""], text: nil)
|
|
== .open(nil))
|
|
// Allow without a requestId cannot answer, so it opens the thread instead.
|
|
#expect(NotificationCommand(actionIdentifier: "allow", userInfo: ["hostId": "h", "threadId": "t"], text: nil)
|
|
== .open(.thread(hostId: "h", threadId: "t")))
|
|
}
|
|
}
|
|
|
|
/// Reads and writes the real shared Keychain group, so the entitlement and Info.plist group agree.
|
|
@Suite(.serialized) struct SharedStateTests {
|
|
@Test func accessGroupHasTeamPrefix() {
|
|
#expect(DeviceStore.accessGroup.hasSuffix(".org.gbrown.wristcode.shared"))
|
|
#expect(!DeviceStore.accessGroup.hasPrefix("$"))
|
|
}
|
|
|
|
@Test func roundTrip() {
|
|
let demo = SharedState.demo
|
|
let summary = SharedState.lastSummary
|
|
defer {
|
|
SharedState.demo = demo
|
|
SharedState.lastSummary = summary
|
|
}
|
|
|
|
let saved = Summary(needsYou: 2, working: 1, next: nil, hostsOffline: 0)
|
|
SharedState.lastSummary = saved
|
|
#expect(SharedState.lastSummary == saved)
|
|
SharedState.lastSummary = nil
|
|
#expect(SharedState.lastSummary == nil)
|
|
|
|
SharedState.demo = true
|
|
#expect(SharedState.demo)
|
|
SharedState.demo = false
|
|
#expect(!SharedState.demo)
|
|
}
|
|
|
|
/// A token saved by a build before the complication sits in the app's own group. Launch moves it.
|
|
@Test func migratesLegacyItems() throws {
|
|
let before = DeviceStore.load()
|
|
defer {
|
|
if let before { DeviceStore.save(before) } else { DeviceStore.clearToken() }
|
|
}
|
|
let team = try #require(DeviceStore.accessGroup.split(separator: ".").first)
|
|
let appGroup = "\(team).org.gbrown.wristcode.watchkitapp"
|
|
func item(_ account: String) -> [String: Any] {
|
|
[kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: "org.gbrown.wristcode",
|
|
kSecAttrAccount as String: account]
|
|
}
|
|
for (account, value) in [("deviceToken", "wc_legacy"), ("gatewayURL", "https://legacy.example")] {
|
|
SecItemDelete(item(account) as CFDictionary)
|
|
var legacy = item(account)
|
|
legacy[kSecAttrAccessGroup as String] = appGroup
|
|
legacy[kSecValueData as String] = Data(value.utf8)
|
|
#expect(SecItemAdd(legacy as CFDictionary, nil) == errSecSuccess)
|
|
}
|
|
#expect(DeviceStore.sharedData("deviceToken") == nil)
|
|
// Before the move the app still finds the old items.
|
|
#expect(DeviceStore.load()?.token == "wc_legacy")
|
|
|
|
DeviceStore.migrateToSharedGroup()
|
|
|
|
#expect(DeviceStore.sharedData("deviceToken") == Data("wc_legacy".utf8))
|
|
#expect(DeviceStore.load() == Credentials(gatewayURL: URL(string: "https://legacy.example")!, token: "wc_legacy"))
|
|
var old = item("deviceToken")
|
|
old[kSecAttrAccessGroup as String] = appGroup
|
|
#expect(SecItemCopyMatching(old as CFDictionary, nil) == errSecItemNotFound)
|
|
}
|
|
}
|
|
|
|
@Suite struct StatusReportTests {
|
|
private let now = Date(timeIntervalSince1970: 1_800_000_000)
|
|
|
|
@Test func demoListsIssuesWorstFirst() {
|
|
let report = StatusReport(DemoFixtures(now: now).status, now: now)
|
|
#expect(report.issues.map(\.title) == [
|
|
"LittleMan offline", "Laptop is on its VPN route", "server-gib token expires in 5 days",
|
|
])
|
|
#expect(report.issues.map(\.detail) == ["Both routes failed · 4 min", "laptop.gib:3773 did not answer", "Run renew-hosts.sh"])
|
|
#expect(!report.allConnected)
|
|
#expect(report.worst == .problem)
|
|
#expect(report.healthySummary == "2 more, all direct")
|
|
#expect(report.subline == "4 environments · pushes OK")
|
|
}
|
|
|
|
@Test func fallbackAloneIsStillAllConnected() {
|
|
var status = DemoFixtures(now: now).status
|
|
status.hosts.removeAll { $0.id == "littleman" }
|
|
for i in status.hosts.indices { status.hosts[i].tokenExpiresAt = now.addingTimeInterval(30 * 86_400) }
|
|
let report = StatusReport(status, now: now)
|
|
#expect(report.allConnected)
|
|
#expect(report.issues.map(\.kind) == [.fallback])
|
|
}
|
|
|
|
@Test func pushProblems() {
|
|
var status = DemoFixtures(now: now).status
|
|
status.device.pushRegistered = false
|
|
#expect(StatusReport(status, now: now).pushSummary == "watch not registered")
|
|
status.gateway.pushConfigured = false
|
|
let report = StatusReport(status, now: now)
|
|
#expect(report.pushSummary == "pushes off")
|
|
#expect(report.issues.first { $0.kind == .push }?.route == .notifications)
|
|
}
|
|
}
|
|
|
|
@Suite struct DemoGatewayTests {
|
|
@Test func allowMovesThreadToWorking() async throws {
|
|
let gateway = DemoGateway()
|
|
let before = try await gateway.feed()
|
|
let item = try #require(before.needsYou.first { $0.request?.quickOptions != nil })
|
|
try await gateway.respond(
|
|
hostId: item.hostId, threadId: item.threadId,
|
|
request: .decision(requestId: item.request!.id, decision: .accept))
|
|
let after = try await gateway.feed()
|
|
#expect(!after.needsYou.contains { $0.id == item.id })
|
|
#expect(after.working.contains { $0.id == item.id })
|
|
await #expect(throws: GatewayError.http(status: 409, message: "Already answered.")) {
|
|
try await gateway.respond(
|
|
hostId: item.hostId, threadId: item.threadId,
|
|
request: .decision(requestId: item.request!.id, decision: .accept))
|
|
}
|
|
}
|
|
|
|
@Test func multiSelectAnswerMovesThreadToWorking() async throws {
|
|
let gateway = DemoGateway()
|
|
let item = try #require(try await gateway.feed().needsYou.first { $0.request?.questions.first?.multiSelect == true })
|
|
let request = try #require(item.request)
|
|
let question = try #require(request.questions.first)
|
|
try await gateway.respond(
|
|
hostId: item.hostId, threadId: item.threadId,
|
|
request: .answers(requestId: request.id, answers: [question.id: .values(["unit", "lint"])]))
|
|
#expect(try await gateway.summary().working == 3)
|
|
#expect(try await gateway.feed().working.contains { $0.id == item.id })
|
|
}
|
|
}
|