tauri-plugin-hot-update 0.1.1

Hot update / OTA live updates for Tauri v2 mobile and desktop apps — CodePush-style, self-hosted
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
//! Exhaustive transition tests for the rollback state machine — every
//! anti-brick scenario from the WP2 plan, as pure state-in/state-out checks.

use std::collections::BTreeSet;

use super::*;

fn v(s: &str) -> Version {
    Version::parse(s).unwrap()
}

fn meta(version: &str, sha: &str) -> BundleMeta {
    BundleMeta {
        version: v(version),
        archive_sha256: sha.to_string(),
    }
}

fn present(seqs: &[u64]) -> BTreeSet<u64> {
    seqs.iter().copied().collect()
}

fn stage_ok(state: State, seq: u64, m: BundleMeta) -> (State, Vec<Effect>) {
    let (state, result) = stage(state, seq, m);
    let effects = result.expect("stage should succeed");
    (state, effects)
}

/// Build a state with `seq` committed at `version` via real transitions
/// (fresh boot → stage → trial boot → ack), embedded at 1.0.0.
fn committed_state(seq: u64, version: &str, sha: &str) -> State {
    let boot = resolve_boot(State::default(), &v("1.0.0"), &present(&[]));
    let (state, _) = stage_ok(boot.state, seq, meta(version, sha));
    let boot = resolve_boot(state, &v("1.0.0"), &present(&[seq]));
    assert_eq!(boot.active, Active::Ota(seq));
    let (state, outcome, _) = ack(boot.state, boot.active);
    assert_eq!(outcome, AckOutcome::Committed(seq));
    state
}

// ---------------------------------------------------------------- fresh boot

#[test]
fn fresh_install_serves_embedded_and_seeds_watermark() {
    let outcome = resolve_boot(State::default(), &v("1.0.0"), &present(&[]));
    assert_eq!(outcome.active, Active::Embedded);
    assert_eq!(outcome.rolled_back, None);
    assert!(outcome.effects.is_empty());
    assert_eq!(outcome.state.max_version_seen, Some(v("1.0.0")));
    assert_eq!(
        outcome.state,
        State {
            max_version_seen: Some(v("1.0.0")),
            ..State::default()
        }
    );
}

#[test]
fn boot_with_nothing_pending_is_idempotent() {
    let first = resolve_boot(State::default(), &v("1.0.0"), &present(&[]));
    let second = resolve_boot(first.state.clone(), &v("1.0.0"), &present(&[]));
    assert_eq!(second.state, first.state);
    assert_eq!(second.active, Active::Embedded);
    assert!(second.effects.is_empty());
}

// ------------------------------------------------- staged → booting → commit

#[test]
fn staged_bundle_is_armed_on_next_boot() {
    let boot = resolve_boot(State::default(), &v("1.0.0"), &present(&[]));
    let (state, effects) = stage_ok(boot.state, 1, meta("1.1.0", "sha-1"));
    assert!(effects.is_empty());
    assert_eq!(state.staged, Some(1));

    let boot = resolve_boot(state, &v("1.0.0"), &present(&[1]));
    assert_eq!(boot.active, Active::Ota(1));
    assert_eq!(boot.state.booting, Some(1));
    assert_eq!(boot.state.staged, None, "staged pointer consumed by arming");
    assert_eq!(boot.state.committed, None, "not committed until acked");
    assert!(boot.effects.is_empty());
}

#[test]
fn ack_commits_the_armed_bundle_and_raises_watermark() {
    let boot = resolve_boot(State::default(), &v("1.0.0"), &present(&[]));
    let (state, _) = stage_ok(boot.state, 1, meta("1.1.0", "sha-1"));
    let boot = resolve_boot(state, &v("1.0.0"), &present(&[1]));

    let (state, outcome, effects) = ack(boot.state, boot.active);
    assert_eq!(outcome, AckOutcome::Committed(1));
    assert_eq!(state.committed, Some(1));
    assert_eq!(state.last_good, Some(1));
    assert_eq!(state.booting, None);
    assert_eq!(state.max_version_seen, Some(v("1.1.0")));
    assert!(effects.is_empty(), "nothing replaced on first commit");
    assert!(state.failed.is_empty());
}

