diff --git a/Dockerfile b/Dockerfile index fc71fea..8726068 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,26 @@ FROM perl:5.42.2-slim-bookworm@sha256:49f4e5e7e2fc5b12e5fc9b5a0603d96502feb24b97babd1bdf42e3f1fc3ebc43 +# expect-dev - provides `unbuffer` RUN apt-get update && \ - apt-get install -y curl npm expect-dev && \ - apt-get purge --auto-remove -y && \ + apt-get install -y --no-install-recommends expect-dev && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* -RUN npm install -g tap-parser - WORKDIR /opt/test-runner COPY . . -RUN curl -fsSL https://raw.githubusercontent.com/skaji/cpm/main/cpm | perl - install -g --cpanfile /opt/test-runner/cpanfile --snapshot /dev/null + +# Fetch the cpm installer (a single-file Perl script). +ADD https://raw.githubusercontent.com/skaji/cpm/main/cpm /tmp/cpm + +# Build the CPAN deps. A C toolchain is needed to compile the XS modules, but it is +# installed and purged within this single layer so the compiler doesn't bloat the +# final image. The cpm build cache is removed too. +RUN apt-get update && \ + apt-get install -y --no-install-recommends build-essential && \ + perl /tmp/cpm install -g --cpanfile /opt/test-runner/cpanfile --snapshot /dev/null && \ + apt-get purge -y build-essential && \ + apt-get autoremove -y && \ + apt-get clean && \ + rm -rf "$HOME/.perl-cpm" /usr/share/doc /var/lib/apt/lists/* /tmp/* ENTRYPOINT ["/opt/test-runner/bin/run.sh"] diff --git a/bin/run.sh b/bin/run.sh index 03d7baa..cc16d33 100755 --- a/bin/run.sh +++ b/bin/run.sh @@ -45,7 +45,7 @@ else test_file="${input_dir}/${slug}.t" fi chmod +x $test_file -TABLE_TERM_SIZE=120 HARNESS_ACTIVE=1 PERL5OPT='-MXXX=-global' unbuffer perl -I"${input_dir}/lib" -I"${input_dir}/local/lib/perl5" $test_file 2>&1 | tap-parser -j 0 > "${output_dir}/tap.json" +TABLE_TERM_SIZE=120 HARNESS_ACTIVE=1 PERL5OPT='-MXXX=-global' unbuffer perl -I"${input_dir}/lib" -I"${input_dir}/local/lib/perl5" $test_file 2>&1 | perl "$(dirname "$0")/tap-to-json.pl" > "${output_dir}/tap.json" bin/transform-results.pl "${output_dir}/tap.json" "${results_file}" $test_file echo "${slug}: done" diff --git a/bin/tap-to-json.pl b/bin/tap-to-json.pl new file mode 100755 index 0000000..2ca7421 --- /dev/null +++ b/bin/tap-to-json.pl @@ -0,0 +1,88 @@ +#!/usr/bin/env perl + +# Drop-in replacement for `tap-parser -j 0` (the npm package), using only core Perl +# (JSON::PP). Reads a TAP stream on STDIN and writes, to STDOUT, the JSON event array +# that bin/transform-results.pl consumes: +# +# ["assert", {"ok": , "name": }] one per TAP test point +# ["comment", "# ...\n"] diagnostic (# ...) lines +# ["extra", "...\n"] non-TAP output (errors, prints) +# ["bailout", ""] a `Bail out!` line +# ["complete",{"count":N,"ok":, +# "plan":{"end":,"skipAll":}}] emitted once at the end +# +# Only the fields transform-results.pl reads are emitted; version/plan lines are folded +# into the trailing `complete` event exactly as the npm tap-parser reports them. + +use v5.36; +use JSON::PP (); + +binmode STDIN, ':encoding(UTF-8)'; +binmode STDOUT, ':encoding(UTF-8)'; + +sub jbool { $_[0] ? JSON::PP::true : JSON::PP::false } + +my @events; +my ($count, $failed, $bailed) = (0, 0, 0); +my $plan_end; # stays undef unless a `1..N` plan line is seen + +while (my $line = ) { + if ($line =~ /^TAP version \d+/) { + # version is not consumed by transform-results.pl + } + elsif ($line =~ /^(\d+)\.\.(\d+)/) { + $plan_end = 0 + $2; # reported via the `complete` event, not as its own event + } + elsif ($line =~ /^Bail out!\s*(.*?)\s*$/) { + $bailed = 1; + push @events, [ 'bailout', $1 ]; + } + elsif ($line =~ /^(not )?ok\b[ \t]*[0-9]*(.*)$/) { + my $is_not = $1; + $failed++ if $is_not; + $count++; + my $name = $2 // ''; + $name =~ s/^\s*-\s*//; # drop the "- " separator + $name =~ s/\s+#\s*(?:SKIP|TODO)\b.*$//i; # drop a trailing TAP directive + $name =~ s/\s+$//; + push @events, [ 'assert', { ok => jbool(!$is_not), name => $name } ]; + } + elsif ($line =~ /^\s*#/) { + push @events, [ 'comment', $line ]; + } + elsif ($line =~ /^\s*$/) { + # blank lines produce no event (matches the npm tap-parser) + } + else { + push @events, [ 'extra', $line ]; + } +} + +# Reproduce how tap-parser fills in the plan for the `complete` event: +# - real `1..N` plan -> end = N, skipAll = false +# - no plan and no tests run -> end = 0, skipAll = true ("no tests found") +# - tests ran but no plan -> end = null, skipAll = false (e.g. died mid-run) +my ($end, $skip_all); +if (defined $plan_end) { ($end, $skip_all) = ($plan_end, 0); } +elsif ($count == 0) { ($end, $skip_all) = (0, 1); } +else { ($end, $skip_all) = (undef, 0); } + +my $ok = + $bailed ? 0 + : $skip_all ? 1 + : ($failed == 0 && (!defined $plan_end || $count == $plan_end)) ? 1 + : 0; + +# tap-parser injects this diagnostic when the test count doesn't match the plan +# (e.g. a test died before done_testing()), just before the `complete` event. +if (defined $plan_end ? $count != $plan_end : $count > 0) { + my $plan_str = defined $plan_end ? $plan_end : 'null'; + push @events, [ 'comment', "# test count($count) != plan($plan_str)\n" ]; +} + +push @events, [ + 'complete', + { count => $count, ok => jbool($ok), plan => { end => $end, skipAll => jbool($skip_all) } }, +]; + +print JSON::PP->new->canonical->encode(\@events);