use strict;
use warnings;
use utf8;

use Capture::Tiny qw(capture);
use Cwd qw(getcwd);
use Digest::SHA qw(sha256_hex);
use Encode qw(decode encode FB_CROAK);
use File::Path qw(make_path);
use File::Spec;
use File::Temp qw(tempdir);
use HTTP::Request::Common qw(GET POST);
use HTTP::Response;
use POSIX qw(:sys_wait_h);
use Test::More;
use URI::Escape qw(uri_escape);

use lib 'lib';
use lib 't/lib';

use Developer::Dashboard::Auth;
use Developer::Dashboard::Collector;
use Developer::Dashboard::CollectorRunner;
use Developer::Dashboard::Config;
use Developer::Dashboard::FileRegistry;
use Developer::Dashboard::JSON qw(json_decode);
use Developer::Dashboard::PageDocument;
use Developer::Dashboard::PageRuntime;
use Developer::Dashboard::PageStore;
use Developer::Dashboard::PathRegistry;
use Developer::Dashboard::SessionStore;
use Developer::Dashboard::SkillManager;
use Developer::Dashboard::UpdateManager;
use Developer::Dashboard::Web::App;
use Developer::Dashboard::Web::DancerApp;
use Developer::Dashboard::Web::Server;
use Local::PSGITest;

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

sub drain_stream_body {
    my ($body) = @_;
    return $body if ref($body) ne 'HASH' || ref( $body->{stream} ) ne 'CODE';
    my $output = '';
    $body->{stream}->( sub { $output .= $_[0] if defined $_[0] } );
    return $output;
}

sub decode_body_text {
    my ($body) = @_;
    return $body if !defined $body || utf8::is_utf8($body);
    return decode( 'UTF-8', $body, FB_CROAK );
}

sub form_body {
    my (@pairs) = @_;
    my @encoded;
    while (@pairs) {
        my ( $name, $value ) = splice @pairs, 0, 2;
        push @encoded, uri_escape($name) . '=' . uri_escape( defined $value ? $value : '' );
    }
    return join '&', @encoded;
}

my $home = tempdir(CLEANUP => 1);
local $ENV{HOME} = $home;
local $ENV{DEVELOPER_DASHBOARD_BOOKMARKS};
local $ENV{DEVELOPER_DASHBOARD_CONFIGS};
local $ENV{DEVELOPER_DASHBOARD_CHECKERS};
chdir $home or die "Unable to chdir to $home: $!";
my $test_repos = tempdir( CLEANUP => 1 );
my $paths = Developer::Dashboard::PathRegistry->new( home => $home );
my $files = Developer::Dashboard::FileRegistry->new( paths => $paths );
my $config = Developer::Dashboard::Config->new( files => $files, paths => $paths );
my $store = Developer::Dashboard::PageStore->new( paths => $paths );
my $runtime = Developer::Dashboard::PageRuntime->new( paths => $paths );
my $auth = Developer::Dashboard::Auth->new( files => $files, paths => $paths );
my $sessions = Developer::Dashboard::SessionStore->new( paths => $paths );

my $page = Developer::Dashboard::PageDocument->new(
    id     => 'sample',
    title  => 'Sample',
    layout => { body => 'body text [% stash.name %]' },
);
$store->save_page($page);
my $dotted_page = Developer::Dashboard::PageDocument->new(
    id     => 'learn.ai',
    title  => 'Learn AI',
    layout => { body => 'learn ai body' },
);
$store->save_page($dotted_page);
open my $runtime_routes, '>:raw', File::Spec->catfile( $paths->config_root, 'routes.json' )
  or die "Unable to write runtime routes.json: $!";
print {$runtime_routes} <<'JSON';
{
   "/java" : "/app/learn.ai"
}
JSON
close $runtime_routes or die "Unable to close runtime routes.json: $!";

my $app = Developer::Dashboard::Web::App->new(
    auth     => $auth,
    config   => $config,
    pages    => $store,
    runtime  => $runtime,
    sessions => $sessions,
);

my $skill_manager = Developer::Dashboard::SkillManager->new( paths => $paths );
my $dancer_skill_repo = _create_dancer_route_skill_repo('dancer-route-skill');
my $dancer_skill_install = $skill_manager->install( 'file://' . $dancer_skill_repo );
ok( !$dancer_skill_install->{error}, 'dancer route skill installs cleanly for PSGI route coverage' )
  or diag $dancer_skill_install->{error};