#[test]
fn ack_is_idempotent() {
    let state = committed_state(1, "1.1.0", "sha-1");
    let (after, outcome, effects) = ack(state.clone(), Active::Ota(1));
    assert_eq!(outcome, AckOutcome::AlreadyCommitted(1));
    assert_eq!(after, state);
    assert!(effects.is_empty());
}

#[test]
fn ack_while_serving_embedded_is_a_noop() {
    let boot = resolve_boot(State::default(), &v("1.0.0"), &present(&[]));
    let (after, outcome, effects) = ack(boot.state.clone(), Active::Embedded);
    assert_eq!(outcome, AckOutcome::EmbeddedNoop);
    assert_eq!(after, boot.state);
    assert!(effects.is_empty());
}

#[test]
fn ack_replacing_a_previous_commit_deletes_it() {
    let state = committed_state(1, "1.1.0", "sha-1");
    let (state, _) = stage_ok(state, 2, meta("1.2.0", "sha-2"));
    let boot = resolve_boot(state, &v("1.0.0"), &present(&[1, 2]));
    assert_eq!(boot.active, Active::Ota(2));

    let (state, outcome, effects) = ack(boot.state, boot.active);
    assert_eq!(outcome, AckOutcome::Committed(2));
    assert_eq!(state.committed, Some(2));
    assert_eq!(effects, vec![Effect::DeleteBundle(1)]);
    assert!(!state.versions.contains_key(&1), "replaced bundle pruned");
}

// --------------------------------------------------- rollback (no-ack ⇒ fail)

#[test]
fn unacked_trial_boot_rolls_back_and_blacklists_by_hash() {
    let boot = resolve_boot(State::default(), &v("1.0.0"), &present(&[]));
    let (state, _) = stage_ok(boot.state, 1, meta("1.1.0", "sha-1"));
    let boot = resolve_boot(state, &v("1.0.0"), &present(&[1]));
    assert_eq!(boot.state.booting, Some(1));

    // Two-strike softening (design §4): the first unacked launch re-arms the
    // same bundle rather than condemning it — a desktop app is often quit
    // before notifyAppReady().
    let boot = resolve_boot(boot.state, &v("1.0.0"), &present(&[1]));
    assert_eq!(boot.rolled_back, None, "re-armed, not yet rolled back");
    assert_eq!(boot.state.booting, Some(1));
    assert!(
        boot.state.failed.is_empty(),
        "not blacklisted on the first miss"
    );

    // The second unacked launch condemns it: blacklist by archive hash + revert.
    let boot = resolve_boot(boot.state, &v("1.0.0"), &present(&[1]));
    assert_eq!(boot.rolled_back, Some(1));
    assert_eq!(
        boot.active,
        Active::Embedded,
        "no committed bundle to fall back to"
    );
    assert_eq!(boot.state.booting, None);
    assert!(
        boot.state.failed.contains("sha-1"),
        "blacklisted by archive hash"
    );
    assert_eq!(boot.effects, vec![Effect::DeleteBundle(1)]);
    assert!(!boot.state.versions.contains_key(&1));
}

#[test]
fn rollback_falls_back_to_the_committed_bundle() {
    let state = committed_state(1, "1.1.0", "sha-1");
    let (state, _) = stage_ok(state, 2, meta("1.2.0", "sha-2"));
    let boot = resolve_boot(state, &v("1.0.0"), &present(&[1, 2]));
    assert_eq!(boot.active, Active::Ota(2));

    // Two unacked launches condemn seq 2 (design §4); the first only re-arms.
    let boot = resolve_boot(boot.state, &v("1.0.0"), &present(&[1, 2]));
    assert_eq!(boot.active, Active::Ota(2), "re-armed for a second trial");
    assert_eq!(boot.rolled_back, None);
    let boot = resolve_boot(boot.state, &v("1.0.0"), &present(&[1, 2]));
    assert_eq!(boot.rolled_back, Some(2));
    assert_eq!(boot.active, Active::Ota(1), "last-good keeps serving");
    assert!(boot.state.failed.contains("sha-2"));
    assert_eq!(boot.effects, vec![Effect::DeleteBundle(2)]);
    assert_eq!(boot.state.committed, Some(1));
}

