App-karr
view release on metacpan or search on metacpan
t/80-skill-show-json.t view on Meta::CPAN
use strict;
use warnings;
use Test::More;
use File::Temp qw( tempdir );
use Cwd qw( abs_path );
use IPC::Open3 qw( open3 );
use Symbol qw( gensym );
use Path::Tiny qw( path );
use Encode qw( encode_utf8 decode FB_CROAK LEAVE_SRC );
use JSON::MaybeXS qw( decode_json );
use App::karr::Cmd::Skill;
# Ticket #79: `karr skill show --json` printed the raw skill Markdown, byte for
# byte identical to `karr skill show`, so the flag was ignored and the output
# was not JSON at all (probed pre-fix: `diff <(karr skill show) <(karr skill
# show --json)` empty, decode_json on it dies "malformed number ... before
# '---\nname: karr'"). Its siblings `skill check --json` and `skill install
# --json` were already correct, so only this one action was wrong.
#
# The second half of this file guards the character/octet boundary
# (App::karr::Encoding, tickets #53/#63). _skill_content hands back decoded
# characters and Role::Output::print_json is character-level too, so exactly
# one encode may happen between the share file and the terminal -- the
# :encoding(UTF-8) layer F<bin/karr> installs. See t/65-skill-show-utf8.t for
# the same property on the plain branch.
#
# Written with \x{} escapes so the expectation does not depend on the source
# encoding of this test file.
my $SKILL_TEXT = "# karr \x{2014} skill\n\nBl\x{00f6}cke \x{2026} \x{00fc}ml\x{00e4}ute\n";
my $ROOT = abs_path('.');
my $BIN = "$ROOT/bin/karr";
# Captured with the layer F<bin/karr> installs
# (App::karr::Encoding::enable_std_utf8): reopening STDOUT drops it, so without
# restoring it the capture would not be the bytes a caller actually sees.
# $out therefore holds octets, exactly like t/51-json-output.t.
sub run_skill_show {
my (@cmd_opts) = @_;
my $dir = tempdir( CLEANUP => 1 );
path($dir)->child('claude-skill.md')->spew_utf8($SKILL_TEXT);
require File::ShareDir;
no warnings 'redefine';
local *File::ShareDir::dist_dir = sub { return $dir };
my @warnings;
local $SIG{__WARN__} = sub { push @warnings, $_[0] };
open my $capture, '>:encoding(UTF-8)', \my $out
or die "cannot open in-memory handle: $!";
my $prev = select $capture;
my $ok = eval {
App::karr::Cmd::Skill->new(@cmd_opts)->execute( ['show'], [] );
1;
};
my $err = $@;
select $prev;
close $capture;
die $err unless $ok;
return ( $out, \@warnings );
}
subtest 'skill show --json emits JSON, not raw Markdown' => sub {
my ( $out, $warnings ) = run_skill_show( json => 1 );
unlike $out, qr/\A---\nname: karr/,
'the payload no longer starts with the raw skill frontmatter';
my $data = eval { decode_json($out) };
ok !$@, 'the payload parses as JSON' or diag "decode_json said: $@\nraw: $out";
is ref($data), 'HASH', 'it is a JSON object';
is_deeply [ sort keys %$data ], ['content'], 'with a single "content" key';
is scalar(@$warnings), 0, 'no warnings emitted' or diag "@$warnings";
};
subtest 'the JSON payload carries the skill content, encoded exactly once' => sub {
my ($json_out) = run_skill_show( json => 1 );
my ($plain_out) = run_skill_show();
# decode_json is octet-level, and $json_out is what actually reached the
# handle, so this asserts on the bytes rather than on an identity round
# trip through the same codec that produced them (the #63 lesson).
my $data = decode_json($json_out);
is $data->{content}, $SKILL_TEXT,
'the decoded content is the skill text, character for character';
is encode_utf8( $data->{content} ), $plain_out,
'and re-encoding it reproduces the plain-output bytes byte for byte';
# The failure mode a second encode anywhere on the JSON path would produce:
# bytes that are still valid UTF-8 but decode to the mojibake of the text.
my $decoded_once = eval { decode( 'UTF-8', $json_out, FB_CROAK | LEAVE_SRC ) };
ok defined $decoded_once, 'stdout decodes as UTF-8 exactly once';
unlike $decoded_once, qr/\x{00e2}\x{0080}\x{0094}/,
'the em dash did not survive as double-encoded bytes';
};
subtest 'plain skill show is unchanged by the --json branch' => sub {
my ( $out, $warnings ) = run_skill_show();
is $out, encode_utf8($SKILL_TEXT), 'stdout still carries singly-encoded UTF-8 bytes';
my @wide = grep { /Wide character/ } @$warnings;
is scalar(@wide), 0, 'still no "Wide character in print" warning'
or diag "@$warnings";
};
subtest 'karr skill show --json through the real CLI' => sub {
my $bundled = path($ROOT)->child('share/claude-skill.md');
plan skip_all => "no share/claude-skill.md in this checkout" unless $bundled->exists;
my $run = sub {
my (@argv) = @_;
my $err_fh = gensym;
my $pid = open3( my $in, my $out_fh, $err_fh, $^X, "-I$ROOT/lib", $BIN, @argv );
close $in;
binmode $out_fh;
my $stdout = do { local $/; <$out_fh> };
my $stderr = do { local $/; <$err_fh> };
waitpid( $pid, 0 );
return { exit => $? >> 8, stdout => $stdout // '', stderr => $stderr // '' };
};
my $plain = $run->( 'skill', 'show' );
my $json = $run->( 'skill', 'show', '--json' );
is $json->{exit}, 0, 'karr skill show --json exits 0' or diag $json->{stderr};
isnt $json->{stdout}, $plain->{stdout},
'--json output is no longer identical to the plain output';
my $data = eval { decode_json( $json->{stdout} ) };
ok !$@, 'the CLI payload parses as JSON'
or diag "decode_json said: $@";
is encode_utf8( $data->{content} ), $plain->{stdout},
'its content re-encodes to exactly the bytes karr skill show prints';
unlike $json->{stderr}, qr/Wide character/, 'no wide-character warning on the CLI path';
};
done_testing;
( run in 0.836 second using v1.01-cache-2.11-cpan-364913b4093 )