alba 0.2.1

Alba, the Beamfile task runner: parallel beams, caching, watch mode, and an interactive interface
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
//! End-to-end tests for `alba run`: dependency ordering, failure and
//! cancellation semantics, the `--log-format json` stream, parameter
//! interpolation, and a run that fails before it starts (an unknown
//! target). The docker executor's own end-to-end tests live in
//! `cli_docker.rs`.
//!
//! Every Beamfile here runs commands that behave the same on macOS, Linux,
//! and Windows: `echo <word>` and `exit <n>` are all that is needed, and
//! both work identically in POSIX `sh` and in `powershell`. The two
//! interrupt tests are the exception and are `#[cfg(unix)]`, since sending
//! a signal to another process has no windows equivalent here.
//!
//! Colour is off in all of these: `assert_cmd` captures stdout through a
//! pipe, which `crate::color_enabled()` reports as "not a terminal", so
//! output can be compared as plain text.

use predicates::prelude::PredicateBooleanExt;

fn alba() -> assert_cmd::Command {
    assert_cmd::Command::cargo_bin("alba").unwrap()
}

/// A temporary directory holding `beamfile` as its `Beamfile`.
fn project(beamfile: &str) -> tempfile::TempDir {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("Beamfile"), beamfile).unwrap();
    dir
}

/// `b` needs `a`, so `a`'s output must appear before `b`'s whatever the
/// scheduler's parallelism allows.
#[test]
fn runs_dependency_chain_in_order() {
    let dir = project(
        "beam a { run \"echo alpha\" }\n\
         beam b { needs [a] run \"echo bravo\" }\n",
    );

    let assert = alba()
        .current_dir(&dir)
        .args(["run", "b"])
        .assert()
        .success();
    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();

    let alpha = stdout
        .find("alpha")
        .unwrap_or_else(|| panic!("`a`'s output is missing from:\n{stdout}"));
    let bravo = stdout
        .find("bravo")
        .unwrap_or_else(|| panic!("`b`'s output is missing from:\n{stdout}"));
    assert!(
        alpha < bravo,
        "`a` must be reported before its dependent `b`, got:\n{stdout}"
    );
}

/// A failing beam exits the process 1, and its dependent never runs — so
/// its output never appears. The stderr summary counts the failure.
#[test]
fn beam_failure_exits_1_and_cancels_dependents() {
    let dir = project(
        "beam boom { run \"exit 7\" }\n\
         beam after { needs [boom] run \"echo reached\" }\n",
    );

    alba()
        .current_dir(&dir)
        .args(["run", "after"])
        .assert()
        .code(1)
        .stdout(predicates::str::contains("reached").not())
        .stderr(predicates::str::contains("1 failed"));
}

/// `allow_failure true` turns a non-zero exit into a reported-but-tolerated
/// outcome: the process still exits 0, and the beam is labelled as such.
#[test]
fn allow_failure_exits_0() {
    let dir = project("beam lint { allow_failure true run \"exit 1\" }\n");

    alba()
        .current_dir(&dir)
        .args(["run", "lint"])
        .assert()
        .success()
        .stdout(predicates::str::contains("failed (allowed)"));
}