#[test]
fn failed_hash_is_never_staged_again() {
    let state = committed_state(1, "1.1.0", "sha-1");
    let (state, _) = stage_ok(state, 2, meta("1.2.0", "sha-2"));
    let boot = resolve_boot(state, &v("1.0.0"), &present(&[1, 2]));
    // Two unacked launches to cross the blacklist threshold (design §4).
    let boot = resolve_boot(boot.state, &v("1.0.0"), &present(&[1, 2]));
    let boot = resolve_boot(boot.state, &v("1.0.0"), &present(&[1, 2]));
    assert!(boot.state.failed.contains("sha-2"));

    // The same archive under a new seq is refused; a *fixed* deploy ships
    // under a new hash and passes.
    let (state, result) = stage(boot.state, 3, meta("1.2.0", "sha-2"));
    assert_eq!(result, Err(StageError::HashBlacklisted("sha-2".into())));
    let (_, result) = stage(state, 3, meta("1.2.0", "sha-2-fixed"));
    assert!(result.is_ok());
}

#[test]
fn boot_never_arms_a_blacklisted_hash() {
    // Defense in depth: even if a staged pointer references a blacklisted
    // archive (downloader bug, hand-edited state), arming refuses it.
    let mut state = committed_state(1, "1.1.0", "sha-1");
    state.staged = Some(2);
    state.versions.insert(2, meta("1.2.0", "sha-bad"));
    state.failed.insert("sha-bad".to_string());

    let boot = resolve_boot(state, &v("1.0.0"), &present(&[1, 2]));
    assert_eq!(boot.active, Active::Ota(1));
    assert_eq!(boot.state.booting, None);
    assert_eq!(boot.effects, vec![Effect::DeleteBundle(2)]);
}

#[test]
fn crash_loop_converges_to_committed() {
    let mut state = committed_state(1, "1.1.0", "sha-1");
    for (seq, sha) in [(2, "sha-2"), (3, "sha-3")] {
        let (s, _) = stage_ok(state, seq, meta(&format!("1.{seq}.0"), sha));
        let armed = resolve_boot(s, &v("1.0.0"), &present(&[1, seq]));
        assert_eq!(armed.active, Active::Ota(seq));
        // Crash: no ack. Under the two-strike rule the first unacked boot only
        // re-arms; the second rolls back to the committed bundle.
        let rearmed = resolve_boot(armed.state, &v("1.0.0"), &present(&[1, seq]));
        assert_eq!(rearmed.active, Active::Ota(seq), "re-armed once");
        let rolled = resolve_boot(rearmed.state, &v("1.0.0"), &present(&[1, seq]));
        assert_eq!(rolled.active, Active::Ota(1));
        state = rolled.state;
    }
    assert!(state.failed.contains("sha-2") && state.failed.contains("sha-3"));

    // With nothing staged the state is now a fixed point.
    let again = resolve_boot(state.clone(), &v("1.0.0"), &present(&[1]));
    assert_eq!(again.active, Active::Ota(1));
    assert_eq!(again.state, state);
    assert!(again.effects.is_empty());
}

#[test]
fn crash_loop_with_no_committed_converges_to_embedded() {
    let boot = resolve_boot(State::default(), &v("1.0.0"), &present(&[]));
    let (state, _) = stage_ok(boot.state, 1, meta("1.1.0", "sha-1"));
    let armed = resolve_boot(state, &v("1.0.0"), &present(&[1]));
    // Two-strike rule: the first unacked boot re-arms, the second rolls back.
    let rearmed = resolve_boot(armed.state, &v("1.0.0"), &present(&[1]));
    assert_eq!(
        rearmed.active,
        Active::Ota(1),
        "re-armed once before rollback"
    );
    let rolled = resolve_boot(rearmed.state, &v("1.0.0"), &present(&[1]));
    assert_eq!(rolled.active, Active::Embedded);

    let again = resolve_boot(rolled.state.clone(), &v("1.0.0"), &present(&[]));
    assert_eq!(again.active, Active::Embedded);
    assert_eq!(again.state, rolled.state);
}

#[test]
fn rollback_of_a_seq_without_metadata_does_not_panic() {
    let state = State {
        booting: Some(9), // corrupt-ish: no versions entry for 9
        ..State::default()
    };
    let boot = resolve_boot(state, &v("1.0.0"), &present(&[]));
    assert_eq!(boot.rolled_back, Some(9));
    assert_eq!(boot.active, Active::Embedded);
    assert!(
        boot.state.failed.is_empty(),
        "no hash known, nothing to blacklist"
    );
}

