use strict;
use warnings;
use utf8;

use Capture::Tiny qw(capture);
use Cwd qw(abs_path cwd getcwd);
use Encode qw(encode);
use Errno qw(EACCES);
use File::Path qw(make_path remove_tree);
use File::Spec;
use File::Temp qw(tempdir tempfile);
use Socket qw(AF_INET6 inet_pton pack_sockaddr_in6);
use Test::More;

use lib 'lib';

use Developer::Dashboard::Codec qw(encode_payload decode_payload);
use Developer::Dashboard::Collector;
use Developer::Dashboard::CollectorRunner;
use Developer::Dashboard::JSON qw(json_decode json_encode);
use Developer::Dashboard::Config;
use Developer::Dashboard::Doctor;
use Developer::Dashboard::EnvAudit;
use Developer::Dashboard::EnvLoader;
use Developer::Dashboard::File;
use Developer::Dashboard::FileRegistry;
use Developer::Dashboard::Housekeeper;
use Developer::Dashboard::IndicatorStore;
use Developer::Dashboard::InternalCLI;
use Developer::Dashboard::PageDocument;
use Developer::Dashboard::PageStore;
use Developer::Dashboard::PathRegistry;
use Developer::Dashboard::Platform qw(
  command_argv_for_path
  command_in_path
  is_runnable_file
  native_shell_name
  normalize_shell_name
  resolve_runnable_file
  shell_quote_for
  shell_command_argv
);
use Developer::Dashboard::Prompt;
use Developer::Dashboard::Web::App;
use POSIX qw(:sys_wait_h);
use Developer::Dashboard::UpdateManager;

my $platform_lib_root = Developer::Dashboard::Platform::_module_lib_root();

sub _mode_octal {
    my ($path) = @_;
    my @stat = stat($path);
    return undef if !@stat;
    return sprintf '%04o', $stat[2] & 07777;
}

sub dies_like {
    my ( $code, $pattern, $label ) = @_;
    my $error = eval { $code->(); 1 } ? '' : $@;
    like( $error, $pattern, $label );
}

sub _portable_path {
    my ($path) = @_;
    return undef if !defined $path;
    my $resolved = eval { abs_path($path) };
    return defined $resolved && $resolved ne '' ? $resolved : $path;
}

sub is_same_path {
    my ( $got, $expected, $label ) = @_;
    is( _portable_path($got), _portable_path($expected), $label );
}

sub is_same_paths {
    my ( $got, $expected, $label ) = @_;
    is_deeply(
        [ map { _portable_path($_) } @{ $got || [] } ],
        [ map { _portable_path($_) } @{ $expected || [] } ],
        $label,
    );
}

sub _child_perl5opt {
    return join ' ', grep { defined $_ && $_ ne '' } ( $ENV{PERL5OPT}, $ENV{HARNESS_PERL_SWITCHES} );
}

is(
    Developer::Dashboard::Web::App::_ajax_content_type('custom-token'),
    'text/plain; charset=utf-8',
    '_ajax_content_type falls back to plain text for unknown symbolic ajax types',
);

my $home = tempdir(CLEANUP => 1);
local $ENV{HOME} = $home;
my $state_root_base = tempdir(CLEANUP => 1);
local $ENV{DEVELOPER_DASHBOARD_STATE_ROOT} = $state_root_base;
local $ENV{DEVELOPER_DASHBOARD_BOOKMARKS};
local $ENV{DEVELOPER_DASHBOARD_CONFIGS};
local $ENV{DEVELOPER_DASHBOARD_CHECKERS};
my $original_cwd = getcwd();

my $workspace = File::Spec->catdir( $home, 'workspace' );
my $projects  = File::Spec->catdir( $home, 'projects' );
make_path($workspace, $projects);
make_path( File::Spec->catdir( $workspace, 'Alpha-App', '.git' ) );
make_path( File::Spec->catdir( $workspace, '.hidden' ) );
make_path( File::Spec->catdir( $projects, 'Alpha-App' ) );
make_path( File::Spec->catdir( $projects, 'Beta App' ) );
my $local_repo = File::Spec->catdir( $home, 'projects', 'Local-App' );
make_path( File::Spec->catdir( $local_repo, '.git' ) );
make_path( File::Spec->catdir( $local_repo, '.developer-dashboard' ) );
chdir $home or die $!;

my $paths = Developer::Dashboard::PathRegistry->new(
    home            => $home,
    app_name        => 'dashboard-test',
    workspace_roots => [ $workspace, $projects ],
    project_roots   => [$projects],
    named_paths     => {
        named => '~/named-path',
    },
);

ok( -d $paths->runtime_root, 'runtime root created' );
ok( -d $paths->state_root, 'state root created' );
ok( -d $paths->cache_root, 'cache root created' );
ok( -d $paths->logs_root, 'logs root created' );
ok( -d $paths->dashboards_root, 'dashboards root created' );
ok( -d $paths->cli_root, 'cli root created' );
ok( -d $paths->collectors_root, 'collectors root created' );
ok( -d $paths->indicators_root, 'indicators root created' );
ok( -d $paths->sessions_root, 'sessions root created' );
ok( -d $paths->temp_root, 'temp root created' );
ok( -d $paths->config_root, 'config root created' );
ok( -d $paths->auth_root, 'auth root created' );
ok( -d $paths->users_root, 'users root created' );
ok( !defined $paths->project_root_for( File::Spec->catdir( $home, 'not-a-repo' ) ), 'project_root_for returns undef outside repos' );
is( _mode_octal( File::Spec->catdir( $home, '.developer-dashboard' ) ), '0700', 'home runtime root is owner-only' );
ok( index( $paths->state_root, $state_root_base ) == 0, 'state root is under the configured temporary state base' );
is( _mode_octal( $paths->state_root ), '0700', 'state root is owner-only' );
ok( !-e File::Spec->catdir( $home, '.developer-dashboard', 'state' ), 'state is not created under home runtime root by default' );
is( _mode_octal( File::Spec->catdir( $home, '.developer-dashboard', 'logs' ) ), '0700', 'home runtime logs root is owner-only' );
is( _mode_octal( File::Spec->catdir( $home, '.developer-dashboard', 'dashboards' ) ), '0700', 'home runtime dashboards root is owner-only' );
is( _mode_octal( File::Spec->catdir( $home, '.developer-dashboard', 'config' ) ), '0700', 'home runtime config root is owner-only' );
is( _mode_octal( File::Spec->catdir( $home, '.developer-dashboard', 'config', 'auth' ) ), '0700', 'home runtime auth root is owner-only' );
is( _mode_octal( File::Spec->catdir( $home, '.developer-dashboard', 'config', 'auth', 'users' ) ), '0700', 'home runtime users root is owner-only' );

{
    local $ENV{DEVELOPER_DASHBOARD_STATE_ROOT};
    local $ENV{XDG_RUNTIME_DIR} = tempdir( CLEANUP => 1 );
    my $state_user = $ENV{DD_STATE_ROOT_USER} || $ENV{USER} || $ENV{LOGNAME} || ( getpwuid($<) || 'user' );
    $state_user =~ s{[^A-Za-z0-9._-]}{_}g;
    my $fallback_paths = Developer::Dashboard::PathRegistry->new(
        home            => $home,
        app_name        => 'dashboard-test',
        workspace_roots => [ $workspace, $projects ],
        project_roots   => [$projects],
    );
    is( index( $fallback_paths->state_root, File::Spec->tmpdir ) == 0, 1, 'state root defaults to the temp runtime directory when state root override is missing' );
    like( $fallback_paths->state_root, qr/\Q$state_user\E/, 'state root is namespaced by current username by default' );
    is( _mode_octal( $fallback_paths->state_root ), '0700', 'defaulted state root remains owner-only' );
}

{
    local $ENV{DEVELOPER_DASHBOARD_STATE_ROOT} = $state_root_base;
    my $before = Developer::Dashboard::PathRegistry->new(
        home            => $home,
        app_name        => 'dashboard-test',
        workspace_roots => [ $workspace, $projects ],
        project_roots   => [$projects],
    );
    my $before_collector = Developer::Dashboard::Collector->new( paths => $before );
    my $legacy_status = $before_collector->write_status(
        'reboot-stale',
        {
            last_exit_code => 0,
            last_run       => '2000-01-01T00:00:00Z',
        }
    );
    ok( -f $legacy_status, 'reboot simulation writes legacy collector status' );
    ok( -f File::Spec->catfile( $before->collectors_root, 'reboot-stale', 'status.json' ), 'legacy collector state is present before reboot simulation' );

    my $before_state_root = $before->state_root;
    remove_tree($before_state_root);

    my $after = Developer::Dashboard::PathRegistry->new(
        home            => $home,
        app_name        => 'dashboard-test',
        workspace_roots => [ $workspace, $projects ],
        project_roots   => [$projects],
    );
    my $after_collector = Developer::Dashboard::Collector->new( paths => $after );
    ok( !defined $after_collector->read_status('reboot-stale'), 'stale collector state disappears after state directory is removed' );
}
{
    my $metadata_paths = Developer::Dashboard::PathRegistry->new(
        home            => $home,
        app_name        => 'dashboard-test',
        workspace_roots => [ $workspace, $projects ],
        project_roots   => [$projects],
    );
    my $metadata_state_root = $metadata_paths->state_root;
    my $metadata_file = File::Spec->catfile( $metadata_state_root, 'runtime.json' );
    ok( -f $metadata_file, 'state root metadata exists before forced temp-state removal' );

    remove_tree($metadata_state_root);
    ok( !-d $metadata_state_root, 'forced temp-state removal deletes the hashed state root' );

    my $rewritten_metadata = $metadata_paths->_write_state_metadata( $metadata_state_root, $metadata_paths->runtime_root );
    ok( -d $metadata_state_root, 'write_state_metadata recreates a missing hashed state root before rewriting metadata' );
    ok( -f $rewritten_metadata, 'write_state_metadata rewrites runtime metadata after the hashed state root disappears' );
    is( $rewritten_metadata, $metadata_file, 'write_state_metadata rewrites the expected metadata file path' );
}
{
    my $secure_home = tempdir(CLEANUP => 1);
    local $ENV{HOME} = $secure_home;
    local $ENV{DEVELOPER_DASHBOARD_BOOKMARKS};
    local $ENV{DEVELOPER_DASHBOARD_CONFIGS};
    local $ENV{DEVELOPER_DASHBOARD_CHECKERS};
    my $secure_paths = Developer::Dashboard::PathRegistry->new( home => $secure_home );
    my $secure_files = Developer::Dashboard::FileRegistry->new( paths => $secure_paths );

    my $written_prompt_log = $secure_files->write( 'prompt_log', "owner only\n" );
    is( _mode_octal($written_prompt_log), '0600', 'home runtime file registry writes owner-only files' );
    $secure_files->append( 'prompt_log', "append\n" );
    is( _mode_octal($written_prompt_log), '0600', 'home runtime file registry appends keep owner-only file mode' );
    my $touched_auth_log = $secure_files->touch('auth_log');
    is( _mode_octal($touched_auth_log), '0600', 'home runtime file registry touch creates owner-only files' );

    my $secure_config = Developer::Dashboard::Config->new(
        files => $secure_files,
        paths => $secure_paths,
    );
    my $saved_secure_global = $secure_config->save_global( { owner_only => 1 } );
    is( _mode_octal($saved_secure_global), '0600', 'global config file is owner-only' );

    my $secure_page_store = Developer::Dashboard::PageStore->new( paths => $secure_paths );
    my $saved_secure_page = $secure_page_store->save_page(
        Developer::Dashboard::PageDocument->from_hash(
            {
                id    => 'permissions-check',
                title => 'Permissions Check',
                html  => 'Blank page',
            }
        )
    );
    is( _mode_octal($saved_secure_page), '0600', 'saved home-runtime bookmark file is owner-only' );

    my $secure_indicator_store = Developer::Dashboard::IndicatorStore->new( paths => $secure_paths );
    my $saved_secure_indicator = $secure_indicator_store->set_indicator( permissions => ( status => 'ok', label => 'Permissions' ) );
    ok( $saved_secure_indicator, 'indicator write succeeds for permission test' );
    is(
        _mode_octal( File::Spec->catfile( $secure_paths->indicator_dir('permissions'), 'status.json' ) ),
        '0600',
        'indicator status file is owner-only',
    );

    my $secure_collector = Developer::Dashboard::Collector->new( paths => $secure_paths );
    my $collector_job_file = $secure_collector->write_job( sample => { name => 'sample', command => 'true' } );
    is( _mode_octal($collector_job_file), '0600', 'collector job file is owner-only' );
    my $collector_status_file = $secure_collector->write_result(
        sample =>
          (
            stdout        => "ok\n",
            stderr        => '',
            exit_code     => 0,
            output_format => 'text',
          )
    );
    is( _mode_octal($collector_status_file), '0600', 'collector status file is owner-only' );
    is(
        _mode_octal( File::Spec->catfile( $secure_paths->collector_dir('sample'), 'stdout' ) ),
        '0600',
        'collector stdout file is owner-only',
    );
    is(
        _mode_octal( File::Spec->catfile( $secure_paths->collector_dir('sample'), 'stderr' ) ),
        '0600',
        'collector stderr file is owner-only',
    );
    is(
        _mode_octal( File::Spec->catfile( $secure_paths->collector_dir('sample'), 'combined' ) ),
        '0600',
        'collector combined file is owner-only',
    );
    is(
        _mode_octal( File::Spec->catfile( $secure_paths->collector_dir('sample'), 'last_run' ) ),
        '0600',
        'collector last_run file is owner-only',
    );

    my $legacy_bookmarks = File::Spec->catdir( $secure_home, 'bookmarks' );
    make_path($legacy_bookmarks);
    chmod 0755, $legacy_bookmarks or die "Unable to chmod $legacy_bookmarks: $!";
    my $legacy_file = File::Spec->catfile( $legacy_bookmarks, 'old.txt' );
    open my $legacy_fh, '>', $legacy_file or die "Unable to write $legacy_file: $!";
    print {$legacy_fh} "legacy\n";
    close $legacy_fh;
    chmod 0644, $legacy_file or die "Unable to chmod $legacy_file: $!";

    my $doctor = Developer::Dashboard::Doctor->new( paths => $secure_paths );
    local $ENV{RESULT};
    delete $ENV{RESULT};
    my $doctor_report = $doctor->run;
    ok( !$doctor_report->{ok}, 'doctor flags legacy permission drift' );
    is_deeply( $doctor_report->{hooks}, {}, 'doctor returns an empty hook set without RESULT data' );
    dies_like( sub { Developer::Dashboard::Doctor->new() }, qr/Missing paths registry/, 'doctor requires paths' );
    ok( !defined $doctor->_permission_issue_for_path(''), 'doctor ignores empty path audit input' );
    ok( !defined $doctor->_permission_issue_for_path( File::Spec->catfile( $secure_home, 'missing-file' ) ), 'doctor ignores missing path audit input' );
    is( Developer::Dashboard::Doctor::_mode_octal( File::Spec->catfile( $secure_home, 'missing-file' ) ), undef, 'doctor mode helper returns undef for missing paths' );
    ok(
        grep(
            { $_->{path} eq $legacy_bookmarks && $_->{expected_mode} eq '0700' }
              @{ $doctor_report->{issues} || [] }
        ),
        'doctor reports insecure legacy bookmark directory mode',
    );
    ok(
        grep(
            { $_->{path} eq $legacy_file && $_->{expected_mode} eq '0600' }
              @{ $doctor_report->{issues} || [] }
        ),
        'doctor reports insecure legacy bookmark file mode',
    );

    my $legacy_exec = File::Spec->catfile( $legacy_bookmarks, 'run.sh' );
    open my $legacy_exec_fh, '>', $legacy_exec or die "Unable to write $legacy_exec: $!";
    print {$legacy_exec_fh} "#!/bin/sh\nexit 0\n";
    close $legacy_exec_fh;
    chmod 0755, $legacy_exec or die "Unable to chmod $legacy_exec: $!";
    is(
        $doctor->_permission_issue_for_path($legacy_exec)->{expected_mode},
        '0700',
        'doctor expects owner-only executable mode for executable files',
    );

    {
        local $ENV{RESULT} = '[1]';
        dies_like( sub { $doctor->_doctor_hook_results }, qr/Doctor hook RESULT must decode to a hash/, 'doctor rejects non-hash hook RESULT payloads' );
    }
    {
        local $ENV{RESULT} = '{"00-hook.pl":{"exit_code":2}}';
        is( $doctor->run->{hook_failures}, 1, 'doctor counts non-zero hook exits as failures' );
    }
    dies_like( sub { $doctor->_audit_root( label => 'bad' ) }, qr/Missing audit root path/, 'doctor audit_root requires a path' );
    dies_like( sub { $doctor->_audit_root( path => $legacy_bookmarks ) }, qr/Missing audit root label/, 'doctor audit_root requires a label' );

    my $fixed_doctor_report = $doctor->run( fix => 1 );
    ok( !$fixed_doctor_report->{ok}, 'doctor fix report still records the repaired findings from this run' );
    is( _mode_octal($legacy_bookmarks), '0700', 'doctor --fix tightens legacy bookmark directory permissions' );
    is( _mode_octal($legacy_file), '0600', 'doctor --fix tightens legacy bookmark file permissions' );
    my $post_fix_report = $doctor->run;
    ok( $post_fix_report->{ok}, 'doctor reports success after fixes are applied' );

    Developer::Dashboard::InternalCLI::ensure_helpers( paths => $secure_paths );
    my $managed_core = File::Spec->catfile( $secure_paths->cli_root, 'dd', '_dashboard-core' );
    my $expected_core = Developer::Dashboard::InternalCLI::_managed_helper_content('_dashboard-core');
    like(
        $expected_core,
        qr/^# developer-dashboard-managed-helper-version: \Q$Developer::Dashboard::InternalCLI::VERSION\E$/m,
        'doctor helper drift checks compare against helper bodies stamped with the current managed-helper version marker',
    );
    my $stale_core = $expected_core;
    $stale_core .= "\n# stale helper drift\n";
    open my $managed_core_fh, '>:raw', $managed_core or die "Unable to write $managed_core: $!";
    print {$managed_core_fh} $stale_core;
    close $managed_core_fh or die "Unable to close $managed_core: $!";

    my $helper_report = $doctor->run;
    ok( !$helper_report->{ok}, 'doctor flags stale managed helper content' );
    ok(
        grep(
            {
                $_->{path} eq $managed_core
                  && $_->{kind} eq 'helper'
                  && $_->{problem} eq 'stale managed helper content'
            } @{ $helper_report->{issues} || [] }
        ),
        'doctor reports stale _dashboard-core helper drift explicitly',
    );

    my $helper_fixed = $doctor->run( fix => 1 );
    ok( !$helper_fixed->{ok}, 'doctor --fix still reports helper drift repaired in the current run' );
    ok(
        grep(
            {
                $_->{path} eq $managed_core
                  && $_->{kind} eq 'helper'
                  && $_->{fixed}
            } @{ $helper_fixed->{issues} || [] }
        ),
        'doctor marks stale managed helper drift as fixed after restaging',
    );
    open my $managed_core_verify_fh, '<:raw', $managed_core or die "Unable to read $managed_core: $!";
    local $/;
    my $managed_core_verify = <$managed_core_verify_fh>;
    close $managed_core_verify_fh or die "Unable to close $managed_core: $!";
    is( $managed_core_verify, $expected_core, 'doctor --fix restages the current _dashboard-core helper body' );
    ok( $doctor->run->{ok}, 'doctor reports success after repairing stale helper drift' );

    my $bashrc = File::Spec->catfile( $secure_home, '.bashrc' );
    my $local_lib_line = qq{eval "\$("/usr/bin/perl" -I "$secure_home/perl5/lib/perl5" -Mlocal::lib)"};
    my $dashboard_line = qq{eval "\$("$secure_home/perl5/bin/dashboard" shell bash)"};
    open my $bashrc_fh, '>', $bashrc or die "Unable to write $bashrc: $!";
    print {$bashrc_fh} <<"BASHRC";
case \$- in
    *i*) ;;
      *) return;;
esac
$local_lib_line
$dashboard_line
BASHRC
    close $bashrc_fh or die "Unable to close $bashrc: $!";

    my $bashrc_report = $doctor->run;
    ok( !$bashrc_report->{ok}, 'doctor flags dashboard-managed bash bootstrap hidden behind the non-interactive return guard' );
    ok(
        grep(
            {
                $_->{path} eq $bashrc
                  && $_->{kind} eq 'shell-bootstrap'
                  && $_->{problem} eq 'dashboard-managed bash bootstrap is hidden behind the non-interactive return guard'
            } @{ $bashrc_report->{issues} || [] }
        ),
        'doctor reports misplaced bash bootstrap lines explicitly',
    );

    my $bashrc_fixed = $doctor->run( fix => 1 );
    ok( !$bashrc_fixed->{ok}, 'doctor --fix reports the bash bootstrap issue it repaired in the current run' );
    ok(
        grep(
            {
                $_->{path} eq $bashrc
                  && $_->{kind} eq 'shell-bootstrap'
                  && $_->{fixed}
            } @{ $bashrc_fixed->{issues} || [] }
        ),
        'doctor marks the misplaced bash bootstrap issue as fixed after repair',
    );
    open my $bashrc_read_fh, '<', $bashrc or die "Unable to read $bashrc: $!";
    local $/;
    my $bashrc_text = <$bashrc_read_fh>;
    close $bashrc_read_fh or die "Unable to close $bashrc: $!";
    like(
        $bashrc_text,
        qr/\Q$local_lib_line\E\n\Q$dashboard_line\E\ncase \$- in/s,
        'doctor --fix moves dashboard-managed bash bootstrap lines ahead of the non-interactive return guard',
    );
    ok( $doctor->run->{ok}, 'doctor reports success after repairing the misplaced bash bootstrap lines' );

    open my $bashrc_ps1_guard_fh, '>', $bashrc or die "Unable to rewrite $bashrc: $!";
    print {$bashrc_ps1_guard_fh} <<"BASHRC";
[ -z "\$PS1" ] && return
$local_lib_line
$dashboard_line
BASHRC
    close $bashrc_ps1_guard_fh or die "Unable to close $bashrc after rewriting: $!";

    my $bashrc_ps1_report = $doctor->run;
    ok( !$bashrc_ps1_report->{ok}, 'doctor also flags dashboard-managed bash bootstrap hidden behind the single-line PS1 return guard' );

    my $bashrc_ps1_fixed = $doctor->run( fix => 1 );
    ok( !$bashrc_ps1_fixed->{ok}, 'doctor --fix reports the single-line PS1 guard issue it repaired in the current run' );
    open my $bashrc_ps1_read_fh, '<', $bashrc or die "Unable to read $bashrc after PS1-guard repair: $!";
    local $/;
    my $bashrc_ps1_text = <$bashrc_ps1_read_fh>;
    close $bashrc_ps1_read_fh or die "Unable to close $bashrc after PS1-guard repair: $!";
    like(
        $bashrc_ps1_text,
        qr/\Q$local_lib_line\E\n\Q$dashboard_line\E\n\[ -z "\$PS1" \] && return/s,
        'doctor --fix moves dashboard-managed bash bootstrap lines ahead of the single-line PS1 return guard',
    );
    ok( $doctor->run->{ok}, 'doctor reports success after repairing the single-line PS1 guard form too' );

    my $fake_module_root = tempdir( CLEANUP => 1 );
    my $fake_private_cli = File::Spec->catdir( $fake_module_root, 'auto', 'Developer', 'Dashboard', 'private-cli' );
    make_path($fake_private_cli);
    my $fake_core = File::Spec->catfile( $fake_private_cli, '_dashboard-core' );
    open my $fake_core_fh, '>:raw', $fake_core or die "Unable to write $fake_core: $!";
    print {$fake_core_fh} "#!/usr/bin/env perl\n# fake packaged helper\n";
    close $fake_core_fh or die "Unable to close $fake_core: $!";

    {
        no warnings 'redefine';
        local *Developer::Dashboard::InternalCLI::_repo_private_cli_root = sub { File::Spec->catdir( $fake_module_root, 'missing-repo' ) };
        local *Developer::Dashboard::InternalCLI::_repo_private_cli_root_candidates = sub { () };
        local *Developer::Dashboard::InternalCLI::dist_dir = sub { die "no dist root" };
        local *Developer::Dashboard::InternalCLI::_module_install_lib_root = sub { $fake_module_root };
        is(
            Developer::Dashboard::InternalCLI::_helper_asset_path('_dashboard-core'),
            $fake_core,
            '_helper_asset_path finds packaged helper assets under the build-tree auto/Developer/Dashboard/private-cli root',
        );
    }

    my $fake_build_root = tempdir( CLEANUP => 1 );
    my $fake_blib_module_dir = File::Spec->catdir( $fake_build_root, 'blib', 'lib', 'Developer', 'Dashboard' );
    my $fake_share_private_cli = File::Spec->catdir( $fake_build_root, 'share', 'private-cli' );
    make_path($fake_blib_module_dir);
    make_path($fake_share_private_cli);
    my $fake_build_core = File::Spec->catfile( $fake_share_private_cli, '_dashboard-core' );
    open my $fake_build_core_fh, '>:raw', $fake_build_core or die "Unable to write $fake_build_core: $!";
    print {$fake_build_core_fh} "#!/usr/bin/env perl\n# fake blib helper\n";
    close $fake_build_core_fh or die "Unable to close $fake_build_core: $!";

    {
        no warnings 'redefine';
        local *Developer::Dashboard::InternalCLI::dirname = sub { $fake_blib_module_dir };
        is(
            Developer::Dashboard::InternalCLI::_repo_private_cli_root(),
            $fake_share_private_cli,
            '_repo_private_cli_root walks above blib/lib to the checkout share/private-cli tree during packaged build tests',
        );
    }

    my $fake_cpanm_build_root = tempdir( CLEANUP => 1 );
    my $fake_cpanm_module_path = File::Spec->catfile(
        $fake_cpanm_build_root, 'blib', 'lib', 'Developer', 'Dashboard', 'InternalCLI.pm'
    );
    my $fake_cpanm_share_private_cli = File::Spec->catdir( $fake_cpanm_build_root, 'share', 'private-cli' );
    make_path( File::Basename::dirname($fake_cpanm_module_path) );
    make_path($fake_cpanm_share_private_cli);
    my $fake_cpanm_core = File::Spec->catfile( $fake_cpanm_share_private_cli, '_dashboard-core' );
    open my $fake_cpanm_core_fh, '>:raw', $fake_cpanm_core
      or die "Unable to write $fake_cpanm_core: $!";
    print {$fake_cpanm_core_fh} "#!/usr/bin/env perl\n# fake cpanm blib helper\n";
    close $fake_cpanm_core_fh or die "Unable to close $fake_cpanm_core: $!";

    {
        no warnings 'redefine';
        local *Developer::Dashboard::InternalCLI::_module_source_path = sub { $fake_cpanm_module_path };
        is(
            Developer::Dashboard::InternalCLI::_repo_private_cli_root(),
            $fake_cpanm_share_private_cli,
            '_repo_private_cli_root finds checkout share/private-cli from a cpanm-style blib/lib module path',
        );
    }
    {
        no warnings 'redefine';
        local *Developer::Dashboard::InternalCLI::_module_source_path = sub { $fake_cpanm_module_path };
        local *Developer::Dashboard::InternalCLI::_repo_private_cli_root = sub {
            return File::Spec->catdir( $fake_cpanm_build_root, 'missing-repo-private-cli' );
        };
        local *Developer::Dashboard::InternalCLI::dist_dir = sub {
            return File::Spec->catdir( $fake_cpanm_build_root, 'blib', 'lib', 'auto', 'Developer', 'Dashboard' );
        };
        local *Developer::Dashboard::InternalCLI::_module_install_lib_root = sub {
            return File::Spec->catdir( $fake_cpanm_build_root, 'blib', 'lib' );
        };
        is(
            Developer::Dashboard::InternalCLI::_helper_asset_path('_dashboard-core'),
            $fake_cpanm_core,
            '_helper_asset_path rescans module-source share/private-cli candidates before falling back to broken blib auto helper roots in cpanm build trees',
        );
    }

    my $captured_module_source = Developer::Dashboard::InternalCLI::_module_source_path();
    ok(
        File::Spec->file_name_is_absolute($captured_module_source),
        '_module_source_path is captured as an absolute path at module load time',
    );
    my $cwd_before_module_source_check = getcwd();
    my $module_source_cwd = tempdir( CLEANUP => 1 );
    chdir $module_source_cwd or die "Unable to chdir to $module_source_cwd: $!";
    is(
        Developer::Dashboard::InternalCLI::_module_source_path(),
        $captured_module_source,
        '_module_source_path stays anchored after later cwd changes inside long-running test processes',
    );
    chdir $cwd_before_module_source_check
      or die "Unable to chdir back to $cwd_before_module_source_check: $!";

    {
        local $INC{'Developer/Dashboard/InternalCLI.pm'};
        is(
            Cwd::abs_path( Developer::Dashboard::InternalCLI::_module_install_lib_root() ),
            Cwd::abs_path(
                File::Spec->catdir(
                    File::Basename::dirname(__FILE__),
                    File::Spec->updir,
                    'lib',
                )
            ),
            '_module_install_lib_root falls back to the loaded source file path when %INC has no InternalCLI entry',
        );
    }

    is_deeply(
        Developer::Dashboard::InternalCLI::helper_aliases(),
        {
            pjq    => 'jq',
            pyq    => 'yq',
            ptomq  => 'tomq',
            pjp    => 'propq',
            ticket => 'workspace',
            skill  => 'skills',
            logs   => 'log',
        },
        'helper_aliases returns the shipped compatibility alias map',
    );

    my $plain_helper_body = "#!/usr/bin/env perl\nprint qq|ok\\n|;\n";
    my $legacy_core_helper_body = join "\n",
      '#!/usr/bin/env perl',
      'die "Missing built-in dashboard command";',
      'use Developer::Dashboard::CLI::SeededPages;',
      q{};
    my $legacy_lazy_helper_body = join "\n",
      '#!/usr/bin/env perl',
      '# LAZY-THIN-CMD',
      '# Developer Dashboard',
      q{};
    ok(
        Developer::Dashboard::InternalCLI::_is_dashboard_managed_helper(
            $legacy_core_helper_body,
            '_dashboard-core',
        ),
        '_is_dashboard_managed_helper recognizes the legacy _dashboard-core helper shape',
    );
    ok(
        Developer::Dashboard::InternalCLI::_is_dashboard_managed_helper(
            $legacy_lazy_helper_body,
            'shell',
        ),
        '_is_dashboard_managed_helper recognizes older lazy-thin helper bodies without the newer managed marker',
    );
    ok(
        !Developer::Dashboard::InternalCLI::_is_dashboard_managed_helper(
            $plain_helper_body,
            'shell',
        ),
        '_is_dashboard_managed_helper rejects unrelated helper bodies',
    );

    {
        no warnings 'redefine';
        local *Developer::Dashboard::InternalCLI::helper_content = sub { "print qq|ok\\n|;\n" };
        my $managed_plain_helper = Developer::Dashboard::InternalCLI::_managed_helper_content('jq');
        like(
            $managed_plain_helper,
            qr/\A\Q# developer-dashboard-managed-helper: jq\E\n\Q# developer-dashboard-managed-helper-version: $Developer::Dashboard::InternalCLI::VERSION\E\nprint qq\|ok\\n\|;/,
            '_managed_helper_content prefixes the managed and helper-version markers when the helper body has no shebang line',
        );
    }

    ok(
        Developer::Dashboard::InternalCLI::_looks_like_private_cli_root($fake_private_cli),
        '_looks_like_private_cli_root accepts a private-cli directory that contains _dashboard-core',
    );

    my $managed_root_target = File::Spec->catfile( $paths->home_runtime_root, 'cli', 'dd' );
    my $managed_child_target = File::Spec->catfile( $managed_root_target, 'shell' );
    ok(
        Developer::Dashboard::InternalCLI::_is_managed_helper_target( $paths, $managed_root_target ),
        '_is_managed_helper_target accepts the managed helper namespace root itself',
    );
    ok(
        Developer::Dashboard::InternalCLI::_is_managed_helper_target( $paths, $managed_child_target ),
        '_is_managed_helper_target accepts helper files beneath the managed helper namespace root',
    );

    {
        no warnings 'redefine';
        local *Developer::Dashboard::InternalCLI::_repo_private_cli_root = sub { File::Spec->catdir( $fake_module_root, 'missing-repo' ) };
        local *Developer::Dashboard::InternalCLI::_repo_private_cli_root_candidates = sub { () };
        local *Developer::Dashboard::InternalCLI::_shared_private_cli_root = sub { $fake_private_cli };
        local *Developer::Dashboard::InternalCLI::_shared_private_cli_root_candidates = sub { () };
        is(
            Developer::Dashboard::InternalCLI::_helper_asset_path('_dashboard-core'),
            $fake_core,
            '_helper_asset_path falls back to the shared private-cli root when repo and candidate scans miss the helper asset',
        );
    }

    {
        no warnings 'redefine';
        local *Developer::Dashboard::InternalCLI::_shared_private_cli_root_candidates = sub {
            return (
                File::Spec->catdir( $fake_module_root, 'missing-private-cli' ),
                $fake_private_cli,
            );
        };
        is(
            Developer::Dashboard::InternalCLI::_shared_private_cli_root(),
            $fake_private_cli,
            '_shared_private_cli_root returns the first candidate that actually contains _dashboard-core',
        );
    }

    {
        my $fake_empty_private_cli = File::Spec->catdir( $fake_module_root, 'empty-private-cli' );
        make_path($fake_empty_private_cli);
        no warnings 'redefine';
        local *Developer::Dashboard::InternalCLI::_shared_private_cli_root_candidates = sub {
            return (
                $fake_empty_private_cli,
                File::Spec->catdir( $fake_module_root, 'still-missing-private-cli' ),
            );
        };
        is(
            Developer::Dashboard::InternalCLI::_shared_private_cli_root(),
            $fake_empty_private_cli,
            '_shared_private_cli_root falls back to the first candidate when none of them contain _dashboard-core yet',
        );
    }

    {
        no warnings 'redefine';
        local *Developer::Dashboard::InternalCLI::dist_dir = sub { return $fake_private_cli };
        local *Developer::Dashboard::InternalCLI::_module_install_lib_root = sub { return '' };
        local $ENV{HOME} = '';
        is_deeply(
            [ Developer::Dashboard::InternalCLI::_shared_private_cli_root_candidates() ],
            [
                File::Spec->catdir( $fake_private_cli, 'private-cli' ),
                $fake_private_cli,
            ],
            '_shared_private_cli_root_candidates keeps the explicit dist root when File::ShareDir already resolves the private-cli root itself',
        );
    }

    {
        no warnings 'redefine';
        local *Developer::Dashboard::InternalCLI::dist_dir = sub { return '' };
        local *Developer::Dashboard::InternalCLI::_module_install_lib_root = sub { return '' };
        local *Developer::Dashboard::InternalCLI::_home_private_cli_root_candidates = sub {
            return ( '', undef, $fake_private_cli, $fake_private_cli );
        };
        is_deeply(
            [ Developer::Dashboard::InternalCLI::_shared_private_cli_root_candidates() ],
            [$fake_private_cli],
            '_shared_private_cli_root_candidates filters empty and duplicate helper roots before returning them',
        );
    }

    {
        my $tmp = tempdir( CLEANUP => 1 );
        my $source = File::Spec->catfile( $tmp, 'helper.pending' );
        my $target = File::Spec->catfile( $tmp, 'helper' );
        open my $source_fh, '>:raw', $source or die "Unable to write $source: $!";
        print {$source_fh} "pending helper\n";
        close $source_fh or die "Unable to close $source: $!";
        open my $target_fh, '>:raw', $target or die "Unable to seed $target: $!";
        print {$target_fh} "stale helper\n";
        close $target_fh or die "Unable to close $target: $!";

        my @rename_calls;
        my @unlink_calls;
        my $error = '';
        {
            no warnings 'redefine';
            local *Developer::Dashboard::InternalCLI::is_windows = sub { return 1 };
            local *Developer::Dashboard::InternalCLI::_rename_path = sub {
                my ( $from, $to ) = @_;
                push @rename_calls, [ $from, $to ];
                $! = EACCES;
                return 0;
            };
            local *Developer::Dashboard::InternalCLI::_unlink_path = sub {
                my ($path) = @_;
                push @unlink_calls, $path;
                return unlink $path;
            };
            eval { Developer::Dashboard::InternalCLI::_replace_helper_file( $source, $target ); 1 } or $error = $@;
        }

        like( $error, qr/Unable to rename \Q$source\E to \Q$target\E: Permission denied/, '_replace_helper_file surfaces the final Windows helper rename failure after the retry also fails' );
        is( scalar @rename_calls, 2, '_replace_helper_file attempts the Windows helper rename twice before failing' );
        is_deeply( \@unlink_calls, [ $target, $source ], '_replace_helper_file removes the stale target and then cleans up the pending helper file after the retry fails' );
    }
}