/// `--log-format json` makes stdout a machine-readable stream: every line
/// is one JSON object, and every event kind an ordinary run emits is
/// present — `run_started`, which opens the stream with the run's target,
/// its beams and their edges, included.
#[test]
fn json_log_format_emits_one_json_object_per_line() {
    let dir = project("beam a { run \"echo hi\" }\n");

    let assert = alba()
        .current_dir(&dir)
        .args(["run", "a", "--log-format", "json"])
        .assert()
        .success();
    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();

    let mut kinds = Vec::new();
    for line in stdout.lines() {
        let value: serde_json::Value = serde_json::from_str(line)
            .unwrap_or_else(|error| panic!("stdout line is not JSON ({error}): {line:?}"));
        let kind = value["event"]
            .as_str()
            .unwrap_or_else(|| panic!("JSON line has no `event` field: {line:?}"));
        kinds.push(kind.to_string());
    }

    for expected in [
        "run_started",
        "beam_started",
        "beam_output",
        "beam_finished",
        "run_finished",
    ] {
        assert!(
            kinds.iter().any(|kind| kind == expected),
            "missing `{expected}` among {kinds:?}"
        );
    }
    assert_eq!(
        kinds.first().map(String::as_str),
        Some("run_started"),
        "the stream opens on the run's shape, before any beam's own events"
    );

    // The payload a consumer builds a graph from, asserted on the wire
    // rather than only in the renderer's own unit tests.
    let opening: serde_json::Value = serde_json::from_str(stdout.lines().next().unwrap()).unwrap();
    assert_eq!(opening["targets"], serde_json::json!(["a"]));
    assert!(opening.get("affected_by").is_none());
    assert_eq!(opening["beams"], serde_json::json!(["a"]));
    assert_eq!(opening["edges"], serde_json::json!([]));
}

/// The whole cache loop through the real binary: a first run executes and
/// stores, an unchanged second run is cached and replays the output, a
/// changed input runs again, and `--force` bypasses the read side.
#[test]
fn an_unchanged_run_is_cached_and_replays_its_output() {
    let dir = project("beam gen { inputs [\"data.txt\"] run \"echo generated\" }\n");
    std::fs::write(dir.path().join("data.txt"), "v1").unwrap();

    let run = |extra: &[&str]| {
        let mut args = vec!["run", "gen"];
        args.extend_from_slice(extra);
        let assert = alba().current_dir(&dir).args(args).assert().success();
        String::from_utf8(assert.get_output().stdout.clone()).unwrap()
    };

    let first = run(&[]);
    assert!(
        !first.contains("cached"),
        "a first run cannot be cached:\n{first}"
    );
    assert!(first.contains("generated"));
    assert!(
        dir.path().join(".alba/cache").is_dir(),
        "the store must exist after a success"
    );

    let second = run(&[]);
    assert!(
        second.contains("cached"),
        "unchanged inputs must hit:\n{second}"
    );
    assert!(
        second.contains("generated"),
        "the stored output must replay:\n{second}"
    );

    std::fs::write(dir.path().join("data.txt"), "v2").unwrap();
    let third = run(&[]);
    assert!(
        !third.contains("cached"),
        "a changed input must rerun:\n{third}"
    );

    let forced = run(&["--force"]);
    assert!(
        !forced.contains("cached"),
        "--force must execute:\n{forced}"
    );
}

/// The JSON stream's cache contract: a `beam_cached` event, and replayed
/// output lines marked `"replayed": true`.
#[test]
fn the_json_stream_marks_cached_beams_and_replayed_lines() {
    let dir = project("beam gen { inputs [\"data.txt\"] run \"echo generated\" }\n");
    std::fs::write(dir.path().join("data.txt"), "v1").unwrap();

    let run_json = || {
        let assert = alba()
            .current_dir(&dir)
            .args(["run", "gen", "--log-format", "json"])
            .assert()
            .success();
        let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
        stdout
            .lines()
            .map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
            .collect::<Vec<_>>()
    };

    let first = run_json();
    assert!(
        first
            .iter()
            .any(|event| event["event"] == "beam_output" && event["replayed"] == false)
    );

    let second = run_json();
    assert!(second.iter().any(|event| event["event"] == "beam_cached"));
    assert!(second.iter().any(|event| event["event"] == "beam_output"
        && event["replayed"] == true
        && event["text"] == "generated"));
    let finished = second
        .iter()
        .find(|event| event["event"] == "run_finished")
        .expect("the stream must end with run_finished");
    assert_eq!(finished["cached"][0], "gen");
}

