Perl Part II · Module 29 · Practical VLSI Automation Projects
Project: Regression Dashboard Generator
A regression dashboard answers one question every morning: what changed since yesterday? That means a row per test across all its seeds, a status that tells a flaky test from a broken one, and the lists a lead reads first — newly failing, newly passing, and the tests that quietly stopped running. This project builds the generator from per-run result records, writes a CSV that tomorrow's run reads back exactly and an HTML page that survives the characters real messages contain, and runs it on two real nights of simulation.
Advanced22 min readPerlPart IIProjectsRegression
Part II · Module 29 · Chapter 29.6 · Project: Regression Dashboard Generator
1. The Brief
| Command | dashboard.pl RESULTS_DIR [--prev CSV] [--html FILE] [--csv FILE] [--title TEXT] |
| Reads | one result record per run, RESULTS_DIR/*.tsv; optionally yesterday's dashboard CSV |
| Writes | --csv (default dashboard.csv): one row per test. --html: one self-contained page. STDOUT: the counts and the four change lists |
| Exit 0 | written, and nothing needs attention |
| Exit 1 | written, and a test is newly failing or was removed |
| Exit 2 | an input problem — no records, a record in another layout, a run recorded twice, a --prev file from another version or with a broken line — nothing written |
| Exit 3 | usage error |
The CSV is both the output and tomorrow's input. Today's dashboard.csv is what tomorrow's run reads with --prev, so the format has one writer and one reader, both in this tool, and the strongest test of either is that the tool reads its own output back with no changes. Chapter 26.3 called this a documented hand-off; here the other side of the hand-off is the tool itself, one day later.
The tool sits at the end of the chain this module has been building:
- Per-test UVM summaries — the one-row-per-run record the regression leaves (21.11).
- Reading and writing delimited text — fields that contain the delimiter (13.14).
- Perl produces the report, another tool consumes it — the format is a contract (26.3).
- Writing CSV correctly, generating HTML without a framework and comparing today with yesterday — Module 24's chapters on each output this tool writes (24.8, 24.10, 24.11).
2. Two Real Nights
The input is what a regression runner leaves behind: one small record per run. To have two nights to compare, the testbench takes a +NIGHT argument. Night 2 is the next day's build: the PROTO bug in alu_burst is fixed, and a new CSR bug appears in csr_access. Like Module 21's testbench it prints UVM-shaped lines itself, so the logs are real simulator output without being UVM.
// tb_nightly.v -- six tests, UVM-SHAPED report lines (not UVM: UVM cannot
// run on this toolchain), and two "nights" of the design:
// +TEST=name +SEED=n +NIGHT=1|2
// night 2 is the next day's build: the PROTO bug in alu_burst is fixed,
// and a new CSR bug appears. Message texts contain a comma, double quotes,
// '<' and '&' on purpose -- real messages do, and the reports must cope.
`timescale 1ns/1ps
module tb_nightly;
integer seed, night, n_err;
reg [8*16-1:0] test;
task error(input [8*8-1:0] id, input [8*72-1:0] msg);
begin
$display("UVM_ERROR tb/tb_nightly.v(0) @ %0t: uvm_test_top.env [%0s] %0s", $time, id, msg);
n_err = n_err + 1;
end
endtask
initial begin
n_err = 0;
if (!$value$plusargs("TEST=%s", test)) test = "alu_smoke";
if (!$value$plusargs("SEED=%d", seed)) seed = 1;
if (!$value$plusargs("NIGHT=%d", night)) night = 1;
$display("UVM_INFO @ 0: reporter [RNTST] Running test %0s...", test);
#10;
if (test == "alu_smoke" && seed % 5 == 0)
error("SCBD", "data mismatch on lane 2");
if (test == "alu_burst" && night == 1)
error("PROTO", "req asserted while \"busy\" was high");
if (test == "fifo_full" && seed % 3 == 0)
error("FIFO", "overflow: wrote 17 entries, depth 16");
if (test == "csr_access" && night == 2)
error("CSR", "read of ctrl returned <X> & expected 0x0");
#10 $display("--- UVM Report Summary ---");
$display("UVM_ERROR :%5d", n_err);
$finish;
end
endmoduleThe messages were chosen to contain the characters that break naive reports: a comma in the FIFO message, double quotes in the PROTO message, and < and & in the CSR message. Every one of them occurs in real verification messages.
summarise.pl is the runner's last step, reduced to what this chapter needs: a verdict and the first error, written as a header line and one tab-separated row per run. A tab cannot appear in these messages, so the record needs no quoting; the CSV will.
#!/usr/bin/perl
# summarise.pl OUTDIR LOG... -- the regression runner's last step: one
# result record per run, OUTDIR/<run>.tsv, a header line and one row.
# (The verdict and first-error rules are Module 21's, kept minimal here.)
use strict;
use warnings;
my $outdir = shift // die "usage: summarise.pl OUTDIR LOG...\n";
for my $log (@ARGV) {
my ($run) = $log =~ m{([^/]+)\.log\z} or die "summarise.pl: not a .log: $log\n";
my ($test, $seed) = $run =~ /^(.+)_s(\d+)\z/ or die "summarise.pl: bad run name: $run\n";
open my $in, "<", $log or die "summarise.pl: $log: $!\n";
my ($summary, $first) = (0, undef);
while (<$in>) {
$summary = 1 if /^--- UVM Report Summary ---/;
$first //= "$1: $2" if /^UVM_(?:ERROR|FATAL)\b.*?\[(\w+)\]\s*(.*?)\s*$/;
}
close $in;
my $verdict = !$summary ? "CRASH" : defined $first ? "FAIL" : "PASS";
open my $out, ">", "$outdir/$run.tsv" or die "summarise.pl: $outdir/$run.tsv: $!\n";
print {$out} "test\tseed\tverdict\tsignature\n", join("\t", $test, $seed, $verdict, $first // ""), "\n";
close $out or die "summarise.pl: $outdir/$run.tsv: $!\n";
}#!/bin/bash
# run_nightly.sh NIGHT DIR -- six tests x eight seeds of night NIGHT's build;
# logs in DIR/logs/, one result record per run in DIR/results/.
set -u
night=${1:?usage: run_nightly.sh NIGHT DIR}; out=${2:?usage: run_nightly.sh NIGHT DIR}
mkdir -p "$out/logs" "$out/results"
iverilog -o "$out/nightly.vvp" tb/tb_nightly.v || exit 1
for test in alu_smoke alu_burst alu_random fifo_basic fifo_full csr_access; do
for seed in 1 2 3 4 5 6 7 8; do
vvp -n "$out/nightly.vvp" +TEST=$test +SEED=$seed +NIGHT=$night > "$out/logs/${test}_s$seed.log"
done
done
rm -f "$out/nightly.vvp"
perl summarise.pl "$out/results" "$out"/logs/*.log3. The Tool
#!/usr/bin/perl
# dashboard.pl -- aggregate one night's per-run result records into one row
# per test, compare with the previous night's CSV, and write a CSV plus a
# self-contained HTML page.
use strict;
use warnings;
use Getopt::Long qw(GetOptions);
use constant { OK => 0, ATTENTION => 1, INPUT => 2, USAGE => 3 };
my @COLUMNS = qw(test runs passed failed pass_rate status top_signature);
my %opt = (csv => "dashboard.csv");
GetOptions(\%opt, "prev=s", "html=s", "csv=s", "title=s", "help") or usage("bad option");
usage() if $opt{help};
usage("need exactly one results directory") unless @ARGV == 1;
my $dir = $ARGV[0];
fail(INPUT, "$dir is not a directory") unless -d $dir;
# ---- read every record; each must have the documented header and one row
my (%by_test, %seen, @problems);
my @files = sort glob("$dir/*.tsv");
push @problems, "no *.tsv result records in $dir" unless @files;
for my $file (@files) {
open my $fh, "<", $file or fail(INPUT, "$file: $!");
chomp(my @lines = <$fh>);
close $fh;
if (@lines != 2 || $lines[0] ne join("\t", qw(test seed verdict signature))) {
push @problems, "$file: not a result record (want a header line and one row)";
next;
}
my ($test, $seed, $verdict, $sig) = split /\t/, $lines[1], -1;
unless (defined $sig && $seed =~ /^\d+\z/ && $verdict =~ /^(?:PASS|FAIL|CRASH)\z/) {
push @problems, "$file: malformed row: $lines[1]";
next;
}
if (my $first = $seen{"$test $seed"}) {
push @problems, "$file: $test seed $seed was already read from $first";
next;
}
$seen{"$test $seed"} = $file;
push @{ $by_test{$test} }, { seed => $seed, verdict => $verdict, sig => $sig };
}
# ---- yesterday, from yesterday's own CSV
my %prev;
if (defined $opt{prev}) {
open my $fh, "<", $opt{prev} or fail(INPUT, "cannot read --prev $opt{prev}: $!");
chomp(my $header = <$fh> // "");
push @problems, "$opt{prev}: header is not: " . join(",", @COLUMNS)
unless $header eq join(",", @COLUMNS);
while (my $line = <$fh>) {
chomp $line;
my @f = csv_split($line);
if (!@f) {
push @problems, "$opt{prev} line $.: cannot be read as CSV (an unbalanced quote?)";
next;
}
if (@f != @COLUMNS) {
push @problems, "$opt{prev} line $.: " . scalar(@f) . " fields, want " . scalar(@COLUMNS);
next;
}
my %row;
@row{@COLUMNS} = @f;
$prev{ $row{test} } = \%row;
}
close $fh;
}
if (@problems) {
print STDERR "dashboard.pl: $_\n" for @problems;
fail(INPUT, scalar(@problems) . " input problem(s); nothing written");
}
# ---- one row per test: PASS if no run failed, FAIL if none passed, else FLAKY
my @rows;
for my $test (sort keys %by_test) {
my @runs = @{ $by_test{$test} };
my @failed = grep { $_->{verdict} ne "PASS" } @runs;
my %count;
$count{ $_->{sig} }++ for @failed;
my ($top) = sort { $count{$b} <=> $count{$a} or $a cmp $b } keys %count;
my $passed = @runs - @failed;
push @rows, { test => $test, runs => scalar @runs, passed => $passed,
failed => scalar @failed, pass_rate => rate($passed, scalar @runs),
status => !@failed ? "PASS" : !$passed ? "FAIL" : "FLAKY",
top_signature => defined $top ? "$top ($count{$top} of " . @failed . ")" : "" };
}
# ---- what changed since yesterday
my %now = map { $_->{test} => $_ } @rows;
my (@newly_failing, @newly_passing, @new_tests, @removed);
for my $t (sort keys %{ { %now, %prev } }) {
if (!$prev{$t}) { push @new_tests, $t }
elsif (!$now{$t}) { push @removed, $t }
elsif ($prev{$t}{status} eq "PASS" && $now{$t}{status} ne "PASS") { push @newly_failing, $t }
elsif ($prev{$t}{status} ne "PASS" && $now{$t}{status} eq "PASS") { push @newly_passing, $t }
}
@new_tests = () unless defined $opt{prev}; # with no yesterday, nothing is "new"
# ---- the counts every output below reports, computed BEFORE any of them
my %st;
$st{ $_->{status} }++ for @rows;
# ---- the CSV: quoted where a field needs it, so it reads back exactly
write_atomic($opt{csv}, join("", map { join(",", map { csv_field($_) } @$_) . "\n" }
[@COLUMNS], map { [ @{$_}{@COLUMNS} ] } @rows));
# ---- the HTML page: one file, inline style, every text escaped
write_atomic($opt{html}, html_page()) if defined $opt{html};
printf "%d test(s), %d run(s): PASS %d, FLAKY %d, FAIL %d\n", scalar @rows,
scalar(keys %seen), map { $st{$_} // 0 } qw(PASS FLAKY FAIL);
if (defined $opt{prev}) {
printf "%-14s %s\n", "$_->[0]:", @{ $_->[1] } ? join(", ", @{ $_->[1] }) : "none"
for [ "newly failing", \@newly_failing ], [ "newly passing", \@newly_passing ],
[ "removed", \@removed ], [ "new", \@new_tests ];
} else {
print "no --prev: nothing to compare with\n";
}
printf "wrote %s%s\n", $opt{csv}, defined $opt{html} ? " and $opt{html}" : "";
exit(@newly_failing || @removed ? ATTENTION : OK);
# ---------------------------------------------------------------- helpers
# A pass rate rounded DOWN to a tenth, from the counts (Chapter 29.4).
sub rate { my ($n, $d) = @_; my $t = int(1000 * $n / $d); return sprintf "%d.%d", int($t / 10), $t % 10 }
# RFC 4180: quote a field that holds a comma, a quote or a newline, and
# double every quote inside it.
sub csv_field { my ($f) = @_; return $f unless $f =~ /[",\n]/; $f =~ s/"/""/g; return qq{"$f"} }
# The reverse: split one CSV line into fields, honouring quotes.
sub csv_split {
my ($line) = @_;
my @f;
while ($line =~ /\G(?:"((?:[^"]|"")*)"|([^",]*))(,|\z)/gc) {
# copy the captures first: the s/// below is a match of its own, and
# a successful match resets $1, $2 and $3
my ($quoted, $plain, $sep) = ($1, $2, $3);
push @f, defined $quoted ? ($quoted =~ s/""/"/gr) : $plain;
last if $sep eq "";
}
return pos($line) == length($line) ? @f : (); # anything left over: malformed
}
sub html { my ($s) = @_; $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; $s =~ s/"/"/g; return $s }
sub html_page {
my $title = html($opt{title} // "Regression dashboard");
my $list = sub {
my ($label, @t) = @_;
return "<h2>" . html($label) . " (" . @t . ")</h2>\n"
. (@t ? "<ul>\n" . join("", map { "<li>" . html($_) . "</li>\n" } @t) . "</ul>\n"
: "<p class=\"none\">none</p>\n");
};
my $body = "<h1>$title</h1>\n"
. sprintf("<p>%d tests, %d runs: %d pass, %d flaky, %d fail</p>\n", scalar @rows,
scalar(keys %seen), map { $st{$_} // 0 } qw(PASS FLAKY FAIL));
$body .= $list->("Newly failing", @newly_failing) . $list->("Newly passing", @newly_passing)
. $list->("Removed since yesterday", @removed) . $list->("New since yesterday", @new_tests)
if defined $opt{prev};
$body .= "<table>\n<tr>" . join("", map { "<th>" . html($_) . "</th>" } @COLUMNS) . "</tr>\n";
for my $r (@rows) {
$body .= "<tr class=\"" . lc($r->{status}) . "\">"
. join("", map { "<td>" . html($r->{$_}) . "</td>" } @COLUMNS) . "</tr>\n";
}
$body .= "</table>\n";
return <<"END";
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>$title</title>
<style>
body { font-family: sans-serif; margin: 2em; color: #1e293b; }
table { border-collapse: collapse; }
th, td { border: 1px solid #cbd5e1; padding: 4px 8px; text-align: left; }
tr.fail td { background: #fee2e2; }
tr.flaky td { background: #fef3c7; }
.none { color: #64748b; }
</style>
</head>
<body>
$body</body>
</html>
END
}
sub write_atomic {
my ($path, $text) = @_;
my $tmp = "$path.tmp.$$";
open my $fh, ">", $tmp or fail(USAGE, "cannot write $tmp: $!");
print {$fh} $text;
close $fh or fail(USAGE, "cannot write $tmp: $!");
rename $tmp, $path or fail(USAGE, "cannot rename $tmp: $!");
}
sub usage {
my ($why) = @_;
print STDERR "dashboard.pl: $why\n" if $why;
print STDERR <<'END';
usage: dashboard.pl RESULTS_DIR [--prev CSV] [--html FILE] [--csv FILE] [--title TEXT]
exit: 0 written; 1 written, and a test is newly failing or was removed;
2 input problem (nothing written); 3 usage
END
exit($why ? USAGE : OK);
}
sub fail { my ($code, $msg) = @_; print STDERR "dashboard.pl: $msg\n"; exit $code }A status per test, across its seeds
A test ran eight times, once per seed. If every run passed it is PASS; if none did it is FAIL; anything in between is FLAKY. That third value is the reason a per-test roll-up exists: "any run failed" would call alu_smoke — seven of eight passing — exactly as broken as alu_burst, which never passed, and the two need very different responses. The most common failure signature rides along with its count, so the row says which failure, and how much of the failing it explains.
Four lists, and the one that is easy to forget
for my $t (sort keys %{ { %now, %prev } }) {
if (!$prev{$t}) { push @new_tests, $t }
elsif (!$now{$t}) { push @removed, $t }
elsif ($prev{$t}{status} eq "PASS" && $now{$t}{status} ne "PASS") { push @newly_failing, $t }
elsif ($prev{$t}{status} ne "PASS" && $now{$t}{status} eq "PASS") { push @newly_passing, $t }
}The loop walks the union of yesterday's tests and today's. A comparison that walks only today's tests cannot see a test that did not run, and a test that did not run is not a test that passed — it is the same blind spot Chapter 29.5 found in a glob, one level up. Newly failing includes a test that went from PASS to FLAKY: a test that starts failing one seed in eight has changed, and that is the cheapest moment to find out why.
CSV that reads back exactly
sub csv_field { my ($f) = @_; return $f unless $f =~ /[",\n]/; $f =~ s/"/""/g; return qq{"$f"} }A field containing a comma, a quote or a newline is wrapped in quotes, and a quote inside it is doubled — the RFC 4180 rule. The reader undoes it:
while ($line =~ /\G(?:"((?:[^"]|"")*)"|([^",]*))(,|\z)/gc) {
# copy the captures first: the s/// below is a match of its own, and
# a successful match resets $1, $2 and $3
my ($quoted, $plain, $sep) = ($1, $2, $3);
push @f, defined $quoted ? ($quoted =~ s/""/"/gr) : $plain;
last if $sep eq "";
}Each pass takes one field — quoted or plain — and its separator. The loop stops at the end of the line, and if anything is left over the line is malformed and the reader returns nothing, which the caller reports as an unbalanced quote. The comment records the first defect of section 6.
HTML that cannot be broken by a message
sub html { my ($s) = @_; $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; $s =~ s/"/"/g; return $s }Every piece of text that reaches the page goes through html, including the title and the column names, which never contain < today. & is replaced first, so the & of < is not escaped twice. The page has one inline stylesheet and nothing else — no scripts, no fonts, no links — so it can be attached to an e-mail or opened from a file share and look the same. And it contains no timestamp unless --title puts one there, so two runs over the same records write the same bytes, and a page can be compared with yesterday's using diff.
4. Running It
#!/bin/bash
# demo.sh -- two nights of the regression, then a dashboard for each.
bash run_nightly.sh 1 night1 && bash run_nightly.sh 2 night2 || exit 1
echo "$(ls night1/results | wc -l | tr -d ' ') result records a night, e.g. night1/results/fifo_full_s3.tsv:"
cat night1/results/fifo_full_s3.tsv
echo
echo "== night 1"
perl dashboard.pl night1/results --csv night1.csv
echo "exit $?"
echo
echo "== night 2, against night 1"
perl dashboard.pl night2/results --prev night1.csv --csv night2.csv --html report.html --title "Night 2"
echo "exit $?"
echo
cat night2.csv48 result records a night, e.g. night1/results/fifo_full_s3.tsv:
test seed verdict signature
fifo_full 3 FAIL FIFO: overflow: wrote 17 entries, depth 16
== night 1
6 test(s), 48 run(s): PASS 3, FLAKY 2, FAIL 1
no --prev: nothing to compare with
wrote night1.csv
exit 0
== night 2, against night 1
6 test(s), 48 run(s): PASS 3, FLAKY 2, FAIL 1
newly failing: csr_access
newly passing: alu_burst
removed: none
new: none
wrote night2.csv and report.html
exit 1
test,runs,passed,failed,pass_rate,status,top_signature
alu_burst,8,8,0,100.0,PASS,
alu_random,8,8,0,100.0,PASS,
alu_smoke,8,7,1,87.5,FLAKY,SCBD: data mismatch on lane 2 (1 of 1)
csr_access,8,0,8,0.0,FAIL,CSR: read of ctrl returned <X> & expected 0x0 (8 of 8)
fifo_basic,8,8,0,100.0,PASS,
fifo_full,8,6,2,75.0,FLAKY,"FIFO: overflow: wrote 17 entries, depth 16 (2 of 2)"Both nights have the same totals — three passing tests, two flaky, one failing — and the totals hide everything that happened. alu_burst was fixed and csr_access broke, which is exactly the swap a summary line cannot show. The change lists show it, and the exit status of night 2 is 1 because a test is newly failing.
The two FIFO and SCBD rows are FLAKY, with the failing signature and how many of the failures it accounts for: 2 of 2 for the FIFO overflow, which fails on seeds 3 and 6. The FIFO field contains a comma, so it is quoted; the CSR field contains < and &, which mean nothing to CSV and are written as they are.
The page night 2 produced, exactly as written:
#!/bin/bash
# page_html.sh -- the page night 2 produced, exactly as written.
bash run_nightly.sh 1 night1 > /dev/null && bash run_nightly.sh 2 night2 > /dev/null || exit 1
perl dashboard.pl night1/results --csv night1.csv > /dev/null
perl dashboard.pl night2/results --prev night1.csv --csv night2.csv --html report.html --title "Night 2" > /dev/null
cat report.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Night 2</title>
<style>
body { font-family: sans-serif; margin: 2em; color: #1e293b; }
table { border-collapse: collapse; }
th, td { border: 1px solid #cbd5e1; padding: 4px 8px; text-align: left; }
tr.fail td { background: #fee2e2; }
tr.flaky td { background: #fef3c7; }
.none { color: #64748b; }
</style>
</head>
<body>
<h1>Night 2</h1>
<p>6 tests, 48 runs: 3 pass, 2 flaky, 1 fail</p>
<h2>Newly failing (1)</h2>
<ul>
<li>csr_access</li>
</ul>
<h2>Newly passing (1)</h2>
<ul>
<li>alu_burst</li>
</ul>
<h2>Removed since yesterday (0)</h2>
<p class="none">none</p>
<h2>New since yesterday (0)</h2>
<p class="none">none</p>
<table>
<tr><th>test</th><th>runs</th><th>passed</th><th>failed</th><th>pass_rate</th><th>status</th><th>top_signature</th></tr>
<tr class="pass"><td>alu_burst</td><td>8</td><td>8</td><td>0</td><td>100.0</td><td>PASS</td><td></td></tr>
<tr class="pass"><td>alu_random</td><td>8</td><td>8</td><td>0</td><td>100.0</td><td>PASS</td><td></td></tr>
<tr class="flaky"><td>alu_smoke</td><td>8</td><td>7</td><td>1</td><td>87.5</td><td>FLAKY</td><td>SCBD: data mismatch on lane 2 (1 of 1)</td></tr>
<tr class="fail"><td>csr_access</td><td>8</td><td>0</td><td>8</td><td>0.0</td><td>FAIL</td><td>CSR: read of ctrl returned <X> & expected 0x0 (8 of 8)</td></tr>
<tr class="pass"><td>fifo_basic</td><td>8</td><td>8</td><td>0</td><td>100.0</td><td>PASS</td><td></td></tr>
<tr class="flaky"><td>fifo_full</td><td>8</td><td>6</td><td>2</td><td>75.0</td><td>FLAKY</td><td>FIFO: overflow: wrote 17 entries, depth 16 (2 of 2)</td></tr>
</table>
</body>
</html>5. What The Quoting And Escaping Are For
#!/bin/bash
# formats.sh -- what the quoting and escaping are for, measured on night 2.
bash run_nightly.sh 1 night1 > /dev/null && bash run_nightly.sh 2 night2 > /dev/null || exit 1
perl dashboard.pl night1/results --csv night1.csv > /dev/null
perl dashboard.pl night2/results --prev night1.csv --csv night2.csv --html report.html > /dev/null
echo "== night2.csv read by a naive split /,/ (the header has 7 columns)"
perl -ne 'chomp; printf " %d fields %.46s\n", scalar(my @f = split /,/, $_, -1), $_' night2.csv
echo "== night2.csv given back to the tool as yesterday"
perl dashboard.pl night2/results --prev night2.csv --csv again.csv | sed 's/^/ /'
cmp -s night2.csv again.csv && echo " the two CSV files are byte-identical"
echo "== the CSR cell, escaped (as written) and raw (as a naive page would write it)"
grep -o '<td>CSR[^/]*</td>' report.html | sed 's/^/ escaped: /'
python3 -c '
from html.parser import HTMLParser
class P(HTMLParser):
def __init__(s): super().__init__(); s.text = []; s.tags = []
def handle_data(s, d): s.text.append(d)
def handle_starttag(s, t, a): s.tags.append(t)
for label, cell in [("escaped", "<td>CSR: read of ctrl returned <X> & expected 0x0</td>"),
("raw ", "<td>CSR: read of ctrl returned <X> & expected 0x0</td>")]:
p = P(); p.feed(cell)
print(" %s -> text %r, elements %s" % (label, "".join(p.text), p.tags))
'== night2.csv read by a naive split /,/ (the header has 7 columns)
7 fields test,runs,passed,failed,pass_rate,status,top_s
7 fields alu_burst,8,8,0,100.0,PASS,
7 fields alu_random,8,8,0,100.0,PASS,
7 fields alu_smoke,8,7,1,87.5,FLAKY,SCBD: data mismatch
7 fields csr_access,8,0,8,0.0,FAIL,CSR: read of ctrl re
7 fields fifo_basic,8,8,0,100.0,PASS,
8 fields fifo_full,8,6,2,75.0,FLAKY,"FIFO: overflow: wr
== night2.csv given back to the tool as yesterday
6 test(s), 48 run(s): PASS 3, FLAKY 2, FAIL 1
newly failing: none
newly passing: none
removed: none
new: none
wrote again.csv
the two CSV files are byte-identical
== the CSR cell, escaped (as written) and raw (as a naive page would write it)
escaped: <td>CSR: read of ctrl returned <X> & expected 0x0 (8 of 8)</td>
escaped -> text 'CSR: read of ctrl returned <X> & expected 0x0', elements ['td']
raw -> text 'CSR: read of ctrl returned & expected 0x0', elements ['td', 'x']A naive split /,/ reads the FIFO row as eight fields where the header has seven, because the comma inside the message splits it in two. A downstream script that does that — and many quick ones do — shifts every later column by one for that row only. The tool's own reader gets seven, and the round trip shows it: night 2's CSV given back as "yesterday" produces no changes and a byte-identical rewrite.
An HTML parser reads the raw cell differently from the escaped one. Python's html.parser — used here because no browser was run for this chapter — reads the escaped cell as the text the message contained. It reads the raw cell as text with <X> missing, plus an element named x. A browser builds its page from the same tokenising rules, so the reader of a naively written dashboard would see returned & expected with the most important part of the message gone, and no error anywhere.
6. Two Defects In The First Version
Both were found by running the tool, and both are now mutants in section 9.
The page reported "0 pass, 0 flaky, 0 fail" while the CSV and STDOUT said 3, 2 and 1. The counts were computed in the main program after the line that wrote the HTML, and html_page, a named subroutine defined at the end of the file, reads them. Perl compiles that happily: the subroutine closes over the file-level my %st (Chapter 10.10), and it sees whatever the hash holds when it is called — at that moment, nothing. No warning, no error, a plausible-looking page with zeros in it. The fix is the ordering, now stated in the code: compute every count before any output uses it.
Reading yesterday's CSV printed Use of uninitialized value $3. The reader used $1 =~ s/""/"/gr and then tested $3. But a substitution is a match, and a successful match sets the capture variables afresh (Chapter 11.10) — so whenever the field contained a doubled quote, $3 was gone by the next line. The output was still right, only because undef eq "" is true. The warning was the only sign, and the fix is to copy every capture into a named variable the moment the match succeeds. Test 5 now requires reading night 1's CSV, which contains ""busy"", to print nothing on STDERR.
7. A Test That Stopped Running, And Inputs To Refuse
#!/bin/bash
# edges.sh -- a test that stopped running, and inputs the tool must refuse.
bash run_nightly.sh 1 night1 > /dev/null && bash run_nightly.sh 2 night2 > /dev/null || exit 1
perl dashboard.pl night1/results --csv night1.csv > /dev/null
try() { # LABEL RESULTS-DIR [ARGS]
local label=$1; shift
perl dashboard.pl "$@" --csv out.csv > out.txt 2> err.txt
local rc=$?
echo "== $label"
grep -E '^(newly|removed|new:|no --prev)' out.txt | sed 's/^/ /'
grep -v -E '^(usage:|exit:| )' err.txt | sed 's/^/ /'
echo " exit $rc, csv written: $([ -e out.csv ] && echo yes || echo no)"
rm -f out.csv
}
try "night 1, the first night" night1/results
try "night 2 against night 1" night2/results --prev night1.csv
mkdir -p shrunk && cp night2/results/*.tsv shrunk/ && rm shrunk/fifo_basic_s*.tsv
try "night 2 with fifo_basic not run" shrunk --prev night1.csv
mkdir -p empty
try "no result records" empty
mkdir -p dup && cp night2/results/*.tsv dup/ && cp dup/alu_smoke_s5.tsv dup/alu_smoke_s5_rerun.tsv
try "a run recorded twice" dup
mkdir -p old && cp night2/results/*.tsv old/ && printf 'alu_smoke 9 PASS\n' > old/alu_smoke_s9.tsv
try "a record in an older layout" old
sed '1s/,top_signature$//' night1.csv > v1.csv
try "yesterday from an older version" night2/results --prev v1.csv
sed 's/"PROTO: req/"PROTO req/; s/high (8 of 8)"$/high (8 of 8)/' night1.csv > cut.csv
try "yesterday with a broken quote" night2/results --prev cut.csv== night 1, the first night
no --prev: nothing to compare with
exit 0, csv written: yes
== night 2 against night 1
newly failing: csr_access
newly passing: alu_burst
removed: none
new: none
exit 1, csv written: yes
== night 2 with fifo_basic not run
newly failing: csr_access
newly passing: alu_burst
removed: fifo_basic
new: none
exit 1, csv written: yes
== no result records
dashboard.pl: no *.tsv result records in empty
dashboard.pl: 1 input problem(s); nothing written
exit 2, csv written: no
== a run recorded twice
dashboard.pl: dup/alu_smoke_s5_rerun.tsv: alu_smoke seed 5 was already read from dup/alu_smoke_s5.tsv
dashboard.pl: 1 input problem(s); nothing written
exit 2, csv written: no
== a record in an older layout
dashboard.pl: old/alu_smoke_s9.tsv: not a result record (want a header line and one row)
dashboard.pl: 1 input problem(s); nothing written
exit 2, csv written: no
== yesterday from an older version
dashboard.pl: v1.csv: header is not: test,runs,passed,failed,pass_rate,status,top_signature
dashboard.pl: 1 input problem(s); nothing written
exit 2, csv written: no
== yesterday with a broken quote
dashboard.pl: cut.csv line 2: cannot be read as CSV (an unbalanced quote?)
dashboard.pl: 1 input problem(s); nothing written
exit 2, csv written: noThe third case is the one dashboards most often get wrong. fifo_basic's records are missing — the test was dropped from tonight's list, or its jobs never ran. Walking only today's tests, the dashboard would show five tests, all statuses plausible, and nothing about the sixth. Walking the union, it is listed as removed, and the exit status is 1, because a passing test that disappears is a coverage hole until someone decides otherwise.
The refusals each protect a later step. A run recorded twice would count one seed's result twice and shift a pass rate. A record in an older layout — three space-separated words with no header — is refused by its missing header, rather than parsed by guessing. Yesterday's CSV from an older version of the tool, without the top_signature column, is refused by its header, because comparing against a file whose columns mean something else is worse than not comparing. A broken quote in yesterday's file is reported as what it most likely is.
8. A Test Suite For The Tool
#!/usr/bin/perl
# t/dashboard.t -- black-box tests over two real nights of simulation.
use strict;
use warnings;
use Test::More;
use File::Temp qw(tempdir);
use File::Copy qw(copy);
use FindBin qw($Bin);
my $TOOL = "$Bin/../dashboard.pl";
my $base = tempdir(CLEANUP => 1);
chdir "$Bin/.." or die;
for my $n (1, 2) {
system("bash run_nightly.sh $n $base/night$n > /dev/null") == 0 or die "night $n failed\n";
}
my ($N1, $N2) = ("$base/night1/results", "$base/night2/results");
sub slurp { open my $fh, "<", $_[0] or return undef; local $/; return scalar <$fh> }
sub spew { open my $fh, ">", $_[0] or die "$_[0]: $!"; print {$fh} $_[1]; close $fh }
sub run_tool { # (status, stdout, stderr)
my @args = @_;
my $pid = fork // die "fork: $!";
if ($pid == 0) {
open STDOUT, ">", "$base/stdout" or die;
open STDERR, ">", "$base/stderr" or die;
exec $^X, $TOOL, @args;
die "exec: $!";
}
waitpid $pid, 0;
return ($? >> 8, slurp("$base/stdout") // "", slurp("$base/stderr") // "");
}
sub copy_dir { my ($from, $to) = @_; mkdir $to or die; copy($_, $to) or die for glob("$from/*.tsv"); return $to }
# ---- night 1: aggregation
my ($rc, $out) = run_tool($N1, "--csv", "$base/n1.csv");
ok($rc == 0 && $out =~ /PASS 3, FLAKY 2, FAIL 1/, "night 1: exit 0; 3 pass, 2 flaky, 1 fail");
my (%pass, %fail); # counted here from the records, not by the tool
for my $f (glob("$N1/*.tsv")) {
my (undef, $row) = split /\n/, slurp($f);
my ($test, undef, $verdict) = split /\t/, $row;
$verdict eq "PASS" ? $pass{$test}++ : $fail{$test}++;
}
my %csv = map { /^([^,]+),\d+,(\d+),(\d+),/ ? ($1 => [ $2, $3 ]) : () } split /\n/, slurp("$base/n1.csv");
is_deeply(\%csv, { map { $_ => [ $pass{$_} // 0, $fail{$_} // 0 ] } keys %{ { %pass, %fail } } },
"every test's passed and failed counts equal the records'");
like(slurp("$base/n1.csv"), qr/^alu_burst,8,0,8,0\.0,FAIL,"PROTO: req asserted while ""busy"" was high \(8 of 8\)"$/m,
"a quote inside a field is doubled, and the field quoted");
# ---- night 2 against night 1
my $err;
($rc, $out, $err) = run_tool($N2, "--prev", "$base/n1.csv", "--csv", "$base/n2.csv",
"--html", "$base/r.html");
ok($rc == 1 && $out =~ /^newly failing: csr_access$/m && $out =~ /^newly passing: alu_burst$/m,
"night 2: csr_access newly failing, alu_burst newly passing, exit 1");
is($err, "", "reading night 1's CSV, with its doubled quotes, gives no warning");
like(slurp("$base/n2.csv"), qr/^fifo_full,8,6,2,75\.0,FLAKY,"FIFO: overflow: wrote 17 entries, depth 16 \(2 of 2\)"$/m,
"a comma inside a field is quoted");
my ($rc_rt, $out_rt, $err_rt) = run_tool($N2, "--prev", "$base/n2.csv", "--csv", "$base/again.csv");
ok($rc_rt == 0 && $out_rt =~ /^newly failing: none$/m && $err_rt eq "",
"the tool reads its own CSV back: no changes, nothing on STDERR");
is(slurp("$base/again.csv"), slurp("$base/n2.csv"), "and writes it again byte for byte");
# ---- the HTML page
my $html = slurp("$base/r.html");
like($html, qr/<p>6 tests, 48 runs: 3 pass, 2 flaky, 1 fail<\/p>/, "the page's counts match the CSV");
ok($html =~ /returned <X> & expected/ && $html !~ /<X>/, "message text is escaped, never raw");
ok($html !~ /<script|<link|\bsrc=|https?:/i, "the page is self-contained: no scripts, links or URLs");
run_tool($N2, "--prev", "$base/n1.csv", "--csv", "$base/n2b.csv", "--html", "$base/r2.html");
is(slurp("$base/r2.html"), $html, "a second run writes the identical page");
# ---- a test that goes from PASS to FLAKY is newly failing
my $flaky = copy_dir($N1, "$base/flaky");
(my $rec = slurp("$flaky/alu_random_s4.tsv")) =~ s/\tPASS\t$/\tFAIL\tSCBD: one bad seed/m or die;
spew("$flaky/alu_random_s4.tsv", $rec);
(undef, $out) = run_tool($flaky, "--prev", "$base/n1.csv", "--csv", "$base/f.csv");
like($out, qr/^newly failing: alu_random$/m, "PASS yesterday, FLAKY today: newly failing");
# ---- a pass rate is rounded down
my $three = "$base/three";
mkdir $three or die;
spew("$three/t_s$_->[0].tsv", "test\tseed\tverdict\tsignature\nt\t$_->[0]\t$_->[1]\t$_->[2]\n")
for [1, "PASS", ""], [2, "PASS", ""], [3, "FAIL", "X: y"];
run_tool($three, "--csv", "$base/three.csv");
like(slurp("$base/three.csv"), qr/^t,3,2,1,66\.6,FLAKY,/m, "2 of 3 is 66.6, not 66.7");
# ---- a test that stopped running is reported, not forgotten
my $shrunk = copy_dir($N2, "$base/shrunk");
unlink glob("$shrunk/fifo_basic_s*.tsv");
($rc, $out) = run_tool($shrunk, "--prev", "$base/n1.csv", "--csv", "$base/s.csv");
ok($rc == 1 && $out =~ /^removed:\s+fifo_basic$/m, "a test that was not run today is listed as removed");
# ---- input problems: exit 2 and nothing written
my $dup = copy_dir($N2, "$base/dup");
copy("$dup/alu_smoke_s5.tsv", "$dup/alu_smoke_s5_rerun.tsv") or die;
my $old = copy_dir($N2, "$base/old");
spew("$old/alu_smoke_s9.tsv", "alu_smoke 9 PASS\n");
(my $v1 = slurp("$base/n1.csv")) =~ s/,top_signature$//m;
spew("$base/v1.csv", $v1);
(my $cut = slurp("$base/n1.csv")) =~ s/high \(8 of 8\)"$/high (8 of 8)/m;
spew("$base/cut.csv", $cut);
mkdir "$base/empty" or die;
for my $c ([ "a run recorded twice", [ $dup ], qr/already read/ ],
[ "a record in another layout", [ $old ], qr/not a result record/ ],
[ "yesterday from an older version", [ $N2, "--prev", "$base/v1.csv" ], qr/header is not/ ],
[ "yesterday with a broken quote", [ $N2, "--prev", "$base/cut.csv" ], qr/unbalanced quote/ ],
[ "no records at all", [ "$base/empty" ], qr/no \*\.tsv/ ]) {
my ($label, $args, $why) = @$c;
unlink "$base/x.csv";
my ($crc, undef, $cerr) = run_tool(@$args, "--csv", "$base/x.csv");
ok($crc == 2 && $cerr =~ $why && !-e "$base/x.csv", "$label: exit 2, explained, nothing written");
}
($rc) = run_tool();
is($rc, 3, "no results directory: exit 3");
done_testing();Test 2 recounts every test's passes and failures from the records, independently, and compares them with the CSV. Tests 7 and 8 are the round trip: the tool reads its own CSV with no changes, nothing on STDERR, and an identical rewrite. Test 9 compares the page's counts with the CSV's — the test that would have caught the first defect, had it existed. Tests 13 and 14 build their own small inputs, because the two nights never contain a PASS to FLAKY change or a pass rate that rounding could push up (2 of 3 is 66.6, not 66.7 — the rule from Chapter 29.4).
#!/bin/bash
# tests.sh -- run the suite through prove, keeping prove's own exit status.
prove -v t/ > prove.log 2>&1
status=$?
grep -v 'wallclock' prove.log
echo "prove exit $status"t/dashboard.t ..
ok 1 - night 1: exit 0; 3 pass, 2 flaky, 1 fail
ok 2 - every test's passed and failed counts equal the records'
ok 3 - a quote inside a field is doubled, and the field quoted
ok 4 - night 2: csr_access newly failing, alu_burst newly passing, exit 1
ok 5 - reading night 1's CSV, with its doubled quotes, gives no warning
ok 6 - a comma inside a field is quoted
ok 7 - the tool reads its own CSV back: no changes, nothing on STDERR
ok 8 - and writes it again byte for byte
ok 9 - the page's counts match the CSV
ok 10 - message text is escaped, never raw
ok 11 - the page is self-contained: no scripts, links or URLs
ok 12 - a second run writes the identical page
ok 13 - PASS yesterday, FLAKY today: newly failing
ok 14 - 2 of 3 is 66.6, not 66.7
ok 15 - a test that was not run today is listed as removed
ok 16 - a run recorded twice: exit 2, explained, nothing written
ok 17 - a record in another layout: exit 2, explained, nothing written
ok 18 - yesterday from an older version: exit 2, explained, nothing written
ok 19 - yesterday with a broken quote: exit 2, explained, nothing written
ok 20 - no records at all: exit 2, explained, nothing written
ok 21 - no results directory: exit 3
1..21
ok
All tests successful.
Result: PASS
prove exit 09. Proving The Suite Can Fail
#!/bin/bash
# mutate.sh -- plant one defect at a time in a COPY of the tool; does the
# suite notice?
mutant() { # LABEL PERL-SUBSTITUTION (applied to the whole file)
local label=$1 edit=$2 d failed
d=$(mktemp -d)
mkdir "$d/t"
cp -r run_nightly.sh summarise.pl tb "$d/"
cp t/dashboard.t "$d/t/"
perl -0pe "$edit" dashboard.pl > "$d/dashboard.pl"
if cmp -s dashboard.pl "$d/dashboard.pl"; then
printf '%-44s NOT PLANTED (edit matched nothing)\n' "$label"
elif ! perl -c "$d/dashboard.pl" > /dev/null 2>&1; then
printf '%-44s INVALID (the mutant does not compile)\n' "$label"
else
failed=$(perl "$d/t/dashboard.t" 2>/dev/null | grep -c '^not ok')
printf '%-44s %s (%s failing test(s))\n' "$label" \
"$([ "$failed" -gt 0 ] && echo CAUGHT || echo SURVIVED)" "$failed"
fi
rm -rf "$d"
}
mutant "1 count statuses after the page is built" \
's/(my %st;\n)(\$st\{ \$_->\{status\} \}\+\+ for \@rows;\n)(.*?\n)(printf "%d test)/$1$3$2$4/s'
mutant "2 write CSV fields without quoting" 's/return \$f unless \$f =~ \/\[",\\n\]\/;/return \$f;/'
mutant "3 write HTML text without escaping" 's/sub html \{ my \(\$s\) = \@_; /sub html { my (\$s) = \@_; return \$s; /'
mutant "4 read captures after the s///" 's/my \(\$quoted, \$plain, \$sep\) = \(\$1, \$2, \$3\);\n\s+push \@f, defined \$quoted \? \(\$quoted =~ s\/""\/"\/gr\) : \$plain;\n\s+last if \$sep eq "";/push \@f, defined \$1 ? (\$1 =~ s\/""\/"\/gr) : \$2;\n last if \$3 eq "";/'
mutant "5 any failing run makes a test FAIL" 's/!\$passed \? "FAIL" : "FLAKY"/"FAIL"/'
mutant "6 forget tests that stopped running" 's/elsif \(!\$now\{\$t\}\) \{ push \@removed, \$t \}/elsif (!\$now{\$t}) { }/'
mutant "7 round the pass rate to nearest" 's/my \$t = int\(1000 \* \$n \/ \$d\);/my \$t = int(1000 * \$n \/ \$d + 0.5);/'
mutant "8 only FAIL counts as newly failing" 's/\$now\{\$t\}\{status\} ne "PASS"\) \{ push \@newly_failing/\$now{\$t}{status} eq "FAIL") { push \@newly_failing/'1 count statuses after the page is built CAUGHT (1 failing test(s))
2 write CSV fields without quoting CAUGHT (9 failing test(s))
3 write HTML text without escaping CAUGHT (1 failing test(s))
4 read captures after the s/// CAUGHT (1 failing test(s))
5 any failing run makes a test FAIL CAUGHT (4 failing test(s))
6 forget tests that stopped running CAUGHT (1 failing test(s))
7 round the pass rate to nearest CAUGHT (1 failing test(s))
8 only FAIL counts as newly failing CAUGHT (1 failing test(s))All eight are caught, and two of them are the defects from section 6, deliberately put back: mutant 1 moves the counting after the page is built, and test 9 fails; mutant 4 restores the capture read after the substitution, and test 5 sees the warning. Mutant 2, unquoted CSV, fails nine tests at once — the round trip, the quoting tests, and every comparison that reads yesterday's file. A format defect damages everything downstream of it, which is why the format gets the most tests.
10. What The Tool Does Not Do
- It does not keep history beyond yesterday. A trend over a week needs the daily CSVs kept, and a separate tool that reads several of them; the format makes that easy, which is part of the point of a stable format.
- It does not decide flakiness statistically. One failing seed in eight is
FLAKYwhether it is a real intermittent bug or a seed that hits a rare legal corner. Telling those apart needs reruns of the failing seed, which is the runner's job. - It does not re-read logs. Everything it knows comes from the records. A wrong verdict in a record is a wrong row on the page; the place to fix it is the summary step, Module 21's rules.
- Its page is deliberately plain. No sorting, filtering or charts, because those need scripts, and a page with scripts is no longer one self-contained file that opens the same everywhere.
- No browser was run for this chapter. The page is checked as text, by tests, and by an HTML parser; how a particular browser renders it was not observed.
11. Exercises
1. A week of history. Keep the last seven daily CSVs and write a script that prints, per test, its status on each day. Which column of the CSV makes that script trivial, and which design choice of this tool made the files comparable at all?
2. Status changed, not flipped. A test that goes from FLAKY to FAIL is in none of the four lists. Add a fifth, "got worse", and decide whether it should affect the exit status.
3. A new column, compatibly. Add a first_failing_seed column. Yesterday's CSV will not have it, and the header check will refuse it. Change the reader so it accepts both the old and the new header, and write the tests for both.
4. Sort by severity. Make the HTML table list FAIL first, then FLAKY, then PASS, by name within each. Keep the CSV in name order. Why might the two outputs reasonably differ in order?
5. Break the page. Find an input, other than a <, that would damage the page if the escaping were removed — consider an attribute — and add it to the testbench's messages.
12. What To Remember
- A per-test status across seeds needs three values: PASS, FLAKY and FAIL. "Any failure" makes seven of eight look like zero of eight.
- Totals hide changes. Both nights were 3 / 2 / 1; one test was fixed and another broke.
- Compare the union of yesterday and today. A test that stopped running is listed as removed, and needs attention.
- The CSV is tomorrow's input; test the round trip. Measured: the tool reads its own CSV with no changes and rewrites it byte for byte. A naive
split /,/read one row as eight fields. - Escape every text that reaches HTML,
&first. Measured: an HTML parser dropped<X>from the raw cell and made it an element. - One file, no scripts, no timestamp, so the page opens anywhere and can be compared with
diff. - Compute every count before any output uses it. A named sub sees a file-level
myas it is when the sub is called. - Copy captures before the next match, including the match inside an
s///.
Where this fits
Part of the Perl curriculum.
