fresh-editor 0.4.0

A lightweight, fast terminal-based text editor with LSP support and TypeScript plugins
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
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
//! End-to-end coverage for the dev-container *attach* flow against
//! the in-tree fake CLI ([`scripts/fake-devcontainer/`]).
//!
//! Drives Flow A from `docs/internal/FAKE_DEVCONTAINER_TEST_PLAN.md`:
//! launch the editor in a workspace with a `.devcontainer/devcontainer.json`,
//! accept the "Reopen in Container?" popup, and assert that the
//! container authority lands.
//!
//! No Docker daemon, no Node, no `@devcontainers/cli` install — the
//! harness's [`with_fake_devcontainer`] helper points the editor at
//! the bash shims that ship in-tree and an isolated state directory.
//!
//! Asserts go through the editor's public state surface
//! (`Authority::display_label`, on-disk state files, plugin global
//! state) — never internal popup state — per CONTRIBUTING.md §2.
//!
//! [`with_fake_devcontainer`]: crate::common::harness::HarnessOptions::with_fake_devcontainer

#![cfg(feature = "plugins")]

use crate::common::harness::{copy_plugin, copy_plugin_lib, EditorTestHarness, HarnessOptions};
use crossterm::event::{KeyCode, KeyModifiers};
use std::fs;
use std::path::Path;

/// Install the devcontainer plugin and its lib stubs into a workspace,
/// write a minimal `.devcontainer/devcontainer.json`, and return the
/// canonicalized workspace path. Canonicalize because the spawned
/// shell sees `/private/var/...` on macOS even when the test thinks
/// it's in `/var/...`; matching paths back to the workspace later
/// would otherwise fail.
fn set_up_workspace() -> (tempfile::TempDir, std::path::PathBuf) {
    fresh::i18n::set_locale("en");

    let temp = tempfile::tempdir().unwrap();
    let workspace = temp.path().canonicalize().unwrap();

    let dc = workspace.join(".devcontainer");
    fs::create_dir_all(&dc).unwrap();
    fs::write(
        dc.join("devcontainer.json"),
        r#"{
            "name": "fake-e2e",
            "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
            "remoteUser": "vscode"
        }"#,
    )
    .unwrap();

    let plugins_dir = workspace.join("plugins");
    fs::create_dir_all(&plugins_dir).unwrap();
    copy_plugin_lib(&plugins_dir);
    copy_plugin(&plugins_dir, "devcontainer");

    (temp, workspace)
}

/// Wait for the devcontainer plugin to register its commands, then
/// fire `plugins_loaded` (mirroring `main.rs`) so the plugin's
/// `devcontainer_maybe_show_attach_prompt` handler runs and the
/// "Reopen in Container?" popup is shown. The harness doesn't fire
/// the lifecycle hook on its own — production paths
/// (`main.rs`, `gui/mod.rs`) call `fire_plugins_loaded_hook()` after
/// the registry settles, and tests that depend on the popup must do
/// the same.
///
/// Asserts via rendered screen text per CONTRIBUTING §2.
fn wait_for_attach_popup(harness: &mut EditorTestHarness) {
    bounded_wait(harness, "devcontainer plugin command registration", |h| {
        let reg = h.editor().command_registry().read().unwrap();
        reg.get_all().iter().any(|c| c.name == "%cmd.run_lifecycle")
    });
    harness.editor().fire_plugins_loaded_hook();
    bounded_wait(harness, "Reopen in Container popup", |h| {
        let screen = h.screen_to_string();
        screen.contains("Dev Container Detected") && screen.contains("Reopen in Container")
    });
}

