Selkie.git | t/ | 99-terminal-report.rakutest


# Fragmented-terminal-report recognition.
#
# A terminal answers capability queries by writing an escape sequence
# back on the tty. notcurses absorbs those replies — but only when the
# whole reply lands in one read(2). Split it across two reads with as
# little as half a millisecond between them and notcurses replays it
# to the application verbatim, one keypress per byte, which is how
# `[?64;1;2;6;9;15;18;21;22c` once ended up prepended to a passphrase
# in a masked login field.
#
# Selkie's input dispatch reassembles and drops those replies. These
# tests pin the recogniser: what it eats, what it must never eat, and
# what it recognises as "not finished yet, hold on".
use Test;
use lib 'lib';

use Selkie::App::Internal::TerminalReport;

plan 7;

#| Readable failure output: escapes print as <ESC>.
sub vis(Str $s --> Str) { $s.subst("\e", '<ESC>', :g) }

subtest 'every reply the start-up probe burst can provoke is recognised' => {
    # Each of these is a real answer to a query notcurses issues at
    # init, taken from the pty capture that reproduced the incident.
    my @replies =
        "\e[?64;1;2;6;9;15;18;21;22c"       => 'DA1, full attribute list',
        "\e[?62;22c"                        => 'DA1, VT220-style',
        "\e[?1;2;4c"                        => 'DA1, tmux 3.4 three-field form',
        "\e[>1;4000;0c"                     => 'DA2 secondary attributes',
        "\e[24;80R"                         => 'DSR cursor position report',
        "\e[8;24;80t"                       => 'XTWINOPS cell geometry',
        "\e[4;384;640t"                     => 'XTWINOPS pixel geometry',
        "\e[?1u"                            => 'kitty keyboard flags',
        "\e[?2;0;1000;1000S"                => 'XTSMGRAPHICS sixel geometry',
        "\e[?2026;2\$y"                     => 'DECRPM synchronised-update',
        "\e[<0;10;20M"                      => 'SGR mouse press',
        "\e[<0;10;20m"                      => 'SGR mouse release',
        "\eP1+r5463=787465726d\e\\"         => 'XTGETTCAP reply',
        "\eP0+r7A7A\e\\"                    => 'XTGETTCAP negative reply',
        "\eP>|iTerm2 3.5.11\e\\"            => 'XTVERSION',
        "\e]11;rgb:1e1e/1e1e/2e2e\e\\"      => 'OSC 11 background, ST-terminated',
        "\e]10;rgb:cdcd/d6d6/f4f4\a"        => 'OSC 10 foreground, BEL-terminated',
        "\e]4;3;rgb:e0e0/afaf/6868\e\\"     => 'OSC 4 palette entry',
        "\e_Gi=1;OK\e\\"                    => 'kitty graphics acknowledgement',
        ;
    plan @replies.elems;

    for @replies -> (:key($seq), :value($what)) {
        is terminal-report-prefix($seq), $seq.chars,
            "$what is consumed whole — {vis($seq)}";
    }
}

subtest 'what a user types is never mistaken for a reply' => {
    my @typing =
        "\e"                    => 'a bare Escape keypress',
        "\e["                   => 'Escape then an open bracket, nothing more',
        "\e[A"                  => 'cursor up — no parameters, not a report final',
        "\e[B"                  => 'cursor down',
        "\e[H"                  => 'home',
        "\e[F"                  => 'end',
        "\e[1;5A"               => 'Ctrl+Up — parameters, but a cursor final',
        "\e[2~"                 => 'Insert — a tilde final, not a report',
        "\e[?25h"               => 'DECTCEM show cursor — output, not input',
        "\e[0m"                 => 'an SGR reset — bare m without the < introducer',
        "\ehunter2"             => 'Escape then the passphrase',
        'hunter2'               => 'the passphrase on its own',
        "\e[?64;1;2"            => 'half a DA1 reply — incomplete is not a match',
        "\eP1+r5463"            => 'an XTGETTCAP reply with no ST yet',
        "\e]11;rgb:1e1e"        => 'an OSC reply with no terminator yet',
        "\e[?64;1;2\ehunter"    => 'a DA1 aborted by a second Escape',
        ;
    plan @typing.elems;

    for @typing -> (:key($seq), :value($what)) {
        is terminal-report-prefix($seq), 0,
            "$what is passed through — {vis($seq)}";
    }
}

subtest 'a reply is stripped from the head, and only the head' => {
    plan 6;

    # The incident's exact shape: a reply arrives while the user is
    # typing, so the burst is reply-then-passphrase. The passphrase
    # must survive intact.
    my $mixed = "\e[?64;1;2;6;9;15;18;21;22c" ~ 'hunter2';
    is terminal-report-strip-length($mixed), 26,
        'the reply is measured, the typing is not';
    is $mixed.substr(terminal-report-strip-length($mixed)), 'hunter2',
        'and what is left is exactly what the user typed';

    # The probe burst is answered all at once; a replay carries the
    # whole run.
    my $burst = "\e[1;1R\e[?62;22c\e[>1;10;0c\e[8;24;80t";
    is terminal-report-strip-length($burst), $burst.chars,
        'consecutive replies come off together';
    is terminal-report-strip-length($burst ~ 'hunter2'), $burst.chars,
        'even with typing behind them';

    # Typing first means nothing is stripped: the run must start at
    # the head or it is not a replay of an aborted escape.
    is terminal-report-strip-length('h' ~ $burst), 0,
        'a reply that is not at the head is left alone';
    is terminal-report-strip-length(''), 0, 'and the empty burst is a no-op';
}