// ------------------------------------------- two-strike rollback (design §4)
//
// Desktop sessions are long and launches rare, so a *good* bundle whose
// process quit before `notifyAppReady()` must not be blacklisted on a single
// miss. A bundle is re-armed once (strike 1) and only blacklisted after a
// *second* consecutive unacked launch (strike 2). Absence of the ack across
// launches is still the only failure signal — no boot timer.

#[test]
fn first_unacked_launch_rearms_the_same_seq_without_blacklisting() {
    let boot = resolve_boot(State::default(), &v("1.0.0"), &present(&[]));
    let (state, _) = stage_ok(boot.state, 1, meta("1.1.0", "sha-1"));
    let armed = resolve_boot(state, &v("1.0.0"), &present(&[1]));
    assert_eq!(armed.state.booting, Some(1));
    assert_eq!(
        armed.state.booting_strikes, 0,
        "a fresh trial starts at zero"
    );

    // First unacked launch: re-armed, same seq, nothing blacklisted.
    let rearmed = resolve_boot(armed.state, &v("1.0.0"), &present(&[1]));
    assert_eq!(
        rearmed.active,
        Active::Ota(1),
        "served again for a second trial"
    );
    assert_eq!(rearmed.state.booting, Some(1), "same seq re-armed");
    assert_eq!(rearmed.state.booting_strikes, 1);
    assert_eq!(rearmed.rolled_back, None, "not a rollback yet");
    assert!(
        rearmed.state.failed.is_empty(),
        "not blacklisted on the first miss"
    );
    assert!(rearmed.effects.is_empty(), "bundle dir kept for the retry");
}

#[test]
fn second_unacked_launch_blacklists_by_hash_and_rolls_back() {
    let boot = resolve_boot(State::default(), &v("1.0.0"), &present(&[]));
    let (state, _) = stage_ok(boot.state, 1, meta("1.1.0", "sha-1"));
    let armed = resolve_boot(state, &v("1.0.0"), &present(&[1]));
    let rearmed = resolve_boot(armed.state, &v("1.0.0"), &present(&[1]));
    assert_eq!(rearmed.state.booting_strikes, 1);

    // Second unacked launch: now the bundle is condemned.
    let rolled = resolve_boot(rearmed.state, &v("1.0.0"), &present(&[1]));
    assert_eq!(rolled.rolled_back, Some(1));
    assert_eq!(
        rolled.active,
        Active::Embedded,
        "no committed bundle to fall back to"
    );
    assert_eq!(rolled.state.booting, None);
    assert_eq!(
        rolled.state.booting_strikes, 0,
        "counter cleared on blacklist"
    );
    assert!(
        rolled.state.failed.contains("sha-1"),
        "blacklisted by archive hash"
    );
    assert_eq!(rolled.effects, vec![Effect::DeleteBundle(1)]);
    assert!(!rolled.state.versions.contains_key(&1));
}

#[test]
fn ack_on_the_second_trial_clears_strikes_and_never_blacklists() {
    let boot = resolve_boot(State::default(), &v("1.0.0"), &present(&[]));
    let (state, _) = stage_ok(boot.state, 1, meta("1.1.0", "sha-1"));
    let armed = resolve_boot(state, &v("1.0.0"), &present(&[1]));
    // One unacked launch leaves a strike on the board.
    let rearmed = resolve_boot(armed.state, &v("1.0.0"), &present(&[1]));
    assert_eq!(rearmed.state.booting_strikes, 1);

    // This launch DOES ack: the bundle is good after all — commit and reset.
    let (state, outcome, _) = ack(rearmed.state, rearmed.active);
    assert_eq!(outcome, AckOutcome::Committed(1));
    assert_eq!(state.committed, Some(1));
    assert_eq!(state.booting, None);
    assert_eq!(
        state.booting_strikes, 0,
        "a committed bundle clears its strikes"
    );
    assert!(
        state.failed.is_empty(),
        "an acked bundle is never blacklisted"
    );
}