/// Bounded poll loop: ticks the harness until `cond` returns true or
/// `max_iters * 50ms` elapses, panicking with the screen + plugin
/// list on timeout. Replaces `wait_until` for steps where we want
/// targeted diagnostics rather than the test-runner's external
/// timeout firing minutes later with no context.
fn bounded_wait<F>(harness: &mut EditorTestHarness, what: &str, mut cond: F)
where
    F: FnMut(&EditorTestHarness) -> bool,
{
    let max_iters = 200;
    for _ in 0..max_iters {
        harness.tick_and_render().unwrap();
        if cond(harness) {
            return;
        }
        std::thread::sleep(std::time::Duration::from_millis(50));
        harness.advance_time(std::time::Duration::from_millis(50));
    }
    let plugin_names: Vec<_> = harness
        .editor()
        .plugin_manager()
        .list_plugins()
        .into_iter()
        .map(|p| p.name)
        .collect();
    panic!(
        "bounded_wait timed out: {what} not satisfied in {max_iters} ticks (~10s).\n\
         plugins loaded: {plugin_names:?}\n\
         Screen:\n{}",
        harness.screen_to_string()
    );
}

/// Wait until the plugin stages a new authority via `setAuthority`,
/// then promote it to the active authority — the production
/// equivalent is `main.rs` `take_pending_authority` →
/// `set_boot_authority` after the editor restart drops the old
/// process. The harness has no main loop, so the test does that
/// step itself.
fn wait_for_container_authority(harness: &mut EditorTestHarness) -> String {
    let max_iters = 200; // ~10s at 50ms per tick
    for _ in 0..max_iters {
        harness.tick_and_render().unwrap();
        // The plugin stages the new authority via
        // `editor.setAuthority(payload)`, which `install_authority`
        // turns into a `pending_authority` slot plus a restart
        // request. Production's `main.rs` consumes both: it drops the
        // old editor and builds a fresh one with `set_boot_authority`.
        // The harness has no such loop, so we do the swap inline.
        if let Some(auth) = harness.editor_mut().take_pending_authority() {
            harness.editor_mut().set_boot_authority(auth);
            return harness.editor().authority().display_label.clone();
        }
        if harness
            .editor()
            .authority()
            .display_label
            .starts_with("Container:")
        {
            return harness.editor().authority().display_label.clone();
        }
        std::thread::sleep(std::time::Duration::from_millis(50));
        harness.advance_time(std::time::Duration::from_millis(50));
    }
    let plugin_names: Vec<_> = harness
        .editor()
        .plugin_manager()
        .list_plugins()
        .into_iter()
        .map(|p| p.name)
        .collect();
    panic!(
        "container authority never staged after {max_iters} ticks (~10s).\n\
         current display_label: {:?}\n\
         plugins loaded: {plugin_names:?}\n\
         Screen:\n{}",
        harness.editor().authority().display_label,
        harness.screen_to_string()
    );
}

