From 0279e8794269e7d59a8dd89cd437e50cbb20c9b3 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Mon, 2 Feb 2026 20:32:31 -0500 Subject: [PATCH 1/4] feat(core,ci): support URL sources upload to portal --- package-lock.json | 8 ++--- package.json | 2 +- packages/ci/package.json | 2 +- .../__snapshots__/transform.unit.test.ts.snap | 30 ++++++++++++++++ packages/ci/src/lib/portal/transform.ts | 11 ++++++ .../ci/src/lib/portal/transform.unit.test.ts | 36 +++++++++++++++++++ packages/core/package.json | 2 +- .../src/lib/implementation/report-to-gql.ts | 8 ++++- .../implementation/report-to-gql.unit.test.ts | 23 +++++++++++- packages/utils/src/index.ts | 1 + 10 files changed, 114 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index b9c0986ff..de389cb9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@axe-core/playwright": "^4.11.0", - "@code-pushup/portal-client": "^0.16.0", + "@code-pushup/portal-client": "^0.17.0", "@nx/devkit": "22.3.3", "@swc/helpers": "0.5.18", "ansis": "^3.3.2", @@ -2413,9 +2413,9 @@ } }, "node_modules/@code-pushup/portal-client": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@code-pushup/portal-client/-/portal-client-0.16.0.tgz", - "integrity": "sha512-JlMRcTKkJygVfLS+IWQxDRDnvF64p4q+QDLIXzQPep6X99C1OH3MnA9jbfGAOew/3xqOILCrifn0y54fyRs8Qg==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@code-pushup/portal-client/-/portal-client-0.17.0.tgz", + "integrity": "sha512-agY729Ro4VSLwdAog2acHFiW+sQSnR4lkUKabXZIctl5xSoCjgIE4M6KrpB2FSiSII3AHTbgNfIZag+hDV6MZA==", "license": "MIT", "dependencies": { "graphql": "^16.6.0", diff --git a/package.json b/package.json index a7b55cf4f..52a48a7d4 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "private": true, "dependencies": { "@axe-core/playwright": "^4.11.0", - "@code-pushup/portal-client": "^0.16.0", + "@code-pushup/portal-client": "^0.17.0", "@nx/devkit": "22.3.3", "@swc/helpers": "0.5.18", "ansis": "^3.3.2", diff --git a/packages/ci/package.json b/packages/ci/package.json index 47ae841cb..f70db1ce1 100644 --- a/packages/ci/package.json +++ b/packages/ci/package.json @@ -27,7 +27,7 @@ "type": "module", "dependencies": { "@code-pushup/models": "0.112.0", - "@code-pushup/portal-client": "^0.16.0", + "@code-pushup/portal-client": "^0.17.0", "@code-pushup/utils": "0.112.0", "ansis": "^3.3.2", "glob": "^11.0.1", diff --git a/packages/ci/src/lib/portal/__snapshots__/transform.unit.test.ts.snap b/packages/ci/src/lib/portal/__snapshots__/transform.unit.test.ts.snap index a7bcefa67..90ae22c8b 100644 --- a/packages/ci/src/lib/portal/__snapshots__/transform.unit.test.ts.snap +++ b/packages/ci/src/lib/portal/__snapshots__/transform.unit.test.ts.snap @@ -197,6 +197,36 @@ exports[`transformGQLReport > should convert full GraphQL report to valid report "slug": "bundle-stats", "title": "Bundle stats", }, + { + "audits": [ + { + "details": { + "issues": [ + { + "message": "Element has insufficient color contrast of 4.33", + "severity": "error", + "source": { + "selector": ".hero > span:nth-child(2)", + "snippet": "Low contrast", + "url": "https://example.com/", + }, + }, + ], + }, + "displayValue": "1 error", + "score": 0, + "slug": "color-contrast", + "title": "Elements must meet minimum color contrast ratio thresholds", + "value": 1, + }, + ], + "date": "2025-08-01T00:10:25.000Z", + "duration": 5000, + "groups": [], + "icon": "folder-syntax", + "slug": "axe", + "title": "Axe accessibility", + }, ], "version": "0.42.0", } diff --git a/packages/ci/src/lib/portal/transform.ts b/packages/ci/src/lib/portal/transform.ts index e93300a30..d87ee1a0e 100644 --- a/packages/ci/src/lib/portal/transform.ts +++ b/packages/ci/src/lib/portal/transform.ts @@ -166,6 +166,17 @@ function transformGQLIssue(issue: IssueFragment): Issue { }), }, }), + ...(issue.source?.__typename === 'SourceUrlLocation' && { + source: { + url: issue.source.url, + ...(issue.source.snippet != null && { + snippet: issue.source.snippet, + }), + ...(issue.source.selector != null && { + selector: issue.source.selector, + }), + }, + }), }; } diff --git a/packages/ci/src/lib/portal/transform.unit.test.ts b/packages/ci/src/lib/portal/transform.unit.test.ts index 5ae819fca..70005f235 100644 --- a/packages/ci/src/lib/portal/transform.unit.test.ts +++ b/packages/ci/src/lib/portal/transform.unit.test.ts @@ -207,6 +207,29 @@ describe('transformGQLReport', () => { }, groups: [], }, + { + slug: 'axe', + title: 'Axe accessibility', + icon: 'folder-syntax', + runnerStartDate: '2025-08-01T00:10:25.000Z', + runnerDuration: 5000, + audits: { + edges: [ + { + node: { + slug: 'color-contrast', + title: + 'Elements must meet minimum color contrast ratio thresholds', + score: 0, + value: 1, + formattedValue: '1 error', + details: { enabled: true, trees: [], tables: [] }, + }, + }, + ], + }, + groups: [], + }, ], issues: { edges: [ @@ -231,6 +254,19 @@ describe('transformGQLReport', () => { severity: IssueSeverity.Warning, }, }, + { + node: { + audit: { plugin: { slug: 'axe' }, slug: 'color-contrast' }, + message: 'Element has insufficient color contrast of 4.33', + severity: IssueSeverity.Error, + source: { + __typename: 'SourceUrlLocation', + url: 'https://example.com/', + snippet: 'Low contrast', + selector: '.hero > span:nth-child(2)', + }, + }, + }, ], }, }; diff --git a/packages/core/package.json b/packages/core/package.json index e290d892a..78133f288 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -44,7 +44,7 @@ "ansis": "^3.3.0" }, "peerDependencies": { - "@code-pushup/portal-client": "^0.16.0" + "@code-pushup/portal-client": "^0.17.0" }, "peerDependenciesMeta": { "@code-pushup/portal-client": { diff --git a/packages/core/src/lib/implementation/report-to-gql.ts b/packages/core/src/lib/implementation/report-to-gql.ts index e74b29b03..2177f426b 100644 --- a/packages/core/src/lib/implementation/report-to-gql.ts +++ b/packages/core/src/lib/implementation/report-to-gql.ts @@ -33,7 +33,7 @@ import type { TableAlignment, Tree, } from '@code-pushup/models'; -import { isFileIssue } from '@code-pushup/utils'; +import { isFileIssue, isUrlIssue } from '@code-pushup/utils'; export function reportToGQL( report: Report, @@ -115,6 +115,12 @@ export function issueToGQL(issue: Issue): PortalIssue { sourceEndLine: issue.source.position?.endLine, sourceEndColumn: issue.source.position?.endColumn, }), + ...(isUrlIssue(issue) && { + sourceType: safeEnum('Url'), + sourceUrl: issue.source.url, + sourceSnippet: issue.source.snippet, + sourceSelector: issue.source.selector, + }), }; } diff --git a/packages/core/src/lib/implementation/report-to-gql.unit.test.ts b/packages/core/src/lib/implementation/report-to-gql.unit.test.ts index a78bd6878..fbaafe37f 100644 --- a/packages/core/src/lib/implementation/report-to-gql.unit.test.ts +++ b/packages/core/src/lib/implementation/report-to-gql.unit.test.ts @@ -2,7 +2,7 @@ import { type AuditReportTree, TreeType } from '@code-pushup/portal-client'; import { issueToGQL, tableToGQL, treeToGQL } from './report-to-gql.js'; describe('issueToGQL', () => { - it('transforms issue to GraphQL input type', () => { + it('should transform issue with file source to GraphQL input type', () => { expect( issueToGQL({ message: 'No let, use const instead.', @@ -23,6 +23,27 @@ describe('issueToGQL', () => { sourceEndColumn: 25, }); }); + + it('should transform issue with URL source to GraphQL input type', () => { + expect( + issueToGQL({ + message: 'Fix any of the following: Unable to determine contrast ratio', + severity: 'error', + source: { + url: 'https://code-pushup.dev/', + snippet: 'measure development KPIs', + selector: '.text-box > span:nth-child(3)', + }, + }), + ).toStrictEqual({ + message: 'Fix any of the following: Unable to determine contrast ratio', + severity: 'Error', + sourceSelector: '.text-box > span:nth-child(3)', + sourceSnippet: 'measure development KPIs', + sourceType: 'Url', + sourceUrl: 'https://code-pushup.dev/', + }); + }); }); describe('tableToGQL', () => { diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index ce3d64ed2..f8f6bdcf0 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -129,6 +129,7 @@ export { formatIssueSeverities, wrapTags } from './lib/reports/formatting.js'; export { isFileIssue, isFileSource, + isUrlIssue, isUrlSource, } from './lib/reports/type-guards.js'; export { generateMdReport } from './lib/reports/generate-md-report.js'; From 512c0fe40450ba5a017b78ec1052dea0877262ad Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Mon, 2 Feb 2026 20:33:04 -0500 Subject: [PATCH 2/4] refactor(plugin-axe): use structured check messages --- .../plugin-axe/src/lib/runner/transform.ts | 32 +++-- .../src/lib/runner/transform.unit.test.ts | 135 +++++++++++++++--- 2 files changed, 136 insertions(+), 31 deletions(-) diff --git a/packages/plugin-axe/src/lib/runner/transform.ts b/packages/plugin-axe/src/lib/runner/transform.ts index 6f4e1e2c2..1e29e40dc 100644 --- a/packages/plugin-axe/src/lib/runner/transform.ts +++ b/packages/plugin-axe/src/lib/runner/transform.ts @@ -4,7 +4,6 @@ import type { AuditOutputs, Issue, IssueSeverity, - SourceUrlLocation, } from '@code-pushup/models'; import { formatIssueSeverities, @@ -71,26 +70,35 @@ function formatSelector(selector: axe.CrossTreeSelector): string { return selector.join(' >> '); } +/** + * Joins `none`/`all` check messages (each must be fixed). + * Falls back to first `any` check (OR-ed, one represents the group). + */ +function formatNodeMessage(node: axe.NodeResult, fallback: string): string { + const requiredMessages = [...node.none, ...node.all].map( + check => check.message, + ); + if (requiredMessages.length > 0) { + return requiredMessages.join('. '); + } + return node.any[0]?.message ?? fallback; +} + function toIssue(node: axe.NodeResult, result: axe.Result, url: string): Issue { const selector = node.target?.[0] ? formatSelector(node.target[0]) : undefined; - const rawMessage = node.failureSummary || result.help; - const cleanMessage = rawMessage.replace(/\s+/g, ' ').trim(); - - // TODO: Remove selector prefix from message once Portal supports URL sources - const message = selector ? `[\`${selector}\`] ${cleanMessage}` : cleanMessage; - const source: SourceUrlLocation = { - url, - ...(node.html && { snippet: node.html }), - ...(selector && { selector }), - }; + const message = formatNodeMessage(node, result.help); return { message: truncateIssueMessage(message), severity: impactToSeverity(node.impact), - source, + source: { + url, + ...(node.html && { snippet: node.html }), + ...(selector && { selector }), + }, }; } diff --git a/packages/plugin-axe/src/lib/runner/transform.unit.test.ts b/packages/plugin-axe/src/lib/runner/transform.unit.test.ts index 63178b785..e210fd6a2 100644 --- a/packages/plugin-axe/src/lib/runner/transform.unit.test.ts +++ b/packages/plugin-axe/src/lib/runner/transform.unit.test.ts @@ -1,11 +1,25 @@ -import type { AxeResults, NodeResult, Result } from 'axe-core'; +import type { AxeResults, CheckResult, NodeResult, Result } from 'axe-core'; import type { AuditOutput } from '@code-pushup/models'; import { toAuditOutputs } from './transform.js'; +function createMockCheck(overrides: Partial = {}): CheckResult { + return { + id: 'mock-check', + data: null, + relatedNodes: [], + impact: 'serious', + message: 'Mock check message', + ...overrides, + } as CheckResult; +} + function createMockNode(overrides: Partial = {}): NodeResult { return { html: '
', target: ['div'], + all: [], + any: [], + none: [], ...overrides, } as NodeResult; } @@ -61,13 +75,23 @@ describe('toAuditOutputs', () => { html: '', target: ['img'], impact: 'critical', - failureSummary: 'Fix this: Element does not have an alt attribute', + any: [ + createMockCheck({ + id: 'has-alt', + message: 'Element does not have an alt attribute', + }), + ], }), createMockNode({ html: '', target: ['.header > img:nth-child(2)'], impact: 'serious', - failureSummary: 'Fix this: Element does not have an alt attribute', + any: [ + createMockCheck({ + id: 'has-alt', + message: 'Element does not have an alt attribute', + }), + ], }), createMockNode({ html: '', @@ -89,8 +113,7 @@ describe('toAuditOutputs', () => { details: { issues: [ { - message: - '[`img`] Fix this: Element does not have an alt attribute', + message: 'Element does not have an alt attribute', severity: 'error', source: { url: 'https://example.com', @@ -99,8 +122,7 @@ describe('toAuditOutputs', () => { }, }, { - message: - '[`.header > img:nth-child(2)`] Fix this: Element does not have an alt attribute', + message: 'Element does not have an alt attribute', severity: 'error', source: { url: 'https://example.com', @@ -109,7 +131,7 @@ describe('toAuditOutputs', () => { }, }, { - message: '[`#main img`] Mock help for image-alt', + message: 'Mock help for image-alt', severity: 'error', source: { url: 'https://example.com', @@ -131,13 +153,23 @@ describe('toAuditOutputs', () => { html: '', target: ['button'], impact: 'moderate', - failureSummary: 'Fix this: Element has insufficient color contrast', + any: [ + createMockCheck({ + id: 'color-contrast', + message: 'Element has insufficient color contrast', + }), + ], }), createMockNode({ html: 'Link', target: ['a'], impact: 'moderate', - failureSummary: 'Review: Unable to determine contrast ratio', + any: [ + createMockCheck({ + id: 'color-contrast', + message: 'Unable to determine contrast ratio', + }), + ], }), ]), ], @@ -154,8 +186,7 @@ describe('toAuditOutputs', () => { details: { issues: [ { - message: - '[`button`] Fix this: Element has insufficient color contrast', + message: 'Element has insufficient color contrast', severity: 'warning', source: { url: 'https://example.com', @@ -164,7 +195,7 @@ describe('toAuditOutputs', () => { }, }, { - message: '[`a`] Review: Unable to determine contrast ratio', + message: 'Unable to determine contrast ratio', severity: 'warning', source: { url: 'https://example.com', @@ -261,7 +292,12 @@ describe('toAuditOutputs', () => { html: '', target: [['#app', 'my-component', 'button']], impact: 'critical', - failureSummary: 'Fix this: Element has insufficient color contrast', + any: [ + createMockCheck({ + id: 'color-contrast', + message: 'Element has insufficient color contrast', + }), + ], }), ]), ], @@ -278,8 +314,7 @@ describe('toAuditOutputs', () => { details: { issues: [ { - message: - '[`#app >> my-component >> button`] Fix this: Element has insufficient color contrast', + message: 'Element has insufficient color contrast', severity: 'error', source: { url: 'https://example.com', @@ -293,6 +328,63 @@ describe('toAuditOutputs', () => { ]); }); + it('should use none/all check messages over any checks', () => { + const results = createMockAxeResults({ + violations: [ + createMockResult('link-name', [ + createMockNode({ + html: '', + target: ['a'], + impact: 'serious', + none: [ + createMockCheck({ + id: 'focusable-no-name', + message: + 'Element is in tab order and does not have accessible text', + }), + ], + any: [ + createMockCheck({ + id: 'has-visible-text', + message: + 'Element does not have text that is visible to screen readers', + }), + createMockCheck({ + id: 'aria-label', + message: 'aria-label attribute does not exist or is empty', + }), + ], + }), + ]), + ], + }); + + expect(toAuditOutputs(results, 'https://example.com')).toStrictEqual< + AuditOutput[] + >([ + { + slug: 'link-name', + score: 0, + value: 1, + displayValue: '1 error', + details: { + issues: [ + { + message: + 'Element is in tab order and does not have accessible text', + severity: 'error', + source: { + url: 'https://example.com', + snippet: '', + selector: 'a', + }, + }, + ], + }, + }, + ]); + }); + it('should omit selector when target is missing', () => { const results = createMockAxeResults({ violations: [ @@ -301,8 +393,13 @@ describe('toAuditOutputs', () => { html: '
Content
', target: undefined, impact: 'serious', - failureSummary: - 'Fix this: Ensure all values assigned to role="" correspond to valid ARIA roles', + all: [ + createMockCheck({ + id: 'aria-allowed-role', + message: + 'Ensure all values assigned to role="" correspond to valid ARIA roles', + }), + ], }), ]), ], @@ -320,7 +417,7 @@ describe('toAuditOutputs', () => { issues: [ { message: - 'Fix this: Ensure all values assigned to role="" correspond to valid ARIA roles', + 'Ensure all values assigned to role="" correspond to valid ARIA roles', severity: 'error', source: { url: 'https://example.com', From 6c3dff0b5791eb2d89d4a94fb32827acf56eb947 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Mon, 2 Feb 2026 20:50:24 -0500 Subject: [PATCH 3/4] fix(plugin-axe): update e2e snapshot for new message format --- .../tests/__snapshots__/collect.e2e.test.ts.snap | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/e2e/plugin-axe-e2e/tests/__snapshots__/collect.e2e.test.ts.snap b/e2e/plugin-axe-e2e/tests/__snapshots__/collect.e2e.test.ts.snap index 0c85fe667..d43fcf6a0 100644 --- a/e2e/plugin-axe-e2e/tests/__snapshots__/collect.e2e.test.ts.snap +++ b/e2e/plugin-axe-e2e/tests/__snapshots__/collect.e2e.test.ts.snap @@ -497,7 +497,7 @@ exports[`PLUGIN collect report with axe-plugin NPM package > should run plugin o "details": { "issues": [ { - "message": "[\`body > button\`] Fix any of the following: Element does not have inner text that is visible to screen readers aria-label attribute does not exist or is empty aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty Element has no title attribute Element does not have an implicit (wrapped)