#[test]
fn promoting_a_fresh_bundle_clears_a_leftover_strike_count() {
    // A stray strike count must never bleed into a newly promoted bundle, or
    // that bundle would be condemned after a single miss instead of two.
    let state = committed_state(1, "1.1.0", "sha-1");
    let (mut state, _) = stage_ok(state, 2, meta("1.2.0", "sha-2"));
    state.booting_strikes = 7; // defensive: a leftover the ack should have cleared

    let armed = resolve_boot(state, &v("1.0.0"), &present(&[1, 2]));
    assert_eq!(armed.state.booting, Some(2), "staged bundle promoted");
    assert_eq!(
        armed.state.booting_strikes, 0,
        "promotion resets the counter"
    );

    // Prove the fresh trial really started clean: its FIRST miss only re-arms.
    let rearmed = resolve_boot(armed.state, &v("1.0.0"), &present(&[1, 2]));
    assert_eq!(rearmed.active, Active::Ota(2));
    assert_eq!(rearmed.state.booting_strikes, 1);
    assert!(
        rearmed.state.failed.is_empty(),
        "not blacklisted on the first miss"
    );
}

#[test]
fn strike_one_rearm_does_not_promote_a_waiting_staged_bundle() {
    // While a bundle is on its second trial (booting occupied), a freshly
    // staged bundle must wait its turn rather than jump the queue.
    let boot = resolve_boot(State::default(), &v("1.0.0"), &present(&[]));
    let (state, _) = stage_ok(boot.state, 1, meta("1.1.0", "sha-1"));
    let armed = resolve_boot(state, &v("1.0.0"), &present(&[1]));
    assert_eq!(armed.state.booting, Some(1));

    // A newer bundle stages during the first trial session.
    let (state, _) = stage_ok(armed.state, 2, meta("1.2.0", "sha-2"));
    assert_eq!(state.staged, Some(2));

    // First unacked launch: seq 1 is re-armed (strike 1); seq 2 stays staged.
    let rearmed = resolve_boot(state, &v("1.0.0"), &present(&[1, 2]));
    assert_eq!(
        rearmed.active,
        Active::Ota(1),
        "the trial bundle keeps its turn"
    );
    assert_eq!(rearmed.state.booting, Some(1));
    assert_eq!(rearmed.state.booting_strikes, 1);
    assert_eq!(
        rearmed.state.staged,
        Some(2),
        "the queued bundle waits for the next boot"
    );
    assert!(rearmed.effects.is_empty(), "neither bundle GC'd");

    // Second unacked launch: seq 1 is blacklisted AND seq 2 finally promotes.
    let next = resolve_boot(rearmed.state, &v("1.0.0"), &present(&[1, 2]));
    assert_eq!(next.rolled_back, Some(1));
    assert!(next.state.failed.contains("sha-1"));
    assert_eq!(
        next.state.booting,
        Some(2),
        "queued bundle promoted after the rollback"
    );
    assert_eq!(next.state.booting_strikes, 0, "fresh trial for seq 2");
    assert_eq!(next.active, Active::Ota(2));
    assert_eq!(next.effects, vec![Effect::DeleteBundle(1)]);
}

#[test]
fn a_store_update_landing_mid_trial_supersedes_the_booting_bundle() {
    // A Sparkle/native update can ship a newer embedded frontend while an OTA
    // trial is still mid-flight. On the next launch the stale trial bundle
    // must NOT be re-served (that would regress the frontend below the freshly
    // installed native version); the newer embedded wins, and the
    // good-but-stale bundle is discarded — not blacklisted.
    let boot = resolve_boot(State::default(), &v("1.1.2"), &present(&[]));
    let (state, _) = stage_ok(boot.state, 1, meta("1.1.3-ota.1", "sha-1"));
    let armed = resolve_boot(state, &v("1.1.2"), &present(&[1]));
    assert_eq!(
        armed.active,
        Active::Ota(1),
        "trial armed under the old embedded"
    );
    assert_eq!(armed.state.booting, Some(1));

    // Native update to 1.1.3 lands; the trial never acked.
    let boot = resolve_boot(armed.state, &v("1.1.3"), &present(&[1]));
    assert_eq!(
        boot.active,
        Active::Embedded,
        "the newer embedded frontend wins"
    );
    assert_eq!(boot.state.booting, None);
    assert_eq!(
        boot.rolled_back, None,
        "supersession is not a failure rollback"
    );
    assert!(
        boot.state.failed.is_empty(),
        "a superseded bundle is not blacklisted"
    );
    assert_eq!(boot.effects, vec![Effect::DeleteBundle(1)]);
    assert_eq!(boot.state.max_version_seen, Some(v("1.1.3")));
}

