App-karr

 view release on metacpan or  search on metacpan

t/65-skill-show-utf8.t  view on Meta::CPAN

use strict;
use warnings;
use Test::More;
use lib 't/lib';
use TestGit qw( require_git_c );
require_git_c();
use File::Temp qw( tempdir );
use Cwd qw( abs_path getcwd );
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 App::karr::Cmd::Skill;

# The bundled skill file is real Markdown prose and legitimately contains
# non-ASCII (em dashes, ellipses, umlauts). _skill_content hands it back
# decoded (slurp_utf8), so exactly one encode must happen between there and the
# terminal.
#
# Ticket #33 put that encode inside the command, because the rest of the CLI
# handed raw octets to print and a UTF-8 layer on STDOUT would have
# double-encoded them. Ticket #53 moved the boundary: F<bin/karr> now installs
# the layer (App::karr::Encoding::enable_std_utf8) and every command prints
# characters, so the encode in the command became the double encode #33 was
# avoiding and was removed. This file pins the property both fixes were after --
# stdout carries singly-encoded UTF-8 -- rather than either implementation of
# it, so it stays honest across the move.
#
# 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";

sub run_skill_show {
    my ($share_dir) = @_;

    require File::ShareDir;
    no warnings 'redefine';
    local *File::ShareDir::dist_dir = sub { return $share_dir };

    my @warnings;
    local $SIG{__WARN__} = sub { push @warnings, $_[0] };

    # The same layer F<bin/karr> puts on the real STDOUT. Reopening STDOUT drops
    # whatever layers the script installed, so an in-process capture has to
    # restore it or it is not capturing what the CLI would emit.
    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->execute( ['show'], [] ); 1 };
    my $err  = $@;
    select $prev;
    close $capture;

    die $err unless $ok;
    return ( $out, \@warnings );
}

subtest 'skill show prints UTF-8 bytes without a wide character warning' => sub {
    my $dir = tempdir( CLEANUP => 1 );
    path($dir)->child('claude-skill.md')->spew_utf8($SKILL_TEXT);

    my ( $out, $warnings ) = run_skill_show($dir);

    my @wide = grep { /Wide character/ } @$warnings;
    is( scalar(@wide), 0, 'no "Wide character in print" warning' )
        or diag "warnings emitted: @$warnings";
    is( scalar(@$warnings), 0, 'no warnings at all' )
        or diag "warnings emitted: @$warnings";

    is( $out, encode_utf8($SKILL_TEXT), 'stdout carries the correctly encoded UTF-8 bytes' );
    ok( !utf8::is_utf8($out) || $out !~ /[^\x00-\xff]/,
        'nothing wider than a byte reached the output handle' );

    # The failure mode the removed encode_utf8 would now produce: bytes that are
    # still valid UTF-8, but decode to the mojibake of the real text rather than
    # to the text.
    my $decoded = eval { decode( 'UTF-8', $out, FB_CROAK | LEAVE_SRC ) };
    is( $decoded, $SKILL_TEXT, 'decoding the output once gives the text back (encoded exactly once)' );
    isnt( $out, encode_utf8( encode_utf8($SKILL_TEXT) ), 'output is not double-encoded' );
};

subtest '_skill_content stays decoded so check/update comparisons keep working' => sub {
    # Guards the tempting wrong fix of slurping raw: that would silence the
    # warning but make _check/_update compare bytes against slurp_utf8 text
    # (always "outdated") and make _install spew_utf8 a double-encoded file.
    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 $content = App::karr::Cmd::Skill->new->_skill_content;

    is( $content, $SKILL_TEXT, '_skill_content returns decoded characters' );
    is( length($content), length($SKILL_TEXT), 'character length matches (not byte-inflated)' );
};

subtest 'karr skill show through the real CLI emits the bundled file verbatim' => 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 $raw = do {
        open my $fh, '<:raw', "$bundled" or die "open $bundled: $!";
        local $/;
        <$fh>;
    };
    ok( $raw =~ /[\x80-\xff]/, 'the bundled skill really does contain non-ASCII bytes' );

    # Hand the child a share dir of our own, ahead of everything else in @INC.
    #
    # _skill_content asks File::ShareDir for the *installed* dist first and only
    # falls back to the checkout, so on a machine with App::karr installed this
    # compared `karr skill show`'s output against a file the child never read.
    # It passed only for as long as the installed copy happened to be
    # byte-identical to the checkout -- i.e. it broke on any edit to
    # share/claude-skill.md, reporting it as an encoding bug. dist_dir resolves
    # auto/share/dist/<dist> against @INC in order, so a -I in front of the rest
    # pins it deterministically.
    my $share_lib = path( tempdir( CLEANUP => 1 ) );
    my $share_dir = $share_lib->child(qw( auto share dist App-karr ));
    $share_dir->mkpath;
    $bundled->copy( $share_dir->child('claude-skill.md') );

    my $err_fh = gensym;
    my $pid = open3( my $in, my $out_fh, $err_fh,
        $^X, "-I$share_lib", "-I$ROOT/lib", $BIN, 'skill', 'show' );
    close $in;
    binmode $out_fh;
    my $stdout = do { local $/; <$out_fh> };
    my $stderr = do { local $/; <$err_fh> };
    waitpid( $pid, 0 );
    my $exit = $? >> 8;

    is( $exit, 0, 'karr skill show exits 0' ) or diag $stderr;
    is( $stdout, $raw, 'stdout is byte-identical to the bundled file' );
    unlike( $stderr, qr/Wide character/, 'no wide-character warning on the CLI path' );
};

done_testing;



( run in 2.038 seconds using v1.01-cache-2.11-cpan-364913b4093 )