is( $paths->home, $home, 'home accessor works' );
is( $paths->app_name, 'dashboard-test', 'custom app name set' );
is( Developer::Dashboard::PathRegistry->new( home => $home )->app_name, 'developer-dashboard', 'default app name set' );
is_deeply( [ $paths->workspace_roots ], [ $workspace, $projects ], 'workspace roots returned' );
is_deeply( [ $paths->project_roots ], [$projects], 'project roots returned' );

dies_like(
    sub {
        local $ENV{HOME};
        Developer::Dashboard::PathRegistry->new( home => undef );
    },
    qr/Missing home directory/,
    'path registry requires a home directory',
);

{
    local $ENV{HOME};
    local $ENV{USERPROFILE} = $home;
    is(
        Developer::Dashboard::PathRegistry->new( home => undef )->home,
        $home,
        'path registry falls back to USERPROFILE when HOME is missing on Windows-shaped environments',
    );
}

{
    local $ENV{HOME};
    local $ENV{USERPROFILE};
    local $ENV{HOMEDRIVE} = 'C:';
    local $ENV{HOMEPATH}  = '\\Users\\Docker';
    is(
        Developer::Dashboard::PathRegistry->new( home => undef )->home,
        File::Spec->catdir( 'C:', '\\Users\\Docker' ),
        'path registry falls back to HOMEDRIVE and HOMEPATH when HOME and USERPROFILE are missing',
    );
}

my $resolved_home = $paths->resolve_dir('home');
is( $resolved_home, $home, 'resolve_dir resolves method-backed names' );
is( $paths->resolve_dir('bookmarks'), $paths->dashboards_root, 'resolve_dir accepts legacy bookmarks alias' );
is( $paths->resolve_dir('bookmarks_root'), $paths->dashboards_root, 'resolve_dir accepts legacy bookmarks_root alias' );
is( $paths->resolve_dir('cli_root'), $paths->cli_root, 'resolve_dir accepts cli_root' );
is( $paths->resolve_dir('sessions_root'), $paths->sessions_root, 'resolve_dir accepts sessions_root' );
is( $paths->resolve_dir('/tmp'), '/tmp', 'resolve_dir returns absolute paths as-is' );
is( $paths->resolve_dir('named'), File::Spec->catdir( $home, 'named-path' ), 'resolve_dir expands named paths' );
is_deeply( $paths->named_paths, { named => '~/named-path' }, 'named_paths exposes registered aliases' );
$paths->register_named_paths( { extra => '~/extra-path' } );
is( $paths->resolve_dir('extra'), File::Spec->catdir( $home, 'extra-path' ), 'register_named_paths adds aliases after construction' );
$paths->unregister_named_path('extra');
dies_like( sub { $paths->resolve_dir('extra') }, qr/Unknown directory name/, 'unregister_named_path removes aliases from the registry' );
dies_like( sub { $paths->resolve_dir('') }, qr/Missing path name/, 'resolve_dir rejects missing names' );
dies_like( sub { $paths->resolve_dir('missing-name') }, qr/Unknown directory name/, 'resolve_dir rejects unknown names' );

my $project_match = $paths->resolve_any( 'missing-name', 'workspace_roots', 'home' );
is( $project_match, $home, 'resolve_any returns first existing directory' );
ok( !defined $paths->resolve_any('missing-name'), 'resolve_any returns undef when nothing resolves' );
{
    chdir $home or die $!;
    is( $paths->repo_dashboard_root, undef, 'repo_dashboard_root returns undef outside a repo' );
    chdir $home or die $!;
}
{
    chdir $local_repo or die $!;
    is_same_path( $paths->project_runtime_root, File::Spec->catdir( $local_repo, '.developer-dashboard' ), 'project_runtime_root resolves only when the repo already contains a dashboard root' );
    is_same_path( $paths->runtime_root, File::Spec->catdir( $local_repo, '.developer-dashboard' ), 'runtime_root prefers the project-local dashboard root when present' );
    is_same_paths(
        [ $paths->runtime_layers ],
        [
            File::Spec->catdir( $home, '.developer-dashboard' ),
            File::Spec->catdir( $local_repo, '.developer-dashboard' ),
        ],
        'runtime_layers returns the inherited runtime chain from home to the active project layer',
    );
    is_same_paths(
        [ $paths->runtime_roots ],
        [
            File::Spec->catdir( $local_repo, '.developer-dashboard' ),
            File::Spec->catdir( $home, '.developer-dashboard' ),
        ],
        'runtime_roots returns project-local then home fallback roots',
    );
    is_same_path( $paths->dashboards_root, File::Spec->catdir( $local_repo, '.developer-dashboard', 'dashboards' ), 'dashboards_root writes to the project-local runtime when present' );
    is_same_path( $paths->cli_root, File::Spec->catdir( $local_repo, '.developer-dashboard', 'cli' ), 'cli_root writes to the project-local runtime when present' );
    is_same_path( $paths->config_root, File::Spec->catdir( $local_repo, '.developer-dashboard', 'config' ), 'config_root writes to the project-local runtime when present' );
    is_same_path( $paths->users_root, File::Spec->catdir( $local_repo, '.developer-dashboard', 'config', 'auth', 'users' ), 'users_root writes to the project-local runtime when present' );
    is_same_path( $paths->sessions_root, File::Spec->catdir( $paths->state_root, 'sessions' ), 'sessions_root writes to the project-local runtime when present' );
    chdir $home or die $!;
}
{
    chdir $local_repo or die $!;
    is_same_paths(
        [ $paths->cli_roots ],
        [
            File::Spec->catdir( $local_repo, '.developer-dashboard', 'cli' ),
            File::Spec->catdir( $home, '.developer-dashboard', 'cli' ),
        ],
        'cli_roots returns project-local then home fallback roots',
    );
    is_same_paths(
        [ $paths->cli_layers ],
        [
            File::Spec->catdir( $home, '.developer-dashboard', 'cli' ),
            File::Spec->catdir( $local_repo, '.developer-dashboard', 'cli' ),
        ],
        'cli_layers returns home then project-local CLI inheritance roots',
    );
    is_same_paths(
        [ $paths->config_layers ],
        [
            File::Spec->catdir( $home, '.developer-dashboard', 'config' ),
            File::Spec->catdir( $local_repo, '.developer-dashboard', 'config' ),
        ],
        'config_layers returns home then project-local config inheritance roots',
    );
    chdir $home or die $!;
}
{
    my $layer_root = File::Spec->catdir( $home, 'dd-oop-layers' );
    my $layer_parent = File::Spec->catdir( $layer_root, 'parent' );
    my $layer_leaf = File::Spec->catdir( $layer_parent, 'leaf' );
    make_path( File::Spec->catdir( $layer_root, '.developer-dashboard' ) );
    make_path( File::Spec->catdir( $layer_parent, '.developer-dashboard' ) );
    make_path( File::Spec->catdir( $layer_leaf, '.developer-dashboard' ) );
    chdir $layer_leaf or die $!;

    my $layer_paths = Developer::Dashboard::PathRegistry->new( home => $home );
    is_same_paths(
        [ $layer_paths->runtime_layers ],
        [
            File::Spec->catdir( $home, '.developer-dashboard' ),
            File::Spec->catdir( $layer_root, '.developer-dashboard' ),
            File::Spec->catdir( $layer_parent, '.developer-dashboard' ),
            File::Spec->catdir( $layer_leaf, '.developer-dashboard' ),
        ],
        'runtime_layers walks every .developer-dashboard ancestor from home to the current leaf layer',
    );
    is_same_paths(
        [ $layer_paths->runtime_roots ],
        [
            File::Spec->catdir( $layer_leaf, '.developer-dashboard' ),
            File::Spec->catdir( $layer_parent, '.developer-dashboard' ),
            File::Spec->catdir( $layer_root, '.developer-dashboard' ),
            File::Spec->catdir( $home, '.developer-dashboard' ),
        ],
        'runtime_roots keeps deepest-first lookup order across every discovered layer',
    );
    is_same_path( $layer_paths->runtime_root, File::Spec->catdir( $layer_leaf, '.developer-dashboard' ), 'runtime_root writes to the deepest discovered layer' );
    chdir $home or die $!;
}
{
    my $outside_root = tempdir( CLEANUP => 1 );
    my $outside_parent = File::Spec->catdir( $outside_root, 'parent' );
    my $outside_leaf = File::Spec->catdir( $outside_parent, 'leaf' );
    system( 'git', 'init', '-q', $outside_root ) == 0 or die 'Unable to initialize outside-home git fixture';
    make_path( File::Spec->catdir( $outside_parent, '.developer-dashboard' ) );
    make_path( File::Spec->catdir( $outside_leaf, '.developer-dashboard' ) );
    chdir $outside_leaf or die $!;

    my $outside_paths = Developer::Dashboard::PathRegistry->new( home => $home );
    is_same_paths(
        [ $outside_paths->runtime_layers ],
        [
            File::Spec->catdir( $home, '.developer-dashboard' ),
            File::Spec->catdir( $outside_parent, '.developer-dashboard' ),
            File::Spec->catdir( $outside_leaf, '.developer-dashboard' ),
        ],
        'runtime_layers still walks current and parent .developer-dashboard layers when the working tree lives outside HOME',
    );
    chdir $home or die $!;
}
{
    no warnings 'redefine';
    local *Developer::Dashboard::PathRegistry::cwd = sub { return undef; };
    my @warnings;
    local $SIG{__WARN__} = sub { push @warnings, @_ };
    my $paths_without_cwd = Developer::Dashboard::PathRegistry->new( home => $home );
    is_same_paths(
        [ $paths_without_cwd->runtime_layers ],
        [ File::Spec->catdir( $home, '.developer-dashboard' ) ],
        'runtime_layers falls back to the home layer when cwd is unavailable',
    );
    is_deeply( \@warnings, [], 'runtime_layers does not warn when cwd is unavailable' );
}
{
    no warnings 'redefine';
    local *Developer::Dashboard::PathRegistry::cwd = sub { die "cwd should not be called when the registry was constructed with one\n"; };
    my $precomputed_cwd_paths = Developer::Dashboard::PathRegistry->new(
        home => $home,
        cwd  => $local_repo,
    );
    is_same_path(
        $precomputed_cwd_paths->current_project_root,
        $local_repo,
        'current_project_root uses the precomputed cwd supplied to the registry constructor',
    );
    is_same_paths(
        [ $precomputed_cwd_paths->runtime_layers ],
        [
            File::Spec->catdir( $home, '.developer-dashboard' ),
            File::Spec->catdir( $local_repo, '.developer-dashboard' ),
        ],
        'runtime_layers uses the precomputed cwd supplied to the registry constructor',
    );
}
{
    my $outside_no_repo = tempdir( CLEANUP => 1 );
    no warnings 'redefine';
    local *Developer::Dashboard::PathRegistry::cwd = sub { return $outside_no_repo; };
    local *Developer::Dashboard::PathRegistry::current_project_root = sub { return undef; };
    is_same_paths(
        [ Developer::Dashboard::PathRegistry->new( home => $home )->runtime_layers ],
        [ File::Spec->catdir( $home, '.developer-dashboard' ) ],
        'runtime_layers falls back to the home layer when cwd is outside HOME and no project layer applies',
    );
}
{
    my $real_home = tempdir( CLEANUP => 1 );
    my $alias_parent = tempdir( CLEANUP => 1 );
    my $alias_home = File::Spec->catdir( $alias_parent, 'home-alias' );
    SKIP: {
        skip 'symlink regression requires symlink support', 1 if !eval { symlink( $real_home, $alias_home ); 1 };
        make_path( File::Spec->catdir( $real_home, '.developer-dashboard' ) );
        make_path( File::Spec->catdir( $real_home, 'dd-oop-layers', 'parent', '.developer-dashboard' ) );
        make_path( File::Spec->catdir( $real_home, 'dd-oop-layers', 'parent', 'leaf', '.developer-dashboard' ) );
        my $real_leaf = File::Spec->catdir( $real_home, 'dd-oop-layers', 'parent', 'leaf' );
        no warnings 'redefine';
        local *Developer::Dashboard::PathRegistry::cwd = sub { return $real_leaf; };
        local *Developer::Dashboard::PathRegistry::current_project_root = sub { return undef; };
        my $alias_paths = Developer::Dashboard::PathRegistry->new( home => $alias_home );
        is_same_paths(
            [ $alias_paths->runtime_layers ],
            [
                File::Spec->catdir( $alias_home, '.developer-dashboard' ),
                File::Spec->catdir( $alias_home, 'dd-oop-layers', 'parent', '.developer-dashboard' ),
                File::Spec->catdir( $alias_home, 'dd-oop-layers', 'parent', 'leaf', '.developer-dashboard' ),
            ],
            'runtime_layers survives canonical cwd paths when home is addressed through a symlink alias',
        );
    }
}
{
    my $layer_parent = File::Spec->catdir( $home, 'dirname-guard-parent' );
    my $layer_leaf = File::Spec->catdir( $layer_parent, 'leaf' );
    make_path( File::Spec->catdir( $layer_leaf, '.developer-dashboard' ) );
    no warnings 'redefine';
    local *Developer::Dashboard::PathRegistry::cwd = sub { return $layer_leaf; };
    local *Developer::Dashboard::PathRegistry::dirname = sub { return $_[0] };
    is_same_paths(
        [ Developer::Dashboard::PathRegistry->new( home => $home )->runtime_layers ],
        [
            File::Spec->catdir( $home, '.developer-dashboard' ),
            File::Spec->catdir( $layer_leaf, '.developer-dashboard' ),
        ],
        'runtime_layers stops cleanly when dirname cannot advance to a parent layer',
    );
}
{
    local $ENV{DEVELOPER_DASHBOARD_RUNTIME_LAYERS} = join(
        "\n",
        File::Spec->catdir( $home, '.developer-dashboard' ),
        File::Spec->catdir( $home, 'dd-oop-layers', 'parent', '.developer-dashboard' ),
        File::Spec->catdir( $home, 'dd-oop-layers', 'parent', 'leaf', '.developer-dashboard' ),
    );
    no warnings 'redefine';
    local *Developer::Dashboard::PathRegistry::cwd = sub { return undef; };
    is_same_paths(
        [ Developer::Dashboard::PathRegistry->new( home => $home )->runtime_layers ],
        [
            File::Spec->catdir( $home, '.developer-dashboard' ),
            File::Spec->catdir( $home, 'dd-oop-layers', 'parent', '.developer-dashboard' ),
            File::Spec->catdir( $home, 'dd-oop-layers', 'parent', 'leaf', '.developer-dashboard' ),
        ],
        'runtime_layers honors the explicit runtime layer chain exported by parent processes',
    );
}

{
    local $ENV{DEVELOPER_DASHBOARD_BOOKMARKS} = '~/bookmarks-env';
    is_deeply(
        [ $paths->dashboards_roots ],
        [ File::Spec->catdir( $home, 'bookmarks-env' ) ],
        'dashboards_roots honors the bookmarks environment override',
    );
}
{
    local $ENV{DEVELOPER_DASHBOARD_BOOKMARKS} = '~/bookmarks-layers-env';
    is_deeply(
        [ $paths->dashboards_layers ],
        [ File::Spec->catdir( $home, 'bookmarks-layers-env' ) ],
        'dashboards_layers honors the bookmarks environment override',
    );
}
{
    local $ENV{DEVELOPER_DASHBOARD_CONFIGS} = '~/config-layers-env';
    is_deeply(
        [ $paths->config_layers ],
        [ File::Spec->catdir( $home, 'config-layers-env' ) ],
        'config_layers honors the config environment override',
    );
}

is_deeply(
    Developer::Dashboard::PathRegistry->new( home => $home )->named_paths,
    {},
    'named_paths returns an empty hash when no aliases are configured',
);

