App-karr

 view release on metacpan or  search on metacpan

t/213-foundation-plan-cli.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 Path::Tiny qw( path );
use Cwd qw( abs_path getcwd );
use IPC::Open3 qw( open3 );
use Symbol qw( gensym );
use Encode qw( encode );

use App::karr::Git;
use App::karr::Encoding qw( yaml_load );
use App::karr::Foundation;
use App::karr::Foundation::ChainStore;

# Ticket #213: karr-foundation gets a command that writes a chain.
#
# Before it, App::karr::Foundation::ChainStore->write_chain was the only way in
# -- Perl API -- so the one writer that is not a person, the coordination
# agent (#210), was handed a `perl -MApp::karr::Foundation::ChainStore -e ...`
# one-liner in its prompt and asked to type it out. That was the single place
# where karr gave an agent Perl instead of a command, and it meant a rename
# inside that class broke a prompt rather than a call: silently, and only on
# the tick where a plan was wanted.
#
# What is pinned here, and why each is a decision rather than an accident:
#
#   1. The chain arrives as a DOCUMENT on stdin (or --input), not as options.
#      A chain is a DAG and a DAG is nested; the writer that matters most
#      already produces structure. YAML, and JSON through the same parser.
#   2. It REPLACES the chain, it does not append. Only steps whose chain id
#      matches the header are ever ready, so an append would be a new chain
#      over the old steps and the new ones -- a merge with rules of its own.
#   3. Validation is the store's, and nothing is written when it fails: a
#      document karr will not take leaves the chain in the hub exactly as it
#      was, and the writer gets a sentence rather than a Perl error.
#   4. The exit-code contract holds (ADR 0002): a bad invocation is 2, a bad
#      document is 1 -- the document is data, not argv.
#   5. The coordination agent's prompt names the command and no longer carries
#      Perl for it to type.
#
# No agent is ever started: the only invocations here write or check a chain,
# and the prompt is built in-process without dispatching it.

my $ROOT = abs_path('.');

sub run_foundation {
    my ( %arg ) = @_;
    my $old = getcwd();
    my $cwd = $arg{cwd} // $ROOT;
    chdir $cwd or die "chdir $cwd: $!";
    my $errfh = gensym;
    my $pid = open3( my $in, my $outfh, $errfh,
        $^X, "-I$ROOT/lib", "$ROOT/bin/karr-foundation", @{ $arg{argv} } );
    if ( defined $arg{stdin} ) {
        binmode $in, ':raw';
        print {$in} encode( 'UTF-8', $arg{stdin} );
    }
    close $in;
    my $out = do { local $/; <$outfh> };
    my $err = do { local $/; <$errfh> };
    waitpid( $pid, 0 );
    my $exit = $? >> 8;
    chdir $old or die "chdir back: $!";
    return {
        exit   => $exit,
        stdout => defined $out ? $out : '',
        stderr => defined $err ? $err : '',
    };
}

sub init_repo {
    my $repo = tempdir( CLEANUP => 1 );
    system( 'git', 'init', '-q', $repo ) == 0 or BAIL_OUT('git init failed');
    system( 'git', '-C', $repo, 'config', 'user.email', 'fleet@example.com' ) == 0
        or BAIL_OUT('git config failed');
    system( 'git', '-C', $repo, 'config', 'user.name', 'Fleet' ) == 0
        or BAIL_OUT('git config failed');
    return $repo;
}

# A config in a directory of its own, so agents.state and assignment.yml never
# land next to somebody's real fleet.
sub write_config {
    my ( $body ) = @_;
    my $cfg = path( tempdir( CLEANUP => 1 ) )->child('config.yml');
    $cfg->spew_utf8($body);
    return "$cfg";
}

sub store {
    my ( $repo ) = @_;
    return App::karr::Foundation::ChainStore->new(
        git => App::karr::Git->new( dir => "$repo" ) );
}

sub step_refs {
    my ( $repo ) = @_;
    my @refs = sort( split /\n/,
        `git -C '$repo' for-each-ref --format='%(refname)' 'refs/karr-foundation/chain/'` );
    return \@refs;
}

