diff --git a/AGENTS.md b/AGENTS.md index 630e7b2..e6c39fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,14 @@ tests/ # unit/ (Bun), integration/ (Bun + RTL/jsdom), e2e/ 3. Write the minimal implementation in `lib/` / `components/` / `app/` 4. Refactor while keeping tests green +**Accessibility is mandatory** — every feature MUST comply with WCAG 2.2 AA +and all markup MUST use HTML5-valid elements/attributes (no obsolete +presentational attributes; presentation lives in CSS). New pages, components +and template changes must keep the a11y guards green: +`tests/unit/html5-attributes.test.js`, `tests/unit/report-template-wcag.test.js`, +`tests/unit/css-a11y.test.js`, `tests/unit/page-titles.test.js` — and add +coverage there when introducing new markup patterns. + ## Platform-Aware Sections in This Repo - **CSP** lives in `app/layout.jsx` (`script-src` includes the CDN widget hosts and @@ -111,4 +119,10 @@ tests/ # unit/ (Bun), integration/ (Bun + RTL/jsdom), e2e/ ## Current Test Count -~294 unit/integration + 112 E2E (three browsers + basePath leg). +~313 unit/integration + 112 E2E (three browsers + basePath leg). + +WCAG 2.2 AA / HTML5 guards live in tests/unit/html5-attributes.test.js, +tests/unit/report-template-wcag.test.js, tests/unit/css-a11y.test.js and +tests/unit/page-titles.test.js — the report mustache keeps a single h1, +scoped table headers, captions, labelled canvases and a named nav; obsolete +presentational attributes are FORBID_ATTR-stripped in lib/renderer.js. diff --git a/README.md b/README.md index 7ff6609..a6db81c 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ is a **Next.js static export** (`bun run build` produces `out/`, which any stati platform account is captured as the issue author and validated server-side by the platform's CI - Reports and submissions work for repositories hosted on the same platform as the deployment (GitHub, GitLab or Bitbucket) +- **Accessible**: the site and rendered reports target **WCAG 2.2 AA**, with automated checks for selected requirements + such as semantic HTML5, color contrast, per-page titles, table semantics, labeled controls and landmarks - Works with a plain static file server: `python3 -m http.server 8000` --- @@ -370,6 +372,14 @@ npx playwright test --ui # interactive mode bun run test:e2e:basepath # NEXT_PUBLIC_BASE_PATH=/preview leg (chromium) ``` +### Accessibility (WCAG 2.2 AA) + +This application targets **WCAG 2.2 Level AA**. Dedicated guards cover selected requirements: +`tests/unit/html5-attributes.test.js`, `tests/unit/report-template-wcag.test.js`, `tests/unit/css-a11y.test.js` and +`tests/unit/page-titles.test.js` verify HTML5-valid markup (no obsolete presentational attributes), semantic heading +hierarchy, table captions and scoped headers, chart alternative text, landmark names, color contrast, focus visibility, +target sizes and per-page titles. Extend these tests when you introduce new markup patterns. + ### Lint ```bash diff --git a/app/about/page.jsx b/app/about/page.jsx index 0d85e57..809f0df 100644 --- a/app/about/page.jsx +++ b/app/about/page.jsx @@ -1,3 +1,6 @@ +export const metadata = { title: 'About - RefactorFirst' }; + +/** Renders the RefactorFirst project overview. */ export default function AboutPage() { return (
diff --git a/app/api/page.jsx b/app/api/page.jsx index a570de2..236fce0 100644 --- a/app/api/page.jsx +++ b/app/api/page.jsx @@ -1,3 +1,6 @@ +export const metadata = { title: 'API - RefactorFirst' }; + +/** Renders the API and integration reference page. */ export default function ApiPage() { return (
diff --git a/app/documentation/page.jsx b/app/documentation/page.jsx index 4290304..16b1ed8 100644 --- a/app/documentation/page.jsx +++ b/app/documentation/page.jsx @@ -1,5 +1,8 @@ import Link from 'next/link'; +export const metadata = { title: 'Documentation - RefactorFirst' }; + +/** Renders the RefactorFirst documentation overview. */ export default function DocumentationPage() { return (
diff --git a/app/examples/page.jsx b/app/examples/page.jsx index 5b621d4..ffac4bc 100644 --- a/app/examples/page.jsx +++ b/app/examples/page.jsx @@ -1,5 +1,8 @@ import Link from 'next/link'; +export const metadata = { title: 'Example Reports - RefactorFirst' }; + +/** Renders links and guidance for example reports. */ export default function ExamplesPage() { return (
diff --git a/app/faq/page.jsx b/app/faq/page.jsx index 69b1a44..43ce1f7 100644 --- a/app/faq/page.jsx +++ b/app/faq/page.jsx @@ -1,5 +1,8 @@ import Link from 'next/link'; +export const metadata = { title: 'FAQ - RefactorFirst' }; + +/** Renders answers to frequently asked questions. */ export default function FaqPage() { return (
diff --git a/app/feedback/page.jsx b/app/feedback/page.jsx index 36fa7fb..4fd5a53 100644 --- a/app/feedback/page.jsx +++ b/app/feedback/page.jsx @@ -1,3 +1,6 @@ +export const metadata = { title: 'Feedback - RefactorFirst' }; + +/** Renders feedback and support links. */ export default function FeedbackPage() { return (
diff --git a/app/getting-started/page.jsx b/app/getting-started/page.jsx index a91a077..e9daf33 100644 --- a/app/getting-started/page.jsx +++ b/app/getting-started/page.jsx @@ -1,6 +1,9 @@ import Link from 'next/link'; import WorkflowSample from '../../components/workflow-sample'; +export const metadata = { title: 'Getting Started - RefactorFirst' }; + +/** Renders the repository setup walkthrough. */ export default function GettingStartedPage() { return (
diff --git a/app/globals.css b/app/globals.css index 043829a..8f3b9ab 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,7 +2,10 @@ :root { --brand-color: #2a6f97; - --brand-accent: #61c0bf; + /* Darker accent: white text on it passes WCAG 2.2 AA contrast (1.4.3) and + it serves as the visible focus indicator (2.4.11, >= 3:1 against the + white page background). */ + --brand-accent: #1f5273; --text-color: #1f2d3d; --muted-color: #5c6b7a; --bg-color: #ffffff; @@ -49,7 +52,10 @@ main#app { min-height: 60vh; } -main:focus { +/* Skip-link target: suppress the outline only for programmatic (script/ + click) focus; keyboard-focusable users keep the visible indicator (WCAG + 2.4.7 / 2.4.11). */ +main:focus:not(:focus-visible) { outline: none; } diff --git a/app/privacy-policy/page.jsx b/app/privacy-policy/page.jsx index 7ae0f45..15a5579 100644 --- a/app/privacy-policy/page.jsx +++ b/app/privacy-policy/page.jsx @@ -1,3 +1,6 @@ +export const metadata = { title: 'Privacy Policy - RefactorFirst' }; + +/** Renders the RefactorFirst privacy policy. */ export default function PrivacyPolicyPage() { return (
diff --git a/app/terms-of-service/page.jsx b/app/terms-of-service/page.jsx index 57ba59a..c863456 100644 --- a/app/terms-of-service/page.jsx +++ b/app/terms-of-service/page.jsx @@ -1,3 +1,6 @@ +export const metadata = { title: 'Terms of Service - RefactorFirst' }; + +/** Renders the RefactorFirst terms of service. */ export default function TermsOfServicePage() { return (
diff --git a/assets/refactor-first-report.mustache b/assets/refactor-first-report.mustache index 9aa29bc..4043571 100644 --- a/assets/refactor-first-report.mustache +++ b/assets/refactor-first-report.mustache @@ -69,6 +69,33 @@ width: 95%; margin: 20px auto; } + + /* HTML5 replacements for the obsolete presentational attributes the + upstream template used (align/border): text alignment, centered + blocks and the data-table chrome now come from these classes. */ + .rf-text-center { + text-align: center; + } + + .rf-text-left { + text-align: left; + } + + .rf-text-right { + text-align: right; + } + + .rf-data-table { + border: 5px solid; + border-collapse: collapse; + margin: 0 auto; + } + + .rf-data-table th, + .rf-data-table td { + border: 1px solid; + padding: 2px 6px; + }
@@ -76,13 +103,13 @@
-

+

RefactorFirst Report for {{project.name}} {{project.version}}

-
+

Show RefactorFirst some ❤️

Star @@ -99,9 +126,9 @@ data-size="large" aria-label="Sponsor @jimbethancourt on GitHub">Sponsor
-
{{#project.analysisFailed}} -
+ {{/project.analysisFailed}} -

Class Map

+

Class Map

-
+
Red lines represent relationships to remove.
Red nodes represent classes to remove.
Zoom in / out with your mouse wheel and click/move to drag the image.
@@ -171,50 +200,51 @@
{{#classMap.dotThresholdExceeded}} -
SVG is too big to render quickly
+
SVG is too big to render quickly
{{/classMap.dotThresholdExceeded}} {{^classMap.dotThresholdExceeded}}
{{/classMap.dotThresholdExceeded}} -
-
-
-
+
+
+
+
{{#classRelationshipsToRemove.hasRelationships}} - -

Refactor Starting with Priority 1

-
+

Class Relationship Removal Priority

+

Refactor Starting with Priority 1

+
Current Class Cycle Count: {{classRelationshipsToRemove.cycleCount}}
Number of Class Relationships to Remove: {{classRelationshipsToRemove.relationshipsToRemoveCount}}
Classes with * should be broken apart
Removing class relationships below will eliminate class cycles
-
- +
+
+ - - - - - - + + + + + + {{#classRelationshipsToRemove.relationships}} - - - - - + + + + - + {{/classRelationshipsToRemove.relationships}} @@ -222,14 +252,14 @@ {{/classRelationshipsToRemove.hasRelationships}} -
-
-
-
+
+
+
+
{{#packageMap.hasEdges}} -

Package Map

+

Package Map

Class relationships to remove, in priority order
Class RelationshipPriorityIn Class
Cycles
Relationship
Strength
Also Removes Pkg
Cycle Relationship
In Package
Cycles
Class RelationshipPriorityIn Class
Cycles
Relationship
Strength
Also Removes Pkg
Cycle Relationship
In Package
Cycles
{{renderedLabel}}{{priority}}{{cycleCount}}{{effortRank}}{{#alsoRemovesPackageRelationship}} + {{renderedLabel}}{{priority}}{{cycleCount}}{{effortRank}}{{#alsoRemovesPackageRelationship}} true{{/alsoRemovesPackageRelationship}}{{^alsoRemovesPackageRelationship}} false{{/alsoRemovesPackageRelationship}}{{packageCycleCount}}{{packageCycleCount}}
+
+
+ - - - - - + + + + + {{#packageRelationshipsToRemove.relationships}} - - - - - + + + + + {{/packageRelationshipsToRemove.relationships}} @@ -304,41 +335,51 @@ {{/packageRelationshipsToRemove.hasRelationships}} + {{#hasDisharmonies}} +
+
+

Code Disharmonies

+ {{/hasDisharmonies}} {{#disharmonies}} -
-
-
-
- -
-
Package relationships to remove, in priority order
Package RelationshipPriorityIn Pkg
Cycles
Relationship
Strength
Class Relationships to Remove
To Break Package Relationship
Package RelationshipPriorityIn Pkg
Cycles
Relationship
Strength
Class Relationships to Remove
To Break Package Relationship
{{{renderedLabel}}}{{priority}}{{cycleCount}}{{effortRank}}{{#classRelationshipsToBreakPackage}} - {{{.}}}
{{/classRelationshipsToBreakPackage}}
{{{renderedLabel}}}{{priority}}{{cycleCount}}{{effortRank}}{{#classRelationshipsToBreakPackage}} + {{{.}}}
{{/classRelationshipsToBreakPackage}}
+
+
+
+
+

{{title}}

+
+
+ + - + - + +
Problem and recommended solution for {{title}}
Problem:Problem: {{problem}}
Solution:Solution: {{{solution}}}
-
+
- + Bubble chart visualizing the {{title}} findings: one bubble per class, ranked by priority and estimated effort.
-
+
-

{{title}} by the numbers: (Refactor Starting with Priority 1)

-
- +

{{title}} by the numbers: (Refactor Starting with Priority 1)

+
+
+ {{#table.headers}} - + {{/table.headers}} @@ -346,7 +387,7 @@ {{#table.rows}} {{#cells}} - + {{/cells}} {{/table.rows}} @@ -355,32 +396,33 @@ {{/disharmonies}} -
-
-
-
+
+
+
+
{{#classCycles.hasCycles}} - -

Class Cycles by the numbers:

-
-
{{title}} findings, in priority order
{{.}}{{.}}
{{{content}}}{{{content}}}
+

Class Cycles

+

Class Cycles by the numbers:

+
+
+ - - - - + + + + {{#classCycles.summary}} - - - - + + + + {{/classCycles.summary}} @@ -388,8 +430,8 @@ {{#classCycles.largestCycle.hasCycleMap}} -

Largest Class Cycle : {{classCycles.largestCycle.cycleName}}

-

Limiting number of cycles displayed to 1 to keep page load time fast

+

Largest Class Cycle : {{classCycles.largestCycle.cycleName}}

+

Limiting number of cycles displayed to 1 to keep page load time fast

Class cycles summary
Cycle NamePriorityClass CountRelationship CountCycle NamePriorityClass CountRelationship Count
{{cycleName}}{{priority}}{{classCount}}{{relationshipCount}}{{cycleName}}{{priority}}{{classCount}}{{relationshipCount}}
+
+
+ - - + + {{#classCycles.largestCycle.breakdown}} - - + + {{/classCycles.largestCycle.breakdown}} @@ -433,7 +476,7 @@
-
+
Last Published: {{project.scanTimestamp}}
diff --git a/lib/renderer.js b/lib/renderer.js index 3c316ad..626f86c 100644 --- a/lib/renderer.js +++ b/lib/renderer.js @@ -8,6 +8,11 @@ import DOMPurify from 'dompurify'; let mustacheInstance = null; +/** + * Returns the shared Mustache renderer instance. + * + * @returns {typeof Mustache} The initialized Mustache module. + */ export function initializeMustache() { if (!mustacheInstance) { mustacheInstance = Mustache; @@ -15,37 +20,73 @@ export function initializeMustache() { return mustacheInstance; } -// Report JSON data and Mustache templates come from the target repository -// (untrusted third-party content). Escape via Mustache by default and -// sanitize the final HTML with DOMPurify so template-supplied '], + ['iframe', ''], + ['object', ''], + ['embed', ''], + ['meta', ''], + ['link', ''], + ['form', ''], + ['input', ''], + ['select', ''], + ['textarea', ''] + ]; + + for (const [tag, markup] of forbiddenElements) { + it(`strips forbidden <${tag}> elements from raw interpolations`, () => { + const html = renderTemplate('
{{{content}}}
', { content: markup }); + const document = new JSDOM(html).window.document; + + expect(document.querySelector(tag)).toBeNull(); + expect(document.querySelector('section')).not.toBeNull(); + }); + } + it('should handle empty data', () => { const template = '{{name}}'; const result = renderTemplate(template, {}); @@ -112,6 +136,34 @@ describe('templating safety (repository-provided templates are untrusted)', () = expect(html).toContain(' { + const html = renderTemplate( + 'New tab' + + 'Same tab' + + '
Not a link
', + {} + ); + const document = new JSDOM(html).window.document; + + expect(document.querySelector('a[href="https://example.com"]')?.getAttribute('rel')) + .toBe('noopener noreferrer'); + expect(document.querySelector('a[target="_self"]')?.hasAttribute('rel')).toBe(false); + expect(document.querySelector('div[target="_blank"]')?.hasAttribute('rel')).toBe(false); + }); + + it('removes an explicit opener relationship from links that open a new context', () => { + const html = renderTemplate( + 'New tab', + {} + ); + const link = new JSDOM(html).window.document.querySelector('a'); + const relationships = link.getAttribute('rel').split(/\s+/); + + expect(relationships).toContain('noopener'); + expect(relationships).toContain('noreferrer'); + expect(relationships).not.toContain('opener'); + }); + it('keeps benign structure, style attributes and data attributes intact', () => { const html = renderTemplate( '', diff --git a/tests/unit/report-template-wcag.test.js b/tests/unit/report-template-wcag.test.js new file mode 100644 index 0000000..f4bb753 --- /dev/null +++ b/tests/unit/report-template-wcag.test.js @@ -0,0 +1,145 @@ +// WCAG 2.2 AA checks for the rendered report template (SC 1.1.1, 1.3.1, +// 2.4.6): heading hierarchy, table semantics, canvas alternative text and +// named landmarks, exercised against the sanitised render of the fixture. +import { describe, it, expect } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { JSDOM } from 'jsdom'; + +import { renderTemplate } from '../../lib/renderer.js'; + +const ROOT = path.join(import.meta.dir, '../..'); +const template = readFileSync( + path.join(ROOT, 'public/assets/refactor-first-report.mustache'), + 'utf8' +); +const fixture = JSON.parse( + readFileSync(path.join(ROOT, 'tests/fixtures/junit4-report.json'), 'utf8') +); +const doc = new JSDOM(renderTemplate(template, fixture)).window.document; + +describe('report template WCAG 2.2 AA (rendered with fixture data)', () => { + it('has exactly one h1 (the report title)', () => { + expect(doc.querySelectorAll('h1').length).toBe(1); + }); + + it('heading levels never skip (h1 -> h2 -> h3, one level at a time)', () => { + const headings = [...doc.querySelectorAll('h1, h2, h3, h4, h5, h6')]; + expect(headings.length).toBeGreaterThan(0); + let previous = 0; + const violations = []; + for (const heading of headings) { + const level = Number(heading.tagName[1]); + if (level > previous + 1) { + violations.push(`${heading.tagName}: "${heading.textContent.trim().slice(0, 40)}" after h${previous}`); + } + previous = level; + } + expect(violations).toEqual([]); + }); + + it('every data table has a non-empty
Classes and relationships in the largest cycle
ClassesRelationshipsClassesRelationships
{{{className}}}{{{edgesHtml}}}{{{className}}}{{{edgesHtml}}}
', () => { + const violations = []; + for (const table of doc.querySelectorAll('table')) { + const caption = table.querySelector('caption'); + if (!caption || !caption.textContent.trim()) { + violations.push(table.outerHTML.slice(0, 80)); + } + } + expect(violations).toEqual([]); + }); + + it('every header cell declares its scope', () => { + const violations = []; + for (const th of doc.querySelectorAll('th')) { + if (!/^(col|row|colgroup|rowgroup)$/.test(th.getAttribute('scope') || '')) { + violations.push(`
${th.textContent.trim().slice(0, 30)}`); + } + } + expect(violations).toEqual([]); + }); + + it('themes the problem/solution table with row headers instead of td markup', () => { + const { disharmonies } = fixture; + if (!disharmonies || disharmonies.length === 0) return; + const problemLabel = [...doc.querySelectorAll('th')] + .filter(cell => /^Problem/.test(cell.textContent.trim())); + expect(problemLabel.length).toBeGreaterThan(0); + for (const th of problemLabel) { + expect(th.getAttribute('scope')).toBe('row'); + } + }); + + it('every chart canvas has an accessible name and fallback text', () => { + const canvases = [...doc.querySelectorAll('canvas')]; + expect(canvases.length).toBeGreaterThan(0); + const violations = []; + for (const canvas of canvases) { + if (!(canvas.getAttribute('aria-label') || '').trim()) { + violations.push(` without aria-label`); + } + if (!canvas.textContent.trim()) { + violations.push(` without fallback text`); + } + } + expect(violations).toEqual([]); + }); + + it('the report section navigation is a named landmark', () => { + for (const nav of doc.querySelectorAll('nav')) { + expect((nav.getAttribute('aria-label') || '').trim()).not.toBe(''); + } + }); + + it('in-page navigation links resolve to actual targets', () => { + const fixtureWithoutCycleMap = structuredClone(fixture); + fixtureWithoutCycleMap.classCycles.largestCycle.hasCycleMap = false; + const docWithoutCycleMap = new JSDOM( + renderTemplate(template, fixtureWithoutCycleMap) + ).window.document; + + for (const renderedDoc of [doc, docWithoutCycleMap]) { + const violations = []; + for (const anchor of renderedDoc.querySelectorAll('a[href^="#"]')) { + const target = anchor.getAttribute('href'); + if (target === '#') { + violations.push(`placeholder link "${anchor.textContent.trim()}"`); + continue; + } + if (!renderedDoc.getElementById(target.slice(1))) { + violations.push(`unresolved anchor ${target}`); + } + } + expect(violations).toEqual([]); + } + + expect(docWithoutCycleMap.querySelector('a[href="#CYCLEMAP"]')).toBeNull(); + }); + + it('omits optional navigation links when their report sections are absent', () => { + const sparseFixture = structuredClone(fixture); + sparseFixture.classRelationshipsToRemove.hasRelationships = false; + sparseFixture.packageMap.hasEdges = false; + sparseFixture.packageRelationshipsToRemove.hasRelationships = false; + sparseFixture.hasDisharmonies = false; + sparseFixture.disharmonies = []; + sparseFixture.classCycles.hasCycles = false; + + const sparseDoc = new JSDOM(renderTemplate(template, sparseFixture)).window.document; + const optionalTargets = [ + '#CLASSEDGES', + '#PACKAGEMAP', + '#PACKAGEEDGES', + '#DISHARMONIES', + '#CYCLES', + '#CYCLEMAP' + ]; + + expect(sparseDoc.querySelector('a[href="#CLASSMAP"]')).not.toBeNull(); + expect(sparseDoc.getElementById('CLASSMAP')).not.toBeNull(); + for (const target of optionalTargets) { + expect(sparseDoc.querySelector(`a[href="${target}"]`)).toBeNull(); + expect(sparseDoc.getElementById(target.slice(1))).toBeNull(); + } + }); +});