/// Happy-path attach: popup → Reopen → setAuthority → display label.
/// Mirrors Flow A in the interactive test plan.
#[test]
fn attach_via_fake_devcontainer_lands_container_authority() {
    let (_workspace_temp, workspace) = set_up_workspace();
    let mut harness = EditorTestHarness::create(
        160,
        40,
        HarnessOptions::new()
            .with_working_dir(workspace.clone())
            .with_fake_devcontainer(),
    )
    .unwrap();
    harness.tick_and_render().unwrap();

    let plugin_names: Vec<_> = harness
        .editor()
        .plugin_manager()
        .list_plugins()
        .into_iter()
        .map(|p| p.name)
        .collect();
    assert!(
        plugin_names.iter().any(|n| n == "devcontainer"),
        "`devcontainer` plugin must be loaded. Loaded: {:?}",
        plugin_names
    );

    wait_for_attach_popup(&mut harness);

    // The popup is on the global stack; the first action row is
    // "Reopen in Container" so a bare Enter confirms. The harness
    // doesn't simulate the default file-explorer focus that the
    // production launch path has, so we don't need an Esc to
    // release explorer focus first — sending Esc here would
    // dismiss the popup instead.
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();

    let label = wait_for_container_authority(&mut harness);
    let container_id = label
        .strip_prefix("Container:")
        .expect("display_label starts with Container:");
    assert!(
        !container_id.is_empty(),
        "container id must be non-empty (label = {label:?})"
    );

    // The fake CLI persisted the container under
    // `<state>/containers/<id>/`. Authority's display label only
    // carries the short id (12 hex), so match by prefix.
    let state = harness
        .fake_devcontainer_state()
        .expect("with_fake_devcontainer was set");
    let last_id_path = state.join("last_id");
    let last_id = fs::read_to_string(&last_id_path)
        .unwrap_or_else(|e| panic!("fake CLI never wrote last_id at {last_id_path:?}: {e}"));
    assert!(
        last_id.trim().starts_with(container_id) || container_id.starts_with(last_id.trim()),
        "authority short id {container_id:?} must match fake CLI's last_id {last_id:?}"
    );

    // Build log lives at `<workspace>/.fresh-cache/devcontainer-logs/build-<ts>.log`.
    // Plugin opens it in a split before `up` runs, so the file must
    // exist by the time the authority lands.
    let log_dir = workspace.join(".fresh-cache").join("devcontainer-logs");
    let log_count = fs::read_dir(&log_dir)
        .unwrap_or_else(|e| panic!("expected build-log dir at {log_dir:?}: {e}"))
        .count();
    assert!(
        log_count >= 1,
        "expected at least one build-<ts>.log under {log_dir:?}, found {log_count}"
    );

    drop_workspace_temp(&workspace);
}

/// Pin the canonicalized workspace path so the unused-let warning
/// stays out of the way; the actual TempDir cleanup is owned by the
/// caller's `_workspace_temp`.
fn drop_workspace_temp(_workspace: &Path) {}