/// A beam whose `run` template cannot be rendered when the run reaches it
/// — `env(NAME)` without a default is deliberately resolved that late —
/// fails like any other beam. The JSON stream must still end with
/// `run_finished`, which is the machine contract consumers read the run's
/// outcome from, and the process must report the failure rather than an
/// Alba-level error.
#[test]
fn a_template_that_fails_at_run_time_still_ends_the_json_stream() {
    let dir = project(
        "beam good { run \"echo ok\" }\n\
         beam broken { run \"echo {env('ALBA_MISSING_VAR_XYZ')}\" }\n\
         beam all { needs [good, broken] run \"echo all\" }\n",
    );

    let assert = alba()
        .current_dir(&dir)
        .args(["run", "all", "--keep-going", "--log-format", "json"])
        .assert()
        .code(1);
    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();

    let last: serde_json::Value = serde_json::from_str(
        stdout
            .lines()
            .next_back()
            .unwrap_or_else(|| panic!("stdout is empty: {stdout:?}")),
    )
    .unwrap();
    assert_eq!(last["event"], "run_finished");
    assert_eq!(last["failed"], serde_json::json!(["broken"]));
    assert_eq!(last["succeeded"], serde_json::json!(["good"]));

    let kinds: Vec<String> = stdout
        .lines()
        .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
        .filter(|value| value["beam"] == "broken")
        .filter_map(|value| value["event"].as_str().map(str::to_string))
        .collect();
    assert_eq!(
        kinds,
        ["beam_started", "beam_output", "beam_finished"],
        "the beam that failed must report itself like any other"
    );
}

/// A beam's declared parameter is bound from the run's positional
/// arguments and interpolated into its `run` template.
///
/// The interpolated value is quoted (`'{name}'`) so it reaches the shell
/// as a single token, per this file's `echo <word>` invariant (see the
/// module doc comment): an unquoted multi-word `echo hello {name}` would
/// still print "hello world" under POSIX `sh`, but under `powershell`,
/// `echo` (an alias for `Write-Output`) treats each bare word as a
/// separate pipeline object and prints one per line instead of joining
/// them with spaces.
#[test]
fn beam_params_are_interpolated() {
    let dir = project("beam greet(name) { run \"echo '{name}'\" }\n");

    alba()
        .current_dir(&dir)
        .args(["run", "greet", "hello world"])
        .assert()
        .success()
        .stdout(predicates::str::contains("hello world"));
}

/// A misspelled target is an Alba error (exit 2) with the loader's
/// "did you mean...?" suggestion rendered as a diagnostic — and, because
/// the name was typed on the command line rather than written in the
/// Beamfile, with no caret drawn at a source position that has nothing to
/// do with the mistake.
#[test]
fn unknown_beam_suggests_closest() {
    let dir = project("beam build { run \"echo ok\" }\n");

    alba()
        .current_dir(&dir)
        .args(["run", "biuld"])
        .assert()
        .code(2)
        .stderr(predicates::str::contains("did you mean `build`?"))
        .stderr(predicates::str::contains("Beamfile:").not());
}

/// A `default` naming a beam that does not exist is a load failure, so
/// `alba check` reports it instead of green-lighting a project where a
/// bare `alba` cannot work.
#[test]
fn a_default_naming_an_unknown_beam_fails_check() {
    let dir = project("default biuld\nbeam build { run \"echo ok\" }\n");

    alba()
        .current_dir(&dir)
        .arg("check")
        .assert()
        .code(2)
        .stderr(predicates::str::contains("unknown beam `biuld`"))
        .stderr(predicates::str::contains("did you mean `build`?"));
}

/// Bare `alba` in a project that declares a `default` runs that beam
/// rather than listing beams.
#[test]
fn bare_alba_runs_the_default_beam() {
    let dir = project("default build\nbeam build { run \"echo compiled\" }\n");

    alba()
        .current_dir(&dir)
        .assert()
        .success()
        .stdout(predicates::str::contains("compiled"));
}