{
    my $psgi_app = Developer::Dashboard::Web::DancerApp->build_psgi_app( app => $app );
    Local::PSGITest::test_psgi $psgi_app, sub {
        my ($cb) = @_;
        my $ajax = $cb->( GET 'http://127.0.0.1/v1/dancer-route-skill/bar' );
        is( $ajax->code, 200, 'Dancer custom ajax route serves top-level skill-local ajax handlers' );
        is( $ajax->content_type, 'application/json', 'Dancer custom ajax route applies its default json content type' );
        is( decode_body_text( $ajax->content ), qq|{"route":"dancer-top"}\n|, 'Dancer custom ajax route streams the top-level skill-local ajax body' );

        my $legacy_ajax = $cb->( GET 'http://127.0.0.1/ajax/dancer-route-skill/bar' );
        is( $legacy_ajax->code, 200, 'Dancer smart ajax route still serves the top-level skill-local ajax handler' );
        is( decode_body_text( $legacy_ajax->content ), qq|{"route":"dancer-top"}\n|, 'Dancer smart ajax route still streams the top-level skill-local ajax body' );

        my $nested = $cb->( GET 'http://127.0.0.1/v1/dancer-route-skill/nested' );
        is( $nested->code, 200, 'Dancer custom ajax route serves nested skill-local ajax handlers' );
        is( $nested->content_type, 'text/html', 'Dancer nested custom ajax route applies its default html content type' );
        is( decode_body_text( $nested->content ), "<p>dancer nested ajax</p>\n", 'Dancer custom ajax route streams the nested skill-local ajax body' );

        my $legacy_nested = $cb->( GET 'http://127.0.0.1/ajax/dancer-route-skill/def/nested' );
        is( $legacy_nested->code, 200, 'Dancer smart ajax route still serves nested skill-local ajax handlers' );
        is( decode_body_text( $legacy_nested->content ), "<p>dancer nested ajax</p>\n", 'Dancer smart ajax route still streams the nested skill-local ajax body' );

        my $custom_page = $cb->( GET 'http://127.0.0.1/apps/dancer-route-skill/home' );
        is( $custom_page->code, 200, 'Dancer custom app route serves the top-level skill bookmark page' );
        like( decode_body_text( $custom_page->content ), qr/Dancer Skill Index/, 'Dancer custom app route renders the top-level skill bookmark body' );

        my $page = $cb->( GET 'http://127.0.0.1/app/dancer-route-skill' );
        is( $page->code, 200, 'Dancer app route serves the top-level skill bookmark page' );
        like( decode_body_text( $page->content ), qr/Dancer Skill Index/, 'Dancer app route renders the top-level skill bookmark body' );

        my $custom_nested_page = $cb->( GET 'http://127.0.0.1/apps/dancer-route-skill/child' );
        is( $custom_nested_page->code, 200, 'Dancer custom app route serves the nested skill bookmark page' );
        like( decode_body_text( $custom_nested_page->content ), qr/Dancer Nested Index/, 'Dancer custom app route renders the nested skill bookmark body' );

        my $nested_page = $cb->( GET 'http://127.0.0.1/app/dancer-route-skill/def' );
        is( $nested_page->code, 200, 'Dancer app route serves the nested skill bookmark page' );
        like( decode_body_text( $nested_page->content ), qr/Dancer Nested Index/, 'Dancer app route renders the nested skill bookmark body' );

        my $custom_js = $cb->( GET 'http://127.0.0.1/assets/dancer-route-skill.js' );
        is( $custom_js->code, 200, 'Dancer custom js route serves top-level skill-local assets' );
        is( decode_body_text( $custom_js->content ), qq{console.log("dancer top-level js");\n}, 'Dancer custom js route returns the top-level skill-local asset body' );

        my $js = $cb->( GET 'http://127.0.0.1/js/dancer-route-skill/skill.js' );
        is( $js->code, 200, 'Dancer js route serves top-level skill-local assets' );
        is( decode_body_text( $js->content ), qq{console.log("dancer top-level js");\n}, 'Dancer js route returns the top-level skill-local asset body' );

        my $custom_nested_js = $cb->( GET 'http://127.0.0.1/assets/dancer-route-skill-nested.js' );
        is( $custom_nested_js->code, 200, 'Dancer custom js route serves nested skill-local assets' );
        is( decode_body_text( $custom_nested_js->content ), qq{console.log("dancer nested js");\n}, 'Dancer custom js route returns the nested skill-local asset body' );

        my $nested_js = $cb->( GET 'http://127.0.0.1/js/dancer-route-skill/def/ijk/lmn.js' );
        is( $nested_js->code, 200, 'Dancer js route serves nested skill-local assets' );
        is( decode_body_text( $nested_js->content ), qq{console.log("dancer nested js");\n}, 'Dancer js route returns the nested skill-local asset body' );

        my $custom_css = $cb->( GET 'http://127.0.0.1/assets/dancer-route-skill.css' );
        is( $custom_css->code, 200, 'Dancer custom css route serves top-level skill-local assets' );
        is( decode_body_text( $custom_css->content ), qq{body { color: #654321; }\n}, 'Dancer custom css route returns the top-level skill-local css body' );

        my $css = $cb->( GET 'http://127.0.0.1/css/dancer-route-skill/skill.css' );
        is( $css->code, 200, 'Dancer css route serves top-level skill-local assets' );
        is( decode_body_text( $css->content ), qq{body { color: #654321; }\n}, 'Dancer css route returns the top-level skill-local css body' );

        my $custom_other = $cb->( GET 'http://127.0.0.1/downloads/dancer-route-skill.txt' );
        is( $custom_other->code, 200, 'Dancer custom others route serves top-level skill-local assets' );
        is( decode_body_text( $custom_other->content ), "dancer top-level other\n", 'Dancer custom others route returns the top-level skill-local other asset body' );

        my $other = $cb->( GET 'http://127.0.0.1/others/dancer-route-skill/info.txt' );
        is( $other->code, 200, 'Dancer others route serves top-level skill-local assets' );
        is( decode_body_text( $other->content ), "dancer top-level other\n", 'Dancer others route returns the top-level skill-local other asset body' );
    };
}

dies_like( sub { Developer::Dashboard::Web::App->new( pages => $store, sessions => $sessions ) }, qr/Missing auth store/, 'web app requires auth store' );
dies_like( sub { Developer::Dashboard::Web::App->new }, qr/Missing auth store/, 'web app requires auth before other dependencies' );
dies_like( sub { Developer::Dashboard::Web::App->new( auth => $auth, sessions => $sessions ) }, qr/Missing page store/, 'web app requires page store' );
dies_like( sub { Developer::Dashboard::Web::App->new( auth => $auth, pages => $store ) }, qr/Missing session store/, 'web app requires session store' );

my ( $root_code, $root_type, $root_body ) = @{ $app->handle( path => '/', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $root_code, 200, 'root route responds with success' );
like( $root_body, qr/<textarea[^>]*name="instruction"/, 'root route renders free-form instruction editor' );

my ( $runtime_alias_code, undef, $runtime_alias_body ) = @{ $app->handle( path => '/java', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $runtime_alias_code, 200, 'runtime config custom app route serves a saved bookmark with a dotted filename id' );
like( $runtime_alias_body, qr/learn ai body/, 'runtime config custom app route renders the same saved bookmark body as /app/learn\.ai' );

my $index_page = Developer::Dashboard::PageDocument->new(
    id     => 'index',
    title  => 'Index',
    layout => { body => 'index body' },
);
$store->save_page($index_page);
my ( $root_index_code, undef, undef, $root_index_headers ) = @{ $app->handle( path => '/', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $root_index_code, 302, 'root route redirects to the saved index page when it exists' );
is( $root_index_headers->{Location}, '/app/index', 'root route redirects to the canonical saved index bookmark path' );
unlink $store->page_file('index') or die "Unable to remove temporary index bookmark: $!";

my ( $apps_code, undef, undef, $apps_headers ) = @{ $app->handle( path => '/apps', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $apps_code, 302, '/apps redirects to default index bookmark' );
is( $apps_headers->{Location}, '/app/index', '/apps uses index bookmark as default target' );

my ( $apps_direct_code, undef, $apps_direct_body, $apps_direct_headers ) = @{ $app->apps_redirect_response() };
is( $apps_direct_code, 302, 'direct /apps compatibility response returns a redirect' );
is( $apps_direct_headers->{Location}, '/app/index', 'direct /apps compatibility response keeps the canonical index target' );
like( $apps_direct_body, qr/Redirecting/, 'direct /apps compatibility response keeps the redirect body' );

my $token = uri_escape( $store->encode_page($page) );
my ( $blocked_code, $blocked_type, $blocked_body ) = @{ $app->handle( path => '/', query => "token=$token", remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $blocked_code, 403, 'transient edit route is denied by default' );
like( $blocked_type, qr/text\/plain/, 'denied transient edit route returns plain text' );
like( $blocked_body, qr/Transient token URLs are disabled/, 'denied transient edit route explains the policy' );

local $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS} = 1;

my ( $edit_code, $edit_type, $edit_body ) = @{ $app->handle( path => '/', query => "token=$token", remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $edit_code, 200, 'transient edit route responds with success' );
like( $edit_body, qr/<textarea[^>]*name="instruction"/, 'edit route renders editable source textarea' );
unlike( $edit_body, qr/request_host|request_path|request_remote_addr/, 'edit route does not persist synthetic request metadata into source' );

my ( $render_code, undef, $render_body ) = @{ $app->handle( path => '/', query => "mode=render&token=$token", remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $render_code, 200, 'transient render route responds with success' );
like( $render_body, qr/body text/, 'render route includes body text' );

my ( $source_code, $source_type, $source_body ) = @{ $app->handle( path => '/', query => "mode=source&token=$token", remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $source_code, 200, 'transient source route responds with success' );
like( $source_type, qr/text\/plain/, 'source route emits instruction text' );
like( $source_body, qr/^TITLE:\s+Sample/m, 'source route returns canonical instruction text' );

my ( $saved_edit_code, undef, $saved_edit_body ) = @{ $app->handle( path => '/app/sample/edit', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $saved_edit_code, 200, 'saved edit route responds with success' );
like( $saved_edit_body, qr/Right Click Copy &amp; Share or Bookmark This Page/, 'saved edit route includes top chrome links' );
like( $saved_edit_body, qr{<form method="post" action="/app/sample/edit" id="instruction-form">}, 'saved edit route posts back to the named bookmark edit path' );
like( $saved_edit_body, qr{<button type="button" class="chrome-button" id="play-button" data-play-url="/app/sample">Play</button>}, 'saved edit route exposes a saved-page play button that submits the split editor source into render mode' );
my ( $saved_edit_post_without_instruction_code, undef, $saved_edit_post_without_instruction_body ) = @{ $app->handle(
    path        => '/app/sample/edit',
    method      => 'POST',
    body        => '',
    remote_addr => '127.0.0.1',
    headers     => { host => '127.0.0.1' },
) };
is( $saved_edit_post_without_instruction_code, 200, 'saved edit POST without instruction falls back to the saved editor view' );
like( $saved_edit_post_without_instruction_body, qr{<form method="post" action="/app/sample/edit" id="instruction-form">}, 'saved edit POST fallback keeps the named bookmark edit form action' );

my $updated_instruction = join "\n",
    'TITLE: Sample',
    ':--------------------------------------------------------------------------------:',
    'BOOKMARK: sample',
    ':--------------------------------------------------------------------------------:',
    'HTML: updated saved bookmark body',
    '';
my ( $saved_update_code, undef, $saved_update_body ) = @{ $app->handle(
    path        => '/app/sample/edit',
    method      => 'POST',
    body        => 'instruction=' . uri_escape($updated_instruction),
    remote_addr => '127.0.0.1',
    headers     => { host => '127.0.0.1' },
) };
is( $saved_update_code, 200, 'saved edit post route responds with success while transient urls remain disabled' );
like( $saved_update_body, qr/updated saved bookmark body/, 'saved edit post route returns the updated bookmark editor content' );
my ( $saved_updated_source_code, undef, $saved_updated_source_body ) = @{ $app->handle( path => '/app/sample/source', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $saved_updated_source_code, 200, 'saved source route still responds after a saved edit post' );
like( $saved_updated_source_body, qr/^HTML:\s+updated saved bookmark body$/m, 'saved edit post route persists the updated bookmark source text' );

my ( $saved_source_code, undef, $saved_source_body ) = @{ $app->handle( path => '/app/sample/source', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $saved_source_code, 200, 'saved source route responds with success' );
like( $saved_source_body, qr/^BOOKMARK:\s+sample/m, 'saved source route returns canonical page instruction source' );
unlike( $saved_source_body, qr/request_host|request_path|request_remote_addr/, 'saved source route does not inject request metadata into source' );

my ( $saved_render_code, undef, $saved_render_body ) = @{ $app->handle( path => '/app/sample', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $saved_render_code, 200, 'saved render route responds with success' );
like( $saved_render_body, qr/updated saved bookmark body/, 'saved page route renders the latest saved bookmark body content' );

{
    my $broken_path = File::Spec->catfile( $paths->dashboards_root, 'broken-icons' );
    open my $broken_fh, '>:raw', $broken_path or die "Unable to write $broken_path: $!";
    print {$broken_fh} "TITLE: Broken Icons\n";
    print {$broken_fh} ":--------------------------------------------------------------------------------:\n";
    print {$broken_fh} "BOOKMARK: broken-icons\n";
    print {$broken_fh} ":--------------------------------------------------------------------------------:\n";
    print {$broken_fh} "HTML: <h2>";
    print {$broken_fh} pack( 'C*', 0xF0, 0x9F, 0x9A );
    print {$broken_fh} " Learning</h2>\n<span class=\"icon\">";
    print {$broken_fh} pack( 'C*', 0x95 );
    print {$broken_fh} "</span>\n<span class=\"icon\">" . encode( 'UTF-8', "\x{1F9D1}" );
    print {$broken_fh} pack( 'C*', 0xEF, 0xBF, 0xBD );
    print {$broken_fh} encode( 'UTF-8', "\x{1F4BB}</span>\n" );
    close $broken_fh or die "Unable to close $broken_path: $!";

    my ( $broken_source_code, undef, $broken_source_body ) = @{ $app->handle( path => '/app/broken-icons/source', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    my $broken_source_text = decode_body_text($broken_source_body);
    is( $broken_source_code, 200, 'saved source route still responds for malformed legacy bookmark bytes' );
    like( $broken_source_text, qr/◈ Learning/, 'saved source route repairs malformed legacy heading icon bytes into a stable fallback glyph' );
    like( $broken_source_text, qr/<span class="icon">🏷️<\/span>/, 'saved source route repairs malformed legacy item icon bytes into a stable fallback glyph' );
    like( $broken_source_text, qr/<span class="icon">🧑‍💻<\/span>/, 'saved source route repairs malformed joined legacy emoji into a browser-safe glyph' );
    unlike( $broken_source_text, qr/\x{FFFD}/, 'saved source route no longer exposes Unicode replacement glyphs for repaired icon markup' );
    unlike( $broken_source_text, qr/^HTML:\s+<h2> Learning<\/h2>$/m, 'saved source route keeps the repaired raw source text instead of replacing it with canonical text that drops the glyph position' );

    my ( $broken_edit_code, undef, $broken_edit_body ) = @{ $app->handle( path => '/app/broken-icons/edit', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    my $broken_edit_text = decode_body_text($broken_edit_body);
    is( $broken_edit_code, 200, 'saved edit route still responds for malformed legacy bookmark bytes' );
    like( $broken_edit_text, qr/◈ Learning/, 'saved edit route embeds repaired heading fallback glyphs into the browser editor source' );
    like( $broken_edit_text, qr/🏷️/, 'saved edit route embeds repaired item fallback glyphs into the browser editor source' );
    like( $broken_edit_text, qr/🧑‍💻/, 'saved edit route embeds repaired joined emoji glyphs into the browser editor source' );
}

is(
    $app->_nav_items_html(
        page            => $page,
        runtime_context => { params => {} },
    ),
    '',
    'shared nav renderer returns empty html when the nav root does not exist',
);

my ( $escaped_code, undef, $escaped_body ) = @{ $app->handle( path => '/', query => "token=$token&name=hello%20world&empty", remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $escaped_code, 200, 'query parser tolerates values without equals signs' );
like( $escaped_body, qr/\[% stash\.name %\]/, 'edit mode keeps raw TT source instead of pre-rendered HTML after query parsing' );
my ( $escaped_render_code, undef, $escaped_render_body ) = @{ $app->handle( path => '/', query => "mode=render&token=$token&name=hello%20world&empty", remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $escaped_render_code, 200, 'render mode route still responds with success after query parsing' );
like( $escaped_render_body, qr/hello world/, 'render mode still applies query-decoded TT state when rendering' );
is( $app->handle( query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } )->[0], 200, 'handle defaults the path to root when omitted' );
my %parsed = Developer::Dashboard::Web::App::_parse_query('=value&encoded%20key=hello%20world');
is_deeply( \%parsed, { 'encoded key' => 'hello world' }, '_parse_query skips empty keys and decodes URI escapes' );

my $nav_empty = Developer::Dashboard::PageDocument->new(
    id     => 'nav/empty.tt',
    title  => 'Empty Nav',
    layout => {},
);
$store->save_page($nav_empty);
my $nav_form = Developer::Dashboard::PageDocument->new(
    id     => 'nav/form.tt',
    title  => 'Form Nav',
    layout => { body => '<form id="nav-form"></form><div id="nav-form-body"></div>' },
);
$store->save_page($nav_form);
my $nav_missing_file = File::Spec->catfile( $paths->dashboards_root, 'nav', 'broken.tt' );
open my $nav_missing_fh, '>', $nav_missing_file or die $!;
print {$nav_missing_fh} "not a bookmark\n";
close $nav_missing_fh;
my $nav_raw_tt_file = File::Spec->catfile( $paths->dashboards_root, 'nav', 'here.tt' );
open my $nav_raw_tt_fh, '>', $nav_raw_tt_file or die $!;
print {$nav_raw_tt_fh} <<'TT';
[% index = '/app/index' %]
[% foo = '/app/foobar' %]
<a href=[% index %]>[% index %]</a>
TT
close $nav_raw_tt_fh;

my $nav_render = $app->_nav_items_html(
    page            => $page,
    runtime_context => { params => {} },
);
like( $nav_render, qr/dashboard-nav-items/, 'shared nav renderer emits a nav container when valid nav tt files exist' );
like( $nav_render, qr/nav-form/, 'shared nav renderer includes HTML content from nav tt bookmarks' );
like( $nav_render, qr/nav-form-body/, 'shared nav renderer includes HTML body fragments from nav tt bookmarks' );
like( $nav_render, qr{<li data-nav-id="nav/here\.tt">\s*<a href=/app/index>/app/index</a>\s*</li>}s, 'shared nav renderer executes raw nav tt fragment files through Template Toolkit' );
unlike( $nav_render, qr/broken\.tt/, 'shared nav renderer skips invalid nav tt bookmark files' );
unlike( $nav_render, qr/empty\.tt/, 'shared nav renderer skips nav tt bookmarks that render an empty fragment' );
open my $nav_broken_tt_fh, '>', $nav_raw_tt_file or die $!;
print {$nav_broken_tt_fh} <<'TT';
[% index = '/app/index' %]
<a href="[% IF index %]">[% index %]</a>
TT
close $nav_broken_tt_fh;
my $nav_broken_render = $app->_nav_items_html(
    page            => $page,
    runtime_context => { params => {} },
);
like( $nav_broken_render, qr/runtime-error/, 'shared nav renderer surfaces Template Toolkit syntax failures from raw nav tt fragments' );
unlike( $nav_broken_render, qr/\[%\s*IF\s+index\s*%\]|\[%\s*index\s*%\]/, 'shared nav renderer does not leak raw nav tt source after Template Toolkit parse failures' );

my $nav_self_render = $app->_nav_items_html(
    page            => $nav_form,
    runtime_context => { params => {} },
);
is( $nav_self_render, '', 'shared nav renderer does not inject nav items while rendering a nav bookmark itself' );

{
    my $layer_root = File::Spec->catdir( $home, 'nav-layer-root' );
    my $layer_parent = File::Spec->catdir( $layer_root, 'parent' );
    my $layer_leaf = File::Spec->catdir( $layer_parent, 'leaf' );
    make_path( File::Spec->catdir( $layer_parent, '.developer-dashboard', 'dashboards', 'nav' ) );
    make_path( File::Spec->catdir( $layer_parent, '.developer-dashboard', 'dashboards', 'partials' ) );
    make_path( File::Spec->catdir( $layer_leaf, '.developer-dashboard', 'dashboards', 'nav' ) );

    open my $parent_partial_fh, '>', File::Spec->catfile( $layer_parent, '.developer-dashboard', 'dashboards', 'partials', 'nav-fragment.tt' ) or die $!;
    print {$parent_partial_fh} "parent-fragment\n";
    close $parent_partial_fh;

    open my $parent_nav_fh, '>', File::Spec->catfile( $layer_parent, '.developer-dashboard', 'dashboards', 'nav', 'parent-only.tt' ) or die $!;
    print {$parent_nav_fh} <<'PAGE';
TITLE: Parent Only
:--------------------------------------------------------------------------------:
BOOKMARK: nav/parent-only.tt
:--------------------------------------------------------------------------------:
HTML: <div id="parent-only-nav">parent only</div>
PAGE
    close $parent_nav_fh;

    open my $parent_shared_nav_fh, '>', File::Spec->catfile( $layer_parent, '.developer-dashboard', 'dashboards', 'nav', 'shared.tt' ) or die $!;
    print {$parent_shared_nav_fh} <<'PAGE';
TITLE: Parent Shared
:--------------------------------------------------------------------------------:
BOOKMARK: nav/shared.tt
:--------------------------------------------------------------------------------:
HTML: <div id="shared-nav-parent">parent shared</div>
PAGE
    close $parent_shared_nav_fh;

    open my $leaf_nav_fh, '>', File::Spec->catfile( $layer_leaf, '.developer-dashboard', 'dashboards', 'nav', 'leaf-only.tt' ) or die $!;
    print {$leaf_nav_fh} <<'PAGE';
TITLE: Leaf Only
:--------------------------------------------------------------------------------:
BOOKMARK: nav/leaf-only.tt
:--------------------------------------------------------------------------------:
HTML: <div id="leaf-only-nav">[% INCLUDE "partials/nav-fragment.tt" %]</div>
PAGE
    close $leaf_nav_fh;

    open my $leaf_shared_nav_fh, '>', File::Spec->catfile( $layer_leaf, '.developer-dashboard', 'dashboards', 'nav', 'shared.tt' ) or die $!;
    print {$leaf_shared_nav_fh} <<'PAGE';
TITLE: Leaf Shared
:--------------------------------------------------------------------------------:
BOOKMARK: nav/shared.tt
:--------------------------------------------------------------------------------:
HTML: <div id="shared-nav-leaf">leaf shared</div>
PAGE
    close $leaf_shared_nav_fh;

    chdir $layer_leaf or die $!;
    my $layer_paths = Developer::Dashboard::PathRegistry->new( home => $home );
    my $layer_store = Developer::Dashboard::PageStore->new( paths => $layer_paths );
    my $layer_runtime = Developer::Dashboard::PageRuntime->new( paths => $layer_paths );
    my $layer_files = Developer::Dashboard::FileRegistry->new( paths => $layer_paths );
    my $layer_auth = Developer::Dashboard::Auth->new( files => $layer_files, paths => $layer_paths );
    my $layer_sessions = Developer::Dashboard::SessionStore->new( paths => $layer_paths );
    my $layer_app = Developer::Dashboard::Web::App->new(
        auth     => $layer_auth,
        pages    => $layer_store,
        runtime  => $layer_runtime,
        sessions => $layer_sessions,
    );
    my $layer_page = Developer::Dashboard::PageDocument->new(
        id     => 'layered-page',
        title  => 'Layered Page',
        layout => { body => 'layered body' },
    );
    $layer_store->save_page($layer_page);
    my $layer_nav_html = $layer_app->_nav_items_html(
        page            => $layer_page,
        runtime_context => { params => {} },
    );
    like( $layer_nav_html, qr/parent-only-nav/, 'nav renderer includes parent-layer nav fragments' );
    like( $layer_nav_html, qr/leaf-only-nav/, 'nav renderer includes leaf-layer nav fragments' );
    like( $layer_nav_html, qr/parent-fragment/, 'nav renderer resolves TT includes from ancestor dashboard layers' );
    like( $layer_nav_html, qr/shared-nav-leaf/, 'nav renderer prefers the deepest matching nav bookmark id' );
    unlike( $layer_nav_html, qr/shared-nav-parent/, 'nav renderer suppresses overridden parent nav fragments when a deeper layer replaces the same id' );
    chdir $home or die $!;
}

my $fragment = $app->_page_fragment_html(
    Developer::Dashboard::PageDocument->new(
        layout => { body => '<div id="frag-body"></div>', form_tt => '<div id="frag-form-tt"></div>', form => '<div id="frag-form"></div>' },
        meta   => { runtime_outputs => ['<div id="frag-output"></div>'], runtime_errors => ['boom'] },
    )
);
like( $fragment, qr/frag-body/, '_page_fragment_html includes the page body' );
unlike( $fragment, qr/frag-form-tt/, '_page_fragment_html ignores removed form_tt content' );
unlike( $fragment, qr/frag-form/, '_page_fragment_html ignores removed form content' );
like( $fragment, qr/frag-output/, '_page_fragment_html includes runtime output fragments' );
like( $fragment, qr/runtime-error/, '_page_fragment_html renders runtime errors' );
is( $app->_page_fragment_html(), '', '_page_fragment_html returns empty html when no page is provided' );
my $prepared_broken_page = $runtime->prepare_page(
    page => Developer::Dashboard::PageDocument->new(
        id     => 'broken-template',
        title  => 'Broken Template',
        layout => { body => '<div>before [% IF stash.name %] broken</div>' },
    ),
    runtime_context => { params => {}, current_page => '/app/broken-template' },
    source          => 'saved',
);
my $broken_fragment = $app->_page_fragment_html($prepared_broken_page);
like( $broken_fragment, qr/runtime-error/, '_page_fragment_html shows Template Toolkit syntax failures from broken bookmark bodies' );
unlike( $broken_fragment, qr/\[%\s*IF\s+stash\.name\s*%\]/, '_page_fragment_html does not leak raw TT syntax from broken bookmark bodies' );

my ( $not_found_code, $not_found_type, $not_found_body ) = @{ $app->handle( path => '/missing', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $not_found_code, 404, 'unknown routes return not found' );
like( $not_found_type, qr/text\/plain/, 'not found route emits plain text' );
like( $not_found_body, qr/Not found/, 'not found route returns a plain error body' );

{
    my $others_root = File::Spec->catdir( $paths->dashboards_root, 'public', 'others' );
    make_path($others_root);
    my $txt = File::Spec->catfile( $others_root, 'note.txt' );
    open my $txt_fh, '>', $txt or die $!;
    print {$txt_fh} "plain text asset\n";
    close $txt_fh;
    my ( $asset_code, $asset_type, $asset_body ) = @{ $app->handle( path => '/others/note.txt', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    is( $asset_code, 200, 'dispatch_request serves static files through the routed handler path' );
    like( $asset_type, qr/text\/plain/, 'txt assets resolve through the plain-text content type branch' );
    is( $asset_body, "plain text asset\n", 'txt assets return the saved file content' );
}

{
    my $fake_bin = File::Spec->catdir( $home, 'fake-bin' );
    make_path($fake_bin);
    my $ifconfig = File::Spec->catfile( $fake_bin, 'ifconfig' );
    open my $ifconfig_fh, '>', $ifconfig or die $!;
    print {$ifconfig_fh} <<'SH';
#!/bin/sh
cat <<'EOF'
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
    inet 10.0.0.4  netmask 255.255.255.0  broadcast 10.0.0.255
lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
    inet 127.0.0.1  netmask 255.0.0.0
EOF
SH
    close $ifconfig_fh;
    chmod 0755, $ifconfig or die $!;
    no warnings 'redefine';
    local $ENV{PATH} = $fake_bin . ':' . $ENV{PATH};
    local *Developer::Dashboard::Web::App::_ip_pairs_from_ip = sub { return (); };
    is_deeply(
        [ $app->_ip_interface_pairs ],
        [ { iface => 'eth0', ip => '10.0.0.4' } ],
        '_ip_interface_pairs falls back to ifconfig parsing when the ip command yields no candidates',
    );
}

{
    my $fake_bin = File::Spec->catdir( $home, 'fake-win-bin' );
    make_path($fake_bin);
    my $ipconfig = File::Spec->catfile( $fake_bin, 'ipconfig' );
    open my $ipconfig_fh, '>', $ipconfig or die $!;
    print {$ipconfig_fh} <<'BAT';
#!/bin/sh
cat <<'EOF'

Ethernet adapter Ethernet:

   IPv4 Address. . . . . . . . . . . : 10.20.30.40

Wireless LAN adapter Loopback:

   IPv4 Address. . . . . . . . . . . : 127.0.0.1
EOF
BAT
    close $ipconfig_fh;
    chmod 0755, $ipconfig or die $!;
    no warnings 'redefine';
    local $ENV{PATH} = $fake_bin . ':' . $ENV{PATH};
    local *Developer::Dashboard::Web::App::_ip_pairs_from_ip = sub { return (); };
    local *Developer::Dashboard::Web::App::_ip_pairs_from_ifconfig = sub { return (); };
    is_deeply(
        [ $app->_ip_interface_pairs ],
        [ { iface => 'Ethernet', ip => '10.20.30.40' } ],
        '_ip_interface_pairs falls back to ipconfig parsing when Windows ipconfig output is available',
    );
}

{
    no warnings 'redefine';
    local *Developer::Dashboard::Web::App::_ip_pairs_from_ip = sub { return (); };
    local *Developer::Dashboard::Web::App::_ip_pairs_from_ifconfig = sub { return (); };
    ok( !defined $app->_machine_ip, '_machine_ip returns undef when neither ip nor ifconfig yields a usable address' );
}

my ( $ajax_missing_code, $ajax_missing_type, $ajax_missing_body ) = @{ $app->handle( path => '/ajax', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $ajax_missing_code, 400, 'legacy ajax route rejects requests without token or saved file parameters' );
like( $ajax_missing_type, qr/text\/plain/, 'legacy ajax missing-parameter route returns plain text' );
like( $ajax_missing_body, qr/missing token/, 'legacy ajax missing-parameter route explains the missing token' );

{
    local $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS} = 1;
    no warnings 'redefine';
    local *Developer::Dashboard::Web::App::decode_payload = sub { die "forced decode failure\n" };
    my ( $ajax_bad_token_code, $ajax_bad_token_type, $ajax_bad_token_body ) = @{ $app->handle( path => '/ajax', query => 'token=known-good-token&type=json', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    is( $ajax_bad_token_code, 400, 'legacy ajax route rejects decode failures cleanly' );
    like( $ajax_bad_token_type, qr/text\/plain/, 'legacy ajax decode-failure route returns plain text' );
    like( $ajax_bad_token_body, qr/forced decode failure/, 'legacy ajax decode-failure route returns the decode error text' );
}

{
    my ( $ajax_bad_file_code, $ajax_bad_file_type, $ajax_bad_file_body ) = @{ $app->handle( path => '/ajax', query => 'file=..%2Fbad&type=json', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    is( $ajax_bad_file_code, 400, 'legacy ajax route rejects invalid saved bookmark ajax file names cleanly' );
    like( $ajax_bad_file_type, qr/text\/plain/, 'legacy ajax invalid saved-file route returns plain text' );
    like( $ajax_bad_file_body, qr/invalid parent traversal/, 'legacy ajax invalid saved-file route returns the validation error text' );
}

{
    my $streaming_page = Developer::Dashboard::PageDocument->from_instruction(<<'PAGE');
BOOKMARK: ajax-stream
:--------------------------------------------------------------------------------:
HTML: <script>var configs = {};</script>
:--------------------------------------------------------------------------------:
CODE1: Ajax jvar => 'configs.demo.endpoint', type => 'text', file => 'stream.txt', code => q{
  print "first\n";
  print "second\n";
};
PAGE
    $store->save_page($streaming_page);
    my ( undef, undef, undef ) = @{ $app->handle( path => '/app/ajax-stream', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    my ( $ajax_stream_code, $ajax_stream_type, $ajax_stream_body ) = @{ $app->handle( path => '/ajax/stream.txt', query => 'type=text', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    is( $ajax_stream_code, 200, 'legacy ajax saved-file route responds successfully for streaming output' );
    like( $ajax_stream_type, qr/text\/plain/, 'legacy ajax saved-file route keeps the requested content type for streaming output' );
    is( drain_stream_body($ajax_stream_body), "first\nsecond\n", 'legacy ajax saved-file route streams raw printed output without page buffering' );
}

{
    my $process_page = Developer::Dashboard::PageDocument->from_instruction(<<'PAGE');
BOOKMARK: ajax-process
:--------------------------------------------------------------------------------:
HTML: <script>var configs = {};</script>
:--------------------------------------------------------------------------------:
CODE1: Ajax jvar => 'configs.demo.endpoint', type => 'text', file => 'process-endpoint.json', code => q{
print "perl-start\n";
warn "perl-warn\n";
system 'sh', '-c', 'printf "child-out\n"; printf "child-err\n" >&2';
die "perl-die\n";
};
PAGE
    $store->save_page($process_page);
    my ( undef, undef, undef ) = @{ $app->handle( path => '/app/ajax-process', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    my ( $ajax_process_code, $ajax_process_type, $ajax_process_body ) = @{ $app->handle( path => '/ajax/process-endpoint.json', query => 'type=text', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    my $ajax_process_output = drain_stream_body($ajax_process_body);
    is( $ajax_process_code, 200, 'legacy ajax saved-file process route responds successfully for mixed stdout and stderr output' );
    like( $ajax_process_type, qr/text\/plain/, 'legacy ajax saved-file process route keeps the requested content type' );
    like( $ajax_process_output, qr/perl-start/, 'legacy ajax saved-file process route streams direct perl stdout' );
    like( $ajax_process_output, qr/perl-warn/, 'legacy ajax saved-file process route streams perl stderr warnings' );
    like( $ajax_process_output, qr/child-out/, 'legacy ajax saved-file process route streams child process stdout' );
    like( $ajax_process_output, qr/child-err/, 'legacy ajax saved-file process route streams child process stderr' );
    like( $ajax_process_output, qr/perl-die/, 'legacy ajax saved-file process route streams uncaught perl die output' );
}

{
    my $singleton_page = Developer::Dashboard::PageDocument->from_instruction(<<'PAGE');
BOOKMARK: ajax-singleton
:--------------------------------------------------------------------------------:
HTML: <script>var configs = {};</script>
:--------------------------------------------------------------------------------:
CODE1: Ajax jvar => 'configs.demo.endpoint', type => 'text', singleton => 'FOOBAR', file => 'singleton-endpoint.txt', code => q{
print "$0\n";
};
PAGE
    $store->save_page($singleton_page);
    my ( undef, undef, $singleton_page_body ) = @{ $app->handle( path => '/app/ajax-singleton', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    like( $singleton_page_body, qr{/ajax/singleton-endpoint\.txt\?type=text&singleton=FOOBAR}, 'saved bookmark Ajax page emits the singleton query parameter in the generated ajax url' );
    like( $singleton_page_body, qr/dashboard_ajax_singleton_cleanup\('FOOBAR'\)/, 'saved bookmark Ajax page registers browser lifecycle cleanup for singleton-managed workers' );
    my ( $ajax_singleton_code, undef, $ajax_singleton_body ) = @{ $app->handle( path => '/ajax/singleton-endpoint.txt', query => 'type=text&singleton=FOOBAR', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    my $ajax_singleton_output = drain_stream_body($ajax_singleton_body);
    is( $ajax_singleton_code, 200, 'legacy ajax saved-file route responds successfully for singleton-managed requests' );
    like( $ajax_singleton_output, qr/^dashboard ajax: FOOBAR$/m, 'legacy ajax saved-file route renames singleton-managed Perl workers before streaming output' );
}

{
    my @patterns;
    {
        no warnings 'redefine';
        local *Developer::Dashboard::RuntimeManager::_pkill_perl = sub {
            my ( $self, $pattern ) = @_;
            push @patterns, $pattern;
            return 1;
        };
        my ( $stop_code, undef, $stop_body ) = @{ $app->handle( path => '/ajax/singleton/stop', query => 'singleton=BROWSER-STOP', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
        is( $stop_code, 204, 'singleton stop route returns no content after lifecycle cleanup' );
        is( $stop_body, '', 'singleton stop route keeps the response body empty' );
        my ( $direct_stop_code, undef, $direct_stop_body ) = @{ $app->ajax_singleton_stop_response( query => 'singleton=BROWSER-STOP' ) };
        is( $direct_stop_code, 204, 'direct singleton stop response returns no content after lifecycle cleanup' );
        is( $direct_stop_body, '', 'direct singleton stop response keeps the response body empty' );
    }
    is_deeply(
        \@patterns,
        [ '^dashboard ajax: BROWSER-STOP$', '^dashboard ajax: BROWSER-STOP$' ],
        'singleton stop route and direct response target the matching saved ajax worker process title',
    );
}

{
    my $shebang_page = Developer::Dashboard::PageDocument->from_instruction(<<'PAGE');
BOOKMARK: ajax-shebang
:--------------------------------------------------------------------------------:
HTML: <script>var configs = {};</script>
:--------------------------------------------------------------------------------:
CODE1: Ajax jvar => 'configs.demo.endpoint', type => 'text', file => 'script-runner', code => qq{#!/bin/sh\nprintf 'shell-out\\n'\nprintf 'shell-err\\n' >&2\n};
PAGE
    $store->save_page($shebang_page);
    my ( undef, undef, undef ) = @{ $app->handle( path => '/app/ajax-shebang', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    my ( $ajax_shebang_code, undef, $ajax_shebang_body ) = @{ $app->handle( path => '/ajax/script-runner', query => 'type=text', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    my $ajax_shebang_output = drain_stream_body($ajax_shebang_body);
    is( $ajax_shebang_code, 200, 'legacy ajax saved-file route executes shebang scripts directly' );
    like( $ajax_shebang_output, qr/shell-out/, 'legacy ajax saved-file route streams direct executable stdout' );
    like( $ajax_shebang_output, qr/shell-err/, 'legacy ajax saved-file route streams direct executable stderr' );
}

{
    my $runtime_api_file = File::Spec->catfile( $paths->config_root, 'api.json' );
    open my $runtime_api, '>:raw', $runtime_api_file
      or die "Unable to write runtime api.json: $!";
    print {$runtime_api} qq|{"runtime-client":{"secret":"@{[ sha256_hex('stream-secret') ]}","ajax":["/ajax/stream.txt"]}}|;
    close $runtime_api or die "Unable to close runtime api.json: $!";

    my $skill_api_dir = File::Spec->catdir( $dancer_skill_install->{path}, 'config' );
    make_path($skill_api_dir);
    open my $skill_api, '>:raw', File::Spec->catfile( $skill_api_dir, 'api.json' )
      or die "Unable to write skill api.json: $!";
    print {$skill_api} qq|{"skill-client":{"secret":"@{[ sha256_hex('skill-secret') ]}","ajax":["/ajax/dancer-route-skill/bar"]}}|;
    close $skill_api or die "Unable to close skill api.json: $!";

    my $api_host = 'dashboard-helper.example:7890';
    my ( $registered_no_api_code, $registered_no_api_type, $registered_no_api_body ) = @{ $app->handle(
        path        => '/ajax/stream.txt',
        query       => 'type=text',
        remote_addr => '127.0.0.1',
        headers     => { host => $api_host },
    ) };
    is( $registered_no_api_code, 403, 'registered remote ajax route rejects requests that omit API credentials' );
    like( $registered_no_api_type, qr/application\/json/, 'registered remote ajax route returns JSON when API credentials are missing' );
    is_deeply( json_decode( decode_body_text($registered_no_api_body) ), { status => 'forbidden' }, 'registered remote ajax route returns the explicit forbidden JSON payload when API credentials are missing' );

    my ( $registered_bad_api_code, undef, $registered_bad_api_body ) = @{ $app->handle(
        path        => '/ajax/stream.txt',
        query       => 'type=text',
        remote_addr => '127.0.0.1',
        headers     => {
            host            => $api_host,
            'x-dd-api-key'    => 'runtime-client',
            'x-dd-api-secret' => 'wrong-secret',
        },
    ) };
    is( $registered_bad_api_code, 403, 'registered remote ajax route rejects invalid API secrets' );
    is_deeply( json_decode( decode_body_text($registered_bad_api_body) ), { status => 'forbidden' }, 'registered remote ajax route keeps the forbidden JSON payload for invalid API secrets' );

    my ( $unregistered_remote_code, undef, $unregistered_remote_body ) = @{ $app->handle(
        path        => '/ajax/process-endpoint.json',
        query       => 'type=text',
        remote_addr => '127.0.0.1',
        headers     => { host => $api_host },
    ) };
    is( $unregistered_remote_code, 401, 'unregistered remote ajax routes still follow the existing helper-auth flow' );
    is( $unregistered_remote_body, '', 'unregistered remote ajax routes stay silent before helper users exist' );

    my ( $runtime_api_code, $runtime_api_type, $runtime_api_body ) = @{ $app->handle(
        path        => '/ajax/stream.txt',
        query       => 'type=text',
        remote_addr => '127.0.0.1',
        headers     => {
            host              => $api_host,
            'x-dd-api-key'    => 'runtime-client',
            'x-dd-api-secret' => 'stream-secret',
        },
    ) };
    is( $runtime_api_code, 200, 'registered runtime ajax route accepts a matching API key and secret' );
    like( $runtime_api_type, qr/text\/plain/, 'registered runtime ajax route keeps the saved ajax content type under API auth' );
    is( drain_stream_body($runtime_api_body), "first\nsecond\n", 'registered runtime ajax route executes through API auth without requiring a helper session' );

    my ( $skill_api_code, $skill_api_type, $skill_api_body ) = @{ $app->handle(
        path        => '/ajax/dancer-route-skill/bar',
        query       => '',
        remote_addr => '127.0.0.1',
        headers     => {
            host              => $api_host,
            'x-dd-api-key'    => 'skill-client',
            'x-dd-api-secret' => 'skill-secret',
        },
    ) };
    is( $skill_api_code, 200, 'installed skill config/api.json can authorize its ajax routes through API credentials' );
    like( $skill_api_type, qr/application\/json/, 'skill API auth keeps the skill ajax response content type' );
    is( decode_body_text( drain_stream_body($skill_api_body) ), qq|{"route":"dancer-top"}\n|, 'installed skill API auth streams the skill ajax response body' );

    my $psgi_app = Developer::Dashboard::Web::DancerApp->build_psgi_app( app => $app );
    Local::PSGITest::test_psgi $psgi_app, sub {
        my ($cb) = @_;
        my $res = $cb->(
            GET(
                'http://127.0.0.1/ajax/stream.txt?type=text',
                Host              => $api_host,
                'X-DD-API-Key'    => 'runtime-client',
                'X-DD-API-Secret' => 'stream-secret',
            )
        );
        is( $res->code, 200, 'PSGI adapter forwards API auth headers to the backend ajax gate' );
        is( decode_body_text( $res->content ), "first\nsecond\n", 'PSGI adapter keeps API-auth ajax output intact' );
    };
}

is( $auth->trust_tier( remote_addr => '127.0.0.1', host => '127.0.0.1:7890' ), 'admin', 'exact loopback with numeric host is admin' );
is( $auth->trust_tier( remote_addr => '127.0.0.1', host => 'localhost:7890' ), 'admin', 'localhost is trusted as admin when it resolves only to loopback' );
is( $auth->trust_tier( remote_addr => '10.0.0.8', host => '127.0.0.1:7890' ), 'helper', 'non-loopback client is helper' );
my @initial_users = $auth->list_users;
is( scalar @initial_users, 0, 'auth store starts empty' );
ok( !$auth->verify_user( username => 'helper', password => 'nope' ), 'missing user does not verify' );
like( $auth->login_page( message => '<unsafe>' ), qr/&lt;unsafe&gt;/, 'login page escapes message content' );
my $helper_host = 'dashboard-helper.example:7890';

my ( $login_required_code, undef, $login_required_body ) = @{ $app->handle( path => '/', query => '', remote_addr => '127.0.0.1', headers => { host => $helper_host } ) };
is( $login_required_code, 401, 'non-loopback helper-host requests are unauthorized when no helper user exists' );
is( $login_required_body, '', 'outsider requests return an empty body before helper users exist' );
unlike( $login_required_body, qr/<form method="post" action="\/login">/, 'outsider requests without helper users do not receive a login form' );

my ( $saved_login_required_code, undef, $saved_login_required_body ) = @{ $app->handle(
    path        => '/app/index',
    query       => 'from=helper',
    remote_addr => '127.0.0.1',
    headers     => { host => $helper_host },
) };
is( $saved_login_required_code, 401, 'outsider access to a saved page is unauthorized when no helper user exists' );
is( $saved_login_required_body, '', 'forbidden outsider access to a saved page stays silent before helper users exist' );
unlike( $saved_login_required_body, qr{<input[^>]*name="redirect_to"}, 'forbidden outsider requests do not expose a login redirect target before helper users exist' );

my ( $disabled_login_code, undef, $disabled_login_body ) = @{ $app->handle(
    path        => '/login',
    method      => 'POST',
    body        => form_body( username => 'helper', password => 'helper-pass-123' ),
    remote_addr => '127.0.0.1',
    headers     => { host => $helper_host },
) };
is( $disabled_login_code, 401, 'login submission is unauthorized when no helper user exists' );
is( $disabled_login_body, '', 'login submission stays silent before helper users exist' );

my $user = $auth->add_user( username => 'helper', password => 'helper-pass-123', role => 'helper' );
is( $user->{role}, 'helper', 'helper user can be created' );
ok( $auth->verify_user( username => 'helper', password => 'helper-pass-123' ), 'correct password verifies' );
ok( !$auth->verify_user( username => 'helper', password => 'wrong' ), 'wrong password does not verify' );
my $alpha = $auth->add_user( username => 'alpha', password => 'alpha-pass-123', role => 'helper' );
is( $alpha->{username}, 'alpha', 'second helper user can be created' );
my @listed_users = $auth->list_users;
is( scalar @listed_users, 2, 'created helper users are listed' );
is_deeply( [ map { $_->{username} } @listed_users ], [ 'alpha', 'helper' ], 'listed users are sorted by username' );

my ( $enabled_login_required_code, undef, $enabled_login_required_body ) = @{ $app->handle( path => '/', query => '', remote_addr => '127.0.0.1', headers => { host => $helper_host } ) };
is( $enabled_login_required_code, 401, 'non-loopback helper-host requests require login once a helper user exists' );
like( $enabled_login_required_body, qr/Helper access requires login/, 'outsider requests receive the login page after helper users exist' );

my ( $enabled_saved_login_required_code, undef, $enabled_saved_login_required_body ) = @{ $app->handle(
    path        => '/app/index',
    query       => 'from=helper',
    remote_addr => '127.0.0.1',
    headers     => { host => $helper_host },
) };
is( $enabled_saved_login_required_code, 401, 'saved outsider access requires login once a helper user exists' );
like( $enabled_saved_login_required_body, qr{<input[^>]*name="redirect_to"[^>]*value="/app/index\?from=helper"}, 'login page keeps the originally requested path and query for post-login redirect once helper users exist' );

my ( $bad_login_code, undef, $bad_login_body ) = @{ $app->handle(
    path        => '/login',
    method      => 'POST',
    body        => form_body( username => 'helper', password => 'wrong' ),
    remote_addr => '127.0.0.1',
    headers     => { host => $helper_host },
) };
is( $bad_login_code, 401, 'bad login is rejected' );
like( $bad_login_body, qr/Invalid username or password/, 'bad login renders error message' );

my ( $login_code, undef, undef, $login_headers ) = @{ $app->handle(
    path        => '/login',
    method      => 'POST',
    body        => form_body( username => 'helper', password => 'helper-pass-123' ),
    remote_addr => '127.0.0.1',
    headers     => { host => $helper_host },
) };
is( $login_code, 302, 'valid helper login redirects' );
is( $login_headers->{Location}, '/', 'valid helper login redirects to home' );
like( $login_headers->{'Set-Cookie'}, qr/^dashboard_session=/, 'valid helper login sets session cookie' );

my ( $saved_login_code, undef, undef, $saved_login_headers ) = @{ $app->handle(
    path        => '/login',
    method      => 'POST',
    body        => form_body(
        username    => 'helper',
        password    => 'helper-pass-123',
        redirect_to => '/app/index?from=helper',
    ),
    remote_addr => '127.0.0.1',
    headers     => { host => $helper_host },
) };
is( $saved_login_code, 302, 'valid helper login for a saved page redirects' );
is( $saved_login_headers->{Location}, '/app/index?from=helper', 'valid helper login returns to the originally requested saved page' );

my $session = $sessions->from_cookie( $login_headers->{'Set-Cookie'}, remote_addr => '127.0.0.1' );
is( $session->{username}, 'helper', 'session can be loaded from response cookie' );
ok( !$sessions->from_cookie( $login_headers->{'Set-Cookie'}, remote_addr => '10.0.0.8' ), 'session cookie is bound to the original remote address' );
my $expired = $sessions->create( username => 'helper', role => 'helper', remote_addr => '127.0.0.1', ttl_seconds => -1 );
ok( !$sessions->from_cookie( "dashboard_session=$expired->{session_id}", remote_addr => '127.0.0.1' ), 'expired session is rejected and purged' );
my ( $helper_code, undef, $helper_body ) = @{ $app->handle(
    path        => '/',
    query       => '',
    remote_addr => '127.0.0.1',
    headers     => {
        host   => $helper_host,
        cookie => $login_headers->{'Set-Cookie'},
    },
) };
is( $helper_code, 200, 'logged-in helper can view home route' );
like( $helper_body, qr/Developer Dashboard/, 'logged-in helper receives app content' );

my ( $helper_ajax_code, undef, $helper_ajax_body ) = @{ $app->handle(
    path        => '/ajax/stream.txt',
    query       => 'type=text',
    remote_addr => '127.0.0.1',
    headers     => {
        host   => $helper_host,
        cookie => $login_headers->{'Set-Cookie'},
    },
) };
is( $helper_ajax_code, 200, 'logged-in helper can still access an API-registered ajax route without API headers' );
is( drain_stream_body($helper_ajax_body), "first\nsecond\n", 'logged-in helper session keeps normal ajax access on API-registered routes' );

my ( $helper_ajax_with_bad_api_code, undef, $helper_ajax_with_bad_api_body ) = @{ $app->handle(
    path        => '/ajax/stream.txt',
    query       => 'type=text',
    remote_addr => '127.0.0.1',
    headers     => {
        host              => $helper_host,
        cookie            => $login_headers->{'Set-Cookie'},
        'x-dd-api-key'    => 'runtime-client',
        'x-dd-api-secret' => 'wrong-secret',
    },
) };
is( $helper_ajax_with_bad_api_code, 200, 'logged-in helper session still wins over bad API headers on an API-registered ajax route' );
is( drain_stream_body($helper_ajax_with_bad_api_body), "first\nsecond\n", 'logged-in helper session keeps the normal ajax output even when bad API headers are also present' );

my ( $logout_code, undef, undef, $logout_headers ) = @{ $app->handle(
    path        => '/logout',
    method      => 'GET',
    remote_addr => '127.0.0.1',
    headers     => {
        host   => $helper_host,
        cookie => $login_headers->{'Set-Cookie'},
    },
) };
is( $logout_code, 302, 'logout redirects' );
is( $logout_headers->{Location}, '/login', 'logout redirects to login page' );
like( $logout_headers->{'Set-Cookie'}, qr/Max-Age=0/, 'logout expires the session cookie' );
ok( !$sessions->from_cookie( $login_headers->{'Set-Cookie'} ), 'logout deletes the stored session' );

dies_like( sub { Developer::Dashboard::Web::Server->new }, qr/Missing web app/, 'web server requires an app' );
my $default_server = Developer::Dashboard::Web::Server->new( app => $app );
is( $default_server->{host}, '0.0.0.0', 'web server defaults to all interfaces' );
is( $default_server->{port}, 7890, 'web server keeps default port 7890' );

my $server = Developer::Dashboard::Web::Server->new(
    app  => $app,
    host => '127.0.0.1',
    port => 0,
);
my $daemon = $server->start_daemon;
is( $daemon->sockhost, '127.0.0.1', 'start_daemon preserves the requested host' );
ok( $daemon->sockport > 0, 'start_daemon resolves a listen port' );
is( $server->listening_url($daemon), 'http://127.0.0.1:' . $daemon->sockport . '/', 'listening_url builds the daemon URL from the descriptor' );

{
    my $res;
    Local::PSGITest::test_psgi $server->psgi_app, sub {
        my ($cb) = @_;
        $res = $cb->( GET 'http://127.0.0.1/app/sample/source' );
    };
    is( $res->code, 200, 'server PSGI app returns successful status code from app handle' );
    like( $res->header('Content-Type'), qr/text\/plain/, 'server PSGI app keeps the instruction source content type' );
    is( $res->header('X-Frame-Options'), 'DENY', 'server PSGI app sets frame-deny header' );
    like( $res->header('Content-Security-Policy'), qr/frame-ancestors 'none'/, 'server PSGI app sets CSP header' );
    is( $res->header('Cache-Control'), 'no-store', 'server PSGI app disables response caching' );
}

my $header_app = bless {}, 'Local::HeaderApp';
{
    no warnings 'once';
    *Local::HeaderApp::handle = sub {
        return [
            302,
            'text/plain; charset=utf-8',
            "Redirecting\n",
            {
                Location   => '/login',
                'Set-Cookie' => 'dashboard_session=abc',
            },
        ];
    };
}
my $header_server = Developer::Dashboard::Web::Server->new( app => $header_app );
{
    my $res;
    Local::PSGITest::test_psgi $header_server->psgi_app, sub {
        my ($cb) = @_;
        $res = $cb->( POST 'http://127.0.0.1/login', [ username => 'helper', password => 'helper-pass-123' ] );
    };
    is( $res->header('Location'), '/login', 'server forwards custom Location headers from the app' );
    is( $res->header('Set-Cookie'), 'dashboard_session=abc', 'server forwards custom Set-Cookie headers from the app' );
}

my $streaming_app = bless {}, 'Local::StreamingApp';
{
    no warnings 'once';
    *Local::StreamingApp::handle = sub {
        return [
            200,
            'text/plain; charset=utf-8',
            {
                stream => sub {
                    my ($writer) = @_;
                    $writer->("alpha\n");
                    $writer->("beta\n");
                },
            },
            { 'X-Test' => 'streaming' },
        ];
    };
}
my $streaming_server = Developer::Dashboard::Web::Server->new( app => $streaming_app );
{
    my $res;
    Local::PSGITest::test_psgi $streaming_server->psgi_app, sub {
        my ($cb) = @_;
        $res = $cb->( GET 'http://127.0.0.1/ajax' );
    };
    is( $res->code, 200, 'streaming response path returns success' );
    like( $res->header('Content-Type'), qr/text\/plain/, 'streaming response keeps the content type header' );
    is( $res->header('X-Test'), 'streaming', 'streaming response keeps custom headers' );
    is( $res->content, "alpha\nbeta\n", 'streaming response writes streamed body chunks into the final response body' );
}

my $failing_stream_app = bless {}, 'Local::FailingStreamApp';
{
    no warnings 'once';
    *Local::FailingStreamApp::handle = sub {
        return [
            200,
            'text/plain; charset=utf-8',
            {
                stream => sub {
                    my ($writer) = @_;
                    $writer->("alpha\n");
                    die "stream exploded\n";
                },
            },
        ];
    };
}
my $failing_stream_server = Developer::Dashboard::Web::Server->new( app => $failing_stream_app );
{
    my $res;
    Local::PSGITest::test_psgi $failing_stream_server->psgi_app, sub {
        my ($cb) = @_;
        $res = $cb->( GET 'http://127.0.0.1/ajax' );
    };
    is( $res->code, 200, 'streaming error responses keep the original success status' );
    like( $res->content, qr/alpha/, 'streaming error responses keep chunks written before the failure' );
    like( $res->content, qr/stream exploded/, 'streaming error responses append the streaming exception text' );
}

{
    no warnings 'redefine';
    my @chunks;
    local *Developer::Dashboard::Web::DancerApp::delayed = sub (&) { $_[0]->(); return 'delayed-ok' };
    local $Dancer2::Core::Route::RESPONDER = sub {
        my ($response) = @_;
        is( $response->[0], 200, 'disconnect coverage responder receives the original status code' );
        like( join( "\n", @{ $response->[1] || [] } ), qr/Content-Type\ntext\/plain/, 'disconnect coverage responder receives the content type header' );
        return bless {}, 'Local::DisconnectWriter';
    };
    {
        no warnings 'once';
        *Local::DisconnectWriter::write = sub {
            my ( $self, $chunk ) = @_;
            push @chunks, $chunk;
            die "Broken pipe\n" if @chunks > 1;
            return 1;
        };
        *Local::DisconnectWriter::close = sub { return 1 };
    }
    local $Developer::Dashboard::Web::DancerApp::BACKEND_APP = { app => bless( {}, 'Local::DisconnectBackend' ), default_headers => {} };
    my $result = Developer::Dashboard::Web::DancerApp::_response_from_result(
        [
            200,
            'text/plain; charset=utf-8',
            {
                stream => sub {
                    my ($writer) = @_;
                    is( $writer->("alpha\n"), 1, 'stream writer reports success before the client disconnects' );
                    is( $writer->("beta\n"), 0, 'stream writer reports a disconnect when Dancer content writes fail with broken pipe' );
                },
            },
            {},
        ]
    );
    is( $result, 'delayed-ok', '_response_from_result still completes the delayed wrapper when the client disconnects mid-stream' );
    is_deeply( \@chunks, [ "alpha\n", "beta\n" ], '_response_from_result stops treating broken-pipe writes as fatal backend exceptions' );
}

my $failing_app = bless {}, 'Local::FailingApp';
{
    no warnings 'once';
    *Local::FailingApp::handle = sub { die "exploded\n" };
}
my $failing_server = Developer::Dashboard::Web::Server->new( app => $failing_app );
{
    my $res;
    Local::PSGITest::test_psgi $failing_server->psgi_app, sub {
        my ($cb) = @_;
        $res = $cb->( GET 'http://127.0.0.1/' );
    };
    is( $res->code, 500, 'server converts app exceptions into 500 responses' );
    like( $res->content, qr/exploded/, 'server includes error body for exceptions' );
}

{
    no warnings 'redefine';
    local *Developer::Dashboard::Web::DancerApp::splat = sub { return ['alpha', 'beta']; };
    is( Developer::Dashboard::Web::DancerApp::_capture(1), 'beta', '_capture unwraps arrayref-style splat payloads from Dancer route state' );
}

my $missing_route_app = bless {}, 'Local::MissingRouteApp';
my $missing_route_server = Developer::Dashboard::Web::Server->new( app => $missing_route_app );
{
    my $res;
    Local::PSGITest::test_psgi $missing_route_server->psgi_app, sub {
        my ($cb) = @_;
        $res = $cb->( POST 'http://127.0.0.1/login', [ username => 'helper', password => 'helper-pass-123' ] );
    };
    is( $res->code, 500, 'server returns a backend failure when the login route backend implements neither login_response nor handle' );
    like( $res->content, qr/does not implement login_response or handle/, 'missing login route backend failures are exposed directly' );
}

{
    my $res;
    Local::PSGITest::test_psgi $missing_route_server->psgi_app, sub {
        my ($cb) = @_;
        $res = $cb->( GET 'http://127.0.0.1/' );
    };
    is( $res->code, 500, 'server returns a backend failure when an authorized route backend implements neither a route method nor handle' );
    like( $res->content, qr/does not implement root_response or handle/, 'missing authorized route backend failures are exposed directly' );
}

{
    my $res = Local::PSGITest::request( $server->psgi_app, GET 'http://127.0.0.1:7890/' );
    is( $res->code, 200, 'server treats missing URI queries as empty strings' );
}

{
    no warnings 'redefine';
    local *IO::Socket::INET::new = sub { return };
    local $! = 98;
    dies_like( sub { $server->run }, qr/Unable to start server/, 'server dies when daemon startup fails' );
}

{
    package Local::FakeRunner;
    our @parse_options;
    our $run_arg;
    sub new { bless {}, $_[0] }
    sub parse_options { @parse_options = @_[ 1 .. $#_ ]; return 1 }
    sub run { $run_arg = $_[1]; return 1 }
}

{
    no warnings 'redefine';
    local *Plack::Runner::new = sub { return Local::FakeRunner->new };
    my $fake_daemon = Developer::Dashboard::Web::Server::Daemon->new(
        host => '127.0.0.1',
        port => 5999,
    );
    ok( $server->serve_daemon($fake_daemon), 'serve_daemon delegates to the Plack runner successfully' );
    is_deeply(
        \@Local::FakeRunner::parse_options,
        [ '--server', 'Starman', '--host', '127.0.0.1', '--port', 5999, '--env', 'deployment', '--workers', '1' ],
        'serve_daemon configures Starman through Plack::Runner',
    );
    ok( ref( $Local::FakeRunner::run_arg ) eq 'CODE', 'serve_daemon hands a PSGI app coderef to the Plack runner' );
}

{
    no warnings 'redefine';
    local *Plack::Runner::new = sub { return Local::FakeRunner->new };
    my $worker_server = Developer::Dashboard::Web::Server->new(
        app     => $app,
        host    => '127.0.0.1',
        port    => 5998,
        workers => 4,
    );
    my $fake_daemon = Developer::Dashboard::Web::Server::Daemon->new(
        host => '127.0.0.1',
        port => 5998,
    );
    ok( $worker_server->serve_daemon($fake_daemon), 'serve_daemon accepts an explicit worker count' );
    is_deeply(
        \@Local::FakeRunner::parse_options,
        [ '--server', 'Starman', '--host', '127.0.0.1', '--port', 5998, '--env', 'deployment', '--workers', '4' ],
        'serve_daemon forwards the configured worker count to Starman',
    );
}

{
    no warnings 'redefine';
    local $Developer::Dashboard::Platform::OS_NAME = 'MSWin32';
    local *Plack::Runner::new = sub { return Local::FakeRunner->new };
    my $windows_server = Developer::Dashboard::Web::Server->new(
        app     => $app,
        host    => '127.0.0.1',
        port    => 5997,
        workers => 4,
    );
    my $fake_daemon = Developer::Dashboard::Web::Server::Daemon->new(
        host => '127.0.0.1',
        port => 5997,
    );
    ok( $windows_server->serve_daemon($fake_daemon), 'serve_daemon still runs on Windows hosts' );
    is_deeply(
        \@Local::FakeRunner::parse_options,
        [ '--server', 'Standalone', '--host', '127.0.0.1', '--port', 5997, '--env', 'deployment' ],
        'serve_daemon switches Windows hosts to the non-prefork Standalone Plack server without worker flags',
    );
}

{
    no warnings 'redefine';
    my $served;
    local *Developer::Dashboard::Web::Server::start_daemon = sub {
        return Developer::Dashboard::Web::Server::Daemon->new( host => '127.0.0.1', port => 5999 );
    };
    local *Developer::Dashboard::Web::Server::serve_daemon = sub {
        my ( undef, $daemon ) = @_;
        $served = $daemon;
        return 1;
    };
    local *STDOUT;
    open STDOUT, '>', \my $captured or die $!;
    ok( $server->run, 'run returns the serve_daemon result' );
    like( $captured, qr/Developer Dashboard listening on http:\/\/127\.0\.0\.1:5999\//, 'run announces the listening URL' );
    is( $served->sockport, 5999, 'run passes the daemon descriptor through to serve_daemon' );
}

my $update_config = Developer::Dashboard::Config->new( files => $files, paths => $paths );
my $collector = Developer::Dashboard::Collector->new( paths => $paths );
my $runner = Developer::Dashboard::CollectorRunner->new(
    collectors => $collector,
    files      => $files,
    paths      => $paths,
);
my $updater = Developer::Dashboard::UpdateManager->new(
    config => $update_config,
    files  => $files,
    paths  => $paths,
    runner => $runner,
);

dies_like( sub { Developer::Dashboard::UpdateManager->new( config => $config ) }, qr/Missing file registry/, 'update manager requires file registry' );
dies_like( sub { Developer::Dashboard::UpdateManager->new( config => $config, files => $files ) }, qr/Missing path registry/, 'update manager requires path registry' );
dies_like( sub { Developer::Dashboard::UpdateManager->new( config => $config, files => $files, paths => $paths ) }, qr/Missing collector runner/, 'update manager requires collector runner' );

my $update_root = tempdir(CLEANUP => 1);
my $update_dir  = File::Spec->catdir( $update_root, 'updates' );
my $collector_state = $paths->collectors_root;
make_path($update_dir);

open my $one_pl, '>', File::Spec->catfile( $update_dir, '01-one.pl' ) or die $!;
print {$one_pl} <<"PL";
print "one\\n";
PL
close $one_pl;

open my $two_sh, '>', File::Spec->catfile( $update_dir, '02-two.sh' ) or die $!;
print {$two_sh} <<"SH";
printf 'two\\n'
SH
close $two_sh;

open my $skip_txt, '>', File::Spec->catfile( $update_dir, '99-skip.txt' ) or die $!;
print {$skip_txt} "skip\n";
close $skip_txt;
make_path( File::Spec->catdir( $update_dir, 'subdir' ) );

open my $pid_a, '>', File::Spec->catfile( $collector_state, 'alpha.pid' ) or die $!;
print {$pid_a} "12345\n";
close $pid_a;
open my $pid_b, '>', File::Spec->catfile( $collector_state, 'beta.pid' ) or die $!;
print {$pid_b} "12346\n";
close $pid_b;

$config->save_global(
    {
        collectors => [
            { name => 'alpha', command => q{printf alpha}, cwd => 'home' },
            { name => 'gamma', command => q{printf gamma}, cwd => 'home' },
            'skip-non-hash',
        ],
    }
);

{
    package Local::TrackingRunner;
    sub new { bless { stop => [], start => [] }, $_[0] }
    sub running_loops { return ( { name => 'alpha' }, { name => 'beta' } ) }
    sub stop_loop { push @{ $_[0]->{stop} }, $_[1]; return $_[1] }
    sub start_loop { push @{ $_[0]->{start} }, $_[1]{name}; return 100 }
}

my $tracking_runner = Local::TrackingRunner->new;
my $tracking_updater = Developer::Dashboard::UpdateManager->new(
    config => $config,
    files  => $files,
    paths  => $paths,
    runner => $tracking_runner,
);

{
    no warnings 'redefine';
    local *Developer::Dashboard::UpdateManager::updates_dir = sub { $update_dir };
    my $cwd = getcwd();
    chdir $update_root or die $!;
    local *STDOUT;
    open STDOUT, '>', \my $captured or die $!;
    my $results = $tracking_updater->run;
    chdir $cwd or die $!;
    is_deeply( [ map { $_->{file} } @$results ], [ '01-one.pl', '02-two.sh' ], 'run executes supported update scripts in sorted order' );
    like( $captured, qr/Run Update: 01-one\.pl/, 'run prints update progress for perl scripts' );
    like( $captured, qr/Run Update: 02-two\.sh/, 'run prints update progress for shell scripts' );
    is_deeply( $tracking_runner->{stop}, [ 'alpha', 'beta' ], 'run stops every running collector before updates' );
    is_deeply( $tracking_runner->{start}, ['alpha'], 'run restarts only wanted hash-backed collectors' );
}

open my $silent_pl, '>', File::Spec->catfile( $update_dir, '03-silent.pl' ) or die $!;
print {$silent_pl} <<"PL";
1;
PL
close $silent_pl;
{
    no warnings 'redefine';
    local *Developer::Dashboard::UpdateManager::updates_dir = sub { $update_dir };
    unlink File::Spec->catfile( $collector_state, 'alpha.pid' );
    unlink File::Spec->catfile( $collector_state, 'beta.pid' );
    local *STDOUT;
    open STDOUT, '>', \my $captured or die $!;
    my $results = $tracking_updater->run;
    like( $captured, qr/Run Update: 03-silent\.pl/, 'run executes later silent scripts too' );
    unlike( $captured, qr/\n1\n>> Finished\./, 'run skips printing empty output payloads' );
    is_deeply( [ map { $_->{file} } @$results ], [ '01-one.pl', '02-two.sh', '03-silent.pl' ], 'run still skips unsupported files and directories' );
}

unlink File::Spec->catfile( $collector_state, 'alpha.pid' );
unlink File::Spec->catfile( $collector_state, 'beta.pid' );
open my $other_state, '>', File::Spec->catfile( $collector_state, 'ignore.me' ) or die $!;
print {$other_state} "x\n";
close $other_state;
is_deeply( [ $updater->_running_collectors ], [], '_running_collectors returns an empty list when there are no pidfiles' );
is( $updater->updates_dir, File::Spec->catdir( getcwd(), 'updates' ), 'updates_dir follows the current working directory' );

{
    package Local::RunnerWithLoops;
    sub new { bless {}, $_[0] }
    sub running_loops { return ( { name => 'alpha' }, { name => 'beta' } ) }
    sub stop_loop { return }
    sub start_loop { return }
}
my $loop_updater = Developer::Dashboard::UpdateManager->new(
    config => $config,
    files  => $files,
    paths  => $paths,
    runner => Local::RunnerWithLoops->new,
);
is_deeply( [ $loop_updater->_running_collectors ], [ 'alpha', 'beta' ], '_running_collectors delegates validation to collector runner state' );

sub _create_dancer_route_skill_repo {
    my ($name) = @_;
    my $repo = File::Spec->catdir( $test_repos, $name );
    make_path(
        File::Spec->catdir( $repo, 'config' ),
        File::Spec->catdir( $repo, 'dashboards', 'ajax' ),
        File::Spec->catdir( $repo, 'dashboards', 'public', 'js' ),
        File::Spec->catdir( $repo, 'dashboards', 'public', 'css' ),
        File::Spec->catdir( $repo, 'dashboards', 'public', 'others' ),
        File::Spec->catdir( $repo, 'skills', 'def', 'dashboards', 'ajax' ),
        File::Spec->catdir( $repo, 'skills', 'def', 'dashboards', 'public', 'js', 'ijk' ),
    );

    open my $meta, '>:raw', File::Spec->catfile( $repo, '.skill.json' )
      or die "Unable to write skill metadata: $!";
    print {$meta} qq|{"name":"$name","version":"0.01","commands":[]}\n|;
    close $meta or die "Unable to close skill metadata: $!";

    open my $top_ajax, '>:raw', File::Spec->catfile( $repo, 'dashboards', 'ajax', 'bar' )
      or die "Unable to write top-level skill ajax file: $!";
    print {$top_ajax} "print qq({\"route\":\"dancer-top\"}\\n);\n";
    close $top_ajax or die "Unable to close top-level skill ajax file: $!";
    chmod 0700, File::Spec->catfile( $repo, 'dashboards', 'ajax', 'bar' )
      or die "Unable to chmod top-level skill ajax file: $!";

    open my $top_routes, '>:raw', File::Spec->catfile( $repo, 'config', 'routes.json' )
      or die "Unable to write top-level skill routes file: $!";
    print {$top_routes} <<'JSON';
{
   "/apps/dancer-route-skill/home" : "/app/index",
   "/v1/dancer-route-skill/bar" : "/ajax/bar",
   "/assets/dancer-route-skill.css" : "/css/skill.css",
   "/assets/dancer-route-skill.js" : "/js/skill.js",
   "/downloads/dancer-route-skill.txt" : "/others/info.txt"
}
JSON
    close $top_routes or die "Unable to close top-level skill routes file: $!";

    open my $index, '>:raw', File::Spec->catfile( $repo, 'dashboards', 'index' )
      or die "Unable to write top-level skill index bookmark: $!";
    print {$index} <<'BOOKMARK';
TITLE: Dancer Skill Index
:--------------------------------------------------------------------------------:
BOOKMARK: index
:--------------------------------------------------------------------------------:
HTML:
Dancer Skill Index
BOOKMARK
    close $index or die "Unable to close top-level skill index bookmark: $!";

    open my $js, '>:raw', File::Spec->catfile( $repo, 'dashboards', 'public', 'js', 'skill.js' )
      or die "Unable to write top-level skill js asset: $!";
    print {$js} qq{console.log("dancer top-level js");\n};
    close $js or die "Unable to close top-level skill js asset: $!";

    open my $css, '>:raw', File::Spec->catfile( $repo, 'dashboards', 'public', 'css', 'skill.css' )
      or die "Unable to write top-level skill css asset: $!";
    print {$css} qq{body { color: #654321; }\n};
    close $css or die "Unable to close top-level skill css asset: $!";

    open my $other, '>:raw', File::Spec->catfile( $repo, 'dashboards', 'public', 'others', 'info.txt' )
      or die "Unable to write top-level skill other asset: $!";
    print {$other} "dancer top-level other\n";
    close $other or die "Unable to close top-level skill other asset: $!";

    open my $nested_ajax, '>:raw', File::Spec->catfile( $repo, 'skills', 'def', 'dashboards', 'ajax', 'nested' )
      or die "Unable to write nested skill ajax file: $!";
    print {$nested_ajax} qq|print "<p>dancer nested ajax</p>\\n";\n|;
    close $nested_ajax or die "Unable to close nested skill ajax file: $!";
    chmod 0700, File::Spec->catfile( $repo, 'skills', 'def', 'dashboards', 'ajax', 'nested' )
      or die "Unable to chmod nested skill ajax file: $!";

    make_path( File::Spec->catdir( $repo, 'skills', 'def', 'config' ) );
    open my $nested_routes, '>:raw', File::Spec->catfile( $repo, 'skills', 'def', 'config', 'routes.json' )
      or die "Unable to write nested skill routes file: $!";
    print {$nested_routes} <<'JSON';
{
   "/apps/dancer-route-skill/child" : "/app/index",
   "/v1/dancer-route-skill/nested" : {
      "to" : "/ajax/nested",
      "type" : "html"
   },
   "/assets/dancer-route-skill-nested.js" : "/js/ijk/lmn.js"
}
JSON
    close $nested_routes or die "Unable to close nested skill routes file: $!";

    open my $nested_index, '>:raw', File::Spec->catfile( $repo, 'skills', 'def', 'dashboards', 'index' )
      or die "Unable to write nested skill index bookmark: $!";
    print {$nested_index} <<'BOOKMARK';
TITLE: Dancer Nested Index
:--------------------------------------------------------------------------------:
BOOKMARK: index
:--------------------------------------------------------------------------------:
HTML:
Dancer Nested Index
BOOKMARK
    close $nested_index or die "Unable to close nested skill index bookmark: $!";

    open my $nested_js, '>:raw', File::Spec->catfile( $repo, 'skills', 'def', 'dashboards', 'public', 'js', 'ijk', 'lmn.js' )
      or die "Unable to write nested skill js asset: $!";
    print {$nested_js} qq{console.log("dancer nested js");\n};
    close $nested_js or die "Unable to close nested skill js asset: $!";

    my $cwd = getcwd();
    chdir $repo or die "Unable to chdir to $repo: $!";
    system( 'git', 'init', '-q' ) == 0 or die "Unable to git init $repo: $!";
    system( 'git', 'config', 'user.email', 'test@example.invalid' ) == 0 or die "Unable to configure git user.email for $repo: $!";
    system( 'git', 'config', 'user.name', 'Developer Dashboard Test' ) == 0 or die "Unable to configure git user.name for $repo: $!";
    system( 'git', 'add', '.' ) == 0 or die "Unable to git add fixture repo contents: $!";
    system( 'git', 'commit', '-qm', 'fixture' ) == 0 or die "Unable to commit fixture repo contents: $!";
    chdir $cwd or die "Unable to chdir back to $cwd: $!";

    return $repo;
}

done_testing;

__END__

=head1 NAME

08-web-update-coverage.t - extended web and updater behavior tests

=head1 DESCRIPTION

This test verifies extended web server bridging, auth behavior, and update
manager edge cases.

=for comment FULL-POD-DOC START

=head1 PURPOSE

This test is the executable regression contract for runtime update, bootstrap, and staged maintenance behavior. 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 runtime update, bootstrap, and staged maintenance behavior 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 runtime update, bootstrap, and staged maintenance behavior, 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/08-web-update-coverage.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/08-web-update-coverage.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/08-web-update-coverage.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