/// `userEnvProbe` capture is plumbed through to the docker spawner,
/// so subsequent `docker exec` invocations carry the captured env via
/// `-e KEY=VAL` flags. Without this, an LSP server installed by a
/// `postCreateCommand` into a shell-only PATH (e.g. `~/.local/bin`)
/// fails the editor's `command_exists` probe with "executable not
/// found in the active authority's PATH".
///
/// The fake docker exposes two hooks that make this checkable
/// without a real container:
///   - `FAKE_DC_PROBE_RESPONSE`: stdout for any `docker exec ... -c env`,
///     standing in for what `bash -lic env` would print inside a
///     real container.
///   - `<state>/exec_history`: tab-separated record of every `docker
///     exec` (id, semicolon-joined `-e KEY=VAL` pairs, command).
///
/// Test sequence:
///   1. Workspace + plugin set up; fake `bash -lic env` returns a
///      known PATH/HOME.
///   2. Attach via the popup; the plugin's pre-restart probe call
///      gets recorded in `exec_history` (assertion #1: probe ran).
///   3. After authority lands, exercise a `docker exec` through the
///      authority's spawner (LSP `command_exists` is the production
///      caller; we stand in by spawning a process via the plugin
///      runtime so the harness doesn't need an LSP wired up).
///   4. Assertion #2: that exec carries `PATH=...` we set up — the
///      whole point of the env-plumbing fix.
#[cfg(unix)]
#[test]
fn user_env_probe_capture_propagates_path_into_subsequent_execs() {
    use crossterm::event::KeyCode;

    let (_workspace_temp, workspace) = set_up_workspace();

    let mut harness = EditorTestHarness::create(
        160,
        40,
        HarnessOptions::new()
            .with_working_dir(workspace.clone())
            .with_fake_devcontainer(),
    )
    .unwrap();

    // Pin the env-probe response in the per-test state dir (process
    // env vars would leak across parallel test bins). The fake docker
    // shim cats this file when invoked with `... -c env`.
    let state = harness
        .fake_devcontainer_state()
        .expect("fake state present")
        .to_path_buf();
    fs::write(
        state.join("probe_response"),
        "PATH=/home/vscode/.local/bin:/usr/local/bin:/usr/bin\nHOME=/home/vscode\nLANG=C.UTF-8\n",
    )
    .expect("write probe_response");

    harness.tick_and_render().unwrap();

    wait_for_attach_popup(&mut harness);
    harness
        .send_key(KeyCode::Enter, crossterm::event::KeyModifiers::NONE)
        .unwrap();

    let _label = wait_for_container_authority(&mut harness);

    // Read the recorded exec history. The probe call should be in
    // there: a `bash -l -i -c env` invocation against the just-up
    // container — the plugin's `captureContainerLoginEnv` runs this
    // before handing the payload to `setAuthority`.
    let history_path = state.join("exec_history");
    let history = fs::read_to_string(&history_path)
        .unwrap_or_else(|e| panic!("exec_history not found at {history_path:?}: {e}"));
    let probe_lines: Vec<_> = history
        .lines()
        .filter(|l| l.contains("bash -l -i -c env") || l.contains("bash -l -c env"))
        .collect();
    assert!(
        !probe_lines.is_empty(),
        "plugin must call `bash -lic env` to capture userEnvProbe; \
         exec_history was:\n{history}"
    );

    // Now drive a post-attach `docker exec` through the authority's
    // long-running spawner. The actual production caller is
    // `LongRunningSpawner::command_exists` (the LSP path probe).
    // Construct a fresh tokio runtime for the awaiter — the harness
    // doesn't expose its own and creating one here is cheap.
    let spawner = harness.editor().authority().long_running_spawner.clone();
    let rt = tokio::runtime::Runtime::new().expect("tokio runtime starts");
    rt.block_on(async move { spawner.command_exists("ls").await });
    drop(rt);

    // The post-attach `command -v ls` probe should carry the env
    // captured by the pre-restart `bash -lic env` call.
    let final_history = fs::read_to_string(&history_path).expect("history readable post-spawn");
    let cmd_exists_calls: Vec<_> = final_history
        .lines()
        .filter(|l| l.contains("sh -c command -v ls"))
        .collect();
    assert!(
        !cmd_exists_calls.is_empty(),
        "post-attach command_exists must have run a `command -v` probe; \
         final history:\n{final_history}"
    );
    let last = cmd_exists_calls.last().unwrap();
    assert!(
        last.contains("PATH=/home/vscode/.local/bin:/usr/local/bin:/usr/bin"),
        "command_exists probe must include the captured PATH; \
         got line: {last:?}\nfull history:\n{final_history}"
    );

    drop_workspace_temp(&workspace);
}

/// Wait until the failed-attach popup has rendered. Title comes from
/// `popup.failed_attach_title` in the plugin's i18n bundle.
fn wait_for_failed_attach_popup(harness: &mut EditorTestHarness) {
    harness
        .wait_until(|h| {
            let s = h.screen_to_string();
            s.contains("Dev Container Attach Failed")
                && s.contains("Retry")
                && s.contains("Reopen Locally")
        })
        .unwrap();
}

/// Drive the attach popup from the post-`set_up_workspace` state.
fn accept_attach(harness: &mut EditorTestHarness) {
    wait_for_attach_popup(harness);
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
}

