diff --git a/t/unit/Test2/Harness.t b/t/unit/Test2/Harness.t index 079b16e4a..0e99296ea 100644 --- a/t/unit/Test2/Harness.t +++ b/t/unit/Test2/Harness.t @@ -1,5 +1,90 @@ use Test2::V0 -target => 'Test2::Harness::Client'; -skip_all "write me"; +subtest 'constructor' => sub { + my $client = $CLASS->new; + ok($client->isa($CLASS), 'creates instance with no args'); +}; + +subtest 'abstract methods die when not overridden' => sub { + my $client = $CLASS->new; + like( + dies { $client->ipc }, + qr/ipc.*not implemented/i, + 'ipc() dies in base class', + ); + like( + dies { $client->connect }, + qr/connect.*not implemented/i, + 'connect() dies in base class', + ); +}; + +subtest 'send_and_get — success path returns response' => sub { + my $client = $CLASS->new; + + my $fake_con = mock {} => ( + add => [ + send_and_get => sub { + return { + api => {success => 1}, + response => 'pong', + }; + }, + ], + ); + + { + no warnings 'redefine'; + local *Test2::Harness::Client::connect = sub { $fake_con }; + my $result = $client->send_and_get('ping'); + is($result, 'pong', 'send_and_get returns response on success'); + } +}; + +subtest 'send_and_get — failure path croaks' => sub { + my $client = $CLASS->new; + + my $fake_con = mock {} => ( + add => [ + send_and_get => sub { + return { + api => {success => 0, error => 'something failed'}, + response => undef, + }; + }, + ], + ); + + { + no warnings 'redefine'; + local *Test2::Harness::Client::connect = sub { $fake_con }; + like( + dies { $client->send_and_get('stop') }, + qr/API Call failed/, + 'send_and_get croaks on failure', + ); + } +}; + +subtest 'ping delegates to send_and_get' => sub { + my $client = $CLASS->new; + my @calls; + + my $fake_con = mock {} => ( + add => [ + send_and_get => sub { + push @calls, [@_]; + return {api => {success => 1}, response => 'pong'}; + }, + ], + ); + + { + no warnings 'redefine'; + local *Test2::Harness::Client::connect = sub { $fake_con }; + my $res = $client->ping; + is($res, 'pong', 'ping returns pong'); + } +}; done_testing; diff --git a/t/unit/Test2/Harness/Instance.t b/t/unit/Test2/Harness/Instance.t index 5a83eb71e..889b4767c 100644 --- a/t/unit/Test2/Harness/Instance.t +++ b/t/unit/Test2/Harness/Instance.t @@ -1,5 +1,190 @@ use Test2::V0 -target => 'Test2::Harness::Instance'; +use File::Temp qw/tempfile/; -skip_all "write me"; +# Build minimal mock collaborators for the Instance constructor. +# Instance requires: log_file, scheduler (with runner/set_runner), runner, ipc. + +sub make_mock_runner { + return mock {} => ( + add => [ + terminated => sub { 0 }, + terminate => sub { 1 }, + process_list => sub { () }, + overall_status => sub { [] }, + abort => sub { 1 }, + stop => sub { 1 }, + kill => sub { 1 }, + reload => sub { 0 }, + blacklist => sub { {} }, + job_update => sub { 1 }, + set_runner => sub { }, + runner => sub { undef }, + ], + ); +} + +sub make_mock_scheduler { + my ($runner_mock) = @_; + return mock {} => ( + add => [ + runner => sub { undef }, + set_runner => sub { }, + terminated => sub { 0 }, + terminate => sub { 1 }, + process_list => sub { () }, + overall_status => sub { [] }, + abort => sub { 1 }, + stop => sub { 1 }, + kill => sub { 1 }, + advance => sub { 0 }, + queue_run => sub { 1 }, + start => sub { 1 }, + job_update => sub { 1 }, + ], + ); +} + +sub make_mock_ipc { + return mock {} => ( + add => [ + terminate => sub { 1 }, + protocol => sub { 'Test2::Harness::IPC::Protocol::AtomicPipe' }, + callback => sub { sub {} }, + ], + ); +} + +sub make_instance { + my %extra = @_; + my ($fh, $log_file) = tempfile(UNLINK => 1); + close $fh; + my $runner = make_mock_runner(); + my $scheduler = make_mock_scheduler(); + my $ipc = make_mock_ipc(); + return $CLASS->new( + log_file => $log_file, + scheduler => $scheduler, + runner => $runner, + ipc => $ipc, + %extra, + ); +} + +subtest 'required attributes' => sub { + my ($fh, $log_file) = tempfile(UNLINK => 1); + close $fh; + my $runner = make_mock_runner(); + my $scheduler = make_mock_scheduler(); + my $ipc = make_mock_ipc(); + + like( + dies { + $CLASS->new(scheduler => $scheduler, runner => $runner, ipc => $ipc) + }, + qr/log_file.*required/i, + 'log_file is required', + ); + like( + dies { + $CLASS->new(log_file => $log_file, runner => $runner, ipc => $ipc) + }, + qr/scheduler.*required/i, + 'scheduler is required', + ); + like( + dies { + $CLASS->new(log_file => $log_file, scheduler => $scheduler, ipc => $ipc) + }, + qr/runner.*required/i, + 'runner is required', + ); + like( + dies { + $CLASS->new(log_file => $log_file, scheduler => $scheduler, runner => $runner) + }, + qr/ipc.*required/i, + 'ipc is required', + ); +}; + +subtest 'basic construction' => sub { + my $inst = make_instance(); + ok($inst->isa($CLASS), 'creates instance'); + ok($inst->log_file, 'log_file accessible'); + ok($inst->runner, 'runner accessible'); + ok($inst->scheduler, 'scheduler accessible'); + ok($inst->ipc, 'ipc accessible (wrapped in arrayref)'); +}; + +subtest 'ipc wrapped in arrayref when scalar provided' => sub { + my $inst = make_instance(); + ref_ok($inst->{ipc}, 'ARRAY', 'ipc stored as arrayref'); +}; + +subtest 'api_ping returns pong' => sub { + my $inst = make_instance(); + is($inst->api_ping, 'pong', 'api_ping returns "pong"'); +}; + +subtest 'api_pid returns current PID' => sub { + my $inst = make_instance(); + is($inst->api_pid, $$, 'api_pid returns current PID'); +}; + +subtest 'api_log_file returns log file path' => sub { + my $inst = make_instance(); + is($inst->api_log_file, $inst->log_file, 'api_log_file returns log_file'); +}; + +subtest 'handle_request — success path' => sub { + my $inst = make_instance(); + require Test2::Harness::Instance::Request; + my $req = Test2::Harness::Instance::Request->new( + request_id => 'req-ping', + api_call => 'ping', + ); + my $res = $inst->handle_request($req); + ok($res->isa('Test2::Harness::Instance::Response'), 'returns Response object'); + is($res->success, 1, 'success is 1'); + is($res->response, 'pong', 'response contains pong'); +}; + +subtest 'handle_request — error path for unknown api_call' => sub { + my $inst = make_instance(); + require Test2::Harness::Instance::Request; + my $req = Test2::Harness::Instance::Request->new( + request_id => 'req-bad', + api_call => 'no_such_api_call', + ); + my $res = $inst->handle_request($req); + ok($res->isa('Test2::Harness::Instance::Response'), 'returns Response on error'); + is($res->success, 0, 'success is 0 for unknown api call'); + ok($res->api->{error}, 'error message present'); +}; + +subtest 'parse_request_args' => sub { + my $inst = make_instance(); + + is([$inst->parse_request_args(undef)], [], 'undef returns empty list'); + is([$inst->parse_request_args('hello')], ['hello'], 'scalar returned as list with one element'); + is([$inst->parse_request_args([1, 2])], [1, 2], 'arrayref flattened to list'); + is([$inst->parse_request_args({a => 1})], ['a', 1], 'hashref flattened to key-value pairs'); +}; + +subtest 'terminate — first reason wins' => sub { + my $inst = make_instance(); + ok(!$inst->terminated, 'not terminated initially'); + $inst->terminate('first-reason'); + is($inst->terminated, 1, 'terminated set after first call'); + $inst->terminate('second-reason'); + is($inst->terminated, 1, 'terminated not overwritten by second call'); +}; + +subtest 'stop sets stop flag' => sub { + my $inst = make_instance(); + ok(!$inst->{stop}, 'stop not set initially'); + $inst->stop; + ok($inst->{stop}, 'stop set after calling stop()'); +}; done_testing; diff --git a/t/unit/Test2/Harness/Instance/Message.t b/t/unit/Test2/Harness/Instance/Message.t index a1a1875e8..0dc7624e9 100644 --- a/t/unit/Test2/Harness/Instance/Message.t +++ b/t/unit/Test2/Harness/Instance/Message.t @@ -1,5 +1,33 @@ use Test2::V0 -target => 'Test2::Harness::Instance::Message'; -skip_all "write me"; +subtest 'constructor and basic attributes' => sub { + my $msg = $CLASS->new( + ipc_meta => {seq => 1}, + connection => 'con1', + terminate => 1, + run_complete => 1, + ); + ok($msg->isa($CLASS), 'creates instance'); + is($msg->ipc_meta, {seq => 1}, 'ipc_meta accessor'); + is($msg->connection, 'con1', 'connection accessor'); + is($msg->terminate, 1, 'terminate accessor'); + is($msg->run_complete, 1, 'run_complete accessor'); +}; + +subtest 'empty constructor' => sub { + my $msg = $CLASS->new; + ok($msg->isa($CLASS), 'constructs with no args'); +}; + +subtest 'TO_JSON includes class field' => sub { + my $msg = $CLASS->new( + ipc_meta => {seq => 99}, + terminate => 1, + ); + my $json = $msg->TO_JSON; + ref_ok($json, 'HASH', 'TO_JSON returns hashref'); + is($json->{class}, $CLASS, 'TO_JSON includes class key'); + is($json->{terminate}, 1, 'TO_JSON includes terminate'); +}; done_testing; diff --git a/t/unit/Test2/Harness/Instance/Request.t b/t/unit/Test2/Harness/Instance/Request.t index a4a8ae705..88357eca1 100644 --- a/t/unit/Test2/Harness/Instance/Request.t +++ b/t/unit/Test2/Harness/Instance/Request.t @@ -1,5 +1,46 @@ use Test2::V0 -target => 'Test2::Harness::Instance::Request'; -skip_all "write me"; +subtest 'required attributes' => sub { + like( + dies { $CLASS->new(api_call => 'ping') }, + qr/request_id.*required/i, + 'request_id is required', + ); + like( + dies { $CLASS->new(request_id => '123') }, + qr/api_call.*required/i, + 'api_call is required', + ); +}; + +subtest 'valid construction' => sub { + my $req = $CLASS->new(request_id => 'req-1', api_call => 'ping'); + ok($req->isa($CLASS), 'creates instance'); + is($req->request_id, 'req-1', 'request_id accessor'); + is($req->api_call, 'ping', 'api_call accessor'); +}; + +subtest 'optional attributes' => sub { + my $req = $CLASS->new( + request_id => 'req-2', + api_call => 'stop', + args => [1, 2, 3], + do_not_respond => 1, + ); + is($req->args, [1, 2, 3], 'args accessor'); + is($req->do_not_respond, 1, 'do_not_respond accessor'); +}; + +subtest 'inherits from Message' => sub { + require Test2::Harness::Instance::Message; + my $req = $CLASS->new(request_id => 'req-3', api_call => 'ping'); + ok($req->isa('Test2::Harness::Instance::Message'), 'inherits from Message'); +}; + +subtest 'TO_JSON includes class' => sub { + my $req = $CLASS->new(request_id => 'req-4', api_call => 'ping'); + my $json = $req->TO_JSON; + is($json->{class}, $CLASS, 'TO_JSON sets class to Request package'); +}; done_testing; diff --git a/t/unit/Test2/Harness/Instance/Response.t b/t/unit/Test2/Harness/Instance/Response.t index 974405ec0..05342db03 100644 --- a/t/unit/Test2/Harness/Instance/Response.t +++ b/t/unit/Test2/Harness/Instance/Response.t @@ -1,5 +1,69 @@ use Test2::V0 -target => 'Test2::Harness::Instance::Response'; -skip_all "write me"; +subtest 'required attributes' => sub { + like( + dies { $CLASS->new(response => undef, api => {success => 1}) }, + qr/response_id.*required/i, + 'response_id is required', + ); + like( + dies { $CLASS->new(response_id => '1', api => {success => 1}) }, + qr/response.*required/i, + 'response is required (existence check)', + ); + like( + dies { $CLASS->new(response_id => '1', response => undef) }, + qr/api.*required/i, + 'api is required', + ); +}; + +subtest 'valid construction with undef response' => sub { + # response existence is checked with exists, so undef is valid + my $res = $CLASS->new( + response_id => 'res-1', + response => undef, + api => {success => 1}, + ); + ok($res->isa($CLASS), 'creates instance with undef response'); + is($res->response_id, 'res-1', 'response_id accessor'); + ok(!defined($res->response), 'response can be undef'); + is($res->api, {success => 1}, 'api accessor'); +}; + +subtest 'valid construction with data response' => sub { + my $res = $CLASS->new( + response_id => 'res-2', + response => {data => 'value'}, + api => {success => 1}, + ); + is($res->response, {data => 'value'}, 'response accessor'); +}; + +subtest 'success method' => sub { + my $ok = $CLASS->new( + response_id => 'res-3', + response => undef, + api => {success => 1}, + ); + is($ok->success, 1, 'success returns 1 for success'); + + my $fail = $CLASS->new( + response_id => 'res-4', + response => undef, + api => {success => 0, error => 'oops'}, + ); + is($fail->success, 0, 'success returns 0 for failure'); +}; + +subtest 'inherits from Message' => sub { + require Test2::Harness::Instance::Message; + my $res = $CLASS->new( + response_id => 'res-5', + response => undef, + api => {success => 1}, + ); + ok($res->isa('Test2::Harness::Instance::Message'), 'inherits from Message'); +}; done_testing; diff --git a/t/unit/Test2/Harness/Run.t b/t/unit/Test2/Harness/Run.t index 72694913b..31b26530c 100644 --- a/t/unit/Test2/Harness/Run.t +++ b/t/unit/Test2/Harness/Run.t @@ -1,5 +1,99 @@ use Test2::V0 -target => 'Test2::Harness::Run'; +use Test2::Util::UUID qw/gen_uuid/; -skip_all "write me"; +sub make_run { + return $CLASS->new( + run_id => gen_uuid(), + test_settings => {class => 'Test2::Harness::TestSettings'}, + aggregator_ipc => {protocol => 'Test2::Harness::IPC::Protocol::AtomicPipe', connect => []}, + @_, + ); +} + +subtest 'required attributes' => sub { + like( + dies { + $CLASS->new( + test_settings => {class => 'Test2::Harness::TestSettings'}, + aggregator_ipc => {protocol => 'Test2::Harness::IPC::Protocol::AtomicPipe', connect => []}, + ) + }, + qr/run_id.*required/i, + 'run_id is required', + ); + + like( + dies { + $CLASS->new( + run_id => gen_uuid(), + aggregator_ipc => {protocol => 'Test2::Harness::IPC::Protocol::AtomicPipe', connect => []}, + ) + }, + qr/test_settings.*required/i, + 'test_settings is required', + ); + + like( + dies { + $CLASS->new( + run_id => gen_uuid(), + test_settings => {class => 'Test2::Harness::TestSettings'}, + ) + }, + qr/aggregator_ipc.*aggregator_use_io/i, + 'aggregator_ipc or aggregator_use_io is required', + ); +}; + +subtest 'basic construction' => sub { + my $run = make_run(); + ok($run->isa($CLASS), 'creates instance'); + ok($run->run_id, 'run_id accessor returns value'); + ok($run->test_settings->isa('Test2::Harness::TestSettings'), 'test_settings inflated'); +}; + +subtest 'aggregator_use_io as alternative to aggregator_ipc' => sub { + my $run = $CLASS->new( + run_id => gen_uuid(), + test_settings => {class => 'Test2::Harness::TestSettings'}, + aggregator_use_io => 1, + ); + ok($run->isa($CLASS), 'constructs with aggregator_use_io'); +}; + +subtest 'set_ipc and ipc' => sub { + my $run = make_run(); + my $fake_ipc = bless {}, 'FakeIPC'; + $run->set_ipc($fake_ipc); + is($run->ipc, $fake_ipc, 'set_ipc/ipc round-trip'); +}; + +subtest 'abort_on_bail attribute' => sub { + my $run = make_run(); + # Default - not set in constructor, accessor returns undef or default + my $run2 = make_run(abort_on_bail => 1); + is($run2->abort_on_bail, 1, 'abort_on_bail set to 1'); +}; + +subtest 'TO_JSON excludes internal fields' => sub { + my $run = make_run(); + my $fake_ipc = bless {}, 'FakeIPC'; + $run->set_ipc($fake_ipc); + + my $json = $run->TO_JSON; + ref_ok($json, 'HASH', 'TO_JSON returns hashref'); + ok(!exists($json->{ipc}), 'TO_JSON excludes ipc'); + ok(!exists($json->{connect}), 'TO_JSON excludes connect'); + ok(!exists($json->{send_event_cb}), 'TO_JSON excludes send_event_cb'); + ok(exists($json->{run_id}), 'TO_JSON includes run_id'); +}; + +subtest 'data_no_jobs excludes jobs and job_lookup' => sub { + my $run = make_run(); + my $data = $run->data_no_jobs; + ref_ok($data, 'HASH', 'data_no_jobs returns hashref'); + ok(!exists($data->{jobs}), 'data_no_jobs excludes jobs'); + ok(!exists($data->{job_lookup}), 'data_no_jobs excludes job_lookup'); +}; done_testing; diff --git a/t/unit/Test2/Harness/Run/Job.t b/t/unit/Test2/Harness/Run/Job.t index d049b365e..5a416819b 100644 --- a/t/unit/Test2/Harness/Run/Job.t +++ b/t/unit/Test2/Harness/Run/Job.t @@ -1,5 +1,79 @@ use Test2::V0 -target => 'Test2::Harness::Run::Job'; +use File::Temp qw/tempfile/; -skip_all "write me"; +sub make_test_file_path { + my ($content) = @_; + my ($fh, $file) = tempfile(SUFFIX => '.t', UNLINK => 1); + print $fh $content // "use Test2::V0;\ndone_testing;\n"; + close $fh; + return $file; +} + +sub make_job { + my %extra = @_; + my $file = make_test_file_path(); + return $CLASS->new(test_file => {file => $file}, %extra); +} + +subtest 'test_file is required' => sub { + like( + dies { $CLASS->new }, + qr/test_file.*required/i, + 'test_file is required', + ); +}; + +subtest 'basic construction' => sub { + my $job = make_job(); + ok($job->isa($CLASS), 'creates instance'); + ok($job->test_file->isa('Test2::Harness::TestFile'), 'test_file inflated to TestFile object'); +}; + +subtest 'job_id auto-generated' => sub { + my $job = make_job(); + ok($job->job_id, 'job_id is auto-generated'); + like($job->job_id, qr/\w/, 'job_id looks non-empty'); +}; + +subtest 'job_id can be specified' => sub { + my $job = make_job(job_id => 'my-job-123'); + is($job->job_id, 'my-job-123', 'explicit job_id preserved'); +}; + +subtest 'try counts result attempts' => sub { + my $job = make_job(); + is($job->try, 0, 'try is 0 before any results'); + + push @{$job->{results}}, {pass => 0}; + is($job->try, 1, 'try is 1 after one result'); + + push @{$job->{results}}, {pass => 1}; + is($job->try, 2, 'try is 2 after two results'); +}; + +subtest 'resource_id combines job_id and try' => sub { + my $job = make_job(job_id => 'abc-123'); + is($job->resource_id, 'abc-123:0', 'resource_id is job_id:try at 0 results'); + + push @{$job->{results}}, {pass => 0}; + is($job->resource_id, 'abc-123:1', 'resource_id updates when try changes'); +}; + +subtest 'TO_JSON includes job_class' => sub { + my $job = make_job(); + my $json = $job->TO_JSON; + ref_ok($json, 'HASH', 'TO_JSON returns hashref'); + is($json->{job_class}, $CLASS, 'TO_JSON includes job_class'); + ok(exists($json->{job_id}), 'TO_JSON includes job_id'); +}; + +subtest 'process_info excludes test_file and results' => sub { + my $job = make_job(); + my $info = $job->process_info; + ref_ok($info, 'HASH', 'process_info returns hashref'); + ok(!exists($info->{test_file}), 'process_info excludes test_file'); + ok(!exists($info->{results}), 'process_info excludes results'); + ok(exists($info->{job_id}), 'process_info includes job_id'); +}; done_testing; diff --git a/t/unit/Test2/Harness/Runner.t b/t/unit/Test2/Harness/Runner.t index daa29d2ed..50cc17e50 100644 --- a/t/unit/Test2/Harness/Runner.t +++ b/t/unit/Test2/Harness/Runner.t @@ -1,5 +1,80 @@ use Test2::V0 -target => 'Test2::Harness::Runner'; +use File::Temp qw/tempdir/; -skip_all "write me"; +my $workdir = tempdir(CLEANUP => 1); + +sub make_runner { + return $CLASS->new( + workdir => $workdir, + test_settings => {class => 'Test2::Harness::TestSettings'}, + @_, + ); +} + +subtest 'required attributes' => sub { + like( + dies { $CLASS->new(test_settings => {class => 'Test2::Harness::TestSettings'}) }, + qr/workdir.*required/i, + 'workdir is required', + ); + like( + dies { $CLASS->new(workdir => $workdir) }, + qr/test_settings.*required/i, + 'test_settings is required', + ); +}; + +subtest 'basic construction' => sub { + my $runner = make_runner(); + ok($runner->isa($CLASS), 'creates instance'); + is($runner->workdir, $workdir, 'workdir accessor'); + ok($runner->test_settings->isa('Test2::Harness::TestSettings'), 'test_settings inflated'); +}; + +subtest 'test_settings accepts hashref and inflates it' => sub { + my $runner = make_runner(test_settings => {class => 'Test2::Harness::TestSettings', lib => 0}); + ok($runner->test_settings->isa('Test2::Harness::TestSettings'), 'test_settings is a TestSettings object'); +}; + +subtest 'ready always returns 1 for base runner' => sub { + my $runner = make_runner(); + is($runner->ready, 1, 'ready() returns 1'); +}; + +subtest 'stages returns [NONE] for base runner' => sub { + my $runner = make_runner(); + is($runner->stages, ['NONE'], 'stages() returns [NONE]'); +}; + +subtest 'stage_sets returns [[NONE,NONE]] for base runner' => sub { + my $runner = make_runner(); + is($runner->stage_sets, [['NONE', 'NONE']], 'stage_sets() returns [[NONE,NONE]]'); +}; + +subtest 'job_stage returns NONE for base runner' => sub { + my $runner = make_runner(); + is($runner->job_stage(undef, undef), 'NONE', 'job_stage always returns NONE'); +}; + +subtest 'terminate — first reason wins' => sub { + my $runner = make_runner(); + ok(!$runner->terminated, 'not terminated initially'); + my $result = $runner->terminate('first'); + is($result, 'first', 'returns reason'); + is($runner->terminated, 'first', 'terminated set to first reason'); + $runner->terminate('second'); + is($runner->terminated, 'first', 'second call does not overwrite first reason'); +}; + +subtest 'kill calls terminate with kill reason' => sub { + my $runner = make_runner(); + $runner->kill; + ok($runner->terminated, 'kill sets terminated'); +}; + +subtest 'is_daemon attribute' => sub { + my $runner = make_runner(is_daemon => 1); + is($runner->is_daemon, 1, 'is_daemon accessor'); +}; done_testing; diff --git a/t/unit/Test2/Harness/Runner/Preloading.t b/t/unit/Test2/Harness/Runner/Preloading.t index 824ad151e..ba55cf9a6 100644 --- a/t/unit/Test2/Harness/Runner/Preloading.t +++ b/t/unit/Test2/Harness/Runner/Preloading.t @@ -1,5 +1,78 @@ use Test2::V0 -target => 'Test2::Harness::Runner::Preloading'; +use Test2::Util qw/IS_WIN32/; +use File::Temp qw/tempdir/; -skip_all "write me"; +skip_all 'Preloading runner is not supported on Windows' if IS_WIN32; + +my $workdir = tempdir(CLEANUP => 1); + +sub make_runner { + my %extra = @_; + my $runner = $CLASS->new( + workdir => $workdir, + test_settings => {class => 'Test2::Harness::TestSettings'}, + %extra, + ); + # Initialize empty stage data so DESTROY/terminate does not die + $runner->set_stages({}); + return $runner; +} + +subtest 'basic construction' => sub { + my $runner = make_runner(); + ok($runner->isa($CLASS), 'creates instance'); + ok($runner->isa('Test2::Harness::Runner'), 'inherits from Runner base'); +}; + +subtest 'blacklist — starts empty' => sub { + my $runner = make_runner(); + my $bl = $runner->blacklist; + ref_ok($bl, 'HASH', 'blacklist returns hashref'); + is(scalar(keys %$bl), 0, 'blacklist is empty initially'); +}; + +subtest 'blacklist — add modules' => sub { + my $runner = make_runner(); + $runner->blacklist('Foo::Bar', 'Baz::Qux'); + my $bl = $runner->blacklist; + ok($bl->{'Foo::Bar'}, 'Foo::Bar in blacklist'); + ok($bl->{'Baz::Qux'}, 'Baz::Qux in blacklist'); +}; + +subtest 'blacklist — returns current blacklist' => sub { + my $runner = make_runner(); + my $bl = $runner->blacklist('Module::One'); + ref_ok($bl, 'HASH', 'blacklist() returns hashref'); + ok($bl->{'Module::One'}, 'Module::One present in returned hashref'); +}; + +subtest 'terminate — first reason wins' => sub { + my $runner = make_runner(); + ok(!$runner->terminated, 'not terminated initially'); + $runner->terminate('test-reason'); + is($runner->terminated, 'test-reason', 'terminated set to first reason'); + $runner->terminate('other-reason'); + is($runner->terminated, 'test-reason', 'second reason does not overwrite first'); +}; + +subtest 'ready — returns 1 when no stages, 0 when stages present but none ready' => sub { + # Without explicit stages set, the parent ready() is used + my $runner = make_runner(); + # Stages starts as undef, ready() returns 1 when no stages + my $r = $runner->ready; + # The Preloading override: returns 1 if stages exist (i.e. defined) + # Since stages is undef initially, relies on parent ready() + ok(defined($r), 'ready() returns a defined value'); +}; + +subtest 'preload_retry_delay defaults to 5' => sub { + my $runner = make_runner(); + is($runner->preload_retry_delay, 5, 'preload_retry_delay defaults to 5'); +}; + +subtest 'preload_retry_delay can be overridden' => sub { + my $runner = make_runner(preload_retry_delay => 10); + is($runner->preload_retry_delay, 10, 'preload_retry_delay set to 10'); +}; done_testing; diff --git a/t/unit/Test2/Harness/Runner/Preloading/Stage.t b/t/unit/Test2/Harness/Runner/Preloading/Stage.t index 7708043b6..195c08c75 100644 --- a/t/unit/Test2/Harness/Runner/Preloading/Stage.t +++ b/t/unit/Test2/Harness/Runner/Preloading/Stage.t @@ -1,5 +1,67 @@ use Test2::V0 -target => 'Test2::Harness::Runner::Preloading::Stage'; +use Test2::Util qw/IS_WIN32/; -skip_all "write me"; +skip_all 'Preloading stage is not supported on Windows' if IS_WIN32; + +# Stage does not inflate test_settings - pass a pre-built object +my $ts = do { + require Test2::Harness::TestSettings; + Test2::Harness::TestSettings->new; +}; + +sub make_stage { + my %args = @_; + return $CLASS->new( + name => 'TEST', + test_settings => $ts, + root_pid => $$, + %args, + ); +} + +subtest 'basic construction' => sub { + my $stage = make_stage(); + ok($stage->isa($CLASS), 'creates instance'); +}; + +subtest 'name attribute' => sub { + my $stage = make_stage(name => 'MY_STAGE'); + is($stage->name, 'MY_STAGE', 'name accessor'); +}; + +subtest 'test_settings stored as-is' => sub { + my $stage = make_stage(); + ok($stage->test_settings->isa('Test2::Harness::TestSettings'), 'test_settings is a TestSettings object'); +}; + +subtest 'root_pid attribute' => sub { + my $stage = make_stage(root_pid => $$); + is($stage->root_pid, $$, 'root_pid accessor'); +}; + +subtest 'is_daemon attribute defaults' => sub { + my $stage = make_stage(); + ok(!$stage->is_daemon, 'is_daemon defaults to false'); +}; + +subtest 'is_daemon can be set' => sub { + my $stage = make_stage(is_daemon => 1); + is($stage->is_daemon, 1, 'is_daemon set to 1'); +}; + +subtest 'terminate sets terminated reason' => sub { + # Stage::terminate simply overwrites (no first-reason-wins semantics) + my $stage = make_stage(); + ok(!$stage->terminated, 'not terminated initially'); + $stage->terminate('reason-one'); + is($stage->terminated, 'reason-one', 'terminated set after first call'); + $stage->terminate('reason-two'); + is($stage->terminated, 'reason-two', 'terminate overwrites with new reason'); +}; + +subtest 'bad attribute' => sub { + my $stage = make_stage(bad => 1); + is($stage->bad, 1, 'bad accessor'); +}; done_testing; diff --git a/t/unit/Test2/Harness/TestFile.t b/t/unit/Test2/Harness/TestFile.t index 6d5c1cbaa..620992936 100644 --- a/t/unit/Test2/Harness/TestFile.t +++ b/t/unit/Test2/Harness/TestFile.t @@ -1,5 +1,126 @@ use Test2::V0 -target => 'Test2::Harness::TestFile'; +use File::Temp qw/tempfile/; -skip_all "write me"; +sub make_test_file { + my ($content) = @_; + my ($fh, $file) = tempfile(SUFFIX => '.t', UNLINK => 1); + print $fh $content // "# empty test\n"; + close $fh; + return $file; +} + +subtest 'requires valid file' => sub { + like( + dies { $CLASS->new(file => '/no/such/file/xyz.t') }, + qr/Invalid test file/, + 'dies for non-existent file', + ); +}; + +subtest 'basic construction' => sub { + my $file = make_test_file("use Test2::V0;\ndone_testing;\n"); + my $tf = $CLASS->new(file => $file); + ok($tf->isa($CLASS), 'creates instance'); + ok($tf->file, 'file accessor returns value'); + ok(-f $tf->file, 'file exists on disk'); +}; + +subtest 'relative path' => sub { + my $file = make_test_file("# test\n"); + my $tf = $CLASS->new(file => $file); + ok(defined($tf->relative), 'relative() returns a defined value'); +}; + +subtest 'check_feature defaults' => sub { + my $file = make_test_file("# plain test\n"); + my $tf = $CLASS->new(file => $file); + is($tf->check_feature('fork'), 1, 'fork defaults to 1'); + is($tf->check_feature('preload'), 1, 'preload defaults to 1'); + is($tf->check_feature('stream'), 1, 'stream defaults to 1'); + is($tf->check_feature('run'), 1, 'run defaults to 1'); + is($tf->check_feature('timeout'), 1, 'timeout defaults to 1'); + is($tf->check_feature('isolation'), 0, 'isolation defaults to 0'); + is($tf->check_feature('smoke'), 0, 'smoke defaults to 0'); +}; + +subtest 'HARNESS-NO-FORK disables fork feature' => sub { + my $file = make_test_file("# HARNESS-NO-FORK\n"); + my $tf = $CLASS->new(file => $file); + is($tf->check_feature('fork'), 0, 'fork disabled by HARNESS-NO-FORK'); +}; + +subtest 'HARNESS-NO-PRELOAD disables preload feature' => sub { + my $file = make_test_file("# HARNESS-NO-PRELOAD\n"); + my $tf = $CLASS->new(file => $file); + is($tf->check_feature('preload'), 0, 'preload disabled by HARNESS-NO-PRELOAD'); +}; + +subtest 'HARNESS-DURATION sets duration' => sub { + my $file = make_test_file("# HARNESS-DURATION-LONG\n"); + my $tf = $CLASS->new(file => $file); + is($tf->check_duration, 'long', 'duration set to long'); +}; + +subtest 'HARNESS-CATEGORY sets category' => sub { + my $file = make_test_file("# HARNESS-CATEGORY-ISOLATION\n"); + my $tf = $CLASS->new(file => $file); + is($tf->check_category, 'isolation', 'category set to isolation'); +}; + +subtest 'check_duration defaults to medium (timeout enabled)' => sub { + my $file = make_test_file("# plain\n"); + my $tf = $CLASS->new(file => $file); + is($tf->check_duration, 'medium', 'default duration is medium when timeout enabled'); +}; + +subtest 'check_duration is long when timeout disabled' => sub { + my $file = make_test_file("# HARNESS-NO-TIMEOUT\n"); + my $tf = $CLASS->new(file => $file); + is($tf->check_duration, 'long', 'duration is long when no timeout'); +}; + +subtest 'check_category defaults to general' => sub { + my $file = make_test_file("# plain\n"); + my $tf = $CLASS->new(file => $file); + is($tf->check_category, 'general', 'default category is general'); +}; + +subtest 'HARNESS-CONFLICTS sets conflicts list' => sub { + # Conflicts are stored lowercase + my $file = make_test_file("# HARNESS-CONFLICTS foo::bar baz::qux\n"); + my $tf = $CLASS->new(file => $file); + my $conflicts = $tf->conflicts_list; + ref_ok($conflicts, 'ARRAY', 'conflicts_list returns arrayref'); + ok(scalar(grep { $_ eq 'foo::bar' } @$conflicts), 'foo::bar in conflicts'); +}; + +subtest 'conflicts_list returns empty arrayref when no conflicts' => sub { + my $file = make_test_file("# plain\n"); + my $tf = $CLASS->new(file => $file); + is($tf->conflicts_list, [], 'empty conflicts list'); +}; + +subtest 'HARNESS-TIMEOUT-EVENT sets event timeout' => sub { + my $file = make_test_file("# HARNESS-TIMEOUT-EVENT 120\n"); + my $tf = $CLASS->new(file => $file); + is($tf->event_timeout, 120, 'event_timeout set to 120'); +}; + +subtest 'HARNESS-META stores metadata' => sub { + my $file = make_test_file("# HARNESS-META-mykey myvalue\n"); + my $tf = $CLASS->new(file => $file); + my @vals = $tf->meta('mykey'); + ok(scalar(@vals), 'meta returns values for known key'); + is($vals[0], 'myvalue', 'meta value matches'); +}; + +subtest 'set_duration and set_category' => sub { + my $file = make_test_file("# plain\n"); + my $tf = $CLASS->new(file => $file); + $tf->set_duration('long'); + is($tf->check_duration, 'long', 'set_duration works'); + $tf->set_category('immiscible'); + is($tf->check_category, 'immiscible', 'set_category works'); +}; done_testing; diff --git a/t/unit/Test2/Harness/TestSettings.t b/t/unit/Test2/Harness/TestSettings.t index 6f509385c..aada0bd3b 100644 --- a/t/unit/Test2/Harness/TestSettings.t +++ b/t/unit/Test2/Harness/TestSettings.t @@ -1,5 +1,115 @@ use Test2::V0 -target => 'Test2::Harness::TestSettings'; +use Test2::Util qw/IS_WIN32/; -skip_all "write me"; +subtest 'constructor with no args' => sub { + my $ts = $CLASS->new; + ok($ts->isa($CLASS), 'creates instance with defaults'); +}; + +subtest 'default values' => sub { + my $ts = $CLASS->new; + is($ts->event_timeout, 60, 'default event_timeout is 60'); + is($ts->post_exit_timeout, 15, 'default post_exit_timeout is 15'); + is($ts->lib, 1, 'lib defaults to true'); + is($ts->blib, 1, 'blib defaults to true'); + is($ts->allow_retry, 1, 'allow_retry defaults to true'); + is($ts->event_uuids, 1, 'event_uuids defaults to true'); + is($ts->mem_usage, 1, 'mem_usage defaults to true'); + is($ts->use_stream, 1, 'use_stream defaults to true'); + is($ts->use_timeout, 1, 'use_timeout defaults to true'); +}; + +subtest 'includes — lib and blib by default' => sub { + my $ts = $CLASS->new(lib => 1, blib => 1); + my $inc = $ts->includes; + ref_ok($inc, 'ARRAY', 'includes returns arrayref'); + ok(scalar(grep { $_ eq 'lib' } @$inc), 'lib dir included when lib=1'); +}; + +subtest 'includes — tlib' => sub { + my $ts = $CLASS->new(tlib => 1, lib => 0, blib => 0); + my $inc = $ts->includes; + ok(scalar(grep { m/t[\/\\]lib/ } @$inc), 't/lib included when tlib=1'); +}; + +subtest 'use_preload and use_fork disabled on Windows' => sub { + SKIP: { + skip 'not on Windows' unless IS_WIN32; + my $ts = $CLASS->new(use_preload => 1, use_fork => 1); + is($ts->use_preload, 0, 'use_preload=0 on Windows'); + is($ts->use_fork, 0, 'use_fork=0 on Windows'); + } +}; + +subtest 'use_preload and use_fork enabled on non-Windows' => sub { + SKIP: { + skip 'only on non-Windows' if IS_WIN32; + my $ts = $CLASS->new(use_preload => 1, use_fork => 1); + is($ts->use_preload, 1, 'use_preload=1 on non-Windows when set'); + is($ts->use_fork, 1, 'use_fork=1 on non-Windows when set'); + } +}; + +subtest 'merge — later wins for scalar/bool fields' => sub { + my $a = $CLASS->new(event_timeout => 30, lib => 0); + my $b = $CLASS->new(event_timeout => 90, lib => 1); + my $merged = $CLASS->merge($a, $b); + is($merged->event_timeout, 90, 'later item wins for event_timeout'); + is($merged->lib, 1, 'later item wins for lib'); +}; + +subtest 'merge — arrays deduplicated' => sub { + my $a = $CLASS->new(switches => ['-T', '-w']); + my $b = $CLASS->new(switches => ['-w', '-X']); + my $merged = $CLASS->merge($a, $b); + my %seen; + my @deduped = grep { !$seen{$_}++ } @{$merged->switches}; + is(scalar(@deduped), scalar(@{$merged->switches}), 'no duplicate switches after merge'); + ok(scalar(grep { $_ eq '-T' } @{$merged->switches}), '-T present'); + ok(scalar(grep { $_ eq '-w' } @{$merged->switches}), '-w present'); + ok(scalar(grep { $_ eq '-X' } @{$merged->switches}), '-X present'); +}; + +subtest 'merge — hashes merged' => sub { + my $a = $CLASS->new(env_vars => {FOO => '1', BAR => '2'}); + my $b = $CLASS->new(env_vars => {BAR => '3', BAZ => '4'}); + my $merged = $CLASS->merge($a, $b); + is($merged->env_vars->{FOO}, '1', 'FOO from first'); + is($merged->env_vars->{BAR}, '3', 'BAR overridden by second'); + is($merged->env_vars->{BAZ}, '4', 'BAZ from second'); +}; + +subtest 'merge — propagate_false for use_fork and use_preload' => sub { + SKIP: { + skip 'only on non-Windows' if IS_WIN32; + my $permissive = $CLASS->new(use_fork => 1, use_preload => 1); + my $restrictive = $CLASS->new(use_fork => 0, use_preload => 0); + my $merged = $CLASS->merge($permissive, $restrictive); + is($merged->use_fork, 0, 'use_fork=0 propagates through merge'); + is($merged->use_preload, 0, 'use_preload=0 propagates through merge'); + } +}; + +subtest 'set_env_vars' => sub { + my $ts = $CLASS->new; + $ts->set_env_vars(MY_VAR => 'hello', OTHER => 'world'); + is($ts->env_vars->{MY_VAR}, 'hello', 'MY_VAR set'); + is($ts->env_vars->{OTHER}, 'world', 'OTHER set'); +}; + +subtest 'TO_JSON includes class' => sub { + my $ts = $CLASS->new; + my $json = $ts->TO_JSON; + ref_ok($json, 'HASH', 'TO_JSON returns hashref'); + is($json->{class}, $CLASS, 'TO_JSON includes class'); +}; + +subtest 'load_import includes UUID and MemUsage by default' => sub { + my $ts = $CLASS->new(event_uuids => 1, mem_usage => 1); + my $li = $ts->load_import; + ref_ok($li, 'HASH', 'load_import returns hashref'); + ok(scalar(grep { $_ eq 'Test2::Plugin::UUID' } @{$li->{'@'} // []}), 'UUID plugin present'); + ok(scalar(grep { $_ eq 'Test2::Plugin::MemUsage' } @{$li->{'@'} // []}), 'MemUsage plugin present'); +}; done_testing;