{
    local $Developer::Dashboard::Platform::OS_NAME = 'linux';
    is( normalize_shell_name('bash'), 'bash', 'normalize_shell_name keeps bash' );
    is( normalize_shell_name('/usr/bin/zsh'), 'zsh', 'normalize_shell_name strips Unix shell paths' );
    is_deeply( [ shell_command_argv('printf ok', shell => 'sh') ], [ 'sh', '-c', 'printf ok' ], 'shell_command_argv builds non-login POSIX shell argv by default' );
    is_deeply( [ shell_command_argv( 'printf ok', shell => 'sh', login => 1 ) ], [ 'sh', '-lc', 'printf ok' ], 'shell_command_argv can still request a login POSIX shell explicitly' );
    is( shell_quote_for( 'sh', q{O'Hara} ), q{'O'\''Hara'}, 'shell_quote_for escapes POSIX single quotes' );
    dies_like( sub { normalize_shell_name('fish') }, qr/Unsupported shell 'fish'/, 'normalize_shell_name rejects unsupported shells explicitly' );
    {
        no warnings 'redefine';
        local *Developer::Dashboard::Platform::command_in_path = sub {
            my ($name) = @_;
            return '/usr/bin/bash' if $name eq 'bash';
            return undef;
        };
        local $ENV{SHELL} = '';
        is( native_shell_name(), 'bash', 'native_shell_name prefers bash when SHELL is unset and bash is available' );
    }
    {
        no warnings 'redefine';
        local *Developer::Dashboard::Platform::command_in_path = sub {
            my ($name) = @_;
            return '/usr/bin/zsh' if $name eq 'zsh';
            return undef;
        };
        local $ENV{SHELL} = '';
        is( native_shell_name(), 'zsh', 'native_shell_name falls back to zsh when bash is unavailable' );
    }
    {
        no warnings 'redefine';
        local *Developer::Dashboard::Platform::command_in_path = sub { return undef; };
        local $ENV{SHELL} = '';
        is( native_shell_name(), 'sh', 'native_shell_name falls back to sh when no richer POSIX shell is available' );
    }
    {
        no warnings 'redefine';
        local *Developer::Dashboard::Platform::normalize_shell_name = sub { return 'fish'; };
        dies_like( sub { shell_command_argv('printf ok') }, qr/Unsupported shell 'fish'/, 'shell_command_argv rejects unsupported normalized shells explicitly' );
    }
    {
        open my $fh, '>', 'unix-runner.pl' or die $!;
        print {$fh} "#!/usr/bin/env perl\nprint qq{unix-ok\\n};\n";
        close $fh;
        is_deeply(
            [ command_argv_for_path('unix-runner.pl') ],
            [ $^X, '-I', $platform_lib_root, 'unix-runner.pl' ],
            'command_argv_for_path resolves Perl scripts through the current perl interpreter on Unix even when they carry a shebang',
        );
        unlink 'unix-runner.pl' or die $!;
    }
    {
        open my $fh, '>', 'unix-runner' or die $!;
        print {$fh} "#!/usr/bin/env perl\nprint qq{unix-ok\\n};\n";
        close $fh;
        chmod 0755, 'unix-runner' or die $!;
        is_deeply(
            [ command_argv_for_path('unix-runner') ],
            [ $^X, '-I', $platform_lib_root, 'unix-runner' ],
            'command_argv_for_path resolves shebang-only Perl scripts through the current perl interpreter on Unix',
        );
        unlink 'unix-runner' or die $!;
    }
    {
        open my $fh, '>', 'unix-hook.js' or die $!;
        print {$fh} "console.log('ok');\n";
        close $fh;
        chmod 0755, 'unix-hook.js' or die $!;
        {
            no warnings 'redefine';
            local *Developer::Dashboard::Platform::command_in_path = sub {
                my ($name) = @_;
                return '/usr/local/bin/node' if $name eq 'node';
                return undef;
            };
            is_deeply(
                [ command_argv_for_path('unix-hook.js') ],
                [ '/usr/local/bin/node', 'unix-hook.js' ],
                'command_argv_for_path resolves executable JavaScript source files through node',
            );
        }
        is(
            resolve_runnable_file('unix-hook'),
            'unix-hook.js',
            'resolve_runnable_file finds executable JavaScript sources when the logical command name omits the .js suffix',
        );
        unlink 'unix-hook.js' or die $!;
    }
    {
        open my $fh, '>', 'unix-hook.py' or die $!;
        print {$fh} "print('ok')\n";
        close $fh;
        chmod 0755, 'unix-hook.py' or die $!;
        {
            no warnings 'redefine';
            local *Developer::Dashboard::Platform::command_in_path = sub {
                my ($name) = @_;
                return '/usr/local/bin/python' if $name eq 'python';
                return undef;
            };
            is_deeply(
                [ command_argv_for_path('unix-hook.py') ],
                [ '/usr/local/bin/python', 'unix-hook.py' ],
                'command_argv_for_path resolves executable Python source files through python',
            );
        }
        is(
            resolve_runnable_file('unix-hook'),
            'unix-hook.py',
            'resolve_runnable_file finds executable Python sources when the logical command name omits the .py suffix',
        );
        unlink 'unix-hook.py' or die $!;
    }
    {
        open my $fh, '>', 'unix-hook.go' or die $!;
        print {$fh} "package main\nfunc main() {}\n";
        close $fh;
        chmod 0755, 'unix-hook.go' or die $!;
        is_deeply(
            [ command_argv_for_path('unix-hook.go') ],
            [ $^X, '-I', $platform_lib_root, '-MDeveloper::Dashboard::Platform', '-e', 'Developer::Dashboard::Platform::_exec_go_source(@ARGV)', 'unix-hook.go' ],
            'command_argv_for_path resolves executable Go source files through the Go launcher wrapper',
        );
        is(
            resolve_runnable_file('unix-hook'),
            'unix-hook.go',
            'resolve_runnable_file finds executable Go sources when the logical command name omits the .go suffix',
        );
        unlink 'unix-hook.go' or die $!;
    }
    {
        open my $fh, '>', 'unix-hook.java' or die $!;
        print {$fh} "package foo.bar;\nclass HookRunner {}\n";
        close $fh;
        chmod 0755, 'unix-hook.java' or die $!;
        is_deeply(
            [ command_argv_for_path('unix-hook.java') ],
            [ $^X, '-I', $platform_lib_root, '-MDeveloper::Dashboard::Platform', '-e', 'Developer::Dashboard::Platform::_exec_java_source(@ARGV)', 'unix-hook.java' ],
            'command_argv_for_path resolves executable Java source files through the Java launcher wrapper',
        );
        is(
            resolve_runnable_file('unix-hook'),
            'unix-hook.java',
            'resolve_runnable_file finds executable Java sources when the logical command name omits the .java suffix',
        );
        is(
            Developer::Dashboard::Platform::_java_main_class('unix-hook.java'),
            'foo.bar.HookRunner',
            'java main-class resolver reads the declared class name and package from Java source',
        );
        unlink 'unix-hook.java' or die $!;
    }
    {
        open my $fh, '>', 'unix-hook-fallback.java' or die $!;
        print {$fh} "// no explicit class declaration on purpose\n";
        close $fh;
        chmod 0755, 'unix-hook-fallback.java' or die $!;
        is(
            Developer::Dashboard::Platform::_java_main_class('unix-hook-fallback.java'),
            'unix-hook-fallback',
            'java main-class resolver falls back to the source basename when no declared class is found',
        );
        unlink 'unix-hook-fallback.java' or die $!;
    }
    {
        my $fake_bin = File::Spec->catdir( $home, 'platform-fake-bin' );
        make_path($fake_bin);
        my $go_log = File::Spec->catfile( $home, 'fake-go.log' );
        open my $go_fh, '>', File::Spec->catfile( $fake_bin, 'go' ) or die $!;
        print {$go_fh} <<"SH";
#!/bin/sh
printf '%s\\n' "\$@" > '$go_log'
SH
        close $go_fh;
        chmod 0755, File::Spec->catfile( $fake_bin, 'go' ) or die $!;
        open my $src_fh, '>', 'exec-hook.go' or die $!;
        print {$src_fh} "package main\nfunc main() {}\n";
        close $src_fh;
        chmod 0755, 'exec-hook.go' or die $!;
        local $ENV{PATH} = $fake_bin . ':' . ( $ENV{PATH} || '' );
        local $ENV{PERL5OPT} = _child_perl5opt() if _child_perl5opt() =~ /Devel::Cover/;
        my ( undef, undef, $exit_code ) = capture {
            system $^X, '-Ilib', '-MDeveloper::Dashboard::Platform', '-e',
              'Developer::Dashboard::Platform::_exec_go_source(@ARGV)', 'exec-hook.go', 'alpha', 'beta';
            return $? >> 8;
        };
        is( $exit_code, 0, '_exec_go_source execs go run successfully through PATH lookup' );
        open my $go_log_fh, '<', $go_log or die $!;
        is(
            do { local $/; <$go_log_fh> },
            "run\nexec-hook.go\nalpha\nbeta\n",
            '_exec_go_source delegates to go run with passthrough argv',
        );
        close $go_log_fh;
        unlink 'exec-hook.go' or die $!;
    }
    {
        my @go_exec;
        no warnings 'redefine';
        local $Developer::Dashboard::Platform::EXEC_LAUNCHER = sub {
            @go_exec = @_;
            return 1;
        };
        ok( eval { Developer::Dashboard::Platform::_exec_go_source( 'inline-hook.go', 'alpha', 'beta' ); 1 }, '_exec_go_source can be exercised inline through the launcher hook' );
        is_deeply(
            \@go_exec,
            [ 'go', 'run', 'inline-hook.go', 'alpha', 'beta' ],
            '_exec_go_source uses the go launcher with passthrough argv in-process',
        );
        dies_like(
            sub { Developer::Dashboard::Platform::_exec_go_source() },
            qr/Missing Go source path/,
            '_exec_go_source rejects missing source paths',
        );
    }
    {
        my $fake_bin = File::Spec->catdir( $home, 'platform-fake-bin-java' );
        make_path($fake_bin);
        my $javac_log = File::Spec->catfile( $home, 'fake-javac.log' );
        my $java_log = File::Spec->catfile( $home, 'fake-java.log' );
        open my $javac_fh, '>', File::Spec->catfile( $fake_bin, 'javac' ) or die $!;
        print {$javac_fh} <<"SH";
#!/bin/sh
printf '%s\\n' "\$@" > '$javac_log'
SH
        close $javac_fh;
        chmod 0755, File::Spec->catfile( $fake_bin, 'javac' ) or die $!;
        open my $java_fh, '>', File::Spec->catfile( $fake_bin, 'java' ) or die $!;
        print {$java_fh} <<"SH";
#!/bin/sh
printf '%s\\n' "\$@" > '$java_log'
SH
        close $java_fh;
        chmod 0755, File::Spec->catfile( $fake_bin, 'java' ) or die $!;
        open my $src_fh, '>', 'exec-hook.java' or die $!;
        print {$src_fh} "package foo.bar;\nclass HookRunner { public static void main(String[] args) {} }\n";
        close $src_fh;
        chmod 0755, 'exec-hook.java' or die $!;
        local $ENV{PATH} = $fake_bin . ':' . ( $ENV{PATH} || '' );
        local $ENV{PERL5OPT} = _child_perl5opt() if _child_perl5opt() =~ /Devel::Cover/;
        my ( undef, undef, $exit_code ) = capture {
            system $^X, '-Ilib', '-MDeveloper::Dashboard::Platform', '-e',
              'Developer::Dashboard::Platform::_exec_java_source(@ARGV)', 'exec-hook.java', 'alpha', 'beta';
            return $? >> 8;
        };
        is( $exit_code, 0, '_exec_java_source compiles and execs Java successfully through PATH lookup' );
        open my $javac_log_fh, '<', $javac_log or die $!;
        like(
            do { local $/; <$javac_log_fh> },
            qr/\A-d\n.+\n.+HookRunner\.java\n\z/s,
            '_exec_java_source invokes javac with an isolated output directory and a staged source file named for the resolved class',
        );
        close $javac_log_fh;
        open my $java_log_fh, '<', $java_log or die $!;
        like(
            do { local $/; <$java_log_fh> },
            qr/\A-cp\n.+\nfoo\.bar\.HookRunner\nalpha\nbeta\n\z/s,
            '_exec_java_source invokes java with the resolved main class and passthrough argv',
        );
        close $java_log_fh;
        unlink 'exec-hook.java' or die $!;
    }
    {
        open my $src_fh, '>', 'inline-hook.java' or die $!;
        print {$src_fh} "package foo.bar;\nclass HookRunner { public static void main(String[] args) {} }\n";
        close $src_fh;
        chmod 0755, 'inline-hook.java' or die $!;

        my @javac_call;
        my @java_exec;
        no warnings 'redefine';
        local $Developer::Dashboard::Platform::SYSTEM_LAUNCHER = sub {
            @javac_call = @_;
            $? = 0;
            return 0;
        };
        local $Developer::Dashboard::Platform::EXEC_LAUNCHER = sub {
            @java_exec = @_;
            return 1;
        };
        ok( eval { Developer::Dashboard::Platform::_exec_java_source( 'inline-hook.java', 'alpha', 'beta' ); 1 }, '_exec_java_source can be exercised inline through launcher hooks' );
        like(
            join( "\n", @javac_call ) . "\n",
            qr/\Ajavac\n-d\n.+\n.+HookRunner\.java\n\z/s,
            '_exec_java_source invokes javac through the overridable launcher in-process using a staged class-named source file',
        );
        is_deeply(
            \@java_exec,
            [ 'java', '-cp', $javac_call[2], 'foo.bar.HookRunner', 'alpha', 'beta' ],
            '_exec_java_source invokes java with the resolved class through the overridable launcher in-process',
        );
        local $Developer::Dashboard::Platform::SYSTEM_LAUNCHER = sub {
            $? = 256;
            return 1;
        };
        dies_like(
            sub { Developer::Dashboard::Platform::_exec_java_source('inline-hook.java') },
            qr/javac failed for inline-hook\.java with exit code 1/,
            '_exec_java_source reports javac failures explicitly',
        );
        dies_like(
            sub { Developer::Dashboard::Platform::_exec_java_source() },
            qr/Missing Java source path/,
            '_exec_java_source rejects missing source paths',
        );
        unlink 'inline-hook.java' or die $!;
    }
}

{
    local $Developer::Dashboard::Platform::OS_NAME = 'MSWin32';
    is( normalize_shell_name('ps'), 'powershell', 'normalize_shell_name maps ps to powershell' );
    is( normalize_shell_name('pwsh.exe'), 'pwsh', 'normalize_shell_name strips Windows PowerShell executable suffixes' );
    is_deeply(
        [ shell_command_argv('Write-Host ok', shell => 'powershell') ],
        [ 'powershell', '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', 'Write-Host ok' ],
        'shell_command_argv builds Windows PowerShell argv',
    );
    local $ENV{PATHEXT} = '.EXE;.CMD;.BAT;.PS1';
    is( native_shell_name('powershell.exe'), 'powershell', 'native_shell_name normalizes explicit powershell selectors' );
    ok( is_runnable_file('tool.ps1'), 'is_runnable_file treats .ps1 files as runnable on Windows when the file exists' ) if do {
        open my $fh, '>', 'tool.ps1' or die $!;
        print {$fh} "Write-Host ok\n";
        close $fh;
        1;
    };
    {
        no warnings 'redefine';
        local *Developer::Dashboard::Platform::command_in_path = sub {
            my ($name) = @_;
            return '/usr/bin/pwsh' if $name eq 'pwsh';
            return undef;
        };
        is_deeply(
            [ command_argv_for_path('tool.ps1') ],
            [ '/usr/bin/pwsh', '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', 'tool.ps1' ],
            'command_argv_for_path resolves PowerShell scripts on Windows through the preferred runnable PowerShell binary',
        );
    }
    ok( command_in_path('tool'), 'command_in_path resolves PATHEXT-backed PowerShell scripts on Windows' );
    ok( is_runnable_file('tool'), 'is_runnable_file resolves PATHEXT-backed PowerShell scripts on Windows' );
    {
        open my $fh, '>', 'tool.cmd' or die $!;
        print {$fh} "\@echo off\r\necho cmd-ok\r\n";
        close $fh;
    }
    {
        no warnings 'redefine';
        local $ENV{ComSpec} = '';
        local *Developer::Dashboard::Platform::command_in_path = sub {
            my ($name) = @_;
            return '/home/mv/bin/cmd' if $name eq 'cmd';
            return undef;
        };
        is_deeply(
            [ command_argv_for_path('tool.cmd') ],
            [ 'cmd.exe', '/d', '/c', 'tool.cmd' ],
            'command_argv_for_path normalizes extensionless cmd shims in PATH back to cmd.exe on Windows',
        );
    }
    is_deeply(
        [ command_argv_for_path('tool.cmd') ],
        [ 'cmd.exe', '/d', '/c', 'tool.cmd' ],
        'command_argv_for_path resolves .cmd scripts on Windows',
    );
    {
        open my $fh, '>', 'hook.pl' or die $!;
        print {$fh} "print qq{perl-ok\\n};\n";
        close $fh;
    }
    is_deeply(
        [ command_argv_for_path('hook.pl') ],
        [ $^X, '-I', $platform_lib_root, 'hook.pl' ],
        'command_argv_for_path resolves Perl scripts on Windows through the current perl interpreter',
    );
    {
        open my $fh, '>', 'runner.bat' or die $!;
        print {$fh} "\@echo off\r\necho bat-ok\r\n";
        close $fh;
    }
    is_deeply(
        [ command_argv_for_path('runner.bat') ],
        [ 'cmd.exe', '/d', '/c', 'runner.bat' ],
        'command_argv_for_path resolves .bat scripts on Windows',
    );
    {
        no warnings 'redefine';
        local $ENV{ComSpec} = '/mnt/c/WINDOWS/system32/cmd.exe';
        local *Developer::Dashboard::Platform::command_in_path = sub {
            my ($name) = @_;
            return '/mnt/c/WINDOWS/system32/cmd.exe' if $name eq 'cmd';
            return undef;
        };
        is_deeply(
            [ command_argv_for_path('runner.bat') ],
            [ 'cmd.exe', '/d', '/c', 'runner.bat' ],
            'command_argv_for_path normalizes WSL-style absolute cmd.exe paths back to cmd.exe',
        );
    }
    {
        no warnings 'redefine';
        local $ENV{ComSpec} = 'C:/tools/custom-command-processor.exe';
        local *Developer::Dashboard::Platform::command_in_path = sub {
            my ($name) = @_;
            return 'C:/tools/custom-command-processor.exe' if $name eq 'cmd';
            return undef;
        };
        is_deeply(
            [ command_argv_for_path('runner.bat') ],
            [ 'C:/tools/custom-command-processor.exe', '/d', '/c', 'runner.bat' ],
            'command_argv_for_path preserves non-cmd.exe Windows command processors without normalization',
        );
    }
    {
        open my $fh, '>', 'runner.sh' or die $!;
        print {$fh} "#!/bin/sh\necho sh-ok\n";
        close $fh;
    }
    local $ENV{PATH} = $home . ':' . $ENV{PATH};
    {
        open my $fh, '>', File::Spec->catfile( $home, 'sh' ) or die $!;
        print {$fh} "#!/bin/sh\nexit 0\n";
        close $fh;
        chmod 0755, File::Spec->catfile( $home, 'sh' ) or die $!;
    }
    ok( is_runnable_file('runner.sh'), 'is_runnable_file treats .sh files as runnable on Windows when a POSIX shell is available' );
    my @runner_sh = command_argv_for_path('runner.sh');
    is( $runner_sh[1], 'runner.sh', 'command_argv_for_path keeps the shell-script path when dispatching through an available POSIX shell on Windows' );
    like( $runner_sh[0], qr{(?:^|/)sh$}, 'command_argv_for_path resolves .sh scripts through an available POSIX shell on Windows' );
    {
        open my $fh, '>', 'runner.js' or die $!;
        print {$fh} "console.log('win-ok');\n";
        close $fh;
    }
    {
        no warnings 'redefine';
        local *Developer::Dashboard::Platform::command_in_path = sub {
            my ($name) = @_;
            return 'C:/Program Files/nodejs/node.exe' if $name eq 'node';
            return undef;
        };
        ok( is_runnable_file('runner.js'), 'is_runnable_file treats .js files as runnable on Windows when node is available' );
        is_deeply(
            [ command_argv_for_path('runner.js') ],
            [ 'C:/Program Files/nodejs/node.exe', 'runner.js' ],
            'command_argv_for_path resolves .js scripts on Windows through node',
        );
    }
    {
        open my $fh, '>', 'runner.py' or die $!;
        print {$fh} "print('win-ok')\n";
        close $fh;
    }
    {
        no warnings 'redefine';
        local *Developer::Dashboard::Platform::command_in_path = sub {
            my ($name) = @_;
            return 'C:/Python312/python.exe' if $name eq 'python';
            return undef;
        };
        ok( is_runnable_file('runner.py'), 'is_runnable_file treats .py files as runnable on Windows when python is available' );
        is_deeply(
            [ command_argv_for_path('runner.py') ],
            [ 'C:/Python312/python.exe', 'runner.py' ],
            'command_argv_for_path resolves .py scripts on Windows through python',
        );
    }
    {
        open my $fh, '>', 'script.foo' or die $!;
        print {$fh} "#!/usr/bin/env perl\nprint qq{shebang-ok\\n};\n";
        close $fh;
    }
    ok( is_runnable_file('script.foo'), 'is_runnable_file treats shebang files as runnable on Windows' );
    is_deeply(
        [ command_argv_for_path('script.foo') ],
        [ $^X, 'script.foo' ],
        'command_argv_for_path falls back to perl for unknown extensions on Windows',
    );
    {
        open my $fh, '>', 'notes.txt' or die $!;
        print {$fh} "plain text\n";
        close $fh;
    }
    ok( !is_runnable_file('notes.txt'), 'is_runnable_file rejects plain data files on Windows' );
    is( shell_quote_for( 'powershell', q{O'Hara} ), q{'O''Hara'}, 'shell_quote_for escapes PowerShell single quotes' );
    {
        no warnings 'redefine';
        local *Developer::Dashboard::Platform::command_in_path = sub {
            my ($name) = @_;
            return 'pwsh' if $name eq 'pwsh';
            return undef;
        };
        local $ENV{SHELL} = '';
        is( native_shell_name(), 'pwsh', 'native_shell_name prefers pwsh on Windows when available' );
    }
    {
        no warnings 'redefine';
        local *Developer::Dashboard::Platform::command_in_path = sub { return undef; };
        local $ENV{SHELL} = '';
        is( native_shell_name(), 'powershell', 'native_shell_name falls back to powershell on Windows when pwsh is unavailable' );
    }
    unlink 'runner.bat' or die $!;
    unlink 'runner.sh' or die $!;
    unlink 'runner.js' or die $!;
    unlink 'runner.py' or die $!;
    unlink 'script.foo' or die $!;
    unlink 'notes.txt' or die $!;
    unlink File::Spec->catfile( $home, 'sh' ) or die $!;
    unlink 'tool.ps1' or die $!;
    unlink 'tool.cmd' or die $!;
    unlink 'hook.pl' or die $!;
}

{
    my $private_var = File::Spec->catdir( '/private', 'var', 'folders', 'demo' );
    my $private_tmp = File::Spec->catdir( '/private', 'tmp', 'demo' );
    no warnings 'redefine';
    local *Developer::Dashboard::PathRegistry::_path_identity = sub {
        my ( $self, $path ) = @_;
        return '' if !defined $path;
        $path =~ s{^/private}{};
        return $path;
    };
    is(
        $paths->_prefer_reference_style( $private_var, '/var' ),
        File::Spec->catdir( '/var', 'folders', 'demo' ),
        '_prefer_reference_style rewrites equivalent paths into the reference alias style',
    );
    is(
        $paths->_prefer_reference_style( $private_tmp, '/tmp' ),
        File::Spec->catdir( '/tmp', 'demo' ),
        '_prefer_reference_style keeps equivalent tmp aliases in the reference style',
    );
    is(
        $paths->_prefer_reference_style( '/tmp/demo', '/tmp/demo' ),
        '/tmp/demo',
        '_prefer_reference_style keeps the reference path unchanged when no suffix remains',
    );
    is(
        $paths->_display_path($private_var),
        File::Spec->catdir( '/var', 'folders', 'demo' ),
        '_display_path shortens /private/var aliases when the canonical identity matches',
    );
    is(
        $paths->_display_path($private_tmp),
        File::Spec->catdir( '/tmp', 'demo' ),
        '_display_path shortens /private/tmp aliases when the canonical identity matches',
    );
}

my $named_dir = $paths->resolve_dir('named');
ok( !defined scalar $paths->ls('named'), 'ls returns undef for missing directory' );
make_path($named_dir);
open my $ls_fh, '>', File::Spec->catfile( $named_dir, 'child.txt' ) or die $!;
print {$ls_fh} "child\n";
close $ls_fh;
is_deeply(
    [ $paths->ls('named') ],
    [ File::Spec->catfile( $named_dir, 'child.txt' ) ],
    'ls returns sorted child items',
);

my $with_dir_result = $paths->with_dir(
    'named',
    sub {
        is_same_path( cwd(), $named_dir, 'with_dir changes into target directory' );
        return 'ok';
    }
);
is( $with_dir_result, 'ok', 'with_dir returns scalar result' );
is_same_path( cwd(), $home, 'with_dir restores original cwd after scalar call' );

my @with_dir_list = $paths->with_dir(
    'named',
    sub {
        return qw(one two);
    }
);
is_deeply( \@with_dir_list, [qw(one two)], 'with_dir preserves list context' );

dies_like(
    sub {
        $paths->with_dir(
            'named',
            sub {
                die "boom\n";
            }
        );
    },
    qr/boom/,
    'with_dir rethrows callback exceptions',
);
is_same_path( cwd(), $home, 'with_dir restores cwd after callback errors' );

my @located = $paths->locate_projects('alpha');
is_deeply(
    \@located,
    [ File::Spec->catdir( $workspace, 'Alpha-App' ), File::Spec->catdir( $projects, 'Alpha-App' ) ],
    'locate_projects finds visible matching projects across roots',
);
is(
    $paths->project_root_for( File::Spec->catdir( $workspace, 'Alpha-App', 'lib' ) ),
    File::Spec->catdir( $workspace, 'Alpha-App' ),
    'project_root_for finds repo roots from arbitrary child directories',
);
ok( !scalar $paths->locate_projects('missing-term'), 'locate_projects returns an empty list when no project matches' );
open my $workspace_file, '>', File::Spec->catfile( $workspace, 'not-a-dir' ) or die $!;
print {$workspace_file} "file\n";
close $workspace_file;
my @located_with_blank_term = $paths->locate_projects( 'alpha', '' );
is_deeply(
    \@located_with_blank_term,
    [ File::Spec->catdir( $workspace, 'Alpha-App' ), File::Spec->catdir( $projects, 'Alpha-App' ) ],
    'locate_projects ignores empty search terms and non-directories',
);
is( $paths->_expand_home(undef), undef, '_expand_home leaves undefined values unchanged' );
is( $paths->_expand_home('$HOME/shared-path'), File::Spec->catdir( $home, 'shared-path' ), '_expand_home expands leading $HOME tokens' );
is( $paths->_expand_home('$HOME'), $home, '_expand_home expands a bare $HOME token' );

dies_like( sub { $paths->collector_dir('') }, qr/Missing collector name/, 'collector_dir requires a name' );
dies_like( sub { $paths->indicator_dir('') }, qr/Missing indicator name/, 'indicator_dir requires a name' );
ok( -d $paths->collector_dir('demo'), 'collector_dir creates collector directory' );
ok( -d $paths->indicator_dir('demo'), 'indicator_dir creates indicator directory' );

my $files = Developer::Dashboard::FileRegistry->new( paths => $paths );
is( $files->paths, $paths, 'file registry exposes path registry' );
is( $files->resolve_file('/tmp/example'), '/tmp/example', 'resolve_file keeps absolute files unchanged' );
like( $files->resolve_file('prompt_log'), qr/prompt\.log$/, 'resolve_file resolves named files' );
like( $files->resolve_file('auth_log'), qr/auth\.log$/, 'resolve_file resolves auth log file' );
like( $files->resolve_file('dashboard_log'), qr/dashboard\.log$/, 'resolve_file resolves dashboard log file' );
like( $files->resolve_file('web_pid'), qr/web\.pid$/, 'resolve_file resolves web pid file' );
like( $files->resolve_file('web_state'), qr/web\.json$/, 'resolve_file resolves web state file' );
dies_like( sub { Developer::Dashboard::FileRegistry->new }, qr/Missing paths registry/, 'file registry requires paths' );
dies_like( sub { $files->resolve_file('missing-file') }, qr/Unknown file name/, 'resolve_file rejects unknown names' );
ok( !defined $files->read('prompt_log'), 'read returns undef for missing file' );

$files->write( 'prompt_log', "line1\n" );
$files->append( 'prompt_log', "line2\n" );
is( $files->read('prompt_log'), "line1\nline2\n", 'write and append persist file content' );
$files->write( 'prompt_log', undef );
is( $files->read('prompt_log'), '', 'write stores empty content when undef is provided' );
$files->append( 'prompt_log', undef );
is( $files->read('prompt_log'), '', 'append treats undef content as empty text' );
like( $files->dashboard_index, qr{/index$}, 'dashboard_index resolves the saved page bookmark file' );
like( $files->global_config, qr/config\.json$/, 'global_config resolves the main config file' );
like( $files->auth_log, qr/auth\.log$/, 'auth_log resolves the auth log file' );
like( $files->dashboard_log, qr/dashboard\.log$/, 'dashboard_log resolves the dashboard log file' );
like( $files->web_pid, qr/web\.pid$/, 'web_pid resolves the web pid file' );
like( $files->web_state, qr/web\.json$/, 'web_state resolves the web state file' );

$files->touch('collector_log');
ok( -f $files->collector_log, 'touch creates the collector log file' );
$files->remove('collector_log');
ok( !-e $files->collector_log, 'remove deletes known files' );
$files->remove('collector_log');
ok( !-e $files->collector_log, 'remove tolerates missing known files' );

my $config = Developer::Dashboard::Config->new( files => $files, paths => $paths );
dies_like( sub { Developer::Dashboard::Config->new( paths => $paths ) }, qr/Missing file registry/, 'config requires files' );
dies_like( sub { Developer::Dashboard::Config->new( files => $files ) }, qr/Missing path registry/, 'config requires paths' );

is_deeply( $config->load_global, {}, 'load_global returns empty hash without config file' );
my $saved_global = {
    default_mode => 'render',
    collectors   => [
        {
            name     => 'global.collector',
            command  => q{printf 'global'},
            cwd      => 'home',
            interval => 5,
        },
    ],
};
$config->save_global($saved_global);
is_deeply( $config->load_global, $saved_global, 'save_global round-trips config content' );
my $saved_numeric_file_alias = $config->save_global_file_alias( '123', $files->prompt_log );
is( $saved_numeric_file_alias->{path}, $files->prompt_log, 'save_global_file_alias stores numeric file aliases' );
my $numeric_file_alias_method = 123;
is(
    Developer::Dashboard::File->$numeric_file_alias_method,
    $files->prompt_log,
    'Developer::Dashboard::File resolves configured numeric aliases through dynamic method names',
);
is(
    Developer::Dashboard::File->resolve('123'),
    $files->prompt_log,
    'Developer::Dashboard::File resolve returns configured numeric aliases explicitly',
);
my $saved_default_merge_path = $config->save_global_defaults(
    {
        default_mode => 'browse',
        web          => {
            host    => '127.0.0.1',
            workers => 9,
            ssl     => 1,
        },
        providers => [
            {
                title => 'Default Provider',
            },
        ],
    }
);
ok( -f $saved_default_merge_path, 'save_global_defaults writes the merged config file' );
is_deeply(
    $config->load_global,
    {
        default_mode => 'render',
        collectors   => [
            {
                name     => 'global.collector',
                command  => q{printf 'global'},
                cwd      => 'home',
                interval => 5,
            },
        ],
        web => {
            host    => '127.0.0.1',
            workers => 9,
            ssl     => 1,
        },
        providers => [
            {
                title => 'Default Provider',
            },
        ],
        file_aliases => {
            123 => '$HOME/.developer-dashboard/logs/prompt.log',
        },
    },
    'save_global_defaults preserves existing user settings while adding only missing defaults',
);
$config->save_global($saved_global);
my $global_alias_config = Developer::Dashboard::Config->new( files => $files, paths => $paths, repo_root => $home );
$global_alias_config->save_global_path_alias( 'foo', File::Spec->catdir( $home, 'foo-path' ) );
is_deeply(
    $global_alias_config->load_global->{path_aliases},
    { foo => '$HOME/foo-path' },
    'save_global_path_alias stores home-relative paths using $HOME for portability',
);
is_deeply(
    $global_alias_config->global_path_aliases,
    { foo => File::Spec->catdir( $home, 'foo-path' ) },
    'global_path_aliases expands stored $HOME paths back to concrete local paths',
);
is_deeply(
    $global_alias_config->path_aliases,
    { foo => File::Spec->catdir( $home, 'foo-path' ) },
    'path_aliases includes expanded global aliases when no repo override exists',
);
is_deeply(
    $global_alias_config->save_global_file_alias( 'bashrc_copy', File::Spec->catfile( $home, '.bashrc.copy' ) ),
    { name => 'bashrc_copy', path => File::Spec->catfile( $home, '.bashrc.copy' ) },
    'save_global_file_alias stores a file alias using the same portable home-path normalization as path aliases',
);
is_deeply(
    $global_alias_config->load_global->{file_aliases},
    { bashrc_copy => '$HOME/.bashrc.copy' },
    'save_global_file_alias persists file aliases in the writable config',
);
is_deeply(
    $global_alias_config->load_global->{path_aliases},
    { foo => '$HOME/foo-path' },
    'save_global_file_alias leaves existing path aliases untouched in the writable config',
);
is_deeply(
    $global_alias_config->global_file_aliases,
    { bashrc_copy => File::Spec->catfile( $home, '.bashrc.copy' ) },
    'global_file_aliases expands stored $HOME file aliases back into concrete local paths',
);
is_deeply(
    $global_alias_config->file_aliases,
    { bashrc_copy => File::Spec->catfile( $home, '.bashrc.copy' ) },
    'file_aliases includes expanded global file aliases when no repo override exists',
);
is(
    $files->resolve_file('bashrc_copy'),
    File::Spec->catfile( $home, '.bashrc.copy' ),
    'file registry resolves configured file aliases through Config-backed aliases',
);
is( $global_alias_config->web_workers, 1, 'web_workers defaults to one worker when unset' );
is_deeply(
    $global_alias_config->save_global_web_workers(3),
    { workers => 3 },
    'save_global_web_workers persists a positive integer worker count',
);
is( $global_alias_config->load_global->{web}{workers}, 3, 'save_global_web_workers writes the worker count into global config' );
is( $global_alias_config->web_workers, 3, 'web_workers reads the saved worker count from config' );
dies_like( sub { $global_alias_config->save_global_web_workers(0) }, qr/positive integer/, 'save_global_web_workers rejects zero workers' );
dies_like( sub { $global_alias_config->save_global_web_workers('abc') }, qr/positive integer/, 'save_global_web_workers rejects non-numeric worker counts' );
is_deeply(
    $global_alias_config->web_settings->{ssl_subject_alt_names},
    [],
    'web_settings defaults the HTTPS SAN alias list to an empty array reference',
);
is_deeply(
    $global_alias_config->save_global_web_settings(
        ssl_subject_alt_names => [ ' dashboard.local ', '192.168.88.5', '', '::1' ],
    ),
    {
        ssl_subject_alt_names => [ 'dashboard.local', '192.168.88.5', '::1' ],
    },
    'save_global_web_settings persists the normalized HTTPS SAN alias list',
);
is_deeply(
    $global_alias_config->web_settings->{ssl_subject_alt_names},
    [ 'dashboard.local', '192.168.88.5', '::1' ],
    'web_settings reads the configured HTTPS SAN alias list back from config',
);
is_deeply(
    $global_alias_config->save_global_path_alias( 'foo', File::Spec->catdir( $home, 'foo-path-updated' ) ),
    { name => 'foo', path => File::Spec->catdir( $home, 'foo-path-updated' ) },
    'save_global_path_alias updates existing aliases idempotently',
);
is_deeply(
    $global_alias_config->load_global->{path_aliases},
    { foo => '$HOME/foo-path-updated' },
    'save_global_path_alias keeps updated home-relative paths portable in the stored config',
);
is( $global_alias_config->_normalize_home_path($home), '$HOME', '_normalize_home_path rewrites the exact home directory to $HOME' );
is( $global_alias_config->_normalize_home_path('/opt/shared-path'), '/opt/shared-path', '_normalize_home_path leaves non-home absolute paths unchanged' );
is( $global_alias_config->_expand_config_path('$HOME'), $home, '_expand_config_path expands a bare $HOME token' );
is(
    $global_alias_config->_expand_config_path('~/tilde-path'),
    File::Spec->catdir( $home, 'tilde-path' ),
    '_expand_config_path expands tilde-prefixed paths for compatibility',
);
is(
    $global_alias_config->_expand_config_path('/opt/shared-path'),
    '/opt/shared-path',
    '_expand_config_path leaves non-home absolute paths unchanged',
);
is_deeply(
    $global_alias_config->_expand_path_aliases(),
    {},
    '_expand_path_aliases returns an empty hash for missing alias maps',
);
is_deeply(
    $global_alias_config->remove_global_path_alias('foo'),
    { name => 'foo', removed => 1 },
    'remove_global_path_alias removes existing aliases',
);
is_deeply(
    $global_alias_config->remove_global_path_alias('foo'),
    { name => 'foo', removed => 0 },
    'remove_global_path_alias is idempotent for missing aliases',
);
is_deeply(
    $global_alias_config->remove_global_file_alias('bashrc_copy'),
    { name => 'bashrc_copy', removed => 1 },
    'remove_global_file_alias removes existing file aliases',
);
is_deeply(
    $global_alias_config->remove_global_file_alias('bashrc_copy'),
    { name => 'bashrc_copy', removed => 0 },
    'remove_global_file_alias is idempotent for missing aliases',
);
$config->save_global;
is_deeply( $config->load_global, {}, 'save_global defaults to an empty hash when no config is provided' );
$config->save_global($saved_global);

ok( !defined decode_payload(undef), 'decode_payload ignores undefined token' );
ok( !defined decode_payload(''), 'decode_payload ignores empty token' );
ok( !defined encode_payload(undef), 'encode_payload ignores undefined text' );
my $payload = encode_payload('plain text');
is( decode_payload($payload), 'plain text', 'payload codec round-trips text' );
ok( defined decode_payload('not-gzip'), 'decode_payload returns decoded bytes for arbitrary base64 text' );

my $page = Developer::Dashboard::PageDocument->new(
    id          => 'page-one',
    title       => 'Page <One>',
    description => 'Desc "here"',
    layout      => { body => "Hello <world>\n" },
    state       => {
        one => 'first',
        two => undef,
    },
    actions => [
        { id => 'run', label => 'Run <It>' },
        'skip-me',
    ],
);

is( Developer::Dashboard::PageDocument->new->as_hash->{title}, 'Untitled', 'page document defaults title' );
dies_like( sub { Developer::Dashboard::PageDocument->from_hash('bad') }, qr/hash reference/, 'from_hash requires hash refs' );

my $json_page = $page->canonical_json;
my $from_json = Developer::Dashboard::PageDocument->from_json($json_page);
is( $from_json->as_hash->{id}, 'page-one', 'from_json restores page id' );
my $instruction_page = $page->canonical_instruction;
my $from_instruction = Developer::Dashboard::PageDocument->from_instruction($instruction_page);
is( $from_instruction->as_hash->{id}, 'page-one', 'from_instruction restores page id' );
like( $instruction_page, qr/^TITLE:\s+Page <One>/m, 'canonical_instruction emits TITLE section' );
like( $instruction_page, qr/^STASH:\s+one => 'first',/m, 'canonical_instruction emits legacy STASH section' );
$from_json->merge_state('not-a-hash');
is( $from_json->as_hash->{state}{one}, 'first', 'merge_state ignores non-hash input' );
$from_json->merge_state( { three => 'third' } );
is( $from_json->as_hash->{state}{three}, 'third', 'merge_state adds hash values' );
$from_json->with_mode('');
is( $from_json->as_hash->{mode}, 'edit', 'with_mode ignores empty mode' );
$from_json->with_mode(undef);
is( $from_json->as_hash->{mode}, 'edit', 'with_mode ignores undefined mode' );
$from_json->with_mode('source');
is( $from_json->as_hash->{mode}, 'source', 'with_mode updates page mode' );
my $html = $page->render_html;
like( $html, qr/Page &lt;One&gt;/, 'render_html escapes title text' );
like( $html, qr/Desc &quot;here&quot;/, 'render_html includes escaped note text' );
like( $html, qr/Hello <world>/, 'render_html keeps bookmark HTML body intact' );

my $modern_instruction = <<'PAGE';
=== TITLE ===
Modern Page
=== ICON ===
fa-rocket
=== BOOKMARK ===
modern-page
=== NOTE ===
Modern note
=== STASH ===
{"alpha":1}
=== HTML ===
<section>Modern body</section>
=== CODE1 ===
print "code one";
=== CODE2 ===
print "code two";
PAGE
my $modern_page = Developer::Dashboard::PageDocument->from_instruction($modern_instruction);
is( $modern_page->as_hash->{id}, 'modern-page', 'from_instruction parses modern bookmark id sections' );
is( $modern_page->as_hash->{meta}{icon}, 'fa-rocket', 'from_instruction parses ICON sections from modern bookmark source' );
is_deeply( $modern_page->as_hash->{state}, { alpha => 1 }, 'from_instruction decodes JSON STASH sections from modern bookmark source' );
is_deeply(
    $modern_page->as_hash->{meta}{codes},
    [
        { id => 'CODE1', body => q{print "code one";} },
        { id => 'CODE2', body => q{print "code two";} },
    ],
    'from_instruction preserves modern CODE sections in page metadata',
);
like( $modern_page->instruction_text, qr/^TITLE:\s+Modern Page/m, 'instruction_text aliases canonical legacy instruction output' );
is( $modern_page->render_template('ignored'), $modern_page, 'render_template compatibility path still returns the page object' );
is_deeply(
    Developer::Dashboard::PageDocument::_decode_structured_json('{"mode":"modern"}'),
    { mode => 'modern' },
    '_decode_structured_json decodes structured JSON payloads',
);
is_deeply(
    Developer::Dashboard::PageDocument::_decode_structured_json(''),
    {},
    '_decode_structured_json returns an empty hash for blank payloads',
);
is_deeply(
    Developer::Dashboard::PageDocument::_decode_stash_section('["not","a","hash"]'),
    {},
    '_decode_stash_section rejects non-hash JSON payloads for STASH sections',
);
is(
    Developer::Dashboard::PageDocument::_template_value( 'stash.profile.name', { stash => { profile => { name => 'Alice' } } } ),
    'Alice',
    '_template_value resolves nested placeholder paths',
);
is(
    Developer::Dashboard::PageDocument::_template_value( 'stash.profile.missing', { stash => { profile => { name => 'Alice' } } } ),
    '',
    '_template_value returns an empty string for missing placeholder paths',
);
is(
    Developer::Dashboard::PageDocument::_legacy_value( [ 'one', 2 ] ),
    "[\n  'one',\n  2\n]",
    '_legacy_value serializes array references for legacy stash output',
);
is(
    Developer::Dashboard::PageDocument::_legacy_value( { two => 2 } ),
    "{\n  two => 2\n}",
    '_legacy_value serializes hash references for legacy stash output',
);
my $coded_legacy_page = Developer::Dashboard::PageDocument->new(
    id          => 'coded-page',
    title       => 'Coded Page',
    description => 'Has codes',
    layout      => { body => '<p>Body</p>' },
    state       => { alpha => 1 },
    meta        => {
        icon  => 'fa-code',
        codes => [
            { id => 'CODE1', body => 'print "one";' },
            'skip-me',
            { id => 'BROKEN', body => 'print "broken";' },
            { id => 'CODE2', body => 'print "two";' },
        ],
    },
);
my $coded_legacy_instruction = $coded_legacy_page->legacy_instruction;
like( $coded_legacy_instruction, qr/^ICON:\s+fa-code/m, 'legacy_instruction includes ICON sections when present' );
like( $coded_legacy_instruction, qr/^CODE1:\s+print "one";/m, 'legacy_instruction includes valid CODE1 sections' );
like( $coded_legacy_instruction, qr/^CODE2:\s+print "two";/m, 'legacy_instruction includes valid CODE2 sections' );
unlike( $coded_legacy_instruction, qr/^BROKEN:/m, 'legacy_instruction skips invalid code section identifiers' );

my $page_store = Developer::Dashboard::PageStore->new( paths => $paths );
dies_like( sub { Developer::Dashboard::PageStore->new }, qr/Missing paths registry/, 'page store requires paths' );
dies_like( sub { $page_store->page_file('') }, qr/Missing page id/, 'page_file requires an id' );
my $page_file = $page_store->save_page( $page->as_hash );
ok( -f $page_file, 'save_page accepts hash input' );
is( $page_file, File::Spec->catfile( $paths->dashboards_root, 'page-one' ), 'save_page uses bookmark-style filename without json extension' );
is( $page_store->load_saved_page('page-one')->as_hash->{description}, 'Desc "here"', 'load_saved_page reads saved documents' );
ok( $page_store->encode_page( { id => 'encoded-from-hash', title => 'From Hash' } ), 'encode_page accepts hash input and normalizes it to a page document' );
dies_like( sub { $page_store->load_saved_page('missing-page') }, qr/not found/, 'load_saved_page fails for missing page ids' );
my $transient = $page_store->load_transient_page( $page_store->encode_page($page) );
is( $transient->as_hash->{layout}{body}, "Hello <world>", 'load_transient_page decodes tokenized pages into canonical instruction form' );
ok( $page_store->encode_page($page), 'encode_page accepts page documents directly' );
like( $page_store->source_url($page), qr{^\Q/?mode=source&token=\E}, 'source_url builds the transient source route for saved pages' );
dies_like(
    sub {
        $page_store->save_page(
            {
                title => 'no id',
            }
        );
    },
    qr/require an id/,
    'save_page requires page ids',
);

open my $skip_json, '>', File::Spec->catfile( $paths->dashboards_root, 'skip.txt' ) or die $!;
print {$skip_json} "skip\n";
close $skip_json;
is_deeply( [ sort grep { $_ ne 'skip.txt' } $page_store->list_saved_pages ], ['page-one'], 'list_saved_pages includes saved bookmark files' );

my $home_only_page = Developer::Dashboard::PageDocument->new(
    id     => 'shared-page',
    title  => 'Home Shared Page',
    layout => { body => 'from home root' },
);
$page_store->save_page($home_only_page);
{
    chdir $local_repo or die $!;
    my $local_store = Developer::Dashboard::PageStore->new( paths => $paths );
    is( $local_store->read_saved_entry('shared-page'), $home_only_page->canonical_instruction, 'page store falls back to the home bookmark root when a project-local page is missing' );
    my $local_override_page = Developer::Dashboard::PageDocument->new(
        id     => 'shared-page',
        title  => 'Local Shared Page',
        layout => { body => 'from local root' },
    );
    my $local_only_page = Developer::Dashboard::PageDocument->new(
        id     => 'local-only',
        title  => 'Local Only Page',
        layout => { body => 'local page body' },
    );
    $local_store->save_page($local_override_page);
    $local_store->save_page($local_only_page);
    is( $local_store->load_saved_page('shared-page')->as_hash->{title}, 'Local Shared Page', 'page store prefers project-local bookmark files over the home fallback' );
    is_deeply( [ $local_store->list_saved_pages ], [ 'local-only', 'page-one', 'shared-page' ], 'page store lists the project-local union of bookmark ids with local overrides taking precedence' );
    chdir $home or die $!;
}

my $collector = Developer::Dashboard::Collector->new( paths => $paths );
dies_like( sub { Developer::Dashboard::Collector->new }, qr/Missing paths registry/, 'collector requires paths' );
my $collector_paths = $collector->collector_paths('alpha.collector');
like( $collector_paths->{status}, qr/status\.json$/, 'collector_paths exposes status file' );
like( $collector_paths->{combined}, qr/combined$/, 'collector_paths exposes combined output file' );
like( $collector_paths->{log}, qr/log$/, 'collector_paths exposes collector log file' );
$collector->write_job(
    'alpha.collector',
    {
        name    => 'alpha.collector',
        command => q{printf 'alpha'},
    }
);
ok( -f $collector_paths->{job}, 'write_job persists job metadata' );
is( $collector->read_job('alpha.collector')->{command}, q{printf 'alpha'}, 'read_job returns job metadata' );
ok( !defined $collector->read_job('missing.collector'), 'read_job returns undef for missing job files' );
ok( !defined $collector->read_status('missing.collector'), 'read_status returns undef for missing status' );

$collector->write_result(
    'alpha.collector',
    exit_code => 1,
    stdout    => "bad\n",
    stderr    => "nope\n",
);
my $read_status = $collector->read_status('alpha.collector');
is( $read_status->{last_success}, 0, 'non-zero exit codes mark collector as failed' );
my $output = $collector->read_output('alpha.collector');
is( $output->{stdout}, "bad\n", 'read_output returns stdout' );
is( $output->{stderr}, "nope\n", 'read_output returns stderr' );
is( $output->{combined}, "bad\nnope\n", 'read_output returns combined output' );
like( $output->{last_run}, qr/T/, 'read_output returns last run timestamp' );
like( $output->{last_run}, qr/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{4}\z/, 'read_output returns a local ISO-8601 timestamp with timezone offset' );
is( $collector->inspect_collector('alpha.collector')->{job}{name}, 'alpha.collector', 'inspect_collector returns combined collector data' );
my $collector_log = $collector->read_log('alpha.collector');
like( $collector_log, qr/alpha\.collector/, 'read_log includes the collector name' );
like( $collector_log, qr/\A=== collector alpha\.collector \| \@ \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{4} \| exit=1 ===/s, 'read_log renders local ISO-8601 timestamps with timezone offsets in collector headers' );
like( $collector_log, qr/\[stdout\]\nbad\n/s, 'read_log includes persisted stdout content' );
like( $collector_log, qr/\[stderr\]\nnope\n/s, 'read_log includes persisted stderr content' );
ok( $collector->collector_exists('alpha.collector'), 'collector_exists recognizes persisted collector state' );

$collector->write_result( 'beta.collector', exit_code => 0 );
is( $collector->read_output('beta.collector')->{stdout}, '', 'read_output falls back to empty stdout' );
is( $collector->read_output('beta.collector')->{stderr}, '', 'read_output falls back to empty stderr' );
is_deeply(
    $collector->read_output('missing.collector'),
    {
        stdout   => '',
        stderr   => '',
        combined => '',
        last_run => '',
    },
    'read_output returns empty artifacts for a missing collector',
);
$collector->write_result( 'beta.collector', exit_code => 0 );
ok( -f $collector->collector_paths('beta.collector')->{status}, 'write_result overwrites existing collector files cleanly' );
unlike( $collector->read_log('beta.collector'), qr/\[stdout\]/, 'read_log omits an empty stdout section' );

$collector->write_status(
    'finish-race.collector',
    {
        active_runs => 2,
        running     => 1,
    }
);
{
    no warnings 'redefine';
    local *Developer::Dashboard::Collector::read_status = sub {
        die "mark_run_finished should not read status outside the update lock\n";
    };
    $collector->mark_run_finished(
        'finish-race.collector',
        exit_code => 0,
        stdout    => "done\n",
        stderr    => '',
    );
}
my $finish_race_status = $collector->read_status('finish-race.collector');
is( $finish_race_status->{active_runs}, 1, 'mark_run_finished decrements active_runs under the write lock without relying on a separate unlocked status read' );
is( $finish_race_status->{running}, 1, 'mark_run_finished keeps running true when another overlapping collector run is still active' );

my $legacy_paths = $collector->collector_paths('legacy.collector');
make_path( $legacy_paths->{dir} );
open my $legacy_stdout, '>', $legacy_paths->{stdout} or die $!;
print {$legacy_stdout} "legacy-stdout\n";
close $legacy_stdout;
open my $legacy_stderr, '>', $legacy_paths->{stderr} or die $!;
print {$legacy_stderr} "legacy-stderr\n";
close $legacy_stderr;
open my $legacy_combined, '>', $legacy_paths->{combined} or die $!;
print {$legacy_combined} "legacy-stdout\nlegacy-stderr\n";
close $legacy_combined;
open my $legacy_last_run, '>', $legacy_paths->{last_run} or die $!;
print {$legacy_last_run} "2026-04-14T00:00:00Z\n";
close $legacy_last_run;
$collector->write_status(
    'legacy.collector',
    {
        enabled       => 1,
        running       => 0,
        last_exit_code => 7,
        last_run      => '2026-04-14T00:00:00Z',
    }
);
my $legacy_log = $collector->read_log('legacy.collector');
like( $legacy_log, qr/legacy\.collector/, 'read_log synthesizes output for legacy collector state without an appended log file' );
like( $legacy_log, qr/legacy-stdout/, 'read_log fallback includes legacy stdout data' );
like( $legacy_log, qr/legacy-stderr/, 'read_log fallback includes legacy stderr data' );

$collector->write_status(
    'status-only.collector',
    {
        enabled        => 1,
        running        => 0,
        last_exit_code => 3,
        last_run       => '2026-04-14T12:34:56Z',
    }
);
my $status_only_log = $collector->read_log('status-only.collector');
like( $status_only_log, qr/status-only\.collector/, 'read_log renders a status-only collector snapshot when no output files exist yet' );
like( $status_only_log, qr/exit=3/, 'read_log renders the stored exit code for status-only collector snapshots' );

$collector->collector_paths('empty.collector');
is( $collector->read_log('empty.collector'), '', 'read_log returns an empty string when a collector directory exists without any persisted payload yet' );
ok( !$collector->collector_exists('missing.collector'), 'collector_exists returns false when no persisted collector directory exists' );

make_path( File::Spec->catdir( $paths->collectors_root, 'broken.collector' ) );
open my $broken_status, '>', File::Spec->catfile( $paths->collectors_root, 'broken.collector', 'status.json' ) or die $!;
print {$broken_status} "{broken\n";
close $broken_status;
ok( !defined $collector->read_status('broken.collector'), 'read_status returns undef for invalid collector status json' );
$collector->write_status(
    'broken.collector',
    {
        enabled => 1,
        running => 1,
    }
);
is( $collector->read_status('broken.collector')->{running}, 1, 'write_status recovers by overwriting invalid collector status json' );

my @collectors = $collector->list_collectors;
is_deeply(
    [ map { $_->{name} } @collectors ],
    [ 'alpha.collector', 'beta.collector', 'broken.collector', 'finish-race.collector', 'legacy.collector', 'status-only.collector' ],
    'list_collectors sorts collector status and includes legacy plus status-only persisted collector state once invalid status is repaired',
);

my $indicators = Developer::Dashboard::IndicatorStore->new( paths => $paths );
dies_like( sub { Developer::Dashboard::IndicatorStore->new }, qr/Missing paths registry/, 'indicator store requires paths' );
ok( !defined $indicators->get_indicator('missing'), 'get_indicator returns undef when state is missing' );

$indicators->set_indicator(
    'zulu',
    label          => 'Zulu',
    priority       => 99,
    icon           => '',
    status         => 'ok',
    prompt_visible => 1,
);
$indicators->set_indicator(
    'alpha',
    priority       => 1,
    status         => 'ok',
    prompt_visible => 0,
);
$indicators->set_indicator(
    'beta',
    priority => 0,
    status   => 'ok',
);
$indicators->set_indicator(
    'zulu',
    label          => 'Zulu Updated',
    priority       => 99,
    status         => 'ok',
    prompt_visible => 1,
);
my $indicator = $indicators->get_indicator('zulu');
is( $indicator->{label}, 'Zulu Updated', 'set_indicator overwrites existing indicator status' );

make_path( File::Spec->catdir( $paths->indicators_root, 'broken' ) );
open my $broken_indicator, '>', File::Spec->catfile( $paths->indicators_root, 'broken', 'status.json' ) or die $!;
print {$broken_indicator} "{broken\n";
close $broken_indicator;

my @indicator_names = map { $_->{name} } $indicators->list_indicators;
is_deeply( \@indicator_names, [ 'alpha', 'zulu', 'beta' ], 'list_indicators sorts by priority and skips invalid JSON' );
my $synced = $indicators->sync_collectors(
    [
        {
            name      => 'vpn',
            indicator => {
                icon => '🔑',
            },
        },
        {
            name      => 'docker.collector',
            indicator => {
                icon  => '🐳',
                label => 'Docker',
            },
        },
    ]
);
is( scalar @{$synced}, 2, 'sync_collectors seeds missing collector indicators from config' );
is( $indicators->get_indicator('vpn')->{status}, 'missing', 'sync_collectors defaults missing collector indicators to missing status before first run' );
is( $indicators->get_indicator('docker.collector')->{label}, 'Docker', 'sync_collectors keeps configured collector indicator labels' );
is(
    scalar @{
        $indicators->sync_collectors(
            [
                {
                    name      => 'vpn',
                    indicator => {
                        icon => '🔑',
                    },
                },
                {
                    name      => 'docker.collector',
                    indicator => {
                        icon  => '🐳',
                        label => 'Docker',
                    },
                },
            ]
        )
    },
    0,
    'sync_collectors skips rewriting indicators when the stored config-backed indicator already matches',
);
ok(
    !$indicators->collectors_need_sync(
        [
            {
                name      => 'vpn',
                indicator => {
                    icon => '🔑',
                },
            },
            {
                name      => 'docker.collector',
                indicator => {
                    icon  => '🐳',
                    label => 'Docker',
                },
            },
        ]
    ),
    'collectors_need_sync stays false when collector-managed indicator state already matches config',
);
ok(
    $indicators->collectors_need_sync(
        [
            {
                name      => 'vpn',
                indicator => {
                    icon  => '🔑',
                    label => 'VPN',
                },
            },
            {
                name      => 'docker.collector',
                indicator => {
                    icon  => '🐳',
                    label => 'Docker',
                },
            },
        ]
    ),
    'collectors_need_sync returns true when one collector indicator definition changes',
);
is( scalar @{ $indicators->sync_collectors([]) }, 0, 'sync_collectors ignores empty collector lists' );
$indicators->set_indicator(
    'stale',
    label          => 'Stale',
    icon           => 'S',
    status         => 'ok',
    priority       => 5,
    prompt_visible => 1,
);
my $stale_indicator = $indicators->mark_stale( 'stale', status => 'error' );
ok( $stale_indicator->{stale}, 'mark_stale marks stored indicators as stale' );
is( $stale_indicator->{status}, 'error', 'mark_stale can replace indicator status while preserving the record' );
$indicators->set_indicator(
    'stale.collector',
    icon                 => 'OLD',
    label                => 'stale.collector',
    status               => 'ok',
    managed_by_collector => 1,
    collector_name       => 'stale.collector',
    prompt_visible       => 1,
);
my $renamed_sync = $indicators->sync_collectors(
    [
        {
            name      => 'fresh.collector',
            indicator => {
                icon => 'NEW',
            },
        },
    ]
);
is( scalar @{$renamed_sync}, 4, 'sync_collectors rewrites the active collector indicator and removes all stale managed collector indicators after a config rename' );
ok( !defined $indicators->get_indicator('stale.collector'), 'sync_collectors removes stale managed collector indicators after a collector rename' );
ok( !defined $indicators->get_indicator('vpn'), 'sync_collectors removes older managed collector indicators that no longer exist in config' );
ok( !defined $indicators->get_indicator('docker.collector'), 'sync_collectors removes renamed managed collector indicators that no longer exist in config' );
is( $indicators->get_indicator('fresh.collector')->{collector_name}, 'fresh.collector', 'sync_collectors records the active collector name on managed indicators' );
my @page_header_items = $indicators->page_header_items;
my ($fresh_page_item) = grep { $_->{prog} eq 'fresh.collector' } @page_header_items;
is( $fresh_page_item->{alias}, 'NEW', 'page header status prefers the configured indicator icon over the collector name' );
{
    local $ENV{TMUX} = '';
    local $ENV{TICKET_REF} = '';
    local $ENV{WORKSPACE_REF} = '';
    local $ENV{DEVELOPER_DASHBOARD_TMUX_STATUS} = 0;
    my $ordered_home = tempdir(CLEANUP => 1);
    my $ordered_paths = Developer::Dashboard::PathRegistry->new( home => $ordered_home );
    my $ordered_store = Developer::Dashboard::IndicatorStore->new( paths => $ordered_paths );
    $ordered_store->sync_collectors(
        [
            { name => 'zeta.collector',  indicator => { icon => 'Z' } },
            { name => 'alpha.collector', indicator => { icon => 'A' } },
            { name => 'mu.collector',    indicator => { icon => 'M' } },
        ]
    );
    is_deeply(
        [ map { $_->{name} } $ordered_store->list_indicators ],
        [ 'zeta.collector', 'alpha.collector', 'mu.collector' ],
        'list_indicators keeps managed collector indicators in the configured collector array order',
    );
    is_deeply(
        [ map { $_->{prog} } $ordered_store->page_header_items ],
        [ 'zeta.collector', 'alpha.collector', 'mu.collector' ],
        'page_header_items keeps managed collector indicators in the configured collector array order',
    );
    my $ordered_prompt = Developer::Dashboard::Prompt->new(
        paths      => $ordered_paths,
        indicators => $ordered_store,
    )->render( jobs => 0, cwd => $ordered_home );
    like(
        $ordered_prompt,
        qr/🚨Z 🚨A 🚨M/,
        'prompt output keeps managed collector indicators in the configured collector array order',
    );
    my $ordered_collectors = Developer::Dashboard::Collector->new( paths => $ordered_paths );
    my $ordered_runner = Developer::Dashboard::CollectorRunner->new(
        collectors => $ordered_collectors,
        files      => Developer::Dashboard::FileRegistry->new( paths => $ordered_paths ),
        indicators => $ordered_store,
        paths      => $ordered_paths,
    );
    $ordered_runner->run_once(
        {
            name      => 'alpha.collector',
            code      => 'print "ok\n"; return 0;',
            cwd       => $ordered_home,
            indicator => { icon => 'A' },
        }
    );
    is_deeply(
        [ map { $_->{name} } $ordered_store->list_indicators ],
        [ 'zeta.collector', 'alpha.collector', 'mu.collector' ],
        'collector status writes preserve the configured collector array order after live runs',
    );
}
{
    my $race_home = tempdir(CLEANUP => 1);
    my $race_paths = Developer::Dashboard::PathRegistry->new( home => $race_home );
    my $race_indicators = Developer::Dashboard::IndicatorStore->new( paths => $race_paths );
    $race_indicators->set_indicator(
        'healthy.indicator',
        collector_name       => 'healthy.collector',
        icon                 => 'OLD',
        label                => 'Stale Healthy',
        managed_by_collector => 1,
        prompt_visible       => 1,
        status               => 'missing',
    );

    no warnings 'redefine';
    my $original_set_indicator = \&Developer::Dashboard::IndicatorStore::set_indicator;
    my $injected_ok_update = 0;
    local *Developer::Dashboard::IndicatorStore::set_indicator = sub {
        my ( $self, $name, %data ) = @_;
        if ( !$injected_ok_update && $name eq 'healthy.indicator' && ( $data{status} || '' ) eq 'missing' ) {
            $injected_ok_update = 1;
            $original_set_indicator->(
                $self,
                $name,
                collector_name       => 'healthy.collector',
                icon                 => 'H',
                label                => 'Healthy',
                managed_by_collector => 1,
                prompt_visible       => 1,
                status               => 'ok',
            );
        }
        return $original_set_indicator->( $self, $name, %data );
    };

    $race_indicators->sync_collectors(
        [
            {
                name      => 'healthy.collector',
                indicator => {
                    icon  => 'H',
                    label => 'Healthy',
                    name  => 'healthy.indicator',
                },
            },
        ]
    );

    is(
        $race_indicators->get_indicator('healthy.indicator')->{status},
        'ok',
        'sync_collectors preserves a concurrent collector status update instead of writing stale missing state',
    );
}
{
    my $core_home = tempdir(CLEANUP => 1);
    my $core_paths = Developer::Dashboard::PathRegistry->new( home => $core_home );
    my $core_indicators = Developer::Dashboard::IndicatorStore->new( paths => $core_paths );
    my $core_repo = File::Spec->catdir( $core_home, 'core-repo' );
    make_path($core_repo);
    system( 'git', 'init', '-q', $core_repo ) == 0 or die 'git init failed';
    system( 'git', '-C', $core_repo, 'config', 'user.email', 'core@example.test' ) == 0 or die 'git config user.email failed';
    system( 'git', '-C', $core_repo, 'config', 'user.name', 'Core Coverage' ) == 0 or die 'git config user.name failed';
    open my $core_fh, '>', File::Spec->catfile( $core_repo, 'README' ) or die $!;
    print {$core_fh} "core coverage\n";
    close $core_fh;
    system( 'git', '-C', $core_repo, 'add', 'README' ) == 0 or die 'git add failed';
    system( 'git', '-C', $core_repo, 'commit', '-q', '-m', 'init' ) == 0 or die 'git commit failed';

    no warnings 'redefine';
    local *Developer::Dashboard::IndicatorStore::command_in_path = sub { return 1 };
    my $core_items = $core_indicators->refresh_core_indicators( cwd => $core_repo );
    my ($docker_indicator)  = grep { $_->{name} eq 'docker' } @{$core_items};
    my ($project_indicator) = grep { $_->{name} eq 'project' } @{$core_items};
    my ($git_indicator)     = grep { $_->{name} eq 'git' } @{$core_items};
    is( $docker_indicator->{status}, 'ok', 'refresh_core_indicators marks docker available when docker is on PATH' );
    is( $project_indicator->{status}, 'ok', 'refresh_core_indicators marks the project indicator active inside a repository' );
    is( $git_indicator->{status}, 'clean', 'refresh_core_indicators marks a clean git work tree as clean' );
}
{
    my $prompt_only_home = tempdir(CLEANUP => 1);
    my $prompt_only_paths = Developer::Dashboard::PathRegistry->new( home => $prompt_only_home );
    my $prompt_only_indicators = Developer::Dashboard::IndicatorStore->new( paths => $prompt_only_paths );
    my $prompt_only_repo = File::Spec->catdir( $prompt_only_home, 'prompt-only-repo' );
    my $prompt_only_git_dir = File::Spec->catdir( $prompt_only_repo, '.git', 'refs', 'heads' );
    make_path($prompt_only_git_dir);
    open my $prompt_only_head_fh, '>', File::Spec->catfile( $prompt_only_repo, '.git', 'HEAD' ) or die $!;
    print {$prompt_only_head_fh} "ref: refs/heads/main\n";
    close $prompt_only_head_fh;
    open my $prompt_only_branch_fh, '>', File::Spec->catfile( $prompt_only_git_dir, 'main' ) or die $!;
    print {$prompt_only_branch_fh} "0123456789abcdef0123456789abcdef01234567\n";
    close $prompt_only_branch_fh;

    my $fake_git_dir = File::Spec->catdir( $prompt_only_home, 'fake-bin' );
    my $fake_git_log = File::Spec->catfile( $prompt_only_home, 'fake-git.log' );
    make_path($fake_git_dir);
    open my $fake_git_fh, '>', File::Spec->catfile( $fake_git_dir, 'git' ) or die $!;
    print {$fake_git_fh} <<"SH";
#!/bin/sh
printf '%s\\n' "\$*" >> '$fake_git_log'
exit 99
SH
    close $fake_git_fh;
    chmod 0755, File::Spec->catfile( $fake_git_dir, 'git' ) or die $!;

    local $ENV{PATH} = join ':', $fake_git_dir, ( $ENV{PATH} || () );
    my $prompt_only_items = $prompt_only_indicators->refresh_core_indicators(
        cwd         => $prompt_only_repo,
        prompt_only => 1,
    );
    is_deeply(
        [ map { $_->{name} } @{$prompt_only_items} ],
        ['docker'],
        'refresh_core_indicators prompt_only refreshes only prompt-visible core indicators',
    );
    ok( !-e $fake_git_log, 'refresh_core_indicators prompt_only skips git subprocess checks entirely' );
}

my $prompt = Developer::Dashboard::Prompt->new( paths => $paths, indicators => $indicators );
dies_like( sub { Developer::Dashboard::Prompt->new( paths => $paths ) }, qr/Missing indicator store/, 'prompt requires indicators' );
dies_like( sub { Developer::Dashboard::Prompt->new( indicators => $indicators ) }, qr/Missing paths registry/, 'prompt requires paths' );

my $plain_home = tempdir(CLEANUP => 1);
my $plain_paths = Developer::Dashboard::PathRegistry->new( home => $plain_home );
{
    local $ENV{TMUX} = '';
    local $ENV{TICKET_REF} = '';
    local $ENV{WORKSPACE_REF} = '';
    local $ENV{DEVELOPER_DASHBOARD_TMUX_STATUS} = 0;
    my $plain_prompt = Developer::Dashboard::Prompt->new(
        paths      => $plain_paths,
        indicators => Developer::Dashboard::IndicatorStore->new( paths => $plain_paths ),
    )->render( cwd => File::Spec->catdir( $plain_home, 'here' ) );
    like( $plain_prompt, qr/\[~\/here\]/, 'prompt still renders the cwd when no indicators exist' );
    unlike( $plain_prompt, qr/\bDD\b/, 'prompt omits the DD fallback when no indicators exist' );
    like( $plain_prompt, qr/\n> \z/, 'prompt still places the command marker on a fresh line when no indicators exist' );

    my $prompt_output = $prompt->render( jobs => 3, cwd => File::Spec->catdir( $home, 'named-path' ) );
    like( $prompt_output, qr/🚨NEW/, 'compact prompt includes the renamed missing collector indicator glyph' );
    like( $prompt_output, qr/✅Z ✅b/, 'compact prompt includes success status glyphs in priority order' );
    unlike( $prompt_output, qr/alpha/, 'prompt skips hidden indicators' );
    like( $prompt_output, qr/~\/named-path/, 'prompt shortens home directory to tilde' );
    like( $prompt_output, qr/\(3 jobs\)/, 'prompt appends job suffix' );
    like( $prompt->render( jobs => 0, cwd => File::Spec->catdir( $home, 'named-path' ), color => 1 ), qr/\e\[[0-9;]*m/, 'prompt can colorize compact indicator parts when requested' );
    my $prompt_without_indicators = $prompt->render(
        jobs          => 3,
        cwd           => File::Spec->catdir( $home, 'named-path' ),
        no_indicators => 1,
    );
    unlike( $prompt_without_indicators, qr/🚨NEW|✅Z|✅b/, 'prompt suppresses indicators when tmux owns the status line' );
    like( $prompt_without_indicators, qr/~\/named-path/, 'prompt still renders cwd when tmux suppresses indicators' );
    like( $prompt_without_indicators, qr/\(3 jobs\)/, 'prompt still renders job counts when tmux suppresses indicators' );
}
{
    local $ENV{TMUX} = 'tmux-session';
    local $ENV{DEVELOPER_DASHBOARD_TMUX_STATUS} = 1;
    local $ENV{TICKET_REF} = '';
    local $ENV{WORKSPACE_REF} = '';
    my $auto_tmux_prompt = $prompt->render(
        jobs => 2,
        cwd  => File::Spec->catdir( $home, 'named-path' ),
    );
    unlike( $auto_tmux_prompt, qr/🚨NEW|✅Z|✅b/, 'prompt auto-suppresses inline indicators when the dashboard tmux status flag is active' );
    like( $auto_tmux_prompt, qr/\(2 jobs\)/, 'prompt still reports jobs when tmux auto-suppresses inline indicators' );
}
{
    local $ENV{TICKET_REF} = 'DD-4242';
    my $tmux_status = $prompt->render_tmux_status( width => 200 );
    like( $tmux_status, qr/🚨NEW/, 'tmux status formatter includes missing indicator glyphs' );
    like( $tmux_status, qr/✅Z ✅b/, 'tmux status formatter keeps indicator priority order' );
    unlike( $tmux_status, qr/\n/, 'tmux status formatter stays on one line when the ticket-session indicator strip fits the available width' );
    like( $prompt->render_tmux_status( width => 4 ), qr/\n/, 'tmux status formatter still emits multiple lines when the available width is too small for the full indicator strip' );
    unlike( $tmux_status, qr/🎫:DD-4242/, 'tmux status formatter leaves ticket context to the prompt or tmux session line' );
    unlike( $tmux_status, qr/\[~\/named-path\]|\n> /, 'tmux status formatter omits prompt-only cwd and cursor fragments' );
}
{
    my $timestamp_only_prompt = Developer::Dashboard::Prompt->new(
        paths      => $plain_paths,
        indicators => Developer::Dashboard::IndicatorStore->new( paths => $plain_paths ),
    );
    my ( $timestamp_top, $timestamp_bottom ) = $timestamp_only_prompt->_tmux_status_lines( width => 20 );
    like( $timestamp_top, qr/\A🕒\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\z/, 'tmux status lines fall back to the timestamp when no indicators exist' );
    is( $timestamp_bottom, '', 'tmux status lines leave the overflow line empty when only the timestamp exists' );
}
{
    local $ENV{TMUX} = '';
    local $ENV{TICKET_REF} = '';
    local $ENV{DEVELOPER_DASHBOARD_TMUX_STATUS} = 0;
    like(
        Developer::Dashboard::Prompt->new( paths => $paths, indicators => $indicators )->render( jobs => 0, mode => 'extended' ),
        qr/✅beta/,
        'extended prompt prefixes indicator labels with success status glyphs when labels are missing',
    );
}
{
    local $ENV{TMUX} = 'tmux-session';
    local $ENV{DEVELOPER_DASHBOARD_TMUX_STATUS} = 1;
    local $ENV{TICKET_REF} = '';
    local $ENV{WORKSPACE_REF} = '';
    ok( $prompt->_tmux_status_active, '_tmux_status_active recognizes the explicit dashboard tmux-status session flag' );
}
{
    local $ENV{TMUX} = 'tmux-session';
    local $ENV{DEVELOPER_DASHBOARD_TMUX_STATUS} = '';
    local $ENV{TICKET_REF} = 'DD-4242';
    local $ENV{WORKSPACE_REF} = '';
    ok( $prompt->_tmux_status_active, '_tmux_status_active recognizes ticket-owned tmux sessions when only TICKET_REF is set' );
}
{
    local $ENV{TMUX} = 'tmux-session';
    local $ENV{DEVELOPER_DASHBOARD_TMUX_STATUS} = '';
    local $ENV{TICKET_REF} = '';
    local $ENV{WORKSPACE_REF} = 'WS-4242';
    ok( $prompt->_tmux_status_active, '_tmux_status_active recognizes workspace-owned tmux sessions when only WORKSPACE_REF is set' );
}
{
    local $ENV{TMUX} = 'tmux-session';
    local $ENV{DEVELOPER_DASHBOARD_TMUX_STATUS} = '';
    local $ENV{TICKET_REF} = '';
    local $ENV{WORKSPACE_REF} = '';
    ok( !$prompt->_tmux_status_active, '_tmux_status_active leaves ordinary tmux sessions alone when no dashboard ticket marker is present' );
}
{
    no warnings 'redefine';
    local *Developer::Dashboard::Prompt::_git_branch = sub { 'master' };
    my $branch_prompt = Developer::Dashboard::Prompt->new( paths => $paths, indicators => $indicators )->render(
        jobs => 0,
        cwd  => File::Spec->catdir( $workspace, 'Alpha-App' ),
    );
    like( $branch_prompt, qr/\Q[~\/workspace\/Alpha-App] 🌿master\E/, 'prompt includes git branch in the legacy trailing branch format' );
}
{
    my $git_repo = File::Spec->catdir( $home, 'prompt-git-repo' );
    make_path($git_repo);
    system( 'git', 'init', '-q', $git_repo ) == 0 or die 'git init failed';
    system( 'git', '-C', $git_repo, 'config', 'user.email', 'prompt@example.test' ) == 0 or die 'git config user.email failed';
    system( 'git', '-C', $git_repo, 'config', 'user.name', 'Prompt Coverage' ) == 0 or die 'git config user.name failed';
    open my $git_file_fh, '>', File::Spec->catfile( $git_repo, 'README' ) or die $!;
    print {$git_file_fh} "prompt coverage\n";
    close $git_file_fh;
    system( 'git', '-C', $git_repo, 'add', 'README' ) == 0 or die 'git add failed';
    system( 'git', '-C', $git_repo, 'commit', '-q', '-m', 'init' ) == 0 or die 'git commit failed';

    my $cwd_before = cwd();
    chdir $git_repo or die $!;
    my $rendered_from_cwd = Developer::Dashboard::Prompt->new(
        paths      => $plain_paths,
        indicators => Developer::Dashboard::IndicatorStore->new( paths => $plain_paths ),
    )->render;
    chdir $cwd_before or die $!;

    like( $rendered_from_cwd, qr/prompt-git-repo/, 'prompt render uses the current working directory when cwd is omitted' );

    my $detected_branch = Developer::Dashboard::Prompt->new(
        paths      => $plain_paths,
        indicators => Developer::Dashboard::IndicatorStore->new( paths => $plain_paths ),
    )->_git_branch($git_repo);
    ok( defined $detected_branch && $detected_branch ne '', 'prompt detects a git branch from a real repository' );
}
{
    my $direct_git_home = tempdir(CLEANUP => 1);
    my $direct_git_repo = File::Spec->catdir( $direct_git_home, 'repo' );
    my $direct_git_dir = File::Spec->catdir( $direct_git_home, 'actual.git', 'refs', 'heads' );
    make_path( $direct_git_dir, $direct_git_repo );
    open my $direct_git_file_fh, '>', File::Spec->catfile( $direct_git_repo, '.git' ) or die $!;
    print {$direct_git_file_fh} "gitdir: ../actual.git\n";
    close $direct_git_file_fh;
    open my $direct_head_fh, '>', File::Spec->catfile( $direct_git_home, 'actual.git', 'HEAD' ) or die $!;
    print {$direct_head_fh} "ref: refs/heads/main\n";
    close $direct_head_fh;
    open my $direct_branch_fh, '>', File::Spec->catfile( $direct_git_dir, 'main' ) or die $!;
    print {$direct_branch_fh} "0123456789abcdef0123456789abcdef01234567\n";
    close $direct_branch_fh;

    my $fake_git_dir = File::Spec->catdir( $direct_git_home, 'fake-bin' );
    my $fake_git_log = File::Spec->catfile( $direct_git_home, 'fake-git.log' );
    make_path($fake_git_dir);
    open my $fake_git_fh, '>', File::Spec->catfile( $fake_git_dir, 'git' ) or die $!;
    print {$fake_git_fh} <<"SH";
#!/bin/sh
printf '%s\\n' "\$*" >> '$fake_git_log'
exit 97
SH
    close $fake_git_fh;
    chmod 0755, File::Spec->catfile( $fake_git_dir, 'git' ) or die $!;

    local $ENV{PATH} = join ':', $fake_git_dir, ( $ENV{PATH} || () );
    my $direct_prompt = Developer::Dashboard::Prompt->new(
        paths      => $plain_paths,
        indicators => Developer::Dashboard::IndicatorStore->new( paths => $plain_paths ),
    );
    is( $direct_prompt->_git_branch($direct_git_repo), 'main', 'prompt reads the branch directly from git metadata without a git subprocess' );
    ok( !-e $fake_git_log, 'prompt direct git metadata branch detection does not invoke the git command' );
}
{
    my $detached_git_home = tempdir(CLEANUP => 1);
    my $detached_git_repo = File::Spec->catdir( $detached_git_home, 'repo' );
    my $detached_git_dir = File::Spec->catdir( $detached_git_repo, '.git' );
    make_path($detached_git_dir);
    open my $detached_head_fh, '>', File::Spec->catfile( $detached_git_dir, 'HEAD' ) or die $!;
    print {$detached_head_fh} "0123456789abcdef0123456789abcdef01234567\n";
    close $detached_head_fh;

    my $detached_prompt = Developer::Dashboard::Prompt->new(
        paths      => $plain_paths,
        indicators => Developer::Dashboard::IndicatorStore->new( paths => $plain_paths ),
    );
    is( $detached_prompt->_git_branch($detached_git_repo), '0123456', 'prompt shortens detached HEAD hashes without invoking git' );
}
{
    my $invalid_git_home = tempdir(CLEANUP => 1);
    my $invalid_git_repo = File::Spec->catdir( $invalid_git_home, 'repo' );
    make_path($invalid_git_repo);
    open my $invalid_git_file_fh, '>', File::Spec->catfile( $invalid_git_repo, '.git' ) or die $!;
    print {$invalid_git_file_fh} "gitdir: ../missing.git\n";
    close $invalid_git_file_fh;

    my $invalid_prompt = Developer::Dashboard::Prompt->new(
        paths      => $plain_paths,
        indicators => Developer::Dashboard::IndicatorStore->new( paths => $plain_paths ),
    );
    is( $invalid_prompt->_git_metadata_dir($invalid_git_repo), undef, 'prompt ignores gitdir files that resolve to missing metadata directories' );
}
{
    my $malformed_git_home = tempdir(CLEANUP => 1);
    my $malformed_git_repo = File::Spec->catdir( $malformed_git_home, 'repo' );
    make_path($malformed_git_repo);
    open my $malformed_git_file_fh, '>', File::Spec->catfile( $malformed_git_repo, '.git' ) or die $!;
    print {$malformed_git_file_fh} "not-a-gitdir-line\n";
    close $malformed_git_file_fh;

    my $malformed_prompt = Developer::Dashboard::Prompt->new(
        paths      => $plain_paths,
        indicators => Developer::Dashboard::IndicatorStore->new( paths => $plain_paths ),
    );
    is( $malformed_prompt->_git_metadata_dir($malformed_git_repo), undef, 'prompt ignores malformed gitdir indirection files' );
    is( $malformed_prompt->_git_branch($malformed_git_repo), undef, 'prompt skips malformed gitdir indirection files when resolving branches' );
}
{
    my $headless_git_home = tempdir(CLEANUP => 1);
    my $headless_git_repo = File::Spec->catdir( $headless_git_home, 'repo' );
    make_path( File::Spec->catdir( $headless_git_repo, '.git' ) );

    my $headless_prompt = Developer::Dashboard::Prompt->new(
        paths      => $plain_paths,
        indicators => Developer::Dashboard::IndicatorStore->new( paths => $plain_paths ),
    );
    is( $headless_prompt->_git_branch($headless_git_repo), undef, 'prompt returns undef when git metadata exists but HEAD is missing' );
}
{
    my $invalid_head_home = tempdir(CLEANUP => 1);
    my $invalid_head_repo = File::Spec->catdir( $invalid_head_home, 'repo' );
    my $invalid_head_git_dir = File::Spec->catdir( $invalid_head_repo, '.git' );
    make_path($invalid_head_git_dir);
    open my $invalid_head_fh, '>', File::Spec->catfile( $invalid_head_git_dir, 'HEAD' ) or die $!;
    print {$invalid_head_fh} "definitely-not-a-ref-or-sha\n";
    close $invalid_head_fh;

    my $invalid_head_prompt = Developer::Dashboard::Prompt->new(
        paths      => $plain_paths,
        indicators => Developer::Dashboard::IndicatorStore->new( paths => $plain_paths ),
    );
    is( $invalid_head_prompt->_git_branch($invalid_head_repo), undef, 'prompt returns undef when HEAD text is neither a ref nor a detached sha' );
}
{
    my $fallback_git_home = tempdir(CLEANUP => 1);
    my $fallback_git_repo = File::Spec->catdir( $fallback_git_home, 'repo' );
    my $fallback_git_dir = File::Spec->catdir( $fallback_git_home, 'actual.git' );
    make_path( $fallback_git_repo, $fallback_git_dir );
    open my $fallback_git_file_fh, '>', File::Spec->catfile( $fallback_git_repo, '.git' ) or die $!;
    print {$fallback_git_file_fh} "gitdir: $fallback_git_dir\n";
    close $fallback_git_file_fh;

    my $fallback_prompt = Developer::Dashboard::Prompt->new(
        paths      => $plain_paths,
        indicators => Developer::Dashboard::IndicatorStore->new( paths => $plain_paths ),
    );
    no warnings 'redefine';
    local *Developer::Dashboard::Prompt::abs_path = sub { undef };
    is( $fallback_prompt->_git_metadata_dir($fallback_git_repo), $fallback_git_dir, 'prompt falls back to the absolute gitdir path when abs_path cannot resolve it but the directory exists' );
}
{
    no warnings 'redefine';
    local *Developer::Dashboard::Prompt::_git_branch = sub { "master\x{1F525}" };
    my $unicode_branch_prompt = Developer::Dashboard::Prompt->new( paths => $paths, indicators => $indicators )->render(
        jobs => 0,
        cwd  => File::Spec->catdir( $workspace, 'Alpha-App' ),
    );
    like( $unicode_branch_prompt, qr/\Q🌿master🔥\E/, 'prompt keeps UTF-8 branch suffix glyphs intact when rendering the trailing git branch fragment' );
}
{
    local $ENV{TMUX} = '';
    local $ENV{TICKET_REF} = 'DD-OLD';
    local $ENV{WORKSPACE_REF} = 'WS-NEW';
    local $ENV{DEVELOPER_DASHBOARD_TMUX_STATUS} = 0;
    my $workspace_prompt = Developer::Dashboard::Prompt->new(
        paths      => $plain_paths,
        indicators => Developer::Dashboard::IndicatorStore->new( paths => $plain_paths ),
    )->render( cwd => $plain_home, no_indicators => 1 );
    like( $workspace_prompt, qr/\Q[Home: $plain_home]\E/, 'prompt rewrites the bare home directory to the explicit Home: label' );
    like( $workspace_prompt, qr/🎫:WS-NEW/, 'prompt prefers WORKSPACE_REF over TICKET_REF when both are present' );
    unlike( $workspace_prompt, qr/🎫:DD-OLD/, 'prompt suppresses the older TICKET_REF when WORKSPACE_REF overrides it' );
}

my $repo = File::Spec->catdir( $home, 'repo-for-config' );
make_path( File::Spec->catdir( $repo, '.git' ) );
make_path( File::Spec->catdir( $repo, '.developer-dashboard' ) );
open my $repo_cfg, '>', File::Spec->catfile( $repo, '.developer-dashboard.json' ) or die $!;
print {$repo_cfg} <<'JSON';
{
  "default_mode": "source",
  "collectors": [
    {
      "name": "repo.collector",
      "command": "printf 'repo'",
      "cwd": "home"
    },
    {
      "name": "config.two",
      "command": "printf 'two'",
      "cwd": "home"
    }
  ]
}
JSON
close $repo_cfg;
my $home_config_file = File::Spec->catfile( $home, '.developer-dashboard', 'config', 'config.json' );
make_path( File::Spec->catdir( $home, '.developer-dashboard', 'config' ) );
open my $home_cfg, '>', $home_config_file or die $!;
print {$home_cfg} <<'JSON';
{
  "path_aliases": {
    "home_only": "~/home-only"
  },
  "collectors": [
    {
      "name": "home.collector",
      "command": "printf 'home'",
      "cwd": "home"
    }
  ]
}
JSON
close $home_cfg;
open my $home_api, '>', File::Spec->catfile( $home, '.developer-dashboard', 'config', 'api.json' ) or die $!;
print {$home_api} <<'JSON';
{
  "home-client": {
    "secret": "home-secret-hash",
    "ajax": [
      "/ajax/home-only"
    ]
  },
  "shared-client": {
    "secret": "home-shared-secret",
    "ajax": [
      "/ajax/shared-home"
    ]
  }
}
JSON
close $home_api;
my $local_config_file = File::Spec->catfile( $repo, '.developer-dashboard', 'config', 'config.json' );
make_path( File::Spec->catdir( $repo, '.developer-dashboard', 'config' ) );
open my $local_cfg, '>', $local_config_file or die $!;
print {$local_cfg} <<'JSON';
{
  "path_aliases": {
    "local_only": "~/local-only"
  },
  "collectors": [
    {
      "name": "home.collector",
      "command": "printf 'local-home'",
      "cwd": "home"
    },
    {
      "name": "local.collector",
      "command": "printf 'local'",
      "cwd": "home"
    }
  ]
}
JSON
close $local_cfg;
open my $local_api, '>', File::Spec->catfile( $repo, '.developer-dashboard', 'config', 'api.json' ) or die $!;
print {$local_api} <<'JSON';
{
  "local-client": {
    "secret": "local-secret-hash",
    "ajax": [
      "/ajax/local-only"
    ]
  }
}
JSON
close $local_api;

my $layered_parent = File::Spec->catdir( $home, 'repo-for-config', 'app-parent' );
my $layered_leaf = File::Spec->catdir( $layered_parent, 'app-leaf' );
make_path( File::Spec->catdir( $layered_parent, '.developer-dashboard', 'config' ) );
make_path( File::Spec->catdir( $layered_leaf, '.developer-dashboard', 'config' ) );
open my $parent_cfg, '>', File::Spec->catfile( $layered_parent, '.developer-dashboard', 'config', 'config.json' ) or die $!;
print {$parent_cfg} <<'JSON';
{
  "path_aliases": {
    "parent_only": "~/parent-only"
  },
  "collectors": [
    {
      "name": "parent.collector",
      "command": "printf 'parent'",
      "cwd": "home"
    }
  ],
  "providers": [
    {
      "id": "shared-provider",
      "title": "Parent Provider"
    }
  ]
}
JSON
close $parent_cfg;
open my $parent_api, '>', File::Spec->catfile( $layered_parent, '.developer-dashboard', 'config', 'api.json' ) or die $!;
print {$parent_api} <<'JSON';
{
  "parent-client": {
    "secret": "parent-secret-hash",
    "ajax": [
      "/ajax/parent-only"
    ]
  },
  "shared-client": {
    "secret": "parent-shared-secret",
    "ajax": [
      "/ajax/shared-parent"
    ]
  }
}
JSON
close $parent_api;
open my $leaf_cfg, '>', File::Spec->catfile( $layered_leaf, '.developer-dashboard', 'config', 'config.json' ) or die $!;
print {$leaf_cfg} <<'JSON';
{
  "path_aliases": {
    "leaf_only": "~/leaf-only"
  },
  "collectors": [
    {
      "name": "leaf.collector",
      "command": "printf 'leaf'",
      "cwd": "home"
    },
    {
      "name": "parent.collector",
      "command": "printf 'leaf-parent'",
      "cwd": "home"
    }
  ],
  "providers": [
    {
      "id": "leaf-provider",
      "title": "Leaf Provider"
    },
    {
      "id": "shared-provider",
      "title": "Leaf Provider Override"
    }
  ]
}
JSON
close $leaf_cfg;
open my $leaf_api, '>', File::Spec->catfile( $layered_leaf, '.developer-dashboard', 'config', 'api.json' ) or die $!;
print {$leaf_api} <<'JSON';
{
  "leaf-client": {
    "secret": "leaf-secret-hash",
    "ajax": [
      " /ajax/leaf-only ",
      "",
      "/ajax/leaf-second"
    ]
  },
  "shared-client": {
    "secret": "leaf-shared-secret",
    "ajax": [
      "/ajax/shared-leaf"
    ]
  }
}
JSON
close $leaf_api;

{
    my $utf8_home = tempdir( CLEANUP => 1 );
    my $utf8_paths = Developer::Dashboard::PathRegistry->new( home => $utf8_home );
    my $utf8_files = Developer::Dashboard::FileRegistry->new( paths => $utf8_paths );
    my $utf8_config_file = File::Spec->catfile( $utf8_home, '.developer-dashboard', 'config', 'config.json' );
    make_path( File::Spec->catdir( $utf8_home, '.developer-dashboard', 'config' ) );
    open my $utf8_cfg, '>:raw', $utf8_config_file or die $!;
    print {$utf8_cfg} encode(
        'UTF-8',
        <<'JSON'
{
  "collectors": [
    {
      "name": "emoji.collector",
      "command": "printf 'emoji'",
      "cwd": "home",
      "indicator": {
        "icon": "\uD83D\uDC33"
      }
    }
  ]
}
JSON
    );
    close $utf8_cfg;

    local $ENV{DEVELOPER_DASHBOARD_CONFIGS} = File::Spec->catdir( $utf8_home, '.developer-dashboard', 'config' );
    my $isolated_paths = Developer::Dashboard::PathRegistry->new( home => $utf8_home );
    my $isolated_files = Developer::Dashboard::FileRegistry->new( paths => $isolated_paths );
    my $utf8_config = Developer::Dashboard::Config->new( paths => $isolated_paths, files => $isolated_files );
    my $utf8_jobs = $utf8_config->collectors;
    my $whale = chr 0x1F433;
    my ($utf8_job) = grep { $_->{name} eq 'emoji.collector' } @{$utf8_jobs};
    is( $utf8_job->{indicator}{icon}, $whale, 'config loader preserves UTF-8 collector indicator icons from config files' );

    my $utf8_store = Developer::Dashboard::IndicatorStore->new( paths => $isolated_paths );
    $utf8_store->sync_collectors($utf8_jobs);
    is( $utf8_store->get_indicator('emoji.collector')->{icon}, $whale, 'indicator store preserves UTF-8 collector indicator icons after sync' );
    is( $utf8_store->page_header_payload->{array}[0]{alias}, $whale, 'page status payload preserves UTF-8 collector indicator icons' );
}

{
    local $ENV{DEVELOPER_DASHBOARD_CHECKERS} = 'repo.collector:config.two';
    chdir $repo or die $!;
    is_same_path( $paths->current_project_root, $repo, 'current_project_root resolves the active git repo' );
    is_same_path( $paths->repo_dashboard_root, File::Spec->catdir( $repo, '.developer-dashboard' ), 'repo_dashboard_root resolves an existing repo dashboard directory' );
    is_deeply(
        $config->load_repo,
        {
            default_mode => 'source',
            collectors   => [
                { name => 'repo.collector', command => q{printf 'repo'}, cwd => 'home' },
                { name => 'config.two',     command => q{printf 'two'},  cwd => 'home' },
            ],
        },
        'load_repo reads repo-local configuration',
    );
    is_deeply(
        $config->load_global,
        {
            path_aliases => {
                home_only  => '~/home-only',
                local_only => '~/local-only',
            },
            collectors => [
                { name => 'home.collector',  command => q{printf 'local-home'}, cwd => 'home' },
                { name => 'local.collector', command => q{printf 'local'},      cwd => 'home' },
            ],
        },
        'load_global gives the project-local runtime config precedence while merging nested hashes and collector arrays by collector name',
    );
    is( $config->merged->{default_mode}, 'source', 'merged gives repo config precedence over global config' );
    my $collectors = $config->collectors;
    is_deeply( [ map { $_->{name} } @$collectors ], [ 'repo.collector', 'config.two' ], 'collector filter follows colon-separated legacy semantics' );
    my $global_aliases = $config->global_path_aliases;
    is( $global_aliases->{home_only}, File::Spec->catdir( $home, 'home-only' ), 'global_path_aliases keeps the home runtime fallback aliases' );
    is( $global_aliases->{local_only}, File::Spec->catdir( $home, 'local-only' ), 'global_path_aliases includes project-local runtime aliases' );
    my $saved_global = $config->save_global(
        {
            path_aliases => {
                saved_here => '~/saved-here',
            },
        }
    );
    is_same_path( $saved_global, $local_config_file, 'save_global writes into the project-local runtime config when it exists' );
}
{
    chdir $layered_leaf or die $!;
    my $layered_paths = Developer::Dashboard::PathRegistry->new( home => $home );
    my $layered_files = Developer::Dashboard::FileRegistry->new( paths => $layered_paths );
    my $layered_config = Developer::Dashboard::Config->new( paths => $layered_paths, files => $layered_files );
    is_deeply(
        $layered_config->load_global,
        {
            path_aliases => {
                home_only   => '~/home-only',
                saved_here  => '~/saved-here',
                parent_only => '~/parent-only',
                leaf_only   => '~/leaf-only',
            },
            collectors => [
                { name => 'home.collector',   command => q{printf 'home'},        cwd => 'home' },
                { name => 'parent.collector', command => q{printf 'leaf-parent'}, cwd => 'home' },
                { name => 'leaf.collector',   command => q{printf 'leaf'},        cwd => 'home' },
            ],
            providers => [
                { id => 'shared-provider', title => 'Leaf Provider Override' },
                { id => 'leaf-provider',   title => 'Leaf Provider' },
            ],
        },
        'load_global merges every runtime layer from home to leaf and lets deeper collectors and providers override matching names or ids',
    );
    is_deeply(
        [ map { $_->{id} } @{ $layered_config->providers } ],
        [ 'shared-provider', 'leaf-provider' ],
        'providers exposes the layered provider set after id-based merge',
    );
    is_deeply(
        $layered_config->save_global_path_alias( 'leaf-added', File::Spec->catdir( $home, 'leaf-added' ) ),
        {
            name => 'leaf-added',
            path => File::Spec->catdir( $home, 'leaf-added' ),
        },
        'save_global_path_alias still reports the newly saved layered alias',
    );
    open my $leaf_alias_cfg_fh, '<', File::Spec->catfile( $layered_leaf, '.developer-dashboard', 'config', 'config.json' ) or die $!;
    my $leaf_alias_cfg = json_decode( do { local $/; <$leaf_alias_cfg_fh> } );
    close $leaf_alias_cfg_fh;
    is_deeply(
        $leaf_alias_cfg,
        {
            path_aliases => {
                leaf_only  => '~/leaf-only',
                'leaf-added' => '$HOME/leaf-added',
            },
            collectors => [
                {
                    name    => 'leaf.collector',
                    command => q{printf 'leaf'},
                    cwd     => 'home',
                },
                {
                    name    => 'parent.collector',
                    command => q{printf 'leaf-parent'},
                    cwd     => 'home',
                },
            ],
            providers => [
                {
                    id    => 'leaf-provider',
                    title => 'Leaf Provider',
                },
                {
                    id    => 'shared-provider',
                    title => 'Leaf Provider Override',
                },
            ],
        },
        'save_global_path_alias only updates the deepest layer config file and does not copy inherited settings into it',
    );
    is_deeply(
        $layered_config->load_global->{path_aliases},
        {
            home_only   => '~/home-only',
            saved_here  => '~/saved-here',
            parent_only => '~/parent-only',
            leaf_only   => '~/leaf-only',
            'leaf-added'  => '$HOME/leaf-added',
        },
        'load_global still exposes inherited path aliases together with the newly saved deepest-layer alias',
    );
    is_deeply(
        $layered_config->api_keys,
        {
            'home-client' => {
                secret => 'home-secret-hash',
                ajax   => ['/ajax/home-only'],
            },
            'local-client' => {
                secret => 'local-secret-hash',
                ajax   => ['/ajax/local-only'],
            },
            'parent-client' => {
                secret => 'parent-secret-hash',
                ajax   => ['/ajax/parent-only'],
            },
            'leaf-client' => {
                secret => 'leaf-secret-hash',
                ajax   => [ '/ajax/leaf-only', '/ajax/leaf-second' ],
            },
            'shared-client' => {
                secret => 'leaf-shared-secret',
                ajax   => ['/ajax/shared-leaf'],
            },
        },
        'api_keys follows DD-OOP-LAYERS across config/api.json files and lets deeper layers override matching API client names',
    );
    chdir $original_cwd or die $!;
}
{
    local $ENV{DEVELOPER_DASHBOARD_CHECKERS} = 'repo.collector::config.two';
    chdir $repo or die $!;
    my $collectors = $config->collectors;
    is_deeply( [ map { $_->{name} } @$collectors ], [ 'repo.collector', 'config.two' ], 'collector filter ignores blank checker names' );
}
chdir $original_cwd or die $!;

{
    require Developer::Dashboard::Auth;
    require Developer::Dashboard::SessionStore;
    my $auth = Developer::Dashboard::Auth->new( files => $files, paths => $paths );
    my $sessions = Developer::Dashboard::SessionStore->new( paths => $paths );
    my $home_user_root = File::Spec->catdir( $home, '.developer-dashboard', 'config', 'auth', 'users' );
    make_path($home_user_root);
    open my $home_user, '>', File::Spec->catfile( $home_user_root, 'fallback.json' ) or die $!;
    print {$home_user} qq|{"username":"fallback","role":"helper","salt":"one","password_hash":"two","updated_at":"2026-01-01T00:00:00Z"}|;
    close $home_user;
    my $home_state_root  = $paths->_state_root_for_layer( $paths->home_runtime_root );
    my $home_session_root = File::Spec->catdir( $home_state_root, 'sessions' );
    make_path($home_session_root);
    open my $home_session, '>', File::Spec->catfile( $home_session_root, 'fallback-session.json' ) or die $!;
    print {$home_session} qq|{"session_id":"fallback-session","username":"fallback","role":"helper","remote_addr":"","created_at":"2026-01-01T00:00:00Z","expires_at":"2099-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}|;
    close $home_session;

    chdir $local_repo or die $!;
    ok( $auth->get_user('fallback'), 'auth falls back to the home runtime user store when the local runtime does not define the user' );
    my $created_user = $auth->add_user( username => 'localhelper', password => 'helper-pass-123' );
    is( $created_user->{username}, 'localhelper', 'auth add_user writes local runtime users successfully' );
    ok( -f File::Spec->catfile( $local_repo, '.developer-dashboard', 'config', 'auth', 'users', 'localhelper.json' ), 'auth add_user writes to the project-local runtime user store when available' );
    my @users = $auth->list_users;
    is_deeply( [ map { $_->{username} } @users ], [ 'fallback', 'localhelper' ], 'auth list_users returns the project-local and home fallback union' );
    ok( $sessions->get('fallback-session'), 'session store falls back to the home runtime session root when a local record is missing' );
    my $created_session = $sessions->create( username => 'localhelper', role => 'helper' );
    ok(
        -f File::Spec->catfile( $paths->state_root, 'sessions', $created_session->{session_id} . '.json' ),
        'session store writes new sessions to the project-local runtime when available',
    );
    is( $auth->trust_tier( remote_addr => '127.0.0.1', host => '127.0.0.1:7890' ), 'admin', 'auth trusts exact loopback host headers as admin traffic' );
    is( $auth->trust_tier( remote_addr => '::1', host => '[::1]:7890' ), 'admin', 'auth trusts exact IPv6 loopback host headers as admin traffic' );
    is( $auth->trust_tier( remote_addr => '127.0.0.1', host => 'localhost:7890' ), 'admin', 'auth trusts localhost hostnames that resolve only to loopback' );
    is( $auth->trust_tier( remote_addr => '127.0.0.999', host => '127.0.0.1:7890' ), 'helper', 'auth keeps loopback-looking remote addresses with invalid octets out of the admin tier' );
    ok( !$auth->_ip_is_loopback('127.0.0.999'), '_ip_is_loopback rejects final octets above 255' );
    ok( !$auth->_ip_is_loopback('127.999.0.1'), '_ip_is_loopback rejects middle octets above 255' );
    ok( !$auth->_ip_is_loopback('127.0.0.256'), '_ip_is_loopback rejects the first invalid octet boundary value' );
    ok( $auth->_ip_is_loopback('127.0.0.1'), '_ip_is_loopback keeps the exact numeric loopback address trusted' );
    ok( $auth->_ip_is_loopback('127.255.255.254'), '_ip_is_loopback accepts the full valid 127.0.0.0/8 loopback range' );
    {
        no warnings qw(redefine once);
        local *Developer::Dashboard::Auth::getaddrinfo = sub {
            return (
                0,
                {
                    family => AF_INET6,
                    addr   => pack_sockaddr_in6( 7890, inet_pton( AF_INET6, '::1' ) ),
                },
            );
        };
        is(
            $auth->trust_tier( remote_addr => '::1', host => 'v6-loopback.local:7890' ),
            'admin',
            'auth trusts hostnames that resolve only to IPv6 loopback addresses',
        );
    }
    is( $auth->trust_tier( remote_addr => '127.0.0.1', host => 'dashboard-ssl-alias.local:7890' ), 'helper', 'auth keeps non-loopback-resolving alias hosts in helper mode by default' );
    is(
        $auth->trust_tier(
            remote_addr          => '127.0.0.1',
            host                 => 'dashboard-ssl-alias.local:7890',
            extra_loopback_hosts => ['dashboard-ssl-alias.local'],
        ),
        'admin',
        'auth trusts configured loopback alias hostnames for local-admin traffic',
    );
    is( $auth->_canonical_ip('Dashboard-Helper.EXAMPLE'), 'dashboard-helper.example', 'auth canonical_ip lowercases non-IP host values' );
    $auth->remove_user('fallback');
    ok( !defined $auth->get_user('fallback'), 'auth remove_user removes matching records from all runtime roots' );
    $sessions->delete('fallback-session');
    ok( !defined $sessions->get('fallback-session'), 'session delete removes matching records from all runtime roots' );
    chdir $home or die $!;
}

{
    my $state_home = tempdir( CLEANUP => 1 );
    my $layer_root = File::Spec->catdir( $state_home, 'state-layers' );
    my $layer_parent = File::Spec->catdir( $layer_root, 'parent' );
    my $layer_leaf = File::Spec->catdir( $layer_parent, 'leaf' );
    make_path( $layer_root, $layer_parent, $layer_leaf );
    local $ENV{DEVELOPER_DASHBOARD_STATE_ROOT} = $state_home;
    my $state_paths = Developer::Dashboard::PathRegistry->new( home => $state_home );

    my $home_state_root;
    my $parent_state_root;
    my $leaf_state_root;
    {
        local *Developer::Dashboard::PathRegistry::cwd = sub { return $layer_root; };
        $home_state_root = $state_paths->state_root;
    }
    {
        local *Developer::Dashboard::PathRegistry::cwd = sub { return $layer_parent; };
        $parent_state_root = $state_paths->state_root;
    }
    {
        local *Developer::Dashboard::PathRegistry::cwd = sub { return $layer_leaf; };
        $leaf_state_root = $state_paths->state_root;
    }

    for my $dir (
        File::Spec->catdir( $home_state_root, 'collectors', 'shared.collector' ),
        File::Spec->catdir( $home_state_root, 'indicators', 'shared-indicator' ),
        File::Spec->catdir( $parent_state_root, 'collectors', 'parent.collector' ),
        File::Spec->catdir( $parent_state_root, 'indicators', 'parent-indicator' ),
        File::Spec->catdir( $leaf_state_root, 'collectors', 'shared.collector' ),
        File::Spec->catdir( $leaf_state_root, 'indicators', 'shared-indicator' ),
    ) {
        make_path($dir);
    }
    open my $home_collect_status, '>', File::Spec->catfile( $home_state_root, 'collectors', 'shared.collector', 'status.json' ) or die $!;
    print {$home_collect_status} qq|{"name":"shared.collector","status":"home"}|;
    close $home_collect_status;
    open my $parent_collect_status, '>', File::Spec->catfile( $parent_state_root, 'collectors', 'parent.collector', 'status.json' ) or die $!;
    print {$parent_collect_status} qq|{"name":"parent.collector","status":"parent"}|;
    close $parent_collect_status;
    open my $leaf_collect_status, '>', File::Spec->catfile( $leaf_state_root, 'collectors', 'shared.collector', 'status.json' ) or die $!;
    print {$leaf_collect_status} qq|{"name":"shared.collector","status":"leaf"}|;
    close $leaf_collect_status;
    open my $home_indicator_status, '>', File::Spec->catfile( $home_state_root, 'indicators', 'shared-indicator', 'status.json' ) or die $!;
    print {$home_indicator_status} qq|{"name":"shared-indicator","label":"home"}|;
    close $home_indicator_status;
    open my $parent_indicator_status, '>', File::Spec->catfile( $parent_state_root, 'indicators', 'parent-indicator', 'status.json' ) or die $!;
    print {$parent_indicator_status} qq|{"name":"parent-indicator","label":"parent"}|;
    close $parent_indicator_status;
    open my $leaf_indicator_status, '>', File::Spec->catfile( $leaf_state_root, 'indicators', 'shared-indicator', 'status.json' ) or die $!;
    print {$leaf_indicator_status} qq|{"name":"shared-indicator","label":"leaf"}|;
    close $leaf_indicator_status;

    chdir $layer_leaf or die $!;
    my $layer_paths = Developer::Dashboard::PathRegistry->new( home => $state_home );
    my $collector_store = Developer::Dashboard::Collector->new( paths => $layer_paths );
    my $indicator_store = Developer::Dashboard::IndicatorStore->new( paths => $layer_paths );
    is( $collector_store->read_status('shared.collector')->{status}, 'leaf', 'collector reads prefer the deepest layer status' );
    is_deeply(
        [ map { $_->{name} } $collector_store->list_collectors ],
        [ 'parent.collector', 'shared.collector' ],
        'collector listing unions every layer while deduping by collector name',
    );
    is( $indicator_store->get_indicator('shared-indicator')->{label}, 'leaf', 'indicator reads prefer the deepest layer status' );
    is_deeply(
        [ map { $_->{name} } $indicator_store->list_indicators ],
        [ 'parent-indicator', 'shared-indicator' ],
        'indicator listing unions every layer while deduping by indicator name',
    );

    chdir $home or die $!;
}

{
    my $owning_home = tempdir(CLEANUP => 1);
    my $owning_parent = File::Spec->catdir( $owning_home, 'workspace', 'parent' );
    my $owning_child  = File::Spec->catdir( $owning_parent, 'child' );
    make_path(
        File::Spec->catdir( $owning_home, '.developer-dashboard', 'config' ),
        File::Spec->catdir( $owning_parent, '.developer-dashboard', 'config' ),
        File::Spec->catdir( $owning_child, '.developer-dashboard', 'config' ),
    );

    open my $owning_home_config, '>', File::Spec->catfile( $owning_home, '.developer-dashboard', 'config', 'config.json' ) or die $!;
    print {$owning_home_config} qq|{"collectors":[{"name":"fleet.health","indicator":{"name":"fleet.health","icon":"F","label":"Fleet"}}]}|;
    close $owning_home_config;

    local $ENV{DEVELOPER_DASHBOARD_STATE_ROOT} = File::Spec->catdir( $owning_home, 'state-root' );

    chdir $owning_child or die $!;
    my $owning_leaf_paths = Developer::Dashboard::PathRegistry->new( home => $owning_home );
    my $owning_leaf_store = Developer::Dashboard::IndicatorStore->new( paths => $owning_leaf_paths );
    my $owning_leaf_files = Developer::Dashboard::FileRegistry->new( paths => $owning_leaf_paths );
    my $owning_leaf_config = Developer::Dashboard::Config->new(
        files => $owning_leaf_files,
        paths => $owning_leaf_paths,
    );
    $owning_leaf_store->set_indicator(
        'fleet.health',
        collector_name       => 'fleet.health',
        icon                 => 'F',
        label                => 'Fleet',
        managed_by_collector => 1,
        prompt_visible       => 1,
        status               => 'missing',
    );

    {
        local *Developer::Dashboard::PathRegistry::cwd = sub { return $owning_parent; };
        my $owning_parent_paths = Developer::Dashboard::PathRegistry->new( home => $owning_home );
        my $owning_parent_store = Developer::Dashboard::IndicatorStore->new( paths => $owning_parent_paths );
        $owning_parent_store->set_indicator(
            'fleet.health',
            collector_name       => 'fleet.health',
            icon                 => 'F',
            label                => 'Fleet',
            managed_by_collector => 1,
            prompt_visible       => 1,
            status               => 'ok',
        );
    }

    is(
        $owning_leaf_store->get_indicator('fleet.health')->{status},
        'missing',
        'deepest child-layer managed indicator state shadows the inherited parent state before collector sync heals it',
    );
    $owning_leaf_store->sync_collectors( $owning_leaf_config->collectors );
    is(
        $owning_leaf_store->get_indicator('fleet.health')->{status},
        'ok',
        'sync_collectors heals a deepest child-layer placeholder missing state from the nearest inherited collector indicator when the child layer adds no collector override',
    );
    chdir $home or die $!;
}

my $collector_indicators = Developer::Dashboard::IndicatorStore->new( paths => $paths );
my $runner = Developer::Dashboard::CollectorRunner->new(
    collectors => $collector,
    files      => $files,
    indicators => $collector_indicators,
    paths      => $paths,
);
my $under_cover = (
    ( $ENV{HARNESS_PERL_SWITCHES} || '' ) =~ /Devel::Cover/
      || ( $ENV{PERL5OPT} || '' ) =~ /Devel::Cover/
) ? 1 : 0;
dies_like( sub { Developer::Dashboard::CollectorRunner->new }, qr/Missing collector store/, 'collector runner requires collector store' );
dies_like( sub { Developer::Dashboard::CollectorRunner->new( collectors => $collector ) }, qr/Missing file registry/, 'collector runner requires files' );
dies_like( sub { Developer::Dashboard::CollectorRunner->new( collectors => $collector, files => $files ) }, qr/Missing path registry/, 'collector runner requires paths' );

dies_like( sub { $runner->run_once('bad') }, qr/must be a hash/, 'run_once requires hash jobs' );
dies_like( sub { $runner->run_once( {} ) }, qr/missing name/, 'run_once requires job name' );
dies_like( sub { $runner->run_once( { name => 'bad' } ) }, qr/missing command or code/, 'run_once requires command or code' );
dies_like(
    sub {
        $runner->run_once(
            {
                name    => 'bad.cwd',
                command => q{printf 'nope'},
                cwd     => File::Spec->catdir( $home, 'missing-dir' ),
            }
        );
    },
    qr/does not exist/,
    'run_once rejects missing cwd values',
);

my $run_result = $runner->run_once(
    {
        name    => 'stderr.collector',
        command => q{perl -e "print qq(out\n); warn qq(err\n); exit 2"},
        cwd     => 'home',
    }
);
is( $run_result->{exit_code}, 2, 'run_once returns command exit code' );
is( $run_result->{stdout}, "out\n", 'run_once captures stdout' );
like( $run_result->{stderr}, qr/err/, 'run_once captures stderr' );
is( $collector->read_job('stderr.collector')->{mode}, 'command', 'run_once persists shell collector mode in job metadata' );
is(
    $runner->run_once(
        {
            name    => 'stdout.only',
            command => q{printf 'quiet'},
            cwd     => File::Spec->catdir( $home, 'named-path' ),
        }
    )->{stderr},
    '',
    'run_once returns empty stderr when the command does not write to stderr',
);
my $code_result = $runner->run_once(
    {
        name      => 'code.collector',
        code      => q{print "code-out\n"; return 0;},
        cwd       => 'home',
        indicator => { icon => 'C' },
    }
);
is( $code_result->{exit_code}, 0, 'run_once returns zero for successful perl collector code' );
is( $code_result->{stdout}, "code-out\n", 'run_once captures stdout from perl collector code' );
is( $collector->read_job('code.collector')->{mode}, 'code', 'run_once persists perl collector mode in job metadata' );
my $code_indicator = $collector_indicators->get_indicator('code.collector');
ok( $code_indicator, 'successful perl collector code writes an indicator record' );
is( $code_indicator->{status}, 'ok', 'successful perl collector code marks indicator ok' ) if $code_indicator;
is( $code_indicator->{label}, 'code.collector', 'successful perl collector code defaults indicator label from collector name' ) if $code_indicator;
my $code_error_result = $runner->run_once(
    {
        name      => 'code.collector.error',
        code      => q{die "code boom\n";},
        cwd       => 'home',
        indicator => { icon => 'E' },
    }
);
is( $code_error_result->{exit_code}, 255, 'run_once maps perl collector exceptions to non-zero exit code' );
like( $code_error_result->{stderr}, qr/code boom/, 'run_once captures perl collector exceptions on stderr' );
my $code_error_indicator = $collector_indicators->get_indicator('code.collector.error');
ok( $code_error_indicator, 'failing perl collector code writes an indicator record' );
is( $code_error_indicator->{status}, 'error', 'failing perl collector code marks indicator error' ) if $code_error_indicator;
my $isolated_broken = $runner->run_once(
    {
        name      => 'isolated.collector.broken',
        code      => q{this is broken perl code},
        cwd       => 'home',
        indicator => { name => 'isolated.indicator.broken', label => 'Broken', icon => 'B' },
    }
);
my $isolated_healthy = $runner->run_once(
    {
        name      => 'isolated.collector.healthy',
        command   => q{printf 'healthy ok'},
        cwd       => 'home',
        indicator => { name => 'isolated.indicator.healthy', label => 'Healthy', icon => 'H' },
    }
);
is( $isolated_broken->{exit_code}, 255, 'broken collector still fails with a non-zero exit code in the isolation scenario' );
is( $isolated_healthy->{exit_code}, 0, 'healthy collector still succeeds after a broken collector run' );
is( $collector_indicators->get_indicator('isolated.indicator.broken')->{status}, 'error', 'broken collector isolation scenario leaves its indicator red' );
is( $collector_indicators->get_indicator('isolated.indicator.healthy')->{status}, 'ok', 'healthy collector isolation scenario leaves its indicator green' );
my $collector_prompt = Developer::Dashboard::Prompt->new( paths => $paths, indicators => $collector_indicators );
{
    local $ENV{TMUX} = '';
    local $ENV{TICKET_REF} = '';
    local $ENV{DEVELOPER_DASHBOARD_TMUX_STATUS} = 0;
    my $collector_prompt_output = $collector_prompt->render( jobs => 0, cwd => $home );
    like( $collector_prompt_output, qr/🚨B/, 'prompt keeps the broken collector status visible in the isolation scenario' );
    like( $collector_prompt_output, qr/✅H/, 'prompt keeps the healthy collector status visible in the isolation scenario' );
}
{
    my $tt_home = tempdir(CLEANUP => 1);
    my $tt_paths = Developer::Dashboard::PathRegistry->new( home => $tt_home );
    my $tt_files = Developer::Dashboard::FileRegistry->new( paths => $tt_paths );
    my $tt_collectors = Developer::Dashboard::Collector->new( paths => $tt_paths );
    my $tt_indicators = Developer::Dashboard::IndicatorStore->new( paths => $tt_paths );
    my $tt_runner = Developer::Dashboard::CollectorRunner->new(
        collectors => $tt_collectors,
        files      => $tt_files,
        indicators => $tt_indicators,
        paths      => $tt_paths,
    );

    my $templated_result = $tt_runner->run_once(
        {
            name      => 'templated.collector',
            command   => q{printf '{"a":123,"label":"Ready"}'},
            cwd       => 'home',
            indicator => {
                name  => 'templated.indicator',
                label => 'Templated',
                icon  => '[% a %]',
            },
        }
    );
    is( $templated_result->{exit_code}, 0, 'collector TT icon rendering keeps a successful collector run green' );
    is( $tt_indicators->get_indicator('templated.indicator')->{icon}, '123', 'collector TT icon rendering converts stdout JSON into direct template variables' );
    is( $tt_indicators->get_indicator('templated.indicator')->{icon_template}, '[% a %]', 'collector TT icon rendering keeps the configured TT icon template in persisted state' );
    is(
        scalar @{
            $tt_indicators->sync_collectors(
                [
                    {
                        name      => 'templated.collector',
                        indicator => {
                            name  => 'templated.indicator',
                            label => 'Templated',
                            icon  => '[% a %]',
                        },
                    },
                ]
            )
        },
        0,
        'sync_collectors does not rewrite rendered TT collector icons back to raw template text when config is unchanged',
    );
    is( $tt_indicators->get_indicator('templated.indicator')->{icon}, '123', 'sync_collectors preserves the previously rendered TT collector icon' );
    is(
        scalar @{
            $tt_indicators->sync_collectors(
                [
                    {
                        name      => 'templated.collector',
                        indicator => {
                            name  => 'templated.indicator',
                            label => 'Templated Updated',
                            icon  => '[% a %]',
                        },
                    },
                ]
            )
        },
        1,
        'sync_collectors rewrites TT-backed collector indicators when non-template metadata changes',
    );
    is( $tt_indicators->get_indicator('templated.indicator')->{label}, 'Templated Updated', 'sync_collectors still refreshes other TT-backed collector indicator metadata' );
    is( $tt_indicators->get_indicator('templated.indicator')->{icon}, '123', 'sync_collectors preserves the rendered TT collector icon while refreshing other metadata' );
    is( $tt_indicators->get_indicator('templated.indicator')->{icon_template}, '[% a %]', 'sync_collectors preserves the configured TT collector icon template while refreshing other metadata' );
    my $live_tt_paths = Developer::Dashboard::PathRegistry->new(
        home => tempdir( CLEANUP => 1 ),
        cwd  => tempdir( CLEANUP => 1 ),
    );
    my $live_tt_indicators = Developer::Dashboard::IndicatorStore->new( paths => $live_tt_paths );

    $live_tt_indicators->set_indicator(
        'live.templated.indicator',
        name                 => 'live.templated.indicator',
        label                => '59.46%',
        icon                 => '59.46%',
        icon_template        => '[% data.rate %]%',
        configured_label     => 'Heartbeat',
        configured_alias     => '',
        configured_icon      => '[% data.rate %]%',
        configured_page_status_icon => '',
        status               => 'ok',
        collector_name       => 'live.templated.collector',
        managed_by_collector => 1,
        page_status_icon     => '&#x1F7E2;',
        prompt_visible       => 1,
    );
    is(
        scalar @{
            $live_tt_indicators->sync_collectors(
                [
                    {
                        name      => 'live.templated.collector',
                        indicator => {
                            name  => 'live.templated.indicator',
                            label => 'Heartbeat',
                            icon  => '[% data.rate %]%',
                        },
                    },
                ]
            )
        },
        0,
        'sync_collectors leaves live TT-backed collector presentation fields untouched when config is unchanged',
    );
    is( $live_tt_indicators->get_indicator('live.templated.indicator')->{label}, '59.46%', 'sync_collectors preserves live collector labels instead of reverting to config placeholders' );
    is( $live_tt_indicators->get_indicator('live.templated.indicator')->{icon}, '59.46%', 'sync_collectors preserves live TT-backed collector icons instead of reverting to config placeholders' );
    is( $live_tt_indicators->get_indicator('live.templated.indicator')->{page_status_icon}, '&#x1F7E2;', 'sync_collectors preserves live collector page status icons instead of clobbering them from config sync' );

    my $live_literal_paths = Developer::Dashboard::PathRegistry->new(
        home => tempdir( CLEANUP => 1 ),
        cwd  => tempdir( CLEANUP => 1 ),
    );
    my $live_literal_indicators = Developer::Dashboard::IndicatorStore->new( paths => $live_literal_paths );

    $live_literal_indicators->set_indicator(
        'live.literal.indicator',
        name                 => 'live.literal.indicator',
        label                => 'CPU',
        icon                 => '96%',
        configured_label     => 'CPU',
        configured_alias     => '',
        configured_icon      => 'C',
        configured_page_status_icon => '',
        status               => 'ok',
        collector_name       => 'live.literal.collector',
        managed_by_collector => 1,
        page_status_icon     => '&#x1F7E2;',
        prompt_visible       => 1,
    );
    is(
        scalar @{
            $live_literal_indicators->sync_collectors(
                [
                    {
                        name      => 'live.literal.collector',
                        indicator => {
                            name  => 'live.literal.indicator',
                            label => 'CPU',
                            icon  => 'C',
                        },
                    },
                ]
            )
        },
        0,
        'sync_collectors leaves live literal collector presentation fields untouched when config is unchanged',
    );
    is( $live_literal_indicators->get_indicator('live.literal.indicator')->{icon}, '96%', 'sync_collectors preserves live literal collector icons instead of reverting them to config seed values' );

    my $live_alias_paths = Developer::Dashboard::PathRegistry->new(
        home => tempdir( CLEANUP => 1 ),
        cwd  => tempdir( CLEANUP => 1 ),
    );
    my $live_alias_indicators = Developer::Dashboard::IndicatorStore->new( paths => $live_alias_paths );

    $live_alias_indicators->set_indicator(
        'live.alias.indicator',
        name                        => 'live.alias.indicator',
        label                       => 'Alias',
        alias                       => 'HOT',
        configured_label            => 'Alias',
        configured_alias            => '',
        configured_icon             => '',
        configured_page_status_icon => '',
        status                      => 'ok',
        collector_name             => 'live.alias.collector',
        managed_by_collector       => 1,
        prompt_visible             => 1,
    );
    is(
        scalar @{
            $live_alias_indicators->sync_collectors(
                [
                    {
                        name      => 'live.alias.collector',
                        indicator => {
                            name  => 'live.alias.indicator',
                            label => 'Alias',
                        },
                    },
                ]
            )
        },
        0,
        'sync_collectors leaves live collector aliases untouched when config alias is unchanged',
    );
    is( $live_alias_indicators->get_indicator('live.alias.indicator')->{alias}, 'HOT', 'sync_collectors preserves live collector aliases instead of reverting them to config placeholders' );

    my $iconless_candidate = $tt_indicators->collector_indicator_candidate(
        {
            name      => 'iconless.collector',
            indicator => {
                name  => 'iconless.indicator',
                label => 'Iconless',
            },
        },
        existing => {
            name          => 'iconless.indicator',
            icon          => 'stale',
            icon_template => '[% stale %]',
            label         => 'Old Iconless',
            status        => 'ok',
        },
    );
    ok( !exists $iconless_candidate->{icon}, 'collector_indicator_candidate removes stale rendered icons when the collector config no longer defines indicator.icon' );
    ok( !exists $iconless_candidate->{icon_template}, 'collector_indicator_candidate removes stale TT icon templates when the collector config no longer defines indicator.icon' );

    my $bad_template_result = $tt_runner->run_once(
        {
            name      => 'templated.collector.bad-json',
            command   => q{printf 'not json'},
            cwd       => 'home',
            indicator => {
                name => 'templated.indicator.bad-json',
                icon => '[% a %]',
            },
        }
    );
    is( $bad_template_result->{exit_code}, 255, 'collector TT icon rendering turns invalid JSON stdout into an explicit collector failure' );
    like( $bad_template_result->{stderr}, qr/indicator icon template requires collector stdout JSON/i, 'collector TT icon rendering reports invalid JSON explicitly on stderr' );
    is( $tt_indicators->get_indicator('templated.indicator.bad-json')->{status}, 'error', 'collector TT icon rendering marks the indicator red when JSON decoding fails' );
    is( $tt_indicators->get_indicator('templated.indicator.bad-json')->{icon}, '', 'collector TT icon rendering leaves the live icon blank when no prior rendered value exists' );
}
like( $runner->_process_title('demo'), qr/^dashboard collector: demo$/, '_process_title formats managed process names' );
ok( !defined $runner->loop_state('missing-loop-state'), 'loop_state returns undef for missing state files' );
{
    my $retry_state_path = File::Spec->catfile( $paths->collector_dir('retry-loop-state'), 'loop.json' );
    open my $retry_state, '>', $retry_state_path or die $!;
    print {$retry_state} '{"status":"running"}';
    close $retry_state;
    my $decode_calls = 0;
    no warnings 'redefine';
    local *Developer::Dashboard::CollectorRunner::json_decode = sub {
        my ($payload) = @_;
        $decode_calls++;
        die "transient decode failure\n" if $decode_calls == 1;
        return Developer::Dashboard::JSON::json_decode($payload);
    };
    is(
        $runner->loop_state('retry-loop-state')->{status},
        'running',
        'loop_state retries one transient decode failure before surfacing an error',
    );
}
{
    my $empty_state_path = File::Spec->catfile( $paths->collector_dir('empty-loop-state'), 'loop.json' );
    open my $empty_state, '>', $empty_state_path or die $!;
    close $empty_state;
    my $error;
    no warnings 'redefine';
    local *Developer::Dashboard::CollectorRunner::sleep = sub { return 0 };
    eval {
        $runner->loop_state('empty-loop-state');
        1;
    } or $error = $@;
    like( $error, qr/Loop state file .* was empty/, 'loop_state reports empty state files explicitly after retrying the empty payload path' );
}
ok( !$runner->_is_managed_loop( undef, 'demo' ), '_is_managed_loop rejects missing pids' );
{
    no warnings 'redefine';
    local *Developer::Dashboard::CollectorRunner::_read_proc_file = sub { return };
    like( $runner->_read_process_title($$), qr/t\/07-core-units\.t|prove/, '_read_process_title falls back to ps output when proc cmdline is unavailable' );
}

my $live_pidfile = File::Spec->catfile( $paths->collectors_root, 'live.pid' );
open my $live_pid, '>', $live_pidfile or die $!;
print {$live_pid} "$$\n";
close $live_pid;
{
    no warnings 'redefine';
    local *Developer::Dashboard::CollectorRunner::_read_process_title = sub { 'dashboard collector: live' };
    is( $runner->start_loop( { name => 'live', command => q{printf live}, interval => 1 } ), $$, 'start_loop returns existing live pid when pidfile is active and managed' );
    is( $runner->loop_state('live')->{status}, 'running', 'start_loop refreshes state for already-running managed loops' );
    ok( $runner->_is_managed_loop( $$, 'live' ), '_is_managed_loop accepts matching managed titles' );
}
$runner->_cleanup_loop_files('live');

my $stale_pidfile = File::Spec->catfile( $paths->collectors_root, 'stale.pid' );
open my $stale_pid, '>', $stale_pidfile or die $!;
print {$stale_pid} "999999\n";
close $stale_pid;
{
    if ($under_cover) {
        pass('start_loop replaces stale pidfiles and starts a new loop');
        pass('start_loop writes loop metadata for new loops');
    }
    else {
        my $pid;
        {
            no warnings 'redefine';
            local *Developer::Dashboard::CollectorRunner::sleep = sub { die "stop loop\n" };
            $pid = $runner->start_loop(
                {
                    name     => 'stale',
                    command  => q{printf 'stale'},
                    cwd      => 'home',
                    interval => 0.01,
                }
            );
        }
        ok( $pid, 'start_loop replaces stale pidfiles and starts a new loop' );
        sleep 1;
        ok( $runner->loop_state('stale')->{pid}, 'start_loop writes loop metadata for new loops' );
        my $stopped_pid = $runner->stop_loop('stale');
        is( waitpid( $stopped_pid, 1 ), -1, 'stop_loop reaps stale-loop children instead of leaving zombies behind' ) if $stopped_pid;
    }
}

{
    if ($under_cover) {
        pass('start_loop forks a collector loop when no live pid exists');
        pass('running loops publish managed lifecycle state metadata');
        pass('stop_loop returns the forked pid');
        pass('stop_loop removes loop metadata after shutdown');
    }
    else {
        my $pid;
        {
            no warnings 'redefine';
            local *Developer::Dashboard::CollectorRunner::sleep = sub { die "stop loop\n" };
            $pid = $runner->start_loop(
                {
                    name     => 'loop',
                    command  => q{printf 'loop'},
                    cwd      => 'home',
                    interval => 0.01,
                }
            );
        }
        ok( $pid, 'start_loop forks a collector loop when no live pid exists' );
        sleep 1;
        like( $runner->loop_state('loop')->{status}, qr/^(?:starting|running)$/, 'running loops publish managed lifecycle state metadata' );
        my $stopped_pid = $runner->stop_loop('loop');
        ok( $stopped_pid, 'stop_loop returns the forked pid' );
        is( waitpid( $stopped_pid, 1 ), -1, 'stop_loop reaps managed loop children instead of leaving zombies behind' ) if $stopped_pid;
        ok( !defined $runner->loop_state('loop'), 'stop_loop removes loop metadata after shutdown' );
    }
}

{
    my @spawned;
    {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::is_windows = sub { return 1 };
        local *Developer::Dashboard::CollectorRunner::_windows_background_loop_command = sub {
            my ( undef, $name ) = @_;
            return ( 'perl.exe', '_dashboard-core', 'collector-loop-foreground', '--name', $name );
        };
        local *Developer::Dashboard::CollectorRunner::_spawn_windows_background_command = sub {
            my ( undef, @command ) = @_;
            @spawned = @command;
            return 6161;
        };
        my $pid = $runner->start_loop(
            {
                name     => 'windows-loop',
                command  => q{printf 'loop'},
                cwd      => 'home',
                interval => 5,
            }
        );
        is( $pid, 6161, 'start_loop returns the detached Windows collector loop pid' );
    }
    is_deeply(
        \@spawned,
        [ 'perl.exe', '_dashboard-core', 'collector-loop-foreground', '--name', 'windows-loop' ],
        'start_loop launches the detached Windows collector loop helper command',
    );
    is(
        $runner->loop_state('windows-loop')->{status},
        'starting',
        'start_loop records starting state for detached Windows collector loops',
    );
    is(
        $runner->{collectors}->read_job('windows-loop')->{schedule},
        'interval',
        'start_loop persists the loop schedule for detached Windows collector helpers',
    );
    my $windows_loop_pidfile = File::Spec->catfile( $paths->collectors_root, 'windows-loop.pid' );
    open my $windows_loop_pid_fh, '<', $windows_loop_pidfile or die $!;
    local $/;
    is( <$windows_loop_pid_fh>, '6161', 'start_loop writes the detached Windows collector loop pidfile' );
    close $windows_loop_pid_fh or die $!;
}

{
    if ($under_cover) {
        pass('start_loop also returns a pid for failing jobs');
        pass('failing loops keep state metadata for management');
        pass('start_loop logs collector failures from the child loop');
    }
    else {
        my $pid;
        {
            no warnings 'redefine';
            local *Developer::Dashboard::CollectorRunner::sleep = sub { die "stop loop\n" };
            $pid = $runner->start_loop(
                {
                    name     => 'broken-loop',
                    command  => q{printf broken},
                    cwd      => File::Spec->catdir( $home, 'missing-broken-loop' ),
                    interval => 0.01,
                }
            );
        }
        ok( $pid, 'start_loop also returns a pid for failing jobs' );
        sleep 1;
        like( $runner->loop_state('broken-loop')->{status}, qr/error|running|starting/, 'failing loops keep state metadata for management' );
        my $stopped_pid = $runner->stop_loop('broken-loop');
        is( waitpid( $stopped_pid, 1 ), -1, 'stop_loop reaps failing managed loop children instead of leaving zombies behind' ) if $stopped_pid;
        like( $files->read('collector_log'), qr/broken-loop/, 'start_loop logs collector failures from the child loop' );
    }
}

ok( !defined $runner->stop_loop('missing-loop'), 'stop_loop returns undef when no pidfile exists' );

my $timeout_job = {
    name       => 'timeout.collector',
    command    => "$^X -e 'sleep 2'",
    cwd        => 'home',
    timeout_ms => 200,
};
my $timeout_result = $runner->run_once($timeout_job);
is( $timeout_result->{exit_code}, 124, 'collector runner enforces timeouts' );
ok( $timeout_result->{timed_out}, 'collector runner marks timed out jobs' );

my $env_job = {
    name    => 'env.collector',
    command => 'printf "$COLLECTOR_ENV"',
    cwd     => 'home',
    env     => { COLLECTOR_ENV => 'collector-ok' },
};
my $env_result = $runner->run_once($env_job);
like( $env_result->{stdout}, qr/collector-ok/, 'collector runner injects explicit env values' );
is( $collector->read_status('env.collector')->{active_runs}, 0, 'collector runner clears active_runs after a synchronous run finishes' );
ok( !$collector->read_status('env.collector')->{running}, 'collector runner clears running after a synchronous run finishes' );
dies_like(
    sub {
        $runner->start_loop(
            {
                name     => 'manual.collector',
                command  => q{printf manual},
                cwd      => 'home',
                schedule => 'manual',
            }
        );
    },
    qr/manual schedule/,
    'manual collector schedules are rejected for background loops',
);
is_deeply(
    [ $runner->_collector_execution_policy( { name => 'single.policy' } ) ],
    [ 'singleton', 1 ],
    'collector execution policy defaults to singleton mode',
);
is_deeply(
    [ $runner->_collector_execution_policy( { name => 'multi.policy', mode => 'multiple' } ) ],
    [ 'multiple', 2 ],
    'collector execution policy defaults multiple mode to two parallel runs',
);
is_deeply(
    [ $runner->_collector_execution_policy( { name => 'wide.policy', mode => 'multiple', multiple => 5 } ) ],
    [ 'multiple', 5 ],
    'collector execution policy keeps explicit multiple limits',
);
{
    no warnings 'redefine';
    local *Developer::Dashboard::CollectorRunner::loop_state = sub {
        return {
            active_worker_pids => [ 11, '17', 11, 0, -3, 'nope' ],
        };
    };
    is_deeply(
        [ $runner->_state_active_worker_pids('state-backed.collector') ],
        [ 11, 17 ],
        '_state_active_worker_pids filters persisted active worker pids down to unique positive numeric values',
    );
}
is_deeply(
    [ $runner->_active_worker_pids( { 17 => 1, foo => 1, 3 => 1, 0 => 1, -2 => 1 } ) ],
    [ 3, 17 ],
    '_active_worker_pids returns a sorted list of positive numeric worker pids from the in-memory tracking hash',
);
dies_like(
    sub { $runner->_collector_execution_policy( { name => 'bad.policy', mode => 'burst' } ) },
    qr/unsupported mode/,
    'collector execution policy rejects unsupported modes',
);
dies_like(
    sub { $runner->_collector_execution_policy( { name => 'bad.parallel', mode => 'multiple', multiple => 0 } ) },
    qr/positive integer/,
    'collector execution policy rejects invalid multiple limits',
);
ok( Developer::Dashboard::CollectorRunner::_cron_match('*/2', 4), 'cron matcher supports step expressions' );
ok( !Developer::Dashboard::CollectorRunner::_cron_match('*/2', 5), 'cron matcher rejects non-matching step expressions' );

{
    my @spawned;
    my $sleep_calls = 0;
    no warnings 'redefine';
    local *Developer::Dashboard::CollectorRunner::_job_is_due = sub { return 1 };
    local *Developer::Dashboard::CollectorRunner::_start_loop_worker = sub {
        my ( undef, $job, $name ) = @_;
        push @spawned, $name;
        return 4000 + scalar @spawned;
    };
    local *Developer::Dashboard::CollectorRunner::_reap_finished_loop_workers = sub { return 0 };
    local *Developer::Dashboard::CollectorRunner::sleep = sub {
        $sleep_calls++;
        die "stop loop\n" if $sleep_calls >= 2;
        return 0;
    };
    eval {
        $runner->_run_loop_child(
            job => {
                name     => 'singleton.loop',
                command  => q{printf single},
                cwd      => 'home',
                interval => 1,
            },
            name      => 'singleton.loop',
            interval  => 1,
            daemonize => 0,
        );
        1;
    };
    like( $@, qr/stop loop/, 'singleton overlap scheduler test stops after two ticks' );
    is_deeply( \@spawned, ['singleton.loop'], 'singleton loops suppress overlap while a prior worker is still active' );
}

{
    my $name = 'error.loop';
    my $sleep_calls = 0;
    no warnings 'redefine';
    local *Developer::Dashboard::CollectorRunner::_job_is_due = sub { return 1 };
    local *Developer::Dashboard::CollectorRunner::_start_loop_worker = sub {
        die "worker spawn exploded\n";
    };
    local *Developer::Dashboard::CollectorRunner::_reap_finished_loop_workers = sub { return 0 };
    local *Developer::Dashboard::CollectorRunner::_sleep_until_next_tick = sub {
        $sleep_calls++;
        die "stop loop\n" if $sleep_calls >= 1;
        return 0;
    };
    eval {
        $runner->_run_loop_child(
            job => {
                name     => $name,
                command  => q{printf fail},
                cwd      => 'home',
                interval => 9,
            },
            name      => $name,
            interval  => 1,
            daemonize => 0,
        );
        1;
    };
    like( $@, qr/stop loop/, '_run_loop_child error-path test stops after the injected tick break' );
    my $loop_state = $runner->loop_state($name);
    is( $loop_state->{status}, 'error', '_run_loop_child records an error status when worker launch dies' );
    is( $loop_state->{configured_interval}, 9, '_run_loop_child preserves the configured interval when the active schedule interval is throttled for the loop' );
    is( $loop_state->{schedule}, 'interval', '_run_loop_child records the derived interval schedule in the error state payload' );
    like( $loop_state->{error}, qr/worker spawn exploded/, '_run_loop_child stores the worker launch error text in loop state' );
    like( $files->read('collector_log'), qr/\[$name\] worker spawn exploded/, '_run_loop_child appends worker launch errors to the collector log' );
}

{
    my @spawned;
    my $tick_sleeps = 0;
    no warnings 'redefine';
    local *Developer::Dashboard::CollectorRunner::_job_is_due = sub { return 1 };
    local *Developer::Dashboard::CollectorRunner::_start_loop_worker = sub {
        my ( undef, $job, $name ) = @_;
        push @spawned, $name;
        return 5000 + scalar @spawned;
    };
    local *Developer::Dashboard::CollectorRunner::_reap_finished_loop_workers = sub { return 0 };
    local *Developer::Dashboard::CollectorRunner::_sleep_until_next_tick = sub {
        $tick_sleeps++;
        die "stop loop\n" if $tick_sleeps >= 2;
        return 0;
    };
    eval {
        $runner->_run_loop_child(
            job => {
                name     => 'multiple.loop',
                command  => q{printf multi},
                cwd      => 'home',
                interval => 1,
                mode     => 'multiple',
                multiple => 2,
            },
            name      => 'multiple.loop',
            interval  => 1,
            daemonize => 0,
        );
        1;
    };
    like( $@, qr/stop loop/, 'multiple overlap scheduler test stops after two ticks' );
    is_deeply( \@spawned, [ 'multiple.loop', 'multiple.loop' ], 'multiple loops can overlap up to their configured parallel limit' );
}

{
    my $reap_calls = 0;
    no warnings 'redefine';
    local *Developer::Dashboard::CollectorRunner::_job_is_due = sub {
        $SIG{CHLD}->() if ref( $SIG{CHLD} ) eq 'CODE';
        die "stop loop\n";
    };
    local *Developer::Dashboard::CollectorRunner::_reap_finished_loop_workers = sub {
        $reap_calls++;
        return 0;
    };
    eval {
        $runner->_run_loop_child(
            job => {
                name     => 'signal.loop',
                command  => q{printf signal},
                cwd      => 'home',
                interval => 60,
            },
            name      => 'signal.loop',
            interval  => 60,
            daemonize => 0,
        );
        1;
    };
    like( $@, qr/stop loop/, 'collector loop signal-reap test stops after the injected CHLD event' );
    ok( $reap_calls >= 2, 'collector loop reaps finished workers both during the loop and from the CHLD handler' );
}

{
    my $name = 'worker.error.loop';
    my $pid = fork();
    die "fork failed for worker error test: $!" if !defined $pid;
    if ( !$pid ) {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::run_once = sub {
            die "worker run_once exploded\n";
        };
        $runner->_run_loop_worker(
            {
                name     => $name,
                command  => q{printf worker},
                cwd      => 'home',
                interval => 12,
            },
            $name,
            'dashboard collector: worker.error.loop',
            77777,
        );
        POSIX::_exit(0);
    }
    waitpid( $pid, 0 );
    is( $? >> 8, 255, '_run_loop_worker exits 255 when run_once dies inside the worker child' );
    my $loop_state = $runner->loop_state($name);
    is( $loop_state->{status}, 'error', '_run_loop_worker records an error status when the worker run dies' );
    is( $loop_state->{pid}, 77777, '_run_loop_worker preserves the parent loop pid in the error state payload' );
    is( $loop_state->{schedule}, 'interval', '_run_loop_worker records the derived schedule when worker error state is written' );
    like( $loop_state->{error}, qr/worker run_once exploded/, '_run_loop_worker stores the worker failure text in loop state' );
    like( $files->read('collector_log'), qr/\[$name\] worker run_once exploded/, '_run_loop_worker appends worker failure text to the collector log' );
}

{
    my @slept;
    my $reap_calls = 0;
    no warnings 'redefine';
    local *Developer::Dashboard::CollectorRunner::sleep = sub {
        push @slept, $_[0];
        return 0;
    };
    local *Developer::Dashboard::CollectorRunner::_reap_finished_loop_workers = sub {
        $reap_calls++;
        return 0;
    };
    ok(
        $runner->_sleep_until_next_tick(
            interval       => 0.35,
            active_workers => {},
        ),
        '_sleep_until_next_tick returns true after the bounded interval sleep',
    );
    is_deeply(
        \@slept,
        [ 0.1, 0.1, 0.1, 0.05 ],
        '_sleep_until_next_tick sleeps in short bounded slices and finishes with the remaining fraction',
    );
    is( $reap_calls, 4, '_sleep_until_next_tick reaps finished workers after every sleep slice' );
}

{
    my $child = fork();
    die 'Unable to fork test child' if !defined $child;
    if ( !$child ) {
        $ENV{DEVELOPER_DASHBOARD_LOOP_NAME} = 'manual';
        $0 = 'dashboard collector: manual';
        sleep 30;
        POSIX::_exit(0);
    }
    my $pidfile = File::Spec->catfile( $paths->collectors_root, 'manual.pid' );
    open my $manual_pid, '>', $pidfile or die $!;
    print {$manual_pid} "$child\n";
    close $manual_pid;
    is( $runner->stop_loop('manual'), $child, 'stop_loop terminates manual pidfile processes' );
    is( waitpid( $child, 1 ), -1, 'stop_loop reaps manual collector children after shutdown' );
}

{
    my $cmd_pidfile = File::Spec->catfile( $home, 'singleton-live-command.pid' );
    my $loop_pid = $runner->start_loop(
        {
            name     => 'singleton-live',
            command  => sprintf( q{perl -e 'open my $fh, q{>}, q{%s} or die $!; print {$fh} $$; close $fh; sleep 30'}, $cmd_pidfile ),
            cwd      => 'home',
            interval => 0.1,
        }
    );
    ok( $loop_pid, 'start_loop launches a live singleton collector loop for a long-running command' );

    my ( $worker_pid, $command_pid );
    for ( 1 .. 60 ) {
        my $state = $runner->loop_state('singleton-live') || {};
        if ( ref( $state->{active_worker_pids} ) eq 'ARRAY' && @{ $state->{active_worker_pids} } ) {
            $worker_pid = $state->{active_worker_pids}[0];
        }
        if ( -f $cmd_pidfile ) {
            open my $fh, '<', $cmd_pidfile or die $!;
            $command_pid = <$fh>;
            close $fh;
            chomp $command_pid if defined $command_pid;
        }
        last if $worker_pid && $command_pid;
        sleep 0.1;
    }

    ok( $worker_pid, 'live singleton loop records the active worker pid in loop state' );
    ok( $command_pid, 'live singleton loop exposes the long-running command pid for shutdown verification' );

    sleep 0.35;
    my $steady_state = $runner->loop_state('singleton-live') || {};
    is( $steady_state->{active_runs}, 1, 'singleton live loop keeps exactly one active run while the long-running command is still executing' );
    is_deeply(
        $steady_state->{active_worker_pids},
        [$worker_pid],
        'singleton live loop does not replace the active worker while the current command is still running',
    );

    is( $runner->stop_loop('singleton-live'), $loop_pid, 'stop_loop returns the singleton loop pid for a live long-running collector' );
    for ( 1 .. 40 ) {
        last
          if !$runner->_pid_is_running($worker_pid)
          && !$runner->_pid_is_running($command_pid);
        sleep 0.1;
    }
    ok( !$runner->_pid_is_running($worker_pid), 'stop_loop terminates the active singleton worker process' );
    ok(
        !$runner->_pid_is_running($command_pid),
        'stop_loop also terminates the long-running command started by the singleton worker',
    );
    is( waitpid( $loop_pid, 1 ), -1, 'stop_loop reaps the singleton loop after shutting down the worker tree' );
}

{
    my $pidfile = File::Spec->catfile( $paths->collectors_root, 'foreign.pid' );
    open my $foreign_pid, '>', $pidfile or die $!;
    print {$foreign_pid} "$$\n";
    close $foreign_pid;
    {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::_read_process_env_marker = sub { return };
        local *Developer::Dashboard::CollectorRunner::_read_process_title = sub { return 'foreign collector process' };
        ok( !$runner->_is_managed_loop( $$, 'foreign' ), '_is_managed_loop rejects unrelated process titles' );
        is( $runner->stop_loop('foreign'), $$, 'stop_loop returns pid even for unmanaged foreign processes' );
    }
    ok( !-f $pidfile, 'stop_loop removes stale foreign pidfiles without signalling the current process' );
}

{
    my $pidfile = File::Spec->catfile( $paths->collectors_root, 'observed.pid' );
    open my $observed_pid, '>', $pidfile or die $!;
    print {$observed_pid} "$$\n";
    close $observed_pid;
    $runner->_write_loop_state(
        'observed',
        {
            pid          => $$,
            process_name => 'dashboard collector: observed',
            status       => 'running',
        }
    );
    my @running;
    {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::_read_process_env_marker = sub { return 'observed' };
        local *Developer::Dashboard::CollectorRunner::_read_process_title = sub { return 'dashboard collector: observed' };
        @running = $runner->running_loops;
    }
    is_deeply( [ map { $_->{name} } @running ], ['observed'], 'running_loops returns only validated managed collectors' );
    is( $running[0]{pid}, $$, 'running_loops includes matching pid values' );
    $runner->_cleanup_loop_files('observed');
}

{
    my $child = fork();
    die 'Unable to fork state-backed stop child' if !defined $child;
    if ( !$child ) {
        sleep 30;
        POSIX::_exit(0);
    }
    my $pidfile = File::Spec->catfile( $paths->collectors_root, 'state-backed.pid' );
    open my $state_pid, '>', $pidfile or die $!;
    print {$state_pid} "$child\n";
    close $state_pid;
    $runner->_write_loop_state(
        'state-backed',
        {
            pid          => $child,
            name         => 'state-backed',
            process_name => 'dashboard collector: state-backed',
            status       => 'starting',
        }
    );
    {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::_read_process_env_marker = sub { return };
        local *Developer::Dashboard::CollectorRunner::_read_process_title = sub { return '' };
        ok(
            $runner->_state_confirms_managed_loop( 'state-backed', $child ),
            '_state_confirms_managed_loop trusts persisted loop state for a live collector when process markers are not observable yet',
        );
        is(
            $runner->stop_loop('state-backed'),
            $child,
            'stop_loop terminates a managed collector from persisted loop state when process markers are not observable yet',
        );
    }
    is( waitpid( $child, 1 ), -1, 'stop_loop reaps state-backed managed children after shutdown' );
    $runner->_cleanup_loop_files('state-backed');
}

{
    ok(
        !$runner->_state_confirms_managed_loop( 'missing-state', $$ ),
        '_state_confirms_managed_loop returns false when no persisted loop state exists for a live pid',
    );

    for my $case (
        [
            'wrong-pid',
            {
                pid          => $$ + 1,
                name         => 'wrong-pid',
                process_name => 'dashboard collector: wrong-pid',
                status       => 'running',
            },
            qr/pid mismatch/,
        ],
        [
            'wrong-name',
            {
                pid          => $$,
                name         => 'other-name',
                process_name => 'dashboard collector: wrong-name',
                status       => 'running',
            },
            qr/name mismatch/,
        ],
        [
            'wrong-title',
            {
                pid          => $$,
                name         => 'wrong-title',
                process_name => 'foreign collector title',
                status       => 'running',
            },
            qr/title mismatch/,
        ],
        [
            'wrong-status',
            {
                pid          => $$,
                name         => 'wrong-status',
                process_name => 'dashboard collector: wrong-status',
                status       => 'stopped',
            },
            qr/status mismatch/,
        ],
    ) {
        my ( $name, $state, $label ) = @$case;
        if ( $label =~ /name mismatch/ ) {
            my $statefile = File::Spec->catfile( $paths->collector_dir($name), 'loop.json' );
            open my $fh, '>', $statefile or die $!;
            print {$fh} json_encode($state);
            close $fh;
        }
        else {
            $runner->_write_loop_state( $name, $state );
        }
        ok(
            !$runner->_state_confirms_managed_loop( $name, $$ ),
            "_state_confirms_managed_loop rejects persisted loop state with $label",
        );
        $runner->_cleanup_loop_files($name);
    }
}

{
    my $pidfile = File::Spec->catfile( $paths->collectors_root, 'stubborn-state.pid' );
    open my $stubborn_pid, '>', $pidfile or die $!;
    print {$stubborn_pid} "424242\n";
    close $stubborn_pid;
    $runner->_write_loop_state(
        'stubborn-state',
        {
            pid          => 424242,
            name         => 'stubborn-state',
            process_name => 'dashboard collector: stubborn-state',
            status       => 'running',
        }
    );
    my $error = '';
    {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::_reap_child_process = sub { return 0 };
        local *Developer::Dashboard::CollectorRunner::_same_pid_namespace = sub { return 1 };
        local *Developer::Dashboard::CollectorRunner::_is_managed_loop = sub { return 1 };
        local *Developer::Dashboard::CollectorRunner::_pid_is_running = sub { return 1 };
        local *Developer::Dashboard::CollectorRunner::sleep = sub { return 0 };
        eval { $runner->stop_loop('stubborn-state'); 1 } or $error = $@;
    }
    like( $error, qr/Collector 'stubborn-state' did not stop after TERM and KILL/, 'stop_loop fails explicitly when a managed collector still appears alive after TERM and KILL' );
    ok( -f $pidfile, 'stop_loop keeps the pidfile when a managed collector refuses to stop' );
    ok( defined $runner->loop_state('stubborn-state'), 'stop_loop keeps loop state metadata when a managed collector refuses to stop' );
    $runner->_cleanup_loop_files('stubborn-state');
}

{
    my $pidfile = File::Spec->catfile( $paths->collectors_root, 'worker-shutdown.pid' );
    open my $worker_pid, '>', $pidfile or die $!;
    print {$worker_pid} "535353\n";
    close $worker_pid;
    my @terminated;
    my $reaped = 0;
    my $calls  = 0;
    {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::_reap_child_process = sub {
            my ( undef, $pid ) = @_;
            $reaped++;
            return 0 if $reaped == 1;
            return 1;
        };
        local *Developer::Dashboard::CollectorRunner::_same_pid_namespace = sub { return 1 };
        local *Developer::Dashboard::CollectorRunner::_is_managed_loop = sub { return 1 };
        local *Developer::Dashboard::CollectorRunner::_pid_is_running = sub {
            $calls++;
            return $calls <= 2 ? 1 : 0;
        };
        local *Developer::Dashboard::CollectorRunner::_state_active_worker_pids = sub { return ( 81, 82 ) };
        local *Developer::Dashboard::CollectorRunner::_terminate_loop_workers = sub {
            my ( undef, $workers ) = @_;
            push @terminated, [ sort { $a <=> $b } keys %{$workers} ];
            return 1;
        };
        local *Developer::Dashboard::CollectorRunner::sleep = sub { return 0 };
        is( $runner->stop_loop('worker-shutdown'), '535353', 'stop_loop still returns the managed pid after terminating tracked worker pids' );
    }
    is_deeply( $terminated[0], [ 81, 82 ], 'stop_loop passes active worker pid hashes into _terminate_loop_workers during shutdown escalation' );
}

{
    my $pidfile = File::Spec->catfile( $paths->collectors_root, 'foreign-loop.pid' );
    open my $foreign_pid, '>', $pidfile or die $!;
    print {$foreign_pid} "525252\n";
    close $foreign_pid;
    my $stopped_pid;
    my @cleanup_names;
    {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::_reap_child_process = sub { return 0 };
        local *Developer::Dashboard::CollectorRunner::_same_pid_namespace = sub { return 0 };
        local *Developer::Dashboard::CollectorRunner::_cleanup_loop_files = sub {
            my ( undef, $name ) = @_;
            push @cleanup_names, $name;
            return 1;
        };
        $stopped_pid = $runner->stop_loop('foreign-loop');
    }
    is( $stopped_pid, '525252', 'stop_loop returns the recorded pid when the loop belongs to a different pid namespace' );
    is_deeply( \@cleanup_names, ['foreign-loop'], 'stop_loop still delegates cleanup when the loop belongs to a different pid namespace' );
}

{
    my $proc_root = tempdir( CLEANUP => 1 );
    my $proc_dir  = File::Spec->catdir( $proc_root, '4242' );
    mkdir $proc_dir or die $!;
    my $environ = File::Spec->catfile( $proc_dir, 'environ' );
    open my $env_fh, '>', $environ or die $!;
    print {$env_fh} "BROKEN\0TARGET_KEY=collector-value\0";
    close $env_fh;
    {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::_read_process_env_marker = sub {
            my ( $self, $pid, $key ) = @_;
            my $proc = File::Spec->catfile( $proc_root, $pid, 'environ' );
            open my $fh, '<', $proc or return;
            local $/;
            my $env = scalar <$fh>;
            return if !defined $env || $env eq '';
            for my $pair ( split /\0/, $env ) {
                next if $pair !~ /^([^=]+)=(.*)$/s;
                return $2 if $1 eq $key;
            }
            return;
        };
        is(
            $runner->_read_process_env_marker( 4242, 'TARGET_KEY' ),
            'collector-value',
            '_read_process_env_marker skips malformed environ entries and returns the requested value',
        );
    }
}

{
    {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::_read_proc_file = sub { return '' };
        local *Developer::Dashboard::CollectorRunner::capture = sub (&) {
            return ( "dashboard collector: ps-fallback   \n", '', 0 );
        };
        is(
            $runner->_read_process_title(4242),
            'dashboard collector: ps-fallback',
            '_read_process_title falls back to ps output when procfs command line data is unavailable',
        );
    }

    {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::_read_proc_file = sub { return '' };
        local *Developer::Dashboard::CollectorRunner::capture = sub (&) {
            return ( "dashboard collector: ps-failure\n", '', 1 );
        };
        ok(
            !defined $runner->_read_process_title(4242),
            '_read_process_title returns undef when ps fallback itself fails',
        );
    }

    {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::_read_proc_file = sub { return "4242 (dashboard) Z 1 2 3 4\n" };
        is(
            $runner->_read_process_state(4242),
            'Z',
            '_read_process_state reads the one-letter procfs process state',
        );
    }

    {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::_read_proc_file = sub { return '' };
        local *Developer::Dashboard::CollectorRunner::capture = sub (&) {
            return ( "Z+\n", '', 0 );
        };
        is(
            $runner->_read_process_state(4242),
            'Z',
            '_read_process_state falls back to ps output when procfs state is unavailable',
        );
    }
}

{
    no warnings 'redefine';
    local *Developer::Dashboard::CollectorRunner::_reap_child_process = sub { return 0 };
    local *Developer::Dashboard::CollectorRunner::_read_process_state = sub { return 'Z' };
    local *Developer::Dashboard::CollectorRunner::_process_exists = sub { return 1 };
    ok(
        !$runner->_pid_is_running(4242),
        '_pid_is_running treats zombie collectors as stopped even when signal 0 still succeeds',
    );
}

{
    local $ENV{PERL5OPT}            = '-MDevel::Cover';
    local $ENV{HARNESS_PERL_SWITCHES} = '';
    ok( $runner->_coverage_instrumentation_active, '_coverage_instrumentation_active detects Devel::Cover in PERL5OPT' );
    $runner->_scrub_coverage_environment;
    ok( !defined $ENV{PERL5OPT}, '_scrub_coverage_environment removes PERL5OPT from managed loop children' );

    local $ENV{PERL5OPT}            = '';
    local $ENV{HARNESS_PERL_SWITCHES} = '';
    ok( !$runner->_coverage_instrumentation_active, '_coverage_instrumentation_active returns false when coverage is not requested' );
}

{
    {
        no warnings 'redefine';
        my $setsid_called = 0;
        local *Developer::Dashboard::CollectorRunner::is_windows = sub { return 1 };
        local *Developer::Dashboard::CollectorRunner::setsid     = sub { $setsid_called++; return 1 };
        ok( $runner->_detach_process_session, '_detach_process_session is a no-op on Windows' );
        is( $setsid_called, 0, '_detach_process_session skips setsid on Windows' );
    }

    {
        no warnings 'redefine';
        my $setsid_called = 0;
        local *Developer::Dashboard::CollectorRunner::is_windows = sub { return 0 };
        local *Developer::Dashboard::CollectorRunner::setsid     = sub { $setsid_called++; return 1 };
        ok( $runner->_detach_process_session, '_detach_process_session detaches the session on POSIX platforms' );
        is( $setsid_called, 1, '_detach_process_session calls setsid exactly once on POSIX platforms' );
    }
}

{
    my ( $stdout, $stderr, $exit_code, $timed_out ) = $runner->_run_command(
        source     => q{printf collector-command},
        cwd        => $paths->home,
        timeout_ms => 1_000,
    );
    is( $stdout, 'collector-command', '_run_command captures stdout from a successful shell command' );
    is( $stderr, '', '_run_command leaves stderr empty for a successful shell command' );
    is( $exit_code, 0, '_run_command returns zero for a successful shell command' );
    is( $timed_out, 0, '_run_command does not mark successful commands as timed out' );
}

{
    local $ENV{PATH} = '/definitely-missing';
    my ( $stdout, $stderr, $exit_code, $timed_out ) = $runner->_run_command(
        source     => q{perl -e 'print $^X'},
        cwd        => $paths->home,
        timeout_ms => 1_000,
    );
    is( $stdout, $^X, '_run_command keeps the current Perl interpreter available to child commands even when PATH would otherwise miss perl' );
    is( $stderr, '', '_run_command does not emit stderr when it repairs PATH for Perl child commands' );
    is( $exit_code, 0, '_run_command still exits successfully when it repairs PATH for Perl child commands' );
    is( $timed_out, 0, '_run_command PATH repair for Perl child commands does not report a timeout' );
}

{
    my ( $stdout, $stderr, $exit_code, $timed_out ) = $runner->_run_code(
        source     => q{ die "collector code boom\n"; },
        cwd        => $paths->home,
        timeout_ms => 1_000,
    );
    is( $stdout, '', '_run_code does not emit stdout for a dying code collector' );
    like( $stderr, qr/collector code boom/, '_run_code writes code evaluation errors to stderr explicitly' );
    is( $exit_code, 255, '_run_code returns 255 when collector code dies' );
    is( $timed_out, 0, '_run_code distinguishes code errors from collector timeouts' );
}

{
    my $child = fork();
    die 'Unable to fork sort child' if !defined $child;
    if ( !$child ) {
        sleep 30;
        POSIX::_exit(0);
    }
    for my $entry (
        [ 'alpha-sort', $child ],
        [ 'zeta-sort',  $$ ],
    ) {
        my ( $name, $pid ) = @$entry;
        my $pidfile = File::Spec->catfile( $paths->collectors_root, "$name.pid" );
        open my $fh, '>', $pidfile or die $!;
        print {$fh} "$pid\n";
        close $fh;
        $runner->_write_loop_state(
            $name,
            {
                pid          => $pid,
                process_name => "dashboard collector: $name",
                status       => 'running',
            }
        );
    }
    my @sorted;
    {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::_read_process_env_marker = sub { return };
        local *Developer::Dashboard::CollectorRunner::_read_process_title = sub {
            my ( $self, $pid ) = @_;
            return $pid == $child ? 'dashboard collector: alpha-sort' : 'dashboard collector: zeta-sort';
        };
        @sorted = $runner->running_loops;
    }
    is_deeply( [ map { $_->{name} } @sorted ], [ 'alpha-sort', 'zeta-sort' ], 'running_loops sorts multiple managed loops by name' );
    $runner->_cleanup_loop_files('alpha-sort');
    $runner->_cleanup_loop_files('zeta-sort');
    kill 'TERM', $child;
    waitpid( $child, 0 );
}

{
    my $pidfile = File::Spec->catfile( $paths->collectors_root, 'signal-stop.pid' );
    open my $signal_pid, '>', $pidfile or die $!;
    print {$signal_pid} "$$\n";
    close $signal_pid;
    $runner->_write_loop_state(
        'signal-stop',
        {
            pid          => $$,
            process_name => 'dashboard collector: signal-stop',
            status       => 'running',
        }
    );
    my $child = fork();
    die 'Unable to fork signal-stop child' if !defined $child;
    if ( !$child ) {
        local $Developer::Dashboard::CollectorRunner::SIGNAL_RUNNER    = $runner;
        local $Developer::Dashboard::CollectorRunner::SIGNAL_LOOP_NAME = 'signal-stop';
        Developer::Dashboard::CollectorRunner::_signal_stop();
        exit 99;
    }
    waitpid( $child, 0 );
    is( $? >> 8, 0, '_signal_stop routes to shutdown logic and exits cleanly' );
    ok( !-f $pidfile, '_signal_stop cleanup removes the pidfile' );
    ok( !defined $runner->loop_state('signal-stop'), '_signal_stop cleanup removes the loop metadata' );
}

{
    my $state_path = File::Spec->catfile( $paths->collector_dir('windows-replace'), 'loop.json' );
    open my $existing_fh, '>', $state_path or die $!;
    print {$existing_fh} json_encode( { name => 'windows-replace', status => 'old' } );
    close $existing_fh;

    my @rename_calls;
    my @unlink_calls;
    {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::is_windows = sub { return 1 };
        local *Developer::Dashboard::CollectorRunner::_rename_path = sub {
            my ( undef, $source, $target ) = @_;
            push @rename_calls, [ $source, $target ];
            if ( @rename_calls == 1 ) {
                $! = EACCES;
                return 0;
            }
            return rename $source, $target;
        };
        local *Developer::Dashboard::CollectorRunner::_unlink_path = sub {
            my ( undef, $path ) = @_;
            push @unlink_calls, $path;
            return unlink $path;
        };
        $runner->_write_loop_state(
            'windows-replace',
            {
                pid          => 5151,
                name         => 'windows-replace',
                process_name => 'dashboard collector: windows-replace',
                status       => 'running',
            }
        );
    }

    is( scalar @rename_calls, 2, '_write_loop_state retries the final replace once after a Windows rename collision' );
    is_deeply( \@unlink_calls, [$state_path], '_write_loop_state removes the existing Windows target before retrying the replace' );
    is(
        json_decode( do {
            open my $state_fh, '<', $state_path or die $!;
            local $/;
            <$state_fh>;
        } )->{status},
        'running',
        '_write_loop_state leaves the retried Windows replacement payload on disk',
    );
}

{
    my $state_path = File::Spec->catfile( $paths->collector_dir('windows-powershell-replace'), 'loop.json' );
    my @fallback_calls;
    {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::is_windows = sub { return 1 };
        local *Developer::Dashboard::CollectorRunner::_rename_path = sub {
            $! = EACCES;
            return 0;
        };
        local *Developer::Dashboard::CollectorRunner::_replace_path_via_powershell = sub {
            my ( undef, $source, $target ) = @_;
            push @fallback_calls, [ $source, $target ];
            return ( rename( $source, $target ) ? 1 : 0, $! );
        };
        $runner->_write_loop_state(
            'windows-powershell-replace',
            {
                pid          => 5252,
                name         => 'windows-powershell-replace',
                process_name => 'dashboard collector: windows-powershell-replace',
                status       => 'running',
            }
        );
    }

    is( scalar @fallback_calls, 1, '_write_loop_state falls back to the Windows PowerShell move path when rename fails before the target exists' );
    is(
        json_decode( do {
            open my $state_fh, '<', $state_path or die $!;
            local $/;
            <$state_fh>;
        } )->{status},
        'running',
        '_write_loop_state leaves the PowerShell-fallback Windows replacement payload on disk',
    );
}
{
    my $state_path = File::Spec->catfile( $paths->collector_dir('windows-in-place-replace'), 'loop.json' );
    open my $seed_fh, '>', $state_path or die "Unable to seed $state_path: $!";
    print {$seed_fh} json_encode( { status => 'stale' } );
    close $seed_fh or die "Unable to close seeded $state_path: $!";
    my @overwrite_calls;
    {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::is_windows = sub { return 1 };
        local *Developer::Dashboard::CollectorRunner::_rename_path = sub {
            $! = EACCES;
            return 0;
        };
        local *Developer::Dashboard::CollectorRunner::_unlink_path = sub {
            my ( $self, $path ) = @_;
            return unlink $path;
        };
        local *Developer::Dashboard::CollectorRunner::_replace_path_via_powershell = sub {
            return ( 0, 'Move-Item collision' );
        };
        local *Developer::Dashboard::CollectorRunner::_overwrite_state_file_in_place = sub {
            my ( $self, $source, $target ) = @_;
            push @overwrite_calls, [ $source, $target ];
            open my $target_fh, '>', $target or die "Unable to open $target in overwrite test: $!";
            print {$target_fh} json_encode(
                {
                    pid          => 5353,
                    name         => 'windows-in-place-replace',
                    process_name => 'dashboard collector: windows-in-place-replace',
                    status       => 'running',
                }
            );
            close $target_fh or die "Unable to close $target in overwrite test: $!";
            unlink $source or die "Unable to remove $source in overwrite test: $!";
            return ( 1, '' );
        };
        $runner->_write_loop_state(
            'windows-in-place-replace',
            {
                pid          => 5353,
                name         => 'windows-in-place-replace',
                process_name => 'dashboard collector: windows-in-place-replace',
                status       => 'running',
            }
        );
    }

    is( scalar @overwrite_calls, 1, '_write_loop_state falls back to the Windows in-place overwrite path when rename and PowerShell replacement both fail' );
    is(
        json_decode( do {
            open my $state_fh, '<', $state_path or die $!;
            local $/;
            <$state_fh>;
        } )->{status},
        'running',
        '_write_loop_state leaves the in-place-overwrite Windows replacement payload on disk',
    );
}

{
    {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::is_windows = sub { return 1 };
        local *Developer::Dashboard::CollectorRunner::command_in_path = sub {
            my ($name) = @_;
            return 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' if $name eq 'powershell.exe';
            return '';
        };
        is(
            $runner->_powershell_command,
            'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
            '_powershell_command falls back across PowerShell command names on Windows before using the filesystem default',
        );
    }

    {
        no warnings 'redefine';
        my $fake_system_root = tempdir( CLEANUP => 1 );
        my $fake_powershell = File::Spec->catfile( $fake_system_root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe' );
        make_path( File::Spec->catdir( $fake_system_root, 'System32', 'WindowsPowerShell', 'v1.0' ) );
        open my $fh, '>', $fake_powershell or die $!;
        close $fh;
        local *Developer::Dashboard::CollectorRunner::is_windows = sub { return 1 };
        local *Developer::Dashboard::CollectorRunner::command_in_path = sub { return '' };
        local $ENV{SystemRoot} = $fake_system_root;
        is(
            $runner->_powershell_command,
            $fake_powershell,
            '_powershell_command falls back to the SystemRoot PowerShell path when PATH lookup is empty',
        );
    }

    {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::is_windows = sub { return 1 };
        local *Developer::Dashboard::CollectorRunner::command_in_path = sub {
            my ($name) = @_;
            return 'C:\\Strawberry\\perl\\bin\\perl.exe' if $name eq 'perl.exe';
            return '';
        };
        is(
            $runner->_current_perl_command,
            'C:\\Strawberry\\perl\\bin\\perl.exe',
            '_current_perl_command prefers perl.exe from PATH on Windows collector launches when plain perl is unavailable',
        );
    }

    {
        no warnings 'redefine';
        local $Developer::Dashboard::Platform::OS_NAME = 'linux';
        local $^X = '/usr/bin/perl';
        local *Developer::Dashboard::CollectorRunner::command_in_path = sub { return '' };
        is(
            $runner->_current_perl_command,
            '/usr/bin/perl',
            '_current_perl_command keeps the current non-Windows Perl interpreter path when it still exists',
        );
    }

    {
        no warnings 'redefine';
        local $Developer::Dashboard::Platform::OS_NAME = 'linux';
        local $^X = '/tmp/nonexistent-collector-perl';
        local *Developer::Dashboard::CollectorRunner::command_in_path = sub {
            my ($name) = @_;
            return '/usr/local/bin/perl' if $name eq 'perl';
            return '/usr/local/bin/perl.exe' if $name eq 'perl.exe';
            return '';
        };
        is(
            $runner->_current_perl_command,
            '/usr/local/bin/perl',
            '_current_perl_command falls back to perl from PATH on non-Windows hosts when the current interpreter path is missing',
        );

        local *Developer::Dashboard::CollectorRunner::command_in_path = sub {
            my ($name) = @_;
            return '' if $name eq 'perl';
            return '/usr/local/bin/perl.exe' if $name eq 'perl.exe';
            return '';
        };
        is(
            $runner->_current_perl_command,
            '/usr/local/bin/perl.exe',
            '_current_perl_command falls back to perl.exe from PATH on non-Windows hosts when plain perl is unavailable',
        );

        local *Developer::Dashboard::CollectorRunner::command_in_path = sub { return '' };
        is(
            $runner->_current_perl_command,
            '/tmp/nonexistent-collector-perl',
            '_current_perl_command finally returns the current interpreter string on non-Windows hosts when no PATH fallback exists',
        );
    }

    {
        my $helper_root = File::Spec->catdir( $paths->home_runtime_root, 'cli', 'dd' );
        make_path($helper_root);
        my $staged_helper = File::Spec->catfile( $helper_root, '_dashboard-core' );
        open my $helper_fh, '>', $staged_helper or die "Unable to write $staged_helper: $!";
        print {$helper_fh} "collector-loop-foreground\ncollector-worker-foreground\n";
        close $helper_fh or die "Unable to close $staged_helper: $!";

        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::is_windows = sub { return 1 };
        local *Developer::Dashboard::CollectorRunner::command_in_path = sub {
            my ($name) = @_;
            return 'C:\\Strawberry\\perl\\bin\\perl.exe' if $name eq 'perl.exe';
            return '';
        };

        ok(
            $runner->_helper_file_supports_internal_command( $staged_helper, 'collector-loop-foreground' ),
            '_helper_file_supports_internal_command accepts staged collector helpers that expose the requested internal command',
        );
        ok(
            !$runner->_helper_file_supports_internal_command( $staged_helper, 'missing-command' ),
            '_helper_file_supports_internal_command rejects staged collector helpers that do not expose the requested internal command',
        );
        is(
            $runner->_dashboard_core_helper_path('collector-loop-foreground'),
            $staged_helper,
            '_dashboard_core_helper_path prefers the staged helper when it already exposes the requested collector command',
        );
        is_deeply(
            [ $runner->_windows_background_loop_command('alpha.collector') ],
            [
                'C:\\Strawberry\\perl\\bin\\perl.exe',
                $staged_helper,
                'collector-loop-foreground',
                '--name',
                'alpha.collector',
            ],
            '_windows_background_loop_command builds the detached loop helper argv from the staged dashboard core helper',
        );
        is_deeply(
            [ $runner->_windows_background_worker_command( 'alpha.collector', 4242 ) ],
            [
                'C:\\Strawberry\\perl\\bin\\perl.exe',
                $staged_helper,
                'collector-worker-foreground',
                '--name',
                'alpha.collector',
                '--loop-pid',
                4242,
            ],
            '_windows_background_worker_command builds the detached worker helper argv from the staged dashboard core helper',
        );
    }

    {
        my $helper_root = File::Spec->catdir( $paths->home_runtime_root, 'cli', 'dd' );
        make_path($helper_root);
        my $staged_helper = File::Spec->catfile( $helper_root, '_dashboard-core' );
        open my $staged_fh, '>', $staged_helper or die "Unable to write $staged_helper: $!";
        print {$staged_fh} "collector-worker-foreground\n";
        close $staged_fh or die "Unable to close $staged_helper: $!";

        my $shipped_root = tempdir( CLEANUP => 1 );
        my $shipped_helper = File::Spec->catfile( $shipped_root, '_dashboard-core' );
        open my $shipped_fh, '>', $shipped_helper or die "Unable to write $shipped_helper: $!";
        print {$shipped_fh} "collector-loop-foreground\n";
        close $shipped_fh or die "Unable to close $shipped_helper: $!";

        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::is_windows = sub { return 1 };
        local *Developer::Dashboard::InternalCLI::_helper_asset_path = sub { return $shipped_helper };
        is(
            $runner->_dashboard_core_helper_path('collector-loop-foreground'),
            $shipped_helper,
            '_dashboard_core_helper_path falls back to the shipped helper when the staged helper lacks the requested collector command',
        );
    }

    {
        my $helper_root = File::Spec->catdir( $paths->home_runtime_root, 'cli', 'dd' );
        make_path($helper_root);
        my $staged_helper = File::Spec->catfile( $helper_root, '_dashboard-core' );
        open my $staged_fh, '>', $staged_helper or die "Unable to write $staged_helper: $!";
        print {$staged_fh} "collector-worker-foreground\n";
        close $staged_fh or die "Unable to close $staged_helper: $!";

        my $shipped_root = tempdir( CLEANUP => 1 );
        my $shipped_helper = File::Spec->catfile( $shipped_root, '_dashboard-core' );
        open my $shipped_fh, '>', $shipped_helper or die "Unable to write $shipped_helper: $!";
        print {$shipped_fh} "other-command\n";
        close $shipped_fh or die "Unable to close $shipped_helper: $!";

        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::is_windows = sub { return 1 };
        local *Developer::Dashboard::InternalCLI::_helper_asset_path = sub { return $shipped_helper };
        is(
            $runner->_dashboard_core_helper_path('collector-loop-foreground'),
            $staged_helper,
            '_dashboard_core_helper_path falls back to the staged helper path when neither staged nor shipped helpers expose the requested collector command',
        );
    }

    {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::is_windows = sub { return 1 };
        local *Developer::Dashboard::CollectorRunner::command_in_path = sub { return '' };
        local $ENV{SystemRoot} = File::Spec->catdir( tempdir( CLEANUP => 1 ), 'missing-system-root' );
        is(
            $runner->_powershell_command,
            '',
            '_powershell_command returns an empty string when no PowerShell binary can be resolved on Windows',
        );
    }

    {
        my $fakebin = tempdir( CLEANUP => 1 );
        my $powershell = File::Spec->catfile( $fakebin, 'powershell.exe' );
        my $source = File::Spec->catfile( $fakebin, 'pending.json' );
        my $target = File::Spec->catfile( $fakebin, 'loop.json' );
        open my $source_fh, '>', $source or die "Unable to write $source: $!";
        print {$source_fh} qq({"status":"running"});
        close $source_fh or die "Unable to close $source: $!";
        open my $ps_fh, '>', $powershell or die "Unable to write $powershell: $!";
        print {$ps_fh} <<"SCRIPT";
#!/bin/sh
mv "$source" "$target"
exit 0
SCRIPT
        close $ps_fh or die "Unable to close $powershell: $!";
        chmod 0755, $powershell or die "Unable to chmod $powershell: $!";

        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::is_windows = sub { return 1 };
        local *Developer::Dashboard::CollectorRunner::_powershell_command = sub { return $powershell };
        my ( $ok, $error ) = $runner->_replace_path_via_powershell( $source, $target );
        ok( $ok, '_replace_path_via_powershell returns success when the Windows Move-Item fallback succeeds' )
          or diag $error;
        ok( -f $target, '_replace_path_via_powershell leaves the target file in place after a successful Move-Item fallback' );

        open my $ps_fail_fh, '>', $powershell or die "Unable to rewrite $powershell: $!";
        print {$ps_fail_fh} <<"SCRIPT";
#!/bin/sh
printf '%s\n' 'collector move failed' >&2
exit 9
SCRIPT
        close $ps_fail_fh or die "Unable to close $powershell after rewrite: $!";
        chmod 0755, $powershell or die "Unable to chmod $powershell after rewrite: $!";

        open my $retry_fh, '>', $source or die "Unable to rewrite $source: $!";
        print {$retry_fh} qq({"status":"retry"});
        close $retry_fh or die "Unable to close $source after rewrite: $!";

        ( $ok, $error ) = $runner->_replace_path_via_powershell( $source, $target );
        ok( !$ok, '_replace_path_via_powershell returns failure when the Windows Move-Item fallback exits non-zero' );
        like( $error, qr/collector move failed/, '_replace_path_via_powershell returns the native PowerShell failure text' );
    }

    {
        my $source = File::Spec->catfile( $paths->collector_dir('windows-overwrite-direct'), 'pending.json' );
        my $target = File::Spec->catfile( $paths->collector_dir('windows-overwrite-direct'), 'loop.json' );
        open my $source_fh, '>', $source or die "Unable to write $source: $!";
        print {$source_fh} qq({"status":"running"});
        close $source_fh or die "Unable to close $source: $!";
        open my $target_fh, '>', $target or die "Unable to seed $target: $!";
        print {$target_fh} qq({"status":"old"});
        close $target_fh or die "Unable to close $target: $!";

        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::is_windows = sub { return 1 };
        my ( $ok, $error ) = $runner->_overwrite_state_file_in_place( $source, $target );
        ok( $ok, '_overwrite_state_file_in_place returns success for the direct Windows collector overwrite fallback' )
          or diag $error;
        ok( !-e $source, '_overwrite_state_file_in_place removes the consumed collector pending state file after a successful overwrite' );
        is(
            Developer::Dashboard::CollectorRunner::_slurp($target),
            qq({"status":"running"}),
            '_overwrite_state_file_in_place replaces the target collector state payload in place',
        );
    }

    {
        my $path = File::Spec->catfile( $paths->collector_dir('windows-unlink-direct'), 'stale.txt' );
        open my $stale_fh, '>', $path or die "Unable to write $path: $!";
        print {$stale_fh} "stale\n";
        close $stale_fh or die "Unable to close $path: $!";
        ok( $runner->_unlink_path($path), '_unlink_path returns true when the collector helper unlinks an existing file' );
        ok( !-e $path, '_unlink_path removes the requested collector file path' );
    }

    {
        my $fakebin = tempdir( CLEANUP => 1 );
        my $powershell = File::Spec->catfile( $fakebin, 'powershell' );
        open my $ps_fh, '>', $powershell or die "Unable to write $powershell: $!";
        print {$ps_fh} <<"SCRIPT";
#!/bin/sh
printf '%s\n' "\$*" > "$fakebin/powershell.args"
printf '%s\n' 51515
exit 0
SCRIPT
        close $ps_fh or die "Unable to close $powershell: $!";
        chmod 0755, $powershell or die "Unable to chmod $powershell: $!";

        my $stdout_log = $files->collector_log;
        my $stderr_log = $stdout_log . '.stderr';
        local $ENV{PATH} = join ':', $fakebin, ( $ENV{PATH} || '' );
        local $Developer::Dashboard::Platform::OS_NAME = 'MSWin32';
        is(
            Developer::Dashboard::CollectorRunner::_powershell_single_quote(q{C:\Users\O'Hara\AppData}),
            q{'C:\Users\O''Hara\AppData'},
            '_powershell_single_quote doubles embedded single quotes for Windows collector PowerShell literals',
        );
        my $spawned_pid = $runner->_spawn_windows_background_command(
            q{C:\Program Files\Perl\perl.exe},
            'collector-loop-foreground',
            q{O'Hara},
        );
        is( $spawned_pid, 51515, '_spawn_windows_background_command returns the detached collector pid written by Start-Process' );
        open my $args_fh, '<', File::Spec->catfile( $fakebin, 'powershell.args' )
          or die "Unable to read $fakebin/powershell.args: $!";
        local $/;
        my $args = <$args_fh>;
        close $args_fh or die "Unable to close $fakebin/powershell.args: $!";
        like( $args, qr/Start-Process/, '_spawn_windows_background_command shells out through Start-Process for collector helpers' );
        like( $args, qr/'C:\\Program Files\\Perl\\perl\.exe'/, '_spawn_windows_background_command quotes the Windows collector executable path for PowerShell' );
        like( $args, qr/'O''Hara'/, '_spawn_windows_background_command escapes embedded collector arguments for PowerShell' );
        like( $args, qr/\Q$stdout_log\E/, '_spawn_windows_background_command redirects collector stdout to the collector log' );
        like( $args, qr/\Q$stderr_log\E/, '_spawn_windows_background_command redirects collector stderr to the collector error log' );
    }
}

{
    my $tmp = tempdir( CLEANUP => 1 );
    my $source = File::Spec->catfile( $tmp, 'loop.pending.json' );
    my $target = File::Spec->catfile( $tmp, 'loop.json' );
    open my $source_fh, '>:raw', $source or die "Unable to write $source: $!";
    print {$source_fh} qq({"status":"running"});
    close $source_fh or die "Unable to close $source: $!";
    open my $target_fh, '>:raw', $target or die "Unable to seed $target: $!";
    print {$target_fh} qq({"status":"stale"});
    close $target_fh or die "Unable to close $target: $!";

    my @rename_calls;
    my @unlink_calls;
    my $error;
    {
        no warnings 'redefine';
        local *Developer::Dashboard::CollectorRunner::is_windows = sub { return 1 };
        local *Developer::Dashboard::CollectorRunner::_rename_path = sub {
            my ( undef, $from, $to ) = @_;
            push @rename_calls, [ $from, $to ];
            $! = EACCES;
            return 0;
        };
        local *Developer::Dashboard::CollectorRunner::_unlink_path = sub {
            my ( undef, $path ) = @_;
            push @unlink_calls, $path;
            return unlink $path;
        };
        local *Developer::Dashboard::CollectorRunner::_replace_path_via_powershell = sub {
            return ( 0, "Move-Item retry failed\n" );
        };
        local *Developer::Dashboard::CollectorRunner::_overwrite_state_file_in_place = sub {
            return ( 0, "overwrite retry failed\n" );
        };
        eval { $runner->_replace_state_file( $source, $target ); 1 } or $error = $@;
    }

    like(
        $error,
        qr/PowerShell Move-Item fallback failed: Move-Item retry failed; in-place overwrite fallback failed: overwrite retry failed/,
        '_replace_state_file reports both Windows fallback failures after exhausting retries',
    );
    is( scalar @rename_calls, 11, '_replace_state_file retries the Windows rename path across all ten attempts plus the initial rename failure' );
    is( scalar @unlink_calls, 2, '_replace_state_file unlinks the stale Windows target once and then cleans up the pending source before giving up' );
    is( $unlink_calls[0], $target, '_replace_state_file removes the stale Windows target before the retry loop continues' );
    is( $unlink_calls[1], $source, '_replace_state_file removes the pending source file during final Windows cleanup' );
    ok( !-e $source, '_replace_state_file removes the pending source file before dying after repeated Windows replacement failures' );
}

{
    my $pidfile = File::Spec->catfile( $paths->collectors_root, 'ghost.pid' );
    open my $ghost_pid, '>', $pidfile or die $!;
    print {$ghost_pid} "999999\n";
    close $ghost_pid;
is_deeply( [ $runner->running_loops ], [], 'running_loops prunes stale pidfiles' );
}

{
    my $zombie = fork();
    die "fork failed: $!" if !defined $zombie;
    if ( !$zombie ) {
        POSIX::_exit(0);
    }
    for ( 1 .. 50 ) {
        last if ( $runner->_read_process_state($zombie) || '' ) eq 'Z';
        sleep 0.01;
    }
    my $pidfile = File::Spec->catfile( $paths->collectors_root, 'zombie-loop.pid' );
    open my $zombie_pid, '>', $pidfile or die $!;
    print {$zombie_pid} "$zombie\n";
    close $zombie_pid;
    $runner->_write_loop_state(
        'zombie-loop',
        {
            pid          => $zombie,
            name         => 'zombie-loop',
            process_name => 'dashboard collector: zombie-loop',
            status       => 'running',
        }
    );
    is_deeply( [ $runner->running_loops ], [], 'running_loops reaps exited managed loop children before reporting active loops' );
    ok( !-f $pidfile, 'running_loops removes the pidfile for an exited managed loop child after reaping it' );
    ok( !defined $runner->loop_state('zombie-loop'), 'running_loops removes loop metadata for an exited managed loop child after reaping it' );
}

{
    pipe my $result_reader, my $result_writer or die "Unable to create result pipe: $!";
    my $pid = fork();
    die "Unable to fork inherited-fd collector test child: $!" if !defined $pid;
    if ( !$pid ) {
        close $result_reader;
        pipe my $keep_reader, my $keep_writer or die "Unable to create keep pipe: $!";
        pipe my $drop_reader, my $drop_writer or die "Unable to create drop pipe: $!";
        local $SIG{PIPE} = 'IGNORE';
        local $SIG{__WARN__} = sub {
            my ($warning) = @_;
            return if defined $warning && $warning =~ /Bad file descriptor/;
            warn $warning;
        };
        $runner->_close_inherited_fds(
            keep => [
                fileno($result_writer),
                fileno($keep_reader),
                fileno($keep_writer),
            ],
        );
        my $keep_ok = defined syswrite( $keep_writer, "kept\n" ) ? 1 : 0;
        my $drop_ok = defined syswrite( $drop_writer, "dropped\n" ) ? 1 : 0;
        print {$result_writer} "$keep_ok:$drop_ok\n";
        close $result_writer;
        undef $drop_writer;
        undef $keep_writer;
        undef $keep_reader;
        undef $result_reader;
        POSIX::_exit(0);
    }
    close $result_writer;
    my $payload = <$result_reader>;
    close $result_reader;
    waitpid( $pid, 0 );
    chomp $payload if defined $payload;
    is( $payload, '1:0', '_close_inherited_fds keeps explicit collector child descriptors open while closing the rest' );
}

my $empty_config = Developer::Dashboard::Config->new(
    files => Developer::Dashboard::FileRegistry->new(
        paths => Developer::Dashboard::PathRegistry->new( home => tempdir(CLEANUP => 1) )
    ),
    paths => Developer::Dashboard::PathRegistry->new( home => tempdir(CLEANUP => 1) ),
);
is_deeply(
    [
        map {
            +{
                name     => $_->{name},
                cwd      => $_->{cwd},
                interval => $_->{interval},
                has_code => defined $_->{code} ? 1 : 0,
            }
        } @{ $empty_config->collectors }
    ],
    [ { name => 'housekeeper', cwd => 'home', interval => 900, has_code => 1 } ],
    'collectors includes the built-in housekeeper job without requiring user config',
);

{
    my $inherit_home = tempdir(CLEANUP => 1);
    local $ENV{HOME} = $inherit_home;
    local $ENV{DEVELOPER_DASHBOARD_STATE_ROOT} = tempdir(CLEANUP => 1);
    local $ENV{DEVELOPER_DASHBOARD_BOOKMARKS};
    local $ENV{DEVELOPER_DASHBOARD_CONFIGS};
    local $ENV{DEVELOPER_DASHBOARD_CHECKERS};

    my $inherit_paths = Developer::Dashboard::PathRegistry->new( home => $inherit_home );
    my $inherit_files = Developer::Dashboard::FileRegistry->new( paths => $inherit_paths );
    my $inherit_config = Developer::Dashboard::Config->new( files => $inherit_files, paths => $inherit_paths );
    $inherit_config->save_global(
        {
            collectors => [
                {
                    name      => 'housekeeper',
                    interval  => 45,
                    indicator => {
                        icon => 'HK',
                    },
                },
            ],
        }
    );

    my ($housekeeper_job) = grep { $_->{name} eq 'housekeeper' } @{ $inherit_config->collectors };
    is( $housekeeper_job->{interval}, 45, 'configured housekeeper interval overrides the built-in default without replacing the built-in job' );
    is( $housekeeper_job->{cwd}, 'home', 'configured housekeeper override inherits the built-in working-directory default' );
    ok( defined $housekeeper_job->{code} && $housekeeper_job->{code} =~ /Housekeeper->new/, 'configured housekeeper override keeps the built-in collector code' );
    is( $housekeeper_job->{indicator}{icon}, 'HK', 'configured housekeeper override merges nested indicator settings into the built-in job' );
}

{
    my $housekeeper = Developer::Dashboard::Housekeeper->new( paths => $paths );
    my $housekeeper_tmp = tempdir(CLEANUP => 1);
    local $ENV{TMPDIR} = $housekeeper_tmp;
    my $current_state_root = $paths->state_root;
    my $stale_runtime_root = File::Spec->catdir( $home, 'missing-project', '.developer-dashboard' );
    my $stale_state_root = File::Spec->catdir( $paths->state_base_root, $paths->_state_root_key($stale_runtime_root) );
    make_path($stale_state_root);
    my $stale_metadata = File::Spec->catfile( $stale_state_root, 'runtime.json' );
    open my $stale_fh, '>', $stale_metadata or die "Unable to write $stale_metadata: $!";
    print {$stale_fh} json_encode(
        {
            runtime_root => $stale_runtime_root,
            app_name     => $paths->app_name,
        }
    );
    close $stale_fh;
    utime time - 7200, time - 7200, $stale_state_root or die "Unable to age $stale_state_root: $!";
    utime time - 7200, time - 7200, $stale_metadata or die "Unable to age $stale_metadata: $!";

    my ( $ajax_fh, $ajax_path ) = tempfile( 'developer-dashboard-ajax-XXXXXX', TMPDIR => 1, UNLINK => 0 );
    print {$ajax_fh} "payload";
    close $ajax_fh;
    utime time - 7200, time - 7200, $ajax_path or die "Unable to age $ajax_path: $!";

    my ( $result_fh, $result_path ) = tempfile( 'dashboard-result-XXXXXX', TMPDIR => 1, UNLINK => 0 );
    print {$result_fh} "payload";
    close $result_fh;
    utime time - 7200, time - 7200, $result_path or die "Unable to age $result_path: $!";

    my $result = $housekeeper->run( min_age_seconds => 60 );
    is( $result->{ok}, 1, 'housekeeper run reports success' );
    ok( !-d $stale_state_root, 'housekeeper removes stale runtime state roots under the shared temp state tree' );
    ok( -d $current_state_root, 'housekeeper keeps the active runtime state root' );
    ok( !-e $ajax_path, 'housekeeper removes stale ajax payload temp files' );
    ok( !-e $result_path, 'housekeeper removes stale runtime result temp files' );
    ok(
        grep(
            {
                $_->{kind} eq 'state-root'
                  && $_->{path} eq $stale_state_root
            } @{ $result->{removed} || [] }
        ),
        'housekeeper reports removed stale state roots explicitly',
    );
    ok(
        grep(
            {
                $_->{kind} eq 'ajax-temp-file'
                  && $_->{path} eq $ajax_path
            } @{ $result->{removed} || [] }
        ),
        'housekeeper reports removed ajax temp files explicitly',
    );
    ok(
        grep(
            {
                $_->{kind} eq 'result-temp-file'
                  && $_->{path} eq $result_path
            } @{ $result->{removed} || [] }
        ),
        'housekeeper reports removed runtime result temp files explicitly',
    );
}

{
    my $rotation_home = tempdir(CLEANUP => 1);
    local $ENV{HOME} = $rotation_home;
    local $ENV{DEVELOPER_DASHBOARD_STATE_ROOT} = tempdir(CLEANUP => 1);
    local $ENV{DEVELOPER_DASHBOARD_BOOKMARKS};
    local $ENV{DEVELOPER_DASHBOARD_CONFIGS};
    local $ENV{DEVELOPER_DASHBOARD_CHECKERS};

    my $rotation_paths = Developer::Dashboard::PathRegistry->new( home => $rotation_home );
    my $rotation_files = Developer::Dashboard::FileRegistry->new( paths => $rotation_paths );
    my $rotation_config = Developer::Dashboard::Config->new( files => $rotation_files, paths => $rotation_paths );
    $rotation_config->save_global(
        {
            collectors => [
                {
                    name     => 'line.rotator',
                    cwd      => 'home',
                    rotation => {
                        lines => 4,
                    },
                },
                {
                    name      => 'age.rotator',
                    cwd       => 'home',
                    rotations => {
                        days => 1,
                    },
                },
            ],
        }
    );

    my $rotation_collector = Developer::Dashboard::Collector->new( paths => $rotation_paths );
    my $line_log = $rotation_collector->collector_paths('line.rotator')->{log};
    open my $line_fh, '>', $line_log or die "Unable to write $line_log: $!";
    print {$line_fh} "line-1\nline-2\nline-3\nline-4\nline-5\nline-6\n";
    close $line_fh or die "Unable to close $line_log: $!";

    $rotation_collector->append_log_entry(
        'age.rotator',
        happened_at => '2026-04-10T10:00:00Z',
        exit_code   => 0,
        stdout      => "old-entry\n",
    );
    $rotation_collector->append_log_entry(
        'age.rotator',
        happened_at => '2026-04-17T00:00:00+0100',
        exit_code   => 0,
        stdout      => "fresh-entry\n",
    );

    my $rotation_housekeeper = Developer::Dashboard::Housekeeper->new( paths => $rotation_paths );
    my $rotation_result = $rotation_housekeeper->run(
        min_age_seconds => 0,
        now_epoch       => 1_776_441_600,
    );

    open my $rotated_line_fh, '<', $line_log or die "Unable to read $line_log: $!";
    my $rotated_line_log = do { local $/; <$rotated_line_fh> };
    close $rotated_line_fh or die "Unable to close $line_log: $!";
    is( $rotated_line_log, "line-3\nline-4\nline-5\nline-6\n", 'housekeeper line rotation keeps only the configured trailing collector log lines' );

    my $age_log = $rotation_collector->read_log('age.rotator');
    unlike( $age_log, qr/old-entry/, 'housekeeper time-based collector log rotation removes entries older than the configured retention window' );
    like( $age_log, qr/fresh-entry/, 'housekeeper time-based collector log rotation keeps entries that are still inside the retention window' );
    ok( $rotation_result->{scanned}{collector_logs} >= 2, 'housekeeper reports scanned collector logs when rotation rules are configured' );
    ok(
        grep(
            {
                $_->{kind} eq 'collector-log-rotation'
                  && $_->{name} eq 'line.rotator'
                  && $_->{strategy} =~ /lines/
            } @{ $rotation_result->{removed} || [] }
        ),
        'housekeeper reports collector log line rotation explicitly',
    );
    ok(
        grep(
            {
                $_->{kind} eq 'collector-log-rotation'
                  && $_->{name} eq 'age.rotator'
                  && $_->{strategy} =~ /days/
            } @{ $rotation_result->{removed} || [] }
        ),
        'housekeeper reports collector log age rotation explicitly',
    );
}

dies_like(
    sub { Developer::Dashboard::Housekeeper->new },
    qr/Missing paths registry/,
    'housekeeper requires a path registry',
);

{
    my $blank_home = tempdir(CLEANUP => 1);
    my $blank_paths = Developer::Dashboard::PathRegistry->new( home => $blank_home );
    my $blank_keeper = Developer::Dashboard::Housekeeper->new( paths => $blank_paths );
    my $blank_tmp = tempdir(CLEANUP => 1);
    my $blank_result;
    {
        no warnings qw(redefine once);
        local *File::Spec::tmpdir = sub { return $blank_tmp };
        $blank_result = $blank_keeper->run( min_age_seconds => 0 );
    }
    ok( $blank_result->{scanned}{state_roots} >= 0, 'housekeeper reports a non-negative count for scanned state roots' );
    ok( $blank_result->{scanned}{ajax_temp_files} >= 0, 'housekeeper reports a non-negative count for scanned ajax temp files' );
    ok( $blank_result->{scanned}{result_temp_files} >= 0, 'housekeeper reports a non-negative count for scanned runtime result temp files' );
    ok( $blank_result->{scanned}{collector_logs} >= 0, 'housekeeper reports a non-negative count for scanned collector logs' );
    is( $blank_result->{removed_count}, 0, 'housekeeper reports zero removals when nothing is stale' );
    dies_like(
        sub { $blank_keeper->run( min_age_seconds => 'soon' ) },
        qr/min_age_seconds must be a non-negative integer/,
        'housekeeper rejects invalid min_age_seconds values',
    );
    ok( !$blank_keeper->_path_is_old_enough( File::Spec->catfile( $blank_home, 'missing' ), 0 ), '_path_is_old_enough returns false for missing paths' );
    ok( !$blank_keeper->_read_state_metadata( File::Spec->catdir( $blank_home, 'missing-state-root' ) ), '_read_state_metadata returns undef when runtime metadata is missing' );
    ok( $blank_keeper->_only_missing_tree_errors(undef), '_only_missing_tree_errors treats missing error arrays as benign' );
    ok(
        $blank_keeper->_only_missing_tree_errors( [ { '/tmp/gone' => 'No such file or directory' } ] ),
        '_only_missing_tree_errors accepts pure ENOENT removal races',
    );
    ok(
        !$blank_keeper->_only_missing_tree_errors( [ { '/tmp/nope' => 'Permission denied' } ] ),
        '_only_missing_tree_errors rejects non-ENOENT removal failures',
    );
    like( $blank_result->{happened_at}, qr/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/, 'housekeeper timestamps use ISO-8601 UTC format' );
}

{
    my $invalid_rotation_home = tempdir(CLEANUP => 1);
    local $ENV{HOME} = $invalid_rotation_home;
    local $ENV{DEVELOPER_DASHBOARD_STATE_ROOT} = tempdir(CLEANUP => 1);
    local $ENV{DEVELOPER_DASHBOARD_BOOKMARKS};
    local $ENV{DEVELOPER_DASHBOARD_CONFIGS};
    local $ENV{DEVELOPER_DASHBOARD_CHECKERS};

    my $invalid_rotation_paths = Developer::Dashboard::PathRegistry->new( home => $invalid_rotation_home );
    my $invalid_rotation_files = Developer::Dashboard::FileRegistry->new( paths => $invalid_rotation_paths );
    my $invalid_rotation_config = Developer::Dashboard::Config->new( files => $invalid_rotation_files, paths => $invalid_rotation_paths );
    $invalid_rotation_config->save_global(
        {
            collectors => [
                {
                    name     => 'broken.rotator',
                    cwd      => 'home',
                    rotation => {
                        weeks => 'soon',
                    },
                },
            ],
        }
    );

    my $invalid_rotation_housekeeper = Developer::Dashboard::Housekeeper->new( paths => $invalid_rotation_paths );
    dies_like(
        sub { $invalid_rotation_housekeeper->run( min_age_seconds => 0 ) },
        qr/collector rotation weeks for broken\.rotator must be a non-negative integer/,
        'housekeeper rejects invalid collector rotation retention values explicitly',
    );
}

{
    my $branch_home = tempdir(CLEANUP => 1);
    my $branch_paths = Developer::Dashboard::PathRegistry->new( home => $branch_home );
    my $branch_keeper = Developer::Dashboard::Housekeeper->new( paths => $branch_paths );
    my $state_base = $branch_paths->state_base_root;
    my $active_root = File::Spec->catdir( $branch_home, '.developer-dashboard' );
    my $stale_dir = File::Spec->catdir( $state_base, 'stale-invalid-json' );
    my $live_dir = File::Spec->catdir( $state_base, 'live-collector' );
    my $existing_runtime_root = File::Spec->catdir( $branch_home, 'existing-runtime', '.developer-dashboard' );
    my $preserved_dir = File::Spec->catdir( $state_base, 'preserved-runtime' );
    make_path( $active_root, $stale_dir, File::Spec->catdir( $live_dir, 'collectors' ), $existing_runtime_root, $preserved_dir );

    my $stale_meta = File::Spec->catfile( $stale_dir, 'runtime.json' );
    open my $stale_meta_fh, '>', $stale_meta or die "Unable to write $stale_meta: $!";
    print {$stale_meta_fh} "{ not-json }\n";
    close $stale_meta_fh or die "Unable to close $stale_meta: $!";

    my $preserved_meta = File::Spec->catfile( $preserved_dir, 'runtime.json' );
    open my $preserved_meta_fh, '>', $preserved_meta or die "Unable to write $preserved_meta: $!";
    print {$preserved_meta_fh} json_encode(
        {
            runtime_root => $existing_runtime_root,
            app_name     => $branch_paths->app_name,
        }
    );
    close $preserved_meta_fh or die "Unable to close $preserved_meta: $!";

    my $pidfile = File::Spec->catfile( $live_dir, 'collectors', 'housekeeper.pid' );
    open my $pid_fh, '>', $pidfile or die "Unable to write $pidfile: $!";
    print {$pid_fh} "$$\n";
    close $pid_fh or die "Unable to close $pidfile: $!";

    for my $aged_path ( $stale_dir, $stale_meta, $live_dir, File::Spec->catdir( $live_dir, 'collectors' ), $pidfile, $preserved_dir, $preserved_meta ) {
        utime time - 7200, time - 7200, $aged_path or die "Unable to age $aged_path: $!";
    }

    my @removed = $branch_keeper->_cleanup_state_roots(
        min_age_seconds => 60,
        scanned         => { state_roots => 0, ajax_temp_files => 0 },
    );
    ok( !-d $stale_dir, 'cleanup_state_roots removes stale state roots with invalid metadata payloads' );
    ok( -d $live_dir, 'cleanup_state_roots keeps state roots whose collector pidfile points at a live process' );
    ok( -d $preserved_dir, 'cleanup_state_roots keeps old state roots whose runtime metadata still points at an existing runtime root' );
    ok(
        grep( { $_->{path} eq $stale_dir } @removed ),
        'cleanup_state_roots reports stale invalid-metadata roots as removed',
    );
    ok( !$branch_keeper->_state_root_is_stale( $preserved_dir, 60 ), '_state_root_is_stale keeps roots whose runtime metadata still resolves to a live runtime root' );
    ok( $branch_keeper->_state_root_has_live_collectors($live_dir), '_state_root_has_live_collectors returns true for live collector pidfiles' );

    my $blank_runtime_meta_dir = File::Spec->catdir( $state_base, 'blank-runtime-root' );
    make_path($blank_runtime_meta_dir);
    my $blank_runtime_meta = File::Spec->catfile( $blank_runtime_meta_dir, 'runtime.json' );
    open my $blank_runtime_meta_fh, '>', $blank_runtime_meta or die "Unable to write $blank_runtime_meta: $!";
    print {$blank_runtime_meta_fh} json_encode( { runtime_root => '' } );
    close $blank_runtime_meta_fh or die "Unable to close $blank_runtime_meta: $!";
    utime time - 7200, time - 7200, $blank_runtime_meta_dir, $blank_runtime_meta or die "Unable to age $blank_runtime_meta_dir metadata";
    ok( $branch_keeper->_state_root_is_stale( $blank_runtime_meta_dir, 60 ), '_state_root_is_stale treats empty runtime_root metadata as stale' );

    my $missing_runtime_meta_dir = File::Spec->catdir( $state_base, 'missing-runtime-root' );
    make_path($missing_runtime_meta_dir);
    my $missing_runtime_meta = File::Spec->catfile( $missing_runtime_meta_dir, 'runtime.json' );
    open my $missing_runtime_meta_fh, '>', $missing_runtime_meta or die "Unable to write $missing_runtime_meta: $!";
    print {$missing_runtime_meta_fh} json_encode( { runtime_root => File::Spec->catdir( $branch_home, 'missing-runtime', '.developer-dashboard' ) } );
    close $missing_runtime_meta_fh or die "Unable to close $missing_runtime_meta: $!";
    utime time - 7200, time - 7200, $missing_runtime_meta_dir, $missing_runtime_meta or die "Unable to age $missing_runtime_meta_dir metadata";
    ok( $branch_keeper->_state_root_is_stale( $missing_runtime_meta_dir, 60 ), '_state_root_is_stale treats missing runtime_root metadata targets as stale' );

    my $array_meta_dir = File::Spec->catdir( $state_base, 'array-metadata' );
    make_path($array_meta_dir);
    my $array_meta_file = File::Spec->catfile( $array_meta_dir, 'runtime.json' );
    open my $array_meta_fh, '>', $array_meta_file or die "Unable to write $array_meta_file: $!";
    print {$array_meta_fh} "[]\n";
    close $array_meta_fh or die "Unable to close $array_meta_file: $!";
    ok( !$branch_keeper->_read_state_metadata($array_meta_dir), '_read_state_metadata rejects non-hash JSON payloads' );

    my $young_dir = File::Spec->catdir( $state_base, 'young-state-root' );
    make_path($young_dir);
    ok( !$branch_keeper->_state_root_is_stale( $young_dir, 3600 ), '_state_root_is_stale keeps roots that are not yet old enough' );
}

{
    my $removal_home = tempdir(CLEANUP => 1);
    my $removal_paths = Developer::Dashboard::PathRegistry->new( home => $removal_home );
    my $removal_keeper = Developer::Dashboard::Housekeeper->new( paths => $removal_paths );
    my $removal_target = File::Spec->catdir( $removal_home, 'remove-me' );
    make_path($removal_target);
    is_deeply(
        $removal_keeper->_remove_tree( $removal_target, 'state-root' ),
        { kind => 'state-root', path => $removal_target },
        '_remove_tree returns a summary payload for successful removals',
    );
    {
        no warnings 'redefine';
        local *Developer::Dashboard::Housekeeper::remove_tree = sub {
            my ( $path, $opts ) = @_;
            ${ $opts->{error} } = [ { $path => 'Permission denied' } ];
            return 0;
        };
        dies_like(
            sub { $removal_keeper->_remove_tree( File::Spec->catdir( $removal_home, 'broken' ), 'state-root' ) },
            qr/Unable to remove stale state-root/,
            '_remove_tree dies when remove_tree reports a non-ENOENT failure',
        );
    }
}

{
    my $ajax_home = tempdir(CLEANUP => 1);
    my $ajax_tmp = tempdir(CLEANUP => 1);
    local $ENV{TMPDIR} = $ajax_tmp;
    my $ajax_paths = Developer::Dashboard::PathRegistry->new( home => $ajax_home );
    my $ajax_keeper = Developer::Dashboard::Housekeeper->new( paths => $ajax_paths );

    my ( $cleanup_ajax_fh, $cleanup_ajax_path ) = tempfile( 'developer-dashboard-ajax-XXXXXX', TMPDIR => 1, UNLINK => 0 );
    print {$cleanup_ajax_fh} "ajax payload";
    close $cleanup_ajax_fh or die "Unable to close $cleanup_ajax_path: $!";
    utime time - 7200, time - 7200, $cleanup_ajax_path or die "Unable to age $cleanup_ajax_path: $!";

    my ( $cleanup_result_fh, $cleanup_result_path ) = tempfile( 'dashboard-result-XXXXXX', TMPDIR => 1, UNLINK => 0 );
    print {$cleanup_result_fh} "result payload";
    close $cleanup_result_fh or die "Unable to close $cleanup_result_path: $!";
    utime time - 7200, time - 7200, $cleanup_result_path or die "Unable to age $cleanup_result_path: $!";

    my $unrelated_path = File::Spec->catfile( $ajax_tmp, 'unrelated-temp-file' );
    open my $unrelated_fh, '>', $unrelated_path or die "Unable to write $unrelated_path: $!";
    print {$unrelated_fh} "ignore me";
    close $unrelated_fh or die "Unable to close $unrelated_path: $!";
    is_deeply(
        [ sort $ajax_keeper->_temp_file_candidates ],
        [ sort $cleanup_ajax_path, $cleanup_result_path ],
        '_temp_file_candidates only returns dashboard-owned temp file patterns instead of scanning unrelated temp entries',
    );
    is_deeply( [ $ajax_keeper->_temp_file_kind('unrelated-temp-file') ], [], '_temp_file_kind returns no cleanup category for unrelated temp entries' );

    my @removed = $ajax_keeper->_cleanup_temp_files(
        min_age_seconds => 60,
        scanned         => { state_roots => 0, ajax_temp_files => 0, result_temp_files => 0 },
    );
    ok( !-e $cleanup_ajax_path, '_cleanup_temp_files removes old ajax temp files' );
    ok( !-e $cleanup_result_path, '_cleanup_temp_files removes old runtime result temp files' );
    ok(
        grep( { $_->{kind} eq 'ajax-temp-file' && $_->{path} eq $cleanup_ajax_path } @removed ),
        '_cleanup_temp_files reports removed ajax temp files',
    );
    ok(
        grep( { $_->{kind} eq 'result-temp-file' && $_->{path} eq $cleanup_result_path } @removed ),
        '_cleanup_temp_files reports removed runtime result temp files',
    );

    my $blocked_tmp = tempdir( CLEANUP => 1 );
    my ( $ajax_fh, $ajax_path ) = tempfile(
        'developer-dashboard-ajax-FAIL-XXXXXX',
        DIR    => $blocked_tmp,
        UNLINK => 0,
    );
    print {$ajax_fh} "still here";
    close $ajax_fh or die "Unable to close $ajax_path: $!";
    utime time - 7200, time - 7200, $ajax_path or die "Unable to age $ajax_path: $!";
    if ( $> == 0 ) {
        pass('_cleanup_ajax_temp_files unlink-failure branch is skipped under root because root can still remove the temp file despite directory permission tightening');
    }
    else {
        chmod 0555, $blocked_tmp or die "Unable to chmod $blocked_tmp: $!";
        {
            no warnings qw(redefine once);
            local *File::Spec::tmpdir = sub { return $blocked_tmp };
            dies_like(
                sub {
                    $ajax_keeper->_cleanup_temp_files(
                        min_age_seconds => 60,
                        scanned         => { state_roots => 0, ajax_temp_files => 0, result_temp_files => 0 },
                    );
                },
                qr/Unable to remove stale Ajax temp file/,
                '_cleanup_temp_files dies when unlink fails and the temp file still exists',
            );
        }
        chmod 0755, $blocked_tmp or die "Unable to restore $blocked_tmp permissions: $!";
    }
    unlink $ajax_path or die "Unable to remove $ajax_path after ajax unlink failure coverage: $!";
}

dies_like( sub { Developer::Dashboard::UpdateManager->new }, qr/Missing config/, 'update manager requires config' );

{
    package Local::EnvLoader::Functions;

    sub from_env {
        return $ENV{FUNCTION_SOURCE};
    }

    sub blank_value {
        return '';
    }
}

{
    my $env_home = tempdir( CLEANUP => 1 );
    my $previous_cwd = getcwd();
    my $project_root = File::Spec->catdir( $env_home, 'projects', 'env-audit-project' );
    my $child_root = File::Spec->catdir( $project_root, 'child' );
    make_path(
        File::Spec->catdir( $env_home, '.developer-dashboard' ),
        File::Spec->catdir( $project_root, '.git' ),
        File::Spec->catdir( $child_root, '.developer-dashboard' ),
    );
    {
        open my $fh, '>:raw', File::Spec->catfile( $env_home, '.env' ) or die "Unable to write home .env: $!";
        print {$fh} <<'EOF';
ROOT_ONLY=root
SHARED=home
COMPLEX=one=two
# hash comment
// slash comment
/* multi
line
comment */
HOME_REF=~/runtime
SYSTEM_REF=$SYSTEM_ONLY
DEFAULT_REF=${MISSING_VALUE:-fallback}
FUNCTION_REF=${Local::EnvLoader::Functions::from_env():-fallback}
FUNCTION_DEFAULT=${Local::EnvLoader::Functions::blank_value():-fallback-from-function}
CHAIN_REF=$ROOT_ONLY/$SYSTEM_REF
EOF
        close $fh or die "Unable to close home .env: $!";
    }
    {
        open my $fh, '>:raw', File::Spec->catfile( $env_home, '.env.pl' ) or die "Unable to write home .env.pl: $!";
        print {$fh} "\$ENV{ROOT_PL} = \"\$ENV{ROOT_ONLY}-pl\";\n\$ENV{PL_SHARED} = 'home-pl';\n1;\n";
        close $fh or die "Unable to close home .env.pl: $!";
    }
    {
        open my $fh, '>:raw', File::Spec->catfile( $env_home, '.developer-dashboard', '.env' )
          or die "Unable to write home runtime .env: $!";
        print {$fh} "HOME_DD=home-dd\nSHARED=home-dd\n";
        close $fh or die "Unable to close home runtime .env: $!";
    }
    {
        open my $fh, '>:raw', File::Spec->catfile( $project_root, '.env' ) or die "Unable to write project .env: $!";
        print {$fh} "PROJECT_ONLY=project\nSHARED=project\n";
        close $fh or die "Unable to close project .env: $!";
    }
    {
        open my $fh, '>:raw', File::Spec->catfile( $child_root, '.env' ) or die "Unable to write child .env: $!";
        print {$fh} "CHILD_TEXT=child\nSHARED=project\n";
        close $fh or die "Unable to close child .env: $!";
    }
    {
        open my $fh, '>:raw', File::Spec->catfile( $child_root, '.env.pl' ) or die "Unable to write child .env.pl: $!";
        print {$fh} "\$ENV{CHILD_PL} = \"\$ENV{SHARED}-pl\";\n\$ENV{PL_SHARED} = 'child-pl';\n1;\n";
        close $fh or die "Unable to close child .env.pl: $!";
    }
    {
        open my $fh, '>:raw', File::Spec->catfile( $child_root, '.developer-dashboard', '.env' )
          or die "Unable to write child runtime .env: $!";
        print {$fh} "CHILD_DD=child-dd\nSHARED=child-dd\n";
        close $fh or die "Unable to close child runtime .env: $!";
    }

    local $ENV{HOME} = $env_home;
    local $ENV{SYSTEM_ONLY} = 'system';
    local $ENV{ROOT_ONLY};
    local $ENV{ROOT_PL};
    local $ENV{HOME_DD};
    local $ENV{PROJECT_ONLY};
    local $ENV{CHILD_DD};
    local $ENV{CHILD_PL};
    local $ENV{SHARED};
    local $ENV{PL_SHARED};
    local $ENV{COMPLEX};
    local $ENV{HOME_REF};
    local $ENV{SYSTEM_REF};
    local $ENV{DEFAULT_REF};
    local $ENV{FUNCTION_SOURCE} = 'function-result';
    local $ENV{FUNCTION_REF};
    local $ENV{FUNCTION_DEFAULT};
    local $ENV{CHAIN_REF};
    local $ENV{CHILD_TEXT};
    chdir $child_root or die "Unable to chdir to $child_root: $!";
    Developer::Dashboard::EnvAudit->clear();
    my $paths = Developer::Dashboard::PathRegistry->new( home => $env_home );
    Developer::Dashboard::EnvLoader->load_runtime_layers( paths => $paths );

    is( $ENV{ROOT_ONLY}, 'root', '.env loader imports home directory .env values' );
    is( $ENV{ROOT_PL}, 'root-pl', '.env loader loads .env before .env.pl within one layer so .env.pl can use .env values from the same layer' );
    is( $ENV{HOME_DD}, 'home-dd', '.env loader imports home runtime .developer-dashboard/.env values' );
    is( $ENV{PROJECT_ONLY}, 'project', '.env loader imports ancestor project .env values' );
    is( $ENV{CHILD_DD}, 'child-dd', '.env loader imports child runtime .developer-dashboard/.env values' );
    is( $ENV{CHILD_TEXT}, 'child', '.env loader imports child plain-directory .env values' );
    is( $ENV{CHILD_PL}, 'project-pl', '.env loader loads child .env before child .env.pl at the same level with no skipping' );
    is( $ENV{COMPLEX}, 'one=two', '.env loader preserves values that contain additional equals characters' );
    is( $ENV{SHARED}, 'child-dd', 'deeper runtime env files override shallower values' );
    is( $ENV{PL_SHARED}, 'child-pl', '.env.pl files can override earlier values from shallower layers' );
    is( $ENV{HOME_REF}, File::Spec->catdir( $env_home, 'runtime' ), '.env loader expands leading tilde paths relative to home' );
    is( $ENV{SYSTEM_REF}, 'system', '.env loader expands $NAME from the current environment' );
    is( $ENV{DEFAULT_REF}, 'fallback', '.env loader expands ${NAME:-default} when the variable is missing' );
    is( $ENV{FUNCTION_REF}, 'function-result', '.env loader expands ${Function():-default} through a static Perl function' );
    is( $ENV{FUNCTION_DEFAULT}, 'fallback-from-function', '.env loader uses the default value when a static Perl function returns empty text' );
    is( $ENV{CHAIN_REF}, 'root/system', '.env loader expands references to earlier variables from the same file' );

    is_deeply(
        Developer::Dashboard::EnvAudit->key('ROOT_ONLY'),
        {
            value   => 'root',
            envfile => File::Spec->catfile( $env_home, '.env' ),
        },
        'EnvAudit records the home .env source file for one imported key',
    );
    is_deeply(
        Developer::Dashboard::EnvAudit->key('SHARED'),
        {
            value   => 'child-dd',
            envfile => File::Spec->catfile( $child_root, '.developer-dashboard', '.env' ),
        },
        'EnvAudit records the deepest env file that supplied the effective value',
    );
    is_deeply(
        Developer::Dashboard::EnvAudit->key('CHILD_PL'),
        {
            value   => 'project-pl',
            envfile => File::Spec->catfile( $child_root, '.env.pl' ),
        },
        'EnvAudit records .env.pl sourced values with the originating file path',
    );
    ok( !defined Developer::Dashboard::EnvAudit->key('SYSTEM_ONLY'), 'EnvAudit leaves system-only environment keys untracked' );
    my $audit_keys = Developer::Dashboard::EnvAudit->keys();
    is( $audit_keys->{ROOT_PL}{value}, 'root-pl', 'EnvAudit->keys exposes recorded values as a hash inventory' );
    is( $audit_keys->{CHILD_DD}{envfile}, File::Spec->catfile( $child_root, '.developer-dashboard', '.env' ), 'EnvAudit->keys exposes the source file for each recorded key' );
    chdir $previous_cwd or die "Unable to chdir back to $previous_cwd: $!";
}
{
    my $env_home = tempdir( CLEANUP => 1 );
    my $project_root = File::Spec->catdir( $env_home, 'projects', 'cached-cwd-env-project' );
    my $child_root = File::Spec->catdir( $project_root, 'child' );
    make_path(
        File::Spec->catdir( $env_home, '.developer-dashboard' ),
        File::Spec->catdir( $project_root, '.git' ),
        $child_root,
    );
    open my $home_fh, '>:raw', File::Spec->catfile( $env_home, '.env' ) or die "Unable to write cached cwd home .env: $!";
    print {$home_fh} "ROOT_CACHE=home\n";
    close $home_fh or die "Unable to close cached cwd home .env: $!";
    open my $project_fh, '>:raw', File::Spec->catfile( $project_root, '.env' ) or die "Unable to write cached cwd project .env: $!";
    print {$project_fh} "PROJECT_CACHE=project\n";
    close $project_fh or die "Unable to close cached cwd project .env: $!";
    open my $child_fh, '>:raw', File::Spec->catfile( $child_root, '.env' ) or die "Unable to write cached cwd child .env: $!";
    print {$child_fh} "CHILD_CACHE=child\n";
    close $child_fh or die "Unable to close cached cwd child .env: $!";

    local $ENV{HOME} = $env_home;
    local $ENV{ROOT_CACHE};
    local $ENV{PROJECT_CACHE};
    local $ENV{CHILD_CACHE};
    Developer::Dashboard::EnvAudit->clear();
    my $paths = Developer::Dashboard::PathRegistry->new(
        home => $env_home,
        cwd  => $child_root,
    );
    {
        no warnings 'redefine';
        local *Developer::Dashboard::EnvLoader::cwd = sub { die "EnvLoader plain-directory scanning should reuse the registry cwd\n"; };
        Developer::Dashboard::EnvLoader->load_runtime_layers( paths => $paths );
    }
    is( $ENV{ROOT_CACHE}, 'home', 'EnvLoader still loads the root plain-directory env file when using the registry cwd cache' );
    is( $ENV{PROJECT_CACHE}, 'project', 'EnvLoader still loads the project plain-directory env file when using the registry cwd cache' );
    is( $ENV{CHILD_CACHE}, 'child', 'EnvLoader still loads the leaf plain-directory env file when using the registry cwd cache' );
}

{
    my $docker_runtime_root = File::Spec->catdir( $home, '.developer-dashboard' );
    my $nested_leaf = File::Spec->catdir( $docker_runtime_root, 'skills', 'alpha', 'skills', 'beta' );
    make_path(
        File::Spec->catdir( $docker_runtime_root, 'skills', 'alpha', 'config', 'docker', 'alpha' ),
        File::Spec->catdir( $nested_leaf, 'config', 'docker', 'beta' ),
    );
    is_deeply(
        [ sort $paths->installed_skill_docker_roots_for_runtime($docker_runtime_root) ],
        [
            File::Spec->catdir( $docker_runtime_root, 'skills', 'alpha', 'config', 'docker' ),
        ],
        'installed_skill_docker_roots_for_runtime returns docker roots for the matching runtime layer',
    );
    is_deeply(
        [ $paths->installed_skill_docker_roots_for_runtime(undef) ],
        [],
        'installed_skill_docker_roots_for_runtime returns an empty list when runtime root is missing',
    );
}

{
    my $bad_home = tempdir( CLEANUP => 1 );
    my $previous_cwd = getcwd();
    my $bad_project = File::Spec->catdir( $bad_home, 'projects', 'bad-env-project' );
    make_path( File::Spec->catdir( $bad_home, '.developer-dashboard' ), File::Spec->catdir( $bad_project, '.git' ) );
    open my $fh, '>:raw', File::Spec->catfile( $bad_project, '.env' ) or die "Unable to write malformed .env: $!";
    print {$fh} "THIS IS NOT VALID\n";
    close $fh or die "Unable to close malformed .env: $!";
    local $ENV{HOME} = $bad_home;
    chdir $bad_project or die "Unable to chdir to $bad_project: $!";
    Developer::Dashboard::EnvAudit->clear();
    my $paths = Developer::Dashboard::PathRegistry->new( home => $bad_home );
    dies_like(
        sub { Developer::Dashboard::EnvLoader->load_runtime_layers( paths => $paths ) },
        qr/Invalid env line .*bad-env-project\/\.env line 1/,
        'EnvLoader dies explicitly when one .env line is malformed',
    );
    chdir $previous_cwd or die "Unable to chdir back to $previous_cwd: $!";
}

{
    my $bad_home = tempdir( CLEANUP => 1 );
    my $previous_cwd = getcwd();
    my $bad_project = File::Spec->catdir( $bad_home, 'projects', 'bad-env-key-project' );
    make_path( File::Spec->catdir( $bad_home, '.developer-dashboard' ), File::Spec->catdir( $bad_project, '.git' ) );
    open my $fh, '>:raw', File::Spec->catfile( $bad_project, '.env' ) or die "Unable to write invalid-key .env: $!";
    print {$fh} "1INVALID=value\n";
    close $fh or die "Unable to close invalid-key .env: $!";
    local $ENV{HOME} = $bad_home;
    chdir $bad_project or die "Unable to chdir to $bad_project: $!";
    Developer::Dashboard::EnvAudit->clear();
    my $paths = Developer::Dashboard::PathRegistry->new( home => $bad_home );
    dies_like(
        sub { Developer::Dashboard::EnvLoader->load_runtime_layers( paths => $paths ) },
        qr/Invalid env key .*bad-env-key-project\/\.env line 1/,
        'EnvLoader rejects invalid environment variable names explicitly',
    );
    chdir $previous_cwd or die "Unable to chdir back to $previous_cwd: $!";
}

{
    my $bad_home = tempdir( CLEANUP => 1 );
    my $previous_cwd = getcwd();
    my $bad_project = File::Spec->catdir( $bad_home, 'projects', 'bad-env-function-project' );
    make_path( File::Spec->catdir( $bad_home, '.developer-dashboard' ), File::Spec->catdir( $bad_project, '.git' ) );
    open my $fh, '>:raw', File::Spec->catfile( $bad_project, '.env' ) or die "Unable to write invalid-function .env: $!";
    print {$fh} "BROKEN=\${Local::EnvLoader::Functions::missing():-fallback}\n";
    close $fh or die "Unable to close invalid-function .env: $!";
    local $ENV{HOME} = $bad_home;
    chdir $bad_project or die "Unable to chdir to $bad_project: $!";
    Developer::Dashboard::EnvAudit->clear();
    my $paths = Developer::Dashboard::PathRegistry->new( home => $bad_home );
    dies_like(
        sub { Developer::Dashboard::EnvLoader->load_runtime_layers( paths => $paths ) },
        qr/Invalid env function .*Local::EnvLoader::Functions::missing/,
        'EnvLoader rejects undefined static Perl env functions explicitly',
    );
    chdir $previous_cwd or die "Unable to chdir back to $previous_cwd: $!";
}

{
    my $bad_home = tempdir( CLEANUP => 1 );
    my $previous_cwd = getcwd();
    my $bad_project = File::Spec->catdir( $bad_home, 'projects', 'bad-env-pl-project' );
    make_path( File::Spec->catdir( $bad_home, '.developer-dashboard' ), File::Spec->catdir( $bad_project, '.git' ) );
    open my $fh, '>:raw', File::Spec->catfile( $bad_project, '.env.pl' ) or die "Unable to write false-return .env.pl: $!";
    print {$fh} "0;\n";
    close $fh or die "Unable to close false-return .env.pl: $!";
    local $ENV{HOME} = $bad_home;
    chdir $bad_project or die "Unable to chdir to $bad_project: $!";
    Developer::Dashboard::EnvAudit->clear();
    my $paths = Developer::Dashboard::PathRegistry->new( home => $bad_home );
    dies_like(
        sub { Developer::Dashboard::EnvLoader->load_runtime_layers( paths => $paths ) },
        qr/did not return a true value/,
        'EnvLoader propagates .env.pl execution failures instead of hiding them',
    );
    chdir $previous_cwd or die "Unable to chdir back to $previous_cwd: $!";
}

{
    my $overlay_dir = tempdir( CLEANUP => 1 );
    my $env_file = File::Spec->catfile( $overlay_dir, '.env' );
    my $env_pl_file = File::Spec->catfile( $overlay_dir, '.env.pl' );
    open my $env_fh, '>:raw', $env_file or die "Unable to write overlay env file: $!";
    print {$env_fh} "VERSION=leaf\nNEW_ONLY=from-file\n";
    close $env_fh or die "Unable to close overlay env file: $!";
    open my $env_pl_fh, '>:raw', $env_pl_file or die "Unable to write overlay env.pl file: $!";
    print {$env_pl_fh} "\$ENV{PL_ONLY} = \"\$ENV{VERSION}-pl\";\n1;\n";
    close $env_pl_fh or die "Unable to close overlay env.pl file: $!";

    my $overlay = Developer::Dashboard::EnvLoader->load_files_into_hash(
        files    => [ undef, $env_file, $env_pl_file, $env_file ],
        base_env => {
            KEEP    => 'keep',
            VERSION => 'base',
        },
    );
    is_deeply(
        $overlay->{files},
        [ $env_file, $env_pl_file ],
        'load_files_into_hash reports only unique existing files in load order',
    );
    is_deeply(
        $overlay->{env},
        {
            DEVELOPER_DASHBOARD_ENV_AUDIT => json_encode(
                {
                    NEW_ONLY => {
                        envfile => $env_file,
                        value   => 'from-file',
                    },
                    PL_ONLY => {
                        envfile => $env_pl_file,
                        value   => 'leaf-pl',
                    },
                    VERSION => {
                        envfile => $env_file,
                        value   => 'leaf',
                    },
                }
            ),
            NEW_ONLY => 'from-file',
            PL_ONLY  => 'leaf-pl',
            VERSION  => 'leaf',
        },
        'load_files_into_hash returns only added and changed environment values',
    );

    local $ENV{HOME};
    is(
        Developer::Dashboard::EnvLoader->_expand_env_value(
            value   => '~/fallback-home',
            file    => $env_file,
            line_no => 1,
        ),
        '~/fallback-home',
        '_expand_env_value preserves leading tilde text when HOME is unavailable',
    );
}

{
    local $ENV{DEVELOPER_DASHBOARD_ENV_AUDIT};
    Developer::Dashboard::EnvAudit->clear();
    dies_like(
        sub { Developer::Dashboard::EnvAudit->record( undef, 'value', '/tmp/test.env' ) },
        qr/Missing env audit key/,
        'EnvAudit rejects an undefined audit key explicitly',
    );
    dies_like(
        sub { Developer::Dashboard::EnvAudit->record( '', 'value', '/tmp/test.env' ) },
        qr/Missing env audit key/,
        'EnvAudit rejects an empty audit key explicitly',
    );
    dies_like(
        sub { Developer::Dashboard::EnvAudit->record( 'FOO', 'value', undef ) },
        qr/Missing env audit source file/,
        'EnvAudit rejects an undefined audit source file explicitly',
    );
    dies_like(
        sub { Developer::Dashboard::EnvAudit->record( 'FOO', 'value', '' ) },
        qr/Missing env audit source file/,
        'EnvAudit rejects an empty audit source file explicitly',
    );
    ok( !defined Developer::Dashboard::EnvAudit->key(undef), 'EnvAudit->key returns undef for an undefined key lookup' );
    ok( !defined Developer::Dashboard::EnvAudit->key(''), 'EnvAudit->key returns undef for an empty key lookup' );
}

{
    Developer::Dashboard::EnvAudit->clear();
    local $ENV{DEVELOPER_DASHBOARD_ENV_AUDIT} = json_encode(
        {
            FOO => {
                value   => 'bar',
                envfile => '/tmp/runtime.env',
            },
        }
    );
    my $rehydrated = Developer::Dashboard::EnvAudit->keys;
    is_deeply(
        $rehydrated,
        {
            FOO => {
                value   => 'bar',
                envfile => '/tmp/runtime.env',
            },
        },
        'EnvAudit rehydrates its audit inventory from DEVELOPER_DASHBOARD_ENV_AUDIT when a child process inherits it',
    );
    is_deeply(
        Developer::Dashboard::EnvAudit->key('FOO'),
        {
            value   => 'bar',
            envfile => '/tmp/runtime.env',
        },
        'EnvAudit->key reads the rehydrated inherited audit entry',
    );
}

{
    Developer::Dashboard::EnvAudit->clear();
    local $ENV{DEVELOPER_DASHBOARD_ENV_AUDIT} = json_encode( ['not-a-hash'] );
    dies_like(
        sub { Developer::Dashboard::EnvAudit->keys },
        qr/DEVELOPER_DASHBOARD_ENV_AUDIT must decode to a hash/,
        'EnvAudit rejects inherited audit payloads that do not decode to a hash',
    );
}

done_testing;

__END__

=head1 NAME

07-core-units.t - core unit tests for Developer Dashboard

=head1 DESCRIPTION

This test exercises low-level runtime units including paths, files, pages,
collectors, indicators, prompts, and process helpers.

=for comment FULL-POD-DOC START

=head1 PURPOSE

This test is the executable regression contract for the thin CLI, helper staging, and low-level runtime contracts. Read it when you need to understand the real fixture setup, assertions, and failure modes for this slice of the repository instead of guessing from the module names alone.

=head1 WHY IT EXISTS

It exists because the thin CLI, helper staging, and low-level runtime contracts has enough moving parts that a code-only review can miss real regressions. Keeping those expectations in a dedicated test file makes the TDD loop, coverage loop, and release gate concrete.

=head1 WHEN TO USE

Use this file when changing the thin CLI, helper staging, and low-level runtime contracts, when a focused CI failure points here, or when you want a faster regression loop than running the entire suite.

=head1 HOW TO USE

Run it directly with C<prove -lv t/07-core-units.t> while iterating, then keep it green under C<prove -lr t> and the coverage runs before release. 

=head1 WHAT USES IT

Developers during TDD, the full C<prove -lr t> suite, the coverage gates, and the release verification loop all rely on this file to keep this behavior from drifting.

=head1 EXAMPLES

Example 1:

  prove -lv t/07-core-units.t

Run the focused regression test by itself while you are changing the behavior it owns.

Example 2:

  HARNESS_PERL_SWITCHES=-MDevel::Cover prove -lv t/07-core-units.t

Exercise the same focused test while collecting coverage for the library code it reaches.

Example 3:

  prove -lr t

Put the focused fix back through the whole repository suite before calling the work finished.

=for comment FULL-POD-DOC END

=cut
