App-Ariza.git | xt/ | 02-installer-e2e.rakutest
use v6.d;
use Test;
use App::Ariza::Config;
use App::Ariza::Installer;
use App::Ariza::Resources;
use App::Ariza::Tools;
=begin comment
The generated install.sh, exercised against a real bundle: install,
re-install, upgrade, prune, uninstall, and the failure paths — with
C<HOME> pointed at a scratch directory throughout, so nothing here can
touch the machine it runs on.
It lives in xt/ because it needs a built bundle, which t/ has no way to
produce in a second. Nothing here touches the network: the installer's
C<--url> escape hatch takes a plain file path, which is exactly the seam
this test drives.
ariza bundle --app=../App-ExampleApp --out-dir=/tmp/dist
ARIZA_E2E_ARCHIVE=/tmp/dist/exampleapp-9.9.9-macos-arm64.tar.gz \
prove6 -Ilib xt/02-installer-e2e.rakutest
Without C<ARIZA_E2E_ARCHIVE> the whole file skips: there is no honest
way to fake a bundle, and a green run against a fixture would be worse
than no run at all.
=end comment
my $ARCHIVE = (%*ENV<ARIZA_E2E_ARCHIVE> // '').trim;
my $APP = checkout-root().parent.add('App-ExampleApp');
sub bail(Str $why) {
plan 1;
skip $why;
exit 0;
}
bail 'set ARIZA_E2E_ARCHIVE to a built bundle archive to run this'
unless $ARCHIVE;
bail "no bundle archive at $ARCHIVE" unless $ARCHIVE.IO.f;
bail "no App-ExampleApp beside this checkout (looked in $APP)"
unless $APP.add('ariza.toml').f;
my $CFG = App::Ariza::Config.load($APP);
my $EXEC = $CFG.app-exec;
# The version and platform under test are read off the archive's name,
# which ariza builds as <exec>-<version>-<slug>: this file asserts that
# the installed launcher reports its own version, not a number written
# down here.
my $STEM = $ARCHIVE.IO.basename.subst(/'.tar.gz' $/, '');
bail "$STEM is not a $EXEC bundle" unless $STEM.starts-with("$EXEC-");
my $TAIL = $STEM.substr("$EXEC-".chars);
my $SLUG = $CFG.bundle-platforms.list.first({ $TAIL.ends-with("-$_") })
// bail "$STEM names no platform {$CFG.app-name} declares";
my $VERSION = $TAIL.substr(0, $TAIL.chars - $SLUG.chars - 1);
# Resolved up front: on macOS $*TMPDIR is a symlink, and comparing a
# resolved symlink target against an unresolved path would fail for
# reasons that have nothing to do with the installer.
my $SCRATCH = $*TMPDIR.resolve.add("ariza-e2e-{$*PID}-{(^1_000_000).pick}");
my $HOME = ensure-dir($SCRATCH.add('home'));
my $SCRIPTS = ensure-dir($SCRATCH.add('scripts'));
my $ARCHIVES = ensure-dir($SCRATCH.add('archives'));
# `with`, not `if …d`: an early `bail` exits before this line has run, and
# END blocks still fire — on a container that is still the Any default.
END { rm-rf($_) with $SCRATCH }
my $DATA = $HOME.add('.local/share').add($EXEC);
my $BIN = $HOME.add('.local/bin').add($EXEC);
#| Successive versions to upgrade through: the archive's own, then the
#| next two by bumping its last component.
sub bumped(Int $by --> Str) {
my @parts = $VERSION.split('.');
(|@parts.head(*-1), @parts.tail + $by).join('.')
}
#| The environment an installer run gets: a replacement, not a filter, so
#| nothing about the machine running the suite can reach it. C<run>'s
#| C<:env> substitutes the child's whole environment, which is C<env -i>
#| without needing C<env>.
sub inst-env(%extra = {} --> Hash) {
my %env =
HOME => $HOME.absolute,
PATH => '/usr/bin:/bin',
TERM => 'xterm',
TMPDIR => $SCRATCH.absolute,
;
%env{$_} = %extra{$_} for %extra.keys;
%env
}
sub install(*@args --> List) {
try-run(['sh', $SCRIPTS.add('install.sh').absolute, |@args], :env(inst-env))
}
sub uninstall(--> List) {
try-run(['sh', $SCRIPTS.add('uninstall.sh').absolute], :env(inst-env))
}
#| Repack a bundle archive as a different version: unpack, rename the one
#| top-level directory, tar it back up, write the sibling digest. That is
#| what an upgrade looks like from the installer's side, and the only
#| honest way to test one without cutting a release.
sub repack(Str $from, Str $version --> Str) {
my $work = ensure-dir($ARCHIVES.add("work-$version"));
LEAVE { rm-rf($work) }
extract-archive($from, $work);
my $name = "$EXEC-$version-$SLUG";
sole-child($work).rename($work.add($name));
my $out = $ARCHIVES.add("$name.tar.gz");
run-checked(['tar', '-c', '-z', '-f', $out.absolute, '-C', $work.absolute, $name],
:what('tar'));
$ARCHIVES.add("$name.tar.gz.sha256").spurt(sha256-file($out) ~ " $name.tar.gz\n");
$out.absolute
}
sub versions-installed(--> List) {
return () unless $DATA.add('versions').d;
$DATA.add('versions').dir.map(*.basename).sort.List
}
sub rc-files(--> List) { ($HOME.add('.profile'), $HOME.add('.zshrc')).List }
sub marker-count(--> Int) {
rc-files.grep({ .f && .slurp.contains("# >>> $EXEC PATH >>>") }).elems
}
# Two rc files, with content in them, so "the block was appended without
# eating anything" is a checkable claim rather than a hope.
my constant PROFILE-BODY = "# a profile\nexport EDITOR=vi\n";
.spurt(PROFILE-BODY) for rc-files;
plan 9;
subtest 'the installers render for the app under test', {
plan 3;
my @written = App::Ariza::Installer.write(:out-dir($SCRIPTS), :config($CFG));
is-deeply @written.map(*.basename).sort.List,
('install.ps1', 'install.sh', 'uninstall.ps1', 'uninstall.sh'),
'all four scripts';
ok $SCRIPTS.add('install.sh').x, 'and install.sh is executable';
my ($code, $, $err) = try-run(['sh', '-n', $SCRIPTS.add('install.sh').absolute]);
is $code, 0, 'sh -n accepts it' or diag $err;
};
subtest 'installing from a local file', {
plan 10;
my ($code, $out, $err) = install('--url', $ARCHIVE);
is $code, 0, 'install.sh --url <file> exits 0' or diag $err ~ $out;
ok $out.contains('sha256 verified'),
'the sibling .sha256 was found and checked';
ok $out.contains("$VERSION installed"), 'and it says what it installed';
# The warm-up runs for real here, against a real bundle: this is the
# only place in the suite where the installed launcher is started by
# the installer rather than by a test.
ok $out.contains('warming up'),
'the install warms the app up rather than leaving it to the user';
ok $out.contains('ready'),
'and the warm-up succeeded, which for a bundle that installed'
~ ' cleanly it should';
is-deeply versions-installed, ($VERSION,), "versions/$VERSION exists";
ok $DATA.add('current').l, 'current is a symlink, not a copy';
is $DATA.add('current').resolve.basename, $VERSION, 'pointing at that version';
ok $BIN.l, "~/.local/bin/$EXEC is a symlink";
is $BIN.resolve.absolute,
$DATA.add("versions/$VERSION/bin/$EXEC").resolve.absolute,
'that resolves through current to the version just installed';
};
subtest 'PATH is persisted once, without eating the rc file', {
plan 3;
is marker-count, 2, 'both rc files got the marker block';
for rc-files() -> $rc {
ok $rc.slurp.starts-with(PROFILE-BODY),
"{$rc.basename}: what was already in it is untouched";
}
};
subtest 'the installed launcher runs, and knows its own version', {
plan 2;
# Found on PATH through the ~/.local/bin symlink, with a replaced
# environment: this is the whole point of the exercise. A launcher
# whose symlink resolution is wrong, or a bundle that quietly needs
# something from the developer's shell, fails here.
my %env = inst-env({ PATH => "/usr/bin:/bin:{$HOME.add('.local/bin').absolute}" });
my ($code, $out, $err) = try-run(['sh', '-c', "exec $EXEC --version"], :%env);
is $code, 0, "$EXEC --version exits 0" or diag $err;
# Standard output only: the launcher's one-line first-run notice goes
# to stderr, exactly so that this kind of check is not fighting it.
is $out.lines.grep(*.trim).tail, "{$CFG.app-name} $VERSION",
'and reports the version the bundle was built from';
};
subtest 're-running the same version is a no-op that repairs', {
plan 4;
# Delete the two things a user is most likely to lose, then re-run:
# the obvious remedy has to be the correct one.
$BIN.unlink;
.spurt(PROFILE-BODY) for rc-files;
my ($code, $out, $err) = install('--url', $ARCHIVE);
is $code, 0, 'exits 0' or diag $err;
ok $out.contains('already installed'), 'and says so rather than reinstalling';
ok $BIN.l, 'the missing bin link is back';
is marker-count, 2, 'and so is the PATH block';
};
subtest 'upgrading installs beside the old version and flips current', {
plan 5;
my $next = bumped(1);
# Driven through the environment variable rather than --url: that is
# the other half of the escape hatch, and nothing else exercises it.
my ($code, $out, $err) = try-run(
['sh', $SCRIPTS.add('install.sh').absolute],
:env(inst-env({ App::Ariza::Installer.env-url($CFG) => repack($ARCHIVE, $next) })));
is $code, 0, 'the upgrade exits 0' or diag $err;
ok $out.contains("$next installed"), 'and reports the new version';
is-deeply versions-installed, ($VERSION, $next).sort.List,
'the old version is still on disk, to roll back to';
is $DATA.add('current').resolve.basename, $next,
'while current points at the new one';
is $BIN.resolve.absolute, $DATA.add("versions/$next/bin/$EXEC").resolve.absolute,
'so the unchanged bin link now reaches the new version';
};
subtest 'a third version prunes back to one previous', {
plan 3;
my $third = bumped(2);
my ($code, $out, $err) = install('--url', repack($ARCHIVE, $third));
is $code, 0, 'exits 0' or diag $err;
ok $out.contains("removed superseded version $VERSION"),
'the oldest is removed, and the user is told which';
is-deeply versions-installed, (bumped(1), $third).sort.List,
'exactly the current version and the one before it are kept';
};
subtest 'refusing what it cannot verify, without disturbing what is installed', {
plan 6;
my $before = versions-installed;
my $bare = ensure-dir($SCRATCH.add('bare')).add($ARCHIVE.IO.basename);
$ARCHIVE.IO.copy($bare);
my ($no-digest, $, $err1) = install('--url', $bare.absolute);
isnt $no-digest, 0, 'a source with no .sha256 beside it is fatal';
ok $err1.contains('cannot be verified'), 'saying why' or diag $err1;
my $bad = ensure-dir($SCRATCH.add('bad')).add($ARCHIVE.IO.basename);
$ARCHIVE.IO.copy($bad);
$SCRATCH.add('bad').add($ARCHIVE.IO.basename ~ '.sha256')
.spurt('0' x 64 ~ " {$ARCHIVE.IO.basename}\n");
my ($mismatch, $, $err2) = install('--url', $bad.absolute);
isnt $mismatch, 0, 'a wrong checksum is fatal';
ok $err2.contains('checksum mismatch'), 'naming both digests' or diag $err2;
is-deeply versions-installed, $before,
'and neither failure touched the working install';
# The one documented way past it, and only for a source the user
# named themselves.
my ($forced, $, $err3) = install('--url', $bare.absolute, '--insecure-no-verify');
is $forced, 0, '--insecure-no-verify installs it anyway, loudly' or diag $err3;
};
subtest 'uninstalling leaves nothing of its own behind', {
plan 6;
my ($code, $, $err) = uninstall;
is $code, 0, 'uninstall.sh exits 0' or diag $err;
nok $DATA.e, 'the data directory is gone';
nok $BIN.e || $BIN.l, 'the bin link is gone';
is marker-count, 0, 'and the PATH block is out of every rc file';
is-deeply rc-files.map(*.slurp).unique.List, (PROFILE-BODY,),
'leaving both rc files byte-identical to what they were before';
# The other end of the same promise: a platform the app does not
# publish for is named rather than guessed at.
my $elsewhere = ensure-dir($SCRATCH.add('elsewhere'));
my $absent = $SLUG.starts-with('macos') ?? 'linux-aarch64-musl' !! 'macos-x86_64';
$elsewhere.add('ariza.toml').spurt(qq:to/TOML/);
[app]
name = "{$CFG.app-name}"
exec = "$EXEC"
display = "{$CFG.app-display}"
[bundle]
platforms = ["$absent"]
[installer]
repo = "{$CFG.installer-repo}"
TOML
my $other = ensure-dir($SCRATCH.add('elsewhere-scripts'));
App::Ariza::Installer.write(:out-dir($other),
:config(App::Ariza::Config.load($elsewhere)));
my ($no-bundle, $, $why) = try-run(
['sh', $other.add('install.sh').absolute], :env(inst-env));
ok $no-bundle != 0 && $why.contains('no prebuilt'),
'an unsupported platform is a clear refusal, not a wrong download'
or diag $why;
};