t/213-foundation-plan-cli.t  view on Meta::CPAN

        argv => [ '--config', $cfg, 'plan', 'chain.yml' ], stdin => $FOUR_STEPS );
    is( $surplus->{exit}, 2, 'a surplus positional is a usage error' );
    like( $surplus->{stderr}, qr/\AUsage: karr-foundation plan/,
        'on the marker bin/karr-foundation keys the split on' );

    my $bogus = run_foundation( cwd => $hub,
        argv => [ '--config', $cfg, 'plan', '--totally-bogus' ] );
    is( $bogus->{exit}, 2, 'an unknown option is one too' );

    # 1: the invocation was right, what arrived was not.
    my $empty = run_foundation( cwd => $hub, argv => [ '--config', $cfg, 'plan' ] );
    is( $empty->{exit}, 1, 'an empty stdin is a runtime failure' );
    like( $empty->{stderr}, qr/No chain document received on stdin/,
        'and says the pipe was empty' );

    my $nohub = run_foundation( cwd => $hub,
        argv  => [ '--config', write_config("dirs:\n  - $hub\n") , 'plan' ],
        stdin => $FOUR_STEPS );
    is( $nohub->{exit}, 1, 'a fleet with no hub cannot be planned for' );
    like( $nohub->{stderr}, qr/No usable hub repository/,
        'and is told where a hub is named' );

    is( scalar( store($hub)->steps ), 0, 'none of that wrote a chain' );
};

subtest 'the document crosses the octet boundary exactly once' => sub {
    my ( $hub, $cfg ) = fleet();
    my $note = "Gr\x{f6}\x{df}e \x{2014} \x{4e2d}\x{6587}";

    my $r = run_foundation( cwd => $hub, argv => [ '--config', $cfg, 'plan' ],
        stdin => "steps:\n  - id: a\n    kind: shell\n    repo: /srv/x\n"
               . "    command: echo \x{2764}\n"
               . "note: $note\n" );
    is( $r->{exit}, 0, 'a document with non-ASCII in it is accepted' )
        or diag "stderr: $r->{stderr}";

    is( store($hub)->header->{note}, $note,
        'and the note comes back as the characters that went in' );
    my ( $step ) = store($hub)->steps;
    is( $step->{command}, "echo \x{2764}", 'so does a step field' );
};

# ------------------------------------------------- what the planner is told

subtest 'the coordination agent is given the command, not Perl to type' => sub {
    my ( $hub, $cfg ) = fleet();
    path($cfg)->spew_utf8( <<"CONFIG" );
hub: $hub
dirs:
  - $hub
agents:
  planner:
    command: /bin/true
    role: coordinator
CONFIG

    my $foundation = App::karr::Foundation->new( config => $cfg );
    my $coordinator = $foundation->_coordinator;
    ok( $coordinator->configured, 'the fleet marks a coordination agent' );
    $coordinator->want( step => 4, reason => 'kind: plan is not executed here' );
    my $prompt = $coordinator->prompt( $coordinator->wanted );

    unlike( $prompt, qr/perl -M/,
        'the prompt no longer carries a perl one-liner (#213)' );
    unlike( $prompt, qr/write_chain/,
        'nor the name of the storage method it called' );
    like( $prompt, qr/karr-foundation --config '\Q$cfg\E' plan <</,
        'it is told the command, with the config the fleet was started with' );
    like( $prompt, qr/karr-foundation --config '\Q$cfg\E' ask /,
        'and the mailbox command carries it for the same reason' );
    like( $prompt, qr/REPLACES the chain/,
        'and that writing one replaces what is there' );

    # The prompt is worth nothing if the shape in it is not one karr takes, so
    # the document the agent is shown is run through the parser it will meet.
    my ( $document ) = $prompt =~ /plan <<'CHAIN'\n(.*?)\n\s*CHAIN\n/s;
    ok( $document, 'the prompt shows a whole document' );
    $document =~ s/^ {5}//mg;
    my ( $steps, %header ) = store($hub)->parse_chain_document(
        yaml_load($document) );
    is( scalar @$steps, 2, 'which parses as a chain document' );
    my $validated = store($hub)->validate_chain($steps);
    is_deeply( [ map { $_->{id} } @$validated ], [ '1', '2' ],
        'and validates as a chain' );
};

done_testing();



( run in 0.554 second using v1.01-cache-2.11-cpan-aadc1410aed )