/// With no `executor` clause, a beam now runs on the embedded shell rather
/// than the host shell — this is the default this task switches over.
///
/// Plain `echo` would not prove that: it prints byte-identically under
/// `sh`, under `powershell`, and under the embedded shell, so a beam
/// wired to either `Executors` slot would pass this test unchanged — it
/// would only prove a beam runs, not which shell ran it. This command
/// instead exploits a real, deliberate divergence: command substitution
/// `$(...)` shares the embedded shell's state instead of running in an
/// isolated subshell (there is no subshell isolation machinery to reuse,
/// since `(...)` subshells are entirely out of its supported subset), so
/// `$(FOO=leaked)` leaves `FOO` set in the command that follows it. A real
/// POSIX `sh` (macOS and Linux) does the opposite: `$(...)` is a genuine
/// subshell there, so the assignment is discarded the moment it exits —
/// confirmed directly (`sh -c '$(FOO=leaked); echo FOO=$FOO'` prints
/// `FOO=`, not `FOO=leaked`). `powershell -NoProfile -Command` (Windows)
/// has no bare `NAME=value` assignment syntax at all, so `$FOO` there can
/// never end up holding `leaked` either. Only the embedded shell can print
/// `FOO=leaked`; both host shells print `FOO=`, whichever platform this
/// runs on — so if the CLI wiring regressed to routing every beam to
/// `SystemShellExecutor`, this assertion (not just the exit code) would
/// catch it on every platform.
#[test]
fn a_beam_runs_on_the_embedded_shell_by_default() {
    let dir = project("beam hello { run \"$(FOO=leaked); echo FOO=$FOO\" }\n");

    alba()
        .current_dir(&dir)
        .args(["run", "hello"])
        .assert()
        .success()
        .stdout(predicates::str::contains("FOO=leaked"));
}

/// A `run` command outside the embedded shell's supported subset fails the
/// beam rather than being silently accepted: the process exits 1, and the
/// diagnostic (message plus the `executor system_shell` opt-out) reaches
/// the user through the beam's own output.
#[test]
fn out_of_subset_syntax_fails_the_beam_with_the_diagnostic() {
    let dir = project("beam bad { run \"for x in a; do echo $x; done\" }\n");

    alba()
        .current_dir(&dir)
        .args(["run", "bad"])
        .assert()
        .code(1)
        .stdout(predicates::str::contains("not supported"))
        .stdout(predicates::str::contains("executor system_shell"));
}

/// `executor system_shell` opts a beam back out to the host shell — the
/// behaviour every beam had before this task. `echo` behaves identically
/// under POSIX `sh` and `powershell`, so this succeeds on every platform.
#[test]
fn executor_system_shell_opts_a_beam_out() {
    let dir = project("beam legacy { executor system_shell run \"echo sys\" }\n");

    alba()
        .current_dir(&dir)
        .args(["run", "legacy"])
        .assert()
        .success()
        .stdout(predicates::str::contains("sys"));
}

/// `--jobs 0` is rejected by argument parsing rather than silently
/// meaning something: a run with no slots is not a run. The message is
/// asserted, not only the code, since exit 2 alone would also be produced
/// by any other Alba error on the way there.
#[test]
fn jobs_zero_is_rejected() {
    let dir = project("beam a { run \"echo ok\" }\n");

    // This asserts on clap's own error text as plain, uncoloured output,
    // so the child's colour has to be pinned off regardless of what the
    // parent test process's own environment carries: a `test` beam run
    // under the interface sets `FORCE_COLOR`/`CLICOLOR_FORCE` on this
    // process (see `commands/run.rs`), and clap honours those over a
    // piped, non-terminal stderr unless `NO_COLOR` says otherwise.
    alba()
        .current_dir(&dir)
        .env("NO_COLOR", "1")
        .args(["run", "a", "--jobs", "0"])
        .assert()
        .code(2)
        .stderr(predicates::str::contains(
            "invalid value '0' for '--jobs <N>'",
        ));
}

