Selkie.git | xt/ | 07-snapshot-detached-tty.rakutest


use Test;

# Regression test: snapshot scenarios must complete even when the
# process has a controlling terminal whose "far side" never answers
# notcurses's startup queries.
#
# notcurses ignores a non-tty output FILE* and interrogates the
# controlling terminal via /dev/tty, blocking WITHOUT TIMEOUT on the
# replies. Before Selkie::Test::Snapshot detached from the controlling
# terminal (setsid) ahead of notcurses_init, running the snapshot suite
# from an interactive shell meant 60+ query round-trips against the
# developer's real terminal — one lost or fragmented reply (tmux is a
# known fragmenter) hung the suite forever, which is exactly what
# happened intermittently under `mi6 release`.
#
# We reproduce the hostile environment with script(1): it gives the
# child a fresh pty as its controlling terminal, but nothing on the
# master side ever answers terminal queries. Pre-fix, the scenario
# blocked forever in notcurses_core_init. Post-fix, the scenario
# detaches and renders headlessly, so it must finish promptly and
# still produce the golden content.

my $scenario = 'xt/snapshots/01-text-basic.raku'.IO;
my $deadline = 180;

plan 2;

if $*DISTRO.is-win {
    # Windows has no controlling-terminal / /dev/tty mechanism, so the
    # blocked-interrogation failure mode cannot exist there. Run the
    # scenario directly to keep asserting prompt completion + output.
    my @cmd = 'raku', '-I', 'lib', $scenario.Str;
    run-and-check(@cmd);
} else {
    # The scenario must run as a NON-leader child of the pty session,
    # matching the harness topology (harness process → scenario child).
    # script(1) makes its immediate child the session leader
    # (login_tty), and sh -c EXECS a lone command in place — either
    # way raku itself would become the leader, where setsid cannot
    # apply. The trailing `:` makes the command a compound list, which
    # forces sh to fork raku as an ordinary non-leader child.
    my $forked = "raku -I lib {$scenario.Str}; :";
    my @cmd = $*KERNEL.name.lc.contains('darwin')
        ?? ('script', '-q', '/dev/null', 'sh', '-c', $forked)
        !! ('script', '-qec', $forked, '/dev/null');
    run-and-check(@cmd);
}

sub run-and-check(@cmd) {
    my $proc = Proc::Async.new(|@cmd);
    my $out = '';
    $proc.stdout.tap({ $out ~= $_ });
    $proc.stderr.tap({ $out ~= $_ });

    my $done = $proc.start;
    await Promise.anyof($done, Promise.in($deadline));

    if $done.status ~~ Kept | Broken {
        ok True, "scenario completed within {$deadline}s under an unanswered pty";
    } else {
        $proc.kill('SIGKILL');
        # Assigned, not sunk — sinking the killed Proc that `await`
        # returns would throw X::Proc::Unsuccessful outside the try.
        my $ = try await $done;
        flunk "scenario still running after {$deadline}s — notcurses is "
            ~ 'blocked interrogating the controlling terminal (detach regression)';
    }

    # script(1) relays the pty, so the rendered row comes back through
    # our pipe (possibly with \r\n translation — match the substring).
    ok $out.contains('hello, selkie'),
        'scenario rendered the golden content while detached'
        or diag "captured output: {$out.raku}";
}

done-testing;