App-Actions

 view release on metacpan or  search on metacpan

t/integration.t  view on Meta::CPAN

		verbose_diag('deferred image run', $run);
		is($run->{result}, $config{exit}{success}, 'a parallel run whose jobs name an image themselves succeeds');
		cmp_ok(App::Actions::Cache->new(file => $record)->entries->[0]{last_used}, '>=', $now,
			'and the image the jobs used is touched in the record');
	}

	my $daemon = daemon();
	my $record = File::Spec->catfile(tempdir(CLEANUP => 1), 'cache.json');
	with_daemon($daemon, {}, sub { captured(sub { $docker->pull($config{image}) }) });
	my $run = with_daemon($daemon, {}, sub { captured(sub { actions(project($PASSING), cache_file => $record)->run }) });
	is($run->{result}, $config{exit}{success}, 'a run with an image the user pulled succeeds');
	is_deeply(App::Actions::Cache->new(file => $record)->entries, [], 'and does not add it to the record');
};

subtest 'a parallel matrix keeps one log per job, which Logs lists and prunes' => sub {
	plan skip_all => 'jobs run one at a time where fork() is not reliable' if $NO_FORK;

	# Runner::Parallel forks the jobs, copies their logs into log_dir, and
	# Logs reads that directory: the logs a run reports are the ones Logs
	# finds, and pruning them leaves nothing behind.
	my $project = project($PASSING);
	my $logs = File::Spec->catdir(tempdir(CLEANUP => 1), 'logs');
	my $actions = actions($project, parallel => $config{parallel}, log_dir => $logs);

	my $run = with_daemon(daemon(), {}, sub { captured(sub { $actions->run }) });
	verbose_diag('parallel run', $run);
	is($run->{result}, $config{exit}{success}, 'the parallel run succeeds');
	like($run->{stdout}, qr/^\[test \(n=$_\)\] /m, "the output of instance $_ is prefixed with its name")
		for @{ $config{matrix} };

	my $listing = App::Actions::Logs->new(directory => $logs);
	my $runs = $listing->entries;
	verbose_diag('logs', $runs);
	is(scalar @{$runs}, 1, 'one run of logs is kept');
	my @names = map { $_->{name} } @{ $runs->[0]{logs} };
	is(scalar @names, scalar @{ $config{matrix} }, 'with a log for each instance of the matrix');
	like($run->{stdout}, qr/^\s+test \(n=\Q$config{matrix}[0]\E\)\s+\Q$names[0]\E$/m, 'the names the run printed');
	ok((grep { -s $_->{path} } @{ $runs->[0]{logs} }) == @names, 'and none of them is empty');

	is_deeply([ App::Actions::Schema::validate($listing->document, App::Actions->json_schema('logs')) ], [],
		'the listing is a valid logs document');
	my (undef, $status) = $listing->prune;
	is($status, 0, 'pruning the logs succeeds');
	is_deeply(App::Actions::Logs->new(directory => $logs)->entries, [], 'and leaves none');
};

subtest 'independent objects do not interfere with each other' => sub {
	# Two objects, each with its own workflow, daemon, image, record and
	# logger, used in turn.  Strategy: whatever one does - an override, a
	# failure, a pull - must show only in its own record, logger and daemon.
	my ($passing, $failing) = (project($PASSING), project($CASCADE =~ s{^(\s+)runs-on: ubuntu-latest\n}{$1runs-on: ubuntu-latest\n$1container: $config{other_image}\n}mgr));
	my ($passing_daemon, $failing_daemon) = (daemon(), daemon());
	my ($passing_record, $failing_record) = map { File::Spec->catfile(tempdir(CLEANUP => 1), 'cache.json') } 1 .. 2;
	my (@passing_log, @failing_log);

	my $one = actions($passing, cache_file => $passing_record, logger => \@passing_log);
	my $two = actions($failing, cache_file => $failing_record, logger => \@failing_log);
	isnt($one->workflow, $two->workflow, 'each has its own workflow');

	my $only_lint = with_daemon($passing_daemon, {}, sub { captured(sub { $one->run(jobs => [ 'lint' ]) }) });
	my $cascade = with_daemon($failing_daemon, {}, sub { captured(sub { $two->run }) });
	my $whole = with_daemon($passing_daemon, {}, sub { captured(sub { $one->run }) });

	is_deeply([ sort keys %{ summary($only_lint->{stdout}) } ], [ 'lint' ], 'an override selects jobs for one run');
	is(scalar keys %{ summary($whole->{stdout}) }, scalar @{ $config{matrix} } + 1,
		'and does not stay behind for the next run of the same object');
	is($whole->{result}, $config{exit}{success}, 'the passing workflow passes');
	is($cascade->{result}, $config{exit}{failure}, 'while the other fails');

	is_deeply(\@passing_log, [], 'the failure is not reported to the other object\'s logger');
	ok(scalar @{ messages(\@failing_log, 'error') }, 'but to its own');

	is_deeply([ map { $_->{image} } @{ App::Actions::Cache->new(file => $passing_record)->entries } ], [ $config{image} ],
		'one record holds only the image its object pulled');
	is_deeply([ map { $_->{image} } @{ App::Actions::Cache->new(file => $failing_record)->entries } ], [ $config{other_image} ],
		'and the other only its own');
	ok(!(grep { grep { $_ eq $config{other_image} } @{$_} } @{ commands($passing_daemon) }),
		'and neither daemon was asked about the other\'s image');
};