subtest 'a half-arrived reply is recognised as still growing' => {
    # This is what makes the fix work across frames. Under load the
    # terminal's answer reaches notcurses in pieces and notcurses
    # replays each piece separately; without recognising the first
    # piece as viable, dispatch types it into the focused field a
    # frame before the rest shows up.
    my @viable =
        "\e"                        => 'Escape alone — an introducer may follow',
        "\e["                       => 'CSI opened',
        "\e[?"                      => 'private introducer seen',
        "\e[?64"                    => 'mid-parameters',
        "\e[?64;1;2"                => 'the exact fragment from the capture',
        "\e[?2026;2\$"              => 'intermediates seen, final still to come',
        "\eP1+r5463"                => 'a string sequence with no ST yet',
        "\e]11;rgb:1e1e/1e1e"       => 'an OSC with no terminator yet',
        "\e_Gi=1;OK"                => 'an APC with no terminator yet',
        ;
    plan @viable.elems + 5;

    for @viable -> (:key($seq), :value($what)) {
        ok terminal-report-viable($seq), "$what is still viable — {vis($seq)}";
    }

    nok terminal-report-viable("\e[?64;1;2;22c"),
        'a complete reply is not "still growing"';
    nok terminal-report-viable("\e[?64;1;2h"),
        'a sequence that ended on a non-report final is finished, not viable';
    nok terminal-report-viable("\e[A"), 'and so is a cursor key';
    nok terminal-report-viable("\ehunter"),
        'Escape followed by typing is not a sequence at all';
    nok terminal-report-viable('hunter'), 'nor is plain text';
}

subtest 'signed offscreen SGR mouse reports are isolated to mouse grammar' => {
    my @captured =
        "\e[<0;3510;-10M"  => 'the exact press capture',
        "\e[<32;3510;-10M" => 'the exact motion capture',
        "\e[<0;3510;-10m"  => 'the exact release capture',
        ;
    my @viable =
        "\e[<0;-"           => 'a negative first coordinate sign',
        "\e[<0;-10"         => 'a partial negative first coordinate',
        "\e[<0;-10;-"       => 'a negative second coordinate sign',
        "\e[<0;3510;-"      => 'the captured negative-coordinate prefix',
        "\e[<0;3510;-10"    => 'the captured report before its final byte',
        ;
    my @malformed =
        "\e[<;3510;-10M"    => 'a missing button code',
        "\e[<-1;3510;-10M"  => 'a signed button code',
        "\e[<0;;-10M"       => 'a missing first coordinate',
        "\e[<0;3510;M"      => 'a missing second coordinate',
        "\e[<0;3510;-M"     => 'a sign without coordinate digits',
        "\e[<0;3510;-10;1M" => 'a fourth field',
        "\e[<0:3510:-10M"   => 'colon field separators',
        "\e[?64;-1c"        => 'a minus sign in ordinary CSI parameters',
        ;
    plan @captured.elems + @viable.elems + @malformed.elems + 4;

    for @captured -> (:key($seq), :value($what)) {
        is terminal-report-prefix($seq), $seq.chars,
            "$what is consumed whole — {vis($seq)}";
    }

    for @viable -> (:key($seq), :value($what)) {
        ok terminal-report-viable($seq), "$what stays viable — {vis($seq)}";
    }

    my $triplet = @captured.map(*.key).join;
    is terminal-report-strip-length($triplet), $triplet.chars,
        'the consecutive press, motion and release triplet is stripped';

    my $typing = 'real typing';
    my $mixed = $triplet ~ $typing;
    is terminal-report-strip-length($mixed), $triplet.chars,
        'only the triplet is measured when real typing follows';
    is $mixed.substr(terminal-report-strip-length($mixed)), $typing,
        'real typing after the triplet is preserved exactly';

    for @malformed -> (:key($seq), :value($what)) {
        is terminal-report-strip-length($seq), 0,
            "$what is not swallowed — {vis($seq)}";
    }

    nok terminal-report-viable("\e[<0;3510;--"),
        'a malformed negative coordinate does not remain viable';
}

subtest 'the scan is bounded' => {
    plan 4;

    # An unterminated string sequence must not turn a large paste into
    # an unbounded walk, and must not be held back forever either.
    my $long = "\eP1+r" ~ ('a' x 4000);
    is terminal-report-prefix($long), 0,
        'an unterminated string sequence never matches';
    nok terminal-report-viable($long),
        'and past the cap it stops being viable, so dispatch lets it go';

    my $at-cap = "\eP1+r" ~ ('a' x 200) ~ "\e\\";
    is terminal-report-prefix($at-cap), $at-cap.chars,
        'a long but legal reply inside the cap still matches';

    my $past-cap = "\eP1+r" ~ ('a' x 400) ~ "\e\\";
    is terminal-report-prefix($past-cap), 0,
        'and one past the cap does not';
}

subtest 'the recogniser is total' => {
    plan 5;

    # Everything reachable from an input burst must produce an answer
    # rather than an exception: bursts contain whatever the terminal
    # and the user between them produced.
    lives-ok { terminal-report-prefix('') }, 'the empty string';
    lives-ok { terminal-report-prefix("\e") }, 'a lone Escape';
    lives-ok { terminal-report-prefix("\e[") }, 'a truncated CSI';
    lives-ok { terminal-report-prefix("\e[?\x[263A]c") },
        'a non-ASCII character inside a sequence';
    lives-ok { terminal-report-viable("\e]" ~ "\e") },
        'an Escape immediately inside a string sequence';
}