Files
oai-swift/oAITests/AppleMailServiceTests.swift
rune 1dc485badb Fall back to System Settings when the Mail consent prompt is the known beta bug
Confirmed via multiple live rounds with Rune this is a macOS 27 beta issue,
not a Confab bug — matches an already-documented pattern in this project
(Calendar/Contacts requestAccess failing identically). Every failure
returns in single-digit milliseconds, ruled out threading, wrong API,
build/signing, and notarization; even a fresh notarized build fails
identically, while Terminal->Mail (first-party) works with no prompt at
all, and the same-style bug already has a drafted Apple Feedback report
for a different permission category.

Kept the real Apple Event attempt as the primary path (the way this
should work once the OS bug is fixed), but now time it: a failure faster
than any human could plausibly answer a real dialog (default 300ms) is
classified as the platform bug rather than a genuine denial, and the UI
falls back to opening System Settings' Automation pane directly with an
explanatory note, instead of the button silently doing nothing.
2026-08-19 14:55:24 +02:00

217 lines
9.3 KiB
Swift

//
// AppleMailServiceTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import Confab
@Suite("AppleMailService descriptor decoding and formatting")
struct AppleMailServiceTests {
// MARK: - decodeStringList
private func makeListDescriptor(_ items: [String]) -> NSAppleEventDescriptor {
let list = NSAppleEventDescriptor.list()
for (index, item) in items.enumerated() {
list.insert(NSAppleEventDescriptor(string: item), at: index + 1)
}
return list
}
@Test("decodeStringList decodes a flat list of strings")
func decodesFlatList() {
let descriptor = makeListDescriptor(["a", "b", "c"])
#expect(AppleMailService.decodeStringList(descriptor) == ["a", "b", "c"])
}
@Test("decodeStringList returns empty array for an empty list")
func decodesEmptyList() {
let descriptor = makeListDescriptor([])
#expect(AppleMailService.decodeStringList(descriptor) == [])
}
@Test("decodeStringList falls back to a single-element array for a bare string descriptor")
func decodesBareString() {
let descriptor = NSAppleEventDescriptor(string: "hello")
#expect(AppleMailService.decodeStringList(descriptor) == ["hello"])
}
// MARK: - parseSearchResults
@Test("parseSearchResults decodes a list of 7-field message records")
func parsesSearchResults() {
let record1 = makeListDescriptor(["msg-1", "Receipt from Elkjøp", "orders@elkjop.no", "Monday, 1 January 2026", "INBOX", "Personal", "1"])
let record2 = makeListDescriptor(["msg-2", "Another subject", "someone@example.com", "Tuesday, 2 January 2026", "INBOX", "Work", "0"])
let outer = NSAppleEventDescriptor.list()
outer.insert(record1, at: 1)
outer.insert(record2, at: 2)
let results = AppleMailService.parseSearchResults(from: outer)
#expect(results.count == 2)
#expect(results[0].messageId == "msg-1")
#expect(results[0].subject == "Receipt from Elkjøp")
#expect(results[0].from == "orders@elkjop.no")
#expect(results[0].mailbox == "INBOX")
#expect(results[0].account == "Personal")
#expect(results[0].attachmentCount == "1")
#expect(results[1].messageId == "msg-2")
#expect(results[1].attachmentCount == "0")
}
@Test("parseSearchResults returns empty array for an empty outer list")
func parsesEmptySearchResults() {
let outer = NSAppleEventDescriptor.list()
#expect(AppleMailService.parseSearchResults(from: outer) == [])
}
@Test("parseSearchResults skips malformed records with too few fields")
func skipsMalformedRecords() {
let goodRecord = makeListDescriptor(["msg-1", "Subject", "from@example.com", "date", "INBOX", "Account", "0"])
let badRecord = makeListDescriptor(["only", "three", "fields"])
let outer = NSAppleEventDescriptor.list()
outer.insert(badRecord, at: 1)
outer.insert(goodRecord, at: 2)
let results = AppleMailService.parseSearchResults(from: outer)
#expect(results.count == 1)
#expect(results[0].messageId == "msg-1")
}
// MARK: - mapAppleScriptError
@Test("mapAppleScriptError maps -1743 to an Automation-permission message")
func mapsNotAuthorizedError() {
let message = AppleMailService.mapAppleScriptError(number: -1743, message: "Not authorized")
#expect(message.contains("not authorized"))
#expect(message.contains("Automation"))
}
@Test("mapAppleScriptError maps -600 to a Mail-not-available message")
func mapsApplicationNotRunningError() {
let message = AppleMailService.mapAppleScriptError(number: -600, message: "Application isn't running")
#expect(message.contains("not available"))
}
@Test("mapAppleScriptError falls back to a generic formatted message for unknown error numbers")
func mapsGenericError() {
let message = AppleMailService.mapAppleScriptError(number: -1234, message: "Something odd happened")
#expect(message.contains("-1234"))
#expect(message.contains("Something odd happened"))
}
// MARK: - parseArgs
@Test("parseArgs decodes a JSON arguments string")
func parsesArgsJSON() {
let args = AppleMailService.parseArgs("{\"account\":\"work@example.com\",\"subject_contains\":\"Elkjøp\"}")
#expect(args["account"] as? String == "work@example.com")
#expect(args["subject_contains"] as? String == "Elkjøp")
}
@Test("parseArgs returns empty dictionary for invalid JSON")
func parsesInvalidArgsJSON() {
let args = AppleMailService.parseArgs("not json")
#expect(args.isEmpty)
}
// MARK: - hasSearchCriteria
@Test("hasSearchCriteria is false when every criterion is nil or empty")
func rejectsUnscopedSearch() {
#expect(AppleMailService.hasSearchCriteria(subjectContains: nil, fromContains: "", bodyContains: nil, since: nil, before: "") == false)
}
@Test("hasSearchCriteria is true when any single criterion is non-empty")
func acceptsScopedSearch() {
#expect(AppleMailService.hasSearchCriteria(subjectContains: "Elkjøp", fromContains: nil, bodyContains: nil, since: nil, before: nil) == true)
#expect(AppleMailService.hasSearchCriteria(subjectContains: nil, fromContains: nil, bodyContains: nil, since: "2026-01-01", before: nil) == true)
}
// MARK: - approvalSummary
@Test("approvalSummary describes mail_list_accounts")
func summarizesListAccounts() {
let summary = AppleMailService.shared.approvalSummary(forTool: "mail_list_accounts", arguments: "{}")
#expect(summary == "List your Mail.app accounts")
}
@Test("approvalSummary includes account and subject scope for mail_search")
func summarizesSearch() {
let summary = AppleMailService.shared.approvalSummary(
forTool: "mail_search",
arguments: "{\"account\":\"work@example.com\",\"subject_contains\":\"Elkjøp\"}"
)
#expect(summary.contains("work@example.com"))
#expect(summary.contains("Elkjøp"))
}
@Test("approvalSummary for mail_search with no scope still returns a sensible label")
func summarizesUnscopedSearch() {
let summary = AppleMailService.shared.approvalSummary(forTool: "mail_search", arguments: "{}")
#expect(summary == "Search Mail")
}
@Test("approvalSummary describes mail_save_attachment with name and destination")
func summarizesSaveAttachment() {
let summary = AppleMailService.shared.approvalSummary(
forTool: "mail_save_attachment",
arguments: "{\"attachment_name\":\"receipt.pdf\",\"dest_path\":\"/tmp/receipt.pdf\"}"
)
#expect(summary.contains("receipt.pdf"))
#expect(summary.contains("/tmp/receipt.pdf"))
}
@Test("approvalSummary falls back to a generic label for an unknown tool name")
func summarizesUnknownTool() {
let summary = AppleMailService.shared.approvalSummary(forTool: "mail_bogus", arguments: "{}")
#expect(summary == "Perform action: mail_bogus")
}
// MARK: - mapAutomationPermissionStatus
@Test("mapAutomationPermissionStatus maps noErr (0) to granted")
func mapsGrantedStatus() {
#expect(AppleMailService.mapAutomationPermissionStatus(0) == .granted)
}
@Test("mapAutomationPermissionStatus maps -1743 to denied")
func mapsDeniedStatus() {
#expect(AppleMailService.mapAutomationPermissionStatus(-1743) == .denied)
}
@Test("mapAutomationPermissionStatus maps -1744 (would require consent) and other codes to notDetermined")
func mapsNotDeterminedStatus() {
#expect(AppleMailService.mapAutomationPermissionStatus(-1744) == .notDetermined)
#expect(AppleMailService.mapAutomationPermissionStatus(-9999) == .notDetermined)
}
// MARK: - classifyRequestOutcome
@Test("classifyRequestOutcome maps success to granted regardless of elapsed time")
func classifiesSuccessAsGranted() {
#expect(AppleMailService.classifyRequestOutcome(succeeded: true, elapsedSeconds: 0.001) == .granted)
#expect(AppleMailService.classifyRequestOutcome(succeeded: true, elapsedSeconds: 5.0) == .granted)
}
@Test("classifyRequestOutcome treats an implausibly fast failure as the suspected platform bug")
func classifiesFastFailureAsSuspectedBug() {
#expect(AppleMailService.classifyRequestOutcome(succeeded: false, elapsedSeconds: 0.009) == .suspectedPlatformBug)
#expect(AppleMailService.classifyRequestOutcome(succeeded: false, elapsedSeconds: 0.0) == .suspectedPlatformBug)
}
@Test("classifyRequestOutcome treats a slower failure as a real denial")
func classifiesSlowFailureAsDenied() {
#expect(AppleMailService.classifyRequestOutcome(succeeded: false, elapsedSeconds: 1.5) == .denied)
}
@Test("classifyRequestOutcome threshold is right at the boundary")
func classifiesThresholdBoundary() {
#expect(AppleMailService.classifyRequestOutcome(succeeded: false, elapsedSeconds: AppleMailService.suspectedBugThreseholdSeconds) == .denied)
#expect(AppleMailService.classifyRequestOutcome(succeeded: false, elapsedSeconds: AppleMailService.suspectedBugThreseholdSeconds - 0.001) == .suspectedPlatformBug)
}
}