Files
hapi/ios/Packages/HapiKit/Tests/HapiClientTests/EndpointRequestTests.swift
T
weishu 05d063cc33 feat(ios): composer attachments + dictation (A-M3f)
Attachments (Android B-M3f semantics ported verbatim):
- HapiClient/Attachments/AttachmentPolicy — pure plan matrix (>4 MB
  recompressible image -> 2048 px JPEG q85 with .jpg rename, 50 MB hard
  reject, 192 MB image read cap, 512 px q80 previewUrl data-URL thumbs,
  data-URL parse/round-trip).
- HapiClient/Attachments/ComposerAttachments — upload-on-pick tray over an
  AttachmentUploading seam (APIClient conforms): uploading/ready/failed
  chips, retained payload for retry, remove -> best-effort delete,
  mid-upload removal deletes the orphan on completion, consume() ->
  AttachmentMetadata with JPEG data-URL previewUrl, discardAllDetached +
  deinit orphan cleanup (Android onCleared analogue).
- ChatInteractor: tray ownership, unsettled chips refuse the send with a
  notice, attachments-only sends post empty text, optimistic rows carry
  the metadata, appendDictatedText/postNotice/discardAttachments.
- App: AttachmentPreparer (capped security-scoped reads, ImageIO
  downscale/encode with EXIF transform, HEIC-undecodable fallback),
  PhotosPicker multi (videos via FileRepresentation temp files),
  UIImagePickerController camera capture, fileImporter; composer chip row
  (thumb/spinner/tap-to-retry/remove) with attachment-aware send gating;
  user bubbles upgrade chips to off-main-decoded previewUrl thumbnails
  (web-sent attachments included).

Dictation (Android B-M3ce port):
- HapiProtocol/Models/VoiceApi — TranscriptionResponse,
  TranscriptionProvidersResponse, TranscriptionProviderInfo.
- HapiClient/Endpoints/VoiceEndpoints — GET
  /api/voice/transcription/providers + multipart POST
  /api/voice/transcription (file/provider/mode/language, Android part
  order) over MultipartFormData; DictationTranscribing conformance.
- HapiClient/Voice/DictationController — idle/starting/recording/
  transcribing, transcribed/noProvider/error events, provider memoized
  (first standard-capable entry), appendTranscript port.
- App: AVAudioRecorderDictation (m4a/AAC mono 44.1 kHz 96 kbps, session
  activate/deactivate), mic button + recording chip (elapsed + cancel),
  record-permission request via AVAudioApplication.

Info.plist gains NSMicrophoneUsageDescription; the camera string now
covers attachment capture (modern PhotosPicker needs no photo-library
permission). Tests: policy matrix, tray over the real client with exact
base64 upload bodies + gated in-flight scenarios, dictation controller
suite with fake recorder/transport, voice endpoint request shapes, and
the interactor attachment-send matrix transcribed from the Android VM
tests (wire bodies byte-for-byte).
2026-08-18 11:27:28 +08:00

239 lines
10 KiB
Swift

