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';
}