1072 lines
56 KiB
Swift
1072 lines
56 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
|
|
import UserNotifications
|
|
@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")
|
|
// An older gateway without `recentTotal` has nothing past `recent`.
|
|
#expect(feed.recentTotal == 0)
|
|
#expect(feed.moreRecent == 0)
|
|
}
|
|
|
|
@Test func feedWithRecentTotal() throws {
|
|
let item = feedItemJSON.replacingOccurrences(of: "waiting_for_approval", with: "completed")
|
|
let feed = try decode(Feed.self, """
|
|
{ "needsYou": [], "working": [], "recent": [\(item)], "recentTotal": 12, "hosts": [] }
|
|
""")
|
|
#expect(feed.recentTotal == 12)
|
|
#expect(feed.moreRecent == 11)
|
|
#expect(Feed(needsYou: [], working: [], recent: feed.recent, hosts: []).moreRecent == 0)
|
|
}
|
|
|
|
@Test func recentResponses() throws {
|
|
let item = feedItemJSON.replacingOccurrences(
|
|
of: "\"Parser and tests are written. I want to run the suite.\"", with: "null")
|
|
let recent = try decode(RecentResponse.self, "{ \"items\": [\(item)] }")
|
|
#expect(recent.items.count == 1)
|
|
#expect(recent.items.first?.lastMessage == nil)
|
|
let settled = try decode(SettleRecentResponse.self, #"{ "settled": 11, "failed": 1 }"#)
|
|
#expect(settled == SettleRecentResponse(settled: 11, failed: 1))
|
|
}
|
|
|
|
@Test func subagentRequest() throws {
|
|
let item = try decode(FeedItem.self, feedItemJSON.replacingOccurrences(
|
|
of: "\"request\": {",
|
|
with: #""via": { "hostId": "mac", "threadId": "sub", "title": "Check Panama PATH" }, "request": {"#))
|
|
#expect(item.via == FeedItem.Via(hostId: "mac", threadId: "sub", title: "Check Panama PATH"))
|
|
#expect(item.requestThread == ("mac", "sub"))
|
|
#expect(item.route == .thread(hostId: "...", threadId: "..."))
|
|
|
|
// A gateway older than `via` leaves it out, and the request belongs to the thread itself.
|
|
let plain = try decode(FeedItem.self, feedItemJSON)
|
|
#expect(plain.via == nil)
|
|
#expect(plain.requestThread == ("...", "..."))
|
|
}
|
|
|
|
@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)
|
|
// An older gateway leaves out askFirst, so the switch stays hidden, and the phase 3 fields.
|
|
#expect(detail.askFirst == nil)
|
|
#expect(detail.earlierCursor == nil)
|
|
#expect(detail.subagents.isEmpty)
|
|
#expect(detail.item.activity == nil)
|
|
#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"])
|
|
#expect(host.providers.isEmpty)
|
|
#expect(host.projects.first?.defaultModel == nil)
|
|
}
|
|
|
|
@Test func phase2FromDocs() throws {
|
|
let projects = try decode(ProjectsResponse.self, """
|
|
{ "hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null,
|
|
"projects": [{ "id": "a", "title": "wristcode", "model": "claude-opus-5-5", "instanceId": "claudeAgent" }],
|
|
"providers": [{ "instanceId": "claudeAgent", "name": "Claude", "canAskFirst": true,
|
|
"models": [{ "slug": "claude-opus-5-5", "name": "Claude Opus 5.5" }] }] }] }
|
|
""")
|
|
let host = try #require(projects.hosts.first)
|
|
#expect(host.projects.first?.defaultModel == ModelRef(instanceId: "claudeAgent", slug: "claude-opus-5-5"))
|
|
#expect(host.offers(ModelRef(instanceId: "claudeAgent", slug: "claude-opus-5-5")))
|
|
#expect(!host.offers(ModelRef(instanceId: "codex", slug: "claude-opus-5-5")))
|
|
|
|
let detail = try decode(ThreadDetail.self, #"{ "item": \#(feedItemJSON), "messages": [], "canStop": true, "askFirst": false }"#)
|
|
#expect(detail.askFirst == false)
|
|
|
|
let prefs = try decode(NotificationPrefs.self, #"{ "needsMe": true, "finished": true, "failed": true, "subagents": false }"#)
|
|
#expect(NotificationPrefs.Key.allCases.map { prefs[$0] } == [true, true, true, false])
|
|
|
|
let request = try encodeObject(NewThreadRequest(
|
|
hostId: "h", projectId: "p", text: "t",
|
|
model: ModelRef(instanceId: "claudeAgent", slug: "claude-sonnet-5-5"), askFirst: true))
|
|
#expect((request["model"] as? [String: String]) == ["instanceId": "claudeAgent", "slug": "claude-sonnet-5-5"])
|
|
#expect(request["askFirst"] as? Bool == true)
|
|
let plain = try encodeObject(NewThreadRequest(hostId: "h", projectId: "p", text: "t"))
|
|
#expect(plain["model"] == nil && plain["askFirst"] == nil)
|
|
}
|
|
|
|
@Test func phase3FromDocs() throws {
|
|
let working = feedItemJSON
|
|
.replacingOccurrences(of: "waiting_for_approval", with: "running")
|
|
.replacingOccurrences(
|
|
of: #""request": {"#,
|
|
with: #""activity": { "kind": "command", "text": "bun test", "startedAt": "2026-11-01T09:40:00.000Z" }, "request": {"#)
|
|
let detail = try decode(ThreadDetail.self, """
|
|
{ "item": \(working), "messages": [], "canStop": true, "askFirst": false, "earlierCursor": "w12",
|
|
"subagents": [
|
|
{ "hostId": "h", "threadId": "s1", "title": "Check PATH", "phase": "running", "settled": false, "updatedAt": "2026-11-01T09:41:00.000Z" },
|
|
{ "hostId": "h", "threadId": "s2", "title": "Stopped", "phase": null, "settled": true, "updatedAt": "2026-11-01T09:30:00.000Z" }
|
|
] }
|
|
""")
|
|
let activity = try #require(detail.item.activity)
|
|
#expect(activity.kind == .command)
|
|
#expect(activity.text == "bun test")
|
|
#expect(activity.startedAt == Date(timeIntervalSince1970: 1_793_526_000))
|
|
#expect(detail.earlierCursor == "w12")
|
|
#expect(detail.subagents.map(\.statusWord) == ["working", "settled"])
|
|
#expect(detail.subagents.map(\.isActive) == [true, false])
|
|
#expect(detail.subagents.last?.phase == nil)
|
|
|
|
let thinking = try decode(Activity.self, #"{ "kind": "thinking", "text": "Thinking", "startedAt": null }"#)
|
|
#expect(thinking.startedAt == nil)
|
|
|
|
let history = try decode(HistoryResponse.self, """
|
|
{ "messages": [{ "id": "m1", "role": "user", "text": "Hi", "createdAt": "2026-11-01T09:00:00Z" }], "earlierCursor": null }
|
|
""")
|
|
#expect(history.messages.map(\.id) == ["m1"])
|
|
#expect(history.earlierCursor == nil)
|
|
|
|
let list = try decode(ThreadListResponse.self, """
|
|
{ "items": [{ "hostId": "h", "threadId": "t", "title": "Phase 3", "phase": "failed", "settled": false,
|
|
"updatedAt": "2026-11-01T09:41:00.000Z" }] }
|
|
""")
|
|
#expect(list.items.first?.statusWord == "failed")
|
|
#expect(list.items.first?.route == .thread(hostId: "h", threadId: "t"))
|
|
}
|
|
|
|
@Test func phase4FromDocs() throws {
|
|
let item = try decode(FeedItem.self, feedItemJSON.replacingOccurrences(
|
|
of: #""request": {"#, with: #""changes": { "files": 3, "additions": 42, "deletions": 8 }, "request": {"#))
|
|
#expect(item.changes == ChangeTotals(files: 3, additions: 42, deletions: 8))
|
|
#expect(try decode(FeedItem.self, feedItemJSON).changes == nil)
|
|
|
|
let detail = try decode(ThreadDetail.self, """
|
|
{ "item": \(feedItemJSON), "messages": [], "canStop": false, "subagents": [],
|
|
"changedFiles": [{ "path": "gateway/Containerfile", "name": "Containerfile", "additions": 31, "deletions": 0 }] }
|
|
""")
|
|
#expect(detail.changedFiles == [ChangedFile(path: "gateway/Containerfile", name: "Containerfile", additions: 31, deletions: 0)])
|
|
#expect(try decode(ThreadDetail.self, #"{ "item": \#(feedItemJSON), "messages": [], "canStop": true }"#).changedFiles.isEmpty)
|
|
|
|
let summary = try decode(Summary.self, """
|
|
{ "needsYou": 1, "working": 3, "hostsOffline": 0,
|
|
"next": { "hostId": "h", "threadId": "t", "title": "Receipt OCR", "hostLabel": "Mac mini", "request": "bun test --filter ocr" },
|
|
"current": { "hostId": "h", "threadId": "w", "title": "Phase 3 screens",
|
|
"activity": { "kind": "command", "text": "bun test", "startedAt": null } } }
|
|
""")
|
|
#expect(summary.next?.request == "bun test --filter ocr")
|
|
#expect(summary.current?.title == "Phase 3 screens")
|
|
#expect(summary.current?.activity?.text == "bun test")
|
|
#expect(summary.current?.route == .thread(hostId: "h", threadId: "w"))
|
|
let unread = try decode(Summary.self, """
|
|
{ "needsYou": 1, "working": 0, "hostsOffline": 0, "current": null,
|
|
"next": { "hostId": "h", "threadId": "t", "title": "Receipt OCR", "hostLabel": "Mac mini", "request": null } }
|
|
""")
|
|
#expect(unread.next?.request == nil)
|
|
#expect(unread.current == nil)
|
|
|
|
let search = try decode(SearchResponse.self, """
|
|
{ "hits": [{ "thread": { "hostId": "h", "threadId": "t", "title": "Jellyfin image", "phase": null, "settled": true,
|
|
"updatedAt": "2026-11-01T09:41:00.000Z" },
|
|
"projectTitle": "homelab", "hostLabel": "Mac mini", "snippet": "…rebuild from the Containerfile and…" },
|
|
{ "thread": { "hostId": "h", "threadId": "u", "title": "Gateway Containerfile", "phase": "running", "settled": false,
|
|
"updatedAt": "2026-11-01T09:42:00.000Z" },
|
|
"projectTitle": "wristcode", "hostLabel": "Mac mini", "snippet": null }],
|
|
"unsearched": ["Laptop"] }
|
|
""")
|
|
#expect(search.hits.map(\.id) == ["h/t", "h/u"])
|
|
#expect(search.hits.first?.snippet == "…rebuild from the Containerfile and…")
|
|
#expect(search.hits.last?.snippet == nil)
|
|
#expect(search.unsearched == ["Laptop"])
|
|
}
|
|
|
|
@Test func phase5FromDocs() throws {
|
|
let feed = try decode(Feed.self, """
|
|
{
|
|
"needsYou": [], "working": [], "recent": [], "recentTotal": 0, "snoozedTotal": 2, "hosts": []
|
|
}
|
|
""")
|
|
#expect(feed.snoozedTotal == 2)
|
|
|
|
let snoozed = try decode(RecentResponse.self, """
|
|
{ "items": [{
|
|
"hostId": "h", "hostLabel": "Mac mini", "threadId": "t", "projectId": "p", "projectTitle": "wristcode",
|
|
"title": "Gateway Containerfile", "phase": "completed", "updatedAt": "2026-11-02T14:00:00.000Z",
|
|
"lastMessage": null, "activity": null, "changes": null, "snoozedUntil": "2026-11-03T14:00:00.000Z",
|
|
"request": null, "via": null
|
|
}] }
|
|
""")
|
|
#expect(snoozed.items.first?.snoozedUntil == JSON.parseDate("2026-11-03T14:00:00.000Z"))
|
|
|
|
let request = try decode(PendingRequest.self, """
|
|
{
|
|
"id": "r", "kind": "file-change", "prompt": "Edit: deploy/compose.yml", "reason": null,
|
|
"options": [{ "decision": "accept", "label": "Allow" }, { "decision": "decline", "label": "Deny" }],
|
|
"files": [
|
|
{ "path": "deploy/compose.yml", "name": "compose.yml", "additions": 9, "deletions": 6,
|
|
"preview": ["- image: bun:1", "+ image: bun:1-alpine"] },
|
|
{ "path": "gateway/src/health.ts", "name": "health.ts", "additions": null, "deletions": null, "preview": [] }
|
|
],
|
|
"questions": []
|
|
}
|
|
""")
|
|
#expect(request.files.map(\.name) == ["compose.yml", "health.ts"])
|
|
#expect(request.files[0].additions == 9)
|
|
#expect(request.files[1].deletions == nil)
|
|
#expect(request.files[0].preview.count == 2)
|
|
|
|
// Older gateways send neither, which decodes as nothing snoozed and no files.
|
|
let old = try decode(Feed.self, """
|
|
{ "needsYou": [\(feedItemJSON)], "working": [], "recent": [], "hosts": [] }
|
|
""")
|
|
#expect(old.snoozedTotal == 0)
|
|
#expect(old.needsYou.first?.snoozedUntil == nil)
|
|
#expect(old.needsYou.first?.request?.files == [])
|
|
}
|
|
|
|
@Test func pushQuestion() throws {
|
|
let info: [AnyHashable: Any] = [
|
|
"hostId": "h", "threadId": "t", "requestId": "r",
|
|
"question": ["id": "q1", "options": [["label": "bun:1-alpine", "value": "alpine"]], "allowCustomAnswer": true],
|
|
]
|
|
let question = try #require(PushQuestion(userInfo: info))
|
|
#expect(question.id == "q1")
|
|
#expect(question.options.map(\.value) == ["alpine"])
|
|
#expect(question.allowCustomAnswer)
|
|
// Pushes without one, as from an older gateway, and malformed ones have none.
|
|
#expect(PushQuestion(userInfo: ["hostId": "h"]) == nil)
|
|
#expect(PushQuestion(userInfo: ["question": ["id": "q1"]]) == nil)
|
|
}
|
|
|
|
@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 elapsedTimes() {
|
|
let now = Date(timeIntervalSince1970: 100_000)
|
|
#expect(Format.elapsed(since: now.addingTimeInterval(5), now: now) == "0s")
|
|
#expect(Format.elapsed(since: now.addingTimeInterval(-42), now: now) == "42s")
|
|
#expect(Format.elapsed(since: now.addingTimeInterval(-60), now: now) == "1m")
|
|
#expect(Format.elapsed(since: now.addingTimeInterval(-199), now: now) == "3m")
|
|
#expect(Format.elapsed(since: now.addingTimeInterval(-3600), now: now) == "1h")
|
|
#expect(Format.elapsed(since: now.addingTimeInterval(-50 * 3600), now: now) == "2d")
|
|
}
|
|
|
|
@Test func olderMessagesMerge() throws {
|
|
func m(_ id: String) -> Message { Message(id: id, role: .assistant, text: id, createdAt: .now) }
|
|
let detail = try decode(ThreadDetail.self, #"{ "item": \#(feedItemJSON), "messages": [], "canStop": false, "earlierCursor": "w3" }"#)
|
|
var history = MessageHistory()
|
|
#expect(history.cursor(detail) == "w3")
|
|
#expect(history.messages(before: [m("4"), m("5")]).map(\.id) == ["4", "5"])
|
|
|
|
// A page that overlaps the window shows each message once.
|
|
history.prepend(HistoryResponse(messages: [m("2"), m("3"), m("4")], earlierCursor: "w1"))
|
|
#expect(history.cursor(detail) == "w1")
|
|
#expect(history.messages(before: [m("4"), m("5")]).map(\.id) == ["2", "3", "4", "5"])
|
|
|
|
// A refresh moves the window on. The outgoing window stays, so no gap opens.
|
|
history.keep([m("4"), m("5")])
|
|
#expect(history.messages(before: [m("6"), m("7")]).map(\.id) == ["2", "3", "4", "5", "6", "7"])
|
|
history.keep([m("6"), m("7")])
|
|
|
|
history.prepend(HistoryResponse(messages: [m("1"), m("2")], earlierCursor: nil))
|
|
#expect(history.cursor(detail) == nil)
|
|
#expect(history.messages(before: [m("7")]).map(\.id) == ["1", "2", "3", "4", "5", "6", "7"])
|
|
|
|
// Nothing is kept before Load earlier is used.
|
|
var fresh = MessageHistory()
|
|
fresh.keep([m("4")])
|
|
#expect(fresh.messages(before: [m("5")]).map(\.id) == ["5"])
|
|
}
|
|
|
|
@Test func olderMessagesTakeTheNewerCopy() {
|
|
func m(_ id: String, _ text: String) -> Message { Message(id: id, role: .assistant, text: text, createdAt: .now) }
|
|
var history = MessageHistory()
|
|
// Load earlier goes out with 3 caught mid-stream. A refresh lands before the page does.
|
|
history.startKeeping([m("3", "Runn"), m("4", "Done")])
|
|
history.keep([m("3", "Running the suite"), m("4", "Done"), m("5", "Next")])
|
|
#expect(history.messages(before: [m("6", "Later")]).map(\.text) == ["Running the suite", "Done", "Next", "Later"])
|
|
|
|
// The page repeats 3 and its copy, fetched last, wins in place.
|
|
history.prepend(HistoryResponse(messages: [m("2", "Two"), m("3", "Running the suite, all 48")], earlierCursor: nil))
|
|
#expect(history.messages(before: [m("6", "Later")]).map(\.text)
|
|
== ["Two", "Running the suite, all 48", "Done", "Next", "Later"])
|
|
}
|
|
|
|
@Test func queryValuesAreEncodedStrictly() {
|
|
let url = GatewayClient.url(
|
|
URL(string: "https://gateway.test")!, ["v1", "threads", "h/1", "t"],
|
|
query: [URLQueryItem(name: "cursor", value: "habc+/d=&e f")])
|
|
#expect(url.absoluteString == "https://gateway.test/v1/threads/h%2F1/t?cursor=habc%2B%2Fd%3D%26e%20f")
|
|
#expect(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first?.value == "habc+/d=&e f")
|
|
}
|
|
|
|
@Test func threadCacheKeepsTheNewest() async throws {
|
|
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
var cache = FeedCache(directory: directory)
|
|
cache.threadLimit = 3
|
|
var detail = try decode(ThreadDetail.self, #"{ "item": \#(feedItemJSON), "messages": [], "canStop": false }"#)
|
|
for id in ["a", "b", "c", "d", "e"] {
|
|
detail.item.threadId = id
|
|
cache.save(detail)
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
cache.save(Feed(needsYou: [], working: [], recent: [], hosts: []))
|
|
let kept = ["a", "b", "c", "d", "e"].filter { cache.thread(hostId: detail.item.hostId, threadId: $0) != nil }
|
|
#expect(kept == ["c", "d", "e"])
|
|
#expect(cache.feed() != nil)
|
|
}
|
|
|
|
@Test func markdownReadsCleanly() {
|
|
#expect(Markdown.block("## Done") == "**Done**")
|
|
#expect(Markdown.block("## Porting to C#") == "**Porting to C#**")
|
|
#expect(Markdown.block("## Done ##") == "**Done**")
|
|
#expect(Markdown.block("# Notes # ") == "**Notes**")
|
|
#expect(Markdown.block(" - item") == " • item")
|
|
#expect(Markdown.block("> quoted") == "quoted")
|
|
#expect(Markdown.block("**bold** stays") == "**bold** stays")
|
|
let text = String(Markdown.render("All **48** pass in `host.ts`:\n- bounded retry\n```\n# not a heading\n```").characters)
|
|
#expect(text == "All 48 pass in host.ts:\n• bounded retry\n# not a heading")
|
|
}
|
|
|
|
@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 newThreadAgentAndOptions() {
|
|
let claude = AgentProvider(
|
|
instanceId: "claudeAgent", name: "Claude", canAskFirst: true,
|
|
models: [.init(slug: "opus", name: "Claude Opus"), .init(slug: "sonnet", name: "Claude Sonnet")])
|
|
let other = AgentProvider(instanceId: "other", name: "Other", canAskFirst: false, models: [.init(slug: "o1", name: "O1")])
|
|
let project = ProjectsResponse.Project(id: "p", title: "p", model: "opus", instanceId: "claudeAgent")
|
|
let host = ProjectsResponse.Host(id: "h", label: "H", status: .online, error: nil, projects: [project], providers: [claude, other])
|
|
let sonnet = ModelRef(instanceId: "claudeAgent", slug: "sonnet")
|
|
|
|
// The last pick wins while the host still offers it, else the project's default.
|
|
#expect(NewThreadView.agent(on: host, project: project, last: sonnet) == sonnet)
|
|
#expect(NewThreadView.agent(on: host, project: project, last: ModelRef(instanceId: "claudeAgent", slug: "gone"))
|
|
== project.defaultModel)
|
|
#expect(NewThreadView.agent(on: host, project: project, last: nil) == project.defaultModel)
|
|
|
|
// The default model is left out so it keeps its options. A provider that cannot ask sends false.
|
|
let byDefault = NewThreadView.options(project: project, agent: project.defaultModel, provider: claude, askFirst: true)
|
|
#expect(byDefault.model == nil && byDefault.askFirst == true)
|
|
let picked = NewThreadView.options(project: project, agent: sonnet, provider: claude, askFirst: false)
|
|
#expect(picked.model == sonnet && picked.askFirst == false)
|
|
let o1 = ModelRef(instanceId: "other", slug: "o1")
|
|
#expect(NewThreadView.options(project: project, agent: o1, provider: other, askFirst: true).askFirst == false)
|
|
// An older gateway lists no providers, so neither is sent.
|
|
let old = NewThreadView.options(project: project, agent: nil, provider: nil, askFirst: true)
|
|
#expect(old.model == nil && old.askFirst == nil)
|
|
|
|
#expect(AgentList.shortName("Claude Opus 5.5", provider: "Claude") == "Opus 5.5")
|
|
#expect(AgentList.shortName("GPT-5.5", provider: "Codex") == "GPT-5.5")
|
|
}
|
|
|
|
@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 summaryFromFeedNamesRequestAndCurrent() throws {
|
|
let feed = DemoFixtures.previewFeed()
|
|
let summary = Summary(feed)
|
|
#expect(summary.next?.request == feed.needsYou.first?.request?.summary)
|
|
#expect(summary.current?.threadId == feed.working.first?.threadId)
|
|
#expect(summary.current?.activity == feed.working.first?.activity)
|
|
}
|
|
|
|
@Test func changesLine() {
|
|
let parts = ChangeTotals(files: 3, additions: 42, deletions: 8).parts
|
|
#expect(parts.additions == "+42")
|
|
#expect(parts.deletions == "\u{2212}8")
|
|
#expect(parts.files == "3 files")
|
|
#expect(ChangeTotals(files: 1, additions: 0, deletions: 2).parts == ("+0", "\u{2212}2", "1 file"))
|
|
}
|
|
|
|
@Test func unsearchedNote() {
|
|
#expect(SearchResponse.unsearchedNote([], offline: []) == nil)
|
|
#expect(SearchResponse.unsearchedNote(["Laptop"], offline: ["Laptop"])
|
|
== "Laptop is offline, so its threads were not searched.")
|
|
#expect(SearchResponse.unsearchedNote(["Laptop", "LittleMan"], offline: ["Laptop", "LittleMan"])
|
|
== "Laptop and LittleMan are offline, so their threads were not searched.")
|
|
// A host the watch saw online failed or was slow, which the gateway does not tell apart.
|
|
#expect(SearchResponse.unsearchedNote(["Laptop"], offline: []) == "Laptop could not be searched.")
|
|
#expect(SearchResponse.unsearchedNote(["Laptop", "LittleMan"], offline: ["Laptop"])
|
|
== "Laptop and LittleMan could not be searched.")
|
|
#expect(SearchResponse.unsearchedNote(["A", "B", "C"], offline: []) == "A, B and C could not be searched.")
|
|
}
|
|
|
|
@Test func complicationMode() {
|
|
let next = Summary.Next(hostId: "h", threadId: "t", title: "Receipt OCR", hostLabel: "Mac mini", request: "bun test")
|
|
let current = Summary.Current(hostId: "h", threadId: "w", title: "Phase 3", activity: nil)
|
|
#expect(ComplicationMode(Summary(needsYou: 2, working: 1, next: next, current: current, hostsOffline: 0)) == .waiting(next))
|
|
#expect(ComplicationMode(Summary(needsYou: 0, working: 1, next: nil, current: current, hostsOffline: 0)) == .working(current))
|
|
#expect(ComplicationMode(Summary(needsYou: 0, working: 0, next: nil, hostsOffline: 0)) == .idle)
|
|
// An older gateway names no working thread.
|
|
#expect(ComplicationMode(Summary(needsYou: 0, working: 3, next: nil, hostsOffline: 0)) == .idle)
|
|
}
|
|
|
|
@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.recent, .snoozed, .status, .host(id: "mac mini"), .notifications, .allThreads, .hostProjects(hostId: "mac mini"),
|
|
.projectThreads(hostId: "mac mini", projectId: "p/1"), .search(query: "bun & test?"),
|
|
] {
|
|
#expect(Route(url: route.url) == route)
|
|
}
|
|
#expect(Route(url: URL(string: "wristcode://thread/only-host")!) == nil)
|
|
// The gateway searches two characters or more.
|
|
#expect(Route(url: URL(string: "wristcode://search?q=a")!) == nil)
|
|
#expect(Route(url: URL(string: "wristcode://search?q=ab")!) == .search(query: "ab"))
|
|
#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")))
|
|
#expect(NotificationCommand(actionIdentifier: "settle", userInfo: info, text: nil) == .settle(hostId: "h", threadId: "t"))
|
|
#expect(NotificationCommand(actionIdentifier: "settle", userInfo: ["hostId": "h"], text: nil) == .open(nil))
|
|
// Answer opens the thread's question screen.
|
|
#expect(NotificationCommand(actionIdentifier: "answer", userInfo: info, text: nil) == .open(.thread(hostId: "h", threadId: "t")))
|
|
}
|
|
|
|
@Test func questionActionsBuildAnswers() throws {
|
|
let info: [AnyHashable: Any] = [
|
|
"hostId": "h", "threadId": "t", "requestId": "r",
|
|
"question": [
|
|
"id": "base", "allowCustomAnswer": true,
|
|
"options": [["label": "bun:1-alpine", "value": "alpine"], ["label": "bun:1-debian", "value": "debian"]],
|
|
],
|
|
]
|
|
func body(_ action: String, _ text: String? = nil) throws -> [String: Any] {
|
|
let response = try #require(NotificationCommand(actionIdentifier: action, userInfo: info, text: text).response)
|
|
#expect(response.hostId == "h" && response.threadId == "t")
|
|
return try encodeObject(response.request)
|
|
}
|
|
let picked = try body("option.1")
|
|
#expect(picked["requestId"] as? String == "r")
|
|
#expect(picked["answers"] as? [String: String] == ["base": "debian"])
|
|
#expect(try body("say", " the slim one ")["answers"] as? [String: String] == ["base": "the slim one"])
|
|
|
|
// An option the push does not have, an empty answer, or no question opens the thread instead.
|
|
let thread = NotificationCommand.open(.thread(hostId: "h", threadId: "t"))
|
|
#expect(NotificationCommand(actionIdentifier: "option.4", userInfo: info, text: nil) == thread)
|
|
#expect(NotificationCommand(actionIdentifier: "say", userInfo: info, text: " ") == thread)
|
|
#expect(NotificationCommand(actionIdentifier: "option.0", userInfo: ["hostId": "h", "threadId": "t", "requestId": "r"], text: nil) == thread)
|
|
}
|
|
|
|
@MainActor @Test func finishedNotificationActions() throws {
|
|
let finished = try #require(NotificationHandler.categories.first { $0.identifier == PushCategory.finished.rawValue })
|
|
#expect(finished.actions.map(\.title) == ["Reply", "Settle", "Open"])
|
|
// Settle runs in the background, without opening the app.
|
|
#expect(!finished.actions[1].options.contains(.foreground))
|
|
}
|
|
|
|
@MainActor @Test func cancelledCallsShowNoError() {
|
|
let model = AppModel(mode: .demo)
|
|
#expect(model.message(for: CancellationError()) == nil)
|
|
#expect(model.message(for: URLError(.cancelled)) == nil)
|
|
#expect(GatewayClient.transportError(URLError(.cancelled)) is CancellationError)
|
|
#expect(model.message(for: GatewayClient.transportError(URLError(.cancelled))) == nil)
|
|
// Other transport failures still show.
|
|
let offline = GatewayClient.transportError(URLError(.notConnectedToInternet))
|
|
#expect(offline as? GatewayError == .transport(URLError(.notConnectedToInternet).localizedDescription))
|
|
#expect(model.message(for: offline) != nil)
|
|
}
|
|
|
|
@Test func fileEditLines() throws {
|
|
func request(_ files: [PendingFile], kind: String = "file-change") -> PendingRequest {
|
|
PendingRequest(id: "r", kind: kind, prompt: "Edit: a", reason: nil, options: [], questions: [], files: files)
|
|
}
|
|
let compose = PendingFile(
|
|
path: "deploy/compose.yml", name: "compose.yml", additions: 9, deletions: 6,
|
|
preview: ["- image: bun:1", "+ image: bun:1-alpine", " context"])
|
|
let health = PendingFile(path: "src/health.ts", name: "health.ts", additions: nil, deletions: nil, preview: ["+x"])
|
|
|
|
let one = try #require(request([compose]).fileEdit)
|
|
#expect(one.title == "Wants to edit")
|
|
#expect(one.sentence == "Wants to edit compose.yml")
|
|
#expect(one.preview.map(\.change) == [.removed, .added, .context])
|
|
#expect(compose.counts.additions == "+9" && compose.counts.deletions == "\u{2212}6")
|
|
#expect(health.counts.additions == nil && health.counts.deletions == nil)
|
|
|
|
let two = try #require(request([compose, health]).fileEdit)
|
|
#expect(two.title == "Wants to edit 2 files")
|
|
#expect(two.sentence == "Wants to edit 2 files: compose.yml, health.ts")
|
|
// Several files show no preview.
|
|
#expect(two.preview.isEmpty)
|
|
|
|
// No files, or another kind, keeps the prompt.
|
|
#expect(request([]).fileEdit == nil)
|
|
#expect(request([compose], kind: "command").fileEdit == nil)
|
|
}
|
|
|
|
@MainActor @Test func questionNotificationActions() throws {
|
|
let question = PushQuestion(
|
|
id: "base", options: [.init(label: "bun:1-alpine", value: "alpine"), .init(label: "bun:1-debian", value: "debian")],
|
|
allowCustomAnswer: true)
|
|
let actions = NotificationHandler.inputActions(question)
|
|
#expect(actions.map(\.title) == ["bun:1-alpine", "bun:1-debian", "Say an answer", "Open"])
|
|
#expect(actions.map(\.identifier) == ["option.0", "option.1", "say", "open"])
|
|
#expect(actions[2] is UNTextInputNotificationAction)
|
|
var closed = question
|
|
closed.allowCustomAnswer = false
|
|
#expect(NotificationHandler.inputActions(closed).map(\.title) == ["bun:1-alpine", "bun:1-debian", "Open"])
|
|
#expect(NotificationHandler.inputActions(nil).map(\.title) == ["Answer", "Open"])
|
|
}
|
|
}
|
|
|
|
/// 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 && $0.via == 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 subagentApprovalGoesToTheSubagent() async throws {
|
|
let gateway = DemoGateway()
|
|
let item = try #require(try await gateway.feed().needsYou.first { $0.via != nil })
|
|
let via = try #require(item.via)
|
|
let decision = RespondRequest.decision(requestId: item.request!.id, decision: .accept)
|
|
// The parent has no request of its own to answer.
|
|
await #expect(throws: GatewayError.http(status: 409, message: "Already answered.")) {
|
|
try await gateway.respond(hostId: item.hostId, threadId: item.threadId, request: decision)
|
|
}
|
|
try await gateway.respond(hostId: via.hostId, threadId: via.threadId, request: decision)
|
|
let after = try await gateway.feed()
|
|
#expect(!after.needsYou.contains { $0.id == item.id })
|
|
#expect(after.working.first { $0.id == item.id }?.via == nil)
|
|
}
|
|
|
|
@Test func settleLeavesTheFeed() async throws {
|
|
let gateway = DemoGateway()
|
|
let item = try #require(try await gateway.feed().recent.first)
|
|
try await gateway.settle(hostId: item.hostId, threadId: item.threadId)
|
|
#expect(!(try await gateway.feed().recent.contains { $0.id == item.id }))
|
|
// A reply brings it back.
|
|
try await gateway.sendMessage(hostId: item.hostId, threadId: item.threadId, text: "One more thing")
|
|
#expect(try await gateway.feed().working.contains { $0.id == item.id })
|
|
}
|
|
|
|
@Test func feedShowsTenRecentAndCountsTheRest() async throws {
|
|
let gateway = DemoGateway()
|
|
let feed = try await gateway.feed()
|
|
let all = try await gateway.recent().items
|
|
#expect(feed.recent.count == 10)
|
|
#expect(feed.recentTotal == all.count)
|
|
#expect(feed.moreRecent == all.count - 10)
|
|
#expect(feed.recent == Array(all.prefix(10)))
|
|
}
|
|
|
|
@Test func settleRecentLeavesFailedThreads() async throws {
|
|
let gateway = DemoGateway()
|
|
let all = try await gateway.recent().items
|
|
let result = try await gateway.settleRecent()
|
|
#expect(result == SettleRecentResponse(settled: all.filter { $0.phase == .completed }.count, failed: 0))
|
|
let left = try await gateway.recent().items
|
|
#expect(!left.isEmpty && left.allSatisfy { $0.phase == .failed })
|
|
#expect(try await gateway.feed().moreRecent == 0)
|
|
}
|
|
|
|
@Test func subagentsOpenButStayOutOfTheFeed() async throws {
|
|
let gateway = DemoGateway()
|
|
let feed = try await gateway.feed()
|
|
let parent = try #require(feed.working.first { $0.threadId == "t-container" })
|
|
let detail = try await gateway.thread(hostId: parent.hostId, threadId: parent.threadId)
|
|
#expect(!detail.subagents.isEmpty)
|
|
let listed = feed.needsYou + feed.working + feed.recent
|
|
for row in detail.subagents {
|
|
#expect(!listed.contains { $0.threadId == row.threadId })
|
|
#expect(try await gateway.thread(hostId: row.hostId, threadId: row.threadId).item.title == row.title)
|
|
}
|
|
}
|
|
|
|
@Test func searchSkipsOfflineHosts() async throws {
|
|
let gateway = DemoGateway()
|
|
let response = try await gateway.search("healthcheck")
|
|
#expect(response.hits.map(\.thread.threadId).sorted() == ["t-container", "t-health"])
|
|
#expect(response.hits.first { $0.thread.threadId == "t-container" }?.snippet?.contains("healthcheck") == true)
|
|
#expect(response.hits.first { $0.thread.threadId == "t-health" }?.snippet == nil)
|
|
#expect(response.unsearched == ["LittleMan"])
|
|
// Bump Next.js is on LittleMan, which is offline.
|
|
#expect(try await gateway.search("Next.js").hits.isEmpty)
|
|
}
|
|
|
|
@Test func approvalForTheSmartStack() async throws {
|
|
let gateway = DemoGateway()
|
|
// The demo's longest wait is a question, which gets no buttons.
|
|
#expect(await WaitingEntry.Approval.load(for: try await gateway.summary(), api: gateway) == nil)
|
|
let summary = Summary(
|
|
needsYou: 1, working: 0, next: .init(hostId: "mac-mini", threadId: "t-ocr", title: "Receipt OCR", hostLabel: "Mac mini"),
|
|
hostsOffline: 0)
|
|
let approval = try #require(await WaitingEntry.Approval.load(for: summary, api: gateway))
|
|
#expect(approval.requestId == "r-ocr")
|
|
#expect(approval.allow.decision == .accept && approval.deny.decision == .decline)
|
|
}
|
|
|
|
@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 })
|
|
}
|
|
}
|
|
|
|
@Suite struct SnoozeTests {
|
|
private var calendar: Calendar {
|
|
var calendar = Calendar(identifier: .gregorian)
|
|
calendar.timeZone = TimeZone(identifier: "America/Chicago")!
|
|
return calendar
|
|
}
|
|
|
|
private func date(_ day: Int, _ hour: Int, _ minute: Int = 0) -> Date {
|
|
calendar.date(from: DateComponents(year: 2026, month: 11, day: day, hour: hour, minute: minute))!
|
|
}
|
|
|
|
@Test func choicesOnAMondayAfternoon() {
|
|
// Monday, November 2, 2026 at 2 PM.
|
|
let choices = SnoozeChoice.choices(now: date(2, 14), calendar: calendar)
|
|
#expect(choices.map(\.title) == ["In 1 hour", "This evening · 6 PM", "Tomorrow · 9 AM", "Next week"])
|
|
#expect(choices.map(\.until) == [date(2, 15), date(2, 18), date(3, 9), date(9, 9)])
|
|
}
|
|
|
|
@Test func eveningLeavesFromFivePM() {
|
|
#expect(SnoozeChoice.choices(now: date(2, 16, 59), calendar: calendar).count == 4)
|
|
let late = SnoozeChoice.choices(now: date(2, 17, 30), calendar: calendar)
|
|
#expect(late.map(\.title) == ["In 1 hour", "Tomorrow · 9 AM", "Next week"])
|
|
}
|
|
|
|
@Test func nextWeekIsNextMonday() {
|
|
// Sunday the 8th: tomorrow is Monday. Saturday the 7th: two days on.
|
|
#expect(SnoozeChoice.choices(now: date(8, 10), calendar: calendar).last?.until == date(9, 9))
|
|
#expect(SnoozeChoice.choices(now: date(7, 10), calendar: calendar).last?.until == date(9, 9))
|
|
#expect(SnoozeChoice.choices(now: date(4, 10), calendar: calendar).last?.until == date(9, 9))
|
|
}
|
|
|
|
@Test func wakesWording() {
|
|
let now = date(2, 14)
|
|
#expect(Format.wakes(at: date(2, 18), now: now, calendar: calendar) == "Wakes at 6 PM")
|
|
#expect(Format.wakes(at: date(2, 15, 30), now: now, calendar: calendar) == "Wakes at 3:30 PM")
|
|
#expect(Format.wakes(at: date(3, 9), now: now, calendar: calendar) == "Wakes tomorrow 9 AM")
|
|
#expect(Format.wakes(at: date(6, 9), now: now, calendar: calendar) == "Wakes Friday 9 AM")
|
|
#expect(Format.wakes(at: date(30, 9), now: now, calendar: calendar) == "Wakes Nov 30, 9 AM")
|
|
}
|
|
|
|
@Test func snoozeMovesAThreadFromRecentToSnoozed() async throws {
|
|
let gateway = DemoGateway()
|
|
let before = try await gateway.feed()
|
|
let item = try #require(before.recent.first)
|
|
try await gateway.snooze(hostId: item.hostId, threadId: item.threadId, until: .now.addingTimeInterval(3600))
|
|
let after = try await gateway.feed()
|
|
#expect(!after.recent.contains { $0.id == item.id })
|
|
#expect(after.snoozedTotal == before.snoozedTotal + 1)
|
|
#expect(try await gateway.snoozed().items.contains { $0.id == item.id && $0.snoozedUntil != nil })
|
|
|
|
try await gateway.unsnooze(hostId: item.hostId, threadId: item.threadId)
|
|
#expect(try await gateway.feed().recent.contains { $0.id == item.id })
|
|
}
|
|
}
|
|
|
|
@MainActor @Suite struct FeedCacheTests {
|
|
private func temporaryCache() -> FeedCache {
|
|
FeedCache(directory: FileManager.default.temporaryDirectory.appending(path: "FeedCacheTests-\(UUID().uuidString)"))
|
|
}
|
|
|
|
/// Fixtures from a whole second, so their dates survive JSON, which keeps milliseconds.
|
|
private let gateway = DemoGateway(now: Date(timeIntervalSince1970: 1_793_646_000))
|
|
|
|
@Test func roundTrip() async throws {
|
|
let cache = temporaryCache()
|
|
defer { cache.clear() }
|
|
#expect(cache.feed() == nil)
|
|
let feed = try await gateway.feed()
|
|
cache.save(feed)
|
|
#expect(cache.feed() == feed)
|
|
|
|
let detail = try await gateway.thread(hostId: "server-gib", threadId: "t-compose")
|
|
cache.save(detail)
|
|
#expect(cache.thread(hostId: "server-gib", threadId: "t-compose") == detail)
|
|
#expect(cache.thread(hostId: "server-gib", threadId: "t-other") == nil)
|
|
}
|
|
|
|
@Test func pairedModelStartsOnTheCacheAndSignOutClearsIt() async throws {
|
|
let cache = temporaryCache()
|
|
defer { cache.clear() }
|
|
let feed = try await gateway.feed()
|
|
cache.save(feed)
|
|
let credentials = Credentials(gatewayURL: URL(string: "https://gateway.invalid")!, token: "token")
|
|
let model = AppModel(mode: .paired(credentials), cache: cache)
|
|
#expect(model.feed == feed)
|
|
#expect(model.feedIsCached)
|
|
|
|
model.signOut()
|
|
// The clear runs off the main actor, after any write still queued.
|
|
await model.cacheWrite?.value
|
|
#expect(cache.feed() == nil)
|
|
#expect(model.feed == nil)
|
|
}
|
|
|
|
@Test func demoModeKeepsOffTheCache() async throws {
|
|
let cache = temporaryCache()
|
|
defer { cache.clear() }
|
|
cache.save(try await DemoGateway().feed())
|
|
let model = AppModel(mode: .demo, cache: cache)
|
|
#expect(model.feed == nil)
|
|
#expect(!model.feedIsCached)
|
|
#expect(model.cachedThread(hostId: "server-gib", threadId: "t-compose") == nil)
|
|
}
|
|
}
|