// --------------------------------- download finishing during a trial session

#[test]
fn ack_commits_the_booted_seq_not_a_freshly_staged_one() {
    let boot = resolve_boot(State::default(), &v("1.0.0"), &present(&[]));
    let (state, _) = stage_ok(boot.state, 1, meta("1.1.0", "sha-1"));
    let boot = resolve_boot(state, &v("1.0.0"), &present(&[1]));
    assert_eq!(boot.active, Active::Ota(1));

    // A download completes mid-session and stages seq 2.
    let (state, effects) = stage_ok(boot.state, 2, meta("1.2.0", "sha-2"));
    assert!(effects.is_empty());
    assert_eq!(state.booting, Some(1));
    assert_eq!(state.staged, Some(2));

    // The ack must commit seq 1 (the in-memory booted seq) and leave the
    // fresh staged bundle for its own trial boot.
    let (state, outcome, _) = ack(state, boot.active);
    assert_eq!(outcome, AckOutcome::Committed(1));
    assert_eq!(state.committed, Some(1));
    assert_eq!(state.staged, Some(2), "fresh download stays staged");

    let boot = resolve_boot(state, &v("1.0.0"), &present(&[1, 2]));
    assert_eq!(
        boot.active,
        Active::Ota(2),
        "staged bundle gets its own trial"
    );
}

// ------------------------------------------------- embedded-newer-than-OTA

#[test]
fn store_update_newer_than_committed_ota_discards_it() {
    let state = committed_state(1, "1.1.0", "sha-1");
    let boot = resolve_boot(state, &v("1.2.0"), &present(&[1]));
    assert_eq!(boot.active, Active::Embedded);
    assert_eq!(boot.state.committed, None);
    assert_eq!(boot.state.last_good, None);
    assert_eq!(boot.effects, vec![Effect::DeleteBundle(1)]);
    assert_eq!(boot.state.max_version_seen, Some(v("1.2.0")));
}

#[test]
fn store_update_equal_to_committed_ota_discards_it() {
    // A store release at the same version wins: never shadow it.
    let state = committed_state(1, "1.1.0", "sha-1");
    let boot = resolve_boot(state, &v("1.1.0"), &present(&[1]));
    assert_eq!(boot.active, Active::Embedded);
    assert_eq!(boot.effects, vec![Effect::DeleteBundle(1)]);
}

#[test]
fn older_embedded_keeps_serving_the_committed_ota() {
    let state = committed_state(1, "1.1.0", "sha-1");
    let boot = resolve_boot(state, &v("1.0.5"), &present(&[1]));
    assert_eq!(boot.active, Active::Ota(1));
    assert_eq!(boot.state.committed, Some(1));
    assert!(boot.effects.is_empty());
}

#[test]
fn store_update_newer_than_staged_discards_it_before_arming() {
    let boot = resolve_boot(State::default(), &v("1.0.0"), &present(&[]));
    let (state, _) = stage_ok(boot.state, 1, meta("1.1.0", "sha-1"));

    // Store update to 1.1.0 lands before the next boot: staged 1.1.0 is not newer.
    let boot = resolve_boot(state, &v("1.1.0"), &present(&[1]));
    assert_eq!(boot.active, Active::Embedded);
    assert_eq!(boot.state.booting, None);
    assert_eq!(boot.effects, vec![Effect::DeleteBundle(1)]);
}

#[test]
fn staged_ota_newer_than_the_store_update_still_arms() {
    let boot = resolve_boot(State::default(), &v("1.0.0"), &present(&[]));
    let (state, _) = stage_ok(boot.state, 1, meta("1.3.0", "sha-1"));
    let boot = resolve_boot(state, &v("1.2.0"), &present(&[1]));
    assert_eq!(boot.active, Active::Ota(1));
}

// ---------------------------------------- prerelease supersession (design §2)
//
// OTA bundles are versioned as prereleases of the next native release
// (`<next-patch>-ota.N`), so a full-app release always outranks its own
// outstanding OTA bundle by SemVer 2.0 precedence (`1.1.3 > 1.1.3-ota.1`).
// This is the exact comparison a desktop Sparkle-over-OTA update produces on
// every launch, and it must be honored at `resolve_boot`.