/// `FAKE_DC_UP_FAIL=1` → fake exits 1 with `error: …` on stderr →
/// plugin's `enterFailedAttach` surfaces the action popup.
#[test]
fn attach_failure_surfaces_failed_attach_popup() {
    let (_workspace_temp, workspace) = set_up_workspace();

    let mut harness = EditorTestHarness::create(
        160,
        40,
        HarnessOptions::new()
            .with_working_dir(workspace.clone())
            .with_fake_devcontainer(),
    )
    .unwrap();
    // Per-test env knob — set AFTER the harness exists so the
    // fake-devcontainer mutex (held in `FakeDevcontainerHandle`)
    // already serializes us with other tests. Setting before the
    // mutex is acquired races against sibling tests' subprocesses.
    std::env::set_var("FAKE_DC_UP_FAIL", "1");
    std::env::set_var("FAKE_DC_UP_FAIL_REASON", "image not found: bogus:latest");
    harness.tick_and_render().unwrap();

    accept_attach(&mut harness);
    wait_for_failed_attach_popup(&mut harness);

    // Authority must NOT have flipped to a container — the failure
    // path keeps us local.
    assert!(
        !harness
            .editor()
            .authority()
            .display_label
            .starts_with("Container:"),
        "failed attach must not install a container authority; label = {:?}",
        harness.editor().authority().display_label,
    );

    // Clean up env vars before the harness drops (releases the
    // mutex). The harness's mutex guard is the only thing keeping
    // a sibling test's subprocess from inheriting these vars.
    std::env::remove_var("FAKE_DC_UP_FAIL");
    std::env::remove_var("FAKE_DC_UP_FAIL_REASON");
    drop(harness);
    drop_workspace_temp(&workspace);
}

/// `FAKE_DC_UP_BAD_JSON=1` → fake exits 0 but stdout has no parseable
/// JSON line → plugin's `parseDevcontainerUpOutput` returns null →
/// `enterFailedAttach("rebuild_parse_failed")`.
#[test]
fn attach_bad_json_surfaces_failed_attach_popup() {
    let (_workspace_temp, workspace) = set_up_workspace();

    let mut harness = EditorTestHarness::create(
        160,
        40,
        HarnessOptions::new()
            .with_working_dir(workspace.clone())
            .with_fake_devcontainer(),
    )
    .unwrap();
    // Set after the harness exists — see comment in
    // `attach_failure_surfaces_failed_attach_popup`. The fake-
    // devcontainer mutex held by the harness serializes us with
    // sibling tests; setting before that mutex is acquired races
    // against their subprocesses.
    std::env::set_var("FAKE_DC_UP_BAD_JSON", "1");
    harness.tick_and_render().unwrap();

    accept_attach(&mut harness);
    wait_for_failed_attach_popup(&mut harness);

    assert!(
        !harness
            .editor()
            .authority()
            .display_label
            .starts_with("Container:"),
        "bad-JSON failure must not install a container authority"
    );

    std::env::remove_var("FAKE_DC_UP_BAD_JSON");
    drop(harness);
    drop_workspace_temp(&workspace);
}

/// F1 regression: a build-log buffer left over from a previous attach
/// (the kind workspace restore brings back on cold start) must NOT
/// survive the next attach. Pre-create a stale log file under
/// `.fresh-cache/devcontainer-logs/`, open it as a buffer, then drive
/// a fresh attach. The plugin's `closeStaleBuildLogBuffers` must drop
/// the stale buffer before opening the new live log.
///
/// Asserts via `plugin_manager().state_snapshot_handle()` — the same
/// `BufferInfo` snapshot plugins read via `editor.listBuffers()` — so
/// the test exercises the plugin-facing buffer surface, not internal
/// editor state.
#[test]
fn attach_closes_stale_build_log_buffer_from_previous_run() {
    let (_workspace_temp, workspace) = set_up_workspace();

    // Pre-create the stale log: workspace-restore-style. Real
    // restores would ALSO bring the log back as an open buffer; we
    // simulate that with `harness.open_file` right after the harness
    // is built.
    let stale_dir = workspace.join(".fresh-cache").join("devcontainer-logs");
    std::fs::create_dir_all(&stale_dir).unwrap();
    let stale_log = stale_dir.join("build-2026-01-01_00-00-00.log");
    std::fs::write(
        &stale_log,
        "[+] Building 0.0s ... (from a previous attach, restored on cold start)\n",
    )
    .unwrap();

    let mut harness = EditorTestHarness::create(
        160,
        40,
        HarnessOptions::new()
            .with_working_dir(workspace.clone())
            .with_fake_devcontainer(),
    )
    .unwrap();
    harness.tick_and_render().unwrap();
    harness.open_file(&stale_log).unwrap();

    // Sanity: the stale log is open as a buffer before the attach.
    assert!(
        snapshot_has_buffer_at(&harness, &stale_log),
        "test setup: stale log must be open as a buffer before attach.\n\
         buffers: {:?}",
        snapshot_buffer_paths(&harness)
    );

    accept_attach(&mut harness);
    let _ = wait_for_container_authority(&mut harness);
    harness.tick_and_render().unwrap();

    let paths_after = snapshot_buffer_paths(&harness);
    assert!(
        !paths_after.iter().any(|p| p == &stale_log),
        "F1 regression: stale build-log buffer at {stale_log:?} must be \
         closed when a new attach starts. Buffers after attach: {paths_after:?}"
    );

    // The fresh build log should be open under the same dir, but at
    // a *different* path (timestamp differs from the stale one).
    let fresh = paths_after
        .iter()
        .find(|p| p.starts_with(&stale_dir) && **p != stale_log);
    assert!(
        fresh.is_some(),
        "expected at least one fresh build-log buffer under {stale_dir:?} \
         (different from {stale_log:?}). Buffers: {paths_after:?}"
    );
}

