Files
privacy.sexy/tests/e2e/card-list-layout-stability-on-load.cy.ts
undergroundwires bf3426f91b Fix card list UI layout shifts (jumps) on load
This commit fixes layout shifts that occur on card list part of the page
when the page is initially loaded.

- Resolve issue where card list starts with minimal width, leading
  to jumps in UI until correct width is calculated on medium and
  big screens.
- Dispose of existing `ResizeObserver` properly before creating a new
  one. This prevents leaks and incorrect width calculations if
  `containerElement` changes.
- Throttle resize events to minimize width/height calculation changes,
  enhancing performance and reducing the chances for layout shifts.

Supporting CI/CD improvements:

- Enable artifact upload in CI/CD even if E2E tests fail.
- Distinguish uploaded artifacts by operating system for clarity.
2023-11-16 16:06:33 +01:00

232 lines
7.0 KiB
TypeScript

// eslint-disable-next-line max-classes-per-file
import { waitForHeaderBrandTitle } from './shared/ApplicationLoad';
interface Stoppable {
stop(): void;
}
describe('card list layout stability', () => {
describe('during initial page load', () => {
const testScenarios: ReadonlyArray<{
readonly name: string;
readonly width: number;
readonly height: number;
}> = [
{ name: 'iPhone SE', width: 375, height: 667 },
{ name: '13-inch Laptop', width: 1280, height: 800 },
{ name: '4K Ultra HD Desktop', width: 3840, height: 2160 },
];
const testCleanup = new Array<Stoppable>();
afterEach(() => {
testCleanup.forEach((c) => c.stop());
testCleanup.length = 0;
});
testScenarios.forEach(({ name, width, height }) => {
it(`ensures layout stability on ${name}`, () => {
// arrange
const dimensions = new DimensionsStorage();
cy.viewport(width, height);
// act
cy.window().then((win) => {
findElementFast(win, '.cards', (cardList) => {
testCleanup.push(
new SizeMonitor().start(cardList, () => dimensions.add(captureDimensions(cardList))),
);
});
testCleanup.push(
new ContinuousRunner()
.start(() => {
/*
As Cypress does not inherently support CPU throttling, this workaround is used to
intentionally slow down Cypress's execution. It allows capturing sudden layout
issues, such as brief flashes or shifts.
*/
cy.window().then(() => {
cy.log('Throttling');
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(50, { log: false });
});
}, 100),
);
});
cy.visit('/');
for (const assertNextCheckpoint of Object.values(checkpoints)) {
assertNextCheckpoint();
}
// assert
const widthToleranceInPx = 0;
const widthsInPx = dimensions.getUniqueWidths();
expect(isWithinTolerance(widthsInPx, widthToleranceInPx)).to.equal(true, [
`Unique width values over time: ${[...widthsInPx].join(', ')}`,
`Height changes are more than ${widthToleranceInPx}px tolerance`,
`Captured metrics: ${dimensions.toString()}`,
].join('\n\n'));
const heightToleranceInPx = 100; // Set in relation to card sizes.
// Tolerance allows for minor layout shifts without (e.g. for icon or font loading)
// false test failures. The number `100` accounts for shifts when the number of
// cards per row changes, avoiding failures for shifts less than the smallest card
// size (~175px).
const heightsInPx = dimensions.getUniqueHeights();
expect(isWithinTolerance(heightsInPx, heightToleranceInPx)).to.equal(true, [
`Unique height values over time: ${[...heightsInPx].join(', ')}`,
`Height changes are more than ${heightToleranceInPx}px tolerance`,
`Captured metrics: ${dimensions.toString()}`,
].join('\n\n'));
});
});
});
});
/*
It finds a DOM element as quickly as possible.
It's crucial for detecting early layout shifts during page load,
which may be missed by standard Cypress commands such as `cy.get`, `cy.document`.
*/
function findElementFast(
win: Cypress.AUTWindow,
query: string,
handler: (element: Element) => void,
timeoutInMs = 5000,
): void {
const endTime = Date.now() + timeoutInMs;
const finder = new ContinuousRunner();
finder.start(() => {
const element = win.document.querySelector(query);
if (element) {
handler(element);
finder.stop();
return;
}
if (Date.now() >= endTime) {
finder.stop();
throw new Error(`Timed out. Failed to find element. Query: ${query}. Timeout: ${timeoutInMs}ms`);
}
}, 1 /* As aggressive as possible */);
}
class DimensionsStorage {
private readonly dimensions = new Array<SizeDimensions>();
public add(newDimension: SizeDimensions): void {
if (this.dimensions.length > 0) {
const lastDimension = this.dimensions[this.dimensions.length - 1];
if (lastDimension.width === newDimension.width
&& lastDimension.height === newDimension.height) {
return;
}
}
cy.window().then(() => {
cy.log(`Captured: ${JSON.stringify(newDimension)}`);
});
this.dimensions.push(newDimension);
}
public getUniqueWidths(): readonly number[] {
return [...new Set(this.dimensions.map((d) => d.width))];
}
public getUniqueHeights(): readonly number[] {
return [...new Set(this.dimensions.map((d) => d.height))];
}
public toString(): string {
return JSON.stringify(this.dimensions);
}
}
function isWithinTolerance(
numbers: readonly number[],
tolerance: number,
) {
let changeWithinTolerance = true;
const [firstValue, ...otherValues] = numbers;
let previousValue = firstValue;
otherValues.forEach((value) => {
const difference = Math.abs(value - previousValue);
if (difference > tolerance) {
changeWithinTolerance = false;
}
previousValue = value;
});
return changeWithinTolerance;
}
interface SizeDimensions {
readonly width: number;
readonly height: number;
}
function captureDimensions(element: Element): SizeDimensions {
const dimensions = element.getBoundingClientRect(); // more reliable than body.scroll...
return {
width: Math.round(dimensions.width),
height: Math.round(dimensions.height),
};
}
enum ApplicationLoadStep {
IndexHtmlLoaded = 0,
AppVueLoaded = 1,
HeaderBrandTitleLoaded = 2,
}
const checkpoints: Record<ApplicationLoadStep, () => void> = {
[ApplicationLoadStep.IndexHtmlLoaded]: () => cy.get('#app').should('be.visible'),
[ApplicationLoadStep.AppVueLoaded]: () => cy.get('.app__wrapper').should('be.visible'),
[ApplicationLoadStep.HeaderBrandTitleLoaded]: () => waitForHeaderBrandTitle(),
};
class ContinuousRunner implements Stoppable {
private timer: ReturnType<typeof setTimeout> | undefined;
public start(callback: () => void, intervalInMs: number): this {
this.stop();
this.timer = setInterval(() => {
if (this.isStopped) {
return;
}
callback();
}, intervalInMs);
return this;
}
public stop() {
if (this.timer === undefined) {
return;
}
clearInterval(this.timer);
this.timer = undefined;
}
private get isStopped() {
return this.timer === undefined;
}
}
class SizeMonitor implements Stoppable {
private observer: ResizeObserver | undefined;
public start(element: Element, sizeChangedCallback: () => void): this {
this.stop();
this.observer = new ResizeObserver(() => {
if (this.isStopped) {
return;
}
sizeChangedCallback();
});
this.observer.observe(element);
return this;
}
public stop() {
this.observer?.disconnect();
this.observer = undefined;
}
private get isStopped() {
return this.observer === undefined;
}
}