test: deflakes blocks e2e (#11640)

The blocks e2e tests were flaky due to how we conditionally render
fields as they enter the viewport. This prevented Playwright from every
reaching the target element when running
`locator.scrollIntoViewIfNeeded()`. This is especially flaky on pages
with many fields because the page size would continually grow as it was
scrolled.

To fix this there are new `scrollEntirePage` and `waitForPageStability`
helpers. Together, these will ensure that all fields are rendered and
fully loaded before we start testing. An early attempt at this was made
via `page.mouse.wheel(0, 1750)`, but this is an arbitrary pixel value
that is error prone and is not future proof.

These tests were also flaky by an attempt to trigger a form state action
before it was ready to receive events. The fix here is to disable any
buttons while the form is initializing and let Playwright wait for an
interactive state.
This commit is contained in:
Jacob Fletcher
2025-03-11 22:49:06 -04:00
committed by GitHub
parent 88eeeaa8dd
commit f2abc80a00
7 changed files with 159 additions and 117 deletions

View File

@@ -1,43 +1,4 @@
@mixin btn-reset {
border: 0;
background: none;
box-shadow: none;
border-radius: 0;
padding: 0;
color: currentColor;
cursor: pointer;
}
#field-customBlocks {
margin-bottom: var(--base);
.blocks-field__drawer-toggler {
display: none;
}
}
.custom-blocks-field-management { .custom-blocks-field-management {
&__blocks-grid { display: flex;
display: grid; gap: var(--base);
grid-template-columns: repeat(3, 1fr);
gap: calc(var(--base) * 2);
}
&__block-button {
@include btn-reset;
border: 1px solid var(--theme-border-color);
width: 100%;
padding: 25px 10px;
&:hover {
border-color: var(--theme-elevation-400);
}
}
&__replace-block-button {
margin-top: calc(var(--base) * 1.5);
color: var(--theme-bg);
background: var(--theme-text);
}
} }

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useField, useForm } from '@payloadcms/ui' import { Button, useField, useForm } from '@payloadcms/ui'
import * as React from 'react' import * as React from 'react'
import './index.scss' import './index.scss'
@@ -10,7 +10,7 @@ const baseClass = 'custom-blocks-field-management'
const blocksPath = 'customBlocks' const blocksPath = 'customBlocks'
export const AddCustomBlocks: React.FC<any> = (props) => { export const AddCustomBlocks: React.FC<any> = (props) => {
const { addFieldRow, replaceFieldRow } = useForm() const { addFieldRow, initializing, replaceFieldRow } = useForm()
const field = useField<number>({ path: blocksPath }) const field = useField<number>({ path: blocksPath })
const { value } = field const { value } = field
@@ -18,9 +18,8 @@ export const AddCustomBlocks: React.FC<any> = (props) => {
return ( return (
<div className={baseClass}> <div className={baseClass}>
<div className={`${baseClass}__blocks-grid`}> <Button
<button disabled={initializing}
className={`${baseClass}__block-button`}
onClick={() => { onClick={() => {
addFieldRow({ addFieldRow({
blockType: 'block-1', blockType: 'block-1',
@@ -38,10 +37,9 @@ export const AddCustomBlocks: React.FC<any> = (props) => {
type="button" type="button"
> >
Add Block 1 Add Block 1
</button> </Button>
<Button
<button disabled={initializing}
className={`${baseClass}__block-button`}
onClick={() => { onClick={() => {
addFieldRow({ addFieldRow({
blockType: 'block-2', blockType: 'block-2',
@@ -59,12 +57,9 @@ export const AddCustomBlocks: React.FC<any> = (props) => {
type="button" type="button"
> >
Add Block 2 Add Block 2
</button> </Button>
</div> <Button
disabled={initializing}
<div>
<button
className={`${baseClass}__block-button ${baseClass}__replace-block-button`}
onClick={() => onClick={() =>
replaceFieldRow({ replaceFieldRow({
blockType: 'block-1', blockType: 'block-1',
@@ -83,8 +78,7 @@ export const AddCustomBlocks: React.FC<any> = (props) => {
type="button" type="button"
> >
Replace Block {value} Replace Block {value}
</button> </Button>
</div>
</div> </div>
) )
} }

View File

@@ -4,6 +4,7 @@ import { expect, test } from '@playwright/test'
import { addBlock } from 'helpers/e2e/addBlock.js' import { addBlock } from 'helpers/e2e/addBlock.js'
import { openBlocksDrawer } from 'helpers/e2e/openBlocksDrawer.js' import { openBlocksDrawer } from 'helpers/e2e/openBlocksDrawer.js'
import { reorderBlocks } from 'helpers/e2e/reorderBlocks.js' import { reorderBlocks } from 'helpers/e2e/reorderBlocks.js'
import { scrollEntirePage } from 'helpers/e2e/scrollEntirePage.js'
import path from 'path' import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
@@ -329,20 +330,16 @@ describe('Block fields', () => {
test('should add 2 new block rows', async () => { test('should add 2 new block rows', async () => {
await page.goto(url.create) await page.goto(url.create)
await scrollEntirePage(page)
await page await page
.locator('.custom-blocks-field-management') .locator('.custom-blocks-field-management')
.getByRole('button', { name: 'Add Block 1' }) .getByRole('button', { name: 'Add Block 1' })
.click() .click()
const customBlocks = page.locator( await expect(
'#field-customBlocks input[name="customBlocks.0.block1Title"]', page.locator('#field-customBlocks input[name="customBlocks.0.block1Title"]'),
) ).toHaveValue('Block 1: Prefilled Title')
await page.mouse.wheel(0, 1750)
await customBlocks.scrollIntoViewIfNeeded()
await expect(customBlocks).toHaveValue('Block 1: Prefilled Title')
await page await page
.locator('.custom-blocks-field-management') .locator('.custom-blocks-field-management')

View File

@@ -1086,6 +1086,7 @@ export interface CodeField {
json?: string | null; json?: string | null;
html?: string | null; html?: string | null;
css?: string | null; css?: string | null;
codeWithPadding?: string | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
@@ -2853,6 +2854,7 @@ export interface CodeFieldsSelect<T extends boolean = true> {
json?: T; json?: T;
html?: T; html?: T;
css?: T; css?: T;
codeWithPadding?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }

View File

@@ -0,0 +1,34 @@
import type { Page } from '@playwright/test'
import { waitForPageStability } from './waitForPageStability.js'
/**
* Scroll to bottom of the page continuously until no new content is loaded.
* This is needed because we conditionally render fields as they enter the viewport.
* This will ensure that all fields are rendered and fully loaded before we start testing.
* Without this step, Playwright's `locator.scrollIntoView()` might not work as expected.
* @param page - Playwright page object
* @returns Promise<void>
*/
export const scrollEntirePage = async (page: Page) => {
let previousHeight = await page.evaluate(() => document.body.scrollHeight)
while (true) {
await page.evaluate(() => {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' })
})
// Wait for the page to stabilize after scrolling
await waitForPageStability({ page })
// Get the new page height after stability check
const newHeight = await page.evaluate(() => document.body.scrollHeight)
// Stop if the height hasn't changed, meaning no new content was loaded
if (newHeight === previousHeight) {
break
}
previousHeight = newHeight
}
}

View File

@@ -0,0 +1,54 @@
import type { Page } from '@playwright/test'
/**
* Checks if the page is stable by continually polling until the page size remains constant in size and there are no loading shimmers.
* A page is considered stable if it passes this test multiple times.
* This will ensure that the page won't unexpectedly change while testing.
* @param page - Playwright page object
* @param intervalMs - Polling interval in milliseconds
* @param stableChecksRequired - Number of stable checks required to consider page stable
* @returns Promise<void>
*/
export const waitForPageStability = async ({
page,
interval = 1000,
stableChecksRequired = 3,
}: {
interval?: number
page: Page
stableChecksRequired?: number
}) => {
await page.waitForLoadState('networkidle') // Wait for network to be idle
await page.waitForFunction(
async ({ interval, stableChecksRequired }) => {
return new Promise((resolve) => {
let previousHeight = document.body.scrollHeight
let stableChecks = 0
const checkStability = () => {
const currentHeight = document.body.scrollHeight
const loadingShimmers = document.querySelectorAll('.shimmer-effect')
const pageSizeChanged = currentHeight !== previousHeight
if (!pageSizeChanged && loadingShimmers.length === 0) {
stableChecks++ // Increment stability count
} else {
stableChecks = 0 // Reset stability count if page changes
}
previousHeight = currentHeight
if (stableChecks >= stableChecksRequired) {
resolve(true) // Only resolve after multiple stable checks
} else {
setTimeout(checkStability, interval) // Poll again
}
}
checkStability()
})
},
{ interval, stableChecksRequired },
)
}

View File

@@ -31,7 +31,7 @@
} }
], ],
"paths": { "paths": {
"@payload-config": ["./test/_community/config.ts"], "@payload-config": ["./test/fields/config.ts"],
"@payloadcms/admin-bar": ["./packages/admin-bar/src"], "@payloadcms/admin-bar": ["./packages/admin-bar/src"],
"@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],