1 /** 2 * IPC Contract Tests 3 * 4 * These tests validate the contract between the TypeScript frontend and the 5 * Rust Tauri backend. They ensure: 6 * 1. Frontend `invoke()` command names match Rust `#[tauri::command]` names 7 * 2. TypeScript return types have all fields the Rust structs serialize 8 * 3. Frontend parameter names match the Rust command handler parameter names 9 * 10 * When a contract test fails, it means either: 11 * - The frontend wrapper is calling a command the backend does not expose 12 * - The backend renamed a command without updating the frontend
13 * - A TypeScript type is missing a field that the Rust struct serializes
14 * - A field was added to a Rust struct without updating the TypeScript type
15 *
16 * These are STATIC tests -- they validate type shapes at compile time via
17 * assignability checks and verify command name registries at runtime.
18 */
19 import { describe, expect, test } from "vitest" ;
20
21 import type {
22 AppSettings,
23 CommandError,
24 DeleteSongsResult,
25 ExpandedImportPaths,
26 ExtractEmbeddedCoverArtResult,
27 ImportLyricsResult,
28 ImportSongsResult,
29 LyricsPayload,
30 PlaybackStateSnapshot,
31 SeparationStatusSnapshot,
32 Song,
33 SongProperties,
34 } from "./ipc" ;
35
36 interface CommandContract {
37 /** The Tauri IPC command name (snake_case, matches Rust fn name) */
38 command : string ;
39 /** File that wraps this command on the frontend */
40 frontendFile : string ;
41 /** Frontend function that calls invoke() */
42 frontendFn : string ;
43 /** Whether the command takes user-supplied arguments (excludes State/AppHandle) */
44 hasArgs : boolean ;
45 /** Names of user-supplied parameters the Rust handler expects (snake_case) */
46 rustParams ?: string [];
47 }
48
49 const PLAYBACK_COMMANDS : CommandContract [] = [
50 {
51 command: "play" ,
52 frontendFile: "src/lib/tauri/playback.ts" ,
53 frontendFn: "play" ,
54 hasArgs: true ,
55 rustParams: [ "song_id" ],
56 },
57 {
58 command: "resume" ,
59 frontendFile: "src/lib/tauri/playback.ts" ,
60 frontendFn: "resume" ,
61 hasArgs: false ,
62 },
63 {
64 command: "pause" ,
65 frontendFile: "src/lib/tauri/playback.ts" ,
66 frontendFn: "pause" ,
67 hasArgs: false ,
68 },
69 {
70 command: "seek" ,
71 frontendFile: "src/lib/tauri/playback.ts" ,
72 frontendFn: "seek" ,
73 hasArgs: true ,
74 rustParams: [ "ms" ],
75 },
76 {
77 command: "set_volume" ,
78 frontendFile: "src/lib/tauri/playback.ts" ,
79 frontendFn: "setVolume" ,
80 hasArgs: true ,
81 rustParams: [ "level" ],
82 },
83 {
84 command: "set_stem_volume" ,
85 frontendFile: "src/lib/tauri/playback.ts" ,
86 frontendFn: "setStemVolume" ,
87 hasArgs: true ,
88 rustParams: [ "stem" , "level" ],
89 },
90 {
91 command: "load_stems" ,
92 frontendFile: "src/lib/tauri/playback.ts" ,
93 frontendFn: "loadStems" ,
94 hasArgs: false ,
95 },
96 {
97 command: "get_playback_state" ,
98 frontendFile: "src/lib/tauri/playback.ts" ,
99 frontendFn: "getPlaybackState" ,
100 hasArgs: false ,
101 },
102 {
103 command: "get_audio_peaks" ,
104 frontendFile: "src/lib/tauri/playback.ts" ,
105 frontendFn: "getAudioPeaks" ,
106 hasArgs: false ,
107 },
108 {
109 command: "set_preload_candidate" ,
110 frontendFile: "src/lib/tauri/playback.ts" ,
111 frontendFn: "setPreloadCandidate" ,
112 hasArgs: true ,
113 rustParams: [ "song_id" ],
114 },
115 ];
116
117 const LIBRARY_COMMANDS : CommandContract [] = [
118 {
119 command: "import_songs" ,
120 frontendFile: "src/lib/tauri/library.ts" ,
121 frontendFn: "importSongs" ,
122 hasArgs: true ,
123 rustParams: [ "paths" , "options" ],
124 },
125 {
126 command: "get_import_candidate_details" ,
127 frontendFile: "src/lib/tauri/library.ts" ,
128 frontendFn: "getImportCandidateDetails" ,
129 hasArgs: true ,
130 rustParams: [ "paths" ],
131 },
132 {
133 command: "expand_import_paths" ,
134 frontendFile: "src/lib/tauri/library.ts" ,
135 frontendFn: "expandImportPaths" ,
136 hasArgs: true ,
137 rustParams: [ "paths" ],
138 },
139 {
140 command: "pick_import_paths" ,
141 frontendFile: "src/lib/tauri/library.ts" ,
142 frontendFn: "pickImportPaths" ,
143 hasArgs: true ,
144 rustParams: [ "default_path" ],
145 },
146 {
147 command: "get_library" ,
148 frontendFile: "src/lib/tauri/library.ts" ,
149 frontendFn: "getLibrary" ,
150 hasArgs: false ,
151 },
152 {
153 command: "search_library" ,
154 frontendFile: "src/lib/tauri/library.ts" ,
155 frontendFn: "searchLibrary" ,
156 hasArgs: true ,
157 rustParams: [ "query" ],
158 },
159 {
160 command: "get_cover_art" ,
161 frontendFile: "src/lib/tauri/library.ts" ,
162 frontendFn: "getCoverArt" ,
163 hasArgs: true ,
164 rustParams: [ "hash" , "size" ],
165 },
166 {
167 command: "update_song_metadata" ,
168 frontendFile: "src/lib/tauri/library.ts" ,
169 frontendFn: "updateSongMetadata" ,
170 hasArgs: true ,
171 rustParams: [ "hash" , "title" , "artist" ],
172 },
173 {
174 command: "set_songs_instrumental" ,
175 frontendFile: "src/lib/tauri/library.ts" ,
176 frontendFn: "setSongsInstrumental" ,
177 hasArgs: true ,
178 rustParams: [ "song_ids" , "instrumental" ],
179 },
180 {
181 command: "set_songs_language" ,
182 frontendFile: "src/lib/tauri/library.ts" ,
183 frontendFn: "setSongsLanguage" ,
184 hasArgs: true ,
185 rustParams: [ "song_ids" , "language" ],
186 },
187 {
188 command: "delete_songs" ,
189 frontendFile: "src/lib/tauri/library.ts" ,
190 frontendFn: "deleteSongs" ,
191 hasArgs: true ,
192 rustParams: [ "song_ids" ],
193 },
194 {
195 command: "get_song_properties" ,
196 frontendFile: "src/lib/tauri/library.ts" ,
197 frontendFn: "getSongProperties" ,
198 hasArgs: true ,
199 rustParams: [ "song_id" ],
200 },
201 {
202 command: "extract_embedded_cover_art" ,
203 frontendFile: "src/lib/tauri/maintenance.ts" ,
204 frontendFn: "extractEmbeddedCoverArt" ,
205 hasArgs: true ,
206 rustParams: [ "song_ids" ],
207 },
208 {
209 command: "check_library_integrity" ,
210 frontendFile: "src/lib/tauri/library.ts" ,
211 frontendFn: "checkLibraryIntegrity" ,
212 hasArgs: false ,
213 },
214 {
215 command: "remove_missing_library_entries" ,
216 frontendFile: "src/lib/tauri/library.ts" ,
217 frontendFn: "removeMissingLibraryEntries" ,
218 hasArgs: true ,
219 rustParams: [ "hashes" ],
220 },
221 ];
222
223 const LYRICS_COMMANDS : CommandContract [] = [
224 {
225 command: "fetch_lyrics" ,
226 frontendFile: "src/lib/tauri/lyrics.ts" ,
227 frontendFn: "fetchLyrics" ,
228 hasArgs: true ,
229 rustParams: [ "song_id" ],
230 },
231 {
232 command: "set_lyrics_offset" ,
233 frontendFile: "src/lib/tauri/lyrics.ts" ,
234 frontendFn: "setLyricsOffset" ,
235 hasArgs: true ,
236 rustParams: [ "song_id" , "ms" ],
237 },
238 {
239 command: "fetch_lyrics_online" ,
240 frontendFile: "src/lib/tauri/lyrics.ts" ,
241 frontendFn: "fetchLyricsOnline" ,
242 hasArgs: true ,
243 rustParams: [ "song_id" , "user_initiated" ],
244 },
245 {
246 command: "save_manual_lyrics" ,
247 frontendFile: "src/lib/tauri/lyrics.ts" ,
248 frontendFn: "saveManualLyrics" ,
249 hasArgs: true ,
250 rustParams: [ "song_id" , "text" ],
251 },
252 {
253 command: "extract_embedded_lyrics" ,
254 frontendFile: "src/lib/tauri/lyrics.ts" ,
255 frontendFn: "extractEmbeddedLyrics" ,
256 hasArgs: true ,
257 rustParams: [ "song_id" ],
258 },
259 {
260 command: "import_lyrics_files" ,
261 frontendFile: "src/lib/tauri/lyrics.ts" ,
262 frontendFn: "importLyricsFiles" ,
263 hasArgs: true ,
264 rustParams: [ "paths" ],
265 },
266 {
267 command: "set_lyrics_font_step" ,
268 frontendFile: "src/lib/tauri/settings.ts" ,
269 frontendFn: "setLyricsFontStep" ,
270 hasArgs: true ,
271 rustParams: [ "step" ],
272 },
273 ];
274
275 const SETTINGS_COMMANDS : CommandContract [] = [
276 {
277 command: "set_eq_enabled" ,
278 frontendFile: "src/lib/tauri/settings.ts" ,
279 frontendFn: "setEqEnabled" ,
280 hasArgs: true ,
281 rustParams: [ "enabled" ],
282 },
283 {
284 command: "set_eq_gains" ,
285 frontendFile: "src/lib/tauri/settings.ts" ,
286 frontendFn: "setEqGains" ,
287 hasArgs: true ,
288 rustParams: [ "gains_db" ],
289 },
290 {
291 command: "set_library_sort_mode" ,
292 frontendFile: "src/lib/tauri/settings.ts" ,
293 frontendFn: "setLibrarySortMode" ,
294 hasArgs: true ,
295 rustParams: [ "mode" ],
296 },
297 {
298 command: "set_theme_preference" ,
299 frontendFile: "src/lib/tauri/settings.ts" ,
300 frontendFn: "setThemePreference" ,
301 hasArgs: true ,
302 rustParams: [ "preference" ],
303 },
304 ];
305
306 const SEPARATION_COMMANDS : CommandContract [] = [
307 {
308 command: "separate" ,
309 frontendFile: "src/lib/tauri/separation.ts" ,
310 frontendFn: "separate" ,
311 hasArgs: true ,
312 rustParams: [ "song_id" ],
313 },
314 {
315 command: "get_separation_status" ,
316 frontendFile: "src/lib/tauri/separation.ts" ,
317 frontendFn: "getSeparationStatus" ,
318 hasArgs: true ,
319 rustParams: [ "song_id" ],
320 },
321 {
322 command: "get_all_separation_statuses" ,
323 frontendFile: "src/lib/tauri/separation.ts" ,
324 frontendFn: "getAllSeparationStatuses" ,
325 hasArgs: false ,
326 },
327 {
328 command: "upgrade_to_four_stem" ,
329 frontendFile: "src/lib/tauri/separation.ts" ,
330 frontendFn: "upgradeToFourStem" ,
331 hasArgs: true ,
332 rustParams: [ "song_id" ],
333 },
334 {
335 command: "re_separate" ,
336 frontendFile: "src/lib/tauri/separation.ts" ,
337 frontendFn: "reSeparate" ,
338 hasArgs: true ,
339 rustParams: [ "song_id" , "stem_mode" ],
340 },
341 ];
342
343 const ALL_COMMANDS = [
344 ... PLAYBACK_COMMANDS ,
345 ... LIBRARY_COMMANDS ,
346 ... LYRICS_COMMANDS ,
347 ... SEPARATION_COMMANDS ,
348 ... SETTINGS_COMMANDS ,
349 ];
350
351 describe ( "IPC command registry" , () => {
352 test ( "all registered commands have unique names" , () => {
353 const names = ALL_COMMANDS . map (( c ) => c.command);
354 const unique = new Set (names);
355 expect (unique.size). toBe (names. length );
356 });
357
358 test ( "playback commands match contract documentation" , () => {
359 // Phase 2 contract defines these exact command names
360 const expectedPlaybackCommands = [
361 "play" ,
362 "resume" ,
363 "pause" ,
364 "seek" ,
365 "set_volume" ,
366 "set_stem_volume" ,
367 "load_stems" ,
368 "get_playback_state" ,
369 "get_audio_peaks" ,
370 "set_preload_candidate" ,
371 ];
372 const registered = PLAYBACK_COMMANDS . map (( c ) => c.command);
373 expect (registered. sort ()). toEqual (expectedPlaybackCommands. sort ());
374 });
375
376 test ( "library commands match contract documentation" , () => {
377 // Phase 1 contract defines these exact command names
378 const expectedLibraryCommands = [
379 "import_songs" ,
380 "pick_import_paths" ,
381 "expand_import_paths" ,
382 "get_library" ,
383 "search_library" ,
384 "get_cover_art" ,
385 "set_songs_instrumental" ,
386 "extract_embedded_cover_art" ,
387 "get_import_candidate_details" ,
388 "update_song_metadata" ,
389 "set_songs_language" ,
390 "delete_songs" ,
391 "get_song_properties" ,
392 "check_library_integrity" ,
393 "remove_missing_library_entries" ,
394 ];
395 const registered = LIBRARY_COMMANDS . map (( c ) => c.command);
396 expect (registered. sort ()). toEqual (expectedLibraryCommands. sort ());
397 });
398
399 test ( "lyrics commands match contract documentation" , () => {
400 // Phase 4 contract defines these exact command names
401 const expectedLyricsCommands = [
402 "fetch_lyrics" ,
403 "set_lyrics_offset" ,
404 "set_lyrics_font_step" ,
405 "fetch_lyrics_online" ,
406 "save_manual_lyrics" ,
407 "extract_embedded_lyrics" ,
408 "import_lyrics_files" ,
409 ];
410 const registered = LYRICS_COMMANDS . map (( c ) => c.command);
411 expect (registered. sort ()). toEqual (expectedLyricsCommands. sort ());
412 });
413
414 test ( "settings commands match contract documentation" , () => {
415 const expectedSettingsCommands = [
416 "set_eq_enabled" ,
417 "set_eq_gains" ,
418 "set_library_sort_mode" ,
419 "set_theme_preference" ,
420 ];
421 const registered = SETTINGS_COMMANDS . map (( c ) => c.command);
422 expect (registered. sort ()). toEqual (expectedSettingsCommands. sort ());
423 });
424
425 test ( "separation commands match contract documentation" , () => {
426 const expectedSeparationCommands = [
427 "separate" ,
428 "get_separation_status" ,
429 "get_all_separation_statuses" ,
430 "upgrade_to_four_stem" ,
431 "re_separate" ,
432 ];
433 const registered = SEPARATION_COMMANDS . map (( c ) => c.command);
434 expect (registered. sort ()). toEqual (expectedSeparationCommands. sort ());
435 });
436
437 test ( "all command names use snake_case (Tauri convention)" , () => {
438 const snakeCase = / ^ [a-z][a-z0-9] * (_ [a-z0-9] + ) *$ / ;
439 for ( const cmd of ALL_COMMANDS ) {
440 expect (cmd.command). toMatch (snakeCase);
441 }
442 });
443
444 test ( "all frontend function names use camelCase" , () => {
445 const camelCase = / ^ [a-z][a-zA-Z0-9] *$ / ;
446 for ( const cmd of ALL_COMMANDS ) {
447 expect (cmd.frontendFn). toMatch (camelCase);
448 }
449 });
450 });
451
452 describe ( "IPC parameter contracts" , () => {
453 test ( "play expects song_id parameter (camelCase: songId)" , () => {
454 const contract = PLAYBACK_COMMANDS . find (( c ) => c.command === "play" ) ! ;
455 expect (contract.rustParams). toContain ( "song_id" );
456 });
457
458 test ( "seek expects ms parameter" , () => {
459 const contract = PLAYBACK_COMMANDS . find (( c ) => c.command === "seek" ) ! ;
460 expect (contract.rustParams). toContain ( "ms" );
461 });
462
463 test ( "set_volume expects level parameter" , () => {
464 const contract = PLAYBACK_COMMANDS . find (( c ) => c.command === "set_volume" ) ! ;
465 expect (contract.rustParams). toContain ( "level" );
466 });
467
468 test ( "set_stem_volume expects stem and level parameters" , () => {
469 const contract = PLAYBACK_COMMANDS . find (
470 ( c ) => c.command === "set_stem_volume" ,
471 ) ! ;
472 expect (contract.rustParams). toEqual ([ "stem" , "level" ]);
473 });
474
475 test ( "set_preload_candidate expects song_id parameter (camelCase: songId)" , () => {
476 const contract = PLAYBACK_COMMANDS . find (
477 ( c ) => c.command === "set_preload_candidate" ,
478 ) ! ;
479 expect (contract.rustParams). toContain ( "song_id" );
480 });
481
482 test ( "import_songs expects paths and optional options parameters" , () => {
483 const contract = LIBRARY_COMMANDS . find (
484 ( c ) => c.command === "import_songs" ,
485 ) ! ;
486 expect (contract.rustParams). toEqual ([ "paths" , "options" ]);
487 });
488
489 test ( "search_library expects query parameter" , () => {
490 const contract = LIBRARY_COMMANDS . find (
491 ( c ) => c.command === "search_library" ,
492 ) ! ;
493 expect (contract.rustParams). toEqual ([ "query" ]);
494 });
495
496 test ( "set_songs_instrumental expects song_ids and instrumental parameters" , () => {
497 const contract = LIBRARY_COMMANDS . find (
498 ( c ) => c.command === "set_songs_instrumental" ,
499 ) ! ;
500 expect (contract.rustParams). toEqual ([ "song_ids" , "instrumental" ]);
501 });
502
503 test ( "fetch_lyrics expects song_id parameter" , () => {
504 const contract = LYRICS_COMMANDS . find (( c ) => c.command === "fetch_lyrics" ) ! ;
505 expect (contract.rustParams). toEqual ([ "song_id" ]);
506 });
507
508 test ( "set_lyrics_offset expects song_id and ms parameters" , () => {
509 const contract = LYRICS_COMMANDS . find (
510 ( c ) => c.command === "set_lyrics_offset" ,
511 ) ! ;
512 expect (contract.rustParams). toEqual ([ "song_id" , "ms" ]);
513 });
514
515 test ( "separate expects song_id parameter" , () => {
516 const contract = SEPARATION_COMMANDS . find (( c ) => c.command === "separate" ) ! ;
517 expect (contract.rustParams). toEqual ([ "song_id" ]);
518 });
519
520 test ( "re_separate expects song_id and stem_mode parameters" , () => {
521 const contract = SEPARATION_COMMANDS . find (
522 ( c ) => c.command === "re_separate" ,
523 ) ! ;
524 expect (contract.rustParams). toEqual ([ "song_id" , "stem_mode" ]);
525 });
526 });
527
528 describe ( "PlaybackStateSnapshot shape matches Rust PlaybackStateSnapshot" , () => {
529 function assertSnapshotShape ( snapshot : PlaybackStateSnapshot ) : void {
530 // Required fields that Rust always serializes
531 expect (snapshot). toHaveProperty ( "song_id" );
532 expect (snapshot). toHaveProperty ( "transport_generation" );
533 expect (snapshot). toHaveProperty ( "state" );
534 expect (snapshot). toHaveProperty ( "is_playing" );
535 expect (snapshot). toHaveProperty ( "position_ms" );
536 expect (snapshot). toHaveProperty ( "duration_ms" );
537 expect (snapshot). toHaveProperty ( "buffered_ms" );
538 expect (snapshot). toHaveProperty ( "volume" );
539 expect (snapshot). toHaveProperty ( "stem_volumes" );
540 expect (snapshot). toHaveProperty ( "has_stems" );
541 expect (snapshot). toHaveProperty ( "stem_mode" );
542
543 // StemVolumes sub-structure
544 expect (snapshot.stem_volumes). toHaveProperty ( "vocals" );
545 expect (snapshot.stem_volumes). toHaveProperty ( "drums" );
546 expect (snapshot.stem_volumes). toHaveProperty ( "bass" );
547 expect (snapshot.stem_volumes). toHaveProperty ( "other" );
548 }
549
550 test ( "idle snapshot has all required fields" , () => {
551 const idle : PlaybackStateSnapshot = {
552 song_id: null ,
553 transport_generation: 0 ,
554 state: "idle" ,
555 is_playing: false ,
556 position_ms: 0 ,
557 duration_ms: null ,
558 buffered_ms: 0 ,
559 volume: 1.0 ,
560 stem_volumes: { vocals: 1.0 , drums: 1.0 , bass: 1.0 , other: 1.0 },
561 has_stems: false ,
562 stem_mode: null ,
563 };
564 assertSnapshotShape (idle);
565 });
566
567 test ( "playing snapshot has all required fields" , () => {
568 const playing : PlaybackStateSnapshot = {
569 song_id: "abc123" ,
570 transport_generation: 1 ,
571 state: "playing" ,
572 is_playing: true ,
573 position_ms: 1500 ,
574 duration_ms: 180000 ,
575 buffered_ms: 180000 ,
576 volume: 0.8 ,
577 stem_volumes: { vocals: 0.5 , drums: 1.0 , bass: 1.0 , other: 1.0 },
578 has_stems: true ,
579 stem_mode: "two_stem" ,
580 };
581 assertSnapshotShape (playing);
582 });
583
584 test ( "loading snapshot has all required fields" , () => {
585 const loading : PlaybackStateSnapshot = {
586 song_id: "abc123" ,
587 transport_generation: 2 ,
588 state: "loading" ,
589 is_playing: false ,
590 position_ms: 0 ,
591 duration_ms: null ,
592 buffered_ms: 0 ,
593 volume: 1.0 ,
594 stem_volumes: { vocals: 1.0 , drums: 1.0 , bass: 1.0 , other: 1.0 },
595 has_stems: false ,
596 stem_mode: null ,
597 };
598 assertSnapshotShape (loading);
599 });
600
601 test ( "transport state values match Rust enum variants" , () => {
602 const validStates = [ "idle" , "loading" , "playing" , "buffering" ];
603 for ( const state of validStates) {
604 const snapshot : PlaybackStateSnapshot = {
605 song_id: null ,
606 transport_generation: 0 ,
607 state: state as PlaybackStateSnapshot [ "state" ],
608 is_playing: false ,
609 position_ms: 0 ,
610 duration_ms: null ,
611 buffered_ms: 0 ,
612 volume: 1.0 ,
613 stem_volumes: { vocals: 1.0 , drums: 1.0 , bass: 1.0 , other: 1.0 },
614 has_stems: false ,
615 stem_mode: null ,
616 };
617 expect (snapshot.state). toBe (state);
618 }
619 });
620
621 test ( "stem_mode accepts two_stem, four_stem, and null" , () => {
622 const twoStem : PlaybackStateSnapshot [ "stem_mode" ] = "two_stem" ;
623 const fourStem : PlaybackStateSnapshot [ "stem_mode" ] = "four_stem" ;
624 const nullMode : PlaybackStateSnapshot [ "stem_mode" ] = null ;
625 expect (twoStem). toBe ( "two_stem" );
626 expect (fourStem). toBe ( "four_stem" );
627 expect (nullMode). toBeNull ();
628 });
629 });
630
631 describe ( "Song shape matches Rust Song struct" , () => {
632 test ( "Song has all fields from Rust serialization" , () => {
633 const song : Song = {
634 hash: "abc123" ,
635 file_path: "/path/to/song.mp3" ,
636 audio_source_kind: "original" ,
637 cdg_path: null ,
638 media_g_container: null ,
639 instrumental: false ,
640 language: null ,
641 title: "Test Song" ,
642 artist: "Test Artist" ,
643 album: "Test Album" ,
644 duration_ms: 180000 ,
645 cover_art: null ,
646 has_cover_art: false ,
647 artwork_thumb_path: null ,
648 imported_at: 1700000000 ,
649 original_ext: "mp3" ,
650 };
651
652 // All fields from Rust Song struct
653 expect (song). toHaveProperty ( "hash" );
654 expect (song). toHaveProperty ( "file_path" );
655 expect (song). toHaveProperty ( "audio_source_kind" );
656 expect (song). toHaveProperty ( "cdg_path" );
657 expect (song). toHaveProperty ( "media_g_container" );
658 expect (song). toHaveProperty ( "instrumental" );
659 expect (song). toHaveProperty ( "language" );
660 expect (song). toHaveProperty ( "title" );
661 expect (song). toHaveProperty ( "artist" );
662 expect (song). toHaveProperty ( "album" );
663 expect (song). toHaveProperty ( "duration_ms" );
664 expect (song). toHaveProperty ( "cover_art" );
665 expect (song). toHaveProperty ( "has_cover_art" );
666 expect (song). toHaveProperty ( "imported_at" );
667 expect (song). toHaveProperty ( "original_ext" );
668 });
669
670 test ( "Song accepts null for optional fields (remote songs)" , () => {
671 const remoteSong : Song = {
672 hash: "remote-song" ,
673 file_path: null ,
674 audio_source_kind: "original_remote" ,
675 cdg_path: null ,
676 media_g_container: null ,
677 instrumental: false ,
678 language: null ,
679 title: null ,
680 artist: null ,
681 album: null ,
682 duration_ms: 0 ,
683 cover_art: null ,
684 has_cover_art: false ,
685 artwork_thumb_path: null ,
686 imported_at: 0 ,
687 original_ext: null ,
688 };
689 expect (remoteSong.file_path). toBeNull ();
690 expect (remoteSong.audio_source_kind). toBe ( "original_remote" );
691 });
692
693 test ( "audio_source_kind values match Rust enum" , () => {
694 const validKinds : Song [ "audio_source_kind" ][] = [
695 "original" ,
696 "original_remote" ,
697 "stems_remote" ,
698 ];
699 for ( const kind of validKinds) {
700 expect ([ "original" , "original_remote" , "stems_remote" ]). toContain (kind);
701 }
702 });
703
704 test ( "media_g_container values match Rust enum" , () => {
705 const validContainers : Array < Song [ "media_g_container" ]> = [
706 "paired" ,
707 "zip" ,
708 null ,
709 ];
710 for ( const container of validContainers) {
711 expect ([ "paired" , "zip" , null ]). toContain (container);
712 }
713 });
714 });
715
716 describe ( "ImportSongsResult shape matches Rust ImportSongsResult" , () => {
717 test ( "has imported and failed fields" , () => {
718 const result : ImportSongsResult = {
719 imported: [],
720 failed: [],
721 };
722 expect (result). toHaveProperty ( "imported" );
723 expect (result). toHaveProperty ( "failed" );
724 expect (Array. isArray (result.imported)). toBe ( true );
725 expect (Array. isArray (result.failed)). toBe ( true );
726 });
727
728 test ( "ImportFailure has path and error fields" , () => {
729 const result : ImportSongsResult = {
730 imported: [],
731 failed: [
732 {
733 path: "/bad/file.mp3" ,
734 error: {
735 code: "media_read_failed" ,
736 message: "could not read file" ,
737 retryable: false ,
738 fallback: "reimport_song" ,
739 },
740 },
741 ],
742 };
743 expect (result.failed[ 0 ]). toHaveProperty ( "path" );
744 expect (result.failed[ 0 ]). toHaveProperty ( "error" );
745 expect (result.failed[ 0 ].error). toHaveProperty ( "code" );
746 expect (result.failed[ 0 ].error). toHaveProperty ( "message" );
747 expect (result.failed[ 0 ].error). toHaveProperty ( "retryable" );
748 expect (result.failed[ 0 ].error). toHaveProperty ( "fallback" );
749 });
750 });
751
752 describe ( "ExpandedImportPaths shape matches Rust ExpandedImportPaths" , () => {
753 test ( "has paths and song_count fields" , () => {
754 const result : ExpandedImportPaths = {
755 paths: [ "/music/song.mp3" ],
756 song_count: 1 ,
757 };
758 expect (result). toHaveProperty ( "paths" );
759 expect (result). toHaveProperty ( "song_count" );
760 });
761 });
762
763 describe ( "LyricsPayload shape matches Rust LyricsPayload" , () => {
764 test ( "has all fields from Rust serialization" , () => {
765 const payload : LyricsPayload = {
766 song_id: "abc123" ,
767 lines: [
768 {
769 time_ms: 35660 ,
770 text: "Look at the stars" ,
771 words: null ,
772 bg_words: null ,
773 section: null ,
774 },
775 {
776 time_ms: 38000 ,
777 text: "Look how they shine" ,
778 words: null ,
779 bg_words: null ,
780 section: null ,
781 },
782 ],
783 source: "lrc_lib" ,
784 offset_ms: 0 ,
785 raw_lrc: "[00:35.66] Look at the stars \n [00:38.00] Look how they shine" ,
786 };
787
788 expect (payload). toHaveProperty ( "song_id" );
789 expect (payload). toHaveProperty ( "lines" );
790 expect (payload). toHaveProperty ( "source" );
791 expect (payload). toHaveProperty ( "offset_ms" );
792 expect (payload). toHaveProperty ( "raw_lrc" );
793 });
794
795 test ( "LyricLine has time_ms, text, and words fields" , () => {
796 const payload : LyricsPayload = {
797 song_id: "abc123" ,
798 lines: [
799 {
800 time_ms: 1000 ,
801 text: "Hello" ,
802 words: null ,
803 bg_words: null ,
804 section: null ,
805 },
806 ],
807 source: "lrc_lib" ,
808 offset_ms: 0 ,
809 raw_lrc: "[00:01.00] Hello" ,
810 };
811 const line = payload.lines[ 0 ];
812 expect (line). toHaveProperty ( "time_ms" );
813 expect (line). toHaveProperty ( "text" );
814 expect (line). toHaveProperty ( "words" );
815 });
816
817 test ( "source values match Rust LyricsSource enum" , () => {
818 const validSources : Array < LyricsPayload [ "source" ]> = [
819 "lrc_lib" ,
820 "lrc_api" ,
821 "embedded" ,
822 "sidecar" ,
823 "manual" ,
824 null ,
825 ];
826 for ( const source of validSources) {
827 expect ([
828 "lrc_lib" ,
829 "lrc_api" ,
830 "embedded" ,
831 "sidecar" ,
832 "manual" ,
833 null ,
834 ]). toContain (source);
835 }
836 });
837
838 test ( "miss payload returns empty lines and null source" , () => {
839 const miss : LyricsPayload = {
840 song_id: "abc123" ,
841 lines: [],
842 source: null ,
843 offset_ms: 0 ,
844 raw_lrc: "" ,
845 };
846 expect (miss.lines). toHaveLength ( 0 );
847 expect (miss.source). toBeNull ();
848 });
849 });
850
851 describe ( "SeparationStatusSnapshot shape matches Rust SeparationStatusSnapshot" , () => {
852 test ( "has all fields from Rust serialization" , () => {
853 const snapshot : SeparationStatusSnapshot = {
854 song_id: "abc123" ,
855 state: "completed" ,
856 percent: 100 ,
857 cache_hit: true ,
858 vocals_path: "/path/vocals.ogg" ,
859 accomp_path: "/path/accomp.ogg" ,
860 drums_path: null ,
861 bass_path: null ,
862 other_path: null ,
863 model_variant: "htdemucs" ,
864 error: null ,
865 };
866
867 expect (snapshot). toHaveProperty ( "song_id" );
868 expect (snapshot). toHaveProperty ( "state" );
869 expect (snapshot). toHaveProperty ( "percent" );
870 expect (snapshot). toHaveProperty ( "cache_hit" );
871 expect (snapshot). toHaveProperty ( "vocals_path" );
872 expect (snapshot). toHaveProperty ( "accomp_path" );
873 expect (snapshot). toHaveProperty ( "drums_path" );
874 expect (snapshot). toHaveProperty ( "bass_path" );
875 expect (snapshot). toHaveProperty ( "other_path" );
876 expect (snapshot). toHaveProperty ( "model_variant" );
877 expect (snapshot). toHaveProperty ( "error" );
878 });
879
880 test ( "separation state values match Rust SeparationState enum" , () => {
881 const validStates : SeparationStatusSnapshot [ "state" ][] = [
882 "idle" ,
883 "running" ,
884 "completed" ,
885 "failed" ,
886 ];
887 for ( const state of validStates) {
888 expect ([ "idle" , "running" , "completed" , "failed" ]). toContain (state);
889 }
890 });
891
892 test ( "idle status has null paths and zero percent" , () => {
893 const idle : SeparationStatusSnapshot = {
894 song_id: "abc123" ,
895 state: "idle" ,
896 percent: 0 ,
897 cache_hit: false ,
898 vocals_path: null ,
899 accomp_path: null ,
900 drums_path: null ,
901 bass_path: null ,
902 other_path: null ,
903 model_variant: null ,
904 error: null ,
905 };
906 expect (idle.vocals_path). toBeNull ();
907 expect (idle.percent). toBe ( 0 );
908 });
909
910 test ( "failed status includes error object" , () => {
911 const failed : SeparationStatusSnapshot = {
912 song_id: "abc123" ,
913 state: "failed" ,
914 percent: 100 ,
915 cache_hit: false ,
916 vocals_path: null ,
917 accomp_path: null ,
918 drums_path: null ,
919 bass_path: null ,
920 other_path: null ,
921 model_variant: null ,
922 error: {
923 code: "separation_failed" ,
924 message: "model crashed" ,
925 retryable: true ,
926 fallback: "stay_in_original_mode" ,
927 },
928 };
929 expect (failed.error).not. toBeNull ();
930 expect (failed.error ! .code). toBe ( "separation_failed" );
931 });
932 });
933
934 describe ( "CommandError shape matches Rust CommandError" , () => {
935 test ( "has code, message, retryable, fallback fields" , () => {
936 const error : CommandError = {
937 code: "song_not_found" ,
938 message: "song with hash abc not found" ,
939 retryable: false ,
940 fallback: "refresh_library" ,
941 };
942 expect (error). toHaveProperty ( "code" );
943 expect (error). toHaveProperty ( "message" );
944 expect (error). toHaveProperty ( "retryable" );
945 expect (error). toHaveProperty ( "fallback" );
946 });
947
948 test ( "ErrorCode values match Rust ErrorCode enum" , () => {
949 const validCodes : CommandError [ "code" ][] = [
950 "database_unavailable" ,
951 "media_read_failed" ,
952 "song_not_found" ,
953 "model_unavailable" ,
954 "audio_decode_failed" ,
955 "audio_output_unavailable" ,
956 "karaoke_not_ready" ,
957 "lyrics_not_ready" ,
958 "network_unavailable" ,
959 "invalid_playback_state" ,
960 "separation_failed" ,
961 "internal" ,
962 ];
963 // All 12 error codes from phase-5-error-contract.md
964 expect (validCodes). toHaveLength ( 12 );
965 for ( const code of validCodes) {
966 expect ( typeof code). toBe ( "string" );
967 }
968 });
969
970 test ( "FallbackAction values match Rust FallbackAction enum" , () => {
971 const validActions : CommandError [ "fallback" ][] = [
972 "retry" ,
973 "refresh_library" ,
974 "reimport_song" ,
975 "check_audio_output_device" ,
976 "stay_in_original_mode" ,
977 "show_empty_state" ,
978 "keep_current_state" ,
979 ];
980 expect (validActions). toHaveLength ( 7 );
981 for ( const action of validActions) {
982 expect ( typeof action). toBe ( "string" );
983 }
984 });
985 });
986
987 describe ( "SongProperties shape matches Rust SongProperties" , () => {
988 test ( "has all fields from Rust serialization" , () => {
989 const props : SongProperties = {
990 format: "MP3" ,
991 sample_rate_hz: 44100 ,
992 channels: 2 ,
993 bit_rate_bps: 320 ,
994 file_size_bytes: 5_000_000 ,
995 duration_ms: 180000 ,
996 hash: "abc123" ,
997 };
998 expect (props). toHaveProperty ( "format" );
999 expect (props). toHaveProperty ( "sample_rate_hz" );
1000 expect (props). toHaveProperty ( "channels" );
1001 expect (props). toHaveProperty ( "bit_rate_bps" );
1002 expect (props). toHaveProperty ( "file_size_bytes" );
1003 expect (props). toHaveProperty ( "duration_ms" );
1004 expect (props). toHaveProperty ( "hash" );
1005 });
1006 });
1007
1008 describe ( "DeleteSongsResult shape matches Rust DeleteSongsResult" , () => {
1009 test ( "has deleted_song_ids and failed fields" , () => {
1010 const result : DeleteSongsResult = {
1011 deleted_song_ids: [ "abc" , "def" ],
1012 failed: [],
1013 };
1014 expect (result). toHaveProperty ( "deleted_song_ids" );
1015 expect (result). toHaveProperty ( "failed" );
1016 });
1017
1018 test ( "DeleteSongsFailure has song_id and error fields" , () => {
1019 const result : DeleteSongsResult = {
1020 deleted_song_ids: [],
1021 failed: [
1022 {
1023 song_id: "abc" ,
1024 error: {
1025 code: "database_unavailable" ,
1026 message: "db locked" ,
1027 retryable: true ,
1028 fallback: "retry" ,
1029 },
1030 },
1031 ],
1032 };
1033 expect (result.failed[ 0 ]). toHaveProperty ( "song_id" );
1034 expect (result.failed[ 0 ]). toHaveProperty ( "error" );
1035 });
1036 });
1037
1038 describe ( "ExtractEmbeddedCoverArtResult shape matches Rust" , () => {
1039 test ( "has updated_songs and failed fields" , () => {
1040 const result : ExtractEmbeddedCoverArtResult = {
1041 updated_songs: [],
1042 failed: [],
1043 };
1044 expect (result). toHaveProperty ( "updated_songs" );
1045 expect (result). toHaveProperty ( "failed" );
1046 });
1047 });
1048
1049 describe ( "ImportLyricsResult shape matches Rust ImportLyricsResult" , () => {
1050 test ( "has matched and unmatched fields" , () => {
1051 const result : ImportLyricsResult = {
1052 matched: [],
1053 unmatched: [],
1054 };
1055 expect (result). toHaveProperty ( "matched" );
1056 expect (result). toHaveProperty ( "unmatched" );
1057 });
1058
1059 test ( "LyricsMatch has song_id and lrc_path fields" , () => {
1060 const result : ImportLyricsResult = {
1061 matched: [
1062 {
1063 song_id: "abc" ,
1064 lrc_path: "/path/to/lyrics.lrc" ,
1065 song_title: "Test Song" ,
1066 song_artist: "Test Artist" ,
1067 },
1068 ],
1069 unmatched: [],
1070 };
1071 expect (result.matched[ 0 ]). toHaveProperty ( "song_id" );
1072 expect (result.matched[ 0 ]). toHaveProperty ( "lrc_path" );
1073 expect (result.matched[ 0 ]). toHaveProperty ( "song_title" );
1074 expect (result.matched[ 0 ]). toHaveProperty ( "song_artist" );
1075 });
1076 });
1077
1078 describe ( "AppSettings shape matches Rust AppSettings" , () => {
1079 test ( "has all fields from Rust serialization" , () => {
1080 const settings : AppSettings = {
1081 stem_mode: "two_stem" ,
1082 model_variant: "htdemucs" ,
1083 language: "en" ,
1084 hide_batch_separate: false ,
1085 cover_art_backdrop: true ,
1086 hide_upgrade_all: false ,
1087 lyrics_font_step: 0 ,
1088 execution_provider: "cpu" ,
1089 available_execution_providers: [ "cpu" , "xnnpack" ],
1090 eq_enabled: false ,
1091 eq_gains_db: [ 0 , 0 , 0 , 0 , 0 ],
1092 crossfade_enabled: false ,
1093 crossfade_duration_ms: 3_000 ,
1094 library_sort_mode: "recently_imported" ,
1095 theme_preference: "dark" ,
1096 update_policy: "notify" ,
1097 };
1098 expect (settings). toHaveProperty ( "stem_mode" );
1099 expect (settings). toHaveProperty ( "model_variant" );
1100 expect (settings). toHaveProperty ( "language" );
1101 expect (settings). toHaveProperty ( "hide_batch_separate" );
1102 expect (settings). toHaveProperty ( "cover_art_backdrop" );
1103 expect (settings). toHaveProperty ( "hide_upgrade_all" );
1104 expect (settings). toHaveProperty ( "lyrics_font_step" );
1105 expect (settings). toHaveProperty ( "execution_provider" );
1106 expect (settings). toHaveProperty ( "available_execution_providers" );
1107 expect (settings). toHaveProperty ( "eq_enabled" );
1108 expect (settings). toHaveProperty ( "eq_gains_db" );
1109 expect (settings). toHaveProperty ( "library_sort_mode" );
1110 expect (settings). toHaveProperty ( "theme_preference" );
1111 expect (settings). toHaveProperty ( "update_policy" );
1112 });
1113
1114 test ( "stem_mode values match Rust StemMode enum" , () => {
1115 const validModes : AppSettings [ "stem_mode" ][] = [ "two_stem" , "four_stem" ];
1116 for ( const mode of validModes) {
1117 expect ([ "two_stem" , "four_stem" ]). toContain (mode);
1118 }
1119 });
1120
1121 test ( "model_variant values match Rust ModelVariant enum" , () => {
1122 const validVariants : AppSettings [ "model_variant" ][] = [
1123 "htdemucs" ,
1124 "htdemucs_ft" ,
1125 ];
1126 for ( const variant of validVariants) {
1127 expect ([ "htdemucs" , "htdemucs_ft" ]). toContain (variant);
1128 }
1129 });
1130
1131 test ( "execution_provider values match Rust ExecutionProvider enum" , () => {
1132 const validProviders : AppSettings [ "execution_provider" ][] = [
1133 "cpu" ,
1134 "xnnpack" ,
1135 "directml" ,
1136 ];
1137 for ( const provider of validProviders) {
1138 expect ([ "cpu" , "xnnpack" , "directml" ]). toContain (provider);
1139 }
1140 });
1141
1142 test ( "library_sort_mode values match Rust LibrarySortMode enum" , () => {
1143 const validModes : AppSettings [ "library_sort_mode" ][] = [
1144 "recently_imported" ,
1145 "title_asc" ,
1146 "artist_asc" ,
1147 ];
1148 for ( const mode of validModes) {
1149 expect ([ "recently_imported" , "title_asc" , "artist_asc" ]). toContain (mode);
1150 }
1151 });
1152 });
1153
1154 describe ( "Event payload shapes" , () => {
1155 test ( "PlaybackPositionEvent has ms and snapshot fields" , () => {
1156 // Import the type at the module level is sufficient; here we validate
1157 // the shape by constructing a compatible object
1158 const event = {
1159 ms: 1234 ,
1160 transport_generation: 1 ,
1161 snapshot: {
1162 song_id: "abc123" ,
1163 transport_generation: 1 ,
1164 state: "playing" as const ,
1165 is_playing: true ,
1166 position_ms: 1234 ,
1167 duration_ms: 180000 ,
1168 buffered_ms: 180000 ,
1169 volume: 1.0 ,
1170 stem_volumes: { vocals: 1.0 , drums: 1.0 , bass: 1.0 , other: 1.0 },
1171 has_stems: false ,
1172 stem_mode: null ,
1173 },
1174 };
1175 expect (event). toHaveProperty ( "ms" );
1176 expect (event). toHaveProperty ( "transport_generation" );
1177 expect (event). toHaveProperty ( "snapshot" );
1178 });
1179
1180 test ( "PlaybackEndedEvent has song_id field" , () => {
1181 const event = { song_id: "abc123" };
1182 expect (event). toHaveProperty ( "song_id" );
1183 });
1184
1185 test ( "PlaybackErrorEvent has song_id and error fields" , () => {
1186 const event = {
1187 song_id: "abc123" ,
1188 error: {
1189 code: "audio_decode_failed" as const ,
1190 message: "decode failed" ,
1191 retryable: false ,
1192 fallback: "reimport_song" as const ,
1193 },
1194 };
1195 expect (event). toHaveProperty ( "song_id" );
1196 expect (event). toHaveProperty ( "error" );
1197 expect (event.error). toHaveProperty ( "code" );
1198 expect (event.error). toHaveProperty ( "retryable" );
1199 });
1200
1201 test ( "SeparationProgressEvent has song_id and percent fields" , () => {
1202 const event = { song_id: "abc123" , percent: 50 };
1203 expect (event). toHaveProperty ( "song_id" );
1204 expect (event). toHaveProperty ( "percent" );
1205 });
1206
1207 test ( "SeparationCompleteEvent has song_id and status fields" , () => {
1208 const event = {
1209 song_id: "abc123" ,
1210 status: {
1211 song_id: "abc123" ,
1212 state: "completed" as const ,
1213 percent: 100 ,
1214 cache_hit: true ,
1215 vocals_path: "/path/vocals.ogg" ,
1216 accomp_path: "/path/accomp.ogg" ,
1217 drums_path: null ,
1218 bass_path: null ,
1219 other_path: null ,
1220 model_variant: "htdemucs" ,
1221 error: null ,
1222 },
1223 };
1224 expect (event). toHaveProperty ( "song_id" );
1225 expect (event). toHaveProperty ( "status" );
1226 });
1227
1228 test ( "SeparationErrorEvent has song_id and error fields" , () => {
1229 const event = {
1230 song_id: "abc123" ,
1231 error: {
1232 code: "separation_failed" as const ,
1233 message: "model failed" ,
1234 retryable: true ,
1235 fallback: "stay_in_original_mode" as const ,
1236 },
1237 };
1238 expect (event). toHaveProperty ( "song_id" );
1239 expect (event). toHaveProperty ( "error" );
1240 });
1241 });
1242
1243 describe ( "Serialization compatibility" , () => {
1244 test ( "PlaybackStateSnapshot uses snake_case field names (no rename_all on Rust struct)" , () => {
1245 // The Rust PlaybackStateSnapshot struct does NOT use #[serde(rename_all)],
1246 // so fields serialize as-is in snake_case. The TypeScript type must match.
1247 const snapshot : PlaybackStateSnapshot = {
1248 song_id: "x" , // not songId
1249 transport_generation: 1 , // not transportGeneration
1250 state: "idle" ,
1251 is_playing: false , // not isPlaying
1252 position_ms: 0 , // not positionMs
1253 duration_ms: null , // not durationMs
1254 buffered_ms: 0 , // not bufferedMs
1255 volume: 1.0 ,
1256 stem_volumes: { vocals: 1.0 , drums: 1.0 , bass: 1.0 , other: 1.0 }, // not stemVolumes
1257 has_stems: false , // not hasStems
1258 stem_mode: null , // not stemMode
1259 };
1260 expect (snapshot.song_id). toBeDefined ();
1261 expect (snapshot.transport_generation). toBeDefined ();
1262 expect (snapshot.is_playing). toBeDefined ();
1263 expect (snapshot.position_ms). toBeDefined ();
1264 });
1265
1266 test ( "Song uses snake_case field names" , () => {
1267 const song : Song = {
1268 hash: "x" ,
1269 file_path: null , // not filePath
1270 audio_source_kind: "original" , // not audioSourceKind
1271 cdg_path: null , // not cdgPath
1272 media_g_container: null , // not mediaGContainer
1273 instrumental: false ,
1274 language: null ,
1275 title: null ,
1276 artist: null ,
1277 album: null ,
1278 duration_ms: 0 , // not durationMs
1279 cover_art: null , // not coverArt
1280 has_cover_art: false ,
1281 artwork_thumb_path: null ,
1282 imported_at: 0 , // not importedAt
1283 original_ext: null , // not originalExt
1284 };
1285 expect (song.file_path). toBeDefined ();
1286 expect (song.audio_source_kind). toBeDefined ();
1287 expect (song.duration_ms). toBeDefined ();
1288 });
1289
1290 test ( "SeparationStatusSnapshot uses snake_case field names" , () => {
1291 const snapshot : SeparationStatusSnapshot = {
1292 song_id: "x" ,
1293 state: "idle" ,
1294 percent: 0 ,
1295 cache_hit: false , // not cacheHit
1296 vocals_path: null , // not vocalsPath
1297 accomp_path: null , // not accompPath
1298 drums_path: null ,
1299 bass_path: null ,
1300 other_path: null ,
1301 model_variant: null , // not modelVariant
1302 error: null ,
1303 };
1304 expect (snapshot.cache_hit). toBeDefined ();
1305 expect (snapshot.vocals_path). toBeDefined ();
1306 expect (snapshot.model_variant). toBeDefined ();
1307 });
1308
1309 test ( "LyricsPayload uses snake_case field names" , () => {
1310 const payload : LyricsPayload = {
1311 song_id: "x" , // not songId
1312 lines: [],
1313 source: null ,
1314 offset_ms: 0 , // not offsetMs
1315 raw_lrc: "" , // not rawLrc
1316 };
1317 expect (payload.song_id). toBeDefined ();
1318 expect (payload.offset_ms). toBeDefined ();
1319 expect (payload.raw_lrc). toBeDefined ();
1320 });
1321
1322 test ( "CommandError uses snake_case for retryable (no rename needed)" , () => {
1323 const error : CommandError = {
1324 code: "internal" ,
1325 message: "test" ,
1326 retryable: false ,
1327 fallback: "retry" ,
1328 };
1329 // retryable is already lowercase, no rename issue
1330 expect (error.retryable). toBe ( false );
1331 expect (error.fallback). toBe ( "retry" );
1332 });
1333 });
1334