import Foundation
import HapiClient
import HapiProtocol
import Testing
private let emptyPageJSON = """
{"messages":[],"page":{"direction":"before","limit":200,"epoch":0,"reset":false,\
"nextBeforeSeq":null,"nextBeforeAt":null,"nextAfterSeq":null,"nextAfterAt":null,\
"snapshotHeadSeq":null,"snapshotHeadAt":null,"hasMore":false}}
"""
@Suite("Endpoint request construction")
struct EndpointRequestTests {
private func bodyString(_ request: URLRequest?) -> String? {
request?.httpBody.flatMap { String(data: $0, encoding: .utf8) }
}
@Test func messagesPageWithBeforeCursor() async throws {
let token = freshJWT()
let harness = try makeHarness(jwt: token)
await harness.performer.enqueue(json: emptyPageJSON)
let response = try await harness.client.messages(
sessionId: "s 1",
query: .before(seq: 42, at: 170, limit: 200)
)
#expect(response.messages.isEmpty)
#expect(response.page.direction == .before)
let request = await harness.performer.requests.first
#expect(
request?.url?.absoluteString
== "\(testHubURLString)/api/sessions/s%201/messages?beforeAt=170&beforeSeq=42&limit=200"
)
#expect(request?.httpMethod == "GET")
#expect(request?.value(forHTTPHeaderField: "Authorization") == "Bearer \(token)")
}
@Test func messagesPageWithAfterCursorAndEpoch() async throws {
let harness = try makeHarness(jwt: freshJWT())
await harness.performer.enqueue(json: emptyPageJSON)
_ = try await harness.client.messages(
sessionId: "abc",
query: .after(seq: 10, at: 99, epoch: 3)
)
let request = await harness.performer.requests.first
#expect(
request?.url?.absoluteString
== "\(testHubURLString)/api/sessions/abc/messages?afterAt=99&afterSeq=10&epoch=3"
)
}
@Test func sendMessageWithDeliveryMode() async throws {
let harness = try makeHarness(jwt: freshJWT())
await harness.performer.enqueue(json: "{\"ok\":true}")
try await harness.client.sendMessage(
sessionId: "abc",
text: "hi",
localId: "L1",
deliveryMode: .steer
)
let request = await harness.performer.requests.first
#expect(request?.url?.absoluteString == "\(testHubURLString)/api/sessions/abc/messages")
#expect(request?.httpMethod == "POST")
#expect(request?.value(forHTTPHeaderField: "Content-Type") == "application/json")
#expect(bodyString(request) == "{\"deliveryMode\":\"steer\",\"localId\":\"L1\",\"text\":\"hi\"}")
}
@Test func approveWithNestedAnswers() async throws {
let harness = try makeHarness(jwt: freshJWT())
await harness.performer.enqueue(json: "{\"ok\":true}")
try await harness.client.approvePermission(
sessionId: "abc",
requestId: "r/1",
PermissionApproveRequest(answers: ["q1": ["answers": ["a", "b"]]])
)
let request = await harness.performer.requests.first
#expect(
request?.url?.absoluteString
== "\(testHubURLString)/api/sessions/abc/permissions/r%2F1/approve"
)
#expect(bodyString(request) == "{\"answers\":{\"q1\":{\"answers\":[\"a\",\"b\"]}}}")
}
@Test func approveWithFlatAnswersAndModeSwitch() async throws {
let harness = try makeHarness(jwt: freshJWT())
await harness.performer.enqueue(json: "{\"ok\":true}")
try await harness.client.approvePermission(
sessionId: "abc",
requestId: "r1",
PermissionApproveRequest(mode: .acceptEdits, answers: ["q1": ["a", "b"]])
)
let request = await harness.performer.requests.first
#expect(
bodyString(request)
== "{\"answers\":{\"q1\":[\"a\",\"b\"]},\"mode\":\"acceptEdits\"}"
)
}
@Test func setModelEncodesExplicitNull() async throws {
let harness = try makeHarness(jwt: freshJWT())
await harness.performer.enqueue(json: "{\"ok\":true}")
try await harness.client.setModel(sessionId: "abc", model: nil)
let first = await harness.performer.requests.first
#expect(bodyString(first) == "{\"model\":null}")
await harness.performer.enqueue(json: "{\"ok\":true}")
try await harness.client.setModel(
sessionId: "abc",
model: .catalogReference(provider: "openai", modelId: "gpt-5")
)
let second = await harness.performer.requests.last
#expect(bodyString(second) == "{\"model\":{\"modelId\":\"gpt-5\",\"provider\":\"openai\"}}")
}
@Test func spawnDiscriminatesOnType() async throws {
let harness = try makeHarness(jwt: freshJWT())
await harness.performer.enqueue(json: "{\"type\":\"success\",\"sessionId\":\"new-id\"}")
let spawned = try await harness.client.spawnSession(
machineId: "m1",
SpawnRequest(directory: "/work/repo", agent: .claude, sessionType: .worktree, worktreeName: "wt")
)
#expect(spawned == .success(sessionId: "new-id"))
let request = await harness.performer.requests.first
#expect(request?.url?.absoluteString == "\(testHubURLString)/api/machines/m1/spawn")
#expect(
bodyString(request)
== "{\"agent\":\"claude\",\"directory\":\"/work/repo\",\"sessionType\":\"worktree\",\"worktreeName\":\"wt\"}"
)
await harness.performer.enqueue(json: "{\"type\":\"error\",\"message\":\"no runner\"}")
let failed = try await harness.client.spawnSession(machineId: "m1", SpawnRequest(directory: "/x"))
#expect(failed == .error(message: "no runner"))
}
@Test func machineCodexModelsRequestAndRpcTargetMissing() async throws {
let harness = try makeHarness(jwt: freshJWT())
await harness.performer.enqueue(
json: "{\"success\":true,\"models\":[{\"id\":\"gpt-5.2-codex\","
+ "\"displayName\":\"GPT-5.2 Codex\",\"isDefault\":true,"
+ "\"supportedReasoningEfforts\":[\"low\",\"medium\"],"
+ "\"serviceTiers\":[\"standard\",\"fast\"]}]}"
)
let response = try await harness.client.machineCodexModels(machineId: "m 1")
#expect(response.success)
#expect(response.models?.first?.id == "gpt-5.2-codex")
#expect(response.models?.first?.serviceTiers == ["standard", "fast"])
let request = await harness.performer.requests.first
#expect(
request?.url?.absoluteString
== "\(testHubURLString)/api/machines/m%201/codex-models"
)
#expect(request?.httpMethod == "GET")
// Old runner without the machine RPC: 503 with the stable code the
// new-session form keys off to hide the codex model picker.
await harness.performer.enqueue(
status: 503,
json: "{\"success\":false,\"code\":\"rpc_target_missing\"}"
)
let error = await capturedError {
try await harness.client.machineCodexModels(machineId: "m1")
}
#expect((error as? APIError)?.status == 503)
#expect((error as? APIError)?.code == "rpc_target_missing")
}
@Test func transcriptionProvidersRequestsTheVoiceCatalog() async throws {
let harness = try makeHarness(jwt: freshJWT())
await harness.performer.enqueue(
json: "{\"providers\":[{\"id\":\"openai\",\"label\":\"OpenAI\","
+ "\"modes\":[\"standard\",\"realtime\"]},"
+ "{\"id\":\"browser-local\",\"label\":\"Browser on-device\","
+ "\"modes\":[\"realtime\"]}]}"
)
let response = try await harness.client.transcriptionProviders()
#expect(response.providers.map(\.id) == ["openai", "browser-local"])
#expect(response.providers.first?.modes == ["standard", "realtime"])
#expect(response.providers.first?.label == "OpenAI")
let request = await harness.performer.requests.first
#expect(
request?.url?.absoluteString
== "\(testHubURLString)/api/voice/transcription/providers"
)
#expect(request?.httpMethod == "GET")
}
@Test func transcribeVoicePostsMultipartFormData() async throws {
let harness = try makeHarness(jwt: freshJWT())
await harness.performer.enqueue(json: "{\"text\":\"hello world\",\"language\":\"en\"}")
let result = try await harness.client.transcribeVoice(
audio: Data([1, 2, 3]),
filename: "clip.m4a",
mimeType: "audio/mp4",
provider: "openai",
language: "en-US"
)
#expect(result.text == "hello world")
#expect(result.language == "en")
let request = await harness.performer.requests.first
#expect(request?.url?.absoluteString == "\(testHubURLString)/api/voice/transcription")
#expect(request?.httpMethod == "POST")
let contentType = request?.value(forHTTPHeaderField: "Content-Type") ?? ""
#expect(contentType.hasPrefix("multipart/form-data; boundary="))
let raw = String(decoding: request?.httpBody ?? Data(), as: UTF8.self)
// Android-parity part order: file, provider, mode, language.
#expect(raw.contains(#"name="file"; filename="clip.m4a""#))
#expect(raw.contains("Content-Type: audio/mp4"))
#expect(raw.contains(#"name="provider""#) && raw.contains("openai"))
#expect(raw.contains(#"name="mode""#) && raw.contains("standard"))
#expect(raw.contains(#"name="language""#) && raw.contains("en-US"))
}
@Test func transcribeVoiceOmitsTheLanguagePartWhenNil() async throws {
let harness = try makeHarness(jwt: freshJWT())
await harness.performer.enqueue(json: "{\"text\":\"ok\"}")
_ = try await harness.client.transcribeVoice(
audio: Data([9]),
filename: "speech.m4a",
mimeType: "audio/mp4",
provider: "groq"
)
let request = await harness.performer.requests.first
let raw = String(decoding: request?.httpBody ?? Data(), as: UTF8.self)
#expect(!raw.contains(#"name="language""#))
#expect(raw.contains(#"name="mode""#) && raw.contains("standard"))
}
}