/// `alba run` with no beam named runs the declared `default`, exactly
/// like bare `alba` — but with the run flags available.
#[test]
fn run_without_a_beam_runs_the_default() {
    let dir = project(
        "default hello\n\
         beam hello { run \"echo salut\" }\n",
    );

    alba()
        .current_dir(&dir)
        .args(["run"])
        .assert()
        .success()
        .stdout(predicates::str::contains("salut"));
}

/// Without a `default`, `alba run` cannot guess; it says so and exits 2.
#[test]
fn run_without_a_beam_and_no_default_is_an_error() {
    let dir = project("beam hello { run \"echo salut\" }\n");

    alba()
        .current_dir(&dir)
        .args(["run"])
        .assert()
        .code(2)
        .stderr(predicates::str::contains("default"));
}

/// The three specified output shapes, pinned as literal strings so a
/// refactor cannot quietly change any of them. Durations are deliberately
/// never asserted: they are the one part that legitimately varies.
mod layout {
    use super::{alba, project};

    /// `beam-id │ text`, one line per event, with the beam's output
    /// carried verbatim after the separator.
    #[test]
    fn interleaved_prefixes_every_line_with_its_beam() {
        let dir = project("beam a { run \"echo alpha\" }\n");

        let assert = alba()
            .current_dir(&dir)
            .args(["run", "a", "--output", "interleaved"])
            .assert()
            .success();
        let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();

        let lines: Vec<&str> = stdout.lines().collect();
        assert!(
            lines.contains(&"a \u{2502} started"),
            "missing the start line in:\n{stdout}"
        );
        assert!(
            lines.contains(&"a \u{2502} alpha"),
            "missing the prefixed output line in:\n{stdout}"
        );
        // The duration is the only part deliberately left unasserted.
        assert!(
            lines
                .iter()
                .any(|line| line.starts_with("a \u{2502} ok in ")),
            "missing the status line in:\n{stdout}"
        );
    }

    /// `── beam-id (1.2s, ok) ──`, with the beam's lines underneath it.
    #[test]
    fn grouped_frames_each_beam_with_a_header() {
        let dir = project("beam a { run \"echo alpha\" }\n");

        // `--output grouped` explicitly rather than relying on the piped
        // default, so this test still pins the shape if that default ever
        // changes.
        let assert = alba()
            .current_dir(&dir)
            .args(["run", "a", "--output", "grouped"])
            .assert()
            .success();
        let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();

        let lines: Vec<&str> = stdout.lines().collect();
        let header = lines
            .iter()
            .position(|line| line.starts_with("\u{2500}\u{2500} a ("))
            .unwrap_or_else(|| panic!("no group header in:\n{stdout}"));
        assert!(
            lines[header].ends_with(", ok) \u{2500}\u{2500}"),
            "header does not match `── a (<duration>, ok) ──`: {:?}",
            lines[header]
        );
        assert_eq!(
            lines.get(header + 1),
            Some(&"alpha"),
            "the beam's output must follow its header in:\n{stdout}"
        );
    }

    /// `✓ 1 succeeded · ✗ 1 failed · ⊘ 1 cancelled · 4.1s` on stderr. The
    /// chain `good → bad → blocked` makes all three buckets deterministic
    /// whatever `--jobs` resolves to: `good` must finish before `bad` can
    /// start, and `blocked` can never start at all.
    #[test]
    fn the_summary_reports_every_non_empty_bucket_on_stderr() {
        let dir = project(
            "beam good { run \"echo g\" }\n\
             beam bad { needs [good] run \"exit 3\" }\n\
             beam blocked { needs [bad] run \"echo never\" }\n",
        );

        let assert = alba()
            .current_dir(&dir)
            .args(["run", "blocked"])
            .assert()
            .code(1);
        let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap();

        let summary = stderr.lines().next_back().unwrap_or_default();
        let expected =
            "\u{2713} 1 succeeded \u{b7} \u{2717} 1 failed \u{b7} \u{2298} 1 cancelled \u{b7} ";
        assert!(
            summary.starts_with(expected),
            "summary does not match `{expected}<duration>`: {summary:?}"
        );
        assert!(
            summary.ends_with('s'),
            "the summary must end with a duration: {summary:?}"
        );
    }
}