#[test]
fn committed_prerelease_is_discarded_when_its_release_ships_embedded() {
    // The production case: a committed `1.1.3-ota.1` bundle meets an embedded
    // `1.1.3` (the released base). The release outranks its own prerelease, so
    // the OTA bundle is discarded and the watermark rises to the release.
    let state = committed_state(1, "1.1.3-ota.1", "sha-1");
    let boot = resolve_boot(state, &v("1.1.3"), &present(&[1]));
    assert_eq!(boot.active, Active::Embedded);
    assert_eq!(boot.state.committed, None, "committed prerelease discarded");
    assert_eq!(boot.state.last_good, None);
    assert_eq!(boot.effects, vec![Effect::DeleteBundle(1)]);
    assert_eq!(boot.state.max_version_seen, Some(v("1.1.3")));
}

#[test]
fn staged_prerelease_is_not_armed_when_its_release_ships_embedded() {
    // Sparkle staged its update during a long session: on the next boot the
    // embedded frontend is already `1.1.3`, so a staged `1.1.3-ota.1` (a
    // prerelease of that same release) must not arm — embedded serves.
    let boot = resolve_boot(State::default(), &v("1.0.0"), &present(&[]));
    let (state, _) = stage_ok(boot.state, 1, meta("1.1.3-ota.1", "sha-1"));
    let boot = resolve_boot(state, &v("1.1.3"), &present(&[1]));
    assert_eq!(boot.active, Active::Embedded);
    assert_eq!(
        boot.state.booting, None,
        "prerelease of the shipped base never arms"
    );
    assert_eq!(boot.effects, vec![Effect::DeleteBundle(1)]);
    assert_eq!(boot.state.max_version_seen, Some(v("1.1.3")));
}

#[test]
fn committed_prerelease_ahead_of_embedded_is_kept() {
    // Guard against over-eager discard: a committed `1.1.3-ota.4` is a
    // prerelease of the *next* release, and the embedded frontend is still the
    // previous `1.1.2`. `1.1.3-ota.4 > 1.1.2`, so the OTA bundle keeps serving.
    let state = committed_state(1, "1.1.3-ota.4", "sha-1");
    let boot = resolve_boot(state, &v("1.1.2"), &present(&[1]));
    assert_eq!(boot.active, Active::Ota(1), "OTA still ahead of embedded");
    assert_eq!(boot.state.committed, Some(1));
    assert!(boot.effects.is_empty());
}

// ------------------------------------------------------ watermark monotonicity

#[test]
fn watermark_never_goes_down() {
    let boot = resolve_boot(State::default(), &v("1.3.0"), &present(&[]));
    assert_eq!(boot.state.max_version_seen, Some(v("1.3.0")));

    // A boot under an older embedded version (should not happen, but the
    // watermark must be monotonic regardless).
    let boot = resolve_boot(boot.state, &v("1.2.0"), &present(&[]));
    assert_eq!(boot.state.max_version_seen, Some(v("1.3.0")));

    // Committing raises it to the bundle version.
    let (state, _) = stage_ok(boot.state, 1, meta("1.5.0", "sha-1"));
    let boot = resolve_boot(state, &v("1.2.0"), &present(&[1]));
    let (state, _, _) = ack(boot.state, boot.active);
    assert_eq!(state.max_version_seen, Some(v("1.5.0")));

    let boot = resolve_boot(state, &v("1.4.0"), &present(&[1]));
    assert_eq!(boot.state.max_version_seen, Some(v("1.5.0")));
}

#[test]
fn stage_refuses_versions_at_or_below_the_watermark() {
    // Downgrade replay: an old validly-signed manifest must be refused.
    let state = committed_state(1, "1.3.0", "sha-1");
    let (state, result) = stage(state, 2, meta("1.3.0", "sha-2"));
    assert!(matches!(result, Err(StageError::VersionNotNewer { .. })));
    let (state, result) = stage(state, 2, meta("1.2.9", "sha-3"));
    assert!(matches!(result, Err(StageError::VersionNotNewer { .. })));
    let (state, result) = stage(state, 2, meta("1.3.1", "sha-4"));
    assert!(result.is_ok());
    assert_eq!(state.staged, Some(2));
}

// ------------------------------------------------------------ staging details