/// Read every buffer's `path` from the plugin-state snapshot — same
/// surface plugins see via `editor.listBuffers()`. Only paths that
/// resolve to a `Some(PathBuf)` are returned (unnamed buffers
/// dropped).
fn snapshot_buffer_paths(harness: &EditorTestHarness) -> Vec<std::path::PathBuf> {
    let handle = harness
        .editor()
        .plugin_manager()
        .state_snapshot_handle()
        .expect("plugin manager must have a state snapshot in plugins-feature builds");
    let snap = handle.read().unwrap();
    snap.buffers
        .values()
        .filter_map(|b| b.path.clone())
        .collect()
}

fn snapshot_has_buffer_at(harness: &EditorTestHarness, path: &Path) -> bool {
    snapshot_buffer_paths(harness).iter().any(|p| p == path)
}

/// F2 reproducer: a successful attach must persist the
/// `attach:<cwd> = "attached"` per-workspace decision so the
/// "Reopen in Container?" popup doesn't re-fire on the next cold
/// start. We assert via `Editor::plugin_global_state()` — the
/// editor-global blob the orchestrator-state serializer writes to
/// disk on quit and reads back on relaunch.
///
/// The plugin writes the decision in `devcontainer_on_attach_popup`
/// before kicking off `runDevcontainerUp`, so by the time the
/// container authority lands the key must be visible in the global
/// state. If this test ever starts failing, the regression is in
/// either the plugin's call ordering (pre-`setAuthority`) or in how
/// `setGlobalState` reaches `plugin_global_state` — the production
/// cold-restart bug from the test plan would surface here first.
#[test]
fn attach_decision_persists_in_plugin_global_state() {
    let (_workspace_temp, workspace) = set_up_workspace();
    let mut harness = EditorTestHarness::create(
        160,
        40,
        HarnessOptions::new()
            .with_working_dir(workspace.clone())
            .with_fake_devcontainer(),
    )
    .unwrap();
    harness.tick_and_render().unwrap();

    accept_attach(&mut harness);
    let _ = wait_for_container_authority(&mut harness);
    harness.tick_and_render().unwrap();

    let global_state = harness.editor().plugin_global_state();
    let dc_state = global_state.get("devcontainer").unwrap_or_else(|| {
        panic!(
            "expected `devcontainer` plugin to have written global state. \
                 Plugin map: {:?}",
            global_state.keys().collect::<Vec<_>>()
        )
    });

    let key = format!("attach:{}", workspace.display());
    let value = dc_state.get(&key).unwrap_or_else(|| {
        panic!(
            "expected key {key:?} in devcontainer plugin state. \
             Keys present: {:?}",
            dc_state.keys().collect::<Vec<_>>()
        )
    });
    assert_eq!(
        value.as_str(),
        Some("attached"),
        "attach decision must be \"attached\" after a successful \
         Reopen-in-Container; got {value:?}"
    );
}