subtest 'a failing job is reported to the logger and does not bring down the others' => sub {
	# A step that fails is a job that fails: the jobs that need it are
	# skipped, an independent job still runs, and the error goes to the
	# logger rather than to standard error.
	my @logger;
	my $actions = actions(project($CASCADE), logger => \@logger);
	my $run = with_daemon(daemon(), {}, sub { captured(sub { $actions->run }) });
	verbose_diag('cascade', $run);

	is($run->{result}, $config{exit}{failure}, 'the run reports that a job failed');
	is_deeply(summary($run->{stdout}), { build => 'failure', test => 'skipped', docs => 'success' },
		'the job that needs the failed one is skipped, and the other one still runs');
	like(messages(\@logger, 'error')->[0], qr/\b$config{step_status}\b/, 'the logger is told the status the step exited with');
	is($run->{stderr}, '', 'and nothing is printed to standard error');
};

subtest 'preflight prepares the images and returns a valid document' => sub {
	# preflight runs the check and the image preparation a run makes before
	# its first job, without starting one.  Strategy: the first preflight
	# pulls, the second finds the image present, and both documents match
	# the published schema.
	my $actions = actions(project($PASSING));
	my $daemon = daemon();
	my $schema = App::Actions->json_schema('preflight');

	for my $expected (qw(pulled present)) {
		my $outcome = with_daemon($daemon, {}, sub { captured(sub { $actions->preflight(json => 1) }) });
		my $result = $outcome->{result};
		verbose_diag("preflight ($expected)", $result);
		returns_ok($result, { type => 'hashref' }, "the preflight returns a hash ($expected)");
		is($result->{exit}, $config{exit}{success}, "it succeeds ($expected)");
		is($outcome->{stdout}, '', "prints nothing with json ($expected)");
		ok($result->{summary}{ready}, "finds the environment ready ($expected)");
		is_deeply([ map { [ $_->{image}, $_->{status} ] } @{ $result->{summary}{images} } ], [ [ $config{image}, $expected ] ],
			"and says the image was $expected");
		is_deeply([ App::Actions::Schema::validate(JSON::PP->new->decode(JSON::PP->new->encode($result->{summary})), $schema) ],
			[], "in a document that matches its schema ($expected)");
	}
	is_deeply(job_containers($daemon), [], 'no job was started');
};

subtest 'a run degrades cleanly when its directories refuse access' => sub {
	# The record and the logs are a convenience: a run that cannot write them
	# must still run its jobs, succeed, and say why (the record only when
	# verbose, as Runner::Images documents).  Reading them back from a
	# directory that cannot be searched gives nothing rather than an error.
	my $locked = tempdir(CLEANUP => 1);

	SKIP: {
		skip(why_not('create', $locked), 3) unless can_revoke_create($locked);

		my @logger;
		my $actions = actions(project($PASSING), cache_file => File::Spec->catfile($locked, 'cache.json'),
			logger => \@logger, verbose => 1);
		my $run = with_revoked(create => $locked, sub { with_daemon(daemon(), {}, sub { captured(sub { $actions->run }) }) });
		verbose_diag('cache unwritable', \@logger);
		is($run->{result}, $config{exit}{success}, 'a run whose record cannot be created still succeeds');
		ok((grep { /could not update the image cache/ } @{ messages(\@logger, 'warn') }), 'and warns that the record was not kept');
		is_deeply(App::Actions::Cache->new(file => File::Spec->catfile($locked, 'cache.json'))->entries, [],
			'which then has no images');
	}

	SKIP: {
		skip(why_not('search', $locked), 4) unless can_revoke_search($locked);

		my $record = File::Spec->catfile($locked, 'kept.json');
		App::Actions::Cache->new(file => $record)->remember($config{other_image});
		my @logger;



( run in 0.828 second using v1.01-cache-2.11-cpan-f1796dc4e6b )