#!/usr/bin/env perl # Copyright (C) 2017 The Qt Company Ltd and/or its subsidiary(-ies). # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 =head1 NAME 05-qt-jenkins-integrator.t - basic test for qt-jenkins-integrator =head1 DESCRIPTION This test exercises various CI state functions (do_state_*). Each state is called individually with a certain set of parameters; behavior from Jenkins or Gerrit is simulated/mocked, and the test verifies that the system would transition to the next state as expected, with the expected arguments. =cut use strict; use warnings; use AnyEvent; use Carp; use Coro; use English qw( -no_match_vars ); use Env::Path; use File::Spec::Functions; use File::Temp; use FindBin; use JSON; use Test::Builder; use Test::Exception; use Test::More; use Sub::Override; use URI; use lib catfile( $FindBin::Bin, qw(.. .. lib perl5) ); use QtQA::Test::More qw(create_mock_command is_or_like); use QtQA::WWW::Util qw(www_form_urlencoded); my $SCRIPT = catfile( $FindBin::Bin, qw(.. qt-jenkins-integrator.pl) ); my $PACKAGE = 'QtQA::GerritJenkinsIntegrator'; my $SOON = .1; # a small amount of time if ($OSNAME =~ m{win32}i) { plan skip_all => "$PACKAGE is not supported on $OSNAME"; } # expected query string when looking at the build queue my $QUEUE_JSON_QUERY_STRING = 'depth=2&tree=builds[number,actions[parameters[name,value]]]'; # expected query string when monitoring a build my $BUILD_JSON_QUERY_STRING = 'depth=2&tree=building,number,url,result,fullDisplayName,timestamp,duration,runs[building,number,url,result,fullDisplayName,timestamp,duration]'; # base configuration used in tests; override where appropriate my $GERRIT_BASE = 'ssh://gerrit.example.com'; my %CONFIG = ( Global => { # default is to poll very fast to keep test runtime down; # tests which are trying to exercise non-poll code paths should # locally increase these values. StagingQuietPeriod => $SOON, StagingMaximumWait => $SOON*10, StagingPollInterval => $SOON, JenkinsUrl => 'http://jenkins.example.com/', JenkinsUser => 'jenkinsuser', JenkinsToken => 'jenkinstoken', JenkinsPollInterval => $SOON, JenkinsTriggerPollInterval => $SOON, JenkinsTriggerTimeout => $SOON*10, }, prjA => { GerritUrl => URI->new("$GERRIT_BASE/prj/prjA"), GerritBranch => 'mybranch', } ); my @logs; require_ok( $SCRIPT ); sub mock_cmd { my ($cmd, @sequence) = @_; my $tmpdir = File::Temp->newdir( 'qt-jenkins-integrator-test.XXXXXX', TMPDIR => 1 ); create_mock_command( name => $cmd, directory => $tmpdir, sequence => \@sequence, ); return $tmpdir; } sub mock_http { my ($label, $mock_ref) = @_; return unless $mock_ref; my $override = Sub::Override->new(); my $sub = sub { my ($method, $url, %args) = @_; my $expected = $mock_ref; if (ref($expected) eq 'ARRAY') { $expected = shift @{ $mock_ref }; } is( $method, $expected->{ method }, "[$label] http method" ) if $expected->{ method }; is( $url, $expected->{ url }, "[$label] http url" ) if $expected->{ url }; is_or_like( $args{ body }, $expected->{ body }, "[$label] http body" ) if $expected->{ body }; return ( $expected->{ result_body } || q{}, $expected->{ result_headers } || { Status => 500, Reason => 'no result specified in mock_http' } ); }; foreach my $to_mock ("${PACKAGE}::blocking_http_request", "QtQA::WWW::Util::blocking_http_request") { $override->replace( $to_mock, $sub ); } return $override; } # convenience function to create a gerrit stream-events event for test_state_machine sub gerrit_updated_soon { my ($uri, $project) = @_; $uri ||= $GERRIT_BASE; $project ||= 'prj/prjA'; return [ $SOON, $SOON, $uri, { type => 'ref-updated', refUpdate => { project => $project } } ]; } # Returns a mock Jenkins build object sub object_for_build { my (%args) = @_; my %toplevel; my %parameters; # each 'run' is itself a build my @runs = @{ delete($args{ runs }) || [] }; @runs = map { object_for_build( %{$_} ) } @runs; # permitted toplevel attributes; anything else is a 'parameter' foreach my $key (qw(number building result fullDisplayName)) { my $value = delete $args{ $key }; if (defined($value)) { $toplevel{ $key } = $value; } } %parameters = %args; my $object = \%toplevel; while (my ($name, $value) = each %parameters) { push @{ $object->{ actions }[0]{ parameters } }, { name => $name, value => $value }; } if (@runs) { $object->{ runs } = \@runs; } return $object; } # Returns a mock Jenkins build object, JSON encoded sub json_for_build { my (%args) = @_; return encode_json object_for_build( %args ) } # Returns a mock Jenkins object containing multiple builds, JSON encoded sub json_for_builds { my (@args) = @_; my @builds = map { object_for_build( %{ $_ } ) } @args; return encode_json( {builds => \@builds} ); } # Returns a regex for matching the given $pattern as a query string portion sub qr_query_string { my ($pattern) = @_; return qr{ (?:&|\A) # beginning of string or of argument $pattern (?:&|\z) # end of string or of argument }xms; } sub query_string_patterns { my (@args) = @_; # for the entire string to match exactly this query, every individual pattern must match... my @part_patterns = map { qr_query_string($_) } @args; # ... and there must be no other query string components my $outer_pattern = '\A' . join( '&', map { '[^&]+' } @args ) . '\z'; return (@part_patterns, qr{$outer_pattern}); } sub http_responses_for_builds { my ($mock_http_base, @builds) = @_; return [ map { +{ # + helps perlcritic parse this as hashref, not block %{ $mock_http_base }, result_headers => {Status => 200}, result_body => json_for_build( %{$_} ), } } @builds ]; } # Tests a single state machine function. # # 'in' parameters refer to the state input, while 'out' parameters refer # to the expected state output. Omitted 'out' parameters aren't tested. # # The resulting stash is returned in case additional checks are desired. sub test_state_machine { my (%args) = @_; my $object = $args{ object } || croak 'missing object'; my $project_id = $args{ project_id } || croak 'missing project_id'; my $in_state = $args{ in_state } || croak 'missing in_state'; my $in_stash = $args{ in_stash } || {}; my $out_state = $args{ out_state }; my $out_stash = $args{ out_stash }; my $config = $args{ config } || $object->{ config }; my $label = $args{ label } || 'basic'; my $mock_git = $args{ mock_git }; my $mock_ssh = $args{ mock_ssh }; my $mock_http = $args{ mock_http }; my $mock_summarize_jenkins_build = $args{ mock_summarize_jenkins_build }; my $mock_sleep = $args{ mock_sleep }; my $logs = $args{ logs }; my $gerrit_events = $args{ gerrit_events }; my $throws_ok = $args{ throws_ok }; $label = "$in_state $label"; # make failures come from caller context local $Test::Builder::Level = $Test::Builder::Level + 1; local $ENV{ PATH } = $ENV{ PATH }; local $object->{ config } = $config; my @mockdirs; my @overrides; if ($mock_git) { push @mockdirs, mock_cmd( 'git', @{ $mock_git } ); } if ($mock_ssh) { push @mockdirs, mock_cmd( 'ssh', @{ $mock_ssh } ); } if ($mock_summarize_jenkins_build) { push @mockdirs, mock_cmd( 'fake-summarize-jenkins-build', @{ $mock_summarize_jenkins_build } ); push @overrides, Sub::Override->new( "${PACKAGE}::summarize_jenkins_build_cmd" => sub { 'fake-summarize-jenkins-build' } ); } if (@mockdirs) { Env::Path->PATH->Prepend( @mockdirs ); } if ($mock_sleep) { push @overrides, Sub::Override->new( 'Coro::AnyEvent::sleep' => sub {} ); } push @overrides, mock_http( $label, $mock_http ); my @gerrit_event_timers = map { my ($after, $interval, $uri, $gerrit_event) = @{ $_ }; if (!ref($uri)) { $uri = URI->new( $uri ); } AE::timer( $after, $interval, sub { $object->handle_gerrit_stream_event( $uri, $gerrit_event ); }); } @{ $gerrit_events || [] }; my (undef, undef, undef, $caller_name) = caller(1); $caller_name =~ s{^.*::}{}; @logs = (); my %stash; my $sub_name = "do_state_$in_state"; $sub_name =~ s{-}{_}g; my $sub_ref = $object->can( $sub_name ); ok( $sub_ref, "[$label] $in_state is a known state" ) || return; %stash = %{ $in_stash }; my $next_state; my $run = sub { $next_state = $sub_ref->( $object, $project_id, \%stash ); }; if ($throws_ok) { &throws_ok( $run, $throws_ok, "[$label] throws OK" ); } else { &lives_ok( $run, "[$label] doesn't die" ); } if ($out_state) { is( $next_state, $out_state, "[$label] $in_state -> $out_state" ); } if ($out_stash) { is_deeply( \%stash, $out_stash, "[$label] stash" ); } if ($logs) { is_deeply( \@logs, $logs, "[$label] logs" ); } return \%stash; } ## no critic Subroutines::RequireArgUnpacking - allows for convenient syntax when overriding %test sub test_state_wait_until_staging_branch_exists { my (%test) = ( @_, in_state => 'wait-until-staging-branch-exists', out_stash => {}, out_state => 'wait-for-staging', logs => [], ); { # staging branch eventually exists, discovered by polling test_state_machine( %test, label => 'poll', mock_git => [ {}, {}, { stdout => '98921005a7df200cac9e488db4df4bf38ba85478 refs/staging/mybranch' }, ], ); } { # branch is discovered by gerrit event, not polling # make poll interval large so gerrit events arrive first local $CONFIG{ Global }{ StagingPollInterval } = 10; test_state_machine( %test, label => 'non-poll', gerrit_events => [ gerrit_updated_soon(), ], mock_git => [ {}, { stdout => '98921005a7df200cac9e488db4df4bf38ba85478 refs/staging/mybranch' }, ], logs => [ 'woke up by event from gerrit' ], ); } return; } sub test_state_start { my (%test) = ( @_, in_state => 'start', in_stash => {hi => 'there'}, out_stash => {}, # 'start' always empties the stash logs => [], ); { test_state_machine( %test, label => 'no staging branch', mock_git => [ # simulate staging branch doesn't exist (ls-remote has no output) {} ], out_state => 'wait-until-staging-branch-exists' ); } { test_state_machine( %test, label => 'staging branch', mock_git => [ # simulate staging branch exists { stdout => '98921005a7df200cac9e488db4df4bf38ba85478 refs/staging/mybranch' }, ], out_state => 'wait-for-staging', ); } return; } sub test_state_wait_for_staging { my (%test) = ( @_, in_state => 'wait-for-staging', in_stash => {}, logs => [], ); { # staged changes discovered by polling test_state_machine( %test, label => 'poll', mock_ssh => [ # simulate nothing staged for first couple of staging-ls; then eventually some activity appears {}, {}, {stdout => qq{some change\n}}, ], out_state => 'wait-for-staging-quiet', out_stash => { staged => 'some change', }, logs => [], ); } { # staged changes discovered by gerrit events; # put poll interval large so the events arrive first local $CONFIG{ Global }{ StagingPollInterval } = 10; test_state_machine( %test, label => 'non-poll', mock_ssh => [ {}, {stdout => qq{another change\n}}, ], gerrit_events => [ gerrit_updated_soon(), ], out_state => 'wait-for-staging-quiet', out_stash => { staged => 'another change', }, logs => [ 'woke up by event from gerrit' ], ); } return; } sub test_state_wait_for_staging_quiet { my (%test) = ( @_, in_state => 'wait-for-staging-quiet', ); { # polling determines staging branch is stable; start a build test_state_machine( %test, label => 'quiet, poll', mock_ssh => [ # stable staging branch ({stdout => 'c'}) x 2 ], in_stash => { staged => 'c' }, out_stash => { staged => 'c' }, out_state => 'staging-new-build', logs => ['done waiting for staging'], ); } { # polling, changes keep appearing and disappearing in staging branch; # eventually timeout and start a build test_state_machine( %test, label => 'timeout, poll', mock_ssh => [ # content oscillates as things are staged, unstaged ({stdout => 'a'}, {stdout => 'ab'}) x 10 ], in_stash => { staged => 'c' }, out_stash => { staged => 'c' }, out_state => 'staging-new-build', ); # we don't know exactly how many times 'staging activity occurred' should be logged, # it depends on timing; should be a couple at least is_deeply( [ @logs[0..2] ], [ ('staging activity occurred.') x 3 ] ); } { # non-polling, eventually all changes are unstaged, so return to waiting for staging local $CONFIG{ Global }{ StagingPollInterval } = 10; local $CONFIG{ Global }{ StagingQuietPeriod } = 20; local $CONFIG{ Global }{ StagingMaximumWait } = 60; test_state_machine( %test, label => 'unstaged, non-poll', mock_ssh => [ # content oscillates as things are staged, unstaged, then eventually everything # is unstaged ({stdout => 'a'}, {stdout => 'ab'}) x 2, {}, ], in_stash => { staged => 'c' }, out_stash => { }, out_state => 'wait-for-staging', gerrit_events => [ gerrit_updated_soon(), ], ); is_deeply( [ @logs[0..3] ], [ ('woke up by event from gerrit', 'staging activity occurred.') x 2 ] ); } return; } sub test_state_staging_new_build { my (%test) = ( @_, in_state => 'staging-new-build', ); { # succeeds (after an initial error) and moves to check-staged-changes my $stash = test_state_machine( %test, label => 'success', mock_ssh => [ # fake an error to ensure we can recover {stderr => q{some error}, exitcode => 2}, {} ], in_stash => {}, out_state => 'check-staged-changes', ); # build ref should be exported to stash my $build_ref = $stash->{ build_ref }; ok( $build_ref, 'build ref is set' ); like( $build_ref, qr{^refs/builds/mybranch_\d+$}, 'build ref looks OK' ); my $warning = shift @logs; like( $warning, qr{command \[ssh\].*\[staging-new-build\].*exited with status \d+ \[retry}, 'retried ssh OK' ); is_deeply( \@logs, ["created build ref $build_ref"], 'logs' ); } return; } sub test_state_check_staged_changes { my (%test) = ( @_, in_state => 'check-staged-changes', ); { # check staged changes always proceeds to trigger jenkins test_state_machine( %test, mock_ssh => [ {stdout => qq{some stuff\nmore stuff\n}}, ], in_stash => {build_ref => 'refs/builds/mybranch_1234'}, out_state => 'trigger-jenkins', stash => {staged => qq{some stuff\nmore stuff}}, logs => [], ); } return; } sub test_state_trigger_jenkins { my (%test) = ( @_, in_state => 'trigger-jenkins', in_stash => {build_ref => 'refs/builds/somebuild'}, ); # we expect the following HTTP request to be sent out # TODO: verify the postdata my %mock_http_base = ( method => 'POST', url => 'http://jenkins.example.com/job/prjA/buildWithParameters', body => [query_string_patterns( qr/qt_ci_request_id=[0-9a-f]{8}/, quotemeta(www_form_urlencoded(qt_ci_git_url => 'ssh://gerrit.example.com/prj/prjA')), www_form_urlencoded(qt_ci_git_ref => 'refs/builds/somebuild'), )] ); { test_state_machine( %test, label => 'error', mock_http => { %mock_http_base, result_headers => { Status => 404, Reason => 'frobnitz' } }, throws_ok => qr{new build for prjA failed: 404 frobnitz}, ); } { my $stash = test_state_machine( %test, label => 'success', mock_http => { %mock_http_base, result_headers => { Status => 200 } }, out_state => 'wait-for-jenkins-build-active', ); like( $stash->{ request_id }, qr{\A[0-9a-f]{8}\z}, 'request_id is set' ); } } sub test_state_wait_for_jenkins_build_active { my (%test) = ( @_, in_state => 'wait-for-jenkins-build-active', in_stash => { request_id => 'a1b2c3d4' }, ); # we expect the following HTTP request to be sent out my %mock_http_base = ( method => 'GET', url => "http://jenkins.example.com/job/prjA/api/json?$QUEUE_JSON_QUERY_STRING" ); { test_state_machine( %test, label => 'http error', mock_http => { %mock_http_base, result_headers => { Status => 503, Reason => 'server down' } }, throws_ok => qr{server down}, ); } { test_state_machine( %test, label => 'not json error', mock_http => { %mock_http_base, result_headers => { Status => 200 }, result_body => "this ain't json", }, throws_ok => qr{.}, ); } { test_state_machine( %test, label => 'missing data error', mock_http => { %mock_http_base, result_headers => { Status => 200 }, result_body => '{"builds":{"incorrect":"data"}}', }, throws_ok => qr{JSON schema error}, ); } { test_state_machine( %test, label => 'timeout', mock_http => { %mock_http_base, result_headers => { Status => 200 }, result_body => json_for_builds( # some builds, but not the right ones {number => 41, qt_ci_request_id => 'aabbccdd'}, {number => 42, qt_ci_request_id => 'eeff0011'}, ), }, throws_ok => qr{Jenkins did not start a build with request ID a1b2c3d4}, ); } { test_state_machine( %test, label => 'success', mock_http => { %mock_http_base, result_headers => { Status => 200 }, result_body => json_for_builds( # a couple of unrelated builds, plus the real one {number => 41, qt_ci_request_id => 'aabbccdd'}, {number => 42, qt_ci_request_id => 'a1b2c3d4'}, {number => 43} ), }, out_stash => {build_number => 42}, out_state => 'set-jenkins-build-description', ); } return; } sub test_state_set_jenkins_build_description { my (%test) = ( @_, in_state => 'set-jenkins-build-description', in_stash => { build_number => 1234, staged => qq{2b63d8d760c80ebf5fc939a35fd133a62bfb3fc2 123,45 do this\n} .qq{63d8d760c80ebf5fc939a35fd133a62bfb3fc22b 67,89 do that\n} }, ); # we expect the following HTTP request to be sent out # TODO: verify the postdata my %mock_http_base = ( method => 'POST', url => 'http://jenkins.example.com/job/prjA/1234/submitDescription', body => [query_string_patterns( quotemeta(www_form_urlencoded( description => qq{Tested changes: