diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 95291b38aed..55fbb73e9e5 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -174,7 +174,7 @@ jobs: SANITY_E2E_PROJECT_ID: ${{ vars.SANITY_E2E_PROJECT_ID }} SANITY_E2E_DATASET: pr-${{ matrix.project }}-${{ github.event.number }} # As e2e:build ran in the `install` job, turbopack restores it from cache here - run: pnpm e2e:build && pnpm test:e2e --project ${{ matrix.project }} --shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + run: pnpm e2e:build && pnpm test:e2e --excludeTag "@drag-drop" --project ${{ matrix.project }} --shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }} - uses: actions/upload-artifact@v4 if: always() diff --git a/package.json b/package.json index 67c57d2c461..1a4ba756acd 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "test": "run-s test:vitest", "test:vitest": "vitest --run", "test:vitest:watch": "vitest --watch", - "test:e2e": "playwright test", + "test:e2e": "node -r esbuild-register ./scripts/test-e2e", "test:exports": "turbo run test --filter=@repo/test-exports", "tsdoc:dev": "sanity-tsdoc dev", "updated": "lerna updated", diff --git a/scripts/test-e2e.mts b/scripts/test-e2e.mts new file mode 100644 index 00000000000..dade7c51d16 --- /dev/null +++ b/scripts/test-e2e.mts @@ -0,0 +1,102 @@ +import path from 'node:path' + +import cac from 'cac' +import execa from 'execa' + +export const E2E_ANNOTATION_TAGS = { + flake: '@flake', + dragDrop: '@drag-drop', + pte: '@pte', + nightly: '@nightly', +} +export type E2E_ANNOTATION_TAG = (typeof E2E_ANNOTATION_TAGS)[keyof typeof E2E_ANNOTATION_TAGS] +const VALID_E2E_TAGS = Object.values(E2E_ANNOTATION_TAGS) + +function validateE2ETags(tags: string): string[] { + const tagList = tags.split(',') + const invalidTags = tagList.filter((tag) => !VALID_E2E_TAGS.includes(tag as E2E_ANNOTATION_TAG)) + + if (invalidTags.length > 0) { + throw new Error( + `Invalid tag(s): ${invalidTags.join(', ')}. Valid tags are: ${VALID_E2E_TAGS.join(', ')}`, + ) + } + + return tagList +} + +/** + * @example + * Run E2E tests with specific tags: + * + * ```bash + * // Run tests with the "@flake" tag + * pnpm test:e2e --tag "@flake" + * + * // Run tests without the "@pte" tag + * pnpm test:e2e --excludeTag "@pte" + * + * // Run tests with the @flake and @drag-drop tags + * pnpm test:e2e --tag @flake,@drag-drop + * + * // Run tests without the @flake, @drag-drop, and @pte tags + * pnpm test:e2e --excludeTag @flake,@drag-drop,@pte + * ``` + */ + +// Only run the CLI when this is the main entry point +// This prevents double execution when imported by Playwright +if ( + process.argv[1] === path.resolve(process.cwd(), 'scripts/test-e2e') || + process.argv[1].endsWith('/scripts/test-e2e') +) { + const cli = cac('test-e2e') + + cli + .option('--tag ', 'Run tests with specific tags (comma-separated)') + .option('--excludeTag ', 'Run tests without specific tags (comma-separated)') + .help() + + // Allow passing any other arguments directly to Playwright + cli + .command('[...args]', 'Additional arguments passed to Playwright') + .allowUnknownOptions() // This is the key fix - allow unknown options like --project + .action(async (args, options) => { + const playwrightArgs: string[] = [...args] + + // Add all unknown options to the playwright args + for (const [key, value] of Object.entries(options)) { + if (key !== 'tag' && key !== 'excludeTag' && key !== '--') { + // Handle boolean flags vs flags with values + if (value === true) { + playwrightArgs.push(`--${key}`) + } else if (value !== false) { + playwrightArgs.push(`--${key}`, String(value)) + } + } + } + + // Process include tags + if (options.tag) { + const tags = validateE2ETags(options.tag) + playwrightArgs.push('--grep', tags.join('|')) + } + + // Process exclude tags + if (options.excludeTag) { + const tags = validateE2ETags(options.excludeTag) + playwrightArgs.push('--grep-invert', tags.join('|')) + } + + console.log(`[running] playwright test ${playwrightArgs.join(' ')}`) + + try { + await execa('npx', ['playwright', 'test', ...playwrightArgs], {stdio: 'inherit'}) + } catch (error) { + console.error(error) + process.exit(1) + } + }) + + cli.parse() +} diff --git a/test/e2e/.eslintrc b/test/e2e/.eslintrc new file mode 100644 index 00000000000..ff4838d53bf --- /dev/null +++ b/test/e2e/.eslintrc @@ -0,0 +1,6 @@ +{ + "rules": { + "react-hooks/rules-of-hooks": "off", + "max-nested-callbacks": "off" + } +} \ No newline at end of file diff --git a/test/e2e/tests/comments/inline.spec.ts b/test/e2e/tests/comments/inline.spec.ts index 8c9cda531a5..1010d0d3882 100644 --- a/test/e2e/tests/comments/inline.spec.ts +++ b/test/e2e/tests/comments/inline.spec.ts @@ -1,6 +1,8 @@ import {expect, type Page} from '@playwright/test' import {test} from '@sanity/test' +import {E2E_ANNOTATION_TAGS} from '../../../../scripts/test-e2e.mjs' + const WAIT_OPTIONS = { timeout: 20 * 1000, // 20 seconds } @@ -169,20 +171,24 @@ async function inlineCommentCreationTest(props: InlineCommentCreationTestProps) ) } -test.describe('Inline comments:', () => { - test('should create inline comment', async ({page, createDraftDocument}) => { - await inlineCommentCreationTest({page, createDraftDocument}) - }) - - test('should resolve inline comment', async ({page, createDraftDocument}) => { - // 1. Create a new inline comment - await inlineCommentCreationTest({page, createDraftDocument}) - - // 2. Resolve the comment by clicking the status button in the comments list item. - await page.getByTestId('comments-list-item-status-button').waitFor(WAIT_OPTIONS) - await page.locator('[data-testid="comments-list-item-status-button"]').click() - - // 3. Verify that the text is no longer highlighted in the editor. - await expect(page.locator('[data-inline-comment-state="added"]')).not.toBeVisible() - }) -}) +test.describe( + 'Inline comments:', + {tag: [E2E_ANNOTATION_TAGS.pte, E2E_ANNOTATION_TAGS.nightly]}, + () => { + test('should create inline comment', async ({page, createDraftDocument}) => { + await inlineCommentCreationTest({page, createDraftDocument}) + }) + + test('should resolve inline comment', async ({page, createDraftDocument}) => { + // 1. Create a new inline comment + await inlineCommentCreationTest({page, createDraftDocument}) + + // 2. Resolve the comment by clicking the status button in the comments list item. + await page.getByTestId('comments-list-item-status-button').waitFor(WAIT_OPTIONS) + await page.locator('[data-testid="comments-list-item-status-button"]').click() + + // 3. Verify that the text is no longer highlighted in the editor. + await expect(page.locator('[data-inline-comment-state="added"]')).not.toBeVisible() + }) + }, +) diff --git a/test/e2e/tests/inputs/array.spec.ts b/test/e2e/tests/inputs/array.spec.ts index 77981150e98..78c29726f05 100644 --- a/test/e2e/tests/inputs/array.spec.ts +++ b/test/e2e/tests/inputs/array.spec.ts @@ -4,59 +4,61 @@ import path from 'node:path' import {expect, type Page} from '@playwright/test' import {test} from '@sanity/test' +import {E2E_ANNOTATION_TAGS} from '../../../../scripts/test-e2e.mjs' import {createFileDataTransferHandle} from '../../helpers' const fileName = 'capybara.jpg' const image = readFileSync(path.join(__dirname, '..', '..', 'resources', fileName)) -test(`file drop event should not propagate to dialog parent`, async ({ - page, - createDraftDocument, -}) => { - await createDraftDocument('/test/content/input-standard;arraysTest') - - await expect(page.getByTestId('document-panel-scroller')).toBeAttached({ - timeout: 40000, - }) - const list = page.getByTestId('field-arrayOfMultipleTypes').locator('#arrayOfMultipleTypes') - const item = list.locator('[data-ui="Grid"] > div') - - const dataTransfer = await createFileDataTransferHandle( - {page}, - { - buffer: image, - fileName, - fileOptions: { - type: 'image/jpeg', +test( + `file drop event should not propagate to dialog parent`, + {tag: [E2E_ANNOTATION_TAGS.dragDrop, E2E_ANNOTATION_TAGS.nightly]}, + async ({page, createDraftDocument}) => { + await createDraftDocument('/test/content/input-standard;arraysTest') + + await expect(page.getByTestId('document-panel-scroller')).toBeAttached({ + timeout: 40000, + }) + const list = page.getByTestId('field-arrayOfMultipleTypes').locator('#arrayOfMultipleTypes') + const item = list.locator('[data-ui="Grid"] > div') + + const dataTransfer = await createFileDataTransferHandle( + {page}, + { + buffer: image, + fileName, + fileOptions: { + type: 'image/jpeg', + }, }, - }, - ) + ) - await expect(list).toBeVisible() + await expect(list).toBeVisible() - // Drop the file. - await list.dispatchEvent('drop', {dataTransfer}) + // Drop the file. + await list.dispatchEvent('drop', {dataTransfer}) - // Ensure the list contains one item. - expect(item).toHaveCount(1) + // Ensure the list contains one item. + expect(item).toHaveCount(1) - // Open the dialog. - await page.getByRole('button', {name: fileName}).click() - await expect(page.getByRole('dialog')).toBeVisible() + // Open the dialog. + await page.getByRole('button', {name: fileName}).click() + await expect(page.getByRole('dialog')).toBeVisible() - // Drop the file again; this time, while the dialog is open. - // - // - The drop event should not propagate to the parent. - // - Therefore, the drop event should not cause the image to be added to the list again. - await page.getByRole('dialog').dispatchEvent('drop', {dataTransfer}) + // Drop the file again; this time, while the dialog is open. + // + // - The drop event should not propagate to the parent. + // - Therefore, the drop event should not cause the image to be added to the list again. + await page.getByRole('dialog').dispatchEvent('drop', {dataTransfer}) - // Close the dialog. - await page.keyboard.press('Escape') - await expect(page.getByRole('dialog')).not.toBeVisible() + // Close the dialog. + await page.keyboard.press('Escape') + await expect(page.getByRole('dialog')).not.toBeVisible() - // Ensure the list still contains one item. - expect(item).toHaveCount(1) -}) + // Ensure the list still contains one item. + expect(item).toHaveCount(1) + }, +) test(`Scenario: Adding a new type from multiple options`, async ({page, createDraftDocument}) => { await createDraftDocument('/test/content/input-standard;arraysTest') diff --git a/test/e2e/tests/pte/ImageArrayDrag.spec.ts b/test/e2e/tests/pte/ImageArrayDrag.spec.ts index f27b81907ac..23b681112c0 100644 --- a/test/e2e/tests/pte/ImageArrayDrag.spec.ts +++ b/test/e2e/tests/pte/ImageArrayDrag.spec.ts @@ -5,68 +5,76 @@ import {expect} from '@playwright/test' import {type SanityImageAssetDocument} from '@sanity/client' import {test} from '@sanity/test' -test.describe('Portable Text Input - ImageArrayDraft', () => { - let uploadedAsset: SanityImageAssetDocument - test.beforeAll(async ({sanityClient}) => { - const asset = await sanityClient.assets.upload( - 'image', - createReadStream(path.join(__dirname, '..', '..', 'resources', 'capybara.jpg')), - { - filename: 'image-array-drag.jpg', - title: 'image-array-drag', - }, - ) - uploadedAsset = asset - }) +import {E2E_ANNOTATION_TAGS} from '../../../../scripts/test-e2e.mjs' - test.afterAll(async ({sanityClient}) => { - await sanityClient.delete(uploadedAsset._id) - }) +test.describe( + 'Portable Text Input - ImageArrayDraft', + { + tag: [E2E_ANNOTATION_TAGS.dragDrop, E2E_ANNOTATION_TAGS.pte, E2E_ANNOTATION_TAGS.nightly], + }, + () => { + let uploadedAsset: SanityImageAssetDocument + test.beforeAll(async ({sanityClient}) => { + const asset = await sanityClient.assets.upload( + 'image', + createReadStream(path.join(__dirname, '..', '..', 'resources', 'capybara.jpg')), + { + filename: 'image-array-drag.jpg', + title: 'image-array-drag', + }, + ) + uploadedAsset = asset + }) - test('Portable Text Input - Array Input of images dragging an image will not trigger range out of bounds (toast)', async ({ - page, - createDraftDocument, - }) => { - await createDraftDocument( - '/test/content/input-standard;portable-text;pt_allTheBellsAndWhistles', - ) + test.afterAll(async ({sanityClient}) => { + await sanityClient.delete(uploadedAsset._id) + }) - // set up the portable text editor - await page.getByTestId('field-body').focus() - await page.getByTestId('field-body').click() + test('Portable Text Input - Array Input of images dragging an image will not trigger range out of bounds (toast)', async ({ + page, + createDraftDocument, + }) => { + await createDraftDocument( + '/test/content/input-standard;portable-text;pt_allTheBellsAndWhistles', + ) - page.on('dialog', async () => { - await expect(page.getByTestId('insert-menu-auto-collapse-menu')).toBeVisible() - }) + // set up the portable text editor + await page.getByTestId('field-body').focus() + await page.getByTestId('field-body').click() - // open the insert menu - await page - .getByTestId('insert-menu-auto-collapse-menu') - .getByRole('button', {name: 'Insert Image slideshow (block)'}) - .click() + page.on('dialog', async () => { + await expect(page.getByTestId('insert-menu-auto-collapse-menu')).toBeVisible() + }) - // set up for the PTE block - await page.getByRole('button', {name: 'Add item'}).click() - await page.getByTestId('file-input-multi-browse-button').click() - await page.getByTestId('file-input-browse-button-sanity-default').click() + // open the insert menu + await page + .getByTestId('insert-menu-auto-collapse-menu') + .getByRole('button', {name: 'Insert Image slideshow (block)'}) + .click() - // grab an image - await page.getByRole('button', {name: uploadedAsset.originalFilename}).click() - await page.getByLabel('Edit Image With Caption').getByLabel('Close dialog').click() + // set up for the PTE block + await page.getByRole('button', {name: 'Add item'}).click() + await page.getByTestId('file-input-multi-browse-button').click() + await page.getByTestId('file-input-browse-button-sanity-default').click() - // grab drag element in array element - await page.locator("[data-sanity-icon='drag-handle']").hover() + // grab an image + await page.getByRole('button', {name: uploadedAsset.originalFilename}).click() + await page.getByLabel('Edit Image With Caption').getByLabel('Close dialog').click() - // drag and drop element - await page.mouse.down() - await page.getByRole('button', {name: 'Add item'}).hover() - await page.mouse.up() + // grab drag element in array element + await page.locator("[data-sanity-icon='drag-handle']").hover() - await page.locator( - `:has-text("Failed to execute 'getRangeAt' on 'Selection': 0 is not a valid index.']`, - ) + // drag and drop element + await page.mouse.down() + await page.getByRole('button', {name: 'Add item'}).hover() + await page.mouse.up() - // check that the alert is not visible - await expect(await page.getByRole('alert').locator('div').nth(1)).not.toBeVisible() - }) -}) + await page.locator( + `:has-text("Failed to execute 'getRangeAt' on 'Selection': 0 is not a valid index.']`, + ) + + // check that the alert is not visible + await expect(await page.getByRole('alert').locator('div').nth(1)).not.toBeVisible() + }) + }, +)