/// `alba run x | head -1` — the reader walks away mid-run. Under
/// `println!` this panicked the renderer, lost the summary, and handed
/// back exit 0 for a run that had failed. Every renderer now writes
/// through a `LineSink`, so a dead stdout is silence, not a crash.
///
/// The read end is closed before the run starts rather than after a line
/// or two: it makes the test deterministic (every single write fails, on
/// every platform) instead of racing the pipe's buffer.
#[test]
fn a_closed_stdout_neither_panics_nor_changes_the_exit_code() {
    // The third element says whether this mode reports a summary on
    // stderr: `json` deliberately does not, since its `run_finished` line
    // already carries every count — and that line went to the stdout
    // nobody is reading, which is the point of the exit-code assertion.
    for (mode, summarizes) in [
        (["--output", "interleaved"], true),
        (["--output", "grouped"], true),
        (["--log-format", "json"], false),
    ] {
        let dir = project("beam bad { run \"exit 7\" }\n");

        let mut child = std::process::Command::new(assert_cmd::cargo::cargo_bin("alba"))
            .current_dir(dir.path())
            .args(["run", "bad"])
            .args(mode)
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::piped())
            .spawn()
            .unwrap();
        drop(child.stdout.take());

        let output = child.wait_with_output().unwrap();
        let stderr = String::from_utf8_lossy(&output.stderr);

        assert!(
            !stderr.contains("panicked"),
            "{mode:?}: a closed stdout must not panic a renderer:\n{stderr}"
        );
        assert_eq!(
            output.status.code(),
            Some(1),
            "{mode:?}: the run's exit code must survive a closed stdout, stderr:\n{stderr}"
        );
        assert!(
            !summarizes || stderr.contains("1 failed"),
            "{mode:?}: the summary must still reach stderr:\n{stderr}"
        );
    }
}

/// Ctrl-C handling. Alba puts every command in its own process group, so a
/// terminal Ctrl-C no longer reaches those children — Alba is the only
/// thing that can stop them, which is what these two tests pin down.
#[cfg(unix)]
mod interrupts {
    use std::io::{BufRead, BufReader};
    use std::process::{Child, Command, Stdio};
    use std::sync::mpsc;
    use std::time::{Duration, Instant};

    use super::project;

    /// How long to wait for the beam to report that it is running before
    /// giving up. Not a synchronisation delay — the test never waits this
    /// long on the happy path — just a bound so a broken binary fails the
    /// test instead of hanging the suite.
    const READY_TIMEOUT: Duration = Duration::from_secs(30);

    /// Spawns `alba run <beam>` and returns once the beam's own command
    /// has printed `ready` on stdout.
    ///
    /// This waits on a real event, not a duration. A fixed sleep before
    /// the first signal is not merely imprecise here, it can invert the
    /// test: a `SIGINT` that arrives before `watch_interrupts` has
    /// installed its handler is handled by the default disposition, which
    /// kills `alba` outright — no `cancelling...`, no exit code, and both
    /// tests below fail for a reason that has nothing to do with what they
    /// assert. Seeing the beam's own output proves the runtime is up, the
    /// engine is scheduling, the watcher task was spawned ahead of it, and
    /// a shell child is running and waiting to be killed.
    ///
    /// `--output interleaved` because the grouped default buffers a beam's
    /// output until it finishes, which is exactly never here.
    fn spawn_running(dir: &tempfile::TempDir, beam: &str) -> Child {
        let mut child = Command::new(assert_cmd::cargo::cargo_bin("alba"))
            .current_dir(dir.path())
            .args(["run", beam, "--output", "interleaved"])
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .unwrap();

        let stdout = child.stdout.take().expect("stdout was piped");
        let (ready, is_ready) = mpsc::channel();
        // Reads to EOF rather than stopping at the marker, so the child
        // can never block on a full stdout pipe later in the test.
        std::thread::spawn(move || {
            for line in BufReader::new(stdout).lines().map_while(Result::ok) {
                if line.contains("ready") {
                    let _ = ready.send(());
                }
            }
        });

        is_ready
            .recv_timeout(READY_TIMEOUT)
            .expect("the beam never reported that it was running");
        child
    }

