From bb22399570224fc45919d057d362ff1a3db9a508 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 20 Jun 2026 12:10:54 +1000 Subject: [PATCH 1/8] [#658] Added suite-level accessibility aggregate report. --- STEPS.md | 6 + src/AccessibilityTrait.php | 558 ++++++++++++++++++- tests/behat/features/accessibility.feature | 14 + tests/phpunit/src/AccessibilityTraitTest.php | 323 +++++++++++ 4 files changed, 895 insertions(+), 6 deletions(-) diff --git a/STEPS.md b/STEPS.md index 3d9c806b..d5f15261 100644 --- a/STEPS.md +++ b/STEPS.md @@ -78,6 +78,12 @@ > be plugged in by overriding `accessibilityRunEngine()` (perform the > assessment, return raw results) and `accessibilityNormalizeResults()` > (remap raw output into the canonical shape the rest of the trait expects). +>

+> Reporting. Each scenario writes its own HTML and JUnit report. After the +> whole suite, a single cross-page `index.html` is written to the same +> directory, de-duplicating every assessed page and rolling violations up by +> rule. The aggregate accumulates in process-global state, so under parallel +> Behat each process writes its own `index.html`.
diff --git a/src/AccessibilityTrait.php b/src/AccessibilityTrait.php index 1d2b5d2a..b30162c9 100644 --- a/src/AccessibilityTrait.php +++ b/src/AccessibilityTrait.php @@ -9,6 +9,7 @@ use Behat\Behat\Hook\Scope\BeforeScenarioScope; use Behat\Hook\AfterScenario; use Behat\Hook\AfterStep; +use Behat\Hook\AfterSuite; use Behat\Hook\BeforeScenario; use Behat\Hook\BeforeSuite; use Behat\Mink\Exception\ExpectationException; @@ -32,6 +33,12 @@ * be plugged in by overriding `accessibilityRunEngine()` (perform the * assessment, return raw results) and `accessibilityNormalizeResults()` * (remap raw output into the canonical shape the rest of the trait expects). + * + * Reporting. Each scenario writes its own HTML and JUnit report. After the + * whole suite, a single cross-page `index.html` is written to the same + * directory, de-duplicating every assessed page and rolling violations up by + * rule. The aggregate accumulates in process-global state, so under parallel + * Behat each process writes its own `index.html`. */ trait AccessibilityTrait { @@ -69,6 +76,26 @@ trait AccessibilityTrait { */ protected static ?string $accessibilityBaseDir = NULL; + /** + * Normalized per-scenario results accumulated across the whole suite. + * + * Populated as each scenario finalizes and consumed once by the static + * `@AfterSuite` renderer. URLs are stored already formatted for display. + * + * @var array}>}> + */ + protected static array $accessibilityAggregate = []; + + /** + * Report directory captured in the instance phase for the static renderer. + * + * The `@AfterSuite` hook is static and cannot call + * `accessibilityGetReportDir()` (an instance method, overridable per + * consumer), so the resolved directory is captured while a scenario + * finalizes and reused when the aggregate is written. + */ + protected static ?string $accessibilityAggregateReportDir = NULL; + /** * Normalized results collected during the current scenario. * @@ -178,7 +205,7 @@ public function accessibilityAutoAssess(AfterStepScope $scope): void { return; } - if (in_array($url, ['', 'about:blank', $this->accessibilityLastCheckedUrl], TRUE)) { + if (in_array($url, [...static::accessibilityBlankUrls(), $this->accessibilityLastCheckedUrl], TRUE)) { return; } @@ -206,6 +233,10 @@ public function accessibilityFinalizeScenario(AfterScenarioScope $scope): void { file_put_contents($dir . '/' . $slug . '.html', $this->accessibilityRenderHtml()); file_put_contents($dir . '/junit-' . $slug . '.xml', $this->accessibilityRenderJunit()); + // Accumulate before the gate so a scenario that fails the gate is still + // represented in the suite-level aggregate. + $this->accessibilityAggregateCapture($dir); + if (!$this->accessibilityAutoMode) { return; } @@ -636,7 +667,7 @@ protected function accessibilityFormatGateMessage(string $url, string $rules, st $lines[] = sprintf(' violation [%s] %s - %s', $v['impact'] ?? 'unknown', $v['id'], $v['help']); $lines[] = sprintf(' %s', $v['helpUrl']); foreach ($v['nodes'] ?? [] as $node) { - $lines[] = sprintf(' -> %s', $this->accessibilityStringifyTarget($node['target'] ?? [])); + $lines[] = sprintf(' -> %s', static::accessibilityStringifyTarget($node['target'] ?? [])); $html = trim((string) ($node['html'] ?? '')); if ($html !== '') { $lines[] = sprintf(' %s', mb_strimwidth($html, 0, 160, '...')); @@ -648,7 +679,7 @@ protected function accessibilityFormatGateMessage(string $url, string $rules, st $lines[] = sprintf(' incomplete [%s] %s - %s', $i['impact'] ?? 'unknown', $i['id'], $i['help']); $lines[] = sprintf(' %s', $i['helpUrl']); foreach ($i['nodes'] ?? [] as $node) { - $lines[] = sprintf(' -> %s', $this->accessibilityStringifyTarget($node['target'] ?? [])); + $lines[] = sprintf(' -> %s', static::accessibilityStringifyTarget($node['target'] ?? [])); } } @@ -661,7 +692,7 @@ protected function accessibilityFormatGateMessage(string $url, string $rules, st * @param array $target * Canonical `target` value of a node entry. */ - protected function accessibilityStringifyTarget(array $target): string { + protected static function accessibilityStringifyTarget(array $target): string { return implode(' > ', array_map(fn($t): string => is_array($t) ? implode(' ', $t) : (string) $t, $target)); } @@ -817,7 +848,7 @@ protected function accessibilityRenderIssueList(string $heading, string $css_cla } foreach ($issue['nodes'] ?? [] as $node) { - $target = htmlspecialchars($this->accessibilityStringifyTarget($node['target'] ?? []), ENT_QUOTES); + $target = htmlspecialchars(static::accessibilityStringifyTarget($node['target'] ?? []), ENT_QUOTES); $html = htmlspecialchars(trim((string) ($node['html'] ?? '')), ENT_QUOTES); $out .= sprintf('
%s
%s
', $target, $html); } @@ -853,7 +884,7 @@ protected function accessibilityRenderJunit(): string { $help_url = (string) ($v['helpUrl'] ?? ''); foreach ($v['nodes'] ?? [] as $node) { - $target = $this->accessibilityStringifyTarget($node['target'] ?? []); + $target = static::accessibilityStringifyTarget($node['target'] ?? []); $html = trim((string) ($node['html'] ?? '')); $message = sprintf('[%s] %s', $impact, $help); @@ -887,4 +918,519 @@ protected function accessibilityRenderJunit(): string { ); } + /** + * Return URL values that represent a blank tab rather than a real page. + * + * Shared by the per-step auto assessment (so a blank tab never enters the + * results) and the aggregate renderer (defence in depth). Override to extend. + * + * @return array + * URL values to ignore. + */ + protected static function accessibilityBlankUrls(): array { + return ['', 'about:blank', 'data:,']; + } + + /** + * Clear the suite-level aggregate state before the suite runs. + * + * The accumulator is process-global, so resetting at suite start stops a + * second suite in the same process from inheriting the first one's results. + */ + #[BeforeSuite] + public static function accessibilityAggregateReset(): void { + self::$accessibilityAggregate = []; + self::$accessibilityAggregateReportDir = NULL; + } + + /** + * Record the scenario's formatted results for the suite-level aggregate. + * + * URLs are formatted here, in the instance phase, so the static renderer can + * reuse the one `accessibilityFormatUrl()` helper (and any consumer override + * of it) without an instance to call it on. + * + * @param string $dir + * The resolved per-scenario report directory, captured for the static + * `@AfterSuite` renderer. + */ + protected function accessibilityAggregateCapture(string $dir): void { + self::$accessibilityAggregateReportDir = $dir; + + $results = []; + foreach ($this->accessibilityResults as $result) { + $results[] = [ + 'url' => $this->accessibilityFormatUrl((string) $result['url']), + 'rules' => (string) $result['rules'], + 'result' => $result['result'], + ]; + } + + self::$accessibilityAggregate[] = [ + 'feature' => $this->accessibilityFeatureName, + 'scenario' => $this->accessibilityScenarioName, + 'threshold' => $this->accessibilityEffectiveThreshold(), + 'failOnIncomplete' => $this->accessibilityEffectiveFailOnIncomplete(), + 'results' => $results, + ]; + } + + /** + * Render the single cross-page report after the whole suite has run. + */ + #[AfterSuite] + public static function accessibilityAggregateRender(): void { + static::accessibilityWriteAggregateReport(); + } + + /** + * Write the aggregate report when at least one scenario produced results. + * + * The timestamp is resolved here and passed into the renderer so the render + * methods stay deterministic for tests. + */ + protected static function accessibilityWriteAggregateReport(): void { + if (self::$accessibilityAggregate === []) { + return; + } + + $dir = self::$accessibilityAggregateReportDir ?? (getcwd() ?: '.') . DIRECTORY_SEPARATOR . '.logs/test_results/accessibility'; + if (!is_dir($dir)) { + mkdir($dir, 0777, TRUE); + } + + file_put_contents($dir . '/index.html', static::accessibilityRenderAggregateHtml(self::$accessibilityAggregate, date('Y-m-d H:i'))); + } + + /** + * Build the full aggregate HTML document. + * + * Composes the page wrapper around the body sections. Override + * accessibilityRenderAggregatePage() to rebrand the page, or + * accessibilityRenderAggregateSections() to change the body, without + * rewriting the aggregation logic. + * + * @param array> $aggregate + * The accumulated per-scenario results. + * @param string $generated + * Human-readable generation timestamp. + */ + protected static function accessibilityRenderAggregateHtml(array $aggregate, string $generated): string { + return static::accessibilityRenderAggregatePage($generated, static::accessibilityRenderAggregateSections($aggregate)); + } + + /** + * Compose the aggregate body: summary, pages, rules, per-scenario detail. + * + * @param array> $aggregate + * The accumulated per-scenario results. + */ + protected static function accessibilityRenderAggregateSections(array $aggregate): string { + $pages = static::accessibilityAggregatePages($aggregate); + $rollup = static::accessibilityAggregateRollup($pages); + $totals = $rollup['totals']; + + return static::accessibilityRenderAggregateSummary((int) array_sum($totals), count($pages), count($aggregate), $totals) + . static::accessibilityRenderAggregatePages($pages) + . static::accessibilityRenderAggregateRules($rollup['rules']) + . static::accessibilityRenderAggregateScenarios($aggregate); + } + + /** + * De-duplicate assessed pages by URL across every scenario. + * + * @param array> $aggregate + * The accumulated per-scenario results. + * + * @return array> + * One entry per unique URL, in first-seen order, each holding its + * violations, incomplete and passes counts, and visiting scenarios. + */ + protected static function accessibilityAggregatePages(array $aggregate): array { + $blank = static::accessibilityBlankUrls(); + $pages = []; + + foreach ($aggregate as $entry) { + foreach ($entry['results'] ?? [] as $result) { + $url = (string) ($result['url'] ?? ''); + + if (in_array($url, $blank, TRUE)) { + continue; + } + + if (!isset($pages[$url])) { + $pages[$url] = [ + 'violations' => $result['result']['violations'] ?? [], + 'incomplete' => count($result['result']['incomplete'] ?? []), + 'passes' => count($result['result']['passes'] ?? []), + 'scenarios' => [], + ]; + } + + $pages[$url]['scenarios'][(string) ($entry['scenario'] ?? '')] = TRUE; + } + } + + return $pages; + } + + /** + * Roll violations up by rule and tally totals by impact. + * + * Rules are sorted highest-impact first, then by affected-element count. + * + * @param array> $pages + * Per-URL rollup from accessibilityAggregatePages(). + * + * @return array{rules: array>, totals: array} + * Severity-sorted rules and per-impact totals. + */ + protected static function accessibilityAggregateRollup(array $pages): array { + $rank = [self::IMPACT_CRITICAL => 0, self::IMPACT_SERIOUS => 1, self::IMPACT_MODERATE => 2, self::IMPACT_MINOR => 3]; + $rules = []; + $totals = [self::IMPACT_CRITICAL => 0, self::IMPACT_SERIOUS => 0, self::IMPACT_MODERATE => 0, self::IMPACT_MINOR => 0]; + + foreach ($pages as $url => $page) { + foreach ($page['violations'] ?? [] as $violation) { + $impact = (string) ($violation['impact'] ?? self::IMPACT_MINOR); + $totals[$impact] = ($totals[$impact] ?? 0) + 1; + + $rule_id = (string) ($violation['id'] ?? 'unknown'); + + if (!isset($rules[$rule_id])) { + $rules[$rule_id] = [ + 'impact' => $impact, + 'help' => (string) ($violation['help'] ?? ''), + 'helpUrl' => (string) ($violation['helpUrl'] ?? ''), + 'pages' => [], + 'nodes' => [], + ]; + } + + $rules[$rule_id]['pages'][$url] = TRUE; + + foreach ($violation['nodes'] ?? [] as $node) { + $rules[$rule_id]['nodes'][] = [ + 'url' => (string) $url, + 'target' => static::accessibilityStringifyTarget($node['target'] ?? []), + 'html' => trim((string) ($node['html'] ?? '')), + ]; + } + } + } + + uasort($rules, function (array $a, array $b) use ($rank): int { + $by_impact = ($rank[$a['impact']] ?? 9) <=> ($rank[$b['impact']] ?? 9); + + return $by_impact !== 0 ? $by_impact : count($b['nodes']) <=> count($a['nodes']); + }); + + return ['rules' => $rules, 'totals' => $totals]; + } + + /** + * Render the summary cards. + * + * @param int $total_violations + * Total violation count across all pages. + * @param int $page_count + * Number of unique pages assessed. + * @param int $scenario_count + * Number of scenarios that produced results. + * @param array $totals + * Violation counts keyed by impact level. + */ + protected static function accessibilityRenderAggregateSummary(int $total_violations, int $page_count, int $scenario_count, array $totals): string { + $state = $total_violations > 0 ? 'fail' : 'ok'; + + $card = fn(string $class, int $num, string $label): string => sprintf('
%d%s
', $class, $num, $label); + + return '
' + . $card('', $page_count, 'pages assessed') + . $card('', $scenario_count, 'scenarios') + . $card($state, $total_violations, 'violations') + . $card('crit', $totals[self::IMPACT_CRITICAL] ?? 0, 'critical') + . $card('ser', $totals[self::IMPACT_SERIOUS] ?? 0, 'serious') + . $card('mod', $totals[self::IMPACT_MODERATE] ?? 0, 'moderate') + . $card('min', $totals[self::IMPACT_MINOR] ?? 0, 'minor') + . '
'; + } + + /** + * Render the de-duplicated table of assessed pages. + * + * @param array> $pages + * Per-URL rollup. + */ + protected static function accessibilityRenderAggregatePages(array $pages): string { + $rows = ''; + + foreach ($pages as $url => $page) { + $count = count($page['violations'] ?? []); + $scenarios = implode(', ', array_keys($page['scenarios'] ?? [])); + $rows .= sprintf( + '%s%s%d%d%s', + $count > 0 ? 'bad' : 'good', + htmlspecialchars((string) $url, ENT_QUOTES), + static::accessibilityRenderAggregateRuleChips($page['violations'] ?? []), + $page['incomplete'] ?? 0, + $page['passes'] ?? 0, + htmlspecialchars($scenarios, ENT_QUOTES) + ); + } + + return '

Pages assessed

Each URL is listed once, even when several scenarios visit it. Violations are broken down by rule, with the number of affected elements.

' + . '' + . $rows . '
URLViolations by typeIncompletePassesSeen in scenarios
'; + } + + /** + * Render per-rule violation chips for a page, each with its element count. + * + * @param array> $violations + * Normalized violations for a single page. + */ + protected static function accessibilityRenderAggregateRuleChips(array $violations): string { + if ($violations === []) { + return ''; + } + + $chips = ''; + + foreach ($violations as $violation) { + $impact = strtolower((string) ($violation['impact'] ?? self::IMPACT_MINOR)); + $chips .= sprintf( + '%s %d', + htmlspecialchars($impact, ENT_QUOTES), + htmlspecialchars((string) ($violation['id'] ?? 'unknown'), ENT_QUOTES), + count($violation['nodes'] ?? []) + ); + } + + return $chips; + } + + /** + * Render the violations grouped by rule. + * + * @param array> $rules + * Per-rule rollup, pre-sorted by severity. + */ + protected static function accessibilityRenderAggregateRules(array $rules): string { + if ($rules === []) { + return '

Violations by rule

No violations found.

'; + } + + $blocks = ''; + + foreach ($rules as $rule_id => $rule) { + $nodes = ''; + foreach ($rule['nodes'] ?? [] as $node) { + $nodes .= sprintf( + '
%s · %s
%s
', + htmlspecialchars((string) $node['target'], ENT_QUOTES), + htmlspecialchars((string) $node['url'], ENT_QUOTES), + htmlspecialchars((string) $node['html'], ENT_QUOTES) + ); + } + + $impact = (string) $rule['impact']; + $blocks .= sprintf( + '

%s %s

' + . '

%s · affects %d page(s) · %d element(s) · docs

%s
', + htmlspecialchars($impact, ENT_QUOTES), + htmlspecialchars($impact, ENT_QUOTES), + htmlspecialchars((string) $rule_id, ENT_QUOTES), + htmlspecialchars((string) $rule['help'], ENT_QUOTES), + count($rule['pages'] ?? []), + count($rule['nodes'] ?? []), + htmlspecialchars((string) $rule['helpUrl'], ENT_QUOTES), + $nodes + ); + } + + return '

Violations by rule

Highest impact first.

' . $blocks . '
'; + } + + /** + * Render each scenario's full findings inline, in the order pages were seen. + * + * Mirrors the per-scenario report: a scenario heading with its threshold, + * then per page the rules string, the counts, and the violation and + * incomplete lists with the same fields. + * + * @param array> $aggregate + * The accumulated per-scenario results. + */ + protected static function accessibilityRenderAggregateScenarios(array $aggregate): string { + $blank = static::accessibilityBlankUrls(); + $blocks = ''; + + foreach ($aggregate as $entry) { + $sections = ''; + + foreach ($entry['results'] ?? [] as $result) { + $url = (string) ($result['url'] ?? ''); + + if (in_array($url, $blank, TRUE)) { + continue; + } + + $violations = $result['result']['violations'] ?? []; + $incomplete = $result['result']['incomplete'] ?? []; + + $sections .= sprintf( + '

%s

Rules: %s · %d violations · %d incomplete · %d passes

%s%s
', + htmlspecialchars($url, ENT_QUOTES), + htmlspecialchars((string) ($result['rules'] ?? ''), ENT_QUOTES), + count($violations), + count($incomplete), + count($result['result']['passes'] ?? []), + static::accessibilityRenderAggregateIssueList('Violations', 'violation', $violations), + static::accessibilityRenderAggregateIssueList('Incomplete (needs human review)', 'incomplete', $incomplete) + ); + } + + $blocks .= sprintf( + '

%s %s

threshold: %s · fail on incomplete: %s

%s
', + htmlspecialchars((string) ($entry['scenario'] ?? ''), ENT_QUOTES), + htmlspecialchars((string) ($entry['feature'] ?? ''), ENT_QUOTES), + htmlspecialchars((string) ($entry['threshold'] ?? ''), ENT_QUOTES), + ($entry['failOnIncomplete'] ?? FALSE) ? 'yes' : 'no', + $sections + ); + } + + return '

Per-scenario detail

Every page each scenario assessed, in order, with its full findings embedded.

' . $blocks . '
'; + } + + /** + * Render a single issue list (violations or incomplete) for the aggregate. + * + * Matches the per-scenario report fields: impact, rule id, help text, docs + * link, and the target and HTML of each offending element. + * + * @param string $heading + * Section heading. + * @param string $css_class + * CSS class applied to each issue (`violation` or `incomplete`). + * @param array> $issues + * Normalized issues to render. + */ + protected static function accessibilityRenderAggregateIssueList(string $heading, string $css_class, array $issues): string { + if ($issues === []) { + return sprintf('
%s

None.

', htmlspecialchars($heading, ENT_QUOTES)); + } + + $out = sprintf('
%s
', htmlspecialchars($heading, ENT_QUOTES)); + + foreach ($issues as $issue) { + $impact = strtolower((string) ($issue['impact'] ?? 'unknown')); + $impact_safe = htmlspecialchars($impact, ENT_QUOTES); + $id = htmlspecialchars((string) ($issue['id'] ?? ''), ENT_QUOTES); + $help = htmlspecialchars((string) ($issue['help'] ?? ''), ENT_QUOTES); + $help_url = htmlspecialchars((string) ($issue['helpUrl'] ?? ''), ENT_QUOTES); + + $out .= sprintf('
%s%s — %s', htmlspecialchars($css_class, ENT_QUOTES), $impact_safe, $impact_safe, $id, $help); + + if ($help_url !== '') { + $out .= sprintf(' (docs)', $help_url); + } + + foreach ($issue['nodes'] ?? [] as $node) { + $target = htmlspecialchars(static::accessibilityStringifyTarget($node['target'] ?? []), ENT_QUOTES); + $html = htmlspecialchars(trim((string) ($node['html'] ?? '')), ENT_QUOTES); + $out .= sprintf('
%s
%s
', $target, $html); + } + + $out .= '
'; + } + + return $out; + } + + /** + * Wrap the rendered sections in a standalone HTML document. + * + * Default: a self-contained page with the aggregate's own styles. Override + * to brand the report without rebuilding the section markup - the caller + * supplies it as `$body`. + * + * @param string $generated + * Human-readable generation timestamp. + * @param string $body + * Pre-rendered body sections. + */ + protected static function accessibilityRenderAggregatePage(string $generated, string $body): string { + return << + + + +Accessibility report - aggregate + + + +

Accessibility report - aggregate

+

One page summarising every accessibility assessment in the run · generated {$generated}

+{$body} + + +HTML; + } + } diff --git a/tests/behat/features/accessibility.feature b/tests/behat/features/accessibility.feature index bc866142..8a4a8cf3 100644 --- a/tests/behat/features/accessibility.feature +++ b/tests/behat/features/accessibility.feature @@ -86,3 +86,17 @@ Feature: Check that AccessibilityTrait works """ violation [critical] image-alt """ + + @trait:AccessibilityTrait + Scenario: A cross-page aggregate report is written after the suite + Given some behat configuration + And scenario steps tagged with "@javascript @accessibility-warning": + """ + Given I visit "/sites/default/files/accessibility_violations.html" + Then I should see "Inaccessible Page" + When I visit "/sites/default/files/accessibility_clean.html" + Then I should see "Clean Accessibility Page" + """ + When I run "behat --no-colors" + Then it should pass + And file ".logs/test_results/accessibility/index.html" should exist diff --git a/tests/phpunit/src/AccessibilityTraitTest.php b/tests/phpunit/src/AccessibilityTraitTest.php index 7e774453..87aef396 100644 --- a/tests/phpunit/src/AccessibilityTraitTest.php +++ b/tests/phpunit/src/AccessibilityTraitTest.php @@ -27,6 +27,7 @@ protected function setUp(): void { $this->testObject = new AccessibilityTraitTestImplementation(); AccessibilityTraitTestImplementation::testSetBaseDir(NULL); + AccessibilityTraitTestImplementation::accessibilityAggregateReset(); } /** @@ -34,6 +35,7 @@ protected function setUp(): void { */ protected function tearDown(): void { AccessibilityTraitTestImplementation::testSetBaseDir(NULL); + AccessibilityTraitTestImplementation::accessibilityAggregateReset(); parent::tearDown(); } @@ -52,6 +54,7 @@ public static function dataProviderFormatUrl(): array { 'base root without trailing slash' => ['http://nginx:8080', 'http://nginx:8080', '/'], 'nested path' => ['http://nginx:8080', 'http://nginx:8080/a/b/c', '/a/b/c'], 'query string preserved' => ['http://nginx:8080', 'http://nginx:8080/search?q=x', '/search?q=x'], + 'fragment preserved' => ['http://nginx:8080', 'http://nginx:8080/page#main', '/page#main'], 'base configured with trailing slash' => ['http://nginx:8080/', 'http://nginx:8080/contact', '/contact'], 'cross-origin kept absolute' => ['http://nginx:8080', 'https://example.com/page', 'https://example.com/page'], 'similar prefix not stripped' => ['http://nginx:8080', 'http://nginx:8080extra/foo', 'http://nginx:8080extra/foo'], @@ -101,6 +104,287 @@ public function testCaptureBaseDirDoesNotOverwrite(): void { $this->assertSame('/sentinel/base', AccessibilityTraitTestImplementation::testGetBaseDir()); } + #[DataProvider('dataProviderAggregateHtmlContains')] + public function testAggregateHtmlContains(string $expected): void { + $html = AccessibilityTraitTestImplementation::testRenderAggregateHtml(static::createSampleAggregate(), '2026-01-02 03:04'); + + $this->assertStringContainsString($expected, $html); + } + + public static function dataProviderAggregateHtmlContains(): array { + return [ + 'aggregate title' => ['

Accessibility report - aggregate

'], + 'generation timestamp passed in by the writer' => ['generated 2026-01-02 03:04'], + 'seven summary cards in the specified order with counts' => [ + '
' + . '
2pages assessed
' + . '
2scenarios
' + . '
3violations
' + . '
2critical
' + . '
1serious
' + . '
0moderate
' + . '
0minor
' + . '
', + ], + 'critical chip carries its affected-element count' => ['image-alt 2'], + 'serious chip carries its affected-element count' => ['color-contrast 1'], + 'second page chip' => ['button-name 1'], + 'a deduplicated URL lists every visiting scenario' => ['Home page, Contact page'], + 'rule rollup links to the axe docs' => ['href="https://dequeuniversity.com/rules/axe/4.11/image-alt"'], + 'rule rollup reports affected pages and elements' => ['affects 1 page(s) · 2 element(s)'], + 'scenario detail shows the default threshold' => ['threshold: any'], + 'scenario detail shows the overridden threshold' => ['threshold: critical'], + 'scenario detail shows fail-on-incomplete' => ['fail on incomplete: yes'], + 'scenario detail shows the rules string' => ['Rules: wcag2a,wcag2aa'], + 'scenario detail shows the page counts' => ['2 violations · 1 incomplete · 3 passes'], + 'scenario detail has a violations heading' => ['
Violations
'], + 'scenario detail has an incomplete heading' => ['
Incomplete (needs human review)
'], + 'scenario detail renders the incomplete finding' => ['aria-valid-attr'], + 'scenario detail renders an element target' => ['img.logo'], + 'scenario detail renders the escaped element HTML' => ['<img src="logo.png">'], + ]; + } + + public function testAggregateHtmlSortsRulesBySeverityThenElementCount(): void { + $html = AccessibilityTraitTestImplementation::testRenderAggregateHtml(static::createSampleAggregate(), '2026-01-02 03:04'); + + $image_alt = strpos($html, 'image-alt'); + $button_name = strpos($html, 'button-name'); + $color_contrast = strpos($html, 'color-contrast'); + + // Both critical rules precede the serious one, and within the critical + // group the rule with more affected elements (image-alt: 2) precedes the + // one with fewer (button-name: 1). + $this->assertNotFalse($image_alt); + $this->assertNotFalse($button_name); + $this->assertNotFalse($color_contrast); + $this->assertLessThan($button_name, $image_alt); + $this->assertLessThan($color_contrast, $button_name); + } + + public function testAggregateHtmlUsesPathOnlyUrls(): void { + $html = AccessibilityTraitTestImplementation::testRenderAggregateHtml(static::createSampleAggregate(), '2026-01-02 03:04'); + + $this->assertStringContainsString('/contact', $html); + // No assessed-page URL retains a scheme + host + port. + $this->assertDoesNotMatchRegularExpression('#://[^/"\s]+:\d+#', $html); + } + + public function testAggregatePagesDeduplicatesAndSkipsBlankUrls(): void { + $pages = AccessibilityTraitTestImplementation::testAggregatePages(static::createSampleAggregate()); + + $this->assertSame(['/', '/contact'], array_keys($pages)); + $this->assertSame(['Home page', 'Contact page'], array_keys($pages['/']['scenarios'])); + $this->assertCount(2, $pages['/']['violations']); + $this->assertSame(1, $pages['/']['incomplete']); + $this->assertSame(3, $pages['/']['passes']); + $this->assertSame(['Contact page'], array_keys($pages['/contact']['scenarios'])); + } + + public function testAggregateRollupTalliesAndSortsBySeverity(): void { + $pages = AccessibilityTraitTestImplementation::testAggregatePages(static::createSampleAggregate()); + $rollup = AccessibilityTraitTestImplementation::testAggregateRollup($pages); + + $this->assertSame(['critical' => 2, 'serious' => 1, 'moderate' => 0, 'minor' => 0], $rollup['totals']); + $this->assertSame(['image-alt', 'button-name', 'color-contrast'], array_keys($rollup['rules'])); + $this->assertCount(2, $rollup['rules']['image-alt']['nodes']); + $this->assertSame(['/'], array_keys($rollup['rules']['image-alt']['pages'])); + } + + public function testWriteAggregateReportWritesIndexHtml(): void { + $dir = static::locationsTmp() . DIRECTORY_SEPARATOR . 'aggregate-report'; + AccessibilityTraitTestImplementation::testSetAggregate(static::createSampleAggregate()); + AccessibilityTraitTestImplementation::testSetAggregateReportDir($dir); + + // First call creates the directory; second call finds it already present. + AccessibilityTraitTestImplementation::testWriteAggregateReport(); + AccessibilityTraitTestImplementation::testWriteAggregateReport(); + + $file = $dir . '/index.html'; + $this->assertFileExists($file); + $this->assertStringContainsString('Accessibility report - aggregate', (string) file_get_contents($file)); + } + + public function testWriteAggregateReportDoesNothingWhenEmpty(): void { + $dir = static::locationsTmp() . DIRECTORY_SEPARATOR . 'aggregate-empty'; + AccessibilityTraitTestImplementation::testSetAggregate([]); + AccessibilityTraitTestImplementation::testSetAggregateReportDir($dir); + + AccessibilityTraitTestImplementation::testWriteAggregateReport(); + + $this->assertFileDoesNotExist($dir . '/index.html'); + } + + public function testAggregateRenderHookWritesReport(): void { + $dir = static::locationsTmp() . DIRECTORY_SEPARATOR . 'aggregate-hook'; + AccessibilityTraitTestImplementation::testSetAggregate(static::createSampleAggregate()); + AccessibilityTraitTestImplementation::testSetAggregateReportDir($dir); + + AccessibilityTraitTestImplementation::accessibilityAggregateRender(); + + $this->assertFileExists($dir . '/index.html'); + } + + public function testAggregateHtmlRendersCleanRunWithoutViolations(): void { + $html = AccessibilityTraitTestImplementation::testRenderAggregateHtml(static::createCleanAggregate(), '2026-01-02 03:04'); + + $this->assertStringContainsString('
0violations
', $html); + $this->assertStringContainsString('No violations found.', $html); + $this->assertStringContainsString('', $html); + } + + public function testAggregateResetClearsState(): void { + AccessibilityTraitTestImplementation::testSetAggregate(static::createSampleAggregate()); + AccessibilityTraitTestImplementation::testSetAggregateReportDir('/sentinel'); + + AccessibilityTraitTestImplementation::accessibilityAggregateReset(); + + $this->assertSame([], AccessibilityTraitTestImplementation::testGetAggregate()); + $this->assertNull(AccessibilityTraitTestImplementation::testGetAggregateReportDir()); + } + + public function testAggregateCaptureFormatsUrlsAndRecordsEntry(): void { + $this->testObject->baseUrl = 'http://nginx:8080'; + + $this->testObject->testCapture( + [['url' => 'http://nginx:8080/contact', 'rules' => 'wcag2a', 'result' => ['violations' => [], 'incomplete' => [], 'passes' => []]]], + 'My feature', + 'My scenario', + '/captured/dir' + ); + + $aggregate = AccessibilityTraitTestImplementation::testGetAggregate(); + $this->assertCount(1, $aggregate); + $this->assertSame('/contact', $aggregate[0]['results'][0]['url']); + $this->assertSame('My feature', $aggregate[0]['feature']); + $this->assertSame('My scenario', $aggregate[0]['scenario']); + $this->assertSame('any', $aggregate[0]['threshold']); + $this->assertFalse($aggregate[0]['failOnIncomplete']); + $this->assertSame('/captured/dir', AccessibilityTraitTestImplementation::testGetAggregateReportDir()); + } + + /** + * Build a representative accumulator: two scenarios, a shared URL, a blank + * tab, and mixed-impact findings. + * + * @return array> + * Sample aggregate data in the shape produced by accessibilityAggregateCapture(). + */ + protected static function createSampleAggregate(): array { + $image_alt = [ + 'id' => 'image-alt', + 'impact' => 'critical', + 'help' => 'Images must have alternate text', + 'helpUrl' => 'https://dequeuniversity.com/rules/axe/4.11/image-alt', + 'nodes' => [ + ['target' => ['img.logo'], 'html' => ''], + ['target' => ['img.hero'], 'html' => ''], + ], + ]; + $color_contrast = [ + 'id' => 'color-contrast', + 'impact' => 'serious', + 'help' => 'Elements must have sufficient colour contrast', + 'helpUrl' => 'https://dequeuniversity.com/rules/axe/4.11/color-contrast', + 'nodes' => [ + ['target' => ['a.muted'], 'html' => 'link'], + ], + ]; + $aria_incomplete = [ + 'id' => 'aria-valid-attr', + 'impact' => 'moderate', + 'help' => 'ARIA attributes must be valid', + 'helpUrl' => 'https://dequeuniversity.com/rules/axe/4.11/aria-valid-attr', + 'nodes' => [ + ['target' => ['div#widget'], 'html' => '
'], + ], + ]; + $button_name = [ + 'id' => 'button-name', + 'impact' => 'critical', + 'help' => 'Buttons must have discernible text', + 'helpUrl' => 'https://dequeuniversity.com/rules/axe/4.11/button-name', + 'nodes' => [ + ['target' => ['button'], 'html' => ''], + ], + ]; + + return [ + [ + 'feature' => 'Homepage', + 'scenario' => 'Home page', + 'threshold' => 'any', + 'failOnIncomplete' => FALSE, + 'results' => [ + [ + 'url' => '/', + 'rules' => 'wcag2a,wcag2aa', + 'result' => [ + 'violations' => [$image_alt, $color_contrast], + 'incomplete' => [$aria_incomplete], + 'passes' => [['id' => 'document-title'], ['id' => 'html-lang'], ['id' => 'region']], + ], + ], + ], + ], + [ + 'feature' => 'Contact', + 'scenario' => 'Contact page', + 'threshold' => 'critical', + 'failOnIncomplete' => TRUE, + 'results' => [ + [ + 'url' => '/', + 'rules' => 'wcag2a', + 'result' => [ + 'violations' => [$image_alt, $color_contrast], + 'incomplete' => [], + 'passes' => [['id' => 'document-title']], + ], + ], + [ + 'url' => '/contact', + 'rules' => 'wcag2a', + 'result' => [ + 'violations' => [$button_name], + 'incomplete' => [], + 'passes' => [['id' => 'document-title']], + ], + ], + [ + 'url' => 'about:blank', + 'rules' => 'wcag2a', + 'result' => ['violations' => [], 'incomplete' => [], 'passes' => []], + ], + ], + ], + ]; + } + + /** + * Build an accumulator for a run that found no violations at all. + * + * @return array> + * Sample aggregate data with a single clean page. + */ + protected static function createCleanAggregate(): array { + return [ + [ + 'feature' => 'Homepage', + 'scenario' => 'Clean home', + 'threshold' => 'any', + 'failOnIncomplete' => FALSE, + 'results' => [ + [ + 'url' => '/', + 'rules' => 'wcag2a', + 'result' => ['violations' => [], 'incomplete' => [], 'passes' => [['id' => 'document-title']]], + ], + ], + ], + ]; + } + } /** @@ -135,4 +419,43 @@ public static function testGetBaseDir(): ?string { return self::$accessibilityBaseDir; } + public static function testSetAggregate(array $aggregate): void { + self::$accessibilityAggregate = $aggregate; + } + + public static function testGetAggregate(): array { + return self::$accessibilityAggregate; + } + + public static function testSetAggregateReportDir(?string $dir): void { + self::$accessibilityAggregateReportDir = $dir; + } + + public static function testGetAggregateReportDir(): ?string { + return self::$accessibilityAggregateReportDir; + } + + public static function testRenderAggregateHtml(array $aggregate, string $generated): string { + return static::accessibilityRenderAggregateHtml($aggregate, $generated); + } + + public static function testAggregatePages(array $aggregate): array { + return static::accessibilityAggregatePages($aggregate); + } + + public static function testAggregateRollup(array $pages): array { + return static::accessibilityAggregateRollup($pages); + } + + public static function testWriteAggregateReport(): void { + static::accessibilityWriteAggregateReport(); + } + + public function testCapture(array $results, string $feature, string $scenario, string $dir): void { + $this->accessibilityResults = $results; + $this->accessibilityFeatureName = $feature; + $this->accessibilityScenarioName = $scenario; + $this->accessibilityAggregateCapture($dir); + } + } From b76ace1bff1a135e11321b7a2e303c14ad516b76 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 20 Jun 2026 12:23:07 +1000 Subject: [PATCH 2/8] [#658] Fixed array line length and docblock short description. --- src/AccessibilityTrait.php | 7 ++++++- tests/phpunit/src/AccessibilityTraitTest.php | 3 +-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/AccessibilityTrait.php b/src/AccessibilityTrait.php index b30162c9..308720ba 100644 --- a/src/AccessibilityTrait.php +++ b/src/AccessibilityTrait.php @@ -1088,7 +1088,12 @@ protected static function accessibilityAggregatePages(array $aggregate): array { protected static function accessibilityAggregateRollup(array $pages): array { $rank = [self::IMPACT_CRITICAL => 0, self::IMPACT_SERIOUS => 1, self::IMPACT_MODERATE => 2, self::IMPACT_MINOR => 3]; $rules = []; - $totals = [self::IMPACT_CRITICAL => 0, self::IMPACT_SERIOUS => 0, self::IMPACT_MODERATE => 0, self::IMPACT_MINOR => 0]; + $totals = [ + self::IMPACT_CRITICAL => 0, + self::IMPACT_SERIOUS => 0, + self::IMPACT_MODERATE => 0, + self::IMPACT_MINOR => 0, + ]; foreach ($pages as $url => $page) { foreach ($page['violations'] ?? [] as $violation) { diff --git a/tests/phpunit/src/AccessibilityTraitTest.php b/tests/phpunit/src/AccessibilityTraitTest.php index 87aef396..f3ad0787 100644 --- a/tests/phpunit/src/AccessibilityTraitTest.php +++ b/tests/phpunit/src/AccessibilityTraitTest.php @@ -264,8 +264,7 @@ public function testAggregateCaptureFormatsUrlsAndRecordsEntry(): void { } /** - * Build a representative accumulator: two scenarios, a shared URL, a blank - * tab, and mixed-impact findings. + * Build a representative accumulator with two scenarios, a shared URL, a blank tab, and mixed-impact findings. * * @return array> * Sample aggregate data in the shape produced by accessibilityAggregateCapture(). From c0437430ff042d2b9959b5c91a0c84d3329cfe38 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 20 Jun 2026 12:50:36 +1000 Subject: [PATCH 3/8] [#658] Removed redundant casts and switched rule nodes to array accumulation. --- src/AccessibilityTrait.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/AccessibilityTrait.php b/src/AccessibilityTrait.php index 308720ba..01ee1dc7 100644 --- a/src/AccessibilityTrait.php +++ b/src/AccessibilityTrait.php @@ -893,7 +893,7 @@ protected function accessibilityRenderJunit(): string { $cases_xml .= sprintf( '%s', htmlspecialchars($rule_id, ENT_XML1 | ENT_QUOTES), - htmlspecialchars((string) $target ?: $rule_id, ENT_XML1 | ENT_QUOTES), + htmlspecialchars($target ?: $rule_id, ENT_XML1 | ENT_QUOTES), htmlspecialchars($impact, ENT_XML1 | ENT_QUOTES), htmlspecialchars($message, ENT_XML1 | ENT_QUOTES), htmlspecialchars($details, ENT_XML1 | ENT_QUOTES) @@ -1030,7 +1030,7 @@ protected static function accessibilityRenderAggregateSections(array $aggregate) $rollup = static::accessibilityAggregateRollup($pages); $totals = $rollup['totals']; - return static::accessibilityRenderAggregateSummary((int) array_sum($totals), count($pages), count($aggregate), $totals) + return static::accessibilityRenderAggregateSummary(array_sum($totals), count($pages), count($aggregate), $totals) . static::accessibilityRenderAggregatePages($pages) . static::accessibilityRenderAggregateRules($rollup['rules']) . static::accessibilityRenderAggregateScenarios($aggregate); @@ -1229,9 +1229,9 @@ protected static function accessibilityRenderAggregateRules(array $rules): strin $blocks = ''; foreach ($rules as $rule_id => $rule) { - $nodes = ''; + $nodes = []; foreach ($rule['nodes'] ?? [] as $node) { - $nodes .= sprintf( + $nodes[] = sprintf( '
%s · %s
%s
', htmlspecialchars((string) $node['target'], ENT_QUOTES), htmlspecialchars((string) $node['url'], ENT_QUOTES), @@ -1250,7 +1250,7 @@ protected static function accessibilityRenderAggregateRules(array $rules): strin count($rule['pages'] ?? []), count($rule['nodes'] ?? []), htmlspecialchars((string) $rule['helpUrl'], ENT_QUOTES), - $nodes + implode('', $nodes) ); } From 2ab974476b16e2769c1465479e693d00b9305281 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 20 Jun 2026 13:12:50 +1000 Subject: [PATCH 4/8] [#658] Omitted the docs link in the rule rollup when the help URL is empty. --- src/AccessibilityTrait.php | 6 +++-- tests/phpunit/src/AccessibilityTraitTest.php | 27 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/AccessibilityTrait.php b/src/AccessibilityTrait.php index 01ee1dc7..ebcbf8ef 100644 --- a/src/AccessibilityTrait.php +++ b/src/AccessibilityTrait.php @@ -1240,16 +1240,18 @@ protected static function accessibilityRenderAggregateRules(array $rules): strin } $impact = (string) $rule['impact']; + $help_url = (string) $rule['helpUrl']; + $docs = $help_url !== '' ? sprintf(' · docs', htmlspecialchars($help_url, ENT_QUOTES)) : ''; $blocks .= sprintf( '

%s %s

' - . '

%s · affects %d page(s) · %d element(s) · docs

%s
', + . '

%s · affects %d page(s) · %d element(s)%s

%s', htmlspecialchars($impact, ENT_QUOTES), htmlspecialchars($impact, ENT_QUOTES), htmlspecialchars((string) $rule_id, ENT_QUOTES), htmlspecialchars((string) $rule['help'], ENT_QUOTES), count($rule['pages'] ?? []), count($rule['nodes'] ?? []), - htmlspecialchars((string) $rule['helpUrl'], ENT_QUOTES), + $docs, implode('', $nodes) ); } diff --git a/tests/phpunit/src/AccessibilityTraitTest.php b/tests/phpunit/src/AccessibilityTraitTest.php index f3ad0787..b2369671 100644 --- a/tests/phpunit/src/AccessibilityTraitTest.php +++ b/tests/phpunit/src/AccessibilityTraitTest.php @@ -233,6 +233,33 @@ public function testAggregateHtmlRendersCleanRunWithoutViolations(): void { $this->assertStringContainsString('', $html); } + public function testAggregateRulesOmitDocsLinkWhenHelpUrlEmpty(): void { + $aggregate = [ + [ + 'feature' => 'Homepage', + 'scenario' => 'Home page', + 'threshold' => 'any', + 'failOnIncomplete' => FALSE, + 'results' => [ + [ + 'url' => '/', + 'rules' => 'wcag2a', + 'result' => [ + 'violations' => [['id' => 'custom-rule', 'impact' => 'serious', 'help' => 'A rule with no docs URL', 'helpUrl' => '', 'nodes' => [['target' => ['div'], 'html' => '
']]]], + 'incomplete' => [], + 'passes' => [], + ], + ], + ], + ], + ]; + + $html = AccessibilityTraitTestImplementation::testRenderAggregateHtml($aggregate, '2026-01-02 03:04'); + + $this->assertStringContainsString('custom-rule', $html); + $this->assertStringNotContainsString('href=""', $html); + } + public function testAggregateResetClearsState(): void { AccessibilityTraitTestImplementation::testSetAggregate(static::createSampleAggregate()); AccessibilityTraitTestImplementation::testSetAggregateReportDir('/sentinel'); From 35eb23cd6e173899575b98273567bdeca43ade05 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 20 Jun 2026 13:53:43 +1000 Subject: [PATCH 5/8] [#658] Timestamped the aggregate report filename and grouped trait methods by concern. --- STEPS.md | 10 +- src/AccessibilityTrait.php | 99 ++++++++++++-------- tests/behat/bootstrap/BehatCliContext.php | 13 +++ tests/behat/features/accessibility.feature | 2 +- tests/phpunit/src/AccessibilityTraitTest.php | 24 +++-- 5 files changed, 97 insertions(+), 51 deletions(-) diff --git a/STEPS.md b/STEPS.md index d5f15261..96402290 100644 --- a/STEPS.md +++ b/STEPS.md @@ -80,10 +80,12 @@ > (remap raw output into the canonical shape the rest of the trait expects). >

> Reporting. Each scenario writes its own HTML and JUnit report. After the -> whole suite, a single cross-page `index.html` is written to the same -> directory, de-duplicating every assessed page and rolling violations up by -> rule. The aggregate accumulates in process-global state, so under parallel -> Behat each process writes its own `index.html`. +> whole suite, a single cross-page `accessibility_report_.html` +> (timestamp `YYYYMMDD_HHMMSS`) is written to the same directory, +> de-duplicating every assessed page and rolling violations up by rule. One +> file is written per run, so a run never overwrites a previous one. The +> aggregate accumulates in process-global state, so under parallel Behat each +> process writes its own report.
diff --git a/src/AccessibilityTrait.php b/src/AccessibilityTrait.php index ebcbf8ef..c24767fa 100644 --- a/src/AccessibilityTrait.php +++ b/src/AccessibilityTrait.php @@ -35,10 +35,12 @@ * (remap raw output into the canonical shape the rest of the trait expects). * * Reporting. Each scenario writes its own HTML and JUnit report. After the - * whole suite, a single cross-page `index.html` is written to the same - * directory, de-duplicating every assessed page and rolling violations up by - * rule. The aggregate accumulates in process-global state, so under parallel - * Behat each process writes its own `index.html`. + * whole suite, a single cross-page `accessibility_report_.html` + * (timestamp `YYYYMMDD_HHMMSS`) is written to the same directory, + * de-duplicating every assessed page and rolling violations up by rule. One + * file is written per run, so a run never overwrites a previous one. The + * aggregate accumulates in process-global state, so under parallel Behat each + * process writes its own report. */ trait AccessibilityTrait { @@ -153,6 +155,18 @@ public static function accessibilityCaptureBaseDir(): void { } } + /** + * Clear the suite-level aggregate state before the suite runs. + * + * The accumulator is process-global, so resetting at suite start stops a + * second suite in the same process from inheriting the first one's results. + */ + #[BeforeSuite] + public static function accessibilityAggregateReset(): void { + self::$accessibilityAggregate = []; + self::$accessibilityAggregateReportDir = NULL; + } + /** * Initialize accessibility state for the scenario. */ @@ -264,6 +278,14 @@ public function accessibilityFinalizeScenario(AfterScenarioScope $scope): void { } } + /** + * Render the single cross-page report after the whole suite has run. + */ + #[AfterSuite] + public static function accessibilityAggregateRender(): void { + static::accessibilityWriteAggregateReport(); + } + /** * Assert that the current page passes accessibility checks. * @@ -725,6 +747,19 @@ protected function accessibilityFormatUrl(string $url): string { return $url; } + /** + * Return URL values that represent a blank tab rather than a real page. + * + * Shared by the per-step auto assessment (so a blank tab never enters the + * results) and the aggregate renderer (defence in depth). Override to extend. + * + * @return array + * URL values to ignore. + */ + protected static function accessibilityBlankUrls(): array { + return ['', 'about:blank', 'data:,']; + } + /** * Render the scenario-level HTML report from collected results. * @@ -918,31 +953,6 @@ protected function accessibilityRenderJunit(): string { ); } - /** - * Return URL values that represent a blank tab rather than a real page. - * - * Shared by the per-step auto assessment (so a blank tab never enters the - * results) and the aggregate renderer (defence in depth). Override to extend. - * - * @return array - * URL values to ignore. - */ - protected static function accessibilityBlankUrls(): array { - return ['', 'about:blank', 'data:,']; - } - - /** - * Clear the suite-level aggregate state before the suite runs. - * - * The accumulator is process-global, so resetting at suite start stops a - * second suite in the same process from inheriting the first one's results. - */ - #[BeforeSuite] - public static function accessibilityAggregateReset(): void { - self::$accessibilityAggregate = []; - self::$accessibilityAggregateReportDir = NULL; - } - /** * Record the scenario's formatted results for the suite-level aggregate. * @@ -975,19 +985,13 @@ protected function accessibilityAggregateCapture(string $dir): void { ]; } - /** - * Render the single cross-page report after the whole suite has run. - */ - #[AfterSuite] - public static function accessibilityAggregateRender(): void { - static::accessibilityWriteAggregateReport(); - } - /** * Write the aggregate report when at least one scenario produced results. * - * The timestamp is resolved here and passed into the renderer so the render - * methods stay deterministic for tests. + * One timestamped file is written per suite run, so a run never overwrites a + * previous one. The same timestamp drives the filename and the in-page + * "generated" line; it is resolved here so the render methods stay + * deterministic for tests. */ protected static function accessibilityWriteAggregateReport(): void { if (self::$accessibilityAggregate === []) { @@ -999,7 +1003,22 @@ protected static function accessibilityWriteAggregateReport(): void { mkdir($dir, 0777, TRUE); } - file_put_contents($dir . '/index.html', static::accessibilityRenderAggregateHtml(self::$accessibilityAggregate, date('Y-m-d H:i'))); + $time = time(); + $report = static::accessibilityRenderAggregateHtml(self::$accessibilityAggregate, date('Y-m-d H:i', $time)); + file_put_contents($dir . DIRECTORY_SEPARATOR . static::accessibilityAggregateFilename($time), $report); + } + + /** + * Build the timestamped aggregate report filename. + * + * @param int $time + * Unix timestamp the report was generated at. + * + * @return string + * Filename of the form `accessibility_report_YYYYMMDD_HHMMSS.html`. + */ + protected static function accessibilityAggregateFilename(int $time): string { + return 'accessibility_report_' . date('Ymd_His', $time) . '.html'; } /** diff --git a/tests/behat/bootstrap/BehatCliContext.php b/tests/behat/bootstrap/BehatCliContext.php index 62421f4d..6a7ae603 100644 --- a/tests/behat/bootstrap/BehatCliContext.php +++ b/tests/behat/bootstrap/BehatCliContext.php @@ -202,6 +202,19 @@ public function fileShouldExist($path) Assert::assertFileExists($this->workingDir . DIRECTORY_SEPARATOR . $path); } + /** + * Checks whether at least one file matching a glob pattern exists. + * + * @Given /^a file matching "([^"]*)" should exist$/ + * + * @param string $pattern + */ + public function fileMatchingShouldExist($pattern) + { + $matches = glob($this->workingDir . DIRECTORY_SEPARATOR . $pattern); + Assert::assertNotEmpty($matches, sprintf('No file matching "%s" was found in the working directory.', $pattern)); + } + /** * Sets specified ENV variable. * diff --git a/tests/behat/features/accessibility.feature b/tests/behat/features/accessibility.feature index 8a4a8cf3..19d7551d 100644 --- a/tests/behat/features/accessibility.feature +++ b/tests/behat/features/accessibility.feature @@ -99,4 +99,4 @@ Feature: Check that AccessibilityTrait works """ When I run "behat --no-colors" Then it should pass - And file ".logs/test_results/accessibility/index.html" should exist + And a file matching ".logs/test_results/accessibility/accessibility_report_*.html" should exist diff --git a/tests/phpunit/src/AccessibilityTraitTest.php b/tests/phpunit/src/AccessibilityTraitTest.php index b2369671..5fb72a99 100644 --- a/tests/phpunit/src/AccessibilityTraitTest.php +++ b/tests/phpunit/src/AccessibilityTraitTest.php @@ -191,7 +191,7 @@ public function testAggregateRollupTalliesAndSortsBySeverity(): void { $this->assertSame(['/'], array_keys($rollup['rules']['image-alt']['pages'])); } - public function testWriteAggregateReportWritesIndexHtml(): void { + public function testWriteAggregateReportWritesTimestampedFile(): void { $dir = static::locationsTmp() . DIRECTORY_SEPARATOR . 'aggregate-report'; AccessibilityTraitTestImplementation::testSetAggregate(static::createSampleAggregate()); AccessibilityTraitTestImplementation::testSetAggregateReportDir($dir); @@ -200,9 +200,9 @@ public function testWriteAggregateReportWritesIndexHtml(): void { AccessibilityTraitTestImplementation::testWriteAggregateReport(); AccessibilityTraitTestImplementation::testWriteAggregateReport(); - $file = $dir . '/index.html'; - $this->assertFileExists($file); - $this->assertStringContainsString('Accessibility report - aggregate', (string) file_get_contents($file)); + $files = glob($dir . '/accessibility_report_*.html') ?: []; + $this->assertNotEmpty($files); + $this->assertStringContainsString('Accessibility report - aggregate', (string) file_get_contents($files[0])); } public function testWriteAggregateReportDoesNothingWhenEmpty(): void { @@ -212,7 +212,7 @@ public function testWriteAggregateReportDoesNothingWhenEmpty(): void { AccessibilityTraitTestImplementation::testWriteAggregateReport(); - $this->assertFileDoesNotExist($dir . '/index.html'); + $this->assertEmpty(glob($dir . '/accessibility_report_*.html') ?: []); } public function testAggregateRenderHookWritesReport(): void { @@ -222,7 +222,15 @@ public function testAggregateRenderHookWritesReport(): void { AccessibilityTraitTestImplementation::accessibilityAggregateRender(); - $this->assertFileExists($dir . '/index.html'); + $this->assertNotEmpty(glob($dir . '/accessibility_report_*.html') ?: []); + } + + public function testAggregateFilenameFormat(): void { + $name = AccessibilityTraitTestImplementation::testAggregateFilename(1750000000); + + $this->assertMatchesRegularExpression('/^accessibility_report_\d{8}_\d{6}\.html$/', $name); + // A different moment yields a different filename, proving the timestamp is used. + $this->assertNotSame($name, AccessibilityTraitTestImplementation::testAggregateFilename(1750086400)); } public function testAggregateHtmlRendersCleanRunWithoutViolations(): void { @@ -477,6 +485,10 @@ public static function testWriteAggregateReport(): void { static::accessibilityWriteAggregateReport(); } + public static function testAggregateFilename(int $time): string { + return static::accessibilityAggregateFilename($time); + } + public function testCapture(array $results, string $feature, string $scenario, string $dir): void { $this->accessibilityResults = $results; $this->accessibilityFeatureName = $feature; From b409707822757c6826fa9c1c165fcd9a76725e24 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 20 Jun 2026 14:09:52 +1000 Subject: [PATCH 6/8] Re-triggered CI to clear a flaky check. From 297d16ec9798fc736b54d5dbe893aa6a07b8fd6c Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 20 Jun 2026 14:42:42 +1000 Subject: [PATCH 7/8] [#658] Consolidated aggregate report rendering into a single data-driven method. --- src/AccessibilityTrait.php | 403 ++++++++----------- tests/phpunit/src/AccessibilityTraitTest.php | 78 +++- 2 files changed, 245 insertions(+), 236 deletions(-) diff --git a/src/AccessibilityTrait.php b/src/AccessibilityTrait.php index c24767fa..41c29b65 100644 --- a/src/AccessibilityTrait.php +++ b/src/AccessibilityTrait.php @@ -1004,8 +1004,8 @@ protected static function accessibilityWriteAggregateReport(): void { } $time = time(); - $report = static::accessibilityRenderAggregateHtml(self::$accessibilityAggregate, date('Y-m-d H:i', $time)); - file_put_contents($dir . DIRECTORY_SEPARATOR . static::accessibilityAggregateFilename($time), $report); + $data = static::accessibilityAggregateData(self::$accessibilityAggregate, date('Y-m-d H:i', $time)); + file_put_contents($dir . DIRECTORY_SEPARATOR . static::accessibilityAggregateFilename($time), static::accessibilityRenderAggregate($data)); } /** @@ -1021,40 +1021,6 @@ protected static function accessibilityAggregateFilename(int $time): string { return 'accessibility_report_' . date('Ymd_His', $time) . '.html'; } - /** - * Build the full aggregate HTML document. - * - * Composes the page wrapper around the body sections. Override - * accessibilityRenderAggregatePage() to rebrand the page, or - * accessibilityRenderAggregateSections() to change the body, without - * rewriting the aggregation logic. - * - * @param array> $aggregate - * The accumulated per-scenario results. - * @param string $generated - * Human-readable generation timestamp. - */ - protected static function accessibilityRenderAggregateHtml(array $aggregate, string $generated): string { - return static::accessibilityRenderAggregatePage($generated, static::accessibilityRenderAggregateSections($aggregate)); - } - - /** - * Compose the aggregate body: summary, pages, rules, per-scenario detail. - * - * @param array> $aggregate - * The accumulated per-scenario results. - */ - protected static function accessibilityRenderAggregateSections(array $aggregate): string { - $pages = static::accessibilityAggregatePages($aggregate); - $rollup = static::accessibilityAggregateRollup($pages); - $totals = $rollup['totals']; - - return static::accessibilityRenderAggregateSummary(array_sum($totals), count($pages), count($aggregate), $totals) - . static::accessibilityRenderAggregatePages($pages) - . static::accessibilityRenderAggregateRules($rollup['rules']) - . static::accessibilityRenderAggregateScenarios($aggregate); - } - /** * De-duplicate assessed pages by URL across every scenario. * @@ -1153,241 +1119,220 @@ protected static function accessibilityAggregateRollup(array $pages): array { } /** - * Render the summary cards. + * Assemble every value the renderer needs into one data array. * - * @param int $total_violations - * Total violation count across all pages. - * @param int $page_count - * Number of unique pages assessed. - * @param int $scenario_count - * Number of scenarios that produced results. - * @param array $totals - * Violation counts keyed by impact level. - */ - protected static function accessibilityRenderAggregateSummary(int $total_violations, int $page_count, int $scenario_count, array $totals): string { - $state = $total_violations > 0 ? 'fail' : 'ok'; - - $card = fn(string $class, int $num, string $label): string => sprintf('
%d%s
', $class, $num, $label); - - return '
' - . $card('', $page_count, 'pages assessed') - . $card('', $scenario_count, 'scenarios') - . $card($state, $total_violations, 'violations') - . $card('crit', $totals[self::IMPACT_CRITICAL] ?? 0, 'critical') - . $card('ser', $totals[self::IMPACT_SERIOUS] ?? 0, 'serious') - . $card('mod', $totals[self::IMPACT_MODERATE] ?? 0, 'moderate') - . $card('min', $totals[self::IMPACT_MINOR] ?? 0, 'minor') - . '
'; - } - - /** - * Render the de-duplicated table of assessed pages. + * All calculation lives here and in the methods it calls - de-duplication, + * severity tallies, sorting, counting, and target flattening - so the single + * renderer only has to turn ready values into markup. * - * @param array> $pages - * Per-URL rollup. + * @param array> $aggregate + * The accumulated per-scenario results. + * @param string $generated + * Human-readable generation timestamp. + * + * @return array + * Render-ready data: `generated`, `page_count`, `scenario_count`, + * `total_violations`, `totals`, `pages`, `rules`, and `scenarios`. */ - protected static function accessibilityRenderAggregatePages(array $pages): string { - $rows = ''; + protected static function accessibilityAggregateData(array $aggregate, string $generated): array { + $deduped = static::accessibilityAggregatePages($aggregate); + $rollup = static::accessibilityAggregateRollup($deduped); + $totals = $rollup['totals']; + $blank = static::accessibilityBlankUrls(); - foreach ($pages as $url => $page) { - $count = count($page['violations'] ?? []); - $scenarios = implode(', ', array_keys($page['scenarios'] ?? [])); - $rows .= sprintf( - '%s%s%d%d%s', - $count > 0 ? 'bad' : 'good', - htmlspecialchars((string) $url, ENT_QUOTES), - static::accessibilityRenderAggregateRuleChips($page['violations'] ?? []), - $page['incomplete'] ?? 0, - $page['passes'] ?? 0, - htmlspecialchars($scenarios, ENT_QUOTES) - ); + $pages = []; + foreach ($deduped as $url => $page) { + $chips = []; + foreach ($page['violations'] ?? [] as $violation) { + $chips[] = [ + 'id' => (string) ($violation['id'] ?? 'unknown'), + 'impact' => strtolower((string) ($violation['impact'] ?? self::IMPACT_MINOR)), + 'count' => count($violation['nodes'] ?? []), + ]; + } + $pages[] = [ + 'url' => (string) $url, + 'violations' => $chips, + 'incomplete' => (int) ($page['incomplete'] ?? 0), + 'passes' => (int) ($page['passes'] ?? 0), + 'scenarios' => implode(', ', array_keys($page['scenarios'] ?? [])), + ]; } - return '

Pages assessed

Each URL is listed once, even when several scenarios visit it. Violations are broken down by rule, with the number of affected elements.

' - . '' - . $rows . '
URLViolations by typeIncompletePassesSeen in scenarios
'; - } - - /** - * Render per-rule violation chips for a page, each with its element count. - * - * @param array> $violations - * Normalized violations for a single page. - */ - protected static function accessibilityRenderAggregateRuleChips(array $violations): string { - if ($violations === []) { - return ''; + $rules = []; + foreach ($rollup['rules'] as $rule_id => $rule) { + $rules[] = [ + 'id' => (string) $rule_id, + 'impact' => (string) ($rule['impact'] ?? self::IMPACT_MINOR), + 'help' => (string) ($rule['help'] ?? ''), + 'helpUrl' => (string) ($rule['helpUrl'] ?? ''), + 'page_count' => count($rule['pages'] ?? []), + 'nodes' => $rule['nodes'] ?? [], + ]; } - $chips = ''; - - foreach ($violations as $violation) { - $impact = strtolower((string) ($violation['impact'] ?? self::IMPACT_MINOR)); - $chips .= sprintf( - '%s %d', - htmlspecialchars($impact, ENT_QUOTES), - htmlspecialchars((string) ($violation['id'] ?? 'unknown'), ENT_QUOTES), - count($violation['nodes'] ?? []) - ); + $scenarios = []; + foreach ($aggregate as $entry) { + $detail = []; + foreach ($entry['results'] ?? [] as $result) { + $url = (string) ($result['url'] ?? ''); + if (in_array($url, $blank, TRUE)) { + continue; + } + $detail[] = [ + 'url' => $url, + 'rules' => (string) ($result['rules'] ?? ''), + 'violation_count' => count($result['result']['violations'] ?? []), + 'incomplete_count' => count($result['result']['incomplete'] ?? []), + 'passes_count' => count($result['result']['passes'] ?? []), + 'violations' => static::accessibilityAggregateFindings($result['result']['violations'] ?? []), + 'incomplete' => static::accessibilityAggregateFindings($result['result']['incomplete'] ?? []), + ]; + } + $scenarios[] = [ + 'feature' => (string) ($entry['feature'] ?? ''), + 'scenario' => (string) ($entry['scenario'] ?? ''), + 'threshold' => (string) ($entry['threshold'] ?? ''), + 'failOnIncomplete' => (bool) ($entry['failOnIncomplete'] ?? FALSE), + 'pages' => $detail, + ]; } - return $chips; + return [ + 'generated' => $generated, + 'page_count' => count($deduped), + 'scenario_count' => count($aggregate), + 'total_violations' => array_sum($totals), + 'totals' => $totals, + 'pages' => $pages, + 'rules' => $rules, + 'scenarios' => $scenarios, + ]; } /** - * Render the violations grouped by rule. + * Flatten normalized findings into render-ready rows. * - * @param array> $rules - * Per-rule rollup, pre-sorted by severity. + * @param array> $issues + * Normalized violations or incomplete findings. + * + * @return array> + * Each finding with its impact, id, help, helpUrl, and flattened nodes. */ - protected static function accessibilityRenderAggregateRules(array $rules): string { - if ($rules === []) { - return '

Violations by rule

No violations found.

'; - } - - $blocks = ''; + protected static function accessibilityAggregateFindings(array $issues): array { + $findings = []; - foreach ($rules as $rule_id => $rule) { + foreach ($issues as $issue) { $nodes = []; - foreach ($rule['nodes'] ?? [] as $node) { - $nodes[] = sprintf( - '
%s · %s
%s
', - htmlspecialchars((string) $node['target'], ENT_QUOTES), - htmlspecialchars((string) $node['url'], ENT_QUOTES), - htmlspecialchars((string) $node['html'], ENT_QUOTES) - ); + foreach ($issue['nodes'] ?? [] as $node) { + $nodes[] = [ + 'target' => static::accessibilityStringifyTarget($node['target'] ?? []), + 'html' => trim((string) ($node['html'] ?? '')), + ]; } - - $impact = (string) $rule['impact']; - $help_url = (string) $rule['helpUrl']; - $docs = $help_url !== '' ? sprintf(' · docs', htmlspecialchars($help_url, ENT_QUOTES)) : ''; - $blocks .= sprintf( - '

%s %s

' - . '

%s · affects %d page(s) · %d element(s)%s

%s
', - htmlspecialchars($impact, ENT_QUOTES), - htmlspecialchars($impact, ENT_QUOTES), - htmlspecialchars((string) $rule_id, ENT_QUOTES), - htmlspecialchars((string) $rule['help'], ENT_QUOTES), - count($rule['pages'] ?? []), - count($rule['nodes'] ?? []), - $docs, - implode('', $nodes) - ); + $findings[] = [ + 'impact' => strtolower((string) ($issue['impact'] ?? 'unknown')), + 'id' => (string) ($issue['id'] ?? ''), + 'help' => (string) ($issue['help'] ?? ''), + 'helpUrl' => (string) ($issue['helpUrl'] ?? ''), + 'nodes' => $nodes, + ]; } - return '

Violations by rule

Highest impact first.

' . $blocks . '
'; + return $findings; } /** - * Render each scenario's full findings inline, in the order pages were seen. + * Render the entire aggregate report from prepared data. * - * Mirrors the per-scenario report: a scenario heading with its threshold, - * then per page the rules string, the counts, and the violation and - * incomplete lists with the same fields. + * This is the single rendering entry point. Every value it needs is already + * computed in `$data` by accessibilityAggregateData(), so a consumer can + * override this one method to completely restyle the report - markup, CSS, + * and layout - without touching any of the aggregation logic. * - * @param array> $aggregate - * The accumulated per-scenario results. + * @param array $data + * Render-ready data from accessibilityAggregateData(). */ - protected static function accessibilityRenderAggregateScenarios(array $aggregate): string { - $blank = static::accessibilityBlankUrls(); - $blocks = ''; + protected static function accessibilityRenderAggregate(array $data): string { + $issue_list = function (string $heading, string $css_class, array $issues): string { + if ($issues === []) { + return sprintf('
%s

None.

', htmlspecialchars($heading, ENT_QUOTES)); + } - foreach ($aggregate as $entry) { - $sections = ''; + $parts = [sprintf('
%s
', htmlspecialchars($heading, ENT_QUOTES))]; + foreach ($issues as $issue) { + $impact = htmlspecialchars((string) ($issue['impact'] ?? 'unknown'), ENT_QUOTES); + $issue_html = sprintf('
%s%s — %s', htmlspecialchars($css_class, ENT_QUOTES), $impact, $impact, htmlspecialchars((string) ($issue['id'] ?? ''), ENT_QUOTES), htmlspecialchars((string) ($issue['help'] ?? ''), ENT_QUOTES)); - foreach ($entry['results'] ?? [] as $result) { - $url = (string) ($result['url'] ?? ''); + if (((string) ($issue['helpUrl'] ?? '')) !== '') { + $issue_html .= sprintf(' (docs)', htmlspecialchars((string) $issue['helpUrl'], ENT_QUOTES)); + } - if (in_array($url, $blank, TRUE)) { - continue; + foreach ($issue['nodes'] ?? [] as $node) { + $issue_html .= sprintf('
%s
%s
', htmlspecialchars((string) ($node['target'] ?? ''), ENT_QUOTES), htmlspecialchars((string) ($node['html'] ?? ''), ENT_QUOTES)); } - $violations = $result['result']['violations'] ?? []; - $incomplete = $result['result']['incomplete'] ?? []; - - $sections .= sprintf( - '

%s

Rules: %s · %d violations · %d incomplete · %d passes

%s%s
', - htmlspecialchars($url, ENT_QUOTES), - htmlspecialchars((string) ($result['rules'] ?? ''), ENT_QUOTES), - count($violations), - count($incomplete), - count($result['result']['passes'] ?? []), - static::accessibilityRenderAggregateIssueList('Violations', 'violation', $violations), - static::accessibilityRenderAggregateIssueList('Incomplete (needs human review)', 'incomplete', $incomplete) - ); + $parts[] = $issue_html . '
'; } - $blocks .= sprintf( - '

%s %s

threshold: %s · fail on incomplete: %s

%s
', - htmlspecialchars((string) ($entry['scenario'] ?? ''), ENT_QUOTES), - htmlspecialchars((string) ($entry['feature'] ?? ''), ENT_QUOTES), - htmlspecialchars((string) ($entry['threshold'] ?? ''), ENT_QUOTES), - ($entry['failOnIncomplete'] ?? FALSE) ? 'yes' : 'no', - $sections - ); - } - - return '

Per-scenario detail

Every page each scenario assessed, in order, with its full findings embedded.

' . $blocks . '
'; - } + return implode('', $parts); + }; + + $totals = $data['totals'] ?? []; + $state = ((int) ($data['total_violations'] ?? 0)) > 0 ? 'fail' : 'ok'; + $cards = '
' + . sprintf('
%dpages assessed
', (int) ($data['page_count'] ?? 0)) + . sprintf('
%dscenarios
', (int) ($data['scenario_count'] ?? 0)) + . sprintf('
%dviolations
', $state, (int) ($data['total_violations'] ?? 0)) + . sprintf('
%dcritical
', (int) ($totals[self::IMPACT_CRITICAL] ?? 0)) + . sprintf('
%dserious
', (int) ($totals[self::IMPACT_SERIOUS] ?? 0)) + . sprintf('
%dmoderate
', (int) ($totals[self::IMPACT_MODERATE] ?? 0)) + . sprintf('
%dminor
', (int) ($totals[self::IMPACT_MINOR] ?? 0)) + . '
'; - /** - * Render a single issue list (violations or incomplete) for the aggregate. - * - * Matches the per-scenario report fields: impact, rule id, help text, docs - * link, and the target and HTML of each offending element. - * - * @param string $heading - * Section heading. - * @param string $css_class - * CSS class applied to each issue (`violation` or `incomplete`). - * @param array> $issues - * Normalized issues to render. - */ - protected static function accessibilityRenderAggregateIssueList(string $heading, string $css_class, array $issues): string { - if ($issues === []) { - return sprintf('
%s

None.

', htmlspecialchars($heading, ENT_QUOTES)); + $rows = []; + foreach ($data['pages'] ?? [] as $page) { + $chips = []; + foreach ($page['violations'] ?? [] as $chip) { + $chips[] = sprintf('%s %d', htmlspecialchars((string) ($chip['impact'] ?? self::IMPACT_MINOR), ENT_QUOTES), htmlspecialchars((string) ($chip['id'] ?? 'unknown'), ENT_QUOTES), (int) ($chip['count'] ?? 0)); + } + $vtypes = $chips === [] ? '' : implode('', $chips); + $rows[] = sprintf('%s%s%d%d%s', $chips === [] ? 'good' : 'bad', htmlspecialchars((string) ($page['url'] ?? ''), ENT_QUOTES), $vtypes, (int) ($page['incomplete'] ?? 0), (int) ($page['passes'] ?? 0), htmlspecialchars((string) ($page['scenarios'] ?? ''), ENT_QUOTES)); } + $pages_section = '

Pages assessed

Each URL is listed once, even when several scenarios visit it. Violations are broken down by rule, with the number of affected elements.

' + . '' + . implode('', $rows) . '
URLViolations by typeIncompletePassesSeen in scenarios
'; - $out = sprintf('
%s
', htmlspecialchars($heading, ENT_QUOTES)); - - foreach ($issues as $issue) { - $impact = strtolower((string) ($issue['impact'] ?? 'unknown')); - $impact_safe = htmlspecialchars($impact, ENT_QUOTES); - $id = htmlspecialchars((string) ($issue['id'] ?? ''), ENT_QUOTES); - $help = htmlspecialchars((string) ($issue['help'] ?? ''), ENT_QUOTES); - $help_url = htmlspecialchars((string) ($issue['helpUrl'] ?? ''), ENT_QUOTES); - - $out .= sprintf('
%s%s — %s', htmlspecialchars($css_class, ENT_QUOTES), $impact_safe, $impact_safe, $id, $help); - - if ($help_url !== '') { - $out .= sprintf(' (docs)', $help_url); + if (($data['rules'] ?? []) === []) { + $rules_section = '

Violations by rule

No violations found.

'; + } + else { + $blocks = []; + foreach ($data['rules'] ?? [] as $rule) { + $nodes = []; + foreach ($rule['nodes'] ?? [] as $node) { + $nodes[] = sprintf('
%s · %s
%s
', htmlspecialchars((string) ($node['target'] ?? ''), ENT_QUOTES), htmlspecialchars((string) ($node['url'] ?? ''), ENT_QUOTES), htmlspecialchars((string) ($node['html'] ?? ''), ENT_QUOTES)); + } + $impact = htmlspecialchars((string) ($rule['impact'] ?? self::IMPACT_MINOR), ENT_QUOTES); + $docs = ((string) ($rule['helpUrl'] ?? '')) !== '' ? sprintf(' · docs', htmlspecialchars((string) $rule['helpUrl'], ENT_QUOTES)) : ''; + $blocks[] = sprintf('

%s %s

%s · affects %d page(s) · %d element(s)%s

%s
', $impact, $impact, htmlspecialchars((string) ($rule['id'] ?? 'unknown'), ENT_QUOTES), htmlspecialchars((string) ($rule['help'] ?? ''), ENT_QUOTES), (int) ($rule['page_count'] ?? 0), count($rule['nodes'] ?? []), $docs, implode('', $nodes)); } + $rules_section = '

Violations by rule

Highest impact first.

' . implode('', $blocks) . '
'; + } - foreach ($issue['nodes'] ?? [] as $node) { - $target = htmlspecialchars(static::accessibilityStringifyTarget($node['target'] ?? []), ENT_QUOTES); - $html = htmlspecialchars(trim((string) ($node['html'] ?? '')), ENT_QUOTES); - $out .= sprintf('
%s
%s
', $target, $html); + $detail = []; + foreach ($data['scenarios'] ?? [] as $scenario) { + $sections = []; + foreach ($scenario['pages'] ?? [] as $page) { + $sections[] = sprintf('

%s

Rules: %s · %d violations · %d incomplete · %d passes

%s%s
', htmlspecialchars((string) ($page['url'] ?? ''), ENT_QUOTES), htmlspecialchars((string) ($page['rules'] ?? ''), ENT_QUOTES), (int) ($page['violation_count'] ?? 0), (int) ($page['incomplete_count'] ?? 0), (int) ($page['passes_count'] ?? 0), $issue_list('Violations', 'violation', $page['violations'] ?? []), $issue_list('Incomplete (needs human review)', 'incomplete', $page['incomplete'] ?? [])); } - - $out .= '
'; + $detail[] = sprintf('

%s %s

threshold: %s · fail on incomplete: %s

%s
', htmlspecialchars((string) ($scenario['scenario'] ?? ''), ENT_QUOTES), htmlspecialchars((string) ($scenario['feature'] ?? ''), ENT_QUOTES), htmlspecialchars((string) ($scenario['threshold'] ?? ''), ENT_QUOTES), ($scenario['failOnIncomplete'] ?? FALSE) ? 'yes' : 'no', implode('', $sections)); } + $scenarios_section = '

Per-scenario detail

Every page each scenario assessed, in order, with its full findings embedded.

' . implode('', $detail) . '
'; - return $out; - } + $generated = htmlspecialchars((string) ($data['generated'] ?? ''), ENT_QUOTES); + $body = $cards . $pages_section . $rules_section . $scenarios_section; - /** - * Wrap the rendered sections in a standalone HTML document. - * - * Default: a self-contained page with the aggregate's own styles. Override - * to brand the report without rebuilding the section markup - the caller - * supplies it as `$body`. - * - * @param string $generated - * Human-readable generation timestamp. - * @param string $body - * Pre-rendered body sections. - */ - protected static function accessibilityRenderAggregatePage(string $generated, string $body): string { return << diff --git a/tests/phpunit/src/AccessibilityTraitTest.php b/tests/phpunit/src/AccessibilityTraitTest.php index 5fb72a99..c2cd2018 100644 --- a/tests/phpunit/src/AccessibilityTraitTest.php +++ b/tests/phpunit/src/AccessibilityTraitTest.php @@ -106,7 +106,7 @@ public function testCaptureBaseDirDoesNotOverwrite(): void { #[DataProvider('dataProviderAggregateHtmlContains')] public function testAggregateHtmlContains(string $expected): void { - $html = AccessibilityTraitTestImplementation::testRenderAggregateHtml(static::createSampleAggregate(), '2026-01-02 03:04'); + $html = static::renderSample(static::createSampleAggregate(), '2026-01-02 03:04'); $this->assertStringContainsString($expected, $html); } @@ -146,7 +146,7 @@ public static function dataProviderAggregateHtmlContains(): array { } public function testAggregateHtmlSortsRulesBySeverityThenElementCount(): void { - $html = AccessibilityTraitTestImplementation::testRenderAggregateHtml(static::createSampleAggregate(), '2026-01-02 03:04'); + $html = static::renderSample(static::createSampleAggregate(), '2026-01-02 03:04'); $image_alt = strpos($html, 'image-alt'); $button_name = strpos($html, 'button-name'); @@ -163,7 +163,7 @@ public function testAggregateHtmlSortsRulesBySeverityThenElementCount(): void { } public function testAggregateHtmlUsesPathOnlyUrls(): void { - $html = AccessibilityTraitTestImplementation::testRenderAggregateHtml(static::createSampleAggregate(), '2026-01-02 03:04'); + $html = static::renderSample(static::createSampleAggregate(), '2026-01-02 03:04'); $this->assertStringContainsString('/contact', $html); // No assessed-page URL retains a scheme + host + port. @@ -234,7 +234,7 @@ public function testAggregateFilenameFormat(): void { } public function testAggregateHtmlRendersCleanRunWithoutViolations(): void { - $html = AccessibilityTraitTestImplementation::testRenderAggregateHtml(static::createCleanAggregate(), '2026-01-02 03:04'); + $html = static::renderSample(static::createCleanAggregate(), '2026-01-02 03:04'); $this->assertStringContainsString('
0violations
', $html); $this->assertStringContainsString('No violations found.', $html); @@ -262,12 +262,60 @@ public function testAggregateRulesOmitDocsLinkWhenHelpUrlEmpty(): void { ], ]; - $html = AccessibilityTraitTestImplementation::testRenderAggregateHtml($aggregate, '2026-01-02 03:04'); + $html = static::renderSample($aggregate, '2026-01-02 03:04'); $this->assertStringContainsString('custom-rule', $html); $this->assertStringNotContainsString('href=""', $html); } + public function testRenderAggregateConsumesPreparedDataArray(): void { + $data = [ + 'generated' => '2026-01-02 03:04', + 'page_count' => 1, + 'scenario_count' => 1, + 'total_violations' => 1, + 'totals' => ['critical' => 1, 'serious' => 0, 'moderate' => 0, 'minor' => 0], + 'pages' => [ + ['url' => '/', 'violations' => [['id' => 'image-alt', 'impact' => 'critical', 'count' => 2]], 'incomplete' => 0, 'passes' => 1, 'scenarios' => 'Home'], + ], + 'rules' => [ + ['id' => 'image-alt', 'impact' => 'critical', 'help' => 'Images need alt text', 'helpUrl' => 'https://example.com/image-alt', 'page_count' => 1, 'nodes' => [['url' => '/', 'target' => 'img.logo', 'html' => '']]], + ], + 'scenarios' => [ + [ + 'feature' => 'Home', + 'scenario' => 'Home page', + 'threshold' => 'any', + 'failOnIncomplete' => FALSE, + 'pages' => [ + ['url' => '/', 'rules' => 'wcag2a', 'violation_count' => 1, 'incomplete_count' => 0, 'passes_count' => 1, 'violations' => [['impact' => 'critical', 'id' => 'image-alt', 'help' => 'Images need alt text', 'helpUrl' => 'https://example.com/image-alt', 'nodes' => [['target' => 'img.logo', 'html' => '']]]], 'incomplete' => []], + ], + ], + ], + ]; + + $html = AccessibilityTraitTestImplementation::testRenderAggregate($data); + + $this->assertStringContainsString('
1critical
', $html); + $this->assertStringContainsString('image-alt 2', $html); + $this->assertStringContainsString('image-alt', $html); + $this->assertStringContainsString('href="https://example.com/image-alt"', $html); + $this->assertStringContainsString('generated 2026-01-02 03:04', $html); + } + + public function testAggregateDataShapesRenderReadyValues(): void { + $data = AccessibilityTraitTestImplementation::testAggregateData(static::createSampleAggregate(), '2026-01-02 03:04'); + + $this->assertSame('2026-01-02 03:04', $data['generated']); + $this->assertSame(2, $data['page_count']); + $this->assertSame(2, $data['scenario_count']); + $this->assertSame(3, $data['total_violations']); + $this->assertSame(['critical' => 2, 'serious' => 1, 'moderate' => 0, 'minor' => 0], $data['totals']); + $this->assertSame('/', $data['pages'][0]['url']); + $this->assertSame(['image-alt', 'button-name', 'color-contrast'], array_column($data['rules'], 'id')); + $this->assertSame('img.logo', $data['rules'][0]['nodes'][0]['target']); + } + public function testAggregateResetClearsState(): void { AccessibilityTraitTestImplementation::testSetAggregate(static::createSampleAggregate()); AccessibilityTraitTestImplementation::testSetAggregateReportDir('/sentinel'); @@ -298,6 +346,18 @@ public function testAggregateCaptureFormatsUrlsAndRecordsEntry(): void { $this->assertSame('/captured/dir', AccessibilityTraitTestImplementation::testGetAggregateReportDir()); } + /** + * Render a sample accumulator through the full data + render pipeline. + * + * @param array> $aggregate + * Sample accumulator. + * @param string $generated + * Fixed generation timestamp. + */ + protected static function renderSample(array $aggregate, string $generated): string { + return AccessibilityTraitTestImplementation::testRenderAggregate(AccessibilityTraitTestImplementation::testAggregateData($aggregate, $generated)); + } + /** * Build a representative accumulator with two scenarios, a shared URL, a blank tab, and mixed-impact findings. * @@ -469,8 +529,12 @@ public static function testGetAggregateReportDir(): ?string { return self::$accessibilityAggregateReportDir; } - public static function testRenderAggregateHtml(array $aggregate, string $generated): string { - return static::accessibilityRenderAggregateHtml($aggregate, $generated); + public static function testAggregateData(array $aggregate, string $generated): array { + return static::accessibilityAggregateData($aggregate, $generated); + } + + public static function testRenderAggregate(array $data): string { + return static::accessibilityRenderAggregate($data); } public static function testAggregatePages(array $aggregate): array { From b2617a4991183b94c65437c028aa917795cf5e4c Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 20 Jun 2026 15:01:53 +1000 Subject: [PATCH 8/8] [#658] Kept feature provenance in the aggregate page scenario list. --- src/AccessibilityTrait.php | 5 ++++- tests/phpunit/src/AccessibilityTraitTest.php | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/AccessibilityTrait.php b/src/AccessibilityTrait.php index 41c29b65..5f702052 100644 --- a/src/AccessibilityTrait.php +++ b/src/AccessibilityTrait.php @@ -1052,7 +1052,10 @@ protected static function accessibilityAggregatePages(array $aggregate): array { ]; } - $pages[$url]['scenarios'][(string) ($entry['scenario'] ?? '')] = TRUE; + $feature = (string) ($entry['feature'] ?? ''); + $scenario = (string) ($entry['scenario'] ?? ''); + $label = $feature !== '' ? $feature . ' > ' . $scenario : $scenario; + $pages[$url]['scenarios'][$label] = TRUE; } } diff --git a/tests/phpunit/src/AccessibilityTraitTest.php b/tests/phpunit/src/AccessibilityTraitTest.php index c2cd2018..3c0b12b1 100644 --- a/tests/phpunit/src/AccessibilityTraitTest.php +++ b/tests/phpunit/src/AccessibilityTraitTest.php @@ -129,7 +129,7 @@ public static function dataProviderAggregateHtmlContains(): array { 'critical chip carries its affected-element count' => ['image-alt 2'], 'serious chip carries its affected-element count' => ['color-contrast 1'], 'second page chip' => ['button-name 1'], - 'a deduplicated URL lists every visiting scenario' => ['Home page, Contact page'], + 'a deduplicated URL lists every visiting scenario with its feature' => ['Homepage > Home page, Contact > Contact page'], 'rule rollup links to the axe docs' => ['href="https://dequeuniversity.com/rules/axe/4.11/image-alt"'], 'rule rollup reports affected pages and elements' => ['affects 1 page(s) · 2 element(s)'], 'scenario detail shows the default threshold' => ['threshold: any'], @@ -174,11 +174,11 @@ public function testAggregatePagesDeduplicatesAndSkipsBlankUrls(): void { $pages = AccessibilityTraitTestImplementation::testAggregatePages(static::createSampleAggregate()); $this->assertSame(['/', '/contact'], array_keys($pages)); - $this->assertSame(['Home page', 'Contact page'], array_keys($pages['/']['scenarios'])); + $this->assertSame(['Homepage > Home page', 'Contact > Contact page'], array_keys($pages['/']['scenarios'])); $this->assertCount(2, $pages['/']['violations']); $this->assertSame(1, $pages['/']['incomplete']); $this->assertSame(3, $pages['/']['passes']); - $this->assertSame(['Contact page'], array_keys($pages['/contact']['scenarios'])); + $this->assertSame(['Contact > Contact page'], array_keys($pages['/contact']['scenarios'])); } public function testAggregateRollupTalliesAndSortsBySeverity(): void {