@@ -0,0 +1,150 @@
export interface TestResult {
success : boolean ;
logs : string [ ] ;
}
// =============================================================================
// Reporting
export interface Reporter {
// While we could simply call a function with a tree representing all
// results, which would indeed greatly simplify implementation of reporters,
// simply registering a path and allowing the reporter to—either implicitly
// or explicitly—construct a tree themselves allows for results to be
// *incrementally reported*, instead of a great deal of silence until all
// tests finish.
update ( path : string [ ] , result : TestResult ) : void ;
// With just update(), the reporter never knows when all tests have
// completed. The simplest possible use for this is to notify the user, but
// its intent is actually more tailored to the StreamReporter scenario:
// while destructively updated report rendering (as with a tree of DOM nodes
// which are mutated) always displays the results in a structured manner
// matching that of the tests, “rerendering” or otherwise destructively
// updating the rendered output might be infeasible in some paradigms, such
// as command-line applications—all existing implementations of such
// rendering both mess up the scrollback position and necessarily crop out
// some of the data at the bottom (since the top of the tree is forced to be
// at the top of the screen, as one cannot unscroll portably). Explicitly
// signaling to the reporter that no more results will be received permits
// it to simply display live test progress linearly, while building up
// a tree and displaying it once it's known the tree is complete.
finalize ( ) : void ;
}
export interface Stream {
writeln ( s : string ) : void ;
}
const SEP = " ❯ " ;
// A simple reporter that outputs to some stream; suitable for CLIs.
export class StreamReporter implements Reporter {
stream : Stream ;
verbose : boolean ;
# failures : ( { path : string [ ] } & TestResult ) [ ] ;
counts : { successes : number , failures : number } ;
constructor ( stream : Stream , verbose : boolean = false ) {
this . stream = stream ;
this . verbose = verbose ;
this . # failures = [ ] ;
this . counts = { successes : 0 , failures : 0 } ;
}
update ( path : string [ ] , result : TestResult ) {
if ( path . length === 0 ) throw new RangeError ( "path is empty" ) ;
const pathStr = path . join ( SEP ) ;
if ( result . success ) {
this . counts . successes ++ ;
// NOTE: emojis are used instead of colored Unicode symbols as
// coloring isn't possible through all streams and detecting if
// colors should be used is very difficult¹. Furthermore, ensuring
// reasonable contrast is retained on every possible theme is
// difficult, with reverse video often being the only way (which
// also has questionable support across terminal emulators), and the
// Unicode characters might be too small to be immediately
// noticeable—consider ✓ and ⚠ and ✗. Emojis have an upper hand in
// that they're more common than obscure Unicode characters—which
// also means you're likely to have an emoji font but not a font for
// some weird symbols—and that they're double-width. Finally,
// Unicode characters are often very different across fonts; some
// fonts make ⚠ filled in, while others have just an outline for the
// triangle (which is much harder to comprehend), and the various
// crosses like all of x ⨯ × ✕ ✖ ✗ 🗙 🞨 🞩 🞪 🞫 🞬 🞭 🞮 look different
// across different fonts, which makes using them for some specific
// purpose difficult. Emojis don't have this problem because emoji
// vendors try to make them look similar to each other.
//
// ¹This necessitates checking if the stream is a TTY, checking if
// $TERM is `dumb` when connected to a TTY, checking
// https://no-color.org, https://bixense.com/clicolors, and
// https://force-color.org, checking if setting the
// ENABLE_VIRTUAL_TERMINAL_PROCESSING bit on the TTY works when on
// on Windows, and doing something similar for Cygwin.
if ( this . verbose ) this . stream . writeln ( ` ✅️ ${ pathStr } ` ) ;
} else {
this . counts . failures ++ ;
this . stream . writeln ( ` ⚠️ ${ pathStr } ` ) ;
this . # failures . push ( { path , . . . result } ) ;
}
}
finalize() {
// Transform [{ path: ["a", "b", "c"] }, { path: ["a", "b", "d"] }] into
// { "a ❯ b": ["c", "d"] }. NOTE: intermediate nodes are collapsed as
// excessive nesting is difficult to convey clearly in a text-only
// environment.
let pathMap = new Map < string , ( { name : string } & TestResult ) [ ] > ( ) ;
for ( const f of this . # failures ) {
if ( f . path . length === 0 ) throw new RangeError ( "path is empty" ) ;
const key = f . path . slice ( 0 , - 1 ) . join ( SEP ) ;
if ( ! pathMap . has ( key ) ) pathMap . set ( key , [ ] ) ;
pathMap . get ( key ) ! . push ( { name : f.path.at ( - 1 ) ! , . . . f } ) ;
}
this . stream . writeln ( "" ) ;
this . stream . writeln ( "FAILURES" ) ;
this . stream . writeln ( "========" ) ;
for ( const [ path , tests ] of pathMap ) {
if ( tests . length === 1 ) {
this . # writeOutput ( tests [ 0 ] , path ? ` ${ path } ${ SEP } ` : "" , false ) ;
} else {
this . stream . writeln ( path ) ;
for ( const t of tests ) this . # writeOutput ( t , " - " , true ) ;
}
}
this . stream . writeln ( "" ) ;
const { successes , failures } = this . counts ;
this . stream . writeln ( ` ${ successes } succeeded, ${ failures } failed ` ) ;
}
# writeOutput ( test : { name : string } & TestResult , prefix : string , nested : boolean ) {
let output = "" ;
if ( test . logs . length ) {
// Individual logs might span multiple lines, so join them together
// then split it again.
const logStr = test . logs . join ( "\n" ) ;
const lines = logStr . split ( "\n" ) ;
if ( lines . length <= 1 ) {
output = ` : ${ logStr } ` ;
} else {
const padding = nested ? " " : " " ;
output = ":\n" + lines . map ( ( line ) = > padding + line ) . join ( "\n" ) ;
}
}
this . stream . writeln ( ` ${ prefix } ${ test . name } ${ output } ` ) ;
}
}
const r = new StreamReporter ( { writeln : console.log } , true ) ;
r . update ( [ "alien" , "can walk" ] , { success : false , logs : [ "assertion failed" ] } ) ;
r . update ( [ "alien" , "can speak" ] , { success : false , logs : [ "Uncaught ReferenceError: larynx is not defined" ] } ) ;
r . update ( [ "alien" , "sleep" ] , { success : true , logs : [ ] } ) ;
r . update ( [ "Tetromino" , "generate" , "tessellates" ] , { success : false , logs : [ "assertion failed: 1 != 2" ] } ) ;
r . update ( [ "Tetromino" , "solve" , "works" ] , { success : true , logs : [ ] } ) ;
r . update ( [ "discombobulate" , "english" ] , { success : false , logs : [ "hippopotomonstrosesquipedaliophobia\npneumonoultramicroscopicsilicovolcanoconiosis" , "supercalifragilisticexpialidocious" ] } ) ;
r . update ( [ "discombobulate" , "geography" ] , { success : false , logs : [ "Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu" ] } ) ;
r . update ( [ "recombobulate" ] , { success : true , logs : [ ] } ) ;
r . finalize ( ) ;