Watch: snooze finished threads, name the files a file approval would edit, Settle on finished notifications, an instant launch from the saved feed, plain Loading states, and no more cancelled errors
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 22s

Gateway: an approval push for a file change says which files it would edit
This commit is contained in:
gib committed 2026-11-02 14:45:34 -05:00
1 parent 4dc4e57dac
commit edc4b88990
35 files changed
+1162 -144

No files matched your search

+196 -1
View File
@@ -289,6 +289,50 @@ private let feedItemJSON = """
#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",
@@ -549,7 +593,7 @@ private let feedItemJSON = """
#expect(Route(url: thread.url) == thread)
#expect(Route(url: Route.newThread.url) == .newThread)
for route in [
Route.recent, .status, .host(id: "mac mini"), .notifications, .allThreads, .hostProjects(hostId: "mac mini"),
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)
@@ -576,6 +620,8 @@ private let feedItemJSON = """
// 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")))
}
@@ -605,6 +651,52 @@ private let feedItemJSON = """
#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")],
@@ -827,3 +919,106 @@ private let feedItemJSON = """
#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()
#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)
}
}