diff --git a/tests/Feature/Http/Controllers/Subscribers/SubscribeTest.php b/tests/Feature/Http/Controllers/Subscribers/SubscribeTest.php index d8561832..227c1004 100644 --- a/tests/Feature/Http/Controllers/Subscribers/SubscribeTest.php +++ b/tests/Feature/Http/Controllers/Subscribers/SubscribeTest.php @@ -24,7 +24,6 @@ it('shows the subscribe page', function () { get(route('cachet.subscribers.create')) - ->assertOk() ->assertSee(__('cachet::subscriber.status_page.subscribe.heading')) ->assertSee('data-page="subscribe"', escape: false) ->assertSee('data-component="subscribe"', escape: false); @@ -181,7 +180,6 @@ $subscriber = Subscriber::factory()->verified()->create(); get($subscriber->unsubscribeUrl()) - ->assertOk() ->assertSee(__('cachet::subscriber.status_page.unsubscribe.heading')) ->assertSee($subscriber->email) ->assertSee('data-page="unsubscribe"', escape: false) @@ -192,16 +190,15 @@ it('renders unsubscribe hooks around the confirmation', function () { $subscriber = Subscriber::factory()->verified()->create(); - CachetView::registerRenderHook(RenderHook::STATUS_PAGE_UNSUBSCRIBE_BEFORE, fn () => 'unsubscribe-before-hook'); - CachetView::registerRenderHook(RenderHook::STATUS_PAGE_UNSUBSCRIBE_AFTER, fn () => 'unsubscribe-after-hook'); + CachetView::registerRenderHook(RenderHook::STATUS_PAGE_UNSUBSCRIBE_BEFORE, fn () => 'unsubscribe-before-hook'); + CachetView::registerRenderHook(RenderHook::STATUS_PAGE_UNSUBSCRIBE_AFTER, fn () => 'unsubscribe-after-hook'); get($subscriber->unsubscribeUrl()) - ->assertOk() - ->assertSeeInOrder([ + ->assertSeeTextInOrder([ 'unsubscribe-before-hook', - 'data-slot="content"', + __('cachet::subscriber.status_page.unsubscribe.heading'), 'unsubscribe-after-hook', - ], escape: false); + ]); }); it('unsubscribes a subscriber once confirmed', function () { diff --git a/tests/Feature/StatusPage/IncidentPageTest.php b/tests/Feature/StatusPage/IncidentPageTest.php index c64089e9..7706ffb4 100644 --- a/tests/Feature/StatusPage/IncidentPageTest.php +++ b/tests/Feature/StatusPage/IncidentPageTest.php @@ -4,17 +4,30 @@ use Cachet\Models\Component; use Cachet\Models\Incident; -it('shows the affected components on the incident page', function () { +it('renders incident details with stable theme selectors', function () { $component = Component::factory()->create(['name' => 'API']); - $incident = Incident::factory()->create(); + $incident = Incident::factory()->create(['name' => 'API connectivity']); $incident->components()->attach($component, [ 'component_status' => ComponentStatusEnum::performance_issues->value, ]); $this->get(route('cachet.status-page.incident', $incident)) - ->assertOk() + ->assertSeeText('API connectivity') ->assertSee(__('cachet::incident.affected_components_header')) - ->assertSee('API'); + ->assertSee('API') + ->assertSee([ + 'data-page="incident"', + 'data-component="affected-components"', + 'data-component="incident"', + 'data-component="incident-update"', + 'data-component="incident-update-status"', + 'data-component="badge"', + 'data-component="timestamp"', + 'data-component="page-navigation"', + 'data-slot="main"', + 'data-slot="indicator"', + 'data-slot="message"', + ], escape: false); }); it('does not show the affected components box when none are attached', function () { @@ -31,39 +44,6 @@ ]); $this->get(route('cachet.status-page.incident', $incident)) - ->assertOk() - ->assertSee('investigating', escape: false) + ->assertSeeText('investigating') ->assertDontSee('', escape: false); }); - -it('uses the incident name as the page heading', function () { - $incident = Incident::factory()->create(['name' => 'API connectivity']); - - $page = $this->get(route('cachet.status-page.incident', $incident)) - ->assertOk() - ->getContent(); - - expect($page)->toMatch('/]*>\s*API connectivity\s*<\/h1>/'); -}); - -it('renders stable incident page attributes', function () { - $component = Component::factory()->create(); - $incident = Incident::factory()->create(); - $incident->components()->attach($component, [ - 'component_status' => ComponentStatusEnum::performance_issues->value, - ]); - - $this->get(route('cachet.status-page.incident', $incident)) - ->assertOk() - ->assertSee('data-page="incident"', escape: false) - ->assertSee('data-component="affected-components"', escape: false) - ->assertSee('data-component="incident"', escape: false) - ->assertSee('data-component="incident-update"', escape: false) - ->assertSee('data-component="incident-update-status"', escape: false) - ->assertSee('data-component="badge"', escape: false) - ->assertSee('data-component="timestamp"', escape: false) - ->assertSee('data-component="page-navigation"', escape: false) - ->assertSee('data-slot="main"', escape: false) - ->assertSee('data-slot="indicator"', escape: false) - ->assertSee('data-slot="message"', escape: false); -}); diff --git a/tests/Feature/StatusPage/SchedulePageTest.php b/tests/Feature/StatusPage/SchedulePageTest.php index cb276a2c..1bddd2f3 100644 --- a/tests/Feature/StatusPage/SchedulePageTest.php +++ b/tests/Feature/StatusPage/SchedulePageTest.php @@ -8,7 +8,7 @@ use function Pest\Laravel\get; -it('renders the schedule page', function () { +it('renders schedule details with stable theme selectors', function () { $schedule = Schedule::factory()->create([ 'name' => 'Database maintenance', 'message' => 'We will be upgrading the database.', @@ -22,9 +22,18 @@ $schedule->updates()->save($update); get(route('cachet.status-page.schedule', ['schedule' => $schedule])) - ->assertOk() ->assertSee('Database maintenance') - ->assertSee('Maintenance window confirmed.'); + ->assertSee('Maintenance window confirmed.') + ->assertSee([ + 'data-page="schedule"', + 'data-component="schedule"', + 'data-component="schedule-update"', + 'data-component="badge"', + 'data-component="timestamp"', + 'data-component="page-navigation"', + 'data-slot="main"', + 'data-slot="message"', + ], escape: false); }); it('links to schedules from the status page', function () { @@ -38,32 +47,3 @@ it('returns not found for a missing schedule', function () { get(route('cachet.status-page.schedule', ['schedule' => 999]))->assertNotFound(); }); - -it('uses the maintenance name as the page heading', function () { - $schedule = Schedule::factory()->create(['name' => 'Database maintenance']); - - $page = get(route('cachet.status-page.schedule', ['schedule' => $schedule])) - ->assertOk() - ->getContent(); - - expect($page)->toMatch('/]*>\s*Database maintenance\s*<\/h1>/'); -}); - -it('renders stable maintenance page attributes', function () { - $schedule = Schedule::factory()->create(); - $schedule->updates()->save(new Update([ - 'message' => 'Maintenance is underway.', - 'status' => IncidentStatusEnum::unknown, - ])); - - get(route('cachet.status-page.schedule', ['schedule' => $schedule])) - ->assertOk() - ->assertSee('data-page="schedule"', escape: false) - ->assertSee('data-component="schedule"', escape: false) - ->assertSee('data-component="schedule-update"', escape: false) - ->assertSee('data-component="badge"', escape: false) - ->assertSee('data-component="timestamp"', escape: false) - ->assertSee('data-component="page-navigation"', escape: false) - ->assertSee('data-slot="main"', escape: false) - ->assertSee('data-slot="message"', escape: false); -}); diff --git a/tests/Feature/StatusPage/StatusPageTest.php b/tests/Feature/StatusPage/StatusPageTest.php index f0b6503d..bc61c680 100644 --- a/tests/Feature/StatusPage/StatusPageTest.php +++ b/tests/Feature/StatusPage/StatusPageTest.php @@ -63,7 +63,7 @@ ->assertNotFound(); }); -it('can hide the site name and about content without changing status page metadata', function () { +it('hides the site name and about content when disabled', function () { $settings = app(AppSettings::class); $settings->name = 'Acme Status'; $settings->about = 'A private production system.'; @@ -77,10 +77,6 @@ expect($body) ->not->toContain('Acme Status') ->not->toContain('A private production system.'); - - $response - ->assertSee('Acme Status', escape: false) - ->assertSee('', escape: false); }); it('shows the site name once when it is enabled', function () { @@ -96,54 +92,53 @@ expect(substr_count($body, 'Acme Status'))->toBe(1); }); -it('uses a logical heading hierarchy for components', function () { - $group = ComponentGroup::factory()->create(['name' => 'Core services']); - Component::factory()->create(['name' => 'Public API']); - Component::factory()->create(['name' => 'Core API', 'component_group_id' => $group->id]); - - $page = $this->get(route('cachet.status-page')) - ->assertOk() - ->getContent(); - - expect($page) - ->toMatch('/]*>\s*Core services\s*<\\/h2>/') - ->toMatch('/]*>\s*Core API\s*<\\/h3>/') - ->toMatch('/]*>\s*Public API\s*<\\/h2>/'); -}); - it('renders the components after hook after each component', function () { - Component::factory()->create(['name' => 'Public API']); - CachetView::registerRenderHook(RenderHook::STATUS_PAGE_COMPONENTS_AFTER, fn () => 'components-after-hook'); + Component::factory()->create(['name' => 'Alpha API', 'order' => 1]); + Component::factory()->create(['name' => 'Bravo API', 'order' => 2]); + CachetView::registerRenderHook(RenderHook::STATUS_PAGE_COMPONENTS_AFTER, fn () => 'components-after-hook'); $this->get(route('cachet.status-page')) - ->assertOk() - ->assertSee('components-after-hook'); + ->assertSeeTextInOrder([ + 'Alpha API', + 'components-after-hook', + 'Bravo API', + 'components-after-hook', + ]); }); -it('renders the declared banner metrics and footer hooks', function () { +it('renders status page hooks around their content', function () { + Component::factory()->create(['status' => ComponentStatusEnum::operational]); Metric::factory()->create([ + 'name' => 'Response time', 'visible' => ResourceVisibilityEnum::guest, 'display_chart' => true, 'show_when_empty' => true, ]); Cache::forget('cachet::metrics.guests'); - CachetView::registerRenderHook(RenderHook::STATUS_PAGE_BANNER, fn () => 'banner-hook'); - CachetView::registerRenderHook(RenderHook::STATUS_PAGE_METRICS_BEFORE, fn () => 'metrics-before-hook'); - CachetView::registerRenderHook(RenderHook::STATUS_PAGE_METRICS_AFTER, fn () => 'metrics-after-hook'); - CachetView::registerRenderHook(RenderHook::FOOTER, fn () => 'footer-hook'); + CachetView::registerRenderHook(RenderHook::STATUS_PAGE_BANNER, fn () => 'banner-hook'); + CachetView::registerRenderHook(RenderHook::STATUS_PAGE_STATUS_SUMMARY_BEFORE, fn () => 'summary-before-hook'); + CachetView::registerRenderHook(RenderHook::STATUS_PAGE_STATUS_SUMMARY_AFTER, fn () => 'summary-after-hook'); + CachetView::registerRenderHook(RenderHook::STATUS_PAGE_METRICS_BEFORE, fn () => 'metrics-before-hook'); + CachetView::registerRenderHook(RenderHook::STATUS_PAGE_METRICS_AFTER, fn () => 'metrics-after-hook'); + CachetView::registerRenderHook(RenderHook::STATUS_PAGE_INCIDENT_TIMELINE_BEFORE, fn () => 'timeline-before-hook'); + CachetView::registerRenderHook(RenderHook::STATUS_PAGE_INCIDENT_TIMELINE_AFTER, fn () => 'timeline-after-hook'); + CachetView::registerRenderHook(RenderHook::FOOTER, fn () => 'footer-hook'); $this->get(route('cachet.status-page')) - ->assertOk() - ->assertSeeInOrder([ - 'data-component="header"', + ->assertSeeTextInOrder([ 'banner-hook', + 'summary-before-hook', + __('cachet::system_status.operational'), + 'summary-after-hook', 'metrics-before-hook', - 'data-component="metrics"', + 'Response time', 'metrics-after-hook', - 'data-component="footer"', + 'timeline-before-hook', + __('cachet::incident.no_incidents_reported'), + 'timeline-after-hook', 'footer-hook', - ], escape: false); + ]); }); it('renders a footer hook without built-in footer content', function () { @@ -152,33 +147,14 @@ $settings->show_timezone = false; $settings->save(); - CachetView::registerRenderHook(RenderHook::FOOTER, fn () => 'footer-hook'); + CachetView::registerRenderHook(RenderHook::FOOTER, fn () => 'footer-hook'); $this->get(route('cachet.status-page')) - ->assertOk() ->assertSee('data-component="footer"', escape: false) - ->assertSee('footer-hook'); + ->assertSeeText('footer-hook'); }); -it('renders status summary and incident timeline hooks', function () { - CachetView::registerRenderHook(RenderHook::STATUS_PAGE_STATUS_SUMMARY_BEFORE, fn () => 'summary-before-hook'); - CachetView::registerRenderHook(RenderHook::STATUS_PAGE_STATUS_SUMMARY_AFTER, fn () => 'summary-after-hook'); - CachetView::registerRenderHook(RenderHook::STATUS_PAGE_INCIDENT_TIMELINE_BEFORE, fn () => 'timeline-before-hook'); - CachetView::registerRenderHook(RenderHook::STATUS_PAGE_INCIDENT_TIMELINE_AFTER, fn () => 'timeline-after-hook'); - - $this->get(route('cachet.status-page')) - ->assertOk() - ->assertSeeInOrder([ - 'summary-before-hook', - 'data-component="status-summary"', - 'summary-after-hook', - 'timeline-before-hook', - 'data-component="incident-timeline"', - 'timeline-after-hook', - ], escape: false); -}); - -it('renders stable theme attributes on the status page', function () { +it('exposes stable theme selectors on the status page', function () { $settings = app(AppSettings::class); $settings->about = 'Service status and uptime.'; $settings->show_about = true; @@ -196,56 +172,67 @@ Cache::forget('cachet::metrics.guests'); $this->get(route('cachet.status-page')) - ->assertOk() - ->assertSee('data-page="status"', escape: false) - ->assertSee('data-component="header"', escape: false) - ->assertSee('data-component="status-overview"', escape: false) - ->assertSee('data-component="status-summary"', escape: false) - ->assertSee('data-component="component-list"', escape: false) - ->assertSee('data-component="component-group"', escape: false) - ->assertSee('data-component="component"', escape: false) - ->assertSee('data-component="about"', escape: false) - ->assertSee('data-component="metrics"', escape: false) - ->assertSee('data-component="metric"', escape: false) - ->assertSee('data-component="schedules"', escape: false) - ->assertSee('data-component="schedule"', escape: false) - ->assertSee('data-component="incident-timeline"', escape: false) - ->assertSee('data-component="incident-day"', escape: false) - ->assertSee('data-component="incident"', escape: false) - ->assertSee('data-component="incident-update"', escape: false) - ->assertSee('data-component="incident-update-status"', escape: false) - ->assertSee('data-component="badge"', escape: false) - ->assertSee('data-component="timestamp"', escape: false) - ->assertSee('data-component="logo"', escape: false) - ->assertSee('data-component="footer"', escape: false) - ->assertSee('data-component-group-id="'.$group->getKey().'"', escape: false) - ->assertSee('data-component-id="'.$component->getKey().'"', escape: false) - ->assertSee('data-metric-id="'.$metric->getKey().'"', escape: false) - ->assertSee('data-schedule-id="'.$schedule->getKey().'"', escape: false) - ->assertSee('data-incident-id="'.$incident->getKey().'"', escape: false) - ->assertSee('data-update-id="reported"', escape: false) - ->assertSee('data-slot="main"', escape: false) - ->assertSee('data-slot="title"', escape: false) - ->assertSee('data-slot="status"', escape: false) - ->assertSee('data-slot="indicator"', escape: false) - ->assertSee('data-slot="content"', escape: false); + ->assertSee([ + 'data-page="status"', + 'data-component="header"', + 'data-component="status-overview"', + 'data-component="status-summary"', + 'data-component="component-list"', + 'data-component="component-group"', + 'data-component="component"', + 'data-component="about"', + 'data-component="metrics"', + 'data-component="metric"', + 'data-component="schedules"', + 'data-component="schedule"', + 'data-component="incident-timeline"', + 'data-component="incident-day"', + 'data-component="incident"', + 'data-component="incident-update"', + 'data-component="incident-update-status"', + 'data-component="badge"', + 'data-component="timestamp"', + 'data-component="logo"', + 'data-component="footer"', + 'data-component-group-id="'.$group->getKey().'"', + 'data-component-id="'.$component->getKey().'"', + 'data-metric-id="'.$metric->getKey().'"', + 'data-schedule-id="'.$schedule->getKey().'"', + 'data-incident-id="'.$incident->getKey().'"', + 'data-update-id="reported"', + 'data-slot="main"', + 'data-slot="title"', + 'data-slot="status"', + 'data-slot="indicator"', + 'data-slot="content"', + ], escape: false); }); -it('gives status page controls accessible names', function () { +it('uses accessible component headings and form labels', function () { + $group = ComponentGroup::factory()->create(['name' => 'Core services']); Component::factory()->create([ 'name' => 'Public API', 'description' => 'The public API.', ]); + Component::factory()->create([ + 'name' => 'Core API', + 'component_group_id' => $group->id, + ]); - $page = $this->get(route('cachet.status-page')) - ->assertOk() + $response = $this->get(route('cachet.status-page')) ->assertSee('aria-label="'.__('cachet::component.description_label', ['component' => 'Public API']).'"', escape: false) - ->assertSee('aria-label="'.__('cachet::incident.timeline.date_range_label').'"', escape: false) - ->getContent(); - - expect($page) - ->toMatch('/]*>.*'.__('cachet::incident.timeline.from_label').'.*]*type="date"/s') - ->toMatch('/]*>.*'.__('cachet::incident.timeline.to_label').'.*]*type="date"/s'); + ->assertSee('aria-label="'.__('cachet::incident.timeline.date_range_label').'"', escape: false); + + /** Assert semantics without coupling to serialized HTML. */ + $document = new DOMDocument; + $document->loadHTML($response->getContent(), LIBXML_NOERROR | LIBXML_NOWARNING); + $xpath = new DOMXPath($document); + + expect($xpath->query('//h2[normalize-space(.)="Core services"]')->length)->toBe(1); + expect($xpath->query('//h3[normalize-space(.)="Core API"]')->length)->toBe(1); + expect($xpath->query('//h2[normalize-space(.)="Public API"]')->length)->toBe(1); + expect($xpath->query('//label[normalize-space(.)="'.__('cachet::incident.timeline.from_label').'"]//input[@type="date"]')->length)->toBe(1); + expect($xpath->query('//label[normalize-space(.)="'.__('cachet::incident.timeline.to_label').'"]//input[@type="date"]')->length)->toBe(1); }); it('marks the page for conditional metrics loading only when a metric chart exists', function () { @@ -272,6 +259,7 @@ it('can hide component group statuses', function () { $settings = app(AppSettings::class); $settings->show_component_group_status = false; + $settings->major_outage_threshold = 100; $settings->save(); $group = ComponentGroup::factory()->create(['name' => 'Core services']); @@ -289,8 +277,10 @@ expect($page) ->toContain('Core services') - ->toContain('1 Incident') - ->not->toMatch('/Core services\s*<\\/h2>\s*]*>\s*Major outage\s*<\\/span>/'); + ->toContain('1 Incident'); + + expect(substr_count(strip_tags($page), ComponentStatusEnum::major_outage->getLabel())) + ->toBe(1); }); it('can display component tags', function () { @@ -435,7 +425,7 @@ $this->get(route('cachet.status-page')) ->assertOk() - ->assertSee('primary', escape: false) + ->assertSeeText('primary') ->assertDontSee('', escape: false); }); diff --git a/tests/Unit/View/Components/CachetTest.php b/tests/Unit/View/Components/CachetTest.php new file mode 100644 index 00000000..9c1cc045 --- /dev/null +++ b/tests/Unit/View/Components/CachetTest.php @@ -0,0 +1,19 @@ +name = 'Acme Status'; + $settings->about = 'A private production system.'; + $settings->show_site_name = false; + $settings->show_about = false; + $settings->save(); + + $viewData = app(Cachet::class)->render()->getData(); + + expect($viewData) + ->title->toBe('Acme Status') + ->description->toBe('A private production system.'); +}); diff --git a/tests/Unit/View/ViewManagerTest.php b/tests/Unit/View/ViewManagerTest.php new file mode 100644 index 00000000..a2277014 --- /dev/null +++ b/tests/Unit/View/ViewManagerTest.php @@ -0,0 +1,16 @@ + 'global'; + + $viewManager->registerRenderHook('status', $sharedHook); + $viewManager->registerRenderHook('status', $sharedHook, 'public'); + $viewManager->registerRenderHook('status', fn () => 'public', 'public'); + $viewManager->registerRenderHook('status', fn () => 'dashboard', 'dashboard'); + + expect($viewManager->renderHook('status', 'public')->toHtml()) + ->toBe('globalpublic'); +});