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.
217 lines
9.3 KiB
Swift
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)
|
|
}
|
|
}
|