Merge pull request #5436 from payloadcms/temp11

chore: improvements to eslint, and access-control + lexical test suites
This commit is contained in:
Alessio Gravili
2024-03-25 10:48:27 -04:00
committed by GitHub
10 changed files with 275 additions and 221 deletions

View File

@@ -43,40 +43,74 @@ module.exports = {
'stringMatching', 'stringMatching',
] ]
function isNonRetryableAssertion(node) {
return (
node.type === 'MemberExpression' &&
node.property.type === 'Identifier' &&
nonRetryableAssertions.includes(node.property.name)
)
}
function isExpectPollOrToPass(node) {
if (
node.type === 'MemberExpression' &&
(node?.property?.name === 'poll' || node?.property?.name === 'toPass')
) {
return true
}
return (
node.type === 'CallExpression' &&
node.callee.type === 'MemberExpression' &&
((node.callee.object.type === 'CallExpression' &&
node.callee.object.callee.type === 'MemberExpression' &&
node.callee.object.callee.property.name === 'poll') ||
node.callee.property.name === 'toPass')
)
}
function hasExpectPollOrToPassInChain(node) {
let ancestor = node
while (ancestor) {
if (isExpectPollOrToPass(ancestor)) {
return true
}
ancestor = 'object' in ancestor ? ancestor.object : ancestor.callee
}
return false
}
function hasExpectPollOrToPassInParentChain(node) {
let ancestor = node
while (ancestor) {
if (isExpectPollOrToPass(ancestor)) {
return true
}
ancestor = ancestor.parent
}
return false
}
return { return {
CallExpression(node) { CallExpression(node) {
if ( // node.callee is MemberExpressiom
node.callee.type === 'MemberExpression' && if (isNonRetryableAssertion(node.callee)) {
//node.callee.object.name === 'expect' && if (hasExpectPollOrToPassInChain(node.callee)) {
node.callee.property.type === 'Identifier' && return
nonRetryableAssertions.includes(node.callee.property.name)
) {
let ancestor = node
let hasExpectPollOrToPass = false
while (ancestor) {
if (
ancestor.type === 'CallExpression' &&
ancestor.callee.type === 'MemberExpression' &&
((ancestor.callee.object.type === 'CallExpression' &&
ancestor.callee.object.callee.type === 'MemberExpression' &&
ancestor.callee.object.callee.property.name === 'poll') ||
ancestor.callee.property.name === 'toPass')
) {
hasExpectPollOrToPass = true
break
}
ancestor = ancestor.parent
} }
if (hasExpectPollOrToPass) { if (hasExpectPollOrToPassInParentChain(node)) {
return return
} }
context.report({ context.report({
node: node.callee.property, node: node.callee.property,
message: message:
'Non-retryable, flaky assertion used in Playwright test: "{{ assertion }}". Those need to be wrapped in expect.poll() or expect().toPass.', 'Non-retryable, flaky assertion used in Playwright test: "{{ assertion }}". Those need to be wrapped in expect.poll() or expect().toPass().',
data: { data: {
assertion: node.callee.property.name, assertion: node.callee.property.name,
}, },

View File

@@ -1,6 +1,5 @@
import type { Field, SanitizedConfig } from 'payload/types' import type { Field, SanitizedConfig } from 'payload/types'
import { sanitizeFields } from 'payload/config'
import { tabHasName } from 'payload/types' import { tabHasName } from 'payload/types'
import type { FieldSchemaMap } from './types.js' import type { FieldSchemaMap } from './types.js'
@@ -21,16 +20,10 @@ export const traverseFields = ({
validRelationships, validRelationships,
}: Args) => { }: Args) => {
fields.map((field) => { fields.map((field) => {
let fieldsToSet
switch (field.type) { switch (field.type) {
case 'group': case 'group':
case 'array': case 'array':
fieldsToSet = sanitizeFields({ schemaMap.set(`${schemaPath}.${field.name}`, field.fields)
config,
fields: field.fields,
validRelationships,
})
schemaMap.set(`${schemaPath}.${field.name}`, fieldsToSet)
traverseFields({ traverseFields({
config, config,
@@ -55,12 +48,8 @@ export const traverseFields = ({
case 'blocks': case 'blocks':
field.blocks.map((block) => { field.blocks.map((block) => {
const blockSchemaPath = `${schemaPath}.${field.name}.${block.slug}` const blockSchemaPath = `${schemaPath}.${field.name}.${block.slug}`
fieldsToSet = sanitizeFields({
config, schemaMap.set(blockSchemaPath, block.fields)
fields: [...block.fields, { name: 'blockName', type: 'text' }],
validRelationships,
})
schemaMap.set(blockSchemaPath, fieldsToSet)
traverseFields({ traverseFields({
config, config,
@@ -88,12 +77,7 @@ export const traverseFields = ({
const tabSchemaPath = tabHasName(tab) ? `${schemaPath}.${tab.name}` : schemaPath const tabSchemaPath = tabHasName(tab) ? `${schemaPath}.${tab.name}` : schemaPath
if (tabHasName(tab)) { if (tabHasName(tab)) {
fieldsToSet = sanitizeFields({ schemaMap.set(tabSchemaPath, tab.fields)
config,
fields: tab.fields,
validRelationships,
})
schemaMap.set(tabSchemaPath, fieldsToSet)
} }
traverseFields({ traverseFields({

View File

@@ -37,7 +37,9 @@ type Props = {
formData: BlockFields formData: BlockFields
formSchema: FieldMap formSchema: FieldMap
nodeKey: string nodeKey: string
path: string
reducedBlock: ReducedBlock reducedBlock: ReducedBlock
schemaPath: string
} }
/** /**
@@ -52,7 +54,9 @@ export const BlockContent: React.FC<Props> = (props) => {
formData, formData,
formSchema, formSchema,
nodeKey, nodeKey,
path,
reducedBlock: { labels }, reducedBlock: { labels },
schemaPath,
} = props } = props
const { i18n } = useTranslation() const { i18n } = useTranslation()
@@ -236,9 +240,9 @@ export const BlockContent: React.FC<Props> = (props) => {
fieldMap={Array.isArray(formSchema) ? formSchema : []} fieldMap={Array.isArray(formSchema) ? formSchema : []}
forceRender forceRender
margins="small" margins="small"
path="" path={path}
readOnly={false} readOnly={false}
schemaPath="" schemaPath={schemaPath}
/> />
</Collapsible> </Collapsible>

View File

@@ -42,7 +42,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
const config = useConfig() const config = useConfig()
const submitted = useFormSubmitted() const submitted = useFormSubmitted()
const { id } = useDocumentInfo() const { id } = useDocumentInfo()
const { schemaPath } = useFieldProps() const { path, schemaPath } = useFieldProps()
const { editorConfig, field: parentLexicalRichTextField } = useEditorConfigContext() const { editorConfig, field: parentLexicalRichTextField } = useEditorConfigContext()
const [initialState, setInitialState] = useState<FormState | false>(false) const [initialState, setInitialState] = useState<FormState | false>(false)
@@ -137,7 +137,9 @@ export const BlockComponent: React.FC<Props> = (props) => {
formData={formData} formData={formData}
formSchema={Array.isArray(fieldMap) ? fieldMap : []} formSchema={Array.isArray(fieldMap) ? fieldMap : []}
nodeKey={nodeKey} nodeKey={nodeKey}
path={`${path}.feature.blocks.${formData.blockType}`}
reducedBlock={reducedBlock} reducedBlock={reducedBlock}
schemaPath={schemaFieldsPath}
/> />
</Form> </Form>
) )

View File

@@ -1,9 +1,8 @@
import type { Page } from '@playwright/test' import type { Page } from '@playwright/test'
import type { Payload } from 'payload/types' import type { Payload, TypeWithID } from 'payload/types'
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
import path from 'path' import path from 'path'
import { wait } from 'payload/utilities'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import type { ReadOnlyCollection, RestrictedVersion } from './payload-types.js' import type { ReadOnlyCollection, RestrictedVersion } from './payload-types.js'
@@ -134,7 +133,6 @@ describe('access control', () => {
describe('restricted fields', () => { describe('restricted fields', () => {
test('should not show field without permission', async () => { test('should not show field without permission', async () => {
await page.goto(url.account) await page.goto(url.account)
await wait(500)
await expect(page.locator('#field-roles')).toBeHidden() await expect(page.locator('#field-roles')).toBeHidden()
}) })
}) })
@@ -163,7 +161,7 @@ describe('access control', () => {
test('should have collection url', async () => { test('should have collection url', async () => {
await page.goto(readOnlyUrl.list) await page.goto(readOnlyUrl.list)
await expect(page).toHaveURL(readOnlyUrl.list) // no redirect await expect(page).toHaveURL(new RegExp(`${readOnlyUrl.list}.*`)) // will redirect to ?limit=10 at the end, so we have to use a wildcard at the end
}) })
test('should not have "Create New" button', async () => { test('should not have "Create New" button', async () => {
@@ -188,7 +186,6 @@ describe('access control', () => {
test('should not render dot menu popup when `create` and `delete` access control is set to false', async () => { test('should not render dot menu popup when `create` and `delete` access control is set to false', async () => {
await page.goto(readOnlyUrl.edit(existingDoc.id)) await page.goto(readOnlyUrl.edit(existingDoc.id))
await wait(1000)
await expect(page.locator('.collection-edit .doc-controls .doc-controls__popup')).toBeHidden() await expect(page.locator('.collection-edit .doc-controls .doc-controls__popup')).toBeHidden()
}) })
}) })
@@ -213,7 +210,7 @@ describe('access control', () => {
describe('doc level access', () => { describe('doc level access', () => {
let existingDoc: ReadOnlyCollection let existingDoc: ReadOnlyCollection
let docLevelAccessURL let docLevelAccessURL: AdminUrlUtil
beforeAll(async () => { beforeAll(async () => {
docLevelAccessURL = new AdminUrlUtil(serverURL, docLevelAccessSlug) docLevelAccessURL = new AdminUrlUtil(serverURL, docLevelAccessSlug)
@@ -265,28 +262,26 @@ describe('access control', () => {
const unrestrictedURL = new AdminUrlUtil(serverURL, unrestrictedSlug) const unrestrictedURL = new AdminUrlUtil(serverURL, unrestrictedSlug)
await page.goto(unrestrictedURL.edit(unrestrictedDoc.id)) await page.goto(unrestrictedURL.edit(unrestrictedDoc.id))
const button = page.locator( const addDocButton = page.locator(
'#userRestrictedDocs-add-new button.relationship-add-new__add-button.doc-drawer__toggler', '#userRestrictedDocs-add-new button.relationship-add-new__add-button.doc-drawer__toggler',
) )
await button.click() await addDocButton.click()
const documentDrawer = page.locator('[id^=doc-drawer_user-restricted_1_]') const documentDrawer = page.locator('[id^=doc-drawer_user-restricted_1_]')
await expect(documentDrawer).toBeVisible() await expect(documentDrawer).toBeVisible()
await documentDrawer.locator('#field-name').fill('anonymous@email.com') await documentDrawer.locator('#field-name').fill('anonymous@email.com')
await documentDrawer.locator('#action-save').click() await documentDrawer.locator('#action-save').click()
await wait(200)
await expect(page.locator('.Toastify')).toContainText('successfully') await expect(page.locator('.Toastify')).toContainText('successfully')
// ensure user is not allowed to edit this document // ensure user is not allowed to edit this document
await expect(documentDrawer.locator('#field-name')).toBeDisabled() await expect(documentDrawer.locator('#field-name')).toBeDisabled()
await documentDrawer.locator('button.doc-drawer__header-close').click() await documentDrawer.locator('button.doc-drawer__header-close').click()
await wait(200) await expect(documentDrawer).toBeHidden()
await button.click() await addDocButton.click()
const documentDrawer2 = page.locator('[id^=doc-drawer_user-restricted_1_]') const documentDrawer2 = page.locator('[id^=doc-drawer_user-restricted_1_]')
await expect(documentDrawer2).toBeVisible() await expect(documentDrawer2).toBeVisible()
await documentDrawer2.locator('#field-name').fill('dev@payloadcms.com') await documentDrawer2.locator('#field-name').fill('dev@payloadcms.com')
await documentDrawer2.locator('#action-save').click() await documentDrawer2.locator('#action-save').click()
await wait(200)
await expect(page.locator('.Toastify')).toContainText('successfully') await expect(page.locator('.Toastify')).toContainText('successfully')
// ensure user is allowed to edit this document // ensure user is allowed to edit this document
@@ -294,7 +289,7 @@ describe('access control', () => {
}) })
}) })
async function createDoc(data: any): Promise<{ id: string }> { async function createDoc(data: any): Promise<TypeWithID & Record<string, unknown>> {
return payload.create({ return payload.create({
collection: slug, collection: slug,
data, data,

View File

@@ -8,6 +8,7 @@ import { fileURLToPath } from 'url'
import { delayNetwork, initPageConsoleErrorCatch, login, saveDocAndAssert } from '../helpers.js' import { delayNetwork, initPageConsoleErrorCatch, login, saveDocAndAssert } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2E } from '../helpers/initPayloadE2E.js' import { initPayloadE2E } from '../helpers/initPayloadE2E.js'
import { POLL_TOPASS_TIMEOUT } from '../playwright.config.js'
import config from './config.js' import config from './config.js'
import { apiKeysSlug, slug } from './shared.js' import { apiKeysSlug, slug } from './shared.js'
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url)
@@ -105,7 +106,7 @@ describe('auth', () => {
// assert that the value is set // assert that the value is set
const apiKeyLocator = page.locator('#apiKey') const apiKeyLocator = page.locator('#apiKey')
await expect await expect
.poll(async () => await apiKeyLocator.inputValue(), { timeout: 45000 }) .poll(async () => await apiKeyLocator.inputValue(), { timeout: POLL_TOPASS_TIMEOUT })
.toBeDefined() .toBeDefined()
await saveDocAndAssert(page) await saveDocAndAssert(page)
@@ -114,7 +115,7 @@ describe('auth', () => {
const apiKey = await apiKeyLocator.inputValue() const apiKey = await apiKeyLocator.inputValue()
expect(await page.locator('#apiKey').inputValue()).toStrictEqual(apiKey) expect(await page.locator('#apiKey').inputValue()).toStrictEqual(apiKey)
}).toPass({ }).toPass({
timeout: 45000, timeout: POLL_TOPASS_TIMEOUT,
}) })
}) })
@@ -140,7 +141,7 @@ describe('auth', () => {
expect(response.user).toBeNull() expect(response.user).toBeNull()
}).toPass({ }).toPass({
timeout: 45000, timeout: POLL_TOPASS_TIMEOUT,
}) })
}) })
}) })

View File

@@ -13,6 +13,7 @@ import { initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2E } from '../helpers/initPayloadE2E.js' import { initPayloadE2E } from '../helpers/initPayloadE2E.js'
import { RESTClient } from '../helpers/rest.js' import { RESTClient } from '../helpers/rest.js'
import { POLL_TOPASS_TIMEOUT } from '../playwright.config.js'
import { lexicalDocData } from './collections/Lexical/data.js' import { lexicalDocData } from './collections/Lexical/data.js'
import config from './config.js' import config from './config.js'
import { clearAndSeedEverything } from './seed.js' import { clearAndSeedEverything } from './seed.js'
@@ -27,12 +28,6 @@ let client: RESTClient
let page: Page let page: Page
let serverURL: string let serverURL: string
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function navigateToRichTextFields() {
const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'rich-text-fields')
await page.goto(url.list)
await page.locator('.row-1 .cell-title a').click()
}
async function navigateToLexicalFields() { async function navigateToLexicalFields() {
const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'lexical-fields') const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'lexical-fields')
await page.goto(url.list) await page.goto(url.list)
@@ -70,7 +65,7 @@ describe('lexical', () => {
await page.locator('.app-header__step-nav').first().locator('a').first().click() await page.locator('.app-header__step-nav').first().locator('a').first().click()
// Make sure .leave-without-saving__content (the "Leave without saving") is not visible // Make sure .leave-without-saving__content (the "Leave without saving") is not visible
await expect(page.locator('.leave-without-saving__content').first()).not.toBeVisible() await expect(page.locator('.leave-without-saving__content').first()).toBeHidden()
}) })
test('should not warn about unsaved changes when navigating to lexical editor with blocks node and then leaving the page after making a change and saving', async () => { test('should not warn about unsaved changes when navigating to lexical editor with blocks node and then leaving the page after making a change and saving', async () => {
@@ -105,7 +100,7 @@ describe('lexical', () => {
await page.locator('.app-header__step-nav').first().locator('a').first().click() await page.locator('.app-header__step-nav').first().locator('a').first().click()
// Make sure .leave-without-saving__content (the "Leave without saving") is not visible // Make sure .leave-without-saving__content (the "Leave without saving") is not visible
await expect(page.locator('.leave-without-saving__content').first()).not.toBeVisible() await expect(page.locator('.leave-without-saving__content').first()).toBeHidden()
}) })
test('should type and save typed text', async () => { test('should type and save typed text', async () => {
@@ -128,24 +123,28 @@ describe('lexical', () => {
await saveDocAndAssert(page) await saveDocAndAssert(page)
const lexicalDoc: LexicalField = ( await expect(async () => {
await payload.find({ const lexicalDoc: LexicalField = (
collection: lexicalFieldsSlug, await payload.find({
depth: 0, collection: lexicalFieldsSlug,
where: { depth: 0,
title: { where: {
equals: lexicalDocData.title, title: {
equals: lexicalDocData.title,
},
}, },
}, })
}) ).docs[0] as never
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks
const firstParagraphTextNode: SerializedTextNode = ( const firstParagraphTextNode: SerializedTextNode = (
lexicalField.root.children[0] as SerializedParagraphNode lexicalField.root.children[0] as SerializedParagraphNode
).children[0] as SerializedTextNode ).children[0] as SerializedTextNode
expect(firstParagraphTextNode.text).toBe('Upload Node:moretext') expect(firstParagraphTextNode.text).toBe('Upload Node:moretext')
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
}) })
test('should be able to bold text using floating select toolbar', async () => { test('should be able to bold text using floating select toolbar', async () => {
await navigateToLexicalFields() await navigateToLexicalFields()
@@ -193,35 +192,39 @@ describe('lexical', () => {
await saveDocAndAssert(page) await saveDocAndAssert(page)
const lexicalDoc: LexicalField = ( await expect(async () => {
await payload.find({ const lexicalDoc: LexicalField = (
collection: lexicalFieldsSlug, await payload.find({
depth: 0, collection: lexicalFieldsSlug,
where: { depth: 0,
title: { where: {
equals: lexicalDocData.title, title: {
equals: lexicalDocData.title,
},
}, },
}, })
}) ).docs[0] as never
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks
const firstParagraph: SerializedParagraphNode = lexicalField.root const firstParagraph: SerializedParagraphNode = lexicalField.root
.children[0] as SerializedParagraphNode .children[0] as SerializedParagraphNode
expect(firstParagraph.children).toHaveLength(3) expect(firstParagraph.children).toHaveLength(3)
const textNode1: SerializedTextNode = firstParagraph.children[0] as SerializedTextNode const textNode1: SerializedTextNode = firstParagraph.children[0] as SerializedTextNode
const boldNode: SerializedTextNode = firstParagraph.children[1] as SerializedTextNode const boldNode: SerializedTextNode = firstParagraph.children[1] as SerializedTextNode
const textNode2: SerializedTextNode = firstParagraph.children[2] as SerializedTextNode const textNode2: SerializedTextNode = firstParagraph.children[2] as SerializedTextNode
expect(textNode1.text).toBe('Upload ') expect(textNode1.text).toBe('Upload ')
expect(textNode1.format).toBe(0) expect(textNode1.format).toBe(0)
expect(boldNode.text).toBe('Node') expect(boldNode.text).toBe('Node')
expect(boldNode.format).toBe(1) expect(boldNode.format).toBe(1)
expect(textNode2.text).toBe(':') expect(textNode2.text).toBe(':')
expect(textNode2.format).toBe(0) expect(textNode2.format).toBe(0)
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
}) })
test('Make sure highly specific issue does not occur when two richText fields share the same editor prop', async () => { test('Make sure highly specific issue does not occur when two richText fields share the same editor prop', async () => {
@@ -239,10 +242,13 @@ describe('lexical', () => {
await expect(richTextField).toBeVisible() await expect(richTextField).toBeVisible()
const contentEditable = richTextField.locator('.ContentEditable__root').first() const contentEditable = richTextField.locator('.ContentEditable__root').first()
const textContent = await contentEditable.textContent()
expect(textContent).not.toBe('some text') await expect
expect(textContent).toBe('') .poll(async () => await contentEditable.textContent(), { timeout: POLL_TOPASS_TIMEOUT })
.not.toBe('some text')
await expect
.poll(async () => await contentEditable.textContent(), { timeout: POLL_TOPASS_TIMEOUT })
.toBe('')
}) })
test('ensure blocks content is not hidden behind components outside of the editor', async () => { test('ensure blocks content is not hidden behind components outside of the editor', async () => {
@@ -283,23 +289,27 @@ describe('lexical', () => {
const popover = page.locator('.rs__menu').first() const popover = page.locator('.rs__menu').first()
const popoverOption3 = popover.locator('.rs__option').nth(2) const popoverOption3 = popover.locator('.rs__option').nth(2)
const popoverOption3BoundingBox = await popoverOption3.boundingBox() await expect(async () => {
expect(popoverOption3BoundingBox).not.toBeNull() const popoverOption3BoundingBox = await popoverOption3.boundingBox()
expect(popoverOption3BoundingBox).not.toBeUndefined() expect(popoverOption3BoundingBox).not.toBeNull()
expect(popoverOption3BoundingBox.height).toBeGreaterThan(0) expect(popoverOption3BoundingBox).not.toBeUndefined()
expect(popoverOption3BoundingBox.width).toBeGreaterThan(0) expect(popoverOption3BoundingBox.height).toBeGreaterThan(0)
expect(popoverOption3BoundingBox.width).toBeGreaterThan(0)
// Now click the button to see if it actually works. Simulate an actual mouse click instead of using .click() // Now click the button to see if it actually works. Simulate an actual mouse click instead of using .click()
// by using page.mouse and the correct coordinates // by using page.mouse and the correct coordinates
// .isVisible() and .click() might work fine EVEN if the slash menu is not actually visible by humans // .isVisible() and .click() might work fine EVEN if the slash menu is not actually visible by humans
// see: https://github.com/microsoft/playwright/issues/9923 // see: https://github.com/microsoft/playwright/issues/9923
// This is why we use page.mouse.click() here. It's the most effective way of detecting such a z-index issue // This is why we use page.mouse.click() here. It's the most effective way of detecting such a z-index issue
// and usually the only method which works. // and usually the only method which works.
const x = popoverOption3BoundingBox.x const x = popoverOption3BoundingBox.x
const y = popoverOption3BoundingBox.y const y = popoverOption3BoundingBox.y
await page.mouse.click(x, y, { button: 'left' }) await page.mouse.click(x, y, { button: 'left' })
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await expect(reactSelect.locator('.rs__value-container').first()).toHaveText('Option 3') await expect(reactSelect.locator('.rs__value-container').first()).toHaveText('Option 3')
}) })
@@ -332,25 +342,29 @@ describe('lexical', () => {
await expect(spanInSubEditor).toHaveText('Some text below relationship node 1 inserted text') await expect(spanInSubEditor).toHaveText('Some text below relationship node 1 inserted text')
await saveDocAndAssert(page) await saveDocAndAssert(page)
const lexicalDoc: LexicalField = ( await expect(async () => {
await payload.find({ const lexicalDoc: LexicalField = (
collection: lexicalFieldsSlug, await payload.find({
depth: 0, collection: lexicalFieldsSlug,
where: { depth: 0,
title: { where: {
equals: lexicalDocData.title, title: {
equals: lexicalDocData.title,
},
}, },
}, })
}) ).docs[0] as never
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks
const blockNode: SerializedBlockNode = lexicalField.root.children[4] as SerializedBlockNode const blockNode: SerializedBlockNode = lexicalField.root.children[4] as SerializedBlockNode
const textNodeInBlockNodeRichText = blockNode.fields.richText.root.children[1].children[0] const textNodeInBlockNodeRichText = blockNode.fields.richText.root.children[1].children[0]
expect(textNodeInBlockNodeRichText.text).toBe( expect(textNodeInBlockNodeRichText.text).toBe(
'Some text below relationship node 1 inserted text', 'Some text below relationship node 1 inserted text',
) )
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
}) })
test('should be able to bold text using floating select toolbar', async () => { test('should be able to bold text using floating select toolbar', async () => {
// Reproduces https://github.com/payloadcms/payload/issues/4025 // Reproduces https://github.com/payloadcms/payload/issues/4025
@@ -405,32 +419,36 @@ describe('lexical', () => {
await saveDocAndAssert(page) await saveDocAndAssert(page)
const lexicalDoc: LexicalField = ( await expect(async () => {
await payload.find({ const lexicalDoc: LexicalField = (
collection: lexicalFieldsSlug, await payload.find({
depth: 0, collection: lexicalFieldsSlug,
where: { depth: 0,
title: { where: {
equals: lexicalDocData.title, title: {
equals: lexicalDocData.title,
},
}, },
}, })
}) ).docs[0] as never
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks
const blockNode: SerializedBlockNode = lexicalField.root.children[4] as SerializedBlockNode const blockNode: SerializedBlockNode = lexicalField.root.children[4] as SerializedBlockNode
const paragraphNodeInBlockNodeRichText = blockNode.fields.richText.root.children[1] const paragraphNodeInBlockNodeRichText = blockNode.fields.richText.root.children[1]
expect(paragraphNodeInBlockNodeRichText.children).toHaveLength(2) expect(paragraphNodeInBlockNodeRichText.children).toHaveLength(2)
const textNode1: SerializedTextNode = paragraphNodeInBlockNodeRichText.children[0] const textNode1: SerializedTextNode = paragraphNodeInBlockNodeRichText.children[0]
const boldNode: SerializedTextNode = paragraphNodeInBlockNodeRichText.children[1] const boldNode: SerializedTextNode = paragraphNodeInBlockNodeRichText.children[1]
expect(textNode1.text).toBe('Some text below r') expect(textNode1.text).toBe('Some text below r')
expect(textNode1.format).toBe(0) expect(textNode1.format).toBe(0)
expect(boldNode.text).toBe('elationship node 1') expect(boldNode.text).toBe('elationship node 1')
expect(boldNode.format).toBe(1) expect(boldNode.format).toBe(1)
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
}) })
test('ensure slash menu is not hidden behind other blocks', async () => { test('ensure slash menu is not hidden behind other blocks', async () => {
// This test makes sure there are no z-index issues here // This test makes sure there are no z-index issues here
@@ -476,31 +494,35 @@ describe('lexical', () => {
.first() .first()
await expect(popoverHeading2Button).toBeVisible() await expect(popoverHeading2Button).toBeVisible()
// Make sure that, even though it's "visible", it's not actually covered by something else due to z-index issues await expect(async () => {
const popoverHeading2ButtonBoundingBox = await popoverHeading2Button.boundingBox() // Make sure that, even though it's "visible", it's not actually covered by something else due to z-index issues
expect(popoverHeading2ButtonBoundingBox).not.toBeNull() const popoverHeading2ButtonBoundingBox = await popoverHeading2Button.boundingBox()
expect(popoverHeading2ButtonBoundingBox).not.toBeUndefined() expect(popoverHeading2ButtonBoundingBox).not.toBeNull()
expect(popoverHeading2ButtonBoundingBox.height).toBeGreaterThan(0) expect(popoverHeading2ButtonBoundingBox).not.toBeUndefined()
expect(popoverHeading2ButtonBoundingBox.width).toBeGreaterThan(0) expect(popoverHeading2ButtonBoundingBox.height).toBeGreaterThan(0)
expect(popoverHeading2ButtonBoundingBox.width).toBeGreaterThan(0)
// Now click the button to see if it actually works. Simulate an actual mouse click instead of using .click() // Now click the button to see if it actually works. Simulate an actual mouse click instead of using .click()
// by using page.mouse and the correct coordinates // by using page.mouse and the correct coordinates
// .isVisible() and .click() might work fine EVEN if the slash menu is not actually visible by humans // .isVisible() and .click() might work fine EVEN if the slash menu is not actually visible by humans
// see: https://github.com/microsoft/playwright/issues/9923 // see: https://github.com/microsoft/playwright/issues/9923
// This is why we use page.mouse.click() here. It's the most effective way of detecting such a z-index issue // This is why we use page.mouse.click() here. It's the most effective way of detecting such a z-index issue
// and usually the only method which works. // and usually the only method which works.
const x = popoverHeading2ButtonBoundingBox.x const x = popoverHeading2ButtonBoundingBox.x
const y = popoverHeading2ButtonBoundingBox.y const y = popoverHeading2ButtonBoundingBox.y
await page.mouse.click(x, y, { button: 'left' }) await page.mouse.click(x, y, { button: 'left' })
await page.keyboard.type('A Heading') await page.keyboard.type('A Heading')
const newHeadingInSubEditor = lexicalBlock.locator('p ~ h2').getByText('A Heading').first() const newHeadingInSubEditor = lexicalBlock.locator('p ~ h2').getByText('A Heading').first()
await expect(newHeadingInSubEditor).toBeVisible() await expect(newHeadingInSubEditor).toBeVisible()
await expect(newHeadingInSubEditor).toHaveText('A Heading') await expect(newHeadingInSubEditor).toHaveText('A Heading')
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
}) })
test('should allow adding new blocks to a sub-blocks field, part of a parent lexical blocks field', async () => { test('should allow adding new blocks to a sub-blocks field, part of a parent lexical blocks field', async () => {
await navigateToLexicalFields() await navigateToLexicalFields()
@@ -543,32 +565,36 @@ describe('lexical', () => {
await saveDocAndAssert(page) await saveDocAndAssert(page)
/** await expect(async () => {
* Using the local API, check if the data was saved correctly and /**
* can be retrieved correctly * Using the local API, check if the data was saved correctly and
*/ * can be retrieved correctly
*/
const lexicalDoc: LexicalField = ( const lexicalDoc: LexicalField = (
await payload.find({ await payload.find({
collection: lexicalFieldsSlug, collection: lexicalFieldsSlug,
depth: 0, depth: 0,
where: { where: {
title: { title: {
equals: lexicalDocData.title, equals: lexicalDocData.title,
},
}, },
}, })
}) ).docs[0] as never
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks
const blockNode: SerializedBlockNode = lexicalField.root.children[5] as SerializedBlockNode const blockNode: SerializedBlockNode = lexicalField.root.children[5] as SerializedBlockNode
const subBlocks = blockNode.fields.subBlocks const subBlocks = blockNode.fields.subBlocks
expect(subBlocks).toHaveLength(2) expect(subBlocks).toHaveLength(2)
const createdTextAreaBlock = subBlocks[1] const createdTextAreaBlock = subBlocks[1]
expect(createdTextAreaBlock.content).toBe('text123') expect(createdTextAreaBlock.content).toBe('text123')
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
}) })
test('should allow changing values of two different radio button blocks independently', async () => { test('should allow changing values of two different radio button blocks independently', async () => {
@@ -614,24 +640,28 @@ describe('lexical', () => {
await saveDocAndAssert(page) await saveDocAndAssert(page)
const lexicalDoc: LexicalField = ( await expect(async () => {
await payload.find({ const lexicalDoc: LexicalField = (
collection: lexicalFieldsSlug, await payload.find({
depth: 0, collection: lexicalFieldsSlug,
where: { depth: 0,
title: { where: {
equals: lexicalDocData.title, title: {
equals: lexicalDocData.title,
},
}, },
}, })
}) ).docs[0] as never
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks
const radio1: SerializedBlockNode = lexicalField.root.children[8] as SerializedBlockNode const radio1: SerializedBlockNode = lexicalField.root.children[8] as SerializedBlockNode
const radio2: SerializedBlockNode = lexicalField.root.children[9] as SerializedBlockNode const radio2: SerializedBlockNode = lexicalField.root.children[9] as SerializedBlockNode
expect(radio1.fields.radioButtons).toBe('option2') expect(radio1.fields.radioButtons).toBe('option2')
expect(radio2.fields.radioButtons).toBe('option3') expect(radio2.fields.radioButtons).toBe('option3')
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
}) })
test('should not lose focus when writing in nested editor', async () => { test('should not lose focus when writing in nested editor', async () => {

View File

@@ -5,6 +5,7 @@ import { wait } from 'payload/utilities'
import shelljs from 'shelljs' import shelljs from 'shelljs'
import { devUser } from './credentials.js' import { devUser } from './credentials.js'
import { POLL_TOPASS_TIMEOUT } from './playwright.config.js'
type FirstRegisterArgs = { type FirstRegisterArgs = {
page: Page page: Page
@@ -91,7 +92,7 @@ export async function saveDocHotkeyAndAssert(page: Page): Promise<void> {
export async function saveDocAndAssert(page: Page, selector = '#action-save'): Promise<void> { export async function saveDocAndAssert(page: Page, selector = '#action-save'): Promise<void> {
await page.click(selector, { delay: 100 }) await page.click(selector, { delay: 100 })
await expect(page.locator('.Toastify')).toContainText('successfully') await expect(page.locator('.Toastify')).toContainText('successfully')
await expect.poll(() => page.url(), { timeout: 45000 }).not.toContain('create') await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).not.toContain('create')
} }
export async function openNav(page: Page): Promise<void> { export async function openNav(page: Page): Promise<void> {
@@ -137,7 +138,7 @@ export function exactText(text: string) {
export const checkPageTitle = async (page: Page, title: string) => { export const checkPageTitle = async (page: Page, title: string) => {
await expect await expect
.poll(async () => await page.locator('.doc-header__title.render-title')?.first()?.innerText(), { .poll(async () => await page.locator('.doc-header__title.render-title')?.first()?.innerText(), {
timeout: 45000, timeout: POLL_TOPASS_TIMEOUT,
}) })
.toBe(title) .toBe(title)
} }
@@ -147,7 +148,7 @@ export const checkBreadcrumb = async (page: Page, text: string) => {
.poll( .poll(
async () => await page.locator('.step-nav.app-header__step-nav .step-nav__last')?.innerText(), async () => await page.locator('.step-nav.app-header__step-nav .step-nav__last')?.innerText(),
{ {
timeout: 45000, timeout: POLL_TOPASS_TIMEOUT,
}, },
) )
.toBe(text) .toBe(text)

View File

@@ -18,7 +18,7 @@ export class AdminUrlUtil {
return `${this.admin}/collections/${slug}` return `${this.admin}/collections/${slug}`
} }
edit(id: string): string { edit(id: number | string): string {
return `${this.list}/${id}` return `${this.list}/${id}`
} }

View File

@@ -1,5 +1,8 @@
import { defineConfig } from '@playwright/test' import { defineConfig } from '@playwright/test'
export const EXPECT_TIMEOUT = 45000
export const POLL_TOPASS_TIMEOUT = EXPECT_TIMEOUT * 4 // That way expect.poll() or expect().toPass can retry 4 times
export default defineConfig({ export default defineConfig({
// Look for test files in the "test" directory, relative to this configuration file // Look for test files in the "test" directory, relative to this configuration file
testDir: '', testDir: '',
@@ -11,7 +14,7 @@ export default defineConfig({
video: 'retain-on-failure', video: 'retain-on-failure',
}, },
expect: { expect: {
timeout: 60000, timeout: EXPECT_TIMEOUT,
}, },
workers: 16, workers: 16,
}) })