From c7d7866b5c57c27d1988692d597edd53f102dba5 Mon Sep 17 00:00:00 2001 From: Graham Ollis Date: Tue, 29 Sep 2026 15:34:11 -0600 Subject: [PATCH] _diff: insulate line-reading from caller's $/ _diff reads the diff subprocess's output line-by-line via <$stdout>, which implicitly relies on the caller not having changed $/. If a caller left $/ set to undef (slurp mode) or another non-default value - for example via an improperly scoped `local $/;` earlier in the same process - reading an already-at-EOF pipe returns an empty string once instead of undef immediately. That phantom empty "line" was treated as a real line of diff output, producing a false failure for two JSON documents that are actually the same. Found while integrating this module into a large test suite where an unrelated script had left $/ globally altered. Co-Authored-By: Claude Sonnet 5 --- Changes | 4 ++++ lib/Test/JSON/Diff.pm | 7 +++++++ t/test_json_diff.t | 20 ++++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/Changes b/Changes index 7d5872c..89983d7 100644 --- a/Changes +++ b/Changes @@ -1,6 +1,10 @@ Revision history for {{$dist->name}} {{$NEXT}} + - fix _diff to no longer be affected by a caller's ambient $/ (input + record separator); a caller that left $/ set to undef (or any other + non-default value) could previously cause a false failure report for + two JSON documents that are actually the same 0.01 2026-09-29 13:05:04 -0600 - initial version diff --git a/lib/Test/JSON/Diff.pm b/lib/Test/JSON/Diff.pm index 07c1fb8..b637fe4 100644 --- a/lib/Test/JSON/Diff.pm +++ b/lib/Test/JSON/Diff.pm @@ -201,6 +201,13 @@ sub _run_to_files ($cmd, $in_path, $out_path, $err_path) { # returns an empty list if the files are the same, otherwise up to # $max_lines lines of unified diff, followed by '...' if clipped. sub _diff ($diff, $context, $max_lines, $expected, $actual, $err_path) { + # reading $diff's output is line based below, so make sure that's true + # regardless of what the caller has done to $/ -- in particular, if $/ + # is set to undef (slurp mode), reading an already-at-EOF pipe returns + # an empty string once instead of undef immediately, which is read as a + # single (phantom) line of diff output, producing a false failure. + local $/ = "\n"; + my $err = $err_path->openw_raw; my $pid = open3(my $stdin, my $stdout, '>&' . fileno($err), $diff, "-U$context", '--label', 'expected', '--label', 'actual', $expected, $actual); diff --git a/t/test_json_diff.t b/t/test_json_diff.t index 534ef15..2b1949e 100644 --- a/t/test_json_diff.t +++ b/t/test_json_diff.t @@ -163,6 +163,26 @@ subtest 'usage errors' => sub { qr/max_lines must be a positive integer/, 'bad max_lines'; }; +subtest 'insulated from caller $/' => sub { + # a caller that has left $/ in slurp mode (or any non-default value) + # shouldn't affect _diff's own line-based reading of the diff subprocess's + # output -- in particular, with $/ undef, reading an already-at-EOF pipe + # returns an empty string once instead of undef immediately, which used + # to be misread as a single (phantom) line of diff output, producing a + # false failure for two documents that are actually the same. + foreach my $sep ( undef, '', "\x00" ) { + local $/ = $sep; + my $sep_name = defined $sep ? ( length $sep ? "chr(" . ord($sep) . ")" : "''" ) : 'undef'; + + my ($ret) = run_check( '{"a":1,"b":2}', '{"b":2,"a":1}', "same despite \$/ = $sep_name" ); + is $ret, T(), "still detects equal JSON when caller left \$/ = $sep_name"; + + my ( undef, undef, $diag ) = run_check( '[1,2]', '[2,1]', "different despite \$/ = $sep_name" ); + like $diag, qr/^--- expected\n\+\+\+ actual\n\@\@/, + "still produces a real diagnostic for an actual difference when \$/ = $sep_name"; + } +}; + subtest 'missing tools' => sub { my $jq = File::Which::which('jq');