You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1626 lines
81 KiB
1626 lines
81 KiB
#!tclsh |
|
#This script uses shellfilter::run calls under the hood |
|
lassign [split [info tclversion] .] tcl_major tcl_minor |
|
set test_base [file dirname [file normalize [info script]]] |
|
set test_base_parent [file dirname $test_base] |
|
if {[file tail $test_base_parent] eq "src"} { |
|
set project_root [file dirname $test_base_parent] |
|
} else { |
|
set msg "Error: test script is not under a src/ directory: $test_base" |
|
append msg \n "To run tests against the built modules, run src/make.tcl packages and then see the modules/test folder within this project" |
|
puts stderr $msg |
|
exit 2 |
|
} |
|
|
|
#------------------------------------ |
|
#For the toplevel script, use the bootsupport modules. |
|
set original_tmlist [tcl::tm::list] |
|
tcl::tm::remove {*}$original_tmlist |
|
tcl::tm::add [file normalize $project_root/src/bootsupport/modules] ;#ie <projectroot>/src/modules |
|
tcl::tm::add [file normalize $project_root/src/bootsupport/modules_tcl$tcl_major] |
|
tcl::tm::add {*}[lreverse $original_tmlist] |
|
package require platform |
|
|
|
set arch [platform::generic] |
|
|
|
set libdir [file normalize $project_root/src/bootsupport/lib] |
|
if {$libdir ni $::auto_path} { |
|
lappend ::auto_path $libdir |
|
} |
|
set libdir_arch [file normalize $project_root/src/bootsupport/lib/tcl$tcl_major/$arch] |
|
if {$libdir_arch ni $::auto_path} { |
|
lappend ::auto_path $libdir_arch |
|
} |
|
#------------------------------------ |
|
#Boot in two stages (G-093): discovery-time code needs only punk::args (argdoc/parse) and |
|
#punk::path (required by testsupport/discovery.tcl) - the heavy run-phase packages (punk, |
|
#Thread, shellrun, punk::tcltestrun) and the testinterp/child path assembly load after the |
|
#-discover-only early exit below, keeping 'runtests.tcl -discover-only 1' a fast targeting |
|
#inspection tool (boot ~1s instead of ~11s) for humans, agents and the runner-targeting |
|
#regression suite (runner/testsuites/discovery/pathdiscovery.test). |
|
#Minimum-version floors on the runner's own requires (punk::path has one in |
|
#testsupport/discovery.tcl already): their purpose is to REJECT fossilized kit-stamped copies. |
|
#Under a punk kit executable, the kit's boot registers its own (possibly ancient) package |
|
#versions - an unversioned require satisfiable by such a registration never fires package |
|
#unknown, so the freshly-added bootsupport tm path is never even scanned (observed: an old |
|
#punk86 kit's punk::args 0.1.0 won the require, then the versioned punk::path 0.4.0- pulled a |
|
#modern module whose load needs punk::args::define - invalid command name). A floored require |
|
#rejects the fossil and resolves the current bootsupport copy instead. The floors are |
|
#feature-era minimums for the runner's argdoc/parse usage, not latest-version pins. |
|
package require punk::args 0.12- |
|
if {[info commands ::lpop] eq ""} { |
|
#Native Tcl 8.6 interpreter: no builtin lpop, which punk::args::parse (and later runner code) |
|
#uses. punk::lib installs its validated forward-compat family (lpop, ledit, lremove, ...) as |
|
#global aliases whenever the builtins are absent - behavioural parity with the 8.7/9 builtins |
|
#is pinned by modules/punk/lib/testsuites/lib/compat.test. Guarded: on Tcl 8.7+/9 this never |
|
#runs, preserving the G-093 fast two-stage boot for -discover-only. |
|
#(0.5.1- = the parity-validated compat era; also a kit-fossil rejection floor per above.) |
|
package require punk::lib 0.5.1- |
|
} |
|
|
|
#test-file discovery + -include-paths/-exclude-paths filtering (G-093) - shared with the |
|
#runner-targeting regression suite |
|
source [file join $test_base testsupport discovery.tcl] |
|
|
|
proc runtests_json_string {text} { |
|
set text [string map [list \\ \\\\ \" \\\" \n \\n \r \\r \t \\t] $text] |
|
return "\"$text\"" |
|
} |
|
|
|
proc runtests_json_string_array {values} { |
|
set parts [list] |
|
foreach value $values { |
|
lappend parts [runtests_json_string $value] |
|
} |
|
return "\[[join $parts ,]\]" |
|
} |
|
|
|
proc runtests_json_testdict_array {testdicts} { |
|
set parts [list] |
|
foreach testdict $testdicts { |
|
set fields [list] |
|
foreach {key value} $testdict { |
|
if {[string is entier -strict $value] && $value eq [string trim $value]} { |
|
#string is entier accepts surrounding whitespace even with -strict: a |
|
#value like "0\n" must take the quoted-string path or the raw newline |
|
#lands inside the json line (broke a report when a failed test's |
|
#result_was carried a trailing newline - found 2026-07-20, G-093 work) |
|
lappend fields "[runtests_json_string $key]:$value" |
|
} else { |
|
lappend fields "[runtests_json_string $key]:[runtests_json_string $value]" |
|
} |
|
} |
|
lappend parts "{[join $fields ,]}" |
|
} |
|
return "\[[join $parts ,]\]" |
|
} |
|
|
|
proc runtests_count_dict_entries {value} { |
|
if {[llength $value] == 0} { |
|
return 0 |
|
} |
|
return [dict size $value] |
|
} |
|
|
|
proc runtests_dict_get_default {dictvalue key default} { |
|
if {[dict exists $dictvalue $key]} { |
|
return [dict get $dictvalue $key] |
|
} |
|
return $default |
|
} |
|
|
|
proc runtests_json_report {status tallydict file_summaries slowest_tests elapsed_seconds} { |
|
set files_failed [dict get $tallydict files_with_fails] |
|
set files_warned [dict get $tallydict files_with_warnings] |
|
set fields [list] |
|
lappend fields "\"runner\":\"src/tests/runtests.tcl\"" |
|
lappend fields "\"status\":[runtests_json_string $status]" |
|
lappend fields "\"elapsed_seconds\":$elapsed_seconds" |
|
lappend fields "\"total\":[dict get $tallydict total]" |
|
lappend fields "\"passed\":[dict get $tallydict passed]" |
|
lappend fields "\"skipped\":[dict get $tallydict skipped]" |
|
lappend fields "\"failed\":[dict get $tallydict failed]" |
|
lappend fields "\"warnings\":[llength $files_warned]" |
|
lappend fields "\"files\":{\"total\":[llength $file_summaries],\"failed\":[runtests_json_string_array $files_failed],\"warnings\":[runtests_json_string_array $files_warned]}" |
|
|
|
set file_parts [list] |
|
foreach file_summary $file_summaries { |
|
set file_fields [list] |
|
lappend file_fields "\"path\":[runtests_json_string [dict get $file_summary path]]" |
|
lappend file_fields "\"status\":[runtests_json_string [dict get $file_summary status]]" |
|
lappend file_fields "\"total\":[dict get $file_summary total]" |
|
lappend file_fields "\"passed\":[dict get $file_summary passed]" |
|
lappend file_fields "\"skipped\":[dict get $file_summary skipped]" |
|
lappend file_fields "\"failed\":[dict get $file_summary failed]" |
|
lappend file_fields "\"summaryline_detected\":[dict get $file_summary summaryline_detected]" |
|
lappend file_fields "\"observed_passes\":[dict get $file_summary observed_passes]" |
|
lappend file_fields "\"observed_skips\":[dict get $file_summary observed_skips]" |
|
lappend file_fields "\"observed_failures\":[dict get $file_summary observed_failures]" |
|
lappend file_fields "\"warnings\":[runtests_json_testdict_array [dict get $file_summary warnings]]" |
|
lappend file_fields "\"failures\":[runtests_json_testdict_array [dict get $file_summary failures]]" |
|
lappend file_fields "\"skips\":[runtests_json_testdict_array [dict get $file_summary skips]]" |
|
lappend file_parts "{[join $file_fields ,]}" |
|
} |
|
lappend fields "\"testfiles\":\[[join $file_parts ,]\]" |
|
lappend fields "\"slowest\":[runtests_json_testdict_array $slowest_tests]" |
|
return "{[join $fields ,]}" |
|
} |
|
|
|
proc runtests_status {tallydict} { |
|
if {[dict get $tallydict failed] > 0 || [llength [dict get $tallydict files_with_fails]] > 0} { |
|
return fail |
|
} |
|
if {[llength [dict get $tallydict files_with_warnings]] > 0} { |
|
return warn |
|
} |
|
return pass |
|
} |
|
|
|
proc runtests_emit_result_line {status tallydict file_summaries elapsed_seconds} { |
|
puts stdout "RUNTESTS_RESULT status=$status total=[dict get $tallydict total] passed=[dict get $tallydict passed] skipped=[dict get $tallydict skipped] failed=[dict get $tallydict failed] warnings=[llength [dict get $tallydict files_with_warnings]] files=[llength $file_summaries] files_failed=[llength [dict get $tallydict files_with_fails]] elapsed_seconds=$elapsed_seconds" |
|
} |
|
|
|
proc runtests_warning_summary {code message summaryline_detected} { |
|
return [dict create code $code message $message summaryline_detected $summaryline_detected] |
|
} |
|
|
|
proc runtests_print_file_warning {testfile_relative warning observed_passes observed_skips observed_failures} { |
|
puts stdout "### testfile warning for $testfile_relative" |
|
puts stdout "" |
|
puts stdout "- code: [dict get $warning code] " |
|
puts stdout " message: [dict get $warning message] " |
|
puts stdout " summaryline_detected: [dict get $warning summaryline_detected] " |
|
puts stdout " aggregate_counts: unavailable " |
|
puts stdout " observed_passes: $observed_passes " |
|
puts stdout " observed_skips: $observed_skips " |
|
puts stdout " observed_failures: $observed_failures " |
|
puts stdout "" |
|
} |
|
|
|
proc runtests_failure_summaries {resultdict} { |
|
set failures [list] |
|
dict for {testname testdict} [runtests_dict_get_default $resultdict testcase_fails [dict create]] { |
|
set failure [dict create name $testname status [dict get $testdict test_status]] |
|
if {[dict exists $testdict errorcode]} { |
|
dict set failure errorcode [dict get $testdict errorcode] |
|
} |
|
if {[dict exists $testdict errorinfo] && [dict get $testdict errorinfo] ne ""} { |
|
dict set failure errorinfo [dict get $testdict errorinfo] |
|
} |
|
if {[dict exists $testdict result_was] && [dict get $testdict result_was] ne ""} { |
|
dict set failure result_was [dict get $testdict result_was] |
|
} |
|
if {[dict exists $testdict result_expected] && [dict get $testdict result_expected] ne ""} { |
|
dict set failure result_expected [dict get $testdict result_expected] |
|
} |
|
lappend failures $failure |
|
} |
|
return $failures |
|
} |
|
|
|
proc runtests_skip_summaries {resultdict} { |
|
set skips [list] |
|
dict for {testname testdict} [runtests_dict_get_default $resultdict testcase_constraintskips [dict create]] { |
|
lappend skips [dict create name $testname reason [dict get $testdict reason]] |
|
} |
|
return $skips |
|
} |
|
|
|
proc runtests_pass_summaries {testfile_relative resultdict} { |
|
set passes [list] |
|
dict for {testname testdict} [runtests_dict_get_default $resultdict testcase_passes [dict create]] { |
|
set pass [dict create file $testfile_relative name $testname] |
|
if {[dict exists $testdict microseconds]} { |
|
dict set pass microseconds [dict get $testdict microseconds] |
|
} |
|
lappend passes $pass |
|
} |
|
return $passes |
|
} |
|
|
|
proc runtests_print_failure_details {testfile_relative resultdict} { |
|
dict for {testname testdict} [runtests_dict_get_default $resultdict testcase_fails [dict create]] { |
|
puts stdout "- testname: $testname " |
|
puts stdout " file : $testfile_relative " |
|
puts stdout " test_description: [dict get $testdict test_description] " |
|
puts stdout " test_body : " |
|
puts stdout " ```tcl" |
|
puts stdout " [dict get $testdict test_body]" |
|
puts stdout " ```" |
|
puts stdout " test_status : [dict get $testdict test_status] " |
|
if {[dict get $testdict test_status] eq "ERROR"} { |
|
puts stdout " returncode : [dict get $testdict returncode] " |
|
puts stdout " errorcode : [dict get $testdict errorcode] " |
|
if {[dict exists $testdict errorinfo] && [dict get $testdict errorinfo] ne ""} { |
|
puts stdout " errorInfo : " |
|
puts stdout " ```" |
|
puts stdout " [string trimright [dict get $testdict errorinfo]]" |
|
puts stdout " ```" |
|
} |
|
} elseif {[dict get $testdict test_status] eq "FAILED"} { |
|
if {[dict exists $testdict result_was] && [dict get $testdict result_was] ne ""} { |
|
puts stdout " result_was : " |
|
puts stdout " ```" |
|
puts stdout " [string trimright [dict get $testdict result_was]]" |
|
puts stdout " ```" |
|
} |
|
if {[dict exists $testdict result_expected] && [dict get $testdict result_expected] ne ""} { |
|
puts stdout " result_expected : " |
|
puts stdout " ```" |
|
puts stdout " [string trimright [dict get $testdict result_expected]]" |
|
puts stdout " ```" |
|
} |
|
} |
|
} |
|
} |
|
|
|
proc runtests_compare_microseconds_desc {left right} { |
|
set left_usec 0 |
|
set right_usec 0 |
|
if {[dict exists $left microseconds]} { |
|
set left_usec [dict get $left microseconds] |
|
} |
|
if {[dict exists $right microseconds]} { |
|
set right_usec [dict get $right microseconds] |
|
} |
|
if {$left_usec > $right_usec} { |
|
return -1 |
|
} |
|
if {$left_usec < $right_usec} { |
|
return 1 |
|
} |
|
return 0 |
|
} |
|
|
|
proc runtests_slowest_tests {passes count} { |
|
if {$count <= 0} { |
|
return [list] |
|
} |
|
set sorted [lsort -command runtests_compare_microseconds_desc $passes] |
|
return [lrange $sorted 0 [expr {$count - 1}]] |
|
} |
|
|
|
proc runtests_print_slowest_tests {slowest_tests show_timings} { |
|
puts stdout "### slowest passing tests" |
|
puts stdout "" |
|
foreach testdict $slowest_tests { |
|
set line "- `[dict get $testdict file]` [dict get $testdict name]" |
|
if {$show_timings && [dict exists $testdict microseconds]} { |
|
append line " microseconds=[dict get $testdict microseconds]" |
|
} |
|
puts stdout $line |
|
} |
|
puts stdout "" |
|
} |
|
|
|
proc runtests_text_tail {text maxchars} { |
|
#tail of captured child output for failure reports - whole trailing lines, bounded size |
|
set text [string trimright $text] |
|
if {[string length $text] <= $maxchars} { |
|
return $text |
|
} |
|
set text [string range $text end-[expr {$maxchars - 1}] end] |
|
#drop the leading partial line the cut probably produced (bounded scan so a single |
|
#enormous line can't defeat the truncation marker) |
|
set nl [string first \n $text] |
|
if {$nl >= 0 && $nl < 200} { |
|
set text [string range $text [expr {$nl + 1}] end] |
|
} |
|
return "...(truncated)...\n$text" |
|
} |
|
|
|
proc runtests_create_tempdir {nametag} { |
|
#'file tempdir' requires tcl 8.7+ - fall back to env temp + tagged pid subdir for 8.6 |
|
if {![catch {file tempdir} tdir]} { |
|
return $tdir |
|
} |
|
foreach evar {TMPDIR TEMP TMP} { |
|
if {[info exists ::env($evar)] && [file isdirectory $::env($evar)]} { |
|
set tdir [file join $::env($evar) ${nametag}_[pid]] |
|
file mkdir $tdir |
|
return $tdir |
|
} |
|
} |
|
return "" |
|
} |
|
|
|
proc runtests_write_child_payload {payloadfile test_tmlist test_auto_path ifneeded_script tcltestoptions} { |
|
#environment payload sourced by testsupport/child_test_runner.tcl in each child process. |
|
#Mirrors the singleproc testinterp setup order ('package prefer latest' before anything |
|
#that could package require). |
|
set fd [open $payloadfile w] |
|
puts $fd "#auto-generated by runtests.tcl - child test process environment (see testsupport/child_test_runner.tcl)" |
|
puts $fd {package prefer latest} |
|
puts $fd {tcl::tm::remove {*}[tcl::tm::list]} |
|
puts $fd [list tcl::tm::add {*}$test_tmlist] |
|
puts $fd [list set ::auto_path $test_auto_path] |
|
puts $fd $ifneeded_script |
|
puts $fd [list set ::runtests_child_tcltestoptions $tcltestoptions] |
|
close $fd |
|
} |
|
|
|
proc runtests_run_child_process {exe exemodeargs bootstrap payloadfile testfile outfile errfile childtmpdir} { |
|
#Run one test file in a child process, capturing stdout/stderr to files. Deliberately no |
|
#shellrun/shellfilter capture on the parent side: file capture avoids the parent-stdin/pipe |
|
#machinery, and -jobs parallel scheduling reuses the same mechanism (this proc is |
|
#replicated verbatim into tpool worker threads via info args/info body - keep it free of |
|
#package dependencies and namespace/global state, and keep every argument required). |
|
#exemodeargs: extra argv between exe and bootstrap ({} normally; {src} under a punk kit |
|
#so the child boots the project's src dev modules instead of kit-stamped packages). |
|
#childtmpdir overrides the child's tcltest -tmpdir when nonempty (mandatory per child |
|
#under -jobs so concurrent makeFile/makeDirectory helpers cannot collide); "" keeps the |
|
#shared -tmpdir carried in the payload options. |
|
#stdin is an immediate-EOF redirection so a misbehaving child can't hang on it. |
|
#Returns a dict shaped for punk::tcltestrun::parse_testrun: exitcode/stdout/stderr keys, |
|
#or error/errorCode/errorInfo keys if the child could not be launched at all. |
|
foreach f [list $outfile $errfile] { |
|
if {[file exists $f]} { |
|
file delete -force $f |
|
} |
|
} |
|
set exitcode 0 |
|
set wall_t0 [clock milliseconds] |
|
#Child spawn is background-exec plus a tcl::process status poll rather than a blocking |
|
#exec: concurrent blocking execs from -jobs tpool worker threads CONVOY on Windows - they |
|
#all return together when the longest-lived concurrent child exits (proven 2026-07-19 |
|
#with plain sleepers of 2-12s all walling at 12s; see the verification record in |
|
#goals/archive/G-091-runtests-parallel-jobs.md). |
|
#'exec ... &' returns at spawn and per-pid status polling is convoy-free. stdin comes |
|
#from the null device so a misbehaving child can't hang on it. |
|
if {$::tcl_platform(platform) eq "windows"} { |
|
set nulldev NUL |
|
} else { |
|
set nulldev /dev/null |
|
} |
|
if {[llength [info commands ::tcl::process]]} { |
|
#Disable the automatic detached-process reaping: Tcl_ReapDetachedProcs runs on every |
|
#exec call and purges finished background children PROCESS-WIDE, so a sibling |
|
#worker's next spawn could purge this child's status entry before we read it (the |
|
#cross-worker race behind nondeterministic 'key <pid> not known in dictionary' file |
|
#errors in early -jobs runs, 2026-07-19). With autopurge off each pid is purged |
|
#explicitly after its status is read. |
|
tcl::process autopurge false |
|
if {[catch {exec -- $exe {*}$exemodeargs $bootstrap $payloadfile $testfile $childtmpdir < $nulldev > $outfile 2> $errfile &} spawnresult errdict]} { |
|
#launch failure - no usable test run |
|
return [dict create error $spawnresult errorCode [dict get $errdict -errorcode] errorInfo [dict get $errdict -errorinfo]] |
|
} |
|
set pid [lindex $spawnresult 0] |
|
while {1} { |
|
set st [tcl::process status $pid] |
|
if {![dict exists $st $pid]} { |
|
#should be impossible with autopurge off - surface loudly rather than spin |
|
return [dict create error "child pid $pid disappeared from the tcl::process table before its status was read" errorCode {RUNTESTS PIDGONE} errorInfo "child pid $pid missing from tcl::process status result: $st"] |
|
} |
|
set pstatus [dict get $st $pid] |
|
if {$pstatus ne ""} { |
|
break |
|
} |
|
after 50 |
|
} |
|
catch {tcl::process purge $pid} |
|
#status shapes: "0" = clean exit; otherwise {1 <message> <errorcode>} where errorcode |
|
#is usually {CHILDSTATUS <pid> <exitcode>} |
|
if {$pstatus ne "0"} { |
|
set ecode [lindex $pstatus 2] |
|
if {[lindex $ecode 0] eq "CHILDSTATUS"} { |
|
set exitcode [lindex $ecode 2] |
|
} else { |
|
return [dict create error [lindex $pstatus 1] errorCode $ecode errorInfo [lindex $pstatus 1]] |
|
} |
|
} |
|
} else { |
|
#blocking fallback for runtimes without tcl::process (tcl 8.6): correct sequentially; |
|
#concurrent use from -jobs workers re-convoys on Windows (documented in |
|
#src/tests/AGENTS.md) |
|
if {[catch {exec -- $exe {*}$exemodeargs $bootstrap $payloadfile $testfile $childtmpdir < $nulldev > $outfile 2> $errfile} emsg errdict]} { |
|
set ecode [dict get $errdict -errorcode] |
|
if {[lindex $ecode 0] eq "CHILDSTATUS"} { |
|
set exitcode [lindex $ecode 2] |
|
} else { |
|
#launch failure (or CHILDKILLED etc) - no usable test run |
|
return [dict create error $emsg errorCode $ecode errorInfo [dict get $errdict -errorinfo]] |
|
} |
|
} |
|
} |
|
set out "" |
|
set err "" |
|
if {[file exists $outfile]} { |
|
set fd [open $outfile r] |
|
set out [read $fd] |
|
close $fd |
|
} |
|
if {[file exists $errfile]} { |
|
set fd [open $errfile r] |
|
set err [read $fd] |
|
close $fd |
|
} |
|
#wall_ms = whole child-process wall time (boot + fixtures + tests) - per-test usec |
|
#timings miss file-level fixture cost, and this is what -jobs critical-path analysis |
|
#needs. parse_testrun routes the unknown key into its (unused) err text field - harmless. |
|
return [dict create exitcode $exitcode stdout $out stderr $err wall_ms [expr {[clock milliseconds] - $wall_t0}]] |
|
} |
|
|
|
proc runtests_colour_enabled {opt} { |
|
#opt: auto|on|off. auto = colour only when stdout is a real console - piped/redirected |
|
#output (agents, captures) must stay plain - and NO_COLOR is honoured. |
|
#Detection uses a twapi console handle probe (the reliable check on windows - see |
|
#punk::console usage); deliberately NOT punk::console colour state or a+/a, whose |
|
#process-global state proved context-sensitive under mixed tty-ness (2026-07-19 |
|
#console-run findings) - the runner's own indicators must be deterministic. |
|
switch -- $opt { |
|
on {return 1} |
|
off {return 0} |
|
} |
|
if {[info exists ::env(NO_COLOR)] && $::env(NO_COLOR) ne ""} { |
|
return 0 |
|
} |
|
if {$::tcl_platform(platform) eq "windows"} { |
|
#GetConsoleMode on the actual STD_OUTPUT handle is the isatty-equivalent: it errors |
|
#("handle is invalid") when stdout is a pipe/file. twapi::get_console_handle stdout |
|
#is NOT usable here - it succeeds whenever the process has an attached console, |
|
#which is true even for piped children of console shells (verified 2026-07-19). |
|
if {![catch {package require twapi}] |
|
&& ![catch {twapi::GetConsoleMode [twapi::GetStdHandle -11]}]} { |
|
return 1 |
|
} |
|
return 0 |
|
} |
|
#non-windows: no reliable in-core tty test here - default off in auto (use -colour on) |
|
return 0 |
|
} |
|
|
|
proc runtests_clr {style text} { |
|
#wrap text in a status colour when colour is enabled (raw literal SGR - see |
|
#runtests_colour_enabled for why not a+/a) |
|
if {!$::runtests_do_colour} { |
|
return $text |
|
} |
|
switch -- $style { |
|
pass {return "\x1b\[32m$text\x1b\[0m"} |
|
fail - error {return "\x1b\[31;1m$text\x1b\[0m"} |
|
warn - warning {return "\x1b\[33;1m$text\x1b\[0m"} |
|
default {return $text} |
|
} |
|
} |
|
|
|
proc runtests_udptee_event {args} { |
|
#emit a runner lifecycle event to the 'runtests' log tag when the UDP tee is active. |
|
#Each event message is a well-formed tcl list: RUNTESTS-EVENT <eventname> ?key value ...? |
|
if {$::runtests_udptee_target eq ""} { |
|
return |
|
} |
|
catch {::shellfilter::log::write runtests [list RUNTESTS-EVENT {*}$args]} |
|
} |
|
|
|
proc runtests_collect_child_result {testfile_relative jobresult} { |
|
#shared by the -jobs parallel collection loop and its serial tail: cache the child's |
|
#result dict for the main processing loop, emit the file-collected watch event, and |
|
#relay the captured output to the watch target (under -jobs, relay happens here in |
|
#completion order rather than in the discovery-order processing loop). |
|
set ::runtests_job_results($testfile_relative) $jobresult |
|
if {[dict exists $jobresult exitcode]} { |
|
set ecode [dict get $jobresult exitcode] |
|
} else { |
|
set ecode "launch-error" |
|
} |
|
runtests_udptee_event file-collected path $testfile_relative exitcode $ecode |
|
if {$::runtests_udptee_target ne ""} { |
|
if {[dict exists $jobresult stdout] && [dict get $jobresult stdout] ne ""} { |
|
catch {::shellfilter::log::write teststdout [dict get $jobresult stdout]} |
|
} |
|
if {[dict exists $jobresult stderr] && [dict get $jobresult stderr] ne ""} { |
|
catch {::shellfilter::log::write teststderr [dict get $jobresult stderr]} |
|
} |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
#(the testinterp/child module path assembly - test_tmlist/test_auto_path/ifneeded_script - |
|
# runs below the -discover-only early exit as stage 2 of the G-093 two-stage boot) |
|
|
|
|
|
punk::args::define { |
|
@id -id (script)::runtests |
|
@cmd -name runtests\ |
|
-summary\ |
|
"Run the source test suites for this project."\ |
|
-help\ |
|
"Run the test suites for this project. |
|
By default, all test files under src/tests/ will be included |
|
- but you can specify particular test files or paths to include using the arguments below. |
|
|
|
These tests run against the unbuilt pure-tcl modules under development rather than any installed versions |
|
- so you can test the latest code without installing it. |
|
|
|
To run tests against the installed versions, see the modules/test folder within this project or |
|
package require the approprate test::<modulename> package and call its RUN function. |
|
" |
|
@opts |
|
-tcltestoptions -type dict -default {-singleproc 1 -verbose {body pass skip start error line usec}} -help\ |
|
"Pairs of flags/values that will be passed to tcltest::configure before running the tests. |
|
The supplied flags will be merged with the default flags. |
|
|
|
-singleproc is interpreted by this runner rather than by tcltest's runAllTests: |
|
1 (default) sources each test file in a fresh child interp of this process. |
|
0 runs each test file in a child process of the same executable via |
|
testsupport/child_test_runner.tcl, with the same module paths and tcltest |
|
options forwarded through a generated environment payload. A nonzero child |
|
exit is reported as a file-level failure with the stderr tail surfaced. |
|
Prefer a native tclsh for -singleproc 0: children of a kit executable boot |
|
with kit-stamped punk packages preloaded, which can shadow the src dev |
|
modules under test." |
|
-show-raw-output -type boolean -default 0 -help\ |
|
"If true, the raw stdout and stderr produced by tcltest from the test files will be shown in addition to the parsed results. |
|
This will be in markdown code blocks under headings: |
|
stdout from <testfile> |
|
and |
|
stderr from <testfile> |
|
" |
|
-include-paths -type list -default {**} -multiple 1 -help\ |
|
"List of directory glob patterns selecting which directories under the test |
|
base contribute test files. Patterns are relative to the test base, use |
|
forward slashes, and match the DIRECTORY containing a test file - the |
|
file-name axis belongs to the trailing file-tail globs (default *.test). |
|
Segment forms (punk::path pathglob syntax - deliberately separable): |
|
modules/punk files directly in modules/punk only |
|
modules/punk/* files in folders exactly one below modules/punk |
|
modules/punk/** files in subtrees strictly below modules/punk |
|
(NOT files directly in modules/punk) |
|
modules/punk/*** files in modules/punk and everything below it |
|
Use the X/*** form to run a whole subtree. Single-file targeting combines |
|
a directory pattern with a file tail: |
|
-include-paths modules/punk/args/testsuites/args dynamic.test |
|
The value is a space-separated list of patterns, and the flag may also be |
|
repeated, with all occurrences accumulating: |
|
-include-paths modules/punk/args/*** -include-paths modules/punk/ns/*** |
|
is equivalent to |
|
-include-paths {modules/punk/args/*** modules/punk/ns/***} |
|
A received pattern that contributes no files earns a stderr advisory (the |
|
common miss: X/** where X/*** was intended). Directories matching |
|
-exclude-paths are removed from the included set." |
|
-exclude-paths -type list -default {} -multiple 1 -help\ |
|
"List of directory glob patterns excluding directories from discovery - same |
|
syntax, relativity and accumulation semantics as -include-paths. |
|
Patterns whose final segment is ** or *** prune the matched subtree; other |
|
matches exclude only the files directly in the matching directory. |
|
Exclusion wins over inclusion for the same file. |
|
Gives broad runs a middle ground between full and targeted - e.g |
|
-exclude-paths shell/*** when no built kit is present, or excluding a |
|
heavy suite family unrelated to the change being tested." |
|
-discover-only -type boolean -default 0 -help\ |
|
"If true, print the discovered test file list and exit without running any |
|
tests. Always emits one machine-readable line 'RUNTESTS_FILES <path>...' |
|
(a well-formed tcl list with RUNTESTS_FILES as its first element), plus one |
|
path per line first in non-json report modes. No test report is produced. |
|
For inspecting -include-paths/-exclude-paths targeting." |
|
-report -type string -default markdown -choices {markdown compact json markdown+json} -help\ |
|
"Output report style. markdown preserves the detailed human-readable report. |
|
compact prints a short Markdown report with failures, warnings, and optional slow tests. |
|
json prints a machine-readable JSON summary. |
|
markdown+json prints the detailed Markdown report plus a fenced JSON summary." |
|
-show-passes -type boolean -default 1 -help\ |
|
"If true, include per-test pass lists in markdown reports. Use false for shorter agent-oriented output." |
|
-show-timings -type boolean -default 1 -help\ |
|
"If true, include microsecond timings where available." |
|
-slowest -type int -default 0 -help\ |
|
"Number of slowest passing tests to include in Markdown and JSON summaries. Use 0 to suppress." |
|
-strict-exit -type boolean -default 0 -help\ |
|
"If true, exit 1 when tests fail and exit 2 when files produce parser warnings." |
|
-jobs -type int -default 1 -help\ |
|
"Number of concurrent child processes for multi-process mode. |
|
1 (default) runs test files sequentially. |
|
Values above 1 imply -singleproc 0 (multi-process mode): test files run |
|
through a worker pool of this many concurrent child processes, except files |
|
matching -serial-paths, which run sequentially after the parallel batch. |
|
Reports are unchanged and emitted in discovery order; per-test microsecond |
|
timings get noisier under CPU oversubscription. |
|
Conflicts with an explicit -tcltestoptions {-singleproc 1}." |
|
-colour -type string -default auto -choices {auto on off} -help\ |
|
"ANSI colour for the human-facing pass/fail/warning indicators in markdown and |
|
compact reports. auto (default) colours only when stdout is a real console |
|
(never piped/redirected output) and honours the NO_COLOR environment variable. |
|
Machine-facing output (the RUNTESTS_RESULT line, json reports) is never |
|
coloured." |
|
-serial-paths -type list -default {modules/punk/console/*** modules/opunk/console/***} -help\ |
|
"Directory glob patterns (relative to the test base, forward slashes - the |
|
same directory-oriented syntax as -include-paths) selecting test files that |
|
must NOT run concurrently when -jobs exceeds 1: a file is serial when the |
|
directory containing it matches any pattern. Matching files run |
|
sequentially after the parallel batch completes. The default covers the |
|
console-state suites (they emit to and query the shared terminal); pass an |
|
empty list to parallelise everything. Suites driven through piped-stdio |
|
child processes (shell/***, modules/punk/repl/***) are parallel by |
|
default - add them here if they prove flaky under a real console." |
|
@values -min 0 -max -1 |
|
glob -type string -multiple 1 -optional 1 -help\ |
|
"Names or glob patterns of test files to run. |
|
This matches against the file tail - so should not include path segments. |
|
The default if not supplied is a single *.test entry." |
|
} |
|
|
|
if {"-h" in $::argv || "--help" in $::argv || "-help" in $::argv} { |
|
puts stderr [punk::args::usage (script)::runtests] |
|
exit 0 |
|
} |
|
|
|
set ts_start [clock seconds] |
|
puts "argv : $::argv" |
|
puts "argv0: $::argv0" |
|
|
|
set argd [punk::args::parse $::argv withid (script)::runtests] |
|
lassign [dict values $argd] leaders opts values received |
|
set opt_tcltestoptions [dict get $opts -tcltestoptions] |
|
set default_tcltestoptions {-singleproc 1 -verbose {body pass skip start error line usec}} |
|
|
|
set opt_show_raw_output [dict get $opts -show-raw-output] |
|
set opt_report [dict get $opts -report] |
|
set opt_show_passes [dict get $opts -show-passes] |
|
set opt_show_timings [dict get $opts -show-timings] |
|
set opt_slowest [dict get $opts -slowest] |
|
set opt_strict_exit [dict get $opts -strict-exit] |
|
set opt_jobs [dict get $opts -jobs] |
|
set opt_serial_paths [dict get $opts -serial-paths] |
|
set runtests_do_colour [runtests_colour_enabled [dict get $opts -colour]] |
|
if {![string is integer -strict $opt_jobs] || $opt_jobs < 1} { |
|
puts stderr "runtests: invalid -jobs value '$opt_jobs' (expected an integer >= 1)" |
|
exit 2 |
|
} |
|
set report_detailed_markdown [expr {$opt_report in {markdown markdown+json}}] |
|
set report_compact [expr {$opt_report eq "compact"}] |
|
set report_json_only [expr {$opt_report eq "json"}] |
|
set report_emit_json [expr {$opt_report in {json markdown+json}}] |
|
|
|
|
|
#-include-paths/-exclude-paths are -multiple 1: each occurrence contributes a list of |
|
#patterns - flatten (unreceived defaults and single-occurrence usage flatten to themselves) |
|
set include_paths [concat {*}[dict get $opts -include-paths]] |
|
set exclude_paths [concat {*}[dict get $opts -exclude-paths]] |
|
set opt_discover_only [dict get $opts -discover-only] |
|
|
|
|
|
set tcltestoptions [dict merge $default_tcltestoptions $opt_tcltestoptions] |
|
|
|
#-jobs > 1 is a multi-process-mode feature: it implies -singleproc 0. An explicit |
|
#user-supplied -singleproc 1 alongside -jobs > 1 is a conflict. (Check against $received - |
|
#the unreceived -tcltestoptions default also carries -singleproc 1 and must not trigger this.) |
|
if {$opt_jobs > 1} { |
|
if {[dict exists $received -tcltestoptions] |
|
&& [dict exists $opt_tcltestoptions -singleproc] |
|
&& [dict get $opt_tcltestoptions -singleproc]} { |
|
puts stderr "runtests: -jobs $opt_jobs conflicts with -tcltestoptions {-singleproc 1} (parallel jobs require multi-process mode)" |
|
exit 2 |
|
} |
|
dict set tcltestoptions -singleproc 0 |
|
} |
|
|
|
if {[dict exists $tcltestoptions -file]} { |
|
set file_globs [dict get $tcltestoptions -file] |
|
} else { |
|
#set file_globs [list *.test] |
|
set file_globs [list] |
|
} |
|
|
|
if {[dict exists $received glob]} { |
|
#glob is -multiple 1: $values glob is a list of the supplied names - expand, |
|
#or multiple file tails collapse into one space-containing glob matching nothing |
|
lappend file_globs {*}[dict get $values glob] |
|
} |
|
#fall back to default if still empty |
|
if {[llength $file_globs] == 0} { |
|
set file_globs [list *.test] |
|
} |
|
|
|
|
|
dict set tcltestoptions -file $file_globs |
|
|
|
#Default tcltest's -tmpdir (makeFile/makeDirectory location) to a fresh directory under the system temp area. |
|
#Without this, tcltest helpers land in the working directory - aborted runs of e.g core exec.test littered |
|
#src/tests with untracked helper files (cat, echo, gorp.file ...). A user-supplied -tmpdir via -tcltestoptions wins. |
|
#Cleaned up (file delete -force) before exit; an aborted run leaves it in the OS temp area rather than the source tree. |
|
set runtests_tmpdir "" |
|
if {![dict exists $opt_tcltestoptions -tmpdir]} { |
|
set runtests_tmpdir [runtests_create_tempdir runtests_tmp] |
|
if {$runtests_tmpdir ne ""} { |
|
dict set tcltestoptions -tmpdir $runtests_tmpdir |
|
} |
|
} |
|
|
|
#puts "tcltestoptions: $tcltestoptions" |
|
#puts "file_globs: $file_globs" |
|
set thisexecutable [info nameofexecutable] |
|
|
|
if {!$report_json_only} { |
|
puts "" |
|
puts "# runtests.tcl started at [clock format $ts_start -format "%Y-%m-%d %H:%M:%S"]" |
|
puts "" |
|
puts "test_base: $test_base " |
|
puts "executable: $thisexecutable " |
|
puts "" |
|
} |
|
|
|
set exclude_files [list AGENTS.md *.tcl] |
|
|
|
|
|
set tallydict [dict create] |
|
dict set tallydict total 0 |
|
dict set tallydict passed 0 |
|
dict set tallydict skipped 0 |
|
dict set tallydict failed 0 |
|
dict set tallydict files_with_fails [list] |
|
dict set tallydict files_without_fails [list] |
|
dict set tallydict files_with_warnings [list] |
|
set file_summaries [list] |
|
set all_passes [list] |
|
|
|
#tailglobs is a -multiple 1 positional: each glob must be a separate argument, or multiple globs collapse into one |
|
#Discovery hands -include-paths/-exclude-paths to punk::path::treefilenames unchanged |
|
#(directory-oriented pathglob semantics, identical everywhere - see |
|
#testsupport/discovery.tcl for the contract and the G-093 history; punk::path 0.4.0's |
|
#*** zero-or-more-segments form makes X/*** the whole-subtree spelling). |
|
set testfiles [runtests_discovery::discover_testfiles $test_base $include_paths $exclude_paths $file_globs $exclude_files] |
|
|
|
#per-pattern zero-match advisory (G-093): a received include pattern that contributed no |
|
#files is usually the X/** spelling where X/*** was intended (X/** deliberately excludes |
|
#files directly in X). stderr only - results are unaffected. |
|
if {[dict exists $received -include-paths]} { |
|
foreach ip_pat $include_paths { |
|
set ip_hit 0 |
|
foreach tf $testfiles { |
|
if {[runtests_discovery::dir_matches_any [list $ip_pat] $tf]} { |
|
set ip_hit 1 |
|
break |
|
} |
|
} |
|
if {!$ip_hit} { |
|
puts stderr "runtests: -include-paths pattern '$ip_pat' matched no test files (note: X/** excludes files directly in X - use X/*** for a subtree including its base)" |
|
} |
|
} |
|
} |
|
|
|
if {$opt_discover_only} { |
|
#print the discovered relative paths and exit before any run machinery starts |
|
#(before the multi-process workdir and the UDP tee log workers are created; the |
|
#already-created default tcltest tmpdir is removed as at normal run end). |
|
#The RUNTESTS_FILES line is a well-formed tcl list (marker first element) so paths |
|
#survive machine parsing regardless of content; it is emitted last, mirroring the |
|
#RUNTESTS_RESULT end-of-output convention. |
|
if {!$report_json_only} { |
|
foreach tf $testfiles { |
|
puts $tf |
|
} |
|
} |
|
puts [list RUNTESTS_FILES {*}$testfiles] |
|
if {$runtests_tmpdir ne "" && [file isdirectory $runtests_tmpdir]} { |
|
catch {file delete -force $runtests_tmpdir} |
|
} |
|
exit 0 |
|
} |
|
|
|
#run-phase boot (stage 2 of the G-093 two-stage boot): the heavy run-phase packages and the |
|
#testinterp/child path assembly, deferred past the -discover-only early exit above. |
|
package require punk |
|
package require Thread |
|
package require shellrun |
|
package require punk::tcltestrun |
|
|
|
#------------------------------------ |
|
#for the tests running in child processes, |
|
#use the unbuilt modules/libraries under development in preference to the installed versions. |
|
#ORDERING NOTE (rules verified experimentally on Tcl 9.0.3, 2026-07-06 - identical results |
|
#under Tcl's standard 'package unknown' scanner and punk::libunknown): |
|
# - tcl::tm::add PREPENDS each of its arguments in turn, so a single call |
|
# 'tcl::tm::add {*}$test_tmlist' produces tcl::tm::list in REVERSED test_tmlist order |
|
# (the last element of test_tmlist ends up at the head of tcl::tm::list). |
|
# - For the SAME module name-version found under multiple tm paths, the copy under the |
|
# path nearest the HEAD of tcl::tm::list wins (first-scanned registration is kept). |
|
# - Net effect: the LAST element of test_tmlist wins same-version ties. |
|
#Different-version selection is unaffected by order: dev modules (999999.0a1.0) win via |
|
#'package prefer latest' wherever they sit. But bootsupport and vendormodules snapshots can |
|
#carry the SAME version of a module with different code (a version-bump-discipline failure in |
|
#the source project, but it happens - observed 2026-07-06 with a stale vendormodules |
|
#textblock 0.1.3 shadowing the fresh bootsupport copy and breaking shellfilter::run). |
|
#bootsupport is deliberately the LAST test_tmlist element so the punkshell-managed bootsupport |
|
#snapshot wins any same-version tie. |
|
set test_tmlist [list] |
|
set ifneeded_script "" |
|
lappend test_tmlist [file normalize $test_base/../modules] ;#ie <projectroot>/src/modules |
|
append ifneeded_script [punk::tcltestrun::tm_path_additional_ifneeded [file normalize $test_base/../modules] $project_root] |
|
lappend test_tmlist [file normalize $test_base/../modules_tcl$tcl_major] |
|
append ifneeded_script [punk::tcltestrun::tm_path_additional_ifneeded [file normalize $test_base/../modules_tcl$tcl_major] $project_root] |
|
#vendored module dependencies (e.g voo) must be resolvable by modules/tests using them |
|
lappend test_tmlist [file normalize $test_base/../vendormodules] |
|
if {[file isdirectory $test_base/../vendormodules_tcl$tcl_major]} { |
|
lappend test_tmlist [file normalize $test_base/../vendormodules_tcl$tcl_major] |
|
} |
|
lappend test_tmlist [file normalize $test_base/../bootsupport/modules] ;#after vendormodules - wins same-version ties |
|
set test_auto_path [list] |
|
lappend test_auto_path [file normalize $test_base/../lib] |
|
lappend test_auto_path [file normalize $test_base/../lib/tcl$tcl_major] |
|
lappend test_auto_path [file normalize $test_base/../../lib_tcl$tcl_major/$arch] |
|
lappend test_auto_path [info library] |
|
#the parent of [info library] is where an installation's binary packages usually live |
|
#(e.g Thread, needed by shellrun in the testinterp) - the default tclsh auto_path has it, |
|
#but we replace auto_path wholesale in the testinterp, and not every project carries a |
|
#root lib_tcl<N>/<arch> runtime payload that would otherwise supply Thread. |
|
lappend test_auto_path [file dirname [info library]] |
|
#kit-internal library trees: when running under a kit/zipkit executable the wholesale |
|
#auto_path replacement above would otherwise hide packages bundled in the kit's lib trees |
|
#(e.g tcllib's tcl::chan::fifo2, needed by the shellrun harness) - tclPkgUnknown only scans |
|
#an auto_path entry and its immediate children, so [file dirname [info library]] (e.g |
|
#//zipfs:/app) cannot reach //zipfs:/app/lib_tcl<N>/<pkg>. Mirrors the internal-path |
|
#classification in src/vfs/_config/punk_main.tcl: zipfs app mount, tclkit ::tcl::kitpath |
|
#(exe path dir-shadow), cookfs //cookit:/ volume (mount name is compile-configurable; |
|
#only the known default is supported - as per punk_main.tcl). |
|
set kit_lib_bases [list] |
|
if {[info commands tcl::zipfs::root] ne "" && [llength [tcl::zipfs::mount]]} { |
|
set zipbase [file join [tcl::zipfs::root] app] |
|
if {"$zipbase" in [tcl::zipfs::mount]} { |
|
lappend kit_lib_bases $zipbase |
|
} |
|
} |
|
if {[info exists ::tcl::kitpath] && $::tcl::kitpath ne ""} { |
|
lappend kit_lib_bases [file normalize $::tcl::kitpath] |
|
} |
|
if {"//cookit:/" in [file volumes]} { |
|
lappend kit_lib_bases //cookit:/ |
|
} |
|
foreach kit_base $kit_lib_bases { |
|
foreach p [list lib lib_tcl$tcl_major] { |
|
set kit_libdir [file join $kit_base $p] |
|
if {[file isdirectory $kit_libdir] && $kit_libdir ni $test_auto_path} { |
|
lappend test_auto_path $kit_libdir |
|
} |
|
} |
|
} |
|
#------------------------------------ |
|
|
|
if {[dict exists $tcltestoptions -singleproc] && [dict get $tcltestoptions -singleproc]} { |
|
if {!$report_json_only} { |
|
puts "Running tests in single process mode" |
|
} |
|
set singleproc 1 |
|
} else { |
|
if {!$report_json_only} { |
|
if {$opt_jobs > 1} { |
|
puts "Running tests in multiple processes (-jobs $opt_jobs)" |
|
} else { |
|
puts "Running tests in multiple processes" |
|
} |
|
} |
|
set singleproc 0 |
|
} |
|
set jobs_mode [expr {!$singleproc && $opt_jobs > 1}] |
|
|
|
if {!$report_json_only} { |
|
puts "test auto_path: $test_auto_path" |
|
puts "test tmlist: $test_tmlist" |
|
puts "tcltestoptions: $tcltestoptions" |
|
} |
|
|
|
#multi-process mode setup: each test file runs as |
|
# <thisexecutable> testsupport/child_test_runner.tcl <payload> <testfile> |
|
#with stdout/stderr captured to files under a run-scoped work directory. The generated payload |
|
#forwards the parent-computed environment (module paths, auto_path, modpod ifneeded definitions, |
|
#tcltest options) so children select the same unbuilt dev modules as the singleproc testinterp. |
|
set runtests_workdir "" |
|
if {!$singleproc} { |
|
set child_exe_modeargs [list] |
|
if {[llength $kit_lib_bases]} { |
|
if {[namespace exists ::punkboot]} { |
|
#punk kit (punk_main.tcl boot): plain script dispatch boots kit-stamped punk |
|
#packages into the child (e.g punk::console 0.7.2) BEFORE the payload's src |
|
#paths apply, so suite requires pinned 999999.0a1.0- hit version conflicts. |
|
#The kit's 'src' package_mode boots children on the project's src dev modules |
|
#instead - matching what the payload selects (G-098 kit-hosted runs 2026-07-20). |
|
set child_exe_modeargs [list src] |
|
puts stderr "NOTE: punk kit executable - child test processes launch with the kit's 'src' package_mode so src dev modules boot (not kit-stamped packages)." |
|
} else { |
|
puts stderr "WARNING: -singleproc 0 under a non-punk kit executable: child processes boot with the kit's bundled packages which can shadow the src dev modules under test. Prefer a native tclsh for multi-process runs." |
|
} |
|
} |
|
set child_bootstrap [file join $test_base testsupport child_test_runner.tcl] |
|
if {![file exists $child_bootstrap]} { |
|
puts stderr "Error: missing child bootstrap script: $child_bootstrap" |
|
exit 2 |
|
} |
|
set runtests_workdir [runtests_create_tempdir runtests_work] |
|
if {$runtests_workdir eq ""} { |
|
puts stderr "Error: could not create a work directory for multi-process test runs (no usable temp area)" |
|
exit 2 |
|
} |
|
set child_payloadfile [file join $runtests_workdir child_payload.tcl] |
|
set child_outfile [file join $runtests_workdir child_stdout.txt] |
|
set child_errfile [file join $runtests_workdir child_stderr.txt] |
|
#each child runs one file in one process, so its tcltest env mirrors the singleproc |
|
#testinterp (-singleproc 1; per-file -testdir is computed child-side from the test file path) |
|
set child_tcltestoptions $tcltestoptions |
|
dict set child_tcltestoptions -singleproc 1 |
|
runtests_write_child_payload $child_payloadfile $test_tmlist $test_auto_path $ifneeded_script $child_tcltestoptions |
|
} |
|
|
|
#Optional live watch of test runs over UDP (e.g a local tk viewer app): set env(PUNK_TEST_UDPTEE) |
|
#to <port> or <host>:<port> (e.g 41197 for udp://127.0.0.1:41197). Uses the shellfilter::log |
|
#mechanism (shellthread worker threads -> tcludp datagrams, one per line, cooked format with a |
|
#source column). Runner lifecycle events go to tag 'runtests' (each message is a tcl list: |
|
#RUNTESTS-EVENT <eventname> ?key value ...?). Test file output goes to tags 'teststdout' and |
|
#'teststderr': streamed live via shellrun::runx -teelog inside the testinterp in single-process |
|
#mode; written per completed file by this parent in multi-process mode (children do not stream). |
|
#No listener is required - datagram sends to an unbound local port are discarded harmlessly. |
|
set runtests_udptee_target "" |
|
if {[info exists ::env(PUNK_TEST_UDPTEE)] && [string trim $::env(PUNK_TEST_UDPTEE)] ne ""} { |
|
set udptee_raw [string trim $::env(PUNK_TEST_UDPTEE)] |
|
if {[string is integer -strict $udptee_raw]} { |
|
set runtests_udptee_target 127.0.0.1:$udptee_raw |
|
} else { |
|
set runtests_udptee_target $udptee_raw |
|
} |
|
lassign [split $runtests_udptee_target :] udptee_host udptee_port |
|
if {$udptee_host eq "" || ![string is integer -strict $udptee_port] || $udptee_port < 1 || $udptee_port > 65535} { |
|
puts stderr "runtests: ignoring invalid PUNK_TEST_UDPTEE value '$::env(PUNK_TEST_UDPTEE)' (expected <port> or <host>:<port>)" |
|
set runtests_udptee_target "" |
|
} |
|
} |
|
if {$runtests_udptee_target ne ""} { |
|
puts stderr "runtests: UDP tee enabled -> $runtests_udptee_target (PUNK_TEST_UDPTEE)" |
|
#ensure log workers resolve the project's tcludp (1.0.13+) rather than an older copy on |
|
#the runtime's default auto_path - 1.0.12's UDP_ExitProc closes process-global event |
|
#sources at exit (the G-036 piped-stdin exit-hang class). Workers inherit ::auto_path at |
|
#creation, so this must precede the opens. (The testinterp/child test env already gets |
|
#this path via test_auto_path.) |
|
set runtests_udp_libdir [file normalize $test_base/../../lib_tcl$tcl_major/$arch] |
|
if {[file isdirectory $runtests_udp_libdir] && $runtests_udp_libdir ni $::auto_path} { |
|
lappend ::auto_path $runtests_udp_libdir |
|
} |
|
if {[catch {::shellfilter::log::open runtests [list -syslog $runtests_udptee_target]} errM]} { |
|
puts stderr "runtests: could not open UDP tee log worker ($errM) - disabling tee" |
|
set runtests_udptee_target "" |
|
} elseif {!$singleproc} { |
|
#multi-process mode: the parent relays each child's captured output after the file runs |
|
catch {::shellfilter::log::open teststdout [list -syslog $runtests_udptee_target]} |
|
catch {::shellfilter::log::open teststderr [list -syslog $runtests_udptee_target]} |
|
} |
|
} |
|
runtests_udptee_event run-start mode [expr {$singleproc ? "singleproc" : "multiproc"}] jobs $opt_jobs executable $thisexecutable tcl [info patchlevel] files [llength $testfiles] argv $::argv |
|
|
|
#-jobs parallel phase (G-091): partition the discovered files into a parallel batch and a |
|
#serial tail (-serial-paths: console-state-sensitive suites must not run concurrently), run |
|
#the parallel batch through a tpool of -jobs concurrent child processes with per-child |
|
#capture files and tcltest tmpdirs, then run the serial files sequentially through the same |
|
#child mechanism. Results are cached in runtests_job_results and the main loop below consumes |
|
#them in discovery order, so reports are identical in shape and order to a sequential run. |
|
#Submission is longest-first using static weights from the 2026-07-18 timing measurements |
|
#(G-091, achieved - see goals/archive/G-091-runtests-parallel-jobs.md): the full-suite |
|
#floor is the slowest single file, so the slowest files must start immediately. |
|
#Watch-event semantics under -jobs: no file-start events; file-collected (with exitcode) |
|
#fires in completion order here, and the authoritative file-end (with tallies) fires from |
|
#the processing loop in discovery order. |
|
array set runtests_job_results {} |
|
set runtests_phase_times [dict create] |
|
if {$jobs_mode} { |
|
set _phase_t0 [clock milliseconds] |
|
set serial_patterns [concat {*}$opt_serial_paths] |
|
set parallel_files [list] |
|
set serial_files [list] |
|
foreach tf $testfiles { |
|
#directory-of-file classification - same directory-oriented pattern reading as |
|
#-include-paths (G-093; previously matched the file path itself) |
|
set is_serial [runtests_discovery::dir_matches_any $serial_patterns $tf] |
|
if {$is_serial} { |
|
lappend serial_files $tf |
|
} else { |
|
lappend parallel_files $tf |
|
} |
|
} |
|
#longest-first submission order. Static heuristic weights (seconds, measured 2026-07-18 |
|
#on the reference machine); unlisted files weight 0 and keep discovery order among |
|
#themselves (lsort is stable). |
|
set weight_globs { |
|
*/runtimecmd_roundtrip.test 36 |
|
*/runtimecmd_checkfile.test 35 |
|
*/argparsingtest_compare.test 30 |
|
*/multishell_wrapverify.test 26 |
|
*/multishell_wrapdeterminism.test 26 |
|
*/dtplite.test 25 |
|
*/argparsingtest.test 22 |
|
*/multishell.test 20 |
|
*/scriptexec.test 10 |
|
*/shellexit.test 6 |
|
*/exec.test 5 |
|
*/staticruntime.test 3 |
|
*/runtimebash_wsl.test 3 |
|
*/shellnavns.test 2 |
|
} |
|
set decorated [list] |
|
foreach tf $parallel_files { |
|
set w 0 |
|
foreach {g gw} $weight_globs { |
|
if {[string match $g $tf]} { |
|
set w $gw |
|
break |
|
} |
|
} |
|
lappend decorated [list $w $tf] |
|
} |
|
set ordered_parallel [list] |
|
foreach rec [lsort -integer -decreasing -index 0 $decorated] { |
|
lappend ordered_parallel [lindex $rec 1] |
|
} |
|
if {!$report_json_only} { |
|
puts "parallel phase: [llength $parallel_files] files across $opt_jobs jobs; serial tail: [llength $serial_files] files" |
|
} |
|
|
|
#per-child tcltest tmpdirs: nest under a user-supplied -tmpdir if given, else the workdir |
|
if {[dict exists $opt_tcltestoptions -tmpdir]} { |
|
set childtmp_base [dict get $opt_tcltestoptions -tmpdir] |
|
} else { |
|
set childtmp_base $runtests_workdir |
|
} |
|
set idx_of [dict create] |
|
set fidx 0 |
|
foreach tf $testfiles { |
|
dict set idx_of $tf [incr fidx] |
|
} |
|
|
|
#tpool workers need only the child-spawn proc: replicate it verbatim (no packages, no |
|
#globals - see the proc's comments) |
|
set pool_initcmd [list proc runtests_run_child_process [info args runtests_run_child_process] [info body runtests_run_child_process]] |
|
set pool [tpool::create -minworkers 0 -maxworkers $opt_jobs -initcmd $pool_initcmd] |
|
set jobmap [dict create] |
|
foreach tf $ordered_parallel { |
|
set i [dict get $idx_of $tf] |
|
set tfabs [file normalize [file join $test_base $tf]] |
|
set c_out [file join $runtests_workdir child_stdout_$i.txt] |
|
set c_err [file join $runtests_workdir child_stderr_$i.txt] |
|
set c_tmp [file join $childtmp_base childtmp_$i] |
|
file mkdir $c_tmp |
|
set jid [tpool::post $pool [list runtests_run_child_process $thisexecutable $child_exe_modeargs $child_bootstrap $child_payloadfile $tfabs $c_out $c_err $c_tmp]] |
|
dict set jobmap $jid $tf |
|
} |
|
set pending [dict keys $jobmap] |
|
set collected 0 |
|
while {[llength $pending]} { |
|
set done [tpool::wait $pool $pending pending] |
|
foreach jid $done { |
|
set tf [dict get $jobmap $jid] |
|
if {[catch {tpool::get $pool $jid} jobresult]} { |
|
#worker-level failure (a nonzero child exit comes back inside the dict, not here) |
|
set jobresult [dict create error $jobresult errorCode {RUNTESTS JOBFAIL} errorInfo $jobresult] |
|
} |
|
incr collected |
|
if {$report_detailed_markdown} { |
|
puts stdout "parallel collected $collected/[llength $parallel_files]: $tf" |
|
flush stdout |
|
} |
|
runtests_collect_child_result $tf $jobresult |
|
} |
|
} |
|
tpool::release $pool |
|
dict set runtests_phase_times parallel_s [format %.1f [expr {([clock milliseconds] - $_phase_t0) / 1000.0}]] |
|
set _phase_t1 [clock milliseconds] |
|
#serial tail: console-state-sensitive files, one at a time (still child processes) |
|
foreach tf $serial_files { |
|
set i [dict get $idx_of $tf] |
|
set tfabs [file normalize [file join $test_base $tf]] |
|
set c_out [file join $runtests_workdir child_stdout_$i.txt] |
|
set c_err [file join $runtests_workdir child_stderr_$i.txt] |
|
set c_tmp [file join $childtmp_base childtmp_$i] |
|
file mkdir $c_tmp |
|
set jobresult [runtests_run_child_process $thisexecutable $child_exe_modeargs $child_bootstrap $child_payloadfile $tfabs $c_out $c_err $c_tmp] |
|
if {$report_detailed_markdown} { |
|
puts stdout "serial completed: $tf" |
|
flush stdout |
|
} |
|
runtests_collect_child_result $tf $jobresult |
|
} |
|
dict set runtests_phase_times serial_s [format %.1f [expr {([clock milliseconds] - $_phase_t1) / 1000.0}]] |
|
if {!$report_json_only} { |
|
puts "parallel phase: [dict get $runtests_phase_times parallel_s]s; serial tail: [dict get $runtests_phase_times serial_s]s" |
|
#child process wall times (boot + fixtures + tests) - the -jobs critical path is the |
|
#slowest child, which per-test usec timings understate |
|
set walls [list] |
|
foreach tf $testfiles { |
|
if {[info exists runtests_job_results($tf)] && [dict exists $runtests_job_results($tf) wall_ms]} { |
|
lappend walls [list [dict get $runtests_job_results($tf) wall_ms] $tf] |
|
} |
|
} |
|
puts "slowest child processes (wall):" |
|
foreach rec [lrange [lsort -integer -decreasing -index 0 $walls] 0 9] { |
|
puts [format " %7.1fs %s" [expr {[lindex $rec 0] / 1000.0}] [lindex $rec 1]] |
|
} |
|
} |
|
set _phase_t2 [clock milliseconds] |
|
} |
|
|
|
set runtests_file_number 0 |
|
foreach testfile_relative $testfiles { |
|
set testfile [file normalize [file join $test_base $testfile_relative]] |
|
incr runtests_file_number |
|
if {!$jobs_mode} { |
|
#under -jobs the run already happened - file-collected was emitted in completion |
|
#order and the discovery-order file-end below carries the tallies |
|
runtests_udptee_event file-start path $testfile_relative index $runtests_file_number of [llength $testfiles] |
|
} |
|
if {$report_detailed_markdown} { |
|
puts stdout "" |
|
#puts stdout "running test file $testfile" |
|
puts stdout "## runtests $testfile_relative" |
|
puts stdout "" |
|
} |
|
|
|
dict set tcltestoptions -testdir [file dirname $testfile] |
|
|
|
if {$singleproc} { |
|
#in single process mode we can just source the test file in an interp - but we need to set up the environment for the test file - such as the module search path to ensure it picks up the unbuilt modules under development rather than any installed versions. |
|
interp create testinterp |
|
#latest mode so the alpha-versioned dev modules (999999.0a1.0) are preferred over stable |
|
#bootsupport/vendored copies on unversioned 'package require' (default 'stable' mode would |
|
#silently select e.g bootsupport punk::console 0.1.x over the dev module under test) |
|
testinterp eval {package prefer latest} |
|
testinterp eval {tcl::tm::remove [tcl::tm::list]} |
|
testinterp eval [list tcl::tm::add {*}$test_tmlist] |
|
testinterp eval [list set ::auto_path $test_auto_path] |
|
testinterp eval $ifneeded_script |
|
testinterp eval [list set ::argv0 $::argv0] |
|
testinterp eval [list set ::argv $tcltestoptions] |
|
testinterp eval [list set ::argc [llength $tcltestoptions]] |
|
testinterp eval {package require tcltest} |
|
testinterp eval [list tcltest::configure {*}$tcltestoptions] |
|
testinterp eval { |
|
if {[info commands ::lpop] eq ""} { |
|
#native Tcl 8.6 testinterp: shellrun's runx (the runner's capture mechanism) parses via |
|
#punk::args::parse, which uses lpop. punk::lib installs the validated compat aliases |
|
#(parity pinned by modules/punk/lib compat.test). No-op on Tcl 8.7+/9. |
|
#(floored like the toplevel requires - fossil-rejection; prefer-latest selects the dev |
|
# module here anyway) |
|
package require punk::lib 0.5.1- |
|
} |
|
} |
|
testinterp eval {package require shellrun} |
|
#puts ">>>> [testinterp eval { |
|
# set result "" |
|
# foreach key [tcltest::configure] { |
|
# append result "$key: [tcltest::configure $key]\n" |
|
# } |
|
# return $result |
|
# }] <<<<" |
|
if {$runtests_udptee_target ne ""} { |
|
#live-stream this file's output to the UDP watch target while capturing |
|
#(log sources teststdout/teststderr - same tags the multiproc parent relay uses) |
|
set result [testinterp eval [list shellrun::runx -teelog [list -tag test -syslog $runtests_udptee_target] -tcl source $testfile]] |
|
} else { |
|
set result [testinterp eval [list shellrun::runx -tcl source $testfile]] |
|
} |
|
if {$runtests_udptee_target ne ""} { |
|
#Watch mode: close the shellfilter::log worker threads opened inside the testinterp |
|
#(the -teelog tag workers plus shellrun's punkshout/punksherr teehandle workers) and |
|
#terminate them - they are process-level threads whose manager state lives in this |
|
#short-lived interp, so without this every watched file would leak 4 idle worker |
|
#threads for the remainder of the run. Costs ~0.5-0.7s per file (terminate |
|
#handshakes), so it is not done for unwatched runs - those spawn only the two |
|
#teehandle workers per file, a small pre-existing leak noted for the |
|
#shellfilter/shellthread audit. |
|
testinterp eval { |
|
catch { |
|
if {[info exists ::shellthread::manager::workers]} { |
|
foreach _rt_tag [dict keys $::shellthread::manager::workers] { |
|
catch {::shellfilter::log::close $_rt_tag} |
|
} |
|
} |
|
catch {shellthread::manager::shutdown_free_threads 1000} |
|
} |
|
} |
|
} |
|
interp delete testinterp |
|
if {$report_detailed_markdown} { |
|
puts stdout "sourced $testfile " |
|
} |
|
flush stdout |
|
} else { |
|
if {$jobs_mode} { |
|
#run already performed in the parallel/serial phases above; output relay and the |
|
#file-collected event happened at collection time |
|
set result $runtests_job_results($testfile_relative) |
|
} else { |
|
set result [runtests_run_child_process $thisexecutable $child_exe_modeargs $child_bootstrap $child_payloadfile $testfile $child_outfile $child_errfile ""] |
|
if {$runtests_udptee_target ne ""} { |
|
#relay the child's captured output to the watch target (file granularity - |
|
#children do not stream live; see src/tests/AGENTS.md) |
|
if {[dict exists $result stdout] && [dict get $result stdout] ne ""} { |
|
catch {::shellfilter::log::write teststdout [dict get $result stdout]} |
|
} |
|
if {[dict exists $result stderr] && [dict get $result stderr] ne ""} { |
|
catch {::shellfilter::log::write teststderr [dict get $result stderr]} |
|
} |
|
} |
|
} |
|
if {$report_detailed_markdown} { |
|
puts stdout "executed $thisexecutable[expr {[llength $child_exe_modeargs] ? " $child_exe_modeargs" : ""}] testsupport/child_test_runner.tcl $testfile_relative " |
|
} |
|
flush stdout |
|
} |
|
|
|
if {$opt_show_raw_output} { |
|
puts stdout "" |
|
puts stdout "### stdout from $testfile_relative" |
|
puts stdout "" |
|
puts stdout "```" |
|
puts stdout [dict get $result stdout] |
|
puts stdout "```" |
|
puts stdout "" |
|
puts stdout "### stderr from $testfile_relative" |
|
puts stdout "" |
|
puts stdout "```" |
|
puts stdout [dict get $result stderr] |
|
puts stdout "```" |
|
puts stdout "" |
|
} |
|
|
|
if {[dict exists $result error]} { |
|
puts stdout "" |
|
puts stdout "### error running test file $testfile_relative" |
|
puts stdout "error : [dict get $result error] " |
|
puts stdout "errorCode: [dict get $result errorCode] " |
|
puts stdout "errorInfo: " |
|
puts stdout "```" |
|
puts stdout [runtests_clr fail [dict get $result errorInfo]] |
|
puts stdout "```" |
|
puts stdout "" |
|
flush stdout |
|
dict lappend tallydict files_with_fails $testfile_relative |
|
lappend file_summaries [dict create path $testfile_relative status error total 0 passed 0 skipped 0 failed 1 summaryline_detected 0 observed_passes 0 observed_skips 0 observed_failures 1 warnings [list] failures [list [dict create name $testfile_relative status ERROR errorcode [dict get $result error]]] skips [list]] |
|
} else { |
|
set resultdict [punk::tcltestrun::parse_testrun $result $testfile] |
|
set file_status pass |
|
set file_warnings [list] |
|
set child_exit_failures [list] |
|
set child_exitcode [runtests_dict_get_default $resultdict exitcode ""] |
|
if {$child_exitcode ni [list "" 0]} { |
|
#multi-process mode: a nonzero child exit is a file-level failure regardless of any |
|
#summary line - the child usually died before reaching tcltest::cleanupTests. |
|
#(previously this was misreported as a missing-cleanupTests warning with the child's |
|
#stderr discarded) |
|
set stderr_tail [runtests_text_tail [runtests_dict_get_default $result stderr ""] 2000] |
|
set child_exit_failures [list [dict create name $testfile_relative status ERROR errorcode [list CHILDPROCESS exit $child_exitcode] errorinfo $stderr_tail]] |
|
puts stderr "ERROR: test file child process exited with code $child_exitcode: $testfile" |
|
if {!$report_json_only} { |
|
puts stdout [runtests_clr fail "test file $testfile_relative child process failed (exitcode $child_exitcode)"] |
|
} |
|
dict lappend tallydict files_with_fails $testfile_relative |
|
set file_status error |
|
if {[dict get $resultdict summaryline_detected] == 1} { |
|
#the child still reported results before dying - keep them in the tallies |
|
dict incr tallydict total [dict get $resultdict summaryline_total] |
|
dict incr tallydict passed [dict get $resultdict summaryline_passed] |
|
dict incr tallydict skipped [dict get $resultdict summaryline_skipped] |
|
dict incr tallydict failed [dict get $resultdict summaryline_failed] |
|
} |
|
} elseif {[dict get $resultdict summaryline_detected] == 0} { |
|
set warning_message "No tcltest summary line detected; test file may be missing tcltest::cleanupTests" |
|
set file_warnings [list [runtests_warning_summary missing-cleanupTests $warning_message 0]] |
|
puts stderr "WARNING: $warning_message: $testfile" |
|
dict lappend tallydict files_with_warnings $testfile_relative |
|
set file_status warning |
|
} elseif {[dict get $resultdict summaryline_detected] > 1} { |
|
set warning_message "More than one tcltest summary line detected; test file may be calling tcltest::cleanupTests multiple times" |
|
set file_warnings [list [runtests_warning_summary multiple-summary-lines $warning_message [dict get $resultdict summaryline_detected]]] |
|
puts stderr "WARNING: $warning_message: $testfile" |
|
dict lappend tallydict files_with_warnings $testfile_relative |
|
set file_status warning |
|
} else { |
|
dict incr tallydict total [dict get $resultdict summaryline_total] |
|
dict incr tallydict passed [dict get $resultdict summaryline_passed] |
|
dict incr tallydict skipped [dict get $resultdict summaryline_skipped] ;#more than just skipped due to constraints - also includes skipped due to -skip and -match values. |
|
dict incr tallydict failed [dict get $resultdict summaryline_failed] |
|
#puts stdout [dict get $resultdict out] |
|
if {[dict get $resultdict summaryline_failed] > 0} { |
|
if {!$report_json_only} { |
|
puts stdout [runtests_clr fail "test file $testfile_relative had failures"] |
|
} |
|
dict lappend tallydict files_with_fails $testfile_relative |
|
set file_status fail |
|
} else { |
|
if {$report_detailed_markdown} { |
|
puts stdout [runtests_clr pass "test file $testfile_relative passed"] |
|
} |
|
dict lappend tallydict files_without_fails $testfile_relative |
|
} |
|
} |
|
|
|
set failures [concat [runtests_failure_summaries $resultdict] $child_exit_failures] |
|
set skips [runtests_skip_summaries $resultdict] |
|
set passes [runtests_pass_summaries $testfile_relative $resultdict] |
|
if {$file_status ne "warning"} { |
|
set all_passes [concat $all_passes $passes] |
|
} |
|
set observed_passes [runtests_count_dict_entries [runtests_dict_get_default $resultdict testcase_passes [dict create]]] |
|
set observed_skips [runtests_count_dict_entries [runtests_dict_get_default $resultdict testcase_constraintskips [dict create]]] |
|
set observed_failures [runtests_count_dict_entries [runtests_dict_get_default $resultdict testcase_fails [dict create]]] |
|
lappend file_summaries [dict create {*}{ |
|
} path $testfile_relative {*}{ |
|
} status $file_status {*}{ |
|
} total [dict get $resultdict summaryline_total] {*}{ |
|
} passed [dict get $resultdict summaryline_passed] {*}{ |
|
} skipped [dict get $resultdict summaryline_skipped] {*}{ |
|
} failed [dict get $resultdict summaryline_failed] {*}{ |
|
} summaryline_detected [dict get $resultdict summaryline_detected] {*}{ |
|
} observed_passes $observed_passes {*}{ |
|
} observed_skips $observed_skips {*}{ |
|
} observed_failures $observed_failures {*}{ |
|
} warnings $file_warnings {*}{ |
|
} failures $failures {*}{ |
|
} skips $skips {*}{ |
|
} |
|
] |
|
|
|
|
|
#puts stdout "resultdict: $resultdict" |
|
if {$report_detailed_markdown} { |
|
if {$file_status eq "warning"} { |
|
foreach warning $file_warnings { |
|
runtests_print_file_warning $testfile_relative $warning $observed_passes $observed_skips $observed_failures |
|
} |
|
} |
|
if {[llength $child_exit_failures]} { |
|
puts stdout "### child process failure for $testfile_relative" |
|
puts stdout "" |
|
puts stdout "- exitcode: $child_exitcode " |
|
puts stdout " stderr tail: " |
|
puts stdout " ```" |
|
puts stdout " [dict get [lindex $child_exit_failures 0] errorinfo]" |
|
puts stdout " ```" |
|
puts stdout "" |
|
} |
|
if {$opt_show_passes && $file_status ne "warning"} { |
|
puts stdout "" |
|
puts stdout "### testcase passes for $testfile_relative" |
|
puts stdout "" |
|
dict for {testname testdict} [runtests_dict_get_default $resultdict testcase_passes [dict create]] { |
|
puts stdout "- testname: $testname " |
|
if {$opt_show_timings && [dict exists $testdict microseconds]} { |
|
puts stdout " microseconds: [dict get $testdict microseconds] " |
|
} |
|
} |
|
puts stdout "" |
|
} |
|
|
|
if {$opt_show_passes && $file_status eq "warning" && $observed_passes > 0} { |
|
puts stdout "### observed testcase pass events for $testfile_relative" |
|
puts stdout "" |
|
puts stdout "These events are untrusted for aggregate counts because no single tcltest cleanup summary was parsed." |
|
puts stdout "" |
|
dict for {testname testdict} [runtests_dict_get_default $resultdict testcase_passes [dict create]] { |
|
puts stdout "- testname: $testname " |
|
if {$opt_show_timings && [dict exists $testdict microseconds]} { |
|
puts stdout " microseconds: [dict get $testdict microseconds] " |
|
} |
|
} |
|
puts stdout "" |
|
} |
|
|
|
if {$file_status eq "warning"} { |
|
puts stdout "### observed testcase failures for $testfile_relative" |
|
} else { |
|
puts stdout "### testcase failures for $testfile_relative" |
|
} |
|
puts stdout "" |
|
runtests_print_failure_details $testfile_relative $resultdict |
|
puts stdout "" |
|
if {$file_status eq "warning"} { |
|
puts stdout "### observed testcase_constraintskips for $testfile_relative" |
|
} else { |
|
puts stdout "### testcase_constraintskips for $testfile_relative" |
|
} |
|
puts stdout "" |
|
dict for {testname testdict} [runtests_dict_get_default $resultdict testcase_constraintskips [dict create]] { |
|
puts stdout "- testname: $testname " |
|
puts stdout " skipreason: [dict get $testdict reason] " |
|
} |
|
puts -nonewline stdout "\n" |
|
} |
|
flush stdout |
|
} |
|
|
|
if {$runtests_udptee_target ne ""} { |
|
set last_summary [lindex $file_summaries end] |
|
runtests_udptee_event file-end path $testfile_relative status [dict get $last_summary status] total [dict get $last_summary total] passed [dict get $last_summary passed] skipped [dict get $last_summary skipped] failed [dict get $last_summary failed] |
|
} |
|
set i 0 |
|
} |
|
set status [runtests_status $tallydict] |
|
set slowest_tests [runtests_slowest_tests $all_passes $opt_slowest] |
|
set elapsed_seconds [expr {[clock seconds] - $ts_start}] |
|
if {$jobs_mode} { |
|
dict set runtests_phase_times processing_s [format %.1f [expr {([clock milliseconds] - $_phase_t2) / 1000.0}]] |
|
if {!$report_json_only} { |
|
puts stdout "jobs phase times: parallel [dict get $runtests_phase_times parallel_s]s, serial tail [dict get $runtests_phase_times serial_s]s, processing [dict get $runtests_phase_times processing_s]s" |
|
} |
|
} |
|
runtests_udptee_event run-end status $status total [dict get $tallydict total] passed [dict get $tallydict passed] skipped [dict get $tallydict skipped] failed [dict get $tallydict failed] files [llength $file_summaries] elapsed_seconds $elapsed_seconds {*}$runtests_phase_times |
|
|
|
if {!$report_json_only} { |
|
puts stdout "## runtests summary" |
|
puts stdout "" |
|
runtests_emit_result_line $status $tallydict $file_summaries $elapsed_seconds |
|
puts stdout "" |
|
} |
|
|
|
if {$report_compact} { |
|
puts stdout "### testfiles" |
|
puts stdout "" |
|
foreach file_summary $file_summaries { |
|
set fstatus [dict get $file_summary status] |
|
set line "- [runtests_clr $fstatus $fstatus] `[dict get $file_summary path]` total=[dict get $file_summary total] passed=[dict get $file_summary passed] skipped=[dict get $file_summary skipped] failed=[dict get $file_summary failed]" |
|
if {[dict get $file_summary status] eq "warning"} { |
|
set warnings [dict get $file_summary warnings] |
|
if {[llength $warnings] > 0} { |
|
set warning [lindex $warnings 0] |
|
append line " reason=[dict get $warning code] summaryline_detected=[dict get $warning summaryline_detected] observed_passes=[dict get $file_summary observed_passes] observed_skips=[dict get $file_summary observed_skips] observed_failures=[dict get $file_summary observed_failures]" |
|
} |
|
} |
|
puts stdout $line |
|
} |
|
puts stdout "" |
|
puts stdout "### failures" |
|
puts stdout "" |
|
foreach file_summary $file_summaries { |
|
foreach failure [dict get $file_summary failures] { |
|
set line "- `[dict get $file_summary path]` [dict get $failure name] status=[dict get $failure status]" |
|
if {[dict exists $failure errorcode] && [dict get $failure errorcode] ne ""} { |
|
append line " errorcode=[dict get $failure errorcode]" |
|
} |
|
if {[dict exists $failure errorinfo] && [dict get $failure errorinfo] ne ""} { |
|
set msg [string trim [dict get $failure errorinfo]] |
|
set first_line [lindex [split $msg \n] 0] |
|
if {[string length $first_line] > 120} { |
|
set first_line "[string range $first_line 0 119]..." |
|
} |
|
append line " message=$first_line" |
|
} |
|
if {[dict exists $failure result_was] && [dict get $failure result_was] ne ""} { |
|
set actual [string trim [dict get $failure result_was]] |
|
set first_line [lindex [split $actual \n] 0] |
|
if {[string length $first_line] > 80} { |
|
set first_line "[string range $first_line 0 79]..." |
|
} |
|
append line " actual=$first_line" |
|
} |
|
if {[dict exists $failure result_expected] && [dict get $failure result_expected] ne ""} { |
|
set expected [string trim [dict get $failure result_expected]] |
|
set first_line [lindex [split $expected \n] 0] |
|
if {[string length $first_line] > 80} { |
|
set first_line "[string range $first_line 0 79]..." |
|
} |
|
append line " expected=$first_line" |
|
} |
|
puts stdout [runtests_clr fail $line] |
|
} |
|
} |
|
puts stdout "" |
|
puts stdout "### warnings" |
|
puts stdout "" |
|
foreach file_summary $file_summaries { |
|
foreach warning [dict get $file_summary warnings] { |
|
puts stdout [runtests_clr warning "- `[dict get $file_summary path]` reason=[dict get $warning code] summaryline_detected=[dict get $warning summaryline_detected] observed_passes=[dict get $file_summary observed_passes]"] |
|
} |
|
} |
|
puts stdout "" |
|
if {$opt_slowest > 0} { |
|
runtests_print_slowest_tests $slowest_tests $opt_show_timings |
|
} |
|
} |
|
|
|
if {$report_detailed_markdown} { |
|
puts stdout "### testfiles without failures" |
|
puts stdout "" |
|
foreach f [dict get $tallydict files_without_fails] { |
|
puts stdout " - $f" |
|
} |
|
puts stdout "" |
|
|
|
puts stdout "### testfiles with failures" |
|
puts stdout "" |
|
foreach f [dict get $tallydict files_with_fails] { |
|
puts stdout " - [runtests_clr fail $f]" |
|
} |
|
puts stdout "" |
|
|
|
puts stdout "### testfiles with warnings" |
|
puts stdout "" |
|
foreach f [dict get $tallydict files_with_warnings] { |
|
puts stdout " - [runtests_clr warning $f]" |
|
} |
|
puts stdout "" |
|
|
|
if {$opt_slowest > 0} { |
|
runtests_print_slowest_tests $slowest_tests $opt_show_timings |
|
} |
|
} |
|
|
|
if {!$report_json_only} { |
|
puts stdout "### runtests tally" |
|
puts stdout "" |
|
set tally_passed "Passed [dict get $tallydict passed]" |
|
set tally_failed "Failed [dict get $tallydict failed]" |
|
if {[dict get $tallydict failed] > 0 || [llength [dict get $tallydict files_with_fails]]} { |
|
set tally_failed [runtests_clr fail $tally_failed] |
|
} else { |
|
set tally_passed [runtests_clr pass $tally_passed] |
|
} |
|
set tally_line "Total [dict get $tallydict total] $tally_passed Skipped [dict get $tallydict skipped] $tally_failed" |
|
if {[llength [dict get $tallydict files_with_fails]]} { |
|
#file-level failures (load/setup errors, child process failures) are not test |
|
#results - surface them here so a red FAIL can't sit beside 'Failed 0' unexplained |
|
append tally_line " " [runtests_clr fail "Testfiles-failed [llength [dict get $tallydict files_with_fails]]"] |
|
} |
|
puts stdout "$tally_line " |
|
puts stdout "" |
|
} |
|
|
|
if {$report_emit_json} { |
|
if {!$report_json_only} { |
|
puts stdout "### runtests json" |
|
puts stdout "" |
|
puts stdout "```json" |
|
} |
|
puts stdout [runtests_json_report $status $tallydict $file_summaries $slowest_tests $elapsed_seconds] |
|
if {!$report_json_only} { |
|
puts stdout "```" |
|
puts stdout "" |
|
} |
|
} |
|
|
|
if {!$report_json_only} { |
|
puts stdout "### runtests DONE at [clock format [clock seconds] -format "%Y-%m-%d %H:%M:%S"] (elapsed ${elapsed_seconds}s) - [runtests_clr $status [string toupper $status]]" |
|
puts stdout "" |
|
} |
|
|
|
if {$runtests_tmpdir ne "" && [file isdirectory $runtests_tmpdir]} { |
|
catch {file delete -force $runtests_tmpdir} |
|
} |
|
if {$runtests_workdir ne "" && [file isdirectory $runtests_workdir]} { |
|
catch {file delete -force $runtests_workdir} |
|
} |
|
|
|
if {$runtests_udptee_target ne ""} { |
|
#orderly shutdown of the parent's tee log workers before exit - worker threads and their |
|
#tcludp sockets must not be left running into process teardown (see the G-036 exit-wedge |
|
#history around udp workers at exit/quit) |
|
catch {::shellfilter::log::close runtests} |
|
catch {::shellfilter::log::close teststdout} |
|
catch {::shellfilter::log::close teststderr} |
|
catch {shellthread::manager::shutdown_free_threads 2500} |
|
} |
|
|
|
if {$opt_strict_exit} { |
|
if {[dict get $tallydict failed] > 0 || [llength [dict get $tallydict files_with_fails]] > 0} { |
|
exit 1 |
|
} |
|
if {[llength [dict get $tallydict files_with_warnings]] > 0} { |
|
exit 2 |
|
} |
|
} |
|
|
|
exit 0 |
|
|
|
##don't package require tcltest too early or it may examine and respond to ::argv itself. (e.g to respond to --help, but we have our own help) |
|
#package require tcltest |
|
# |
|
#set ::argv $tcltestoptions |
|
#set ::argc [llength $tcltestoptions] |
|
##set ::argv {} |
|
##set ::argc 0 |
|
# |
|
#tcltest::configure -verbose "body pass skip error usec" |
|
#tcltest::configure -testdir $script_dir |
|
#tcltest::configure -file $file_globs |
|
##review - single process has less isolation - but works better in this case. |
|
##(some tclsh shells can hang when running with -singleproc false - needs investigation) |
|
##tclte::configure -singleproc true |
|
#tcltest::configure -singleproc true |
|
#dict for {k v} $tcltestoptions { |
|
# tcltest::configure $k $v |
|
#} |
|
#tcltest::runAllTests
|
|
|