App-karr

 view release on metacpan or  search on metacpan

t/120-error-message-sweep.t  view on Meta::CPAN

#   $ karr restore --input /nonexistent/nope.yml --yes
#   Error open (<:unix) on '/nonexistent/nope.yml': No such file or directory
#    at /.../lib/App/karr/Cmd/Restore.pm line 101.
#
#   $ karr backup --output <unwritable>/x.yml
#   Error spew on '...': ... Permission denied at /.../lib/App/karr/Cmd/Backup.pm line 75.
#
#   $ karr context --write-to <unwritable>/ctx.md
#   Error spew on '...': ... Permission denied at /.../lib/App/karr/Cmd/Context.pm line 213.
#
#   $ karr init --claude-skill        # into an unwritable .claude
#   mkpath failed for .../.claude/skills: Permission denied
#    at /.../lib/App/karr/Cmd/Init.pm line 171.
#
#   $ karr-foundation --config <sequence.yml>
#   Config must be a YAML mapping at /.../lib/App/karr/Foundation.pm line 299.
#
# The rule the whole ticket is about: where karr keeps its source is never the
# reader's problem. What IS their problem -- the path they typed, the reason the
# OS gave, the line and column YAML::XS objected at -- has to survive the fix,
# so each case asserts the useful half is still there.

my $ROOT = abs_path('.');

sub run_bin {
    my ( $bin, $cwd, @argv ) = @_;
    my $old = getcwd();
    chdir $cwd or die "chdir $cwd: $!";
    my $errfh = gensym;
    my $pid = open3( my $in, my $outfh, $errfh, $^X, "-I$ROOT/lib", "$ROOT/bin/$bin", @argv );
    close $in;
    my $out = do { local $/; <$outfh> };
    my $err = do { local $/; <$errfh> };
    waitpid( $pid, 0 );
    my $exit = $? >> 8;
    chdir $old or die "chdir $old: $!";
    return {
        exit   => $exit,
        stdout => defined $out ? $out : '',
        stderr => defined $err ? $err : '',
    };
}

sub run_karr { return run_bin( 'karr', @_ ) }

sub new_board {
    my $repo = tempdir( CLEANUP => 1 );
    system( 'git', 'init', '-q', $repo ) == 0 or die 'git init failed';
    system( 'git', '-C', $repo, 'config', 'user.email', 'test@example.com' );
    system( 'git', '-C', $repo, 'config', 'user.name',  'Test User' );
    my $r = run_karr( $repo, 'init' );
    die "karr init failed: $r->{stderr}" unless $r->{exit} == 0;
    return $repo;
}

# An unwritable directory inside $parent, or a skip reason.
sub unwritable_dir {
    my ($parent) = @_;
    my $dir = path($parent)->child('locked');
    $dir->mkpath;
    chmod 0500, "$dir" or return;
    return $dir;
}

my $ROOT_USER = ( $> == 0 );

# ---------------------------------------------------------------------------

subtest 'karr outside a git repository says so and nothing else' => sub {
    my $nowhere = tempdir( CLEANUP => 1 );
    my $r = run_karr( $nowhere, 'list' );

    isnt $r->{exit}, 0, 'the command fails';
    like $r->{stderr}, qr/Not a git repository\. karr requires Git\./,
        'and says what is wrong';
    unlike $r->{stderr}, qr/ at \S+ line \d+/, 'no "at FILE line N." suffix'
        or diag "stderr was:\n$r->{stderr}";
    unlike $r->{stderr}, qr/BoardDiscovery\.pm/, 'no karr module path'
        or diag "stderr was:\n$r->{stderr}";
    is scalar( grep { length } split /\n/, $r->{stderr} ), 1,
        'exactly one line of error'
        or diag "stderr was:\n$r->{stderr}";
};

subtest 'karr restore names the --input it could not read' => sub {
    my $repo = new_board();
    my $missing = path($repo)->child('no-such-backup.yml');
    my $r = run_karr( $repo, 'restore', '--yes', '--input', "$missing" );

    isnt $r->{exit}, 0, 'the restore fails';
    like $r->{stderr}, qr/Could not read \Q$missing\E/, 'the path the user typed is named';
    like $r->{stderr}, qr/No such file or directory/,   'the reason from the OS survives';
    unlike $r->{stderr}, qr/ at \S+ line \d+/, 'no source location'
        or diag "stderr was:\n$r->{stderr}";
    unlike $r->{stderr}, qr/Restore\.pm/, 'no karr module path'
        or diag "stderr was:\n$r->{stderr}";
};

subtest 'karr backup reports an --output it cannot write' => sub {
    plan skip_all => 'running as root: an unwritable directory is still writable'
        if $ROOT_USER;
    my $repo = new_board();
    my $dir = unwritable_dir($repo) or plan skip_all => "cannot chmod: $!";
    my $target = $dir->child('backup.yml');

    my $r = run_karr( $repo, 'backup', '--output', "$target" );
    chmod 0700, "$dir";

    isnt $r->{exit}, 0, 'the backup fails';
    like $r->{stderr}, qr/Could not write \Q$target\E/, 'the target is named';
    like $r->{stderr}, qr/Permission denied/,           'the reason survives';
    unlike $r->{stderr}, qr/ at \S+ line \d+/, 'no source location'
        or diag "stderr was:\n$r->{stderr}";
    unlike $r->{stderr}, qr/Backup\.pm/, 'no karr module path'
        or diag "stderr was:\n$r->{stderr}";
};