/// **Follow-up to PR #1704**: the attach prompt used to offer a single
/// `Ignore` action. User feedback was that they need _two_ separate
/// dismissals: a session-only "not now" (re-asks next launch) and a
/// permanent "stop asking" (persisted). Pin the new three-action
/// shape and the side-effects of each:
///   - `Ignore (once)` → no plugin global state writes; popup not
///     re-shown in this session, but next editor launch re-asks.
///   - `Ignore (always …)` → writes `attach:<cwd> = "dismissed"`
///     to plugin global state, persisted across launches.
#[test]
fn attach_popup_offers_separate_once_and_always_dismiss() {
    use crossterm::event::{KeyCode, KeyModifiers};

    let (_workspace_temp, workspace) = set_up_workspace();
    let mut harness = EditorTestHarness::create(
        160,
        40,
        HarnessOptions::new()
            .with_working_dir(workspace.clone())
            .with_fake_devcontainer(),
    )
    .unwrap();
    harness.tick_and_render().unwrap();
    wait_for_attach_popup(&mut harness);

    // Both new option labels must appear on the rendered popup.
    let screen = harness.screen_to_string();
    assert!(
        screen.contains("Ignore (once)"),
        "popup must offer session-only dismiss option. Screen:\n{screen}"
    );
    assert!(
        screen.contains("Ignore (always"),
        "popup must offer permanent dismiss option. Screen:\n{screen}"
    );

    // Pick "Ignore (once)" — second row in the popup. Down arrow
    // moves the focus, Enter activates.
    harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.tick_and_render().unwrap();

    // After "Ignore (once)" the plugin must NOT have written a
    // persistent dismissal — re-launches should re-ask.
    let global_state = harness.editor().plugin_global_state();
    let dc_state = global_state.get("devcontainer");
    let key = format!("attach:{}", workspace.display());
    let persisted = dc_state.and_then(|m| m.get(&key));
    assert!(
        persisted.is_none() || persisted.and_then(|v| v.as_str()) != Some("dismissed"),
        "Ignore (once) must NOT persist `dismissed` to plugin global state. \
         Got: {persisted:?}"
    );
}

/// **Follow-up to PR #1704**: pin the persistent dismissal path.
/// Picking `Ignore (always in this folder)` writes `dismissed` to
/// plugin global state so a subsequent editor launch finds it and
/// skips the popup.
#[test]
fn attach_popup_dismiss_always_persists_decision() {
    use crossterm::event::{KeyCode, KeyModifiers};

    let (_workspace_temp, workspace) = set_up_workspace();
    let mut harness = EditorTestHarness::create(
        160,
        40,
        HarnessOptions::new()
            .with_working_dir(workspace.clone())
            .with_fake_devcontainer(),
    )
    .unwrap();
    harness.tick_and_render().unwrap();
    wait_for_attach_popup(&mut harness);

    // Pick the third row: `Ignore (always in this folder)`.
    harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
    harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.tick_and_render().unwrap();

    let global_state = harness.editor().plugin_global_state();
    let dc_state = global_state.get("devcontainer").unwrap_or_else(|| {
        panic!(
            "Ignore (always …) must write to plugin global state. \
                 Plugin map: {:?}",
            global_state.keys().collect::<Vec<_>>()
        )
    });
    let key = format!("attach:{}", workspace.display());
    let value = dc_state.get(&key).unwrap_or_else(|| {
        panic!(
            "expected key {key:?} after Ignore (always). Keys present: {:?}",
            dc_state.keys().collect::<Vec<_>>()
        )
    });
    assert_eq!(
        value.as_str(),
        Some("dismissed"),
        "Ignore (always …) must persist as \"dismissed\"; got {value:?}"
    );
}

