Selkie.git | t/ | 98-best-effort-shell.rakutest
use Test;
use lib 'lib';
# `try shell '...'` does NOT make a shell-out best-effort.
#
# Proc throws from its `sink` method, and in `try shell '…';` the value
# of `try` is sunk at STATEMENT level — outside the try. So the exception
# escapes, from a line whose entire purpose was to be ignorable.
#
# Selkie shells out in one place: the centralized terminal platform
# adapter's best-effort flow-control setup. The adapter gates Windows and
# /dev/tty before invoking the fixed stty command. App and Snapshot never
# issue platform-specific shell commands themselves.
#
# This file pins both halves: the language behaviour that makes the naive
# form wrong, and the absence of that form from the source.
plan 4;
subtest 'the trap itself' => {
plan 2;
# The naive form: the Proc is sunk by the statement, outside the try.
# NB the trailing statement — without one, `try shell` is the block's
# own value and the sink moves out past this CATCH as well, which is
# the same trap one level up.
my $escaped = False;
{
CATCH { default { $escaped = True } }
try shell 'exit 3';
$escaped = False;
}
ok $escaped, '`try shell` alone does NOT catch a failing command';
# The fix: assign, so there is no sink to throw from.
my $survived = False;
{
CATCH { default { $survived = False } }
my $ = try shell 'exit 3';
$survived = True;
}
ok $survived, 'assigning the Proc instead of sinking it does';
}
# The call site, by inspection. There is no way to require a live controlling
# terminal portably in this test, so the guard pins the safe spelling and the
# consumer/adapter boundary in source.
my sub source(Str:D $path --> Str:D) { $path.IO.slurp }
my sub unsafe-lines(Str:D $path --> List) {
source($path).lines.grep({
# A `try shell` / `try run` whose result is not being assigned.
.trim.starts-with('try shell') || .trim.starts-with('try run')
}).List;
}
subtest 'the terminal adapter does not sink its best-effort shell-out' => {
plan 2;
my $path = 'lib/Selkie/App/Internal/TerminalPlatform.rakumod';
is unsafe-lines($path).elems, 0,
'no bare `try shell` — it would escape the adapter';
ok source($path).contains('my $ = try shell'),
'the centralized stty call is assigned rather than sunk';
}
subtest 'App and Snapshot contain no platform shell-outs' => {
plan 2;
my @paths = <lib/Selkie/App.rakumod lib/Selkie/Test/Snapshot.rakumod>;
is @paths.map({ unsafe-lines($_).elems }).sum, 0, 'neither consumer has an unsafe shell-out';
my $consumer-source = @paths.map({ source($_) }).join("\n");
nok $consumer-source.contains('try shell') || $consumer-source.contains('qx{'),
'platform shell behavior exists only in the adapter';
}
subtest 'and nowhere else in the library' => {
plan 1;
# Walk the whole tree rather than a fixed list: the point is that the
# idiom does not come back, wherever somebody puts it next.
my @found;
my sub walk(IO::Path $dir --> Nil) {
for $dir.dir -> $entry {
if $entry.d {
walk($entry);
}
elsif $entry.extension eq 'rakumod' {
@found.push($entry.Str) if unsafe-lines($entry.Str).elems;
}
}
Nil;
}
walk('lib'.IO);
is-deeply @found.sort.List, ().List,
'no module sinks a Proc it meant to ignore';
}
# vim: ft=raku