subtest 'karr context --write reports an unwritable parent directory' => sub {
    plan skip_all => 'running as root: an unwritable directory is still writable'
        if $ROOT_USER;
    my $repo = new_board();
    my $dir = unwritable_dir($repo) or plan skip_all => "cannot chmod: $!";
    my $target = $dir->child('context.md');

    my $r = run_karr( $repo, 'context', '--write-to', "$target" );
    chmod 0700, "$dir";

    isnt $r->{exit}, 0, 'the write fails';
    like $r->{stderr}, qr/Could not write \Q$target\E/, 'the target is named';
    like $r->{stderr}, qr/Permission denied/,           'the reason survives';
    unlike $r->{stderr}, qr/ at \S+ line \d+/, 'no source location'
        or diag "stderr was:\n$r->{stderr}";
    unlike $r->{stderr}, qr/Context\.pm/, 'no karr module path'
        or diag "stderr was:\n$r->{stderr}";
};

subtest 'karr context --write still overwrites a read-only target file' => sub {
    plan skip_all => 'running as root: a read-only file is still writable'
        if $ROOT_USER;
    # Named in ticket #77 as the case that must NOT start failing: spew renames
    # a temp file into place, so the mode of the existing file is irrelevant --
    # only the directory has to be writable. Guarding the write must not turn
    # this into an error.
    my $repo = new_board();
    my $target = path($repo)->child('ctx.md');
    $target->spew_utf8("previous content\n");
    chmod 0400, "$target" or plan skip_all => "cannot chmod: $!";

    my $r = run_karr( $repo, 'context', '--write-to', "$target" );

    is $r->{exit}, 0, 'the write succeeds' or diag "stderr was:\n$r->{stderr}";
    like $target->slurp_utf8, qr/kanban-md context/, 'and the context really landed';
};

subtest 'karr init --claude-skill reports an unwritable .claude' => sub {
    plan skip_all => 'running as root: an unwritable directory is still writable'
        if $ROOT_USER;
    my $repo = tempdir( CLEANUP => 1 );
    system( 'git', 'init', '-q', $repo ) == 0 or die 'git init failed';
    system( 'git', '-C', $repo, 'config', 'user.email', 'test@example.com' );
    system( 'git', '-C', $repo, 'config', 'user.name',  'Test User' );
    my $claude = path($repo)->child('.claude');
    $claude->mkpath;
    chmod 0500, "$claude" or plan skip_all => "cannot chmod: $!";

    my $r = run_karr( $repo, 'init', '--claude-skill' );
    chmod 0700, "$claude";

    isnt $r->{exit}, 0, 'the install fails';
    like $r->{stderr}, qr/Could not create /,  'karr says what it could not do';
    like $r->{stderr}, qr/Permission denied/,  'the reason survives';
    unlike $r->{stderr}, qr/ at \S+ line \d+/, 'no source location'
        or diag "stderr was:\n$r->{stderr}";
    unlike $r->{stderr}, qr/Init\.pm/, 'no karr module path'
        or diag "stderr was:\n$r->{stderr}";
};

subtest 'karr-foundation reports a broken config without leaking its own source' => sub {
    my $tmp = tempdir( CLEANUP => 1 );

    my $broken = path($tmp)->child('broken.yml');
    $broken->spew_utf8("foo: [unclosed\n");
    my $r = run_bin( 'karr-foundation', $tmp, '--config', "$broken" );
    isnt $r->{exit}, 0, 'an unparseable config fails the run';
    like $r->{stderr}, qr/Cannot parse config \Q$broken\E/, 'the config is named';
    # clean_error would keep only "YAML::XS::Load Error: The problem:" and throw
    # the diagnostic away, so this one passes the parser's own message through.
    like $r->{stderr}, qr/line: 2, column: 1/,
        "YAML::XS's line and column survive -- they are the whole point";
    unlike $r->{stderr}, qr/Foundation\.pm/, 'no karr module path'
        or diag "stderr was:\n$r->{stderr}";

    my $seq = path($tmp)->child('sequence.yml');
    $seq->spew_utf8("- a\n- b\n");
    my $s = run_bin( 'karr-foundation', $tmp, '--config', "$seq" );
    isnt $s->{exit}, 0, 'a config that is not a mapping fails the run';
    like $s->{stderr}, qr/Config must be a YAML mapping/, 'and says why';
    unlike $s->{stderr}, qr/ at \S+ line \d+/, 'no source location'
        or diag "stderr was:\n$s->{stderr}";
    unlike $s->{stderr}, qr/Foundation\.pm/, 'no karr module path'
        or diag "stderr was:\n$s->{stderr}";
};

# ---- the sync half: one call site gone, one copy of the git error ----------

{
    # Fails every pull with a distinctive multi-line error, the shape a per-ref
    # rejection has had since #84.
    package FailingGit;
    sub new { bless {}, shift }
    sub pull { 0 }
    sub push { 0 }
    sub push_rejections { [] }
    sub last_error {
        "the remote 'origin' refused the push:\n"
      . "    refs/karr/tasks/1/data: DISTINCTIVE-REASON"
    }
}

{
    package SyncBoard;
    use Moo;
    use MooX::Options;
    with 'App::karr::Role::SyncLifecycle';
    has git => ( is => 'ro', required => 1 );
}



( run in 0.730 second using v1.01-cache-2.11-cpan-6736b670a1e )