#[test]
fn staging_over_a_previous_staged_bundle_orphans_it() {
    let boot = resolve_boot(State::default(), &v("1.0.0"), &present(&[]));
    let (state, _) = stage_ok(boot.state, 1, meta("1.1.0", "sha-1"));
    let (state, effects) = stage_ok(state, 2, meta("1.2.0", "sha-2"));
    assert_eq!(state.staged, Some(2));
    assert_eq!(effects, vec![Effect::DeleteBundle(1)]);
    assert!(!state.versions.contains_key(&1));
}

#[test]
fn stage_refuses_a_seq_already_in_use() {
    let state = committed_state(1, "1.1.0", "sha-1");
    let (_, result) = stage(state, 1, meta("1.2.0", "sha-2"));
    assert_eq!(result, Err(StageError::SeqInUse(1)));
}

// ----------------------------------------------------- disk-truth validation

#[test]
fn orphan_dirs_are_ignored_and_garbage_collected() {
    // A kill between extraction and the state write leaves a dir with no
    // pointer to it: never served, swept at boot.
    let boot = resolve_boot(State::default(), &v("1.0.0"), &present(&[5]));
    assert_eq!(boot.active, Active::Embedded);
    assert_eq!(boot.effects, vec![Effect::DeleteBundle(5)]);
}

#[test]
fn staged_pointer_without_a_dir_is_discarded() {
    let boot = resolve_boot(State::default(), &v("1.0.0"), &present(&[]));
    let (state, _) = stage_ok(boot.state, 1, meta("1.1.0", "sha-1"));
    let boot = resolve_boot(state, &v("1.0.0"), &present(&[]));
    assert_eq!(boot.active, Active::Embedded);
    assert_eq!(boot.state.booting, None);
    assert!(!boot.state.versions.contains_key(&1));
}

#[test]
fn committed_pointer_without_a_dir_falls_back_to_embedded() {
    let state = committed_state(1, "1.1.0", "sha-1");
    let boot = resolve_boot(state, &v("1.0.0"), &present(&[]));
    assert_eq!(boot.active, Active::Embedded);
    assert_eq!(boot.state.committed, None);
    assert!(boot.effects.is_empty(), "nothing on disk to delete");
}

// --------------------------------------------------------------------- reset

#[test]
fn reset_returns_to_factory_state_but_spares_the_active_dir() {
    let state = committed_state(1, "1.1.0", "sha-1");
    let (state, _) = stage_ok(state, 2, meta("1.2.0", "sha-2"));

    let (fresh, effects) = reset(state, &v("1.0.0"), Active::Ota(1));
    assert_eq!(
        fresh,
        State {
            max_version_seen: Some(v("1.0.0")),
            ..State::default()
        }
    );
    // Seq 1 is being served right now — deleting it under the webview would
    // tear the UI; the next boot's GC sweeps it instead.
    assert_eq!(effects, vec![Effect::DeleteBundle(2)]);

    // Next boot: factory behavior, leftover dir swept.
    let boot = resolve_boot(fresh, &v("1.0.0"), &present(&[1]));
    assert_eq!(boot.active, Active::Embedded);
    assert_eq!(boot.effects, vec![Effect::DeleteBundle(1)]);
}

#[test]
fn reset_while_serving_embedded_deletes_everything() {
    let boot = resolve_boot(State::default(), &v("1.0.0"), &present(&[]));
    let (state, _) = stage_ok(boot.state, 1, meta("1.1.0", "sha-1"));
    let (fresh, effects) = reset(state, &v("1.0.0"), Active::Embedded);
    assert_eq!(effects, vec![Effect::DeleteBundle(1)]);
    assert_eq!(fresh.staged, None);
}

#[test]
fn ack_after_a_mid_session_reset_is_refused() {
    // reset() rewrote state while a trial boot was live: the ack no longer
    // matches and must not resurrect the bundle.
    let boot = resolve_boot(State::default(), &v("1.0.0"), &present(&[]));
    let (state, _) = stage_ok(boot.state, 1, meta("1.1.0", "sha-1"));
    let boot = resolve_boot(state, &v("1.0.0"), &present(&[1]));
    assert_eq!(boot.active, Active::Ota(1));

    let (fresh, _) = reset(boot.state, &v("1.0.0"), boot.active);
    let (after, outcome, effects) = ack(fresh.clone(), boot.active);
    assert_eq!(outcome, AckOutcome::Stale(1));
    assert_eq!(after, fresh);
    assert!(effects.is_empty());
}