/// `FAKE_DC_UP_NO_CONTAINER_ID=1` → fake emits `outcome:success` JSON
/// but omits `containerId` → plugin's `buildContainerAuthorityPayload`
/// returns null → `enterFailedAttach("rebuild_missing_container_id")`.
#[test]
fn attach_missing_container_id_surfaces_failed_attach_popup() {
    let (_workspace_temp, workspace) = set_up_workspace();

    let mut harness = EditorTestHarness::create(
        160,
        40,
        HarnessOptions::new()
            .with_working_dir(workspace.clone())
            .with_fake_devcontainer(),
    )
    .unwrap();
    // Set after the harness exists — see comment in
    // `attach_failure_surfaces_failed_attach_popup`. The fake-
    // devcontainer mutex held by the harness serializes us with
    // sibling tests; setting before that mutex is acquired races
    // against their subprocesses.
    std::env::set_var("FAKE_DC_UP_NO_CONTAINER_ID", "1");
    harness.tick_and_render().unwrap();

    accept_attach(&mut harness);
    wait_for_failed_attach_popup(&mut harness);

    assert!(
        !harness
            .editor()
            .authority()
            .display_label
            .starts_with("Container:"),
        "missing-containerId failure must not install a container authority"
    );

    std::env::remove_var("FAKE_DC_UP_NO_CONTAINER_ID");
    drop(harness);
    drop_workspace_temp(&workspace);
}

/// Regression test for issue #2201 (Devcontainer CLI not found on
/// Windows when installed via Bun): the CLI-presence probe must not
/// depend on an external `which` utility vouching for `devcontainer`.
/// Native Windows ships no `which` at all, so a probe that shells out
/// to it reports "CLI Not Found" even when `devcontainer` itself is
/// perfectly spawnable from PATH. The probe must ask the question we
/// actually care about — "can the CLI be spawned?" — by running it
/// directly.
///
/// Modeled here (the suite is unix-only) by shadowing `which` with a
/// shim that denies knowledge of `devcontainer` while the fake CLI
/// stays on PATH: attach must still succeed.
#[test]
fn attach_succeeds_when_which_cannot_locate_cli() {
    let (_workspace_temp, workspace) = set_up_workspace();

    let mut harness = EditorTestHarness::create(
        160,
        40,
        HarnessOptions::new()
            .with_working_dir(workspace.clone())
            .with_fake_devcontainer(),
    )
    .unwrap();

    // Shadow `which` AFTER the harness exists so the fake-devcontainer
    // mutex already serializes us with sibling tests (same reasoning
    // as the FAKE_DC_* env knobs above). The shim fails only for
    // `devcontainer` and delegates everything else, so the rest of the
    // attach flow (sh, mkdir, docker) is unaffected.
    let shim_temp = tempfile::tempdir().unwrap();
    let shim = shim_temp.path().join("which");
    fs::write(
        &shim,
        "#!/bin/sh\n\
         for a in \"$@\"; do [ \"$a\" = devcontainer ] && exit 1; done\n\
         [ -x /usr/bin/which ] && exec /usr/bin/which \"$@\"\n\
         exit 127\n",
    )
    .unwrap();
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(&shim, fs::Permissions::from_mode(0o755)).unwrap();
    }
    let saved_path = std::env::var("PATH").unwrap_or_default();
    std::env::set_var(
        "PATH",
        format!("{}:{}", shim_temp.path().display(), saved_path),
    );
    harness.tick_and_render().unwrap();

    accept_attach(&mut harness);
    let label = wait_for_container_authority(&mut harness);
    assert!(
        label.starts_with("Container:"),
        "attach must succeed without `which` vouching for the CLI; label = {label:?}"
    );

    std::env::set_var("PATH", saved_path);
    drop(harness);
    drop_workspace_temp(&workspace);
}