Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/golden-sample-app-e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const baseConfig: PlaywrightTestConfig<ProjectTestArgs> = {
'html',
{
outputFolder: join(distDir, 'reports/html'),
open: 'never',
},
],
],
Expand Down
20 changes: 20 additions & 0 deletions apps/golden-sample-app-e2e/src/expect/expect.ts
Original file line number Diff line number Diff line change
@@ -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
);
208 changes: 208 additions & 0 deletions apps/golden-sample-app-e2e/src/expect/matcher-shared.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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<string[]> {
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<void>,
options: DebugScreenshotOptions
): Promise<void> {
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<void> {
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
);
}
147 changes: 147 additions & 0 deletions apps/golden-sample-app-e2e/src/expect/to-be-obscured.ts
Original file line number Diff line number Diff line change
@@ -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<ObscuredAnalysis> {
// 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',
});
}
Loading
Loading