@@ -391,7 +391,13 @@ fn filter_go_test_json(output: &str) -> String {
391391 let total_fail: usize = packages. values ( ) . map ( |p| p. fail ) . sum ( ) ;
392392 let total_skip: usize = packages. values ( ) . map ( |p| p. skip ) . sum ( ) ;
393393 let total_build_fail: usize = packages. values ( ) . filter ( |p| p. build_failed ) . count ( ) ;
394- let total_pkg_fail: usize = packages. values ( ) . filter ( |p| p. package_failed ) . count ( ) ;
394+ // Only count package-level fails for packages with no individual test or build failures.
395+ // go test -json emits a trailing package-level {"action":"fail"} after any test failure
396+ // too, but that event is just a cascade — the individual test failures are already counted.
397+ let total_pkg_fail: usize = packages
398+ . values ( )
399+ . filter ( |p| p. package_failed && p. fail == 0 && !p. build_failed )
400+ . count ( ) ;
395401
396402 let has_failures = total_fail > 0 || total_build_fail > 0 || total_pkg_fail > 0 ;
397403
@@ -418,9 +424,11 @@ fn filter_go_test_json(output: &str) -> String {
418424 result. push_str ( & format ! ( " in {} packages\n " , total_packages) ) ;
419425 result. push_str ( "═══════════════════════════════════════\n " ) ;
420426
421- // Show package-level failures first (timeouts, signals, panics)
427+ // Show package-level failures first (timeouts, signals, panics).
428+ // Skip packages that already have individual test-level failures — those are displayed
429+ // in the per-package section below and the package-level event is just a cascade.
422430 for ( package, pkg_result) in packages. iter ( ) {
423- if !pkg_result. package_failed {
431+ if !pkg_result. package_failed || pkg_result . fail > 0 || pkg_result . build_failed {
424432 continue ;
425433 }
426434
@@ -754,6 +762,91 @@ mod tests {
754762 ) ;
755763 }
756764
765+ #[ test]
766+ fn test_filter_go_test_no_double_count_on_test_failure ( ) {
767+ // go test -json always emits a package-level {"action":"fail"} after each
768+ // test-level failure. The package-level event is a cascade, not an additional
769+ // failure. The summary header must show "1 failed", not "2 failed".
770+ let output = r#"{"Time":"2024-01-01T10:00:00Z","Action":"run","Package":"example.com/foo","Test":"TestFail"}
771+ {"Time":"2024-01-01T10:00:01Z","Action":"output","Package":"example.com/foo","Test":"TestFail","Output":"=== RUN TestFail\n"}
772+ {"Time":"2024-01-01T10:00:02Z","Action":"output","Package":"example.com/foo","Test":"TestFail","Output":" Error: expected 5, got 3\n"}
773+ {"Time":"2024-01-01T10:00:03Z","Action":"fail","Package":"example.com/foo","Test":"TestFail","Elapsed":0.5}
774+ {"Time":"2024-01-01T10:00:03Z","Action":"fail","Package":"example.com/foo","Elapsed":0.5}"# ;
775+
776+ let result = filter_go_test_json ( output) ;
777+ // The summary header must say "1 failed", not "2 failed" (no double-counting).
778+ assert ! (
779+ result. starts_with( "Go test: 0 passed, 1 failed" ) ,
780+ "Expected header 'Go test: 0 passed, 1 failed', got: {}" ,
781+ result
782+ ) ;
783+ assert ! ( result. contains( "TestFail" ) ) ;
784+ assert ! ( result. contains( "expected 5, got 3" ) ) ;
785+ // The package must NOT appear twice (once as "[FAIL]" and once with test details).
786+ assert_eq ! (
787+ result. matches( "foo" ) . count( ) ,
788+ 1 ,
789+ "Package name should appear exactly once, got: {}" ,
790+ result
791+ ) ;
792+ }
793+
794+ #[ test]
795+ fn test_filter_go_test_timeout_with_signal_quit_output ( ) {
796+ // Exact reproduction of the scenario from issue #958: the signal: quit line
797+ // appears as a separate JSON output event.
798+ let output = r#"{"Action":"start","Package":"example.com/pkg"}
799+ {"Action":"output","Package":"example.com/pkg","Output":"*** Test killed with quit: ran too long (1m30s).\n"}
800+ {"Action":"output","Package":"example.com/pkg","Output":"signal: quit\n"}
801+ {"Action":"output","Package":"example.com/pkg","Output":"FAIL\texample.com/pkg\t90.000s\n"}
802+ {"Action":"fail","Package":"example.com/pkg","Elapsed":90.001}"# ;
803+
804+ let result = filter_go_test_json ( output) ;
805+ assert ! (
806+ result. starts_with( "Go test: 0 passed, 1 failed" ) ,
807+ "Expected 'Go test: 0 passed, 1 failed', got: {}" ,
808+ result
809+ ) ;
810+ assert ! (
811+ !result. contains( "No tests found" ) ,
812+ "Must not say 'No tests found' on timeout, got: {}" ,
813+ result
814+ ) ;
815+ assert ! (
816+ result. contains( "Test killed with quit" ) ,
817+ "Should show the timeout message, got: {}" ,
818+ result
819+ ) ;
820+ }
821+
822+ #[ test]
823+ fn test_filter_go_test_timeout_with_passing_tests_before_kill ( ) {
824+ // Some tests pass before the package times out.
825+ // Summary should show both pass and fail counts.
826+ let output = r#"{"Action":"run","Package":"example.com/foo","Test":"TestFast"}
827+ {"Action":"pass","Package":"example.com/foo","Test":"TestFast","Elapsed":0.001}
828+ {"Action":"run","Package":"example.com/foo","Test":"TestHang"}
829+ {"Action":"output","Package":"example.com/foo","Output":"*** Test killed with quit: ran too long (30s).\n"}
830+ {"Action":"fail","Package":"example.com/foo","Elapsed":30.001}"# ;
831+
832+ let result = filter_go_test_json ( output) ;
833+ assert ! (
834+ result. starts_with( "Go test: 1 passed, 1 failed" ) ,
835+ "Expected 'Go test: 1 passed, 1 failed', got: {}" ,
836+ result
837+ ) ;
838+ assert ! (
839+ !result. contains( "No tests found" ) ,
840+ "Must not say 'No tests found', got: {}" ,
841+ result
842+ ) ;
843+ assert ! (
844+ result. contains( "Test killed with quit" ) ,
845+ "Should show timeout message, got: {}" ,
846+ result
847+ ) ;
848+ }
849+
757850 #[ test]
758851 fn test_filter_go_build_success ( ) {
759852 let output = "" ;
0 commit comments