diff --git a/entities/core-result.entity.ts b/entities/core-result.entity.ts index e3007a71..bc826802 100644 --- a/entities/core-result.entity.ts +++ b/entities/core-result.entity.ts @@ -368,12 +368,12 @@ export class CoreResult { accessibilityScanStatus?: string; @Column({ nullable: true }) - @Expose({ name: 'accessibility_violations' }) + @Expose({ name: 'accessibility_results' }) @Exclude() - accessibilityViolations?: string; + accessibilityResults?: string; @Column({ nullable: true }) - @Expose({ name: 'accessibility_violations_list' }) + @Expose({ name: 'accessibility_results_list' }) @Exclude() @Transform((value: string) => { if (value) { @@ -382,7 +382,7 @@ export class CoreResult { return null; } }) - accessibilityViolationsList?: string; + accessibilityResultsList?: string; @Column({ nullable: true }) @Expose({ name: 'viewport_meta_tag' }) diff --git a/entities/scan-data.entity.ts b/entities/scan-data.entity.ts index e8eb7462..9600dc91 100644 --- a/entities/scan-data.entity.ts +++ b/entities/scan-data.entity.ts @@ -113,8 +113,8 @@ export type SearchScan = { }; export type AccessibilityScan = { - accessibilityViolations: string; - accessibilityViolationsList: string; + accessibilityResults: string; + accessibilityResultsList: string; }; export type MobileScan = { diff --git a/libs/core-scanner/src/core-scanner.service.ts b/libs/core-scanner/src/core-scanner.service.ts index c0816dd8..c1d3df96 100644 --- a/libs/core-scanner/src/core-scanner.service.ts +++ b/libs/core-scanner/src/core-scanner.service.ts @@ -201,8 +201,8 @@ export class CoreScannerService status: ScanStatus.Completed, result: { accessibilityScan: { - accessibilityViolations: result.accessibilityViolations, - accessibilityViolationsList: result.accessibilityViolationsList, + accessibilityResults: result.accessibilityResults, + accessibilityResultsList: result.accessibilityResultsList, }, }, error: null, diff --git a/libs/core-scanner/src/pages/accessibility.ts b/libs/core-scanner/src/pages/accessibility.ts deleted file mode 100644 index 316b490c..00000000 --- a/libs/core-scanner/src/pages/accessibility.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Logger } from 'pino'; -import { getHttpsUrl } from '../util'; -import { CoreInputDto } from '../core.input.dto'; -import { Page } from 'puppeteer'; -import { AccessibilityScan } from 'entities/scan-data.entity'; -import { AxePuppeteer } from '@axe-core/puppeteer'; -import { Result } from 'axe-core'; - -export const createAccessibilityScanner = ( - logger: Logger, - input: CoreInputDto, -) => { - logger.info('Starting a11y scan...'); - - return async (page: Page): Promise => { - page.on('console', (message) => console.log('PAGE LOG:', message.text())); - page.on('error', (error) => console.log('ERROR LOG:', error)); - - await page.goto(getHttpsUrl(input.url)); - - const axeScanResult = await new AxePuppeteer(page).analyze(); - const violations = axeScanResult.violations; - - const { violationsSummary, violationsList } = - aggregateViolations(violations); - - const accessibilityViolations = Object.keys(violationsSummary).length - ? JSON.stringify(violationsSummary) - : null; - const accessibilityViolationsList = violationsList.length - ? JSON.stringify(violationsList) - : null; - - return { - accessibilityViolations, - accessibilityViolationsList, - }; - }; -}; - -type AggregatedViolations = { - violationsSummary: Record; - violationsList: Result[]; -}; - -function aggregateViolations(violations: Result[]): AggregatedViolations { - const violationsSummary = {}; - const violationsList = []; - - // Mapping of a11y violation categories to axe-core Result id values - const violationCategoryMapping = { - aria: [ - 'aria-allowed-attr', - 'aria-deprecated-role', - 'aria-hidden-body', - 'aria-hidden-focus', - 'aria-prohibited-attr', - 'aria-required-attr', - 'aria-required-children', - 'aria-required-parent', - 'aria-roles', - 'aria-tooltip-name', - 'aria-valid-attr-value', - 'aria-valid-attr', - ], - 'auto-updating': ['meta-refresh'], - contrast: ['color-contrast'], - flash: ['blink', 'marquee'], - 'form-names': ['aria-input-field-name', 'input-field-name', 'select-name'], - 'frames-iframes': ['frame-title'], - images: [ - 'area-alt', - 'image-alt', - 'input-image-alt', - 'object-alt', - 'role-img-alt', - 'svg-img-alt', - ], - 'keyboard-access': [ - 'frame-focusable-content', - 'scrollable-region-focusable', - ], - language: ['html-lang-valid', 'valid-lang', 'html-has-lang'], - 'link-purpose': ['link-name'], - lists: ['definition-list', 'dlitem', 'list', 'listitem'], - 'page-titled': ['document-title'], - tables: ['td-headers-attr', 'th-has-data-cells'], - 'user-control-name': [ - 'aria-command-name', - 'aria-meter-name', - 'aria-progressbar-name', - 'aria-toggle-field-name', - 'button-name', - ], - }; - - violations.forEach((violation) => { - for (const categorys in violationCategoryMapping) { - if (violationCategoryMapping[categorys].includes(violation.id)) { - violationsSummary[categorys] = violationsSummary[categorys] - ? violationsSummary[categorys] + 1 - : 1; - violationsList.push(violation); - break; - } - } - }); - - return { - violationsSummary, - violationsList, - }; -} diff --git a/libs/core-scanner/src/pages/accessibility/index.ts b/libs/core-scanner/src/pages/accessibility/index.ts new file mode 100644 index 00000000..5af92e47 --- /dev/null +++ b/libs/core-scanner/src/pages/accessibility/index.ts @@ -0,0 +1,38 @@ +import { Logger } from 'pino'; +import { getHttpsUrl } from '../../util'; +import { CoreInputDto } from '../../core.input.dto'; +import { Page } from 'puppeteer'; +import { AccessibilityScan } from 'entities/scan-data.entity'; +import { AxePuppeteer } from '@axe-core/puppeteer'; +import { aggregateResults } from './results-aggregator'; + +export const createAccessibilityScanner = ( + logger: Logger, + input: CoreInputDto, +) => { + logger.info('Starting a11y scan...'); + + return async (page: Page): Promise => { + page.on('console', (message) => console.log('PAGE LOG:', message.text())); + page.on('error', (error) => console.log('ERROR LOG:', error)); + + await page.goto(getHttpsUrl(input.url)); + + const axeScanResult = await new AxePuppeteer(page).analyze(); + const violationResults = axeScanResult.violations; + + const { resultsSummary, resultsList } = aggregateResults(violationResults); + + const accessibilityResults = Object.keys(resultsSummary).length + ? JSON.stringify(resultsSummary) + : null; + const accessibilityResultsList = resultsList.length + ? JSON.stringify(resultsList) + : null; + + return { + accessibilityResults, + accessibilityResultsList, + }; + }; +}; diff --git a/libs/core-scanner/src/pages/accessibility/results-aggregator.spec.ts b/libs/core-scanner/src/pages/accessibility/results-aggregator.spec.ts new file mode 100644 index 00000000..fe9906d9 --- /dev/null +++ b/libs/core-scanner/src/pages/accessibility/results-aggregator.spec.ts @@ -0,0 +1,44 @@ +import { promises as fs } from 'fs'; +import { join } from 'path'; +import { Result } from 'axe-core'; +import { aggregateResults } from './results-aggregator'; + +async function readJsonFile(filePath) { + try { + const jsonString = await fs.readFile(filePath, 'utf8'); + const jsonObject = JSON.parse(jsonString); + return jsonObject; + } catch (error) { + console.error('Error reading the file:', error); + } +} + +describe('aggregateResults', () => { + it('should aggregate results from a list of one result', async () => { + const results: Result[] = await readJsonFile( + join(__dirname, './test-fixtures/results1Raw.json'), + ); + + const result = aggregateResults(results); + + const expectedResult = await readJsonFile( + join(__dirname, './test-fixtures/results1Expected.json'), + ); + + expect(result).toEqual(expectedResult); + }); + + it('should aggregate results from a list of two results', async () => { + const results: Result[] = await readJsonFile( + join(__dirname, './test-fixtures/results2Raw.json'), + ); + + const result = aggregateResults(results); + + const expectedResult = await readJsonFile( + join(__dirname, './test-fixtures/results2Expected.json'), + ); + + expect(result).toEqual(expectedResult); + }); +}); diff --git a/libs/core-scanner/src/pages/accessibility/results-aggregator.ts b/libs/core-scanner/src/pages/accessibility/results-aggregator.ts new file mode 100644 index 00000000..7e8ad9e8 --- /dev/null +++ b/libs/core-scanner/src/pages/accessibility/results-aggregator.ts @@ -0,0 +1,141 @@ +import { + Result, + NodeResult, + CheckResult, + TagValue, + ImpactValue, + UnlabelledFrameSelector, + RelatedNode, +} from 'axe-core'; + +type ResultSubset = { + description: string; + helpUrl: string; + id: string; + tags: TagValue[]; + nodes: NodeResultSubset[]; +}; + +type NodeResultSubset = { + html: string; + impact?: ImpactValue; + xpath?: string[]; + ancestry?: UnlabelledFrameSelector; + any: CheckResultSubset[]; + all: CheckResultSubset[]; + none: CheckResultSubset[]; + element?: HTMLElement; +}; + +type CheckResultSubset = { + id: string; + impact: string; + message: string; + relatedNodes?: RelatedNode[]; +}; + +type AggregatedResults = { + resultsSummary: Record; + resultsList: ResultSubset[]; +}; + +export function aggregateResults(results: Result[]): AggregatedResults { + const resultsSummary = {}; + const rawResultsList = []; + + // Mapping of a11y violation categories to axe-core Result id values + const resultCategoryMapping = { + aria: [ + 'aria-allowed-attr', + 'aria-deprecated-role', + 'aria-hidden-body', + 'aria-hidden-focus', + 'aria-prohibited-attr', + 'aria-required-attr', + 'aria-required-children', + 'aria-required-parent', + 'aria-roles', + 'aria-tooltip-name', + 'aria-valid-attr-value', + 'aria-valid-attr', + ], + 'auto-updating': ['meta-refresh'], + contrast: ['color-contrast'], + flash: ['blink', 'marquee'], + 'form-names': ['aria-input-field-name', 'input-field-name', 'select-name'], + 'frames-iframes': ['frame-title'], + images: [ + 'area-alt', + 'image-alt', + 'input-image-alt', + 'object-alt', + 'role-img-alt', + 'svg-img-alt', + ], + 'keyboard-access': [ + 'frame-focusable-content', + 'scrollable-region-focusable', + ], + language: ['html-lang-valid', 'valid-lang', 'html-has-lang'], + 'link-purpose': ['link-name'], + lists: ['definition-list', 'dlitem', 'list', 'listitem'], + 'page-titled': ['document-title'], + tables: ['td-headers-attr', 'th-has-data-cells'], + 'user-control-name': [ + 'aria-command-name', + 'aria-meter-name', + 'aria-progressbar-name', + 'aria-toggle-field-name', + 'button-name', + ], + }; + + results.forEach((result) => { + for (const categorys in resultCategoryMapping) { + if (resultCategoryMapping[categorys].includes(result.id)) { + resultsSummary[categorys] = resultsSummary[categorys] + ? resultsSummary[categorys] + 1 + : 1; + rawResultsList.push(result); + break; + } + } + }); + + return { + resultsSummary, + resultsList: getResultsListSubset(rawResultsList), + }; +} + +function getResultsListSubset(results: Result[]): ResultSubset[] { + return results.map((result) => ({ + description: result.description, + helpUrl: result.helpUrl, + id: result.id, + tags: result.tags, + nodes: getNodeResultsSubset(result.nodes), + })); +} + +function getNodeResultsSubset(nodes: NodeResult[]): NodeResultSubset[] { + return nodes.map((node) => ({ + html: node.html, + impact: node.impact, + xpath: node.xpath, + ancestry: node.ancestry, + any: getCheckResultSubset(node.any), + all: getCheckResultSubset(node.all), + none: getCheckResultSubset(node.none), + element: node.element, + })); +} + +function getCheckResultSubset(checks: CheckResult[]): CheckResultSubset[] { + return checks.map((check) => ({ + id: check.id, + impact: check.impact, + message: check.message, + relatedNodes: check.relatedNodes, + })); +} diff --git a/libs/core-scanner/src/pages/accessibility/test-fixtures/results1Expected.json b/libs/core-scanner/src/pages/accessibility/test-fixtures/results1Expected.json new file mode 100644 index 00000000..32042248 --- /dev/null +++ b/libs/core-scanner/src/pages/accessibility/test-fixtures/results1Expected.json @@ -0,0 +1,38 @@ +{ + "resultsSummary": { + "language": 1 + }, + "resultsList": [ + { + "id": "html-has-lang", + "tags": [ + "cat.language", + "wcag2a", + "wcag311", + "TTv5", + "TT11.a", + "EN-301-549", + "EN-9.3.1.1", + "ACT" + ], + "description": "Ensures every HTML document has a lang attribute", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.8/html-has-lang?application=axe-puppeteer", + "nodes": [ + { + "any": [ + { + "id": "has-lang", + "relatedNodes": [], + "impact": "serious", + "message": "The element does not have a lang attribute" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "" + } + ] + } + ] +} diff --git a/libs/core-scanner/src/pages/accessibility/test-fixtures/results1Raw.json b/libs/core-scanner/src/pages/accessibility/test-fixtures/results1Raw.json new file mode 100644 index 00000000..5e613a5e --- /dev/null +++ b/libs/core-scanner/src/pages/accessibility/test-fixtures/results1Raw.json @@ -0,0 +1,40 @@ +[ + { + "id": "html-has-lang", + "impact": "serious", + "tags": [ + "cat.language", + "wcag2a", + "wcag311", + "TTv5", + "TT11.a", + "EN-301-549", + "EN-9.3.1.1", + "ACT" + ], + "description": "Ensures every HTML document has a lang attribute", + "help": " element must have a lang attribute", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.8/html-has-lang?application=axe-puppeteer", + "nodes": [ + { + "any": [ + { + "id": "has-lang", + "data": { + "messageKey": "noLang" + }, + "relatedNodes": [], + "impact": "serious", + "message": "The element does not have a lang attribute" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "", + "target": ["html"], + "failureSummary": "Fix any of the following:\n The element does not have a lang attribute" + } + ] + } +] diff --git a/libs/core-scanner/src/pages/accessibility/test-fixtures/results2Expected.json b/libs/core-scanner/src/pages/accessibility/test-fixtures/results2Expected.json new file mode 100644 index 00000000..13e3bc75 --- /dev/null +++ b/libs/core-scanner/src/pages/accessibility/test-fixtures/results2Expected.json @@ -0,0 +1,102 @@ +{ + "resultsSummary": { + "contrast": 1, + "images": 1 + }, + "resultsList": [ + { + "id": "color-contrast", + "tags": [ + "cat.color", + "wcag2aa", + "wcag143", + "TTv5", + "TT13.c", + "EN-301-549", + "EN-9.1.4.3", + "ACT" + ], + "description": "Ensures the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.8/color-contrast?application=axe-puppeteer", + "nodes": [ + { + "any": [ + { + "id": "color-contrast", + "relatedNodes": [ + { + "html": "

Last Modified: 12/13/2023

", + "target": ["div:nth-child(14)"] + } + ], + "impact": "serious", + "message": "Element has insufficient color contrast of 3.73 (foreground color: #6b7280, background color: #e4e0ef, font size: 9.8pt (13.125px), font weight: normal). Expected contrast ratio of 4.5:1" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

Last Modified: 12/13/2023

" + } + ] + }, + { + "id": "image-alt", + "tags": [ + "cat.text-alternatives", + "wcag2a", + "wcag111", + "section508", + "section508.22.a", + "TTv5", + "TT7.a", + "TT7.b", + "EN-301-549", + "EN-9.1.1.1", + "ACT" + ], + "description": "Ensures elements have alternate text or a role of none or presentation", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.8/image-alt?application=axe-puppeteer", + "nodes": [ + { + "any": [ + { + "id": "has-alt", + "relatedNodes": [], + "impact": "critical", + "message": "Element does not have an alt attribute" + }, + { + "id": "aria-label", + "relatedNodes": [], + "impact": "critical", + "message": "aria-label attribute does not exist or is empty" + }, + { + "id": "aria-labelledby", + "relatedNodes": [], + "impact": "critical", + "message": "aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty" + }, + { + "id": "non-empty-title", + "relatedNodes": [], + "impact": "critical", + "message": "Element has no title attribute" + }, + { + "id": "presentational-role", + "relatedNodes": [], + "impact": "critical", + "message": "Element's default semantics were not overridden with role=\"none\" or role=\"presentation\"" + } + ], + "all": [], + "none": [], + "impact": "critical", + "html": "" + } + ] + } + ] +} diff --git a/libs/core-scanner/src/pages/accessibility/test-fixtures/results2Raw.json b/libs/core-scanner/src/pages/accessibility/test-fixtures/results2Raw.json new file mode 100644 index 00000000..bdc86fda --- /dev/null +++ b/libs/core-scanner/src/pages/accessibility/test-fixtures/results2Raw.json @@ -0,0 +1,120 @@ +[ + { + "id": "color-contrast", + "impact": "serious", + "tags": [ + "cat.color", + "wcag2aa", + "wcag143", + "TTv5", + "TT13.c", + "EN-301-549", + "EN-9.1.4.3", + "ACT" + ], + "description": "Ensures the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds", + "help": "Elements must meet minimum color contrast ratio thresholds", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.8/color-contrast?application=axe-puppeteer", + "nodes": [ + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#6b7280", + "bgColor": "#e4e0ef", + "contrastRatio": 3.73, + "fontSize": "9.8pt (13.125px)", + "fontWeight": "normal", + "messageKey": null, + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "

Last Modified: 12/13/2023

", + "target": ["div:nth-child(14)"] + } + ], + "impact": "serious", + "message": "Element has insufficient color contrast of 3.73 (foreground color: #6b7280, background color: #e4e0ef, font size: 9.8pt (13.125px), font weight: normal). Expected contrast ratio of 4.5:1" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

Last Modified: 12/13/2023

", + "target": [".text-right"], + "failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 3.73 (foreground color: #6b7280, background color: #e4e0ef, font size: 9.8pt (13.125px), font weight: normal). Expected contrast ratio of 4.5:1" + } + ] + }, + { + "id": "image-alt", + "impact": "critical", + "tags": [ + "cat.text-alternatives", + "wcag2a", + "wcag111", + "section508", + "section508.22.a", + "TTv5", + "TT7.a", + "TT7.b", + "EN-301-549", + "EN-9.1.1.1", + "ACT" + ], + "description": "Ensures elements have alternate text or a role of none or presentation", + "help": "Images must have alternate text", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.8/image-alt?application=axe-puppeteer", + "nodes": [ + { + "any": [ + { + "id": "has-alt", + "data": null, + "relatedNodes": [], + "impact": "critical", + "message": "Element does not have an alt attribute" + }, + { + "id": "aria-label", + "data": null, + "relatedNodes": [], + "impact": "critical", + "message": "aria-label attribute does not exist or is empty" + }, + { + "id": "aria-labelledby", + "data": null, + "relatedNodes": [], + "impact": "critical", + "message": "aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty" + }, + { + "id": "non-empty-title", + "data": { + "messageKey": "noAttr" + }, + "relatedNodes": [], + "impact": "critical", + "message": "Element has no title attribute" + }, + { + "id": "presentational-role", + "data": null, + "relatedNodes": [], + "impact": "critical", + "message": "Element's default semantics were not overridden with role=\"none\" or role=\"presentation\"" + } + ], + "all": [], + "none": [], + "impact": "critical", + "html": "", + "target": [".image > img"], + "failureSummary": "Fix any of the following:\n Element does not have an alt attribute\n aria-label attribute does not exist or is empty\n aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n Element has no title attribute\n Element's default semantics were not overridden with role=\"none\" or role=\"presentation\"" + } + ] + } +] diff --git a/libs/database/src/core-results/core-result.service.spec.ts b/libs/database/src/core-results/core-result.service.spec.ts index 282be45e..5e88a903 100644 --- a/libs/database/src/core-results/core-result.service.spec.ts +++ b/libs/database/src/core-results/core-result.service.spec.ts @@ -206,8 +206,8 @@ describe('CoreResultService', () => { status: scanStatus, result: { accessibilityScan: { - accessibilityViolations: '', - accessibilityViolationsList: '', + accessibilityResults: '', + accessibilityResultsList: '', }, }, }, diff --git a/libs/database/src/core-results/core-result.service.ts b/libs/database/src/core-results/core-result.service.ts index 3ffc5dce..6d88b479 100644 --- a/libs/database/src/core-results/core-result.service.ts +++ b/libs/database/src/core-results/core-result.service.ts @@ -322,18 +322,18 @@ export class CoreResultService { coreResult.accessibilityScanStatus = pages.accessibility.status; if (pages.accessibility.status === ScanStatus.Completed) { - coreResult.accessibilityViolations = - pages.accessibility.result.accessibilityScan.accessibilityViolations; - coreResult.accessibilityViolationsList = - pages.accessibility.result.accessibilityScan.accessibilityViolationsList; + coreResult.accessibilityResults = + pages.accessibility.result.accessibilityScan.accessibilityResults; + coreResult.accessibilityResultsList = + pages.accessibility.result.accessibilityScan.accessibilityResultsList; } else { logger.error({ msg: pages.accessibility.error, page: 'accessibility', }); - coreResult.accessibilityViolations = null; - coreResult.accessibilityViolationsList = null; + coreResult.accessibilityResults = null; + coreResult.accessibilityResultsList = null; } }