From ff06e5c34475b0f1c7a270a0bca669ff6cd304ec Mon Sep 17 00:00:00 2001 From: Tomasz Kasowicz Date: Tue, 16 Jun 2026 16:41:56 +0200 Subject: [PATCH 01/15] test: extend axe validations with best-practice, experimental, wcag22aa --- .../src/specs/custom-tests/transactions.a11y.spec.ts | 4 +++- apps/golden-sample-app/src/app/app.component.html | 4 ++-- .../app/locale-selector/locale-selector.component.html | 8 +++----- .../src/app/theme-switcher/theme-switcher.component.html | 4 ++-- libs/shared/util/e2e-tests/src/utils/a11y/a11y-scanner.ts | 3 +++ .../transaction-item/transaction-item.component.scss | 4 ++-- 6 files changed, 15 insertions(+), 12 deletions(-) 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..94208ab20 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 @@ -7,6 +7,8 @@ test.describe( () => { 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/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/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/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); } } From 7130c0a4d20e2e3c8c0cd0937c5d7d6039156908 Mon Sep 17 00:00:00 2001 From: Tomasz Kasowicz Date: Tue, 16 Jun 2026 17:53:02 +0200 Subject: [PATCH 02/15] test: add matchers --- .../src/expect/expect.ts | 30 ++ .../src/expect/matcher-shared.ts | 291 ++++++++++++++++++ .../src/expect/to-be-obscured.ts | 141 +++++++++ .../src/expect/to-have-focus-order.ts | 69 +++++ ...have-elements-with-text-outside-the-box.ts | 208 +++++++++++++ .../src/expect/to-overflow-viewport.ts | 267 ++++++++++++++++ 6 files changed, 1006 insertions(+) create mode 100644 apps/golden-sample-app-e2e/src/expect/expect.ts create mode 100644 apps/golden-sample-app-e2e/src/expect/matcher-shared.ts create mode 100644 apps/golden-sample-app-e2e/src/expect/to-be-obscured.ts create mode 100644 apps/golden-sample-app-e2e/src/expect/to-have-focus-order.ts create mode 100644 apps/golden-sample-app-e2e/src/expect/to-not-have-elements-with-text-outside-the-box.ts create mode 100644 apps/golden-sample-app-e2e/src/expect/to-overflow-viewport.ts 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..f147a2b4b --- /dev/null +++ b/apps/golden-sample-app-e2e/src/expect/expect.ts @@ -0,0 +1,30 @@ +import { mergeExpects } from '@playwright/test'; +import { obscuredExpect } from './to-be-obscured'; +import { textOutsideBoxExpect } from './to-not-have-elements-with-text-outside-the-box'; +import { viewportOverflowExpect } from './to-overflow-viewport'; +import { focusOrderExpect } from './to-have-focus-order'; + +export type { ObscuredAnalysis } from './to-be-obscured'; +export type { + OverflowIssue, + OverflowReason, + ViewportOverflowExclusion, +} from './to-overflow-viewport'; +export type { + TextOutsideBoxExclusion, + TextOutsideBoxIssue, +} from './to-not-have-elements-with-text-outside-the-box'; + +export { analyzeElementObscured } from './to-be-obscured'; +export { + findOverflowIssues, + overflowIssueLocator, +} from './to-overflow-viewport'; +export { findTextOutsideBoxIssues } from './to-not-have-elements-with-text-outside-the-box'; + +export const expect = mergeExpects( + viewportOverflowExpect, + focusOrderExpect, + obscuredExpect, + textOutsideBoxExpect +); 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..a80b36812 --- /dev/null +++ b/apps/golden-sample-app-e2e/src/expect/matcher-shared.ts @@ -0,0 +1,291 @@ +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]; + + const containers = [ + document.documentElement, + document.body, + document.querySelector('mat-sidenav-content'), + document.querySelector('mat-sidenav-container'), + document.querySelector('main'), + ]; + + for (const container of containers) { + if (!(container instanceof HTMLElement)) continue; + + const rect = container.getBoundingClientRect(); + heights.push(container.scrollHeight, rect.top + container.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(); +} + +/** Runs in the browser — passed to page.evaluate(). */ +function applyPageDebugHighlights(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 = document.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); +} + +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 + ); +} + +export async function attachPageDebugScreenshot( + page: Page, + issues: DebugScreenshotIssue[], + options: DebugScreenshotOptions +): Promise { + const selectors = issues.map((issue) => issue.selector); + + await captureDebugScreenshot( + page, + () => + page.evaluate(applyPageDebugHighlights, { + 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..8f64b44f1 --- /dev/null +++ b/apps/golden-sample-app-e2e/src/expect/to-be-obscured.ts @@ -0,0 +1,141 @@ +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. */ +export 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; + const deadline = Date.now() + timeout; + const intervals = [100, 250, 500, 1000]; + + // Auto-retry like a built-in assertion: re-sample until the (possibly + // negated) expectation is satisfied or the timeout elapses. The matcher + // fails when `obscured === isNot`, so we keep polling while that holds. + let analysis = await analyzeElementObscured(locator); + for (let attempt = 0; analysis.obscured === this.isNot; attempt++) { + if (Date.now() >= deadline) break; + const wait = intervals[Math.min(attempt, intervals.length - 1)]; + await new Promise((resolve) => setTimeout(resolve, wait)); + analysis = await analyzeElementObscured(locator); + } + + 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..3e813c9fd --- /dev/null +++ b/apps/golden-sample-app-e2e/src/expect/to-have-focus-order.ts @@ -0,0 +1,69 @@ +import { 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 focusedElement = page.locator(':focus'); + const textContent = await focusedElement.textContent(); + const innerText = await focusedElement.innerText(); + // const ariaSnapshot = await focusedElement.ariaSnapshot(); + const tag = await focusedElement.evaluate((el) => el.tagName.toLowerCase()); + return { + tagName: tag, + textContent: normalizeText(textContent ?? ''), + innerText: normalizeText(innerText ?? ''), + }; +} + +export const focusOrderExpect = baseExpect.extend({ + async toHaveFocusOrder(page: Page, expected: FocusableElement[]) { + const actual: FocusableElement[] = []; + for (let i = 0; i < expected.length; i++) { + await page.keyboard.press('Tab'); + + const currentFocus = await getFocusedElement(page); + actual.push(currentFocus); + } + + 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 { + pass: this.isNot ? !pass : pass, + name: 'toHaveFocusOrder', + message: () => + `Expected different focus order ${this.utils.printDiffOrStringify( + expected, + actual, + 'expected', + 'actual', + false + )}`, + }; + }, +}); diff --git a/apps/golden-sample-app-e2e/src/expect/to-not-have-elements-with-text-outside-the-box.ts b/apps/golden-sample-app-e2e/src/expect/to-not-have-elements-with-text-outside-the-box.ts new file mode 100644 index 000000000..2195c9f27 --- /dev/null +++ b/apps/golden-sample-app-e2e/src/expect/to-not-have-elements-with-text-outside-the-box.ts @@ -0,0 +1,208 @@ +import { Locator, expect as baseExpect } from '@playwright/test'; +import { + attachDebugScreenshot, + DEFAULT_TOLERANCE_PX, + MatcherExclusion, + resolveExclusionSelectors, +} from './matcher-shared'; + +export type TextOutsideBoxExclusion = MatcherExclusion; + +/** Serializable text-outside-box report — locators are rebuilt from `selector` + root. */ +export type TextOutsideBoxIssue = { + /** CSS selector relative to the root locator (starts with `:scope` for the root itself). */ + selector: string; + text: string; + boxRight: number; + textRight: number; + boxBottom: number; + textBottom: number; +}; + +type TextOutsideBoxScanResult = TextOutsideBoxIssue[]; + +/** Runs in the browser — passed to locator.evaluate(). */ +function scanTextOutsideBoxIssues( + root: Element, + args: { tolerance: number; exclusionSelectors: string[] } +): TextOutsideBoxScanResult { + const { tolerance, exclusionSelectors } = args; + + 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; + }; + + const getTextOutsideBoxIssue = ( + el: Element + ): Omit | null => { + const box = el.getBoundingClientRect(); + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); + + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + if (!(node.textContent || '').trim()) continue; + + const range = document.createRange(); + range.selectNodeContents(node); + const text = range.getBoundingClientRect(); + + if ( + text.right > box.right + tolerance || + text.left < box.left - tolerance || + text.bottom > box.bottom + tolerance || + text.top < box.top - tolerance + ) { + return { + text: (node.textContent || '').trim().slice(0, 50), + boxRight: Math.round(box.right), + textRight: Math.round(text.right), + boxBottom: Math.round(box.bottom), + textBottom: Math.round(text.bottom), + }; + } + } + + return null; + }; + + 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: TextOutsideBoxScanResult + ): TextOutsideBoxScanResult => + allIssues.filter( + (candidate) => + !allIssues.some( + (other) => + candidate !== other && + isStrictAncestorSelector(candidate.selector, other.selector) + ) + ); + + const issues: TextOutsideBoxScanResult = []; + + for (const el of [root, ...Array.from(root.querySelectorAll('*'))]) { + if (!isVisible(el) || isExcluded(el)) continue; + + const issue = getTextOutsideBoxIssue(el); + if (!issue) continue; + + issues.push({ + selector: relativeSelector(el), + ...issue, + }); + } + + return keepLeafOffenders(issues); +} + +/** Scan a root locator and return elements whose text exceeds their box. */ +export async function findTextOutsideBoxIssues( + root: Locator, + exclusions: TextOutsideBoxExclusion[] = [], + tolerance = DEFAULT_TOLERANCE_PX +): Promise { + const exclusionSelectors = await resolveExclusionSelectors(root, exclusions); + + return root.evaluate(scanTextOutsideBoxIssues, { + tolerance, + exclusionSelectors, + }); +} + +function formatTextOutsideBoxIssues(issues: TextOutsideBoxIssue[]): string { + return issues + .map( + (issue) => + ` ${issue.selector}\n` + + ` text: "${issue.text}"\n` + + ` box right/bottom: ${issue.boxRight}px / ${issue.boxBottom}px\n` + + ` text right/bottom: ${issue.textRight}px / ${issue.textBottom}px` + ) + .join('\n\n'); +} + +export const textOutsideBoxExpect = baseExpect.extend({ + async toHaveElementsWithTextOutsideTheBox( + root: Locator, + exclude: TextOutsideBoxExclusion[] = [], + options?: { tolerance?: number } + ) { + const tolerance = options?.tolerance ?? DEFAULT_TOLERANCE_PX; + const issues = await findTextOutsideBoxIssues(root, exclude, tolerance); + const pass = issues.length === 0; + + if (!pass) { + await attachDebugScreenshot(root, issues, { + debugId: 'a11y-text-outside-box-debug', + label: 'text outside box', + attachmentName: 'text-outside-box.png', + }); + } + + return { + pass: this.isNot ? !pass : pass, + name: 'toNotHaveElementsWithTextOutsideTheBox', + expected: [], + actual: issues, + message: () => { + if (this.isNot) { + return pass + ? `Expected elements with text outside their box inside root, but found none` + : `Expected no text-outside-box issues, but found ${ + issues.length + }:\n\n${formatTextOutsideBoxIssues(issues)}`; + } + + return `Expected no elements with text outside their box inside root, but found ${ + issues.length + }:\n\n${formatTextOutsideBoxIssues(issues)}`; + }, + }; + }, +}); 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..8440c62af --- /dev/null +++ b/apps/golden-sample-app-e2e/src/expect/to-overflow-viewport.ts @@ -0,0 +1,267 @@ +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; + + console.log('scanOverflowIssues', { tag: root.tagName, args, viewportWidth }); + + 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'); + } + + // A horizontal scroll container (overflow-x: auto|scroll) intentionally + // holds wider content without forcing the page to reflow, so it is the + // recommended WCAG 1.4.10 fix rather than a failure — skip it. + const scrollsHorizontally = + style.overflowX === 'auto' || style.overflowX === 'scroll'; + if ( + !scrollsHorizontally && + !insideHorizontalScroll && + el.scrollWidth > el.clientWidth + tolerance + ) { + reasons.push('contentWiderThanBox'); + } + + if (!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. */ +export async function findOverflowIssues( + root: Locator, + exclusions: ViewportOverflowExclusion[] = [], + tolerance = DEFAULT_TOLERANCE_PX +): Promise { + const exclusionSelectors = await resolveExclusionSelectors(root, exclusions); + + return root.evaluate(scanOverflowIssues, { + tolerance, + exclusionSelectors, + }); +} + +/** Rebuild a Playwright locator for an issue relative to the same root. */ +export function overflowIssueLocator( + root: Locator, + issue: OverflowIssue +): Locator { + return root.locator(issue.selector); +} + +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`, + }; + }, +}); From 7c33a76b49689bb2193eba63086152259d60d435 Mon Sep 17 00:00:00 2001 From: Tomasz Kasowicz Date: Tue, 16 Jun 2026 17:54:25 +0200 Subject: [PATCH 03/15] fix: reflow in transactions component --- .../transactions.a11y.viewport.spec.ts | 24 +++++++++++++++++++ .../transactions-view.component.html | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 apps/golden-sample-app-e2e/src/specs/custom-tests/transactions.a11y.viewport.spec.ts 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..db1021337 --- /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', '@viewport'] }, + () => { + 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/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 !== '') { From 43f41d225f6c201493ed484c340011e6b6703cdc Mon Sep 17 00:00:00 2001 From: Tomasz Kasowicz Date: Tue, 16 Jun 2026 17:56:54 +0200 Subject: [PATCH 04/15] test: add @axe tag to axe tests --- .../src/specs/custom-tests/transactions.a11y.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 94208ab20..eb6629332 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 @@ -3,7 +3,7 @@ import { expect } from '@playwright/test'; test.describe( 'Transaction Page A11y tests', - { tag: ['@a11y', '@e2e', '@mocks'] }, + { tag: ['@a11y', '@e2e', '@mocks', '@axe'] }, () => { test.beforeEach(async ({ transactionsPage }) => { await transactionsPage.open(); From e5f71098862163fccc397db4ff0f964a7218fdba Mon Sep 17 00:00:00 2001 From: Tomasz Kasowicz Date: Wed, 17 Jun 2026 08:08:31 +0200 Subject: [PATCH 05/15] fix: start focus order from 1st focused element --- .../src/expect/to-have-focus-order.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 index 3e813c9fd..5067653da 100644 --- 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 @@ -1,4 +1,4 @@ -import { Page, expect as baseExpect } from '@playwright/test'; +import { Locator, Page, expect as baseExpect } from '@playwright/test'; export type FocusableElement = { tagName: string; @@ -28,13 +28,13 @@ async function getFocusedElement(page: Page): Promise { } export const focusOrderExpect = baseExpect.extend({ - async toHaveFocusOrder(page: Page, expected: FocusableElement[]) { + async toHaveFocusOrder(root: Locator, expected: FocusableElement[]) { + const page = root.page(); const actual: FocusableElement[] = []; - for (let i = 0; i < expected.length; i++) { - await page.keyboard.press('Tab'); - - const currentFocus = await getFocusedElement(page); - actual.push(currentFocus); + actual.push(await getFocusedElement(page)); + for (let i = 1; i < expected.length; i++) { + await root.press('Tab'); + actual.push(await getFocusedElement(page)); } const pass = From d9220176b5fad8cb129e09fe5168b853199d4e9f Mon Sep 17 00:00:00 2001 From: Tomasz Kasowicz Date: Wed, 17 Jun 2026 08:08:52 +0200 Subject: [PATCH 06/15] feat: add more checks to overflow matcher --- .../src/expect/to-overflow-viewport.ts | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) 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 index 8440c62af..1a3c73c9f 100644 --- a/apps/golden-sample-app-e2e/src/expect/to-overflow-viewport.ts +++ b/apps/golden-sample-app-e2e/src/expect/to-overflow-viewport.ts @@ -165,20 +165,31 @@ function scanOverflowIssues( reasons.push('boxOutsideViewport'); } - // A horizontal scroll container (overflow-x: auto|scroll) intentionally - // holds wider content without forcing the page to reflow, so it is the - // recommended WCAG 1.4.10 fix rather than a failure — skip it. - const scrollsHorizontally = - style.overflowX === 'auto' || style.overflowX === 'scroll'; + // 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 ( - !scrollsHorizontally && + !overflowXContainsContent && !insideHorizontalScroll && el.scrollWidth > el.clientWidth + tolerance ) { reasons.push('contentWiderThanBox'); } - if (!insideHorizontalScroll && hasDirectTextOutsideViewport(el)) { + if ( + !overflowXContainsContent && + !insideHorizontalScroll && + hasDirectTextOutsideViewport(el) + ) { reasons.push('textOutsideViewport'); } From 3c03ea7beac2212ab38b2ed1b228e25d84f63883 Mon Sep 17 00:00:00 2001 From: Tomasz Kasowicz Date: Wed, 17 Jun 2026 08:09:11 +0200 Subject: [PATCH 07/15] feat: use original a11y expect --- apps/golden-sample-app-e2e/src/expect/expect.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/golden-sample-app-e2e/src/expect/expect.ts b/apps/golden-sample-app-e2e/src/expect/expect.ts index f147a2b4b..daccfb09a 100644 --- a/apps/golden-sample-app-e2e/src/expect/expect.ts +++ b/apps/golden-sample-app-e2e/src/expect/expect.ts @@ -3,6 +3,7 @@ import { obscuredExpect } from './to-be-obscured'; import { textOutsideBoxExpect } from './to-not-have-elements-with-text-outside-the-box'; 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 { @@ -23,6 +24,7 @@ export { export { findTextOutsideBoxIssues } from './to-not-have-elements-with-text-outside-the-box'; export const expect = mergeExpects( + a11yExpect, viewportOverflowExpect, focusOrderExpect, obscuredExpect, From 380fa18581b1b4e37cf5d0c97faae2c961ea663a Mon Sep 17 00:00:00 2001 From: Tomasz Kasowicz Date: Wed, 17 Jun 2026 08:11:10 +0200 Subject: [PATCH 08/15] test: add a11y tests for make transfer --- apps/golden-sample-app-e2e/src/fixtures/test.ts | 13 ++++++++++++- .../custom-tests/transactions.a11y.spec.ts | 2 +- .../transactions.a11y.viewport.spec.ts | 2 +- .../e2e-tests/src/page-objects/_base-page.ts | 3 ++- .../src/utils/custom-assertions/a11y-expect.ts | 11 ----------- libs/transactions-journey/e2e-tests/index.ts | 1 + .../make-transfer-form.component.html | 17 +++++++++++++++-- .../src/lib/transfer-journey.component.html | 2 +- 8 files changed, 33 insertions(+), 18 deletions(-) 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/transactions.a11y.spec.ts b/apps/golden-sample-app-e2e/src/specs/custom-tests/transactions.a11y.spec.ts index eb6629332..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,5 +1,5 @@ import { test } from '../../fixtures/test'; -import { expect } from '@playwright/test'; +import { expect } from '../../expect/expect'; test.describe( 'Transaction Page A11y tests', 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 index db1021337..1f4b02f37 100644 --- 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 @@ -3,7 +3,7 @@ import { expect } from '../../expect/expect'; test.describe( 'Transaction Page A11y Viewport tests', - { tag: ['@a11y', '@e2e', '@mocks', '@viewport'] }, + { tag: ['@a11y', '@e2e', '@mocks', '@reflow'] }, () => { test.use({ viewport: { width: 320, height: 256 } }); 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/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/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 }}

From ed1f76466a9a04f4eaf8dfab2f863aae73028c00 Mon Sep 17 00:00:00 2001 From: Tomasz Kasowicz Date: Wed, 17 Jun 2026 08:15:23 +0200 Subject: [PATCH 09/15] chore: update eslint, really add tests for make a transfer --- .../playwright.config.ts | 1 + .../custom-tests/make-a-transfer.a11y.spec.ts | 52 +++++++++ .../overflow-matcher.a11y.viewport.spec.ts | 103 ++++++++++++++++++ eslint.config.mjs | 2 + 4 files changed, 158 insertions(+) create mode 100644 apps/golden-sample-app-e2e/src/specs/custom-tests/make-a-transfer.a11y.spec.ts create mode 100644 apps/golden-sample-app-e2e/src/specs/custom-tests/overflow-matcher.a11y.viewport.spec.ts 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/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..c70c5e0bd --- /dev/null +++ b/apps/golden-sample-app-e2e/src/specs/custom-tests/make-a-transfer.a11y.spec.ts @@ -0,0 +1,52 @@ +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, page }) => { + const toAccount = page.getByRole('textbox', { name: 'To Account' }); + await toAccount.focus(); + const locator = makeTransferPage.locator('bb-transfer-journey'); + await expect(locator).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(); + } + ); + } +); 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..fcba5a942 --- /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 deoes 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/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'], }, From dfb060cc92629bb7b719add7165bae5691ba7d0e Mon Sep 17 00:00:00 2001 From: Tomasz Kasowicz Date: Wed, 17 Jun 2026 08:59:03 +0200 Subject: [PATCH 10/15] test: add proper locators for make a transfer PO --- .../custom-tests/make-a-transfer.a11y.spec.ts | 6 ++--- .../page-objects/pages/make-transfer.ts | 26 +++++++++++++++---- 2 files changed, 23 insertions(+), 9 deletions(-) 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 index c70c5e0bd..15d7f0a00 100644 --- 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 @@ -26,10 +26,8 @@ test.describe( 'Validate Make a Transfer tab Order', { tag: ['@tab-order'] }, async ({ makeTransferPage, page }) => { - const toAccount = page.getByRole('textbox', { name: 'To Account' }); - await toAccount.focus(); - const locator = makeTransferPage.locator('bb-transfer-journey'); - await expect(locator).toHaveFocusOrder([ + 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 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; + } } From f7581abba44068c4ec1d4161f32c1a7cb6c93168 Mon Sep 17 00:00:00 2001 From: Tomasz Kasowicz Date: Wed, 17 Jun 2026 10:27:06 +0200 Subject: [PATCH 11/15] test: add aria snapshot --- .../custom-tests/make-a-transfer.a11y.spec.ts | 8 ++++++++ ...Validate-Make-Transfer-Aria-Snapshot-1.aria.yml | 14 ++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 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 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 index 15d7f0a00..d6bd38b89 100644 --- 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 @@ -46,5 +46,13 @@ test.describe( await expect(locator).not.toOverflowViewPort(); } ); + + test( + 'Validate Make Transfer Aria Snapshot', + { tag: ['@aria-snapshot'] }, + async ({ makeTransferPage, page }) => { + await expect(makeTransferPage.element).toMatchAriaSnapshot(); + } + ); } ); 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 From fd292b84d8d84b2e8b34461e903dc75846db57d4 Mon Sep 17 00:00:00 2001 From: Tomasz Kasowicz Date: Wed, 17 Jun 2026 13:04:53 +0200 Subject: [PATCH 12/15] refactor: remove duplications, minor fixes --- .../src/expect/expect.ts | 16 +- .../src/expect/matcher-shared.ts | 91 +------- .../src/expect/to-be-obscured.ts | 34 +-- .../src/expect/to-have-focus-order.ts | 24 +- ...have-elements-with-text-outside-the-box.ts | 208 ------------------ .../src/expect/to-overflow-viewport.ts | 12 +- .../custom-tests/make-a-transfer.a11y.spec.ts | 13 +- .../overflow-matcher.a11y.viewport.spec.ts | 2 +- .../src/page-objects/_base-component.ts | 3 +- 9 files changed, 55 insertions(+), 348 deletions(-) delete mode 100644 apps/golden-sample-app-e2e/src/expect/to-not-have-elements-with-text-outside-the-box.ts diff --git a/apps/golden-sample-app-e2e/src/expect/expect.ts b/apps/golden-sample-app-e2e/src/expect/expect.ts index daccfb09a..19005ba86 100644 --- a/apps/golden-sample-app-e2e/src/expect/expect.ts +++ b/apps/golden-sample-app-e2e/src/expect/expect.ts @@ -1,32 +1,20 @@ import { mergeExpects } from '@playwright/test'; import { obscuredExpect } from './to-be-obscured'; -import { textOutsideBoxExpect } from './to-not-have-elements-with-text-outside-the-box'; 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 type { - TextOutsideBoxExclusion, - TextOutsideBoxIssue, -} from './to-not-have-elements-with-text-outside-the-box'; - -export { analyzeElementObscured } from './to-be-obscured'; -export { - findOverflowIssues, - overflowIssueLocator, -} from './to-overflow-viewport'; -export { findTextOutsideBoxIssues } from './to-not-have-elements-with-text-outside-the-box'; export const expect = mergeExpects( a11yExpect, viewportOverflowExpect, focusOrderExpect, - obscuredExpect, - textOutsideBoxExpect + 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 index a80b36812..74a1dcda6 100644 --- a/apps/golden-sample-app-e2e/src/expect/matcher-shared.ts +++ b/apps/golden-sample-app-e2e/src/expect/matcher-shared.ts @@ -117,23 +117,12 @@ function measureContentHeight(): number { } } - const heights: number[] = [window.innerHeight]; - - const containers = [ - document.documentElement, - document.body, - document.querySelector('mat-sidenav-content'), - document.querySelector('mat-sidenav-container'), - document.querySelector('main'), + const heights: number[] = [ + window.innerHeight, + document.documentElement.scrollHeight, + document.body.scrollHeight, ]; - for (const container of containers) { - if (!(container instanceof HTMLElement)) continue; - - const rect = container.getBoundingClientRect(); - heights.push(container.scrollHeight, rect.top + container.scrollHeight); - } - for (const el of Array.from(document.querySelectorAll('body *'))) { const rect = el.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) continue; @@ -148,59 +137,6 @@ function removeDebugHighlights(debugId: string): void { document.getElementById(debugId)?.remove(); } -/** Runs in the browser — passed to page.evaluate(). */ -function applyPageDebugHighlights(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 = document.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); -} - export type DebugScreenshotOptions = { debugId: string; label: string; @@ -270,22 +206,3 @@ export async function attachDebugScreenshot( options ); } - -export async function attachPageDebugScreenshot( - page: Page, - issues: DebugScreenshotIssue[], - options: DebugScreenshotOptions -): Promise { - const selectors = issues.map((issue) => issue.selector); - - await captureDebugScreenshot( - page, - () => - page.evaluate(applyPageDebugHighlights, { - 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 index 8f64b44f1..9e27c02e0 100644 --- a/apps/golden-sample-app-e2e/src/expect/to-be-obscured.ts +++ b/apps/golden-sample-app-e2e/src/expect/to-be-obscured.ts @@ -83,7 +83,7 @@ function analyzeObscuration(el: Element): ObscuredAnalysis { } /** Returns whether the element is entirely covered by other content. */ -export async function analyzeElementObscured( +async function analyzeElementObscured( locator: Locator ): Promise { // Bring the element into the viewport so the hit-test points are valid. @@ -94,18 +94,24 @@ export async function analyzeElementObscured( export const obscuredExpect = baseExpect.extend({ async toBeObscured(locator: Locator, options?: { timeout?: number }) { const timeout = options?.timeout ?? this.timeout ?? 5000; - const deadline = Date.now() + timeout; - const intervals = [100, 250, 500, 1000]; - - // Auto-retry like a built-in assertion: re-sample until the (possibly - // negated) expectation is satisfied or the timeout elapses. The matcher - // fails when `obscured === isNot`, so we keep polling while that holds. - let analysis = await analyzeElementObscured(locator); - for (let attempt = 0; analysis.obscured === this.isNot; attempt++) { - if (Date.now() >= deadline) break; - const wait = intervals[Math.min(attempt, intervals.length - 1)]; - await new Promise((resolve) => setTimeout(resolve, wait)); - analysis = await analyzeElementObscured(locator); + + 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; @@ -123,7 +129,7 @@ export const obscuredExpect = baseExpect.extend({ 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 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}`] : [], 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 index 5067653da..ec21f35fe 100644 --- 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 @@ -15,15 +15,18 @@ function normalizeText(text: string): string { } async function getFocusedElement(page: Page): Promise { - const focusedElement = page.locator(':focus'); - const textContent = await focusedElement.textContent(); - const innerText = await focusedElement.innerText(); - // const ariaSnapshot = await focusedElement.ariaSnapshot(); - const tag = await focusedElement.evaluate((el) => el.tagName.toLowerCase()); + const { tagName, textContent, innerText } = await page + .locator(':focus') + .evaluate((el) => ({ + tagName: el.tagName.toLowerCase(), + textContent: el.textContent ?? '', + innerText: (el as HTMLElement).innerText ?? '', + })); + return { - tagName: tag, - textContent: normalizeText(textContent ?? ''), - innerText: normalizeText(innerText ?? ''), + tagName, + textContent: normalizeText(textContent), + innerText: normalizeText(innerText), }; } @@ -54,10 +57,11 @@ export const focusOrderExpect = baseExpect.extend({ }); return { - pass: this.isNot ? !pass : pass, + // Positive result; Playwright inverts automatically for `.not`. + pass, name: 'toHaveFocusOrder', message: () => - `Expected different focus order ${this.utils.printDiffOrStringify( + `Expected focus order to match:\n${this.utils.printDiffOrStringify( expected, actual, 'expected', diff --git a/apps/golden-sample-app-e2e/src/expect/to-not-have-elements-with-text-outside-the-box.ts b/apps/golden-sample-app-e2e/src/expect/to-not-have-elements-with-text-outside-the-box.ts deleted file mode 100644 index 2195c9f27..000000000 --- a/apps/golden-sample-app-e2e/src/expect/to-not-have-elements-with-text-outside-the-box.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { Locator, expect as baseExpect } from '@playwright/test'; -import { - attachDebugScreenshot, - DEFAULT_TOLERANCE_PX, - MatcherExclusion, - resolveExclusionSelectors, -} from './matcher-shared'; - -export type TextOutsideBoxExclusion = MatcherExclusion; - -/** Serializable text-outside-box report — locators are rebuilt from `selector` + root. */ -export type TextOutsideBoxIssue = { - /** CSS selector relative to the root locator (starts with `:scope` for the root itself). */ - selector: string; - text: string; - boxRight: number; - textRight: number; - boxBottom: number; - textBottom: number; -}; - -type TextOutsideBoxScanResult = TextOutsideBoxIssue[]; - -/** Runs in the browser — passed to locator.evaluate(). */ -function scanTextOutsideBoxIssues( - root: Element, - args: { tolerance: number; exclusionSelectors: string[] } -): TextOutsideBoxScanResult { - const { tolerance, exclusionSelectors } = args; - - 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; - }; - - const getTextOutsideBoxIssue = ( - el: Element - ): Omit | null => { - const box = el.getBoundingClientRect(); - const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); - - for (let node = walker.nextNode(); node; node = walker.nextNode()) { - if (!(node.textContent || '').trim()) continue; - - const range = document.createRange(); - range.selectNodeContents(node); - const text = range.getBoundingClientRect(); - - if ( - text.right > box.right + tolerance || - text.left < box.left - tolerance || - text.bottom > box.bottom + tolerance || - text.top < box.top - tolerance - ) { - return { - text: (node.textContent || '').trim().slice(0, 50), - boxRight: Math.round(box.right), - textRight: Math.round(text.right), - boxBottom: Math.round(box.bottom), - textBottom: Math.round(text.bottom), - }; - } - } - - return null; - }; - - 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: TextOutsideBoxScanResult - ): TextOutsideBoxScanResult => - allIssues.filter( - (candidate) => - !allIssues.some( - (other) => - candidate !== other && - isStrictAncestorSelector(candidate.selector, other.selector) - ) - ); - - const issues: TextOutsideBoxScanResult = []; - - for (const el of [root, ...Array.from(root.querySelectorAll('*'))]) { - if (!isVisible(el) || isExcluded(el)) continue; - - const issue = getTextOutsideBoxIssue(el); - if (!issue) continue; - - issues.push({ - selector: relativeSelector(el), - ...issue, - }); - } - - return keepLeafOffenders(issues); -} - -/** Scan a root locator and return elements whose text exceeds their box. */ -export async function findTextOutsideBoxIssues( - root: Locator, - exclusions: TextOutsideBoxExclusion[] = [], - tolerance = DEFAULT_TOLERANCE_PX -): Promise { - const exclusionSelectors = await resolveExclusionSelectors(root, exclusions); - - return root.evaluate(scanTextOutsideBoxIssues, { - tolerance, - exclusionSelectors, - }); -} - -function formatTextOutsideBoxIssues(issues: TextOutsideBoxIssue[]): string { - return issues - .map( - (issue) => - ` ${issue.selector}\n` + - ` text: "${issue.text}"\n` + - ` box right/bottom: ${issue.boxRight}px / ${issue.boxBottom}px\n` + - ` text right/bottom: ${issue.textRight}px / ${issue.textBottom}px` - ) - .join('\n\n'); -} - -export const textOutsideBoxExpect = baseExpect.extend({ - async toHaveElementsWithTextOutsideTheBox( - root: Locator, - exclude: TextOutsideBoxExclusion[] = [], - options?: { tolerance?: number } - ) { - const tolerance = options?.tolerance ?? DEFAULT_TOLERANCE_PX; - const issues = await findTextOutsideBoxIssues(root, exclude, tolerance); - const pass = issues.length === 0; - - if (!pass) { - await attachDebugScreenshot(root, issues, { - debugId: 'a11y-text-outside-box-debug', - label: 'text outside box', - attachmentName: 'text-outside-box.png', - }); - } - - return { - pass: this.isNot ? !pass : pass, - name: 'toNotHaveElementsWithTextOutsideTheBox', - expected: [], - actual: issues, - message: () => { - if (this.isNot) { - return pass - ? `Expected elements with text outside their box inside root, but found none` - : `Expected no text-outside-box issues, but found ${ - issues.length - }:\n\n${formatTextOutsideBoxIssues(issues)}`; - } - - return `Expected no elements with text outside their box inside root, but found ${ - issues.length - }:\n\n${formatTextOutsideBoxIssues(issues)}`; - }, - }; - }, -}); 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 index 1a3c73c9f..849be4d6e 100644 --- a/apps/golden-sample-app-e2e/src/expect/to-overflow-viewport.ts +++ b/apps/golden-sample-app-e2e/src/expect/to-overflow-viewport.ts @@ -37,8 +37,6 @@ function scanOverflowIssues( // window.innerWidth, which includes the scrollbar gutter). const viewportWidth = document.documentElement.clientWidth; - console.log('scanOverflowIssues', { tag: root.tagName, args, viewportWidth }); - const relativeSelector = (el: Element): string => { if (el === root) return ':scope'; @@ -209,7 +207,7 @@ function scanOverflowIssues( } /** Scan a root locator (e.g. main) and return serializable overflow issues. */ -export async function findOverflowIssues( +async function findOverflowIssues( root: Locator, exclusions: ViewportOverflowExclusion[] = [], tolerance = DEFAULT_TOLERANCE_PX @@ -222,14 +220,6 @@ export async function findOverflowIssues( }); } -/** Rebuild a Playwright locator for an issue relative to the same root. */ -export function overflowIssueLocator( - root: Locator, - issue: OverflowIssue -): Locator { - return root.locator(issue.selector); -} - function formatIssues(issues: OverflowIssue[]): string { return issues .map( 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 index d6bd38b89..618a1cd26 100644 --- 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 @@ -25,7 +25,7 @@ test.describe( test( 'Validate Make a Transfer tab Order', { tag: ['@tab-order'] }, - async ({ makeTransferPage, page }) => { + async ({ makeTransferPage }) => { await makeTransferPage.toAccount.element.focus(); await expect(makeTransferPage.element).toHaveFocusOrder([ { tagName: 'input', textContent: '' }, // To Account @@ -50,9 +50,18 @@ test.describe( test( 'Validate Make Transfer Aria Snapshot', { tag: ['@aria-snapshot'] }, - async ({ makeTransferPage, page }) => { + async ({ makeTransferPage }) => { await expect(makeTransferPage.element).toMatchAriaSnapshot(); } ); + + test( + 'Validate focus obscured', + { tag: ['@focus-obscured'] }, + async ({ makeTransferPage }) => { + await makeTransferPage.toAccount.element.focus(); + await expect(makeTransferPage.toAccount.element).not.toBeObscured(); + } + ); } ); 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 index fcba5a942..e9d5b222f 100644 --- 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 @@ -1,6 +1,6 @@ /** * This file is just to test the toOverflowViewPort matcher. - * It deoes not test the application + * It does not test the application */ import { test } from '@playwright/test'; import { expect } from '../../expect/expect'; 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'; From 3ca3982ab2dbe317303c3ce8b8cf99c5b23012ef Mon Sep 17 00:00:00 2001 From: Tomasz Kasowicz Date: Wed, 17 Jun 2026 13:18:16 +0200 Subject: [PATCH 13/15] test: add obscured check for all focusable elements --- .../specs/custom-tests/make-a-transfer.a11y.spec.ts | 13 +++++++++++-- .../e2e-tests/page-objects/ui-components/amount.ts | 9 ++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) 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 index 618a1cd26..ab0eb264e 100644 --- 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 @@ -59,8 +59,17 @@ test.describe( 'Validate focus obscured', { tag: ['@focus-obscured'] }, async ({ makeTransferPage }) => { - await makeTransferPage.toAccount.element.focus(); - await expect(makeTransferPage.toAccount.element).not.toBeObscured(); + const locators = [ + makeTransferPage.toAccount.element, + makeTransferPage.amount.currencyInput, + makeTransferPage.amount.valueInput, + makeTransferPage.amount.decimalsInput, + makeTransferPage.submitButton, + ]; + for (const locator of locators) { + await locator.focus(); + await expect(locator).not.toBeObscured(); + } } ); } 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); } From 36f38c90801baf4fb1086eec2f75e431957be649 Mon Sep 17 00:00:00 2001 From: Tomasz Kasowicz Date: Wed, 17 Jun 2026 13:20:28 +0200 Subject: [PATCH 14/15] test: update test --- .../src/specs/custom-tests/make-a-transfer.a11y.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index ab0eb264e..72ac1fa02 100644 --- 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 @@ -67,8 +67,10 @@ test.describe( makeTransferPage.submitButton, ]; for (const locator of locators) { - await locator.focus(); - await expect(locator).not.toBeObscured(); + expect(locator).toBeDefined(); + await locator?.focus(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await expect.soft(locator!).not.toBeObscured(); } } ); From 6c4b62fcd38409a3b5681579b25fb771fd54b8d2 Mon Sep 17 00:00:00 2001 From: Tomasz Kasowicz Date: Thu, 18 Jun 2026 14:20:09 +0200 Subject: [PATCH 15/15] test: advance focus properly --- apps/golden-sample-app-e2e/src/expect/to-have-focus-order.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index ec21f35fe..7b78c294d 100644 --- 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 @@ -36,7 +36,7 @@ export const focusOrderExpect = baseExpect.extend({ const actual: FocusableElement[] = []; actual.push(await getFocusedElement(page)); for (let i = 1; i < expected.length; i++) { - await root.press('Tab'); + await page.keyboard.press('Tab'); actual.push(await getFocusedElement(page)); }