    fn interrupt(child: &Child) {
        let pid = nix::unistd::Pid::from_raw(child.id() as i32);
        nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGINT).unwrap();
    }

    /// The first signal cancels the run: Alba announces it, terminates the
    /// command it started, exits on its own rather than being killed, and
    /// reports 130 — an interrupted run must not look like a success, or
    /// `alba run deploy && ship` would ship anyway.
    #[test]
    fn first_interrupt_cancels_the_run_and_exits_130() {
        let dir = project("beam slow { run \"echo ready; sleep 30\" }\n");
        let child = spawn_running(&dir, "slow");

        interrupt(&child);
        let output = child.wait_with_output().unwrap();

        let stderr = String::from_utf8_lossy(&output.stderr);
        assert!(
            stderr.contains("cancelling"),
            "the first signal must announce the cancellation, got:\n{stderr}"
        );
        assert_eq!(
            output.status.code(),
            Some(130),
            "an interrupted run must not report success, stderr:\n{stderr}"
        );
    }

    /// How long the whole two-signal sequence may take before the abort
    /// stops being an abort. The beam ignores `SIGTERM`, so the orderly
    /// path would wait out the executor's 5 s grace period and only then
    /// escalate — exiting within this bound is what proves the second
    /// signal short-circuited it, since exit code 130 alone is also what
    /// the orderly path eventually produces.
    const ABORT_DEADLINE: Duration = Duration::from_secs(3);

    /// The second signal aborts immediately, without waiting for the
    /// executor's grace period. The beam installs its `SIGTERM`-ignoring
    /// trap *before* reporting ready and then loops for a bounded time, so
    /// the run is provably still cancelling (inside that 5 s grace) when
    /// the second signal lands, and the shell cannot outlive the test even
    /// though the abort leaves it running.
    ///
    /// `executor system_shell`: this beam pins `SystemShellExecutor`'s own
    /// two-signal escalation against a real process that installs a
    /// `SIGTERM` trap — `trap`, `while`, and arithmetic expansion are all
    /// outside the embedded shell's supported subset (by design; it is not
    /// a POSIX shell), so under the default executor this Beamfile would
    /// fail to parse instead of exercising the behaviour under test.
    #[test]
    fn second_interrupt_aborts_the_process() {
        let dir = project(
            "beam stubborn { executor system_shell run \"trap '' TERM; echo ready; i=0; while [ $i -lt 40 ]; do sleep 0.25; i=$((i+1)); done\" }\n",
        );
        let child = spawn_running(&dir, "stubborn");

        interrupt(&child);
        std::thread::sleep(Duration::from_millis(500));
        let started_at = Instant::now();
        interrupt(&child);
        let output = child.wait_with_output().unwrap();
        let elapsed = started_at.elapsed();

        let stderr = String::from_utf8_lossy(&output.stderr);
        assert!(
            stderr.contains("aborting"),
            "the second signal must say it gave up on the orderly path, got:\n{stderr}"
        );
        assert!(
            elapsed < ABORT_DEADLINE,
            "the second signal must not wait out the executor's grace period, took {elapsed:?}"
        );
        assert_eq!(
            output.status.code(),
            Some(130),
            "the second signal must abort with the conventional SIGINT code, stderr:\n{stderr}"
        );
    }
}