also looking at this
iOS Phase 9: calendar and contacts — ratchet 53 → 48
#91
6 files
+5708
-1
| @@ -0,0 +1,810 @@ | ||
| 1 | //! The Speech + AVFoundation actions of the `mobile` namespace — | |
| 2 | //! `startListening` and `stopListening` — **researched in full and | |
| 3 | //! deliberately not served**. Both fall through to the working Swift shim. | |
| 4 | //! | |
| 5 | //! This is the outcome rule 6 exists for, and it is the cheapest one in the | |
| 6 | //! migration: the shim answers both actions exactly as the app answers them | |
| 7 | //! today, and — unlike `scheduleNotification`, `registerBackgroundTask` and | |
| 8 | //! `sendToWatch` — **there is no promise to strand**, because neither action | |
| 9 | //! has a `callbackId` and neither ever replies. Falling through costs the page | |
| 10 | //! nothing at all. What is written below is the part a host can honestly | |
| 11 | //! finish and pin: the event vocabulary, the four error messages, and the two | |
| 12 | //! detail shapers. What is missing is named, not glossed. | |
| 13 | //! | |
| 14 | //! ## The contract, read rather than assumed | |
| 15 | //! | |
| 16 | //! **Payload: none, on both actions.** The injected JS is the whole surface | |
| 17 | //! (`CraftApp.swift:1562-1568`): | |
| 18 | //! | |
| 19 | //! ```js | |
| 20 | //! startListening: function() { | |
| 21 | //! window.webkit.messageHandlers.craft.postMessage({action: 'startListening'}); | |
| 22 | //! }, | |
| 23 | //! stopListening: function() { | |
| 24 | //! window.webkit.messageHandlers.craft.postMessage({action: 'stopListening'}); | |
| 25 | //! }, | |
| 26 | //! ``` | |
| 27 | //! | |
| 28 | //! No fields, no `callbackId`, no return value. `ios_dispatch.payloadOf` would | |
| 29 | //! hand a handler `"{}"`. There is no second page surface: the nested mobile | |
| 30 | //! contract block (`:2297-2427`) has no speech methods, and `craft-bridge.js` | |
| 31 | //! has no `mobile` namespace at all — its `window.craft.speech` is desktop TTS | |
| 32 | //! over `AVSpeechSynthesizer`, namespace `t = "speech"`, unrelated. So is | |
| 33 | //! `bridge_speech_recognition.zig`, which is the desktop `speechRecognition` | |
| 34 | //! namespace with actions `isAvailable`/`start`/`stop`; it must not be | |
| 35 | //! confused with this one. | |
| 36 | //! | |
| 37 | //! **Reply: none, on any path.** The dispatcher (`CraftApp.swift:533-536`) is | |
| 38 | //! | |
| 39 | //! ```swift | |
| 40 | //! case "startListening": | |
| 41 | //! if config.enableSpeechRecognition { startSpeechRecognition() } | |
| 42 | //! case "stopListening": | |
| 43 | //! stopSpeechRecognition() | |
| 44 | //! ``` | |
| 45 | //! | |
| 46 | //! and neither `startSpeechRecognition` (`:2486-2494`), `beginRecording` | |
| 47 | //! (`:2496-2548`) nor `stopSpeechRecognition` (`:2550-2559`) touches | |
| 48 | //! `resolveCallback`, `rejectCallback` or `resolveCallbackJSON` on any line. | |
| 49 | //! `craft.d.ts:188` and `:193` both declare `(): void`, and | |
| 50 | //! `test-bridges.html:785-795` awaits nothing. If this module ever serves | |
| 51 | //! these, the declaration is `.reply = .none` — the same reading | |
| 52 | //! `bridge_mobile_haptics.zig` made for `haptic`, and for the same reason. | |
| 53 | //! | |
| 54 | //! A denial does not reply either; it **emits**. Nor does the flag-off case | |
| 55 | //! emit anything at all: `if config.enableSpeechRecognition { … }` has no | |
| 56 | //! `else`, and `stopListening` is ungated and always runs. | |
| 57 | //! | |
| 58 | //! **Everything is events.** Four names, and the detail of each: | |
| 59 | //! | |
| 60 | //! | event | detail | when | | |
| 61 | //! |---|---|---| | |
| 62 | //! | `craftSpeechError` | `{"error":"Not authorized"}` | auth status is not `.authorized` (`:2489`) | | |
| 63 | //! | `craftSpeechError` | `{"error":"Audio session failed"}` | `setCategory`/`setActive` threw (`:2507`) | | |
| 64 | //! | `craftSpeechError` | `{"error":"Speech recognizer unavailable"}` | request nil, recognizer nil, or not `isAvailable` (`:2515`) | | |
| 65 | //! | `craftSpeechError` | `{"error":"Audio engine failed"}` | `audioEngine.start()` threw (`:2546`) | | |
| 66 | //! | `craftSpeechStart` | `{}` | after `audioEngine.start()` succeeded (`:2543`) | | |
| 67 | //! | `craftSpeechResult` | `{"transcript": String, "isFinal": Bool}` | every partial and every final result (`:2525-2528`) | | |
| 68 | //! | `craftSpeechEnd` | `{}` | every `stopSpeechRecognition()` (`:2557`) | | |
| 69 | //! | |
| 70 | //! The result detail is **exactly two keys**. No `confidence`, no | |
| 71 | //! `alternatives`, no `segments`. Three independent consumers agree — | |
| 72 | //! `test-bridges.html:384-387` reads `e.detail.transcript` and | |
| 73 | //! `e.detail.isFinal`, `packages/ios/README.md:167-176` documents the same | |
| 74 | //! pair, and Android emits the same four names with the same keys | |
| 75 | //! (`CraftBridge.kt.template:1344-1383`). Unlike motion, there is no | |
| 76 | //! doc/implementation mismatch here to report. | |
| 77 | //! | |
| 78 | //! `sendToWeb` (`:4835-4846`) builds | |
| 79 | //! `window.dispatchEvent(new CustomEvent('<name>', {detail: <json>}));` — the | |
| 80 | //! same form `ios_events.formatEvent` builds, so the detail bytes below are | |
| 81 | //! the bytes that reach `e.detail` on either path. | |
| 82 | //! | |
| 83 | //! ## Why nothing is served | |
| 84 | //! | |
| 85 | //! Four blockers. Only the last three are load-bearing; the first is one line | |
| 86 | //! of data that would unblock nothing on its own. | |
| 87 | //! | |
| 88 | //! **1. `craftSpeechStart` cannot be spelled.** `ios_events.Event` carries | |
| 89 | //! `.speech_result`, `.speech_error` and `.speech_end`, and no | |
| 90 | //! `.speech_start`. Serving `startListening` without it would drop the one | |
| 91 | //! event that drives the page's state — `test-bridges.html:380-383` paints | |
| 92 | //! "Listening..." from it and from nothing else — so a working session would | |
| 93 | //! look to the page exactly like a session that never started. Adding the | |
| 94 | //! enum member and its `switch` arm is data rather than mechanism, and the | |
| 95 | //! existing test `"every event name is one an iOS page actually subscribes | |
| 96 | //! to"` already accepts the shape (`startsWith "craft"`, no colon). It is not | |
| 97 | //! left undone because a second file is off limits — landing this module | |
| 98 | //! already edits `ios_dispatch.zig`, `build.zig` and | |
| 99 | //! `test/ios_conformance_test.zig`. It is left undone because adding it alone | |
| 100 | //! serves nothing: blocker 2 stops `startListening` by itself, and an `Event` | |
| 101 | //! member no code emits is vocabulary for an event that never fires. So it is | |
| 102 | //! precondition 1 below rather than a reason of its own — and a test trips | |
| 103 | //! when it lands. | |
| 104 | //! | |
| 105 | //! **2. The realtime tap has no precedent in this tree.** | |
| 106 | //! `-[AVAudioNode installTapOnBus:bufferSize:format:block:]` runs its block on | |
| 107 | //! the audio I/O thread, which is not a queue any existing module touches. | |
| 108 | //! `bridge_mobile_motion.zig`'s handler is an `NSOperationQueue.mainQueue` | |
| 109 | //! callback and is **not** a precedent for it. Swift's | |
| 110 | //! `self?.recognitionRequest?.append(buffer)` gets a free ARC retain for the | |
| 111 | //! duration of the call; Zig has none, so `stopListening` releasing the | |
| 112 | //! request while a tap call is in flight is a use-after-free on a realtime | |
| 113 | //! thread. It is solvable — an atomic pointer, never a mutex (priority | |
| 114 | //! inversion), plus stop-engine, remove-tap, clear-pointer, never-release | |
| 115 | //! ordering — but solving it is building a new foundation, and this round was | |
| 116 | //! asked to apply proven ones and to say so instead. | |
| 117 | //! | |
| 118 | //! **3. The guard the tap needs is asserted, not read.** With no active input | |
| 119 | //! route, `-outputFormatForBus:0` yields a format whose `sampleRate` and | |
| 120 | //! `channelCount` are 0, and `installTapOnBus:` then raises an | |
| 121 | //! `NSException` — which Zig cannot catch, so the process aborts. Swift has | |
| 122 | //! the identical crash at `:2535-2538`. The honest port pre-checks the format | |
| 123 | //! and refuses with `craftSpeechError`; but that behaviour comes from API | |
| 124 | //! knowledge, nothing in this repo documents it, and there is no simulator | |
| 125 | //! here to verify it. Shipping an unverified guard against an unreproducible | |
| 126 | //! abort is the one place rule 5 and rule 1 meet. | |
| 127 | //! | |
| 128 | //! **4. Nothing here can test the audio path.** Every host test that could be | |
| 129 | //! written for a served version would exercise selector interning and block | |
| 130 | //! flags, and nothing of the recognizer, the session, the engine or the tap. | |
| 131 | //! That is a served action whose entire risk surface is untested. | |
| 132 | //! | |
| 133 | //! The gate is *not* among the blockers, because it is solved: | |
| 134 | //! `config.enableSpeechRecognition` defaults to `false` | |
| 135 | //! (`CraftApp.swift:189`, `packages/ios/src/index.ts:117`) and reaches Zig | |
| 136 | //! through nothing, but `packages/ios/src/index.ts:183` writes | |
| 137 | //! `NSSpeechRecognitionUsageDescription` from that flag **and nothing else, | |
| 138 | //! with no `||`** — the exact-proxy shape `bridge_mobile_motion.zig` already | |
| 139 | //! reads. (`NSMicrophoneUsageDescription` at `:184` is | |
| 140 | //! `enableSpeechRecognition || enableAudioRecording` and is **not** an exact | |
| 141 | //! proxy; it must not be used as the gate.) Here the proxy is mandatory | |
| 142 | //! rather than an improvement: without those keys in Info.plist iOS | |
| 143 | //! *terminates the process* on the authorization request or on mic access. | |
| 144 | //! | |
| 145 | //! ## Why falling through, and not `.unavailable` | |
| 146 | //! | |
| 147 | //! `.unavailable` makes `ios_dispatch` route the action into this module and | |
| 148 | //! refuse it. For an action with a pending promise that is a nameable failure | |
| 149 | //! and an improvement on silence. Here there is no pending promise on any | |
| 150 | //! page surface, so the refusal would surface only as a console log — while | |
| 151 | //! taking away a shim path that works completely. That is strictly worse, and | |
| 152 | //! it is why `A` below is empty rather than carrying two `.unavailable` rows. | |
| 153 | //! | |
| 154 | //! Serving only `stopListening` is worse still, and is ruled out for the | |
| 155 | //! reason `bridge_mobile_motion.zig` gives: a session started through the shim | |
| 156 | //! would meet a Zig stop, which would stop Zig's own null engine and emit | |
| 157 | //! `craftSpeechEnd` while Swift's recognizer kept pushing `craftSpeechResult`. | |
| 158 | //! Fabricated success at the event layer. Either both or neither; neither. | |
| 159 | //! | |
| 160 | //! ## What is finished here | |
| 161 | //! | |
| 162 | //! The two shapers and the four messages — the half of the port that decides | |
| 163 | //! page-visible bytes and that a host can pin. This is the precedent | |
| 164 | //! `bridge_mobile_haptics.zig` set with `triggerHapticChecked`: the finished | |
| 165 | //! half of a blocked action lives next to the admission, tested, so that | |
| 166 | //! unblocking it is a small deliberate edit rather than a rewrite. | |
| 167 | //! | |
| 168 | //! `shapeResultDetail` is worth more than it looks. A transcript is arbitrary | |
| 169 | //! spoken text, the detail is inlined into JavaScript source as a literal, and | |
| 170 | //! `ios_events.zig`'s own module comment records what happens when a value | |
| 171 | //! like that is concatenated with hand-rolled escaping: `bridge_iap.zig:362` | |
| 172 | //! "works until a value contains a quote". Swift is safe here only because | |
| 173 | //! `JSONSerialization` escapes for it. Any Zig port must, and this one does. | |
| 174 | //! | |
| 175 | //! ## What a future round still owes | |
| 176 | //! | |
| 177 | //! Five preconditions, all five, before either action moves into `A`: | |
| 178 | //! | |
| 179 | //! 1. `.speech_start => "craftSpeechStart"` in `ios_events.Event`. | |
| 180 | //! 2. Refuse unless Info.plist carries `NSSpeechRecognitionUsageDescription` | |
| 181 | //! (`bridge_mobile_motion.zig:infoPlistHas` is the shape). | |
| 182 | //! 3. Validate `-sampleRate` and `-channelCount` before `installTapOnBus:`, | |
| 183 | //! and emit `craftSpeechError` rather than installing on a zero format. | |
| 184 | //! 4. Never release the recognition request while a tap call may be in | |
| 185 | //! flight — atomically clear the pointer after `removeTapOnBus:`, and | |
| 186 | //! prefer leaking one request per session over racing the audio thread. | |
| 187 | //! 5. Hop to the main queue with `dispatch_async_f(&_dispatch_main_q, …)` | |
| 188 | //! before touching `AVAudioEngine` from the recognition task handler; it | |
| 189 | //! fires on an internal Speech queue. (`ios_events.emit` needs no hop — | |
| 190 | //! it hops itself.) | |
| 191 | //! | |
| 192 | //! And two decisions that are decisions, not facts, to be taken out loud: | |
| 193 | //! | |
| 194 | //! - **The error-emission divergence.** On a recognition error Swift emits | |
| 195 | //! **no** `craftSpeechError` — `:2530-2532` only calls | |
| 196 | //! `stopSpeechRecognition()`, so the page gets `craftSpeechEnd` and the | |
| 197 | //! `NSError` is discarded entirely. Android *does* emit one | |
| 198 | //! (`CraftBridge.kt.template:1367`). Matching Swift makes a simulator | |
| 199 | //! failure look exactly like a stream that never fires; matching Android | |
| 200 | //! tells the truth but deviates from the emitter contract this migration | |
| 201 | //! preserves. `err_recognition_failed` below is deliberately absent: there | |
| 202 | //! is no Swift string to copy, so inventing one here would prejudge it. | |
| 203 | //! - **`beginRecording` never stops the engine or removes the old tap** | |
| 204 | //! (`:2497-2500` cancels only the task). A second `startListening` while | |
| 205 | //! running installs a second tap on bus 0, which raises on iOS. | |
| 206 | //! Reproducing that bug is not required; stopping first, as | |
| 207 | //! `bridge_mobile_motion.zig` chose to, is the defensible port. | |
| 208 | //! | |
| 209 | //! Also worth carrying: both `:2544` and `:2558` fire `triggerHaptic("light")` | |
| 210 | //! directly, bypassing the `config.enableHaptics` gate the dispatcher applies | |
| 211 | //! to `case "haptic"` at `:537`. Swift itself ignores the flag on this path, | |
| 212 | //! so firing it from a served port would be faithful rather than a | |
| 213 | //! divergence — but `triggerHapticChecked` in `bridge_mobile_haptics.zig` is | |
| 214 | //! not `pub`, so reuse needs a visibility change there. | |
| 215 | //! | |
| 216 | //! ## Nothing in this file touches Objective-C | |
| 217 | //! | |
| 218 | //! No `objc_getClass`, no `sel_registerName`, no block, no delegate, no | |
| 219 | //! `ios_async` ticket — there is no reply to correlate and no framework call | |
| 220 | //! to make. So there is nothing to Darwin-gate: the module compiles and its | |
| 221 | //! tests run identically on Linux and macOS, which is the one upside of | |
| 222 | //! serving nothing. | |
| 223 | ||
| 224 | const std = ; | |
| 225 | const capabilities = ; | |
| 226 | const bridge_error = ; | |
| 227 | const ios_events = ; | |
| 228 | ||
| 229 | /// **Empty on purpose.** No action name here means `ios_dispatch` never routes | |
| 230 | /// `startListening` or `stopListening` into this module, so both reach the | |
| 231 | /// Swift shim that serves them correctly today. See the module comment for the | |
| 232 | /// four blockers and the five preconditions. | |
| 233 | /// | |
| 234 | /// `declared_count` is not decoration. `test/ios_conformance_test.zig` scans | |
| 235 | /// this block line by line from `pub const A = struct {` to the first bare | |
| 236 | /// `};`, and `zig fmt` collapses an empty struct onto one line — after which | |
| 237 | /// the scan would never find its terminator and would collect every quoted | |
| 238 | /// string in this file as an action name. A non-string member keeps the block | |
| 239 | /// two-line-safe under `zig fmt --check`, which CI runs. | |
| 240 | pub const A = struct { | |
| 241 | /// How many actions this module claims. Zero, deliberately. | |
| 242 | pub const declared_count: usize = 0; | |
| 243 | }; | |
| 244 | ||
| 245 | /// Empty, and specifically not two `.unavailable` rows. | |
| 246 | /// | |
| 247 | /// `.unavailable` is for an action that dispatches and refuses — honest when a | |
| 248 | /// promise would otherwise hang, wrong here. Neither of these actions has a | |
| 249 | /// promise on any page surface, so a refusal would show up only in the | |
| 250 | /// console while removing a shim path that works end to end. Declaring | |
| 251 | /// nothing is what lets the shim keep answering. | |
| 252 | pub const capability_actions = [_]capabilities.ActionDecl{}; | |
| 253 | ||
| 254 | /// The two spec actions this module researched and left with the shim. | |
| 255 | /// | |
| 256 | /// Kept as data so the tests can prove both fall through rather than asserting | |
| 257 | /// it in prose, and so a future round has the exact spellings in one place. | |
| 258 | /// These names deliberately live outside the `A` block: inside it, the | |
| 259 | /// conformance scan would read them as served and the ratchet would drop for | |
| 260 | /// actions nothing here answers. | |
| 261 | pub const unserved_actions = [_][]const u8{ "startListening", "stopListening" }; | |
| 262 | ||
| 263 | pub const SpeechBridge = struct { | |
| 264 | /// Held but never used: `ios_dispatch` constructs every mobile bridge with | |
| 265 | /// `init(allocator)` and the shape is the interface. Nothing here | |
| 266 | /// allocates, because nothing here answers. | |
| 267 | allocator: std.mem.Allocator, | |
| 268 | ||
| 269 | const Self = @This(); | |
| 270 | ||
| 271 | pub fn init(allocator: std.mem.Allocator) Self { | |
| 272 | return .{ .allocator = allocator }; | |
| 273 | } | |
| 274 | ||
| 275 | pub fn deinit(_: *Self) void {} | |
| 276 | ||
| 277 | /// Always `UnknownAction` — "not mine, ask the next" — which is what makes | |
| 278 | /// the fall-through work. | |
| 279 | /// | |
| 280 | /// The payload is deliberately never parsed. `ios_dispatch.route` treats | |
| 281 | /// any error other than `UnknownAction` as final and stops the chain, so a | |
| 282 | /// module that parsed first and answered `InvalidJSON` on a malformed | |
| 283 | /// payload would strand an action it does not even serve. That is the | |
| 284 | /// failure this signature is shaped to make impossible, and a test below | |
| 285 | /// pins it with payloads that no parser would accept. | |
| 286 | pub fn handleMessage(_: *Self, action: []const u8, _: []const u8) !void { | |
| 287 | _ = action; | |
| 288 | return bridge_error.BridgeError.UnknownAction; | |
| 289 | } | |
| 290 | }; | |
| 291 | ||
| 292 | // ============================================================================= | |
| 293 | // The event vocabulary, as far as it can be spelled today. | |
| 294 | // ============================================================================= | |
| 295 | ||
| 296 | /// `craftSpeechResult` — the transcript stream. | |
| 297 | pub const event_result: ios_events.Event = .speech_result; | |
| 298 | ||
| 299 | /// `craftSpeechError` — every refusal, on every path. | |
| 300 | pub const event_error: ios_events.Event = .speech_error; | |
| 301 | ||
| 302 | /// `craftSpeechEnd` — every `stopSpeechRecognition()`, including one that | |
| 303 | /// stops nothing. | |
| 304 | pub const event_end: ios_events.Event = .speech_end; | |
| 305 | ||
| 306 | /// `craftSpeechStart`, which `ios_events.Event` cannot currently spell. | |
| 307 | /// | |
| 308 | /// A plain string, not an `Event`, because that is the whole point: there is | |
| 309 | /// no member to name. Pinned here so the eventual enum arm is copied from the | |
| 310 | /// contract rather than retyped from memory. | |
| 311 | pub const event_start_name = "craftSpeechStart"; | |
| 312 | ||
| 313 | /// The `data: [:]` of `craftSpeechStart` and `craftSpeechEnd` — an empty | |
| 314 | /// object, not an empty string and not `null`. `ios_events.formatEvent` | |
| 315 | /// inlines the detail as a literal, so `e.detail` must still be an object the | |
| 316 | /// page can index into. | |
| 317 | pub const empty_detail = "{}"; | |
| 318 | ||
| 319 | // ----------------------------------------------------------------------------- | |
| 320 | // The four `craftSpeechError` messages, verbatim from the Swift. | |
| 321 | // | |
| 322 | // Verbatim matters: `test-bridges.html:391-394` paints `e.detail.error` into | |
| 323 | // the page for a human to read, and `packages/ios/README.md:173-175` documents | |
| 324 | // the key as the thing to log. Nothing downstream matches on the text, which | |
| 325 | // is exactly why a paraphrase would change what a user reads without changing | |
| 326 | // anything a test could see. | |
| 327 | // ----------------------------------------------------------------------------- | |
| 328 | ||
| 329 | /// `CraftApp.swift:2489` — the authorization status was not `.authorized`. | |
| 330 | /// A user declining is this, not a native call failing. | |
| 331 | pub const err_not_authorized = "Not authorized"; | |
| 332 | ||
| 333 | /// `:2507` — `setCategory:mode:options:error:` or | |
| 334 | /// `setActive:withOptions:error:` failed. | |
| 335 | pub const err_audio_session = "Audio session failed"; | |
| 336 | ||
| 337 | /// `:2515` — the request is nil, the recognizer is nil (the | |
| 338 | /// `enableSpeechRecognition` flag was off, `:439-440`), or `-isAvailable` is | |
| 339 | /// NO. A simulator commonly answers NO here. | |
| 340 | pub const err_recognizer_unavailable = "Speech recognizer unavailable"; | |
| 341 | ||
| 342 | /// `:2546` — `-startAndReturnError:` returned NO. | |
| 343 | pub const err_audio_engine = "Audio engine failed"; | |
| 344 | ||
| 345 | /// Every message the port may emit, in Swift's source order. A future round | |
| 346 | /// adding a fifth must add it to Swift's contract first, or document the | |
| 347 | /// divergence — see the module comment on the recognition-error case. | |
| 348 | pub const error_messages = [_][]const u8{ | |
| 349 | err_not_authorized, | |
| 350 | err_audio_session, | |
| 351 | err_recognizer_unavailable, | |
| 352 | err_audio_engine, | |
| 353 | }; | |
| 354 | ||
| 355 | // ============================================================================= | |
| 356 | // The detail shapers. Pure, and the bytes are the contract. | |
| 357 | // ============================================================================= | |
| 358 | ||
| 359 | /// The `detail` of one `craftSpeechError`: `{"error":"<message>"}`. | |
| 360 | /// | |
| 361 | /// Escaped even though all four current messages are plain ASCII, because the | |
| 362 | /// escaping is a property of the position — a JSON string inlined into | |
| 363 | /// JavaScript source — and not of today's inputs. A fifth message quoting a | |
| 364 | /// framework error would otherwise be the bug. | |
| 365 | /// | |
| 366 | /// Caller owns the returned slice. | |
| 367 | pub fn shapeErrorDetail(allocator: std.mem.Allocator, message: []const u8) ![]u8 { | |
| 368 | var out: std.ArrayListUnmanaged(u8) = .empty; | |
| 369 | errdefer out.deinit(allocator); | |
| 370 | ||
| 371 | try out.appendSlice(allocator, "{\"error\":\""); | |
| 372 | try bridge_error.appendJsonEscaped(allocator, &out, message); | |
| 373 | try out.appendSlice(allocator, "\"}"); | |
| 374 | ||
| 375 | return out.toOwnedSlice(allocator); | |
| 376 | } | |
| 377 | ||
| 378 | /// The `detail` of one `craftSpeechResult`: | |
| 379 | /// `{"transcript":"<text>","isFinal":<bool>}`. | |
| 380 | /// | |
| 381 | /// Two keys, Swift's two (`CraftApp.swift:2525-2528`), and no third. Swift | |
| 382 | /// builds a `Dictionary`, so its own key order is arbitrary and no page can | |
| 383 | /// depend on it; one order is fixed here so the bytes are testable. | |
| 384 | /// | |
| 385 | /// `transcript` is escaped because it is arbitrary spoken text landing in a | |
| 386 | /// JavaScript source position. An unescaped `"` would not corrupt one field — | |
| 387 | /// it would end the object early and make the whole `dispatchEvent` call a | |
| 388 | /// syntax error, so the page would see the stream stop rather than see a bad | |
| 389 | /// transcript. That is the failure `ios_events.zig` records against | |
| 390 | /// `bridge_iap.zig:362`, and the reason Swift is safe here is only that | |
| 391 | /// `JSONSerialization` escapes on its behalf. | |
| 392 | /// | |
| 393 | /// `isFinal` is a real JSON boolean, never the string `"true"`: the page | |
| 394 | /// interpolates it into a template literal (`test-bridges.html:386`), where a | |
| 395 | /// quoted `"false"` would render as `false` and read as correct while being a | |
| 396 | /// truthy value to any code that branches on it. | |
| 397 | /// | |
| 398 | /// An empty transcript still emits the key. Swift always writes it — a first | |
| 399 | /// partial result can legitimately be empty — and omitting it would make | |
| 400 | /// `e.detail.transcript` `undefined`, which a page cannot tell from a broken | |
| 401 | /// bridge. | |
| 402 | /// | |
| 403 | /// Caller owns the returned slice. | |
| 404 | pub fn shapeResultDetail( | |
| 405 | allocator: std.mem.Allocator, | |
| 406 | transcript: []const u8, | |
| 407 | is_final: bool, | |
| 408 | ) ![]u8 { | |
| 409 | var out: std.ArrayListUnmanaged(u8) = .empty; | |
| 410 | errdefer out.deinit(allocator); | |
| 411 | ||
| 412 | try out.appendSlice(allocator, "{\"transcript\":\""); | |
| 413 | try bridge_error.appendJsonEscaped(allocator, &out, transcript); | |
| 414 | try out.appendSlice(allocator, "\",\"isFinal\":"); | |
| 415 | try out.appendSlice(allocator, if (is_final) "true" else "false"); | |
| 416 | try out.append(allocator, '}'); | |
| 417 | ||
| 418 | return out.toOwnedSlice(allocator); | |
| 419 | } | |
| 420 | ||
| 421 | // ============================================================================= | |
| 422 | // Tests. | |
| 423 | // | |
| 424 | // Two things are under test. First, that this module really does serve | |
| 425 | // nothing — declaring nothing is only half of it, since a module that | |
| 426 | // answered anything other than `UnknownAction` would break the fall-through | |
| 427 | // even with an empty table. Second, the finished half: the event names and the | |
| 428 | // two detail shapers, whose bytes are what a page reads. | |
| 429 | // | |
| 430 | // Nothing here is Darwin-gated, because nothing in this file touches | |
| 431 | // Objective-C. | |
| 432 | // ============================================================================= | |
| 433 | ||
| 434 | const testing = std.testing; | |
| 435 | ||
| 436 | test "this module declares no actions, so both fall through to the shim" { | |
| 437 | // Table -> dispatch, with the table empty. The shim serves both actions | |
| 438 | // correctly today and neither has a promise to strand, so an empty table | |
| 439 | // is the whole feature: `ios_dispatch.route` never enters this module and | |
| 440 | // `handOffToHost` answers. | |
| 441 | try testing.expectEqual(@as(usize, 0), capability_actions.len); | |
| 442 | try testing.expectEqual(@as(usize, 0), A.declared_count); | |
| 443 | } | |
| 444 | ||
| 445 | test "the two unserved actions are the spec's, spelled exactly" { | |
| 446 | // The spelling a future round will move into `A`. Pinned here so the move | |
| 447 | // is a copy rather than a retype — `startlistening` would route nowhere and | |
| 448 | // look exactly like this module still not serving it. | |
| 449 | try testing.expectEqual(@as(usize, 2), unserved_actions.len); | |
| 450 | try testing.expectEqualStrings("startListening", unserved_actions[0]); | |
| 451 | try testing.expectEqualStrings("stopListening", unserved_actions[1]); | |
| 452 | } | |
| 453 | ||
| 454 | test "neither unserved action is in the action table" { | |
| 455 | // The invariant that keeps the ratchet honest. An action listed in `A` | |
| 456 | // counts as migrated to `test/ios_conformance_test.zig` whether or not | |
| 457 | // anything answers it, so a name leaking from `unserved_actions` into the | |
| 458 | // table would lower the ratchet for work nobody did. | |
| 459 | for (unserved_actions) |name| { | |
| 460 | for (capability_actions) |decl| { | |
| 461 | try testing.expect(!std.mem.eql(u8, decl.name, name)); | |
| 462 | } | |
| 463 | } | |
| 464 | } | |
| 465 | ||
| 466 | test "handleMessage refuses everything, including the actions it researched" { | |
| 467 | // Dispatch -> table, with both empty. `UnknownAction` is the only error | |
| 468 | // `ios_dispatch.route` reads as "not mine, ask the next"; any other error | |
| 469 | // is treated as final and would stop the chain before the shim. | |
| 470 | var bridge = SpeechBridge.init(testing.allocator); | |
| 471 | defer bridge.deinit(); | |
| 472 | ||
| 473 | const E = bridge_error.BridgeError; | |
| 474 | ||
| 475 | for (unserved_actions) |name| { | |
| 476 | try testing.expectError(E.UnknownAction, bridge.handleMessage(name, "{}")); | |
| 477 | } | |
| 478 | ||
| 479 | // Casing, near misses, and the neighbouring namespaces' spellings — the | |
| 480 | // desktop `speechRecognition` actions in particular, which are a different | |
| 481 | // namespace and must never be answered from here. | |
| 482 | for ([_][]const u8{ | |
| 483 | "startlistening", | |
| 484 | "StartListening", | |
| 485 | "startListen", | |
| 486 | "listen", | |
| 487 | "start", | |
| 488 | "stop", | |
| 489 | "isAvailable", | |
| 490 | "speak", | |
| 491 | "startMotionUpdates", | |
| 492 | "getDeviceInfo", | |
| 493 | "", | |
| 494 | }) |name| { | |
| 495 | try testing.expectError(E.UnknownAction, bridge.handleMessage(name, "{}")); | |
| 496 | } | |
| 497 | } | |
| 498 | ||
| 499 | test "no payload can turn the fall-through into a final error" { | |
| 500 | // The subtle way a serve-nothing module breaks the thing it is protecting. | |
| 501 | // `ios_dispatch.route` stops the chain on any error but `UnknownAction`, so | |
| 502 | // a module that parsed its payload before routing would answer | |
| 503 | // `InvalidJSON` for a malformed one and strand an action it does not | |
| 504 | // serve — the page would get a rejection where the shim would have worked. | |
| 505 | var bridge = SpeechBridge.init(testing.allocator); | |
| 506 | defer bridge.deinit(); | |
| 507 | ||
| 508 | const E = bridge_error.BridgeError; | |
| 509 | ||
| 510 | for ([_][]const u8{ | |
| 511 | "{not json", | |
| 512 | "", | |
| 513 | "null", | |
| 514 | "[1,2,3]", | |
| 515 | "{\"transcript\":\"hello\"}", | |
| 516 | "{\"callbackId\":\"cb_7\"}", | |
| 517 | }) |payload| { | |
| 518 | for (unserved_actions) |name| { | |
| 519 | try testing.expectError(E.UnknownAction, bridge.handleMessage(name, payload)); | |
| 520 | } | |
| 521 | } | |
| 522 | } | |
| 523 | ||
| 524 | test "the three spellable events are the ones the page subscribes to" { | |
| 525 | // `test-bridges.html:384`, `:388`, `:391`, and Swift's `sendToWeb` call | |
| 526 | // sites. camelCase, no colon — the iOS vocabulary, which is why | |
| 527 | // `ios_events.Event` exists separately from `capabilities.Channel`. | |
| 528 | try testing.expectEqualStrings("craftSpeechResult", event_result.eventName()); | |
| 529 | try testing.expectEqualStrings("craftSpeechError", event_error.eventName()); | |
| 530 | try testing.expectEqualStrings("craftSpeechEnd", event_end.eventName()); | |
| 531 | ||
| 532 | // And no desktop channel can spell any of them, so emitting through | |
| 533 | // `capabilities.Channel` would fire events with no subscriber. | |
| 534 | for (std.enums.values(capabilities.Channel)) |channel| { | |
| 535 | try testing.expect(!std.mem.eql(u8, channel.eventName(), event_result.eventName())); | |
| 536 | try testing.expect(!std.mem.eql(u8, channel.eventName(), event_error.eventName())); | |
| 537 | try testing.expect(!std.mem.eql(u8, channel.eventName(), event_end.eventName())); | |
| 538 | try testing.expect(!std.mem.eql(u8, channel.eventName(), event_start_name)); | |
| 539 | } | |
| 540 | // Non-vacuity: the loop above proves nothing over an empty enum. | |
| 541 | try testing.expect(std.enums.values(capabilities.Channel).len >= 40); | |
| 542 | } | |
| 543 | ||
| 544 | test "craftSpeechStart is still unspellable, which is blocker one" { | |
| 545 | // A tripwire, not a preference. This module's first blocker is that | |
| 546 | // `ios_events.Event` has no `.speech_start`, and the moment somebody adds | |
| 547 | // it precondition 1 is met — which should be noticed here, in the file | |
| 548 | // that documents it, rather than in a later round rediscovering the | |
| 549 | // research. Blockers 2-4 still stand on their own, so this trip is a | |
| 550 | // prompt to re-read them, never a clearance to serve. | |
| 551 | if (@hasField(ios_events.Event, "speech_start")) { | |
| 552 | std.debug.print( | |
| 553 | "ios_events.Event now has .speech_start, so blocker 1 in " ++ | |
| 554 | "bridge_mobile_speech.zig is lifted.\n" ++ | |
| 555 | " Emit it through the enum, delete event_start_name, and work " ++ | |
| 556 | "through preconditions 2-5 before moving either action into `A`.\n", | |
| 557 | .{}, | |
| 558 | ); | |
| 559 | return error.SpeechStartBlockerLifted; | |
| 560 | } | |
| 561 | ||
| 562 | // Until then the name exists only as a string, and it is the right one. | |
| 563 | try testing.expectEqualStrings("craftSpeechStart", event_start_name); | |
| 564 | } | |
| 565 | ||
| 566 | test "start and end carry an empty object, not an empty string" { | |
| 567 | // `data: [:]` through `JSONSerialization` is `{}`. `ios_events.formatEvent` | |
| 568 | // inlines the detail as a literal, so `""` would produce | |
| 569 | // `{detail:}` — a syntax error that takes the whole event with it — and | |
| 570 | // `"null"` would make `e.detail` null where the page expects an object. | |
| 571 | try testing.expectEqualStrings("{}", empty_detail); | |
| 572 | ||
| 573 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, empty_detail, .{}); | |
| 574 | defer parsed.deinit(); | |
| 575 | try testing.expectEqual(@as(usize, 0), parsed.value.object.count()); | |
| 576 | } | |
| 577 | ||
| 578 | // ----------------------------------------------------------------------------- | |
| 579 | // craftSpeechError | |
| 580 | // ----------------------------------------------------------------------------- | |
| 581 | ||
| 582 | test "the four error messages are Swift's, character for character" { | |
| 583 | // These strings are shown to a human (`test-bridges.html:392-393`) and | |
| 584 | // documented in README.md. A paraphrase changes what a user reads without | |
| 585 | // changing anything else a test can see. | |
| 586 | try testing.expectEqualStrings("Not authorized", err_not_authorized); | |
| 587 | try testing.expectEqualStrings("Audio session failed", err_audio_session); | |
| 588 | try testing.expectEqualStrings("Speech recognizer unavailable", err_recognizer_unavailable); | |
| 589 | try testing.expectEqualStrings("Audio engine failed", err_audio_engine); | |
| 590 | try testing.expectEqual(@as(usize, 4), error_messages.len); | |
| 591 | } | |
| 592 | ||
| 593 | test "an error detail is the single key the page reads" { | |
| 594 | // `e.detail.error` (`test-bridges.html:392`) and nothing else. A `code` or | |
| 595 | // `message` key alongside it would be a surface no consumer knows about. | |
| 596 | const json = try shapeErrorDetail(testing.allocator, err_not_authorized); | |
| 597 | defer testing.allocator.free(json); | |
| 598 | ||
| 599 | try testing.expectEqualStrings("{\"error\":\"Not authorized\"}", json); | |
| 600 | ||
| 601 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 602 | defer parsed.deinit(); | |
| 603 | try testing.expectEqual(@as(usize, 1), parsed.value.object.count()); | |
| 604 | try testing.expectEqualStrings("Not authorized", parsed.value.object.get("error").?.string); | |
| 605 | } | |
| 606 | ||
| 607 | test "every one of the four messages shapes and parses back" { | |
| 608 | for (error_messages) |message| { | |
| 609 | const json = try shapeErrorDetail(testing.allocator, message); | |
| 610 | defer testing.allocator.free(json); | |
| 611 | ||
| 612 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 613 | defer parsed.deinit(); | |
| 614 | try testing.expectEqual(@as(usize, 1), parsed.value.object.count()); | |
| 615 | try testing.expectEqualStrings(message, parsed.value.object.get("error").?.string); | |
| 616 | } | |
| 617 | } | |
| 618 | ||
| 619 | test "an error message carrying a quote does not end the object early" { | |
| 620 | // No current message does. The escaping is a property of the position, not | |
| 621 | // of today's inputs, and a future round quoting a framework error is | |
| 622 | // exactly how the unescaped case arrives. | |
| 623 | const json = try shapeErrorDetail(testing.allocator, "recognizer said \"no\"\\ever"); | |
| 624 | defer testing.allocator.free(json); | |
| 625 | ||
| 626 | try testing.expectEqualStrings( | |
| 627 | "{\"error\":\"recognizer said \\\"no\\\"\\\\ever\"}", | |
| 628 | json, | |
| 629 | ); | |
| 630 | ||
| 631 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 632 | defer parsed.deinit(); | |
| 633 | try testing.expectEqualStrings( | |
| 634 | "recognizer said \"no\"\\ever", | |
| 635 | parsed.value.object.get("error").?.string, | |
| 636 | ); | |
| 637 | } | |
| 638 | ||
| 639 | // ----------------------------------------------------------------------------- | |
| 640 | // craftSpeechResult | |
| 641 | // ----------------------------------------------------------------------------- | |
| 642 | ||
| 643 | test "a result detail is exactly transcript and isFinal" { | |
| 644 | // `CraftApp.swift:2525-2528`, and the two keys `test-bridges.html:386` | |
| 645 | // reads. These are the bytes `e.detail` receives either way, because | |
| 646 | // `sendToWeb` and `ios_events.formatEvent` inline the detail identically. | |
| 647 | const partial = try shapeResultDetail(testing.allocator, "hello world", false); | |
| 648 | defer testing.allocator.free(partial); | |
| 649 | try testing.expectEqualStrings( | |
| 650 | "{\"transcript\":\"hello world\",\"isFinal\":false}", | |
| 651 | partial, | |
| 652 | ); | |
| 653 | ||
| 654 | const final = try shapeResultDetail(testing.allocator, "hello world", true); | |
| 655 | defer testing.allocator.free(final); | |
| 656 | try testing.expectEqualStrings( | |
| 657 | "{\"transcript\":\"hello world\",\"isFinal\":true}", | |
| 658 | final, | |
| 659 | ); | |
| 660 | ||
| 661 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, final, .{}); | |
| 662 | defer parsed.deinit(); | |
| 663 | try testing.expectEqual(@as(usize, 2), parsed.value.object.count()); | |
| 664 | try testing.expectEqualStrings("hello world", parsed.value.object.get("transcript").?.string); | |
| 665 | try testing.expectEqual(true, parsed.value.object.get("isFinal").?.bool); | |
| 666 | } | |
| 667 | ||
| 668 | test "the detail does not invent keys the recognizer could have supplied" { | |
| 669 | // `SFSpeechRecognitionResult` also carries `transcriptions`, | |
| 670 | // `bestTranscription.segments` and per-segment `confidence`, and Swift | |
| 671 | // reads none of them. Adding one would be a surface no consumer reads and | |
| 672 | // a divergence from the emitter contract this migration preserves. | |
| 673 | const json = try shapeResultDetail(testing.allocator, "x", true); | |
| 674 | defer testing.allocator.free(json); | |
| 675 | ||
| 676 | try testing.expect(std.mem.indexOf(u8, json, "confidence") == null); | |
| 677 | try testing.expect(std.mem.indexOf(u8, json, "alternatives") == null); | |
| 678 | try testing.expect(std.mem.indexOf(u8, json, "segments") == null); | |
| 679 | try testing.expect(std.mem.indexOf(u8, json, "\"transcript\":") != null); | |
| 680 | try testing.expect(std.mem.indexOf(u8, json, "\"isFinal\":") != null); | |
| 681 | } | |
| 682 | ||
| 683 | test "isFinal is a JSON boolean, never a quoted string" { | |
| 684 | // `${e.detail.isFinal}` renders `"false"` and `false` identically, so a | |
| 685 | // quoted value would read as correct on the test page while being truthy | |
| 686 | // to any code that branches on it. | |
| 687 | const json = try shapeResultDetail(testing.allocator, "", false); | |
| 688 | defer testing.allocator.free(json); | |
| 689 | ||
| 690 | try testing.expect(std.mem.indexOf(u8, json, "\"isFinal\":false") != null); | |
| 691 | try testing.expect(std.mem.indexOf(u8, json, "\"false\"") == null); | |
| 692 | ||
| 693 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 694 | defer parsed.deinit(); | |
| 695 | try testing.expectEqual(false, parsed.value.object.get("isFinal").?.bool); | |
| 696 | } | |
| 697 | ||
| 698 | test "an empty transcript still emits the key" { | |
| 699 | // A first partial result can legitimately be empty. Omitting the key would | |
| 700 | // leave `e.detail.transcript` undefined, which a page cannot tell from a | |
| 701 | // bridge that stopped working. | |
| 702 | const json = try shapeResultDetail(testing.allocator, "", false); | |
| 703 | defer testing.allocator.free(json); | |
| 704 | ||
| 705 | try testing.expectEqualStrings("{\"transcript\":\"\",\"isFinal\":false}", json); | |
| 706 | ||
| 707 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 708 | defer parsed.deinit(); | |
| 709 | try testing.expectEqual(@as(usize, 2), parsed.value.object.count()); | |
| 710 | try testing.expectEqualStrings("", parsed.value.object.get("transcript").?.string); | |
| 711 | } | |
| 712 | ||
| 713 | test "a transcript with a quote does not truncate the event" { | |
| 714 | // The failure this shaper exists to prevent, and the one | |
| 715 | // `ios_events.zig` records against `bridge_iap.zig:362`: the detail is | |
| 716 | // inlined into JavaScript source, so an unescaped `"` does not corrupt one | |
| 717 | // field, it ends the object early and makes the whole `dispatchEvent` call | |
| 718 | // a syntax error. The page sees the stream stop, not a bad transcript. | |
| 719 | // | |
| 720 | // Dictation really does produce these: it transcribes spoken quotation | |
| 721 | // marks, and `formattedString` is not sanitised. | |
| 722 | const json = try shapeResultDetail(testing.allocator, "she said \"hello\"", true); | |
| 723 | defer testing.allocator.free(json); | |
| 724 | ||
| 725 | try testing.expectEqualStrings( | |
| 726 | "{\"transcript\":\"she said \\\"hello\\\"\",\"isFinal\":true}", | |
| 727 | json, | |
| 728 | ); | |
| 729 | ||
| 730 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 731 | defer parsed.deinit(); | |
| 732 | try testing.expectEqualStrings( | |
| 733 | "she said \"hello\"", | |
| 734 | parsed.value.object.get("transcript").?.string, | |
| 735 | ); | |
| 736 | } | |
| 737 | ||
| 738 | test "backslashes, newlines and control bytes survive as escapes" { | |
| 739 | // A literal newline inside a JavaScript string literal is a syntax error, | |
| 740 | // and a lone backslash would escape whatever followed it. Both reach the | |
| 741 | // page as a dead event rather than as bad text. | |
| 742 | const json = try shapeResultDetail(testing.allocator, "a\\b\nc\td\r\x01", false); | |
| 743 | defer testing.allocator.free(json); | |
| 744 | ||
| 745 | try testing.expectEqualStrings( | |
| 746 | "{\"transcript\":\"a\\\\b\\nc\\td\\r\\u0001\",\"isFinal\":false}", | |
| 747 | json, | |
| 748 | ); | |
| 749 | ||
| 750 | // No raw control byte survived into the bytes that become JS source. | |
| 751 | for (json) |c| try testing.expect(c >= 0x20); | |
| 752 | ||
| 753 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 754 | defer parsed.deinit(); | |
| 755 | try testing.expectEqualStrings("a\\b\nc\td\r\x01", parsed.value.object.get("transcript").?.string); | |
| 756 | } | |
| 757 | ||
| 758 | test "an apostrophe passes through unescaped, and must" { | |
| 759 | // The opposite error, and an easy one to make while fixing the quote case. | |
| 760 | // `formatEvent` single-quotes the event *name* only; the detail is an | |
| 761 | // object literal, where `'` is an ordinary character inside a | |
| 762 | // double-quoted JSON string. Escaping it would put a literal backslash | |
| 763 | // into the transcript the page displays — and "don't" and "it's" are the | |
| 764 | // most common words dictation produces. | |
| 765 | const json = try shapeResultDetail(testing.allocator, "don't stop", true); | |
| 766 | defer testing.allocator.free(json); | |
| 767 | ||
| 768 | try testing.expectEqualStrings("{\"transcript\":\"don't stop\",\"isFinal\":true}", json); | |
| 769 | try testing.expect(std.mem.indexOfScalar(u8, json, '\\') == null); | |
| 770 | ||
| 771 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 772 | defer parsed.deinit(); | |
| 773 | try testing.expectEqualStrings("don't stop", parsed.value.object.get("transcript").?.string); | |
| 774 | } | |
| 775 | ||
| 776 | test "multi-byte UTF-8 is carried through unchanged" { | |
| 777 | // `formattedString` is whatever locale the recognizer was built with, and | |
| 778 | // even `en-US` produces curly apostrophes and em dashes. Escaping per byte | |
| 779 | // rather than per code point would split a sequence and hand the page | |
| 780 | // invalid JSON. | |
| 781 | const spoken = "café — naïve 日本語"; | |
| 782 | const json = try shapeResultDetail(testing.allocator, spoken, true); | |
| 783 | defer testing.allocator.free(json); | |
| 784 | ||
| 785 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 786 | defer parsed.deinit(); | |
| 787 | try testing.expectEqualStrings(spoken, parsed.value.object.get("transcript").?.string); | |
| 788 | try testing.expect(std.unicode.utf8ValidateSlice(json)); | |
| 789 | } | |
| 790 | ||
| 791 | test "a shaped detail is a complete JSON value, which is what emit requires" { | |
| 792 | // `ios_events.emit` documents its argument as a complete JSON value and | |
| 793 | // inlines it verbatim. A shaper returning a fragment, or leaving a | |
| 794 | // trailing comma, would produce a `dispatchEvent` call the page cannot | |
| 795 | // parse — an event that silently never arrives. | |
| 796 | const details = [_][]u8{ | |
| 797 | try shapeResultDetail(testing.allocator, "one", false), | |
| 798 | try shapeResultDetail(testing.allocator, "", true), | |
| 799 | try shapeErrorDetail(testing.allocator, err_audio_engine), | |
| 800 | }; | |
| 801 | defer for (details) |d| testing.allocator.free(d); | |
| 802 | ||
| 803 | for (details) |detail| { | |
| 804 | try testing.expect(std.mem.startsWith(u8, detail, "{")); | |
| 805 | try testing.expect(std.mem.endsWith(u8, detail, "}")); | |
| 806 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, detail, .{}); | |
| 807 | defer parsed.deinit(); | |
| 808 | try testing.expect(parsed.value == .object); | |
| 809 | } | |
| 810 | } | |