diff --git a/apps/golden-sample-app-e2e/playwright.config.ts b/apps/golden-sample-app-e2e/playwright.config.ts index 5b7e34fe1..a8949f1c8 100644 --- a/apps/golden-sample-app-e2e/playwright.config.ts +++ b/apps/golden-sample-app-e2e/playwright.config.ts @@ -47,6 +47,7 @@ export const baseConfig: PlaywrightTestConfig = { 'html', { outputFolder: join(distDir, 'reports/html'), + open: 'never', }, ], ], diff --git a/apps/golden-sample-app-e2e/src/expect/expect.ts b/apps/golden-sample-app-e2e/src/expect/expect.ts new file mode 100644 index 000000000..19005ba86 --- /dev/null +++ b/apps/golden-sample-app-e2e/src/expect/expect.ts @@ -0,0 +1,20 @@ +import { mergeExpects } from '@playwright/test'; +import { obscuredExpect } from './to-be-obscured'; +import { viewportOverflowExpect } from './to-overflow-viewport'; +import { focusOrderExpect } from './to-have-focus-order'; +import { a11yExpect } from '@backbase/e2e-tests'; + +export type { ObscuredAnalysis } from './to-be-obscured'; +export type { FocusableElement } from './to-have-focus-order'; +export type { + OverflowIssue, + OverflowReason, + ViewportOverflowExclusion, +} from './to-overflow-viewport'; + +export const expect = mergeExpects( + a11yExpect, + viewportOverflowExpect, + focusOrderExpect, + obscuredExpect +); diff --git a/apps/golden-sample-app-e2e/src/expect/matcher-shared.ts b/apps/golden-sample-app-e2e/src/expect/matcher-shared.ts new file mode 100644 index 000000000..74a1dcda6 --- /dev/null +++ b/apps/golden-sample-app-e2e/src/expect/matcher-shared.ts @@ -0,0 +1,208 @@ +import { Locator, Page, test } from '@playwright/test'; + +export type MatcherExclusion = Locator | string; + +export const DEFAULT_TOLERANCE_PX = 1; +const SCREENSHOT_HEIGHT_PADDING_PX = 32; + +export async function relativeSelectorFromRoot( + root: Locator, + target: Locator +): Promise { + const rootHandle = await root.elementHandle(); + if (!rootHandle) return null; + + try { + return await target.evaluate((el, rootEl) => { + if (!(rootEl instanceof Element) || !rootEl.contains(el)) return null; + if (el === rootEl) return ':scope'; + + const segments: string[] = []; + let node: Element | null = el; + + while (node && node !== rootEl) { + const parent: Element | null = node.parentElement; + if (!parent) return null; + + const index = Array.from(parent.children).indexOf(node) + 1; + segments.unshift(`${node.tagName.toLowerCase()}:nth-child(${index})`); + node = parent; + } + + return segments.join(' > '); + }, rootHandle); + } finally { + await rootHandle.dispose(); + } +} + +export async function resolveExclusionSelectors( + root: Locator, + exclusions: MatcherExclusion[] +): Promise { + const resolved: string[] = []; + + for (const exclusion of exclusions) { + if (typeof exclusion === 'string') { + resolved.push(exclusion); + continue; + } + + const selector = await relativeSelectorFromRoot(root, exclusion); + if (selector) resolved.push(selector); + } + + return resolved; +} + +/** Runs in the browser — passed to locator.evaluate(). */ +function applyDebugHighlights( + root: Element, + args: { selectors: string[]; debugId: string; label: string } +): void { + document.getElementById(args.debugId)?.remove(); + + const debugRoot = document.createElement('div'); + debugRoot.id = args.debugId; + debugRoot.style.cssText = + 'position:fixed;inset:0;pointer-events:none;z-index:2147483647;'; + + for (const selector of args.selectors) { + const el = selector === ':scope' ? root : root.querySelector(selector); + if (!(el instanceof HTMLElement)) continue; + + const rect = el.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) continue; + + const overlay = document.createElement('div'); + overlay.setAttribute('data-a11y-debug-overlay', selector); + overlay.style.cssText = [ + 'position:fixed', + `left:${rect.left}px`, + `top:${rect.top}px`, + `width:${rect.width}px`, + `height:${rect.height}px`, + 'border:4px dashed #c62828', + 'box-sizing:border-box', + 'pointer-events:none', + 'box-shadow:0 0 0 2px #fff, 0 0 8px #c62828', + ].join(';'); + + const tag = document.createElement('span'); + tag.textContent = args.label; + tag.style.cssText = [ + 'position:absolute', + 'top:-1.4rem', + 'left:0', + 'padding:0.1rem 0.35rem', + 'background:#c62828', + 'color:#fff', + 'font:600 11px/1.2 sans-serif', + 'white-space:nowrap', + ].join(';'); + overlay.appendChild(tag); + debugRoot.appendChild(overlay); + } + + document.body.appendChild(debugRoot); +} + +/** Runs in the browser — passed to page.evaluate(). */ +function measureContentHeight(): number { + window.scrollTo(0, 0); + + for (const el of Array.from(document.querySelectorAll('*'))) { + if (el instanceof HTMLElement && el.scrollTop > 0) { + el.scrollTop = 0; + } + } + + const heights: number[] = [ + window.innerHeight, + document.documentElement.scrollHeight, + document.body.scrollHeight, + ]; + + for (const el of Array.from(document.querySelectorAll('body *'))) { + const rect = el.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) continue; + heights.push(rect.bottom); + } + + return Math.ceil(Math.max(...heights)); +} + +/** Runs in the browser — passed to page.evaluate(). */ +function removeDebugHighlights(debugId: string): void { + document.getElementById(debugId)?.remove(); +} + +export type DebugScreenshotOptions = { + debugId: string; + label: string; + attachmentName: string; +}; + +export type DebugScreenshotIssue = { selector: string }; + +async function captureDebugScreenshot( + page: Page, + applyHighlights: () => Promise, + options: DebugScreenshotOptions +): Promise { + const originalViewport = + page.viewportSize() ?? + (await page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + }))); + + const contentHeight = await page.evaluate(measureContentHeight); + const expandedHeight = Math.max( + contentHeight + SCREENSHOT_HEIGHT_PADDING_PX, + originalViewport.height + ); + + await page.setViewportSize({ + width: originalViewport.width, + height: expandedHeight, + }); + + await page.waitForFunction( + (minHeight) => document.documentElement.clientHeight >= minHeight, + expandedHeight + ); + + try { + await applyHighlights(); + + const screenshot = await page.screenshot(); + await test.info().attach(options.attachmentName, { + body: screenshot, + contentType: 'image/png', + }); + } finally { + await page.evaluate(removeDebugHighlights, options.debugId); + await page.setViewportSize(originalViewport); + } +} + +export async function attachDebugScreenshot( + root: Locator, + issues: DebugScreenshotIssue[], + options: DebugScreenshotOptions +): Promise { + const page = root.page(); + const selectors = issues.map((issue) => issue.selector); + + await captureDebugScreenshot( + page, + () => + root.evaluate(applyDebugHighlights, { + selectors, + debugId: options.debugId, + label: options.label, + }), + options + ); +} diff --git a/apps/golden-sample-app-e2e/src/expect/to-be-obscured.ts b/apps/golden-sample-app-e2e/src/expect/to-be-obscured.ts new file mode 100644 index 000000000..9e27c02e0 --- /dev/null +++ b/apps/golden-sample-app-e2e/src/expect/to-be-obscured.ts @@ -0,0 +1,147 @@ +import { Locator, expect as baseExpect } from '@playwright/test'; +import { attachDebugScreenshot } from './matcher-shared'; + +export type ObscuredAnalysis = { + obscured: boolean; + blockerLabel: string | null; +}; + +/** Runs in the browser — passed to locator.evaluate(). */ +function analyzeObscuration(el: Element): ObscuredAnalysis { + const rect = el.getBoundingClientRect(); + // Zero area means the element is not rendered — that's a visibility problem, + // not an occlusion one, so it is out of scope for SC 2.4.11. + if (rect.width === 0 || rect.height === 0) { + return { obscured: false, blockerLabel: null }; + } + + // Drill through open shadow roots to find the truly topmost element. + const deepElementFromPoint = (x: number, y: number): Element | null => { + let node = document.elementFromPoint(x, y); + while (node?.shadowRoot) { + const inner = node.shadowRoot.elementFromPoint(x, y); + if (!inner || inner === node) break; + node = inner; + } + return node; + }; + + // Composed-tree ancestor check (crosses shadow boundaries). + const isSelfOrInside = (node: Element | null): boolean => { + let cur: Node | null = node; + while (cur) { + if (cur === el) return true; + cur = cur instanceof ShadowRoot ? cur.host : cur.parentNode; + } + return false; + }; + + const inset = 1; + const candidatePoints: Array<[number, number]> = [ + [rect.left + rect.width / 2, rect.top + rect.height / 2], + [rect.left + inset, rect.top + inset], + [rect.right - inset, rect.top + inset], + [rect.left + inset, rect.bottom - inset], + [rect.right - inset, rect.bottom - inset], + ]; + + // Only points inside the viewport can be hit-tested reliably. + const points = candidatePoints.filter( + ([x, y]) => + x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight + ); + if (points.length === 0) { + return { obscured: false, blockerLabel: null }; + } + + // SC 2.4.11 (Minimum) only fails when the element is *entirely* hidden, so it + // is obscured only when none of the sampled points reach the element itself. + let topBlocker: Element | null = null; + const anyPartVisible = points.some(([x, y]) => { + const hit = deepElementFromPoint(x, y); + if (isSelfOrInside(hit)) return true; + if (hit && !topBlocker) topBlocker = hit; + return false; + }); + + const obscured = !anyPartVisible; + + let blockerLabel: string | null = null; + if (obscured && topBlocker) { + // Use getAttribute('class') — Element.className is an SVGAnimatedString + // (not a string) on SVG nodes and would throw on .trim(). + const classAttr = (topBlocker as Element).getAttribute('class'); + const className = classAttr + ? `.${classAttr.trim().split(/\s+/).join('.')}` + : ''; + blockerLabel = `${( + topBlocker as Element + ).tagName.toLowerCase()}${className}`; + } + + return { obscured, blockerLabel }; +} + +/** Returns whether the element is entirely covered by other content. */ +async function analyzeElementObscured( + locator: Locator +): Promise { + // Bring the element into the viewport so the hit-test points are valid. + await locator.scrollIntoViewIfNeeded(); + return locator.evaluate(analyzeObscuration); +} + +export const obscuredExpect = baseExpect.extend({ + async toBeObscured(locator: Locator, options?: { timeout?: number }) { + const timeout = options?.timeout ?? this.timeout ?? 5000; + + let analysis: ObscuredAnalysis = { obscured: false, blockerLabel: null }; + + // Auto-retry like a built-in assertion: poll the obscuration state until it + // matches the (possibly negated) expectation or the timeout elapses, then + // report the final sample with our own message + screenshot. + try { + await baseExpect + .poll( + async () => { + analysis = await analyzeElementObscured(locator); + return analysis.obscured; + }, + { timeout } + ) + .toBe(!this.isNot); + } catch { + // Timed out — fall through with the last sampled analysis. + } + + const { obscured, blockerLabel } = analysis; + + if (obscured) { + await attachScreenshot(locator); + } + + const el = await locator.textContent(); + + return { + pass: obscured, + name: 'toBeObscured', + message: () => { + const blocker = blockerLabel ? ` (covered by ${blockerLabel})` : ''; + + return this.isNot + ? `Expected ${el} element not to be obscured, but it is covered by other content${blocker}` + : `Expected ${el} element to be obscured, but it is fully visible`; + }, + log: blockerLabel ? [`covered by ${blockerLabel}`] : [], + }; + }, +}); + +async function attachScreenshot(locator: Locator) { + await locator.scrollIntoViewIfNeeded(); + await attachDebugScreenshot(locator, [{ selector: ':scope' }], { + debugId: 'a11y-obscured-debug', + label: 'obscured', + attachmentName: 'focus-obscured.png', + }); +} diff --git a/apps/golden-sample-app-e2e/src/expect/to-have-focus-order.ts b/apps/golden-sample-app-e2e/src/expect/to-have-focus-order.ts new file mode 100644 index 000000000..7b78c294d --- /dev/null +++ b/apps/golden-sample-app-e2e/src/expect/to-have-focus-order.ts @@ -0,0 +1,73 @@ +import { Locator, Page, expect as baseExpect } from '@playwright/test'; + +export type FocusableElement = { + tagName: string; + textContent?: string; + innerText?: string; +}; + +/** Collapse whitespace — innerText() uses block boundaries; exact spacing is not stable. */ +function normalizeText(text: string): string { + if (!text) { + return ''; + } + return text.replace(/\s+/g, ' ').trim(); +} + +async function getFocusedElement(page: Page): Promise { + const { tagName, textContent, innerText } = await page + .locator(':focus') + .evaluate((el) => ({ + tagName: el.tagName.toLowerCase(), + textContent: el.textContent ?? '', + innerText: (el as HTMLElement).innerText ?? '', + })); + + return { + tagName, + textContent: normalizeText(textContent), + innerText: normalizeText(innerText), + }; +} + +export const focusOrderExpect = baseExpect.extend({ + async toHaveFocusOrder(root: Locator, expected: FocusableElement[]) { + const page = root.page(); + const actual: FocusableElement[] = []; + actual.push(await getFocusedElement(page)); + for (let i = 1; i < expected.length; i++) { + await page.keyboard.press('Tab'); + actual.push(await getFocusedElement(page)); + } + + const pass = + actual.length === expected.length && + actual.every((el, idx) => { + const sameTag = el.tagName === expected[idx].tagName; + + if (expected[idx].textContent) { + return ( + sameTag && el.textContent?.startsWith(expected[idx].textContent) + ); + } + if (expected[idx].innerText) { + return sameTag && el.innerText?.startsWith(expected[idx].innerText); + } + return sameTag; + }); + + return { + // Positive result; Playwright inverts automatically for `.not`. + pass, + name: 'toHaveFocusOrder', + message: () => + `Expected focus order to match:\n${this.utils.printDiffOrStringify( + expected, + actual, + 'expected', + 'actual', + false + )}`, + }; + }, +}); diff --git a/apps/golden-sample-app-e2e/src/expect/to-overflow-viewport.ts b/apps/golden-sample-app-e2e/src/expect/to-overflow-viewport.ts new file mode 100644 index 000000000..849be4d6e --- /dev/null +++ b/apps/golden-sample-app-e2e/src/expect/to-overflow-viewport.ts @@ -0,0 +1,268 @@ +import { Locator, expect as baseExpect } from '@playwright/test'; +import { + attachDebugScreenshot, + DEFAULT_TOLERANCE_PX, + MatcherExclusion, + resolveExclusionSelectors, +} from './matcher-shared'; + +export type OverflowReason = + | 'boxOutsideViewport' + | 'contentWiderThanBox' + | 'textOutsideViewport'; + +export type ViewportOverflowExclusion = MatcherExclusion; + +/** Serializable overflow report — locators are rebuilt from `selector` + root. */ +export type OverflowIssue = { + /** CSS selector relative to the root locator (starts with `:scope` for the root itself). */ + selector: string; + reasons: OverflowReason[]; + text: string; + right: number; + scrollWidth: number; + clientWidth: number; +}; + +type ScanResult = OverflowIssue[]; + +/** Runs in the browser — passed to locator.evaluate(). */ +function scanOverflowIssues( + root: Element, + args: { tolerance: number; exclusionSelectors: string[] } +): ScanResult { + const { tolerance, exclusionSelectors } = args; + + // Layout viewport width excluding any scrollbar (more accurate than + // window.innerWidth, which includes the scrollbar gutter). + const viewportWidth = document.documentElement.clientWidth; + + const relativeSelector = (el: Element): string => { + if (el === root) return ':scope'; + + const segments: string[] = []; + let node: Element | null = el; + + while (node && node !== root) { + const parent: HTMLElement | null = node.parentElement; + if (!parent) break; + + const index = Array.from(parent.children).indexOf(node) + 1; + segments.unshift(`${node.tagName.toLowerCase()}:nth-child(${index})`); + node = parent; + } + + return segments.join(' > '); + }; + + const isVisible = (el: Element): boolean => { + const rect = el.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return false; + + const style = getComputedStyle(el); + return style.display !== 'none' && style.visibility !== 'hidden'; + }; + + const isExcluded = (el: Element): boolean => { + for (const sel of exclusionSelectors) { + const excludedElements = + sel === ':scope' ? [root] : Array.from(root.querySelectorAll(sel)); + + for (const excluded of excludedElements) { + if (el === excluded || excluded.contains(el)) { + return true; + } + } + } + + return false; + }; + + // WCAG 2.1 SC 1.4.10 (Reflow) exempts content that requires two-dimensional + // layout for usage or meaning — data tables, maps, diagrams, video, etc. The + // sanctioned authoring technique is to place such content inside a horizontal + // scroll container so the page itself still reflows. When an element lives + // inside such a container (within the scanned root), its own box/content/text + // is allowed to extend past the viewport, so we skip overflow checks for it. + const hasHorizontalScrollAncestor = (el: Element): boolean => { + let node: Element | null = el.parentElement; + + while (node) { + const overflowX = getComputedStyle(node).overflowX; + if (overflowX === 'auto' || overflowX === 'scroll') return true; + if (node === root) break; + node = node.parentElement; + } + + return false; + }; + + const hasDirectTextOutsideViewport = (el: Element): boolean => { + for (const child of Array.from(el.childNodes)) { + if ( + child.nodeType !== Node.TEXT_NODE || + !(child.textContent || '').trim() + ) { + continue; + } + + const range = document.createRange(); + range.selectNodeContents(child); + const textRect = range.getBoundingClientRect(); + + if ( + textRect.right > viewportWidth + tolerance || + textRect.left < -tolerance + ) { + return true; + } + } + + return false; + }; + + const isStrictAncestorSelector = ( + ancestor: string, + descendant: string + ): boolean => { + if (ancestor === descendant) return false; + if (descendant === ':scope') return false; + if (ancestor === ':scope') return true; + + return descendant.startsWith(`${ancestor} > `); + }; + + const keepLeafOffenders = (allIssues: ScanResult): ScanResult => + allIssues.filter( + (candidate) => + !allIssues.some( + (other) => + candidate !== other && + isStrictAncestorSelector(candidate.selector, other.selector) + ) + ); + + const issues: ScanResult = []; + + for (const el of [root, ...Array.from(root.querySelectorAll('*'))]) { + if (!isVisible(el) || isExcluded(el)) continue; + + const rect = el.getBoundingClientRect(); + const style = getComputedStyle(el); + const reasons: OverflowReason[] = []; + + // Content nested inside a horizontal scroll container is part of a + // WCAG 1.4.10 excepted region (the scroller scopes the exception), so its + // overflow is intentional and must not be reported. + const insideHorizontalScroll = hasHorizontalScrollAncestor(el); + + if ( + !insideHorizontalScroll && + (rect.left < -tolerance || rect.right > viewportWidth + tolerance) + ) { + reasons.push('boxOutsideViewport'); + } + + // Content can only spill out of its box and push the viewport wider when + // overflow-x is `visible`. Any other value contains the content: + // - `auto`/`scroll` => intentional horizontal scroll container (the + // recommended WCAG 1.4.10 technique), so wider content is by design. + // - `hidden`/`clip` => content is clipped and never rendered outside the + // box, e.g. Bootstrap `.visually-hidden` screen-reader-only labels + // (position:absolute; width:1px; overflow:hidden; clip:rect(0 0 0 0)), + // which report scrollWidth >> clientWidth but cannot cause overflow. + // In all non-visible cases the content cannot reflow the page, so reporting + // it as overflow would be a false positive. + const overflowXContainsContent = style.overflowX !== 'visible'; + + if ( + !overflowXContainsContent && + !insideHorizontalScroll && + el.scrollWidth > el.clientWidth + tolerance + ) { + reasons.push('contentWiderThanBox'); + } + + if ( + !overflowXContainsContent && + !insideHorizontalScroll && + hasDirectTextOutsideViewport(el) + ) { + reasons.push('textOutsideViewport'); + } + + if (reasons.length === 0) continue; + + issues.push({ + selector: relativeSelector(el), + reasons, + text: (el.textContent || '').trim().slice(0, 50), + right: Math.round(rect.right), + scrollWidth: el.scrollWidth, + clientWidth: el.clientWidth, + }); + } + + return keepLeafOffenders(issues); +} + +/** Scan a root locator (e.g. main) and return serializable overflow issues. */ +async function findOverflowIssues( + root: Locator, + exclusions: ViewportOverflowExclusion[] = [], + tolerance = DEFAULT_TOLERANCE_PX +): Promise { + const exclusionSelectors = await resolveExclusionSelectors(root, exclusions); + + return root.evaluate(scanOverflowIssues, { + tolerance, + exclusionSelectors, + }); +} + +function formatIssues(issues: OverflowIssue[]): string { + return issues + .map( + (issue) => + ` ${issue.selector}\n` + + ` reasons: ${issue.reasons.join(', ')}\n` + + ` right: ${issue.right}px, scrollWidth: ${issue.scrollWidth}, clientWidth: ${issue.clientWidth}` + + (issue.text ? `\n text: "${issue.text}"` : '') + ) + .join('\n\n'); +} + +export const viewportOverflowExpect = baseExpect.extend({ + async toOverflowViewPort( + root: Locator, + exclusions: ViewportOverflowExclusion[] = [], + options?: { tolerance?: number } + ) { + const tolerance = options?.tolerance ?? DEFAULT_TOLERANCE_PX; + const issues = await findOverflowIssues(root, exclusions, tolerance); + const pass = issues.length > 0; + + if (pass) { + await attachDebugScreenshot(root, issues, { + debugId: 'a11y-overflow-debug', + label: 'overflow', + attachmentName: 'viewport-overflow.png', + }); + } + + return { + // `pass` is the positive result ("does overflow"); Playwright inverts it + // automatically for `.not.toOverflowViewPort()`. + pass, + name: 'toOverflowViewPort', + expected: [], + actual: issues, + message: () => + this.isNot + ? `Expected no viewport overflow inside root, but found ${ + issues.length + } overflowing element(s):\n\n${formatIssues(issues)}` + : `Expected viewport overflow inside root, but found none`, + }; + }, +}); diff --git a/apps/golden-sample-app-e2e/src/fixtures/test.ts b/apps/golden-sample-app-e2e/src/fixtures/test.ts index 824a882b5..ca930960c 100644 --- a/apps/golden-sample-app-e2e/src/fixtures/test.ts +++ b/apps/golden-sample-app-e2e/src/fixtures/test.ts @@ -1,7 +1,10 @@ import { VisualValidator } from '@backbase/e2e-tests'; import { IdentityPage } from '../page-objects/pages/identity-page'; import { User } from '../data/data-types/user'; -import { TransactionsPage } from '@backbase/transactions-journey/e2e-tests'; +import { + TransactionsPage, + MakeTransferPage, +} from '@backbase/transactions-journey/e2e-tests'; import { test as baseTest } from '@playwright/test'; import { ProjectTestArgs } from './environment'; @@ -10,6 +13,7 @@ export interface TestOptions { visual: VisualValidator; identityPage: IdentityPage; transactionsPage: TransactionsPage; + makeTransferPage: MakeTransferPage; userType: string; user: User; } @@ -32,6 +36,13 @@ export const test = baseTest.extend({ transactionsPage: async ({ page, baseURL }, use, testInfo) => { await use(new TransactionsPage(page, { baseURL, testInfo })); }, + makeTransferPage: async ({ page, baseURL }, use) => { + await use( + new MakeTransferPage(page, { + baseURL: `${baseURL}/transfer/make-transfer`, + }) + ); + }, visual: async ({ page }, use) => { await use(new VisualValidator(page)); }, diff --git a/apps/golden-sample-app-e2e/src/specs/custom-tests/make-a-transfer.a11y.spec.ts b/apps/golden-sample-app-e2e/src/specs/custom-tests/make-a-transfer.a11y.spec.ts new file mode 100644 index 000000000..72ac1fa02 --- /dev/null +++ b/apps/golden-sample-app-e2e/src/specs/custom-tests/make-a-transfer.a11y.spec.ts @@ -0,0 +1,78 @@ +import { test } from '../../fixtures/test'; +import { expect } from '../../expect/expect'; +import { TestInfo } from '@playwright/test'; + +test.describe( + 'Make a Transfer Page A11y tests', + { tag: ['@a11y', '@e2e', '@mocks'] }, + () => { + test.beforeEach(async ({ makeTransferPage }) => { + await makeTransferPage.open(); + await expect(makeTransferPage.pageHeader).toBeVisible(); + }); + + test( + 'Validate Make a Transfer page accessibility', + { tag: ['@axe'] }, + async ({ makeTransferPage }, testInfo: TestInfo) => { + await expect({ + page: makeTransferPage.page, + testInfo: testInfo, + }).toBeAccessible(); + } + ); + + test( + 'Validate Make a Transfer tab Order', + { tag: ['@tab-order'] }, + async ({ makeTransferPage }) => { + await makeTransferPage.toAccount.element.focus(); + await expect(makeTransferPage.element).toHaveFocusOrder([ + { tagName: 'input', textContent: '' }, // To Account + { tagName: 'select', textContent: 'USD USD EUR' }, // Currency Input + { tagName: 'input', textContent: '' }, // Integer Input + { tagName: 'input', textContent: '' }, // Decimal Input + { tagName: 'button', textContent: 'Submit' }, // Submit Button + ]); + } + ); + + test( + 'Validate Make a Transfer page reflow', + { tag: ['@reflow'] }, + async ({ makeTransferPage, page }) => { + await page.setViewportSize({ width: 320, height: 256 }); + const locator = makeTransferPage.locator('bb-transfer-journey'); + await expect(locator).not.toOverflowViewPort(); + } + ); + + test( + 'Validate Make Transfer Aria Snapshot', + { tag: ['@aria-snapshot'] }, + async ({ makeTransferPage }) => { + await expect(makeTransferPage.element).toMatchAriaSnapshot(); + } + ); + + test( + 'Validate focus obscured', + { tag: ['@focus-obscured'] }, + async ({ makeTransferPage }) => { + const locators = [ + makeTransferPage.toAccount.element, + makeTransferPage.amount.currencyInput, + makeTransferPage.amount.valueInput, + makeTransferPage.amount.decimalsInput, + makeTransferPage.submitButton, + ]; + for (const locator of locators) { + expect(locator).toBeDefined(); + await locator?.focus(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await expect.soft(locator!).not.toBeObscured(); + } + } + ); + } +); diff --git a/apps/golden-sample-app-e2e/src/specs/custom-tests/make-a-transfer.a11y.spec.ts-snapshots/Make-a-Transfer-Page-A11y-tests-Validate-Make-Transfer-Aria-Snapshot-1.aria.yml b/apps/golden-sample-app-e2e/src/specs/custom-tests/make-a-transfer.a11y.spec.ts-snapshots/Make-a-Transfer-Page-A11y-tests-Validate-Make-Transfer-Aria-Snapshot-1.aria.yml new file mode 100644 index 000000000..7880ad502 --- /dev/null +++ b/apps/golden-sample-app-e2e/src/specs/custom-tests/make-a-transfer.a11y.spec.ts-snapshots/Make-a-Transfer-Page-A11y-tests-Validate-Make-Transfer-Aria-Snapshot-1.aria.yml @@ -0,0 +1,14 @@ +- heading "Make a transfer" [level=1] +- text: From account +- textbox "From account" [disabled]: "/Ankit's Current Account: \\d+/" +- text: To Account +- textbox "To Account" +- text: Amount Currency List Dropdown +- combobox "Currency": + - option "USD" [selected] + - option "EUR" +- text: Integer +- textbox "Integer" +- text: . Decimals +- textbox "Decimals" +- button "Submit" \ No newline at end of file diff --git a/apps/golden-sample-app-e2e/src/specs/custom-tests/overflow-matcher.a11y.viewport.spec.ts b/apps/golden-sample-app-e2e/src/specs/custom-tests/overflow-matcher.a11y.viewport.spec.ts new file mode 100644 index 000000000..e9d5b222f --- /dev/null +++ b/apps/golden-sample-app-e2e/src/specs/custom-tests/overflow-matcher.a11y.viewport.spec.ts @@ -0,0 +1,103 @@ +/** + * This file is just to test the toOverflowViewPort matcher. + * It does not test the application + */ +import { test } from '@playwright/test'; +import { expect } from '../../expect/expect'; + +/** + * Unit-style coverage for the `toOverflowViewPort` matcher's handling of the + * WCAG 2.1 SC 1.4.10 (Reflow) exception for content that requires + * two-dimensional layout (data tables, maps, diagrams, video, ...) and for + * clipped, visually-hidden content. + * + * These tests are self-contained (page.setContent) and do not need the app or + * the mock server. + */ +test.describe( + 'toOverflowViewPort — WCAG 1.4.10 overflow exceptions', + { tag: ['@a11y', '@e2e', '@viewport'] }, + () => { + test.use({ viewport: { width: 320, height: 600 } }); + + const wideTable = ` + + + + + + + + + + + +
AccountDateDescription with a fairly long labelCategoryReference numberAmount
`; + + test('passes when wide content sits inside a horizontal scroll container', async ({ + page, + }) => { + await page.setContent(` + +
+

Transactions

+
${wideTable}
+
+ `); + + const root = page.locator('#root'); + + await expect(root).not.toOverflowViewPort(); + }); + + test('still fails when the same wide content has no scroll container', async ({ + page, + }) => { + await page.setContent(` + +
+

Transactions

+
${wideTable}
+
+ `); + + const root = page.locator('#root'); + + await expect(root).toOverflowViewPort(); + }); + + // Screen-reader-only labels (Bootstrap `.visually-hidden`) collapse to a + // 1px clipped box while keeping their full text for assistive tech. Their + // scrollWidth >> clientWidth, but overflow:hidden means nothing renders + // outside the box, so they must not be reported as overflow. + test('passes for visually-hidden (clipped) labels with wide content', async ({ + page, + }) => { + await page.setContent(` + + +
+ + + +
+ `); + + const root = page.locator('#root'); + + await expect(root).not.toOverflowViewPort(); + }); + } +); diff --git a/apps/golden-sample-app-e2e/src/specs/custom-tests/transactions.a11y.spec.ts b/apps/golden-sample-app-e2e/src/specs/custom-tests/transactions.a11y.spec.ts index d6f6de5ba..cb3220088 100644 --- a/apps/golden-sample-app-e2e/src/specs/custom-tests/transactions.a11y.spec.ts +++ b/apps/golden-sample-app-e2e/src/specs/custom-tests/transactions.a11y.spec.ts @@ -1,12 +1,14 @@ import { test } from '../../fixtures/test'; -import { expect } from '@playwright/test'; +import { expect } from '../../expect/expect'; test.describe( 'Transaction Page A11y tests', - { tag: ['@a11y', '@e2e', '@mocks'] }, + { tag: ['@a11y', '@e2e', '@mocks', '@axe'] }, () => { test.beforeEach(async ({ transactionsPage }) => { await transactionsPage.open(); + await expect(transactionsPage.pageHeader).toBeVisible(); + await expect(transactionsPage.transactions.element.first()).toBeVisible(); }); test('Validate Transactions page accessibility with disabled rules', async ({ @@ -33,7 +35,7 @@ test.describe( await test.step('Validate Transaction element accessibility', async () => { await expect({ page, testInfo }).toBeAccessible({ include: 'bb-transaction-item', - disableRules: ['color-contrast'], + // disableRules: ['color-contrast'], }); }); }); diff --git a/apps/golden-sample-app-e2e/src/specs/custom-tests/transactions.a11y.viewport.spec.ts b/apps/golden-sample-app-e2e/src/specs/custom-tests/transactions.a11y.viewport.spec.ts new file mode 100644 index 000000000..1f4b02f37 --- /dev/null +++ b/apps/golden-sample-app-e2e/src/specs/custom-tests/transactions.a11y.viewport.spec.ts @@ -0,0 +1,24 @@ +import { test } from '../../fixtures/test'; +import { expect } from '../../expect/expect'; + +test.describe( + 'Transaction Page A11y Viewport tests', + { tag: ['@a11y', '@e2e', '@mocks', '@reflow'] }, + () => { + test.use({ viewport: { width: 320, height: 256 } }); + + test.beforeEach(async ({ transactionsPage }) => { + await transactionsPage.open(); + await expect(transactionsPage.pageHeader).toBeVisible(); + await expect(transactionsPage.transactions.element.first()).toBeVisible(); + }); + + test('Validate Overflows for transactions list', async ({ page }) => { + const transactionsView = page.locator('bb-transactions-view'); + + await test.step('Validate Overflows for transactions list', async () => { + await expect(transactionsView).not.toOverflowViewPort(); + }); + }); + } +); diff --git a/apps/golden-sample-app/src/app/app.component.html b/apps/golden-sample-app/src/app/app.component.html index 7ce9d4633..348b6d4e9 100644 --- a/apps/golden-sample-app/src/app/app.component.html +++ b/apps/golden-sample-app/src/app/app.component.html @@ -111,9 +111,9 @@ > -
+
-
+ diff --git a/apps/golden-sample-app/src/app/locale-selector/locale-selector.component.html b/apps/golden-sample-app/src/app/locale-selector/locale-selector.component.html index 48e9da3a9..ba502739b 100644 --- a/apps/golden-sample-app/src/app/locale-selector/locale-selector.component.html +++ b/apps/golden-sample-app/src/app/locale-selector/locale-selector.component.html @@ -5,8 +5,6 @@ icon="caret-down" [options]="localesCatalog" (select)="language = $event" -> - - {{ language?.language }} - - + [label]="language?.language ?? ''" + [ariaLabel]="language?.language ?? ''" +/> diff --git a/apps/golden-sample-app/src/app/theme-switcher/theme-switcher.component.html b/apps/golden-sample-app/src/app/theme-switcher/theme-switcher.component.html index 906a1e786..631967f44 100644 --- a/apps/golden-sample-app/src/app/theme-switcher/theme-switcher.component.html +++ b/apps/golden-sample-app/src/app/theme-switcher/theme-switcher.component.html @@ -2,9 +2,9 @@ + [ariaLabel]="theme" + /> diff --git a/eslint.config.mjs b/eslint.config.mjs index 000cbf512..082c23003 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,6 +4,8 @@ export default [ ...nx.configs['flat/base'], ...nx.configs['flat/typescript'], ...nx.configs['flat/javascript'], + ...nx.configs['flat/angular'], + ...nx.configs['flat/angular-template'], { ignores: ['**/dist'], }, diff --git a/libs/shared/util/e2e-tests/src/page-objects/_base-component.ts b/libs/shared/util/e2e-tests/src/page-objects/_base-component.ts index 4d1fd207f..32ad3db9c 100644 --- a/libs/shared/util/e2e-tests/src/page-objects/_base-component.ts +++ b/libs/shared/util/e2e-tests/src/page-objects/_base-component.ts @@ -1,4 +1,5 @@ -import { Locator, Page, test, TestInfo, expect } from '@playwright/test'; +import { Locator, Page, test, TestInfo } from '@playwright/test'; +import { a11yExpect as expect } from '../utils/custom-assertions/a11y-expect'; import { BasePage } from './_base-page'; import { VisualValidator, isLocator } from '../utils'; import { PageInfo } from './page-info'; diff --git a/libs/shared/util/e2e-tests/src/page-objects/_base-page.ts b/libs/shared/util/e2e-tests/src/page-objects/_base-page.ts index 8ed652d52..5c71ed127 100644 --- a/libs/shared/util/e2e-tests/src/page-objects/_base-page.ts +++ b/libs/shared/util/e2e-tests/src/page-objects/_base-page.ts @@ -1,4 +1,5 @@ -import { Page, test, TestInfo, expect } from '@playwright/test'; +import { Page, test, TestInfo } from '@playwright/test'; +import { a11yExpect as expect } from '../utils/custom-assertions/a11y-expect'; import { VisualValidator, joinUrl } from '../utils'; import { PageInfo } from './page-info'; diff --git a/libs/shared/util/e2e-tests/src/utils/a11y/a11y-scanner.ts b/libs/shared/util/e2e-tests/src/utils/a11y/a11y-scanner.ts index f4e38dac9..89c62b122 100644 --- a/libs/shared/util/e2e-tests/src/utils/a11y/a11y-scanner.ts +++ b/libs/shared/util/e2e-tests/src/utils/a11y/a11y-scanner.ts @@ -28,6 +28,9 @@ export class A11yScanner { 'wcag2aa', 'wcag21a', 'wcag21aa', + 'wcag22aa', + 'best-practice', + 'experimental', ]); if (options?.disableRules) { this.axeBuilder.disableRules(options.disableRules); diff --git a/libs/shared/util/e2e-tests/src/utils/custom-assertions/a11y-expect.ts b/libs/shared/util/e2e-tests/src/utils/custom-assertions/a11y-expect.ts index f1a92df04..adb60a6d1 100644 --- a/libs/shared/util/e2e-tests/src/utils/custom-assertions/a11y-expect.ts +++ b/libs/shared/util/e2e-tests/src/utils/custom-assertions/a11y-expect.ts @@ -6,17 +6,6 @@ import { } from '@playwright/test'; import { A11yScanner, timeID } from '@backbase/e2e-tests'; -declare global { - namespace PlaywrightTest { - interface Matchers { - toBeAccessible(options?: { - include?: Locator | string; - disableRules?: string[]; - }): R; - } - } -} - export const a11yExpect = baseExpect.extend({ async toBeAccessible( pageObject: { page: Page; testInfo: TestInfo }, diff --git a/libs/transactions-journey/e2e-tests/index.ts b/libs/transactions-journey/e2e-tests/index.ts index f476b64f3..e182d7850 100644 --- a/libs/transactions-journey/e2e-tests/index.ts +++ b/libs/transactions-journey/e2e-tests/index.ts @@ -1,6 +1,7 @@ // Export everything from the tests lib from journey. export * from './page-objects/pages/transaction-details-page'; export * from './page-objects/pages/transactions-list-page'; +export * from './page-objects/pages/make-transfer'; export * from './specs/transaction-details.spec'; export * from './specs/transactions-list.spec'; diff --git a/libs/transactions-journey/e2e-tests/page-objects/pages/make-transfer.ts b/libs/transactions-journey/e2e-tests/page-objects/pages/make-transfer.ts index b5b415dc5..32439a5e5 100644 --- a/libs/transactions-journey/e2e-tests/page-objects/pages/make-transfer.ts +++ b/libs/transactions-journey/e2e-tests/page-objects/pages/make-transfer.ts @@ -1,19 +1,31 @@ import { BasePage } from '@backbase/e2e-tests'; import { AmountComponent, AccountSelector } from '../ui-components'; -import { test } from '@playwright/test'; +import { Locator, test } from '@playwright/test'; import type { Transfer } from '../../data/transfer'; export class MakeTransferPage extends BasePage { - fromAccount = new AccountSelector(this.byTestId('from-account')); - toAccount = new AccountSelector(this.byTestId('to-account')); - amount = new AmountComponent(this.locator('bb-currency-input-ui')); + rootLocator = this.locator('bb-transfer-journey'); + fromAccountLocator = this.rootLocator.getByRole('textbox', { + name: 'From Account', + }); + fromAccount = new AccountSelector(this.fromAccountLocator); + toAccountLocator = this.rootLocator.getByRole('textbox', { + name: 'To Account', + }); + toAccount = new AccountSelector(this.toAccountLocator); + amount = new AmountComponent( + this.rootLocator.locator('bb-currency-input-ui') + ); + submitButton = this.rootLocator.getByRole('button', { name: 'Submit' }); noteTextarea = this.byTestId('note-textarea'); continueButton = this.locator('continue-button'); clearButton = this.locator('clear-button'); async fillInTransferDetails(transfer: Transfer) { await test.step('Fill in transfer details', async () => { - await this.fromAccount.select(transfer.fromAccount); + if (await this.fromAccountLocator.isEnabled()) { + await this.fromAccount.select(transfer.fromAccount); + } await this.toAccount.select(transfer.toAccount); await this.amount.fill(transfer.amount); await this.noteTextarea.fill(transfer.note); @@ -31,4 +43,8 @@ export class MakeTransferPage extends BasePage { await this.clearButton.click(); }); } + + get element(): Locator { + return this.rootLocator; + } } diff --git a/libs/transactions-journey/e2e-tests/page-objects/ui-components/amount.ts b/libs/transactions-journey/e2e-tests/page-objects/ui-components/amount.ts index 360134898..c102c772e 100644 --- a/libs/transactions-journey/e2e-tests/page-objects/ui-components/amount.ts +++ b/libs/transactions-journey/e2e-tests/page-objects/ui-components/amount.ts @@ -2,11 +2,14 @@ import { BaseComponent } from '@backbase/e2e-tests'; import type { Amount } from '../../data/amount'; export class AmountComponent extends BaseComponent { - currencyInput = this.childByTestId('currency'); - valueInput = this.childByTestId('value'); - decimalsInput = this.childByTestId('s'); + currencyInput = this.rootLocator?.getByRole('combobox', { name: 'Currency' }); + valueInput = this.rootLocator?.getByRole('textbox', { name: 'Integer' }); + decimalsInput = this.rootLocator?.getByRole('textbox', { name: 'Decimals' }); async fill(amount: Amount) { + if (!this.valueInput || !this.decimalsInput) { + throw new Error('Value or decimals input not found'); + } await this.valueInput.fill(amount.integer); await this.decimalsInput.fill(amount.decimal); } diff --git a/libs/transactions-journey/internal/feature-transaction-view/src/lib/components/transaction-item/transaction-item.component.scss b/libs/transactions-journey/internal/feature-transaction-view/src/lib/components/transaction-item/transaction-item.component.scss index 06904ea8c..775defae0 100644 --- a/libs/transactions-journey/internal/feature-transaction-view/src/lib/components/transaction-item/transaction-item.component.scss +++ b/libs/transactions-journey/internal/feature-transaction-view/src/lib/components/transaction-item/transaction-item.component.scss @@ -38,11 +38,11 @@ .transactions-item__transactionType { font-size: 0.8rem; - color: gray; + color: rgb(70, 70, 70); } .transactions-item__amount { &.negative { - color: rgb(209, 76, 76); + color: rgb(202, 73, 73); } } diff --git a/libs/transactions-journey/internal/feature-transaction-view/src/lib/components/transactions-view/transactions-view.component.html b/libs/transactions-journey/internal/feature-transaction-view/src/lib/components/transactions-view/transactions-view.component.html index dee1ac9cb..806dfe207 100644 --- a/libs/transactions-journey/internal/feature-transaction-view/src/lib/components/transactions-view/transactions-view.component.html +++ b/libs/transactions-journey/internal/feature-transaction-view/src/lib/components/transactions-view/transactions-view.component.html @@ -1,4 +1,4 @@ -
+
@if (title !== '') { diff --git a/libs/transfer-journey/internal/ui/src/lib/components/make-transfer-form/make-transfer-form.component.html b/libs/transfer-journey/internal/ui/src/lib/components/make-transfer-form/make-transfer-form.component.html index e1c064249..e6dbf1a89 100644 --- a/libs/transfer-journey/internal/ui/src/lib/components/make-transfer-form/make-transfer-form.component.html +++ b/libs/transfer-journey/internal/ui/src/lib/components/make-transfer-form/make-transfer-form.component.html @@ -5,9 +5,15 @@